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,183 @@
|
||||
# Beancount Mobile 前端 UI 全面重设计 Spec
|
||||
|
||||
- 日期:2026-07-21
|
||||
- 状态:已确认(经逐节评审)
|
||||
- 范围:`src/app`、`src/components`、`src/theme`、`design-system/`;**不动** `src/domain`、`src/storage`、`src/services`、`plugins/`
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
对现有 UI 的摸底发现以下问题(按严重度):
|
||||
|
||||
1. **品牌色混乱**:代码主题 accent = 靛蓝 `#4F46E5`,`design-system/beancount-mobile/MASTER.md` 规定 CTA = 绿 `#059669`、背景深蓝 `#0F172A`(代码暗色实为 OLED 纯黑 `#040508`)。设计文档与实现脱节。
|
||||
2. **图标体系三套并存**:Ionicons(主力)+ emoji 分类图标(CategoryPicker)+ MASTER.md 要求的 Lucide/Heroicons(未落地)。
|
||||
3. **硬编码颜色绕过 token**:首页 Hero 卡片写死白字;`CATEGORY_COLORS`、标签默认色 `#2196F3`、渠道色 `#1677FF/#07C160/#E53935`。
|
||||
4. **样式重复爆炸**:40+ 处 `StyleSheet.create`;`header` 样式在 ~15 个页面重复;8 个管理页(账户/分类/标签/预算/周期/规则/信用卡/备注模板)是同一"列表+FormModal"模式却各自实现。
|
||||
5. **录入流程偏长**:方向 chip → 金额 → 账户 → 分类网格 → 折叠详情,不是金额优先;无 KeyboardAvoidingView;日期靠手输 `YYYY-MM-DD`。
|
||||
6. **信息架构问题**:设置页 13 入口平铺;报表页周/月/年三套独立状态;年报内嵌月报与月 Tab 重复;AI/导出图标无文字标签。
|
||||
7. **Header 策略不统一**:二级页手写返回栏,唯独 transaction/new 用原生 header。
|
||||
|
||||
## 2. 已确认的关键决策
|
||||
|
||||
| 决策点 | 结论 |
|
||||
|---|---|
|
||||
| 重设计深度 | **全面重做**(所有页面) |
|
||||
| 视觉方向 | **明亮 Bento 现代风**:浅色为主 + 大圆角黑白对比,财务语义色点缀 |
|
||||
| 暗色模式 | **完整保留**(OLED 纯黑,与浅色对等) |
|
||||
| 记一笔交互 | **金额优先数字键盘面板**(NumpadSheet) |
|
||||
| 底部导航 | **中央凸起 +**,4 个内容 Tab(首页/交易/报表/我的) |
|
||||
| 实施策略 | **设计系统先行,逐页替换**(5 个阶段) |
|
||||
|
||||
## 3. 视觉语言(Design Tokens)
|
||||
|
||||
### 3.1 色板
|
||||
|
||||
**浅色(默认)**:
|
||||
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||
| bgTertiary | `#EFF1F4` | 输入框/chip |
|
||||
| fgPrimary | `#111318` | 主文字 |
|
||||
| fgSecondary | `#6B7280` | 次要文字 |
|
||||
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||
| accent | `#111318` | **近黑**,按钮/选中态 |
|
||||
| accentLight / accentDark | 重定义为中性色阶:accentLight = accent 8% 透明度的底色(选中高亮),accentDark = accent 的按压加深态 | 高亮背景/按压态 |
|
||||
| financial.income | `#10B981` | 收入 |
|
||||
| financial.expense | `#EF4444` | 支出 |
|
||||
| financial.transfer | `#3B82F6` | 转账 |
|
||||
|
||||
**暗色(OLED,完整对等)**:
|
||||
|
||||
| Token | 值 |
|
||||
|---|---|
|
||||
| bgPrimary | `#040508` |
|
||||
| bgSecondary | `#101218` |
|
||||
| bgTertiary | `#1A1D24` |
|
||||
| fgPrimary / accent | `#F3F4F6`(accent 反转为白) |
|
||||
| fgSecondary | `#9CA3AF` |
|
||||
| financial.income | `#34D399`(提亮) |
|
||||
| financial.expense | `#F87171`(提亮) |
|
||||
| financial.transfer | `#60A5FA`(提亮) |
|
||||
|
||||
原则:**accent 从靛蓝改为近黑白单色**,品牌感靠排版与财务语义色表达;对比度 ≥ 4.5:1;暗色下用 1px 半透边框代替阴影。
|
||||
|
||||
### 3.2 字体 / 圆角 / 阴影 / 图标
|
||||
|
||||
- **移除 Caveat/Quicksand 自定义字体**,改系统字体(iOS SF / Android Roboto),消除字体加载失败风险;删除 `_layout.tsx` 字体加载代码。
|
||||
- 金额数字统一 `fontVariant: ['tabular-nums']`。
|
||||
- 字阶:display 34 / h1 28 / h2 22 / h3 17 / body 16 / bodySmall 14 / caption 12。
|
||||
- 圆角:sm 8 / md 12 / lg 16 / **xl 24(卡片默认)** / full。
|
||||
- 阴影减重:浅色 2–12px 弥散阴影;暗色以边框代替。
|
||||
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`(Ionicon + 圆形彩色底)。
|
||||
- 硬编码分类色收归 `theme.categoryPalette`(12 色循环);渠道色、标签默认色等一并 token 化。
|
||||
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
|
||||
|
||||
## 4. 核心交互:NumpadSheet 记一笔面板
|
||||
|
||||
### 4.1 结构(一屏完成 90% 记账)
|
||||
|
||||
自上而下:方向 chip(支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格(4 列,常用分类 + "全部") → 内嵌数字键盘(0-9、小数点、⌫、**+/- 连续计算**(如 20+15 直接出 35)、"今天"日期键、完成键)。下滑展开"更多"抽屉:备注、标签、日期、高级模式(PostingEditor)入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
|
||||
|
||||
### 4.2 两个账户(复式记账的"两条腿")
|
||||
|
||||
| 方向 | 腿 1 | 腿 2 |
|
||||
|---|---|---|
|
||||
| 支出 | 资金来源账户(Assets/Liabilities,账户 chip 行,"从") | 分类账户(Expenses:*,分类网格) |
|
||||
| 收入 | 分类账户(Income:*,分类网格) | 到账账户(Assets,账户 chip 行,"到") |
|
||||
| 转账 | 转出账户(chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
|
||||
|
||||
配套规则:
|
||||
|
||||
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**(settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
|
||||
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
|
||||
- 落账走 `buildAndSaveTransaction()` → BillPipeline,与手动/自动渠道完全一致,不产生第二套写入逻辑。
|
||||
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
|
||||
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
|
||||
- 编辑模式按 posting 方向回填两条腿。
|
||||
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
|
||||
|
||||
## 5. 导航
|
||||
|
||||
自定义 **AppTabBar**(替换 expo-router 默认 tabBar):4 个内容 Tab(首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 Modal(NumpadSheet),不丢失上下文。**SpeedDial 退役**。
|
||||
|
||||
## 6. 核心组件库(P2 交付物)
|
||||
|
||||
新增:
|
||||
|
||||
- **NumpadSheet** — 见第 4 节
|
||||
- **AppTabBar** — 见第 5 节
|
||||
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位),替换十几份手写返回栏;transaction/new 的原生 header 一并撤掉
|
||||
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
|
||||
- **ManagementScreen** — 管理页模板(ScreenHeader++ / 可选分组 Tab / FlatList / 删除确认 / FormModal),8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
|
||||
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
|
||||
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
|
||||
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
|
||||
|
||||
改造/退役:Button/Card/Chip/SearchBar 按新 token 重刷;CategoryPicker 改底部弹层网格并 Ionicon 化;FormModal 的 "✕" 字符换 Ionicons;transaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
|
||||
|
||||
## 7. 逐页重设计要点
|
||||
|
||||
### 7.1 首页:从"数据墙"到"今日视角"
|
||||
|
||||
- 顶部:日期 + 问候语(替代应用名标题)。
|
||||
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
|
||||
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
|
||||
- 最近 5 条交易 + "查看全部 →"。
|
||||
- 月度趋势卡(TrendLine 重刷新色板)。
|
||||
- 账户余额树下沉到「我的」页。
|
||||
|
||||
### 7.2 交易页:搜索优先 + 分组时间线
|
||||
|
||||
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
|
||||
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
|
||||
- TransactionCard 重刷:CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
|
||||
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
|
||||
- 解析诊断从页面底部移入设置 → 数据组。
|
||||
|
||||
### 7.3 报表页:统一时间导航 + 去重
|
||||
|
||||
- 周/月/年 Tab 保留;三套独立状态(viewYear/viewMonth/viewWeekDate)合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
|
||||
- 删除年报内嵌的 MonthlyReport(与月 Tab 重复);年报只留:年度收支汇总、月度节奏迷你图、Top 分类。
|
||||
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
|
||||
- CalendarView 保留在月 Tab,配色收归 token;热力图用 expense 色 5 级透明度。
|
||||
- CategoryPie / StatCard / NetWorthChart 统一新 token。
|
||||
|
||||
### 7.4 设置 →「我的」:4 分组重构
|
||||
|
||||
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
|
||||
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
|
||||
- **数据**:同步(WebDAV/Git/iCloud)、备份恢复、导出、解析诊断。
|
||||
- **偏好**:主题、语言、应用锁、AI 设置、每日提醒、关于。
|
||||
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
|
||||
|
||||
### 7.5 管理页模板化
|
||||
|
||||
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen,预计删除上千行重复代码。
|
||||
|
||||
## 8. 阶段计划
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过;token 名不变只改值,旧组件接口兼容 |
|
||||
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
|
||||
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板;pipeline 集成测试不回归 |
|
||||
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表(anchor 统一)/ 我的(4 分组) | 逐页替换,每页替换后全量测试 |
|
||||
| P5 管理页+收尾 | 8 页套模板;图表重刷;emoji 清零;硬编码色清零(grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
- **不动 domain 层**:billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
|
||||
- 新增纯逻辑单测:numpad 表达式求值(+/- 连续计算)、双腿账户解析(方向 → posting 映射)、anchor 日期导航(周/月/年加减)。
|
||||
- 每阶段结束跑 `npm test` + `npm run typecheck`;UI 层靠深浅双主题手工走查清单。
|
||||
- 硬编码色审计:grep 全量扫描。
|
||||
|
||||
## 10. 风险与 YAGNI
|
||||
|
||||
风险对策:
|
||||
|
||||
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
|
||||
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
|
||||
- 移除字体无数据迁移,删加载代码即可。
|
||||
|
||||
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide;改 domain/存储/同步层;平板/桌面布局适配。
|
||||
@@ -0,0 +1,678 @@
|
||||
# UI 重设计 P1:设计系统 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** 将主题系统切换到「明亮 Bento 现代风」新设计令牌(双主题),移除自定义字体,硬编码颜色收归 palette,并重写 MASTER.md 使设计文档与实现一致。
|
||||
|
||||
**Architecture:** 只改 `src/theme/`、`src/app/_layout.tsx`(字体加载)、`src/domain/channelConfig.ts`(品牌色引用)、`src/components/CategoryPicker.tsx`(色板引用)、`design-system/`。Token 名不变只改值,组件接口向后兼容,现有页面自动"粗换皮"。
|
||||
|
||||
**Tech Stack:** React Native + Expo + TypeScript + Vitest。
|
||||
|
||||
**Spec:** `docs/ui-redesign-design.md` §3(视觉语言)、§10(P1 验收)。
|
||||
|
||||
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*`;`presets.ts` 的 typography 无 `fontFamily`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新设计令牌的失败测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/theme.test.ts`
|
||||
- Create: `tests/palette.test.ts`
|
||||
|
||||
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
|
||||
|
||||
完整替换文件内容为:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
|
||||
import { createTheme } from '../src/theme/createTheme';
|
||||
import type { ThemeTokens } from '../src/theme/tokens';
|
||||
|
||||
describe('预置主题(明亮 Bento 设计系统)', () => {
|
||||
it('lightTheme 与 darkTheme 关键色彩不同', () => {
|
||||
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
|
||||
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
|
||||
});
|
||||
|
||||
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
|
||||
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
|
||||
expect(darkTheme.colors.bgPrimary).toBe('#040508');
|
||||
});
|
||||
|
||||
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
|
||||
expect(lightTheme.colors.accent).toBe('#111318');
|
||||
expect(darkTheme.colors.accent).toBe('#F3F4F6');
|
||||
});
|
||||
|
||||
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
|
||||
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
|
||||
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
|
||||
});
|
||||
|
||||
it('财务语义色:暗色整体提亮一档', () => {
|
||||
expect(lightTheme.colors.financial.income).toBe('#10B981');
|
||||
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
|
||||
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
|
||||
expect(darkTheme.colors.financial.income).toBe('#34D399');
|
||||
expect(darkTheme.colors.financial.expense).toBe('#F87171');
|
||||
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
|
||||
});
|
||||
|
||||
it('圆角含 xl(24),卡片默认大圆角', () => {
|
||||
for (const theme of [lightTheme, darkTheme]) {
|
||||
expect(theme.radii.sm).toBe(8);
|
||||
expect(theme.radii.md).toBe(12);
|
||||
expect(theme.radii.lg).toBe(16);
|
||||
expect(theme.radii.xl).toBe(24);
|
||||
expect(theme.radii.full).toBe(9999);
|
||||
}
|
||||
});
|
||||
|
||||
it('字阶含 display(34),且不指定 fontFamily(系统字体)', () => {
|
||||
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||
expect(theme.typography.display.fontSize).toBe(34);
|
||||
expect(theme.typography.h1.fontSize).toBe(28);
|
||||
expect(theme.typography.h2.fontSize).toBe(22);
|
||||
expect(theme.typography.h3.fontSize).toBe(17);
|
||||
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
|
||||
expect(theme.typography[key].fontFamily).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('presetThemes 注册表含 light 与 dark', () => {
|
||||
expect(presetThemes.light).toBe(lightTheme);
|
||||
expect(presetThemes.dark).toBe(darkTheme);
|
||||
});
|
||||
|
||||
it('spacing/shadows 结构完整', () => {
|
||||
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||
expect(theme.spacing).toHaveProperty('xs');
|
||||
expect(theme.spacing).toHaveProperty('xl');
|
||||
expect(theme.shadows).toHaveProperty('sm');
|
||||
expect(theme.shadows).toHaveProperty('lg');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTheme 自定义主题工厂', () => {
|
||||
it('未覆盖时等价于 lightTheme', () => {
|
||||
const custom = createTheme({});
|
||||
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||
expect(custom.spacing).toEqual(lightTheme.spacing);
|
||||
});
|
||||
|
||||
it('覆盖 accent 颜色时其他颜色保留', () => {
|
||||
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
|
||||
expect(custom.colors.accent).toBe('#FF5722');
|
||||
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||
expect(custom.colors.success).toBe(lightTheme.colors.success);
|
||||
});
|
||||
|
||||
it('覆盖 financial 子对象时深度合并', () => {
|
||||
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
|
||||
expect(custom.colors.financial.income).toBe('#000');
|
||||
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
|
||||
});
|
||||
|
||||
it('覆盖 spacing 部分键时其他键保留', () => {
|
||||
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
|
||||
expect(custom.spacing.lg).toBe(32);
|
||||
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新建 tests/palette.test.ts(分类色板 + 渠道品牌色)**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
|
||||
|
||||
describe('categoryPalette', () => {
|
||||
it('色板为 12 色循环', () => {
|
||||
expect(CATEGORY_PALETTE).toHaveLength(12);
|
||||
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
});
|
||||
|
||||
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
|
||||
expect(getCategoryColor('food')).toBe('#F59E0B');
|
||||
expect(getCategoryColor('transport')).toBe('#3B82F6');
|
||||
expect(getCategoryColor('salary')).toBe('#22C55E');
|
||||
});
|
||||
|
||||
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
|
||||
const a = getCategoryColor('user_custom_abc');
|
||||
expect(CATEGORY_PALETTE).toContain(a);
|
||||
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
|
||||
});
|
||||
|
||||
it('空字符串 id 不崩溃', () => {
|
||||
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
|
||||
});
|
||||
|
||||
it('渠道品牌色常量', () => {
|
||||
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
|
||||
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
|
||||
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
|
||||
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/theme.test.ts tests/palette.test.ts
|
||||
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: tokens.ts — display 字阶 + radii.xl
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/tokens.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
|
||||
|
||||
`ThemeRadii` 改为(加 `xl`):
|
||||
|
||||
```typescript
|
||||
export interface ThemeRadii {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number; // 卡片默认大圆角(24)
|
||||
full: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ThemeTypographyEntry` 的 `fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
|
||||
|
||||
```typescript
|
||||
export interface ThemeTypographyEntry {
|
||||
fontSize: number;
|
||||
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
lineHeight: number;
|
||||
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
|
||||
fontFamily?: string;
|
||||
}
|
||||
```
|
||||
|
||||
`ThemeTypography` 加 `display`:
|
||||
|
||||
```typescript
|
||||
export interface ThemeTypography {
|
||||
display: ThemeTypographyEntry; // 34/800,净资产等大数字
|
||||
h1: ThemeTypographyEntry;
|
||||
h2: ThemeTypographyEntry;
|
||||
h3: ThemeTypographyEntry;
|
||||
body: ThemeTypographyEntry;
|
||||
bodySmall: ThemeTypographyEntry;
|
||||
caption: ThemeTypographyEntry;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 typecheck 确认报错点**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: FAIL —— `presets.ts` 缺少 `display` 与 `radii.xl`(Task 3 修复)。
|
||||
|
||||
- [ ] **Step 3: 暂不 commit(与 Task 3 一起)**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: presets.ts — 新双主题色板
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/presets.ts`
|
||||
|
||||
- [ ] **Step 1: 完整替换 presets.ts**
|
||||
|
||||
```typescript
|
||||
import type { ThemeTokens } from './tokens';
|
||||
|
||||
/**
|
||||
* 预置主题 —— 「明亮 Bento 现代风」(docs/ui-redesign-design.md §3)。
|
||||
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
|
||||
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
|
||||
*/
|
||||
|
||||
export const lightTheme: ThemeTokens = {
|
||||
colors: {
|
||||
bgPrimary: '#F6F7F9',
|
||||
bgSecondary: '#FFFFFF',
|
||||
bgTertiary: '#EFF1F4',
|
||||
fgPrimary: '#111318',
|
||||
fgSecondary: '#6B7280',
|
||||
fgInverse: '#FFFFFF',
|
||||
accent: '#111318', // 近黑:按钮/选中态
|
||||
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
|
||||
accentDark: '#000000', // 按压加深态
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
info: '#3B82F6',
|
||||
financial: {
|
||||
income: '#10B981',
|
||||
expense: '#EF4444',
|
||||
transfer: '#3B82F6',
|
||||
},
|
||||
border: '#E5E7EB',
|
||||
divider: '#F0F1F3',
|
||||
overlay: 'rgba(17,19,24,0.4)',
|
||||
skeleton: '#E5E7EB',
|
||||
progressBg: 'rgba(17,19,24,0.06)',
|
||||
},
|
||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
|
||||
typography: {
|
||||
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
|
||||
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
|
||||
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
|
||||
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
|
||||
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
||||
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
|
||||
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
|
||||
},
|
||||
shadows: {
|
||||
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
|
||||
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
|
||||
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
|
||||
},
|
||||
};
|
||||
|
||||
export const darkTheme: ThemeTokens = {
|
||||
...lightTheme,
|
||||
colors: {
|
||||
bgPrimary: '#040508', // OLED 纯黑
|
||||
bgSecondary: '#101218',
|
||||
bgTertiary: '#1A1D24',
|
||||
fgPrimary: '#F3F4F6',
|
||||
fgSecondary: '#9CA3AF',
|
||||
fgInverse: '#111318',
|
||||
accent: '#F3F4F6', // 反转为白
|
||||
accentLight: 'rgba(243,244,246,0.10)',
|
||||
accentDark: '#FFFFFF',
|
||||
success: '#34D399',
|
||||
warning: '#FBBF24',
|
||||
error: '#F87171',
|
||||
info: '#60A5FA',
|
||||
financial: {
|
||||
income: '#34D399', // 提亮一档保证对比度
|
||||
expense: '#F87171',
|
||||
transfer: '#60A5FA',
|
||||
},
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
divider: 'rgba(255,255,255,0.04)',
|
||||
overlay: 'rgba(0,0,0,0.7)',
|
||||
skeleton: '#1A1D24',
|
||||
progressBg: 'rgba(243,244,246,0.08)',
|
||||
},
|
||||
shadows: {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
/** 预置主题注册表。 */
|
||||
export const presetThemes: Record<string, ThemeTokens> = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 Task 1 的测试**
|
||||
|
||||
Run: `npx vitest run tests/theme.test.ts`
|
||||
Expected: PASS(palette.test.ts 仍 FAIL,Task 4 修复)。
|
||||
|
||||
- [ ] **Step 3: 运行 typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/tokens.ts src/theme/presets.ts
|
||||
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
|
||||
|
||||
**Files:**
|
||||
- Create: `src/theme/palette.ts`
|
||||
- Modify: `src/domain/channelConfig.ts:32,40,48`
|
||||
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
|
||||
|
||||
- [ ] **Step 1: 创建 src/theme/palette.ts(纯 TS,无 RN/domain 依赖,可被双方向引用)**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 分类/渠道颜色统一色板(docs/ui-redesign-design.md §3.2)。
|
||||
*
|
||||
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
|
||||
* - components/(CategoryPicker 等)用它给分类配色;
|
||||
* - domain/channelConfig.ts 用它引用渠道品牌色;
|
||||
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件(P5 grep 审计)。
|
||||
*/
|
||||
|
||||
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
|
||||
export const CATEGORY_PALETTE = [
|
||||
'#F59E0B', // Amber
|
||||
'#3B82F6', // Blue
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Purple
|
||||
'#10B981', // Emerald
|
||||
'#64748B', // Slate
|
||||
'#F43F5E', // Rose
|
||||
'#14B8A6', // Teal
|
||||
'#EF4444', // Red
|
||||
'#84CC16', // Lime
|
||||
] as const;
|
||||
|
||||
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
|
||||
const NAMED_CATEGORY_COLORS: Record<string, string> = {
|
||||
food: '#F59E0B',
|
||||
transport: '#3B82F6',
|
||||
shopping: '#EC4899',
|
||||
housing_utility: '#06B6D4',
|
||||
housing_rent: '#6366F1',
|
||||
housing_communication: '#8B5CF6',
|
||||
entertainment: '#10B981',
|
||||
services: '#64748B',
|
||||
personal_care: '#F43F5E',
|
||||
clothing: '#14B8A6',
|
||||
health: '#EF4444',
|
||||
learning: '#84CC16',
|
||||
salary: '#22C55E',
|
||||
income_activity: '#EF4444',
|
||||
income_investment: '#F59E0B',
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地 FNV-1a 哈希。
|
||||
* 注:domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
|
||||
*/
|
||||
function fnv1a(input: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
|
||||
export function getCategoryColor(categoryId: string): string {
|
||||
const named = NAMED_CATEGORY_COLORS[categoryId];
|
||||
if (named) return named;
|
||||
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
|
||||
}
|
||||
|
||||
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
|
||||
export const CHANNEL_BRAND_COLORS = {
|
||||
alipay: '#1677FF',
|
||||
wechat: '#07C160',
|
||||
bank: '#E53935',
|
||||
} as const;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
|
||||
|
||||
文件顶部加 import:
|
||||
|
||||
```typescript
|
||||
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
|
||||
```
|
||||
|
||||
三处字面量替换:`color: '#1677FF'` → `color: CHANNEL_BRAND_COLORS.alipay`,`color: '#07C160'` → `color: CHANNEL_BRAND_COLORS.wechat`,`color: '#E53935'` → `color: CHANNEL_BRAND_COLORS.bank`。
|
||||
|
||||
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
|
||||
|
||||
删除第 31–48 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
|
||||
|
||||
```typescript
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
```
|
||||
|
||||
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
|
||||
|
||||
```typescript
|
||||
const color = getCategoryColor(cat.id);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
|
||||
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
|
||||
Expected: PASS(渠道色值未变,channelConfig 测试不受影响)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
|
||||
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette,消除组件与 domain 硬编码色"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 移除自定义字体(Caveat/Quicksand)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
|
||||
- Modify: `package.json:14-15`
|
||||
|
||||
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
|
||||
|
||||
删除第 27–39 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
|
||||
|
||||
- [ ] **Step 2: 删除 useFonts 调用**
|
||||
|
||||
删除第 70–79 行的 `const [fontsLoaded, fontError] = useFonts({...});`。
|
||||
|
||||
- [ ] **Step 3: 简化加载门禁**
|
||||
|
||||
第 370 行:
|
||||
|
||||
```typescript
|
||||
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
if (phase === 'loading') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 移除 package.json 依赖并重装**
|
||||
|
||||
删除 `package.json` 中 `"@expo-google-fonts/caveat"` 与 `"@expo-google-fonts/quicksand"` 两行,然后:
|
||||
|
||||
Run: `npm install --legacy-peer-deps`
|
||||
Expected: lockfile 更新,无字体包残留。
|
||||
|
||||
- [ ] **Step 5: 验证无残留引用**
|
||||
|
||||
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
|
||||
Expected: 无输出(typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefined,RN 回退系统字体,无需在 P1 清理)。
|
||||
|
||||
- [ ] **Step 6: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
Expected: 全绿。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/_layout.tsx package.json package-lock.json
|
||||
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: commonStyles 适配新圆角
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/commonStyles.ts`
|
||||
|
||||
- [ ] **Step 1: modalCard 圆角 lg → xl**
|
||||
|
||||
`modalCard` 中 `borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
|
||||
|
||||
```typescript
|
||||
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
|
||||
```
|
||||
|
||||
(`input` 的 `theme.radii.md` 现在即 12px,无需改动,仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/commonStyles.ts
|
||||
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 重写 MASTER.md 使其与实现一致
|
||||
|
||||
**Files:**
|
||||
- Modify: `design-system/beancount-mobile/MASTER.md`
|
||||
|
||||
- [ ] **Step 1: 完整替换 MASTER.md**
|
||||
|
||||
```markdown
|
||||
# Beancount Mobile 设计系统(MASTER)
|
||||
|
||||
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
|
||||
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
|
||||
|
||||
## 设计方向:明亮 Bento 现代风
|
||||
|
||||
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
|
||||
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
|
||||
|
||||
## 色板(= presets.ts)
|
||||
|
||||
### 浅色(默认)
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||
| bgTertiary | `#EFF1F4` | 输入框 / chip |
|
||||
| fgPrimary | `#111318` | 主文字 |
|
||||
| fgSecondary | `#6B7280` | 次要文字 |
|
||||
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||
| accent | `#111318` | 按钮 / 选中态(近黑) |
|
||||
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
|
||||
| accentDark | `#000000` | 按压态 |
|
||||
| financial.income | `#10B981` | 收入 |
|
||||
| financial.expense | `#EF4444` | 支出 |
|
||||
| financial.transfer | `#3B82F6` | 转账 |
|
||||
| border | `#E5E7EB` | 边框 |
|
||||
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
|
||||
|
||||
### 暗色(OLED)
|
||||
| Token | 值 |
|
||||
|---|---|
|
||||
| bgPrimary | `#040508` |
|
||||
| bgSecondary | `#101218` |
|
||||
| bgTertiary | `#1A1D24` |
|
||||
| fgPrimary / accent | `#F3F4F6` |
|
||||
| fgSecondary | `#9CA3AF` |
|
||||
| fgInverse | `#111318` |
|
||||
| financial.income | `#34D399`(提亮) |
|
||||
| financial.expense | `#F87171`(提亮) |
|
||||
| financial.transfer | `#60A5FA`(提亮) |
|
||||
| border | `rgba(255,255,255,0.08)` |
|
||||
|
||||
分类/渠道颜色见 `src/theme/palette.ts`(12 色循环 + 具名映射 + 渠道品牌色)。
|
||||
全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。
|
||||
|
||||
## 字体
|
||||
|
||||
系统字体(iOS SF / Android Roboto),不加载自定义字体。
|
||||
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
|
||||
|
||||
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
|
||||
|
||||
## 圆角 / 间距 / 阴影
|
||||
|
||||
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(卡片与弹窗默认)** · full
|
||||
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
|
||||
- 阴影:浅色 4–12px 弥散轻阴影;暗色以 1px 半透边框代替阴影
|
||||
|
||||
## 图标
|
||||
|
||||
统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标;
|
||||
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
|
||||
|
||||
## 反模式
|
||||
|
||||
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
|
||||
- 禁止低对比文字(< 4.5:1)
|
||||
- 禁止瞬间状态变化,交互反馈 150–300ms
|
||||
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add design-system/beancount-mobile/MASTER.md
|
||||
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: P1 全量验收
|
||||
|
||||
- [ ] **Step 1: 全量测试**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: 全部通过(30+ 文件,含新 theme/palette 测试)。
|
||||
|
||||
- [ ] **Step 2: typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: 无错误。
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
|
||||
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
|
||||
|
||||
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219` 的 `#2196F3`(P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字(P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用(P4/P5 逐页清理)。
|
||||
|
||||
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
|
||||
|
||||
Run: `npm run android`(或 expo start)
|
||||
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑;chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
|
||||
|
||||
---
|
||||
|
||||
## 后续计划(不在本文件)
|
||||
|
||||
- **P2 组件库**:ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
|
||||
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
|
||||
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
|
||||
- **P5 管理页模板化 + 清零审计**
|
||||
|
||||
每阶段完成后基于实际代码编写下一阶段计划。
|
||||
@@ -0,0 +1,934 @@
|
||||
# UI 重设计 P2:核心组件库 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 建立新设计系统的核心组件库:CategoryIcon(去 emoji)、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar(中央凸起+)、ManagementScreen 模板(tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
|
||||
|
||||
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`,P3 换 NumpadSheet。
|
||||
|
||||
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
|
||||
|
||||
**Spec:** `docs/ui-redesign-design.md` §5(导航)、§6(组件库)。**与 spec 的偏差**:FilterSheet 移至 P4(其形态依赖交易页筛选状态,P4 一并设计);CategoryPicker 的"改底部弹层"移至 P3(随 NumpadSheet 一起设计,P2 仅完成 Ionicon 化);FormModal 增加 `children` 支持(ManagementScreen 的颜色选择器需要)。
|
||||
|
||||
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效,系统字体,538 测试全绿)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 组件纯逻辑的失败测试
|
||||
|
||||
**Files:**
|
||||
- Create: `tests/components-p2.test.ts`
|
||||
|
||||
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
|
||||
import { buildMonthGrid } from '../src/components/dateGrid';
|
||||
|
||||
describe('getCategoryIcon', () => {
|
||||
it('15 个内置分类都有具名图标', () => {
|
||||
const builtin = [
|
||||
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
|
||||
'housing_communication', 'entertainment', 'services', 'personal_care',
|
||||
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
|
||||
];
|
||||
for (const id of builtin) {
|
||||
const icon = getCategoryIcon(id);
|
||||
expect(typeof icon).toBe('string');
|
||||
expect(icon.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
it('未知分类返回 fallback 图标', () => {
|
||||
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
|
||||
});
|
||||
|
||||
it('原型链属性名 id 不穿透(返回 fallback)', () => {
|
||||
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
|
||||
});
|
||||
|
||||
it('所有图标名以 -outline 结尾(风格统一)', () => {
|
||||
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
|
||||
expect(name).toMatch(/-outline$/);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMonthGrid', () => {
|
||||
it('固定 6 行 × 7 列', () => {
|
||||
const grid = buildMonthGrid(2026, 7);
|
||||
expect(grid).toHaveLength(6);
|
||||
for (const week of grid) expect(week).toHaveLength(7);
|
||||
});
|
||||
|
||||
it('2026-07:首日周三,周一开头填充 6 月末两天', () => {
|
||||
const grid = buildMonthGrid(2026, 7);
|
||||
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
|
||||
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
|
||||
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
|
||||
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
|
||||
});
|
||||
|
||||
it('2026-02:首日周日,填充 1 月最后 6 天', () => {
|
||||
const grid = buildMonthGrid(2026, 2);
|
||||
expect(grid[0][0].date).toBe('2026-01-26');
|
||||
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
|
||||
});
|
||||
|
||||
it('首日恰为周一时无跨月填充(2026-06)', () => {
|
||||
const grid = buildMonthGrid(2026, 6);
|
||||
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
|
||||
});
|
||||
|
||||
it('inMonth 标记与当月天数一致(2026-07 共 31 天)', () => {
|
||||
const grid = buildMonthGrid(2026, 7);
|
||||
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行确认失败**
|
||||
|
||||
Run: `npx vitest run tests/components-p2.test.ts`
|
||||
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
|
||||
|
||||
---
|
||||
|
||||
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/categoryIcons.ts`
|
||||
- Create: `src/components/CategoryIcon.tsx`
|
||||
- Modify: `src/components/CategoryPicker.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
|
||||
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
|
||||
*/
|
||||
import type { ComponentProps } from 'react';
|
||||
import type { Ionicons } from '@expo/vector-icons';
|
||||
|
||||
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
|
||||
|
||||
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
|
||||
food: 'fast-food-outline',
|
||||
transport: 'bus-outline',
|
||||
shopping: 'bag-handle-outline',
|
||||
housing_utility: 'water-outline',
|
||||
housing_rent: 'home-outline',
|
||||
housing_communication: 'call-outline',
|
||||
entertainment: 'game-controller-outline',
|
||||
services: 'construct-outline',
|
||||
personal_care: 'sparkles-outline',
|
||||
clothing: 'shirt-outline',
|
||||
health: 'medkit-outline',
|
||||
learning: 'book-outline',
|
||||
salary: 'wallet-outline',
|
||||
income_activity: 'gift-outline',
|
||||
income_investment: 'trending-up-outline',
|
||||
};
|
||||
|
||||
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
|
||||
|
||||
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||||
export function getCategoryIcon(categoryId: string): IoniconName {
|
||||
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
|
||||
? CATEGORY_ICON_NAMES[categoryId]
|
||||
: FALLBACK_ICON;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
import { getCategoryIcon } from './categoryIcons';
|
||||
|
||||
interface CategoryIconProps {
|
||||
categoryId: string;
|
||||
/** 圆形底色直径,默认 36。 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
|
||||
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
|
||||
const color = getCategoryColor(categoryId);
|
||||
return (
|
||||
<View
|
||||
style={[styles.circle, {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color + '1A',
|
||||
}]}
|
||||
>
|
||||
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: { alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
|
||||
|
||||
- 删除整个 `CATEGORY_ICONS` 常量(第 13–30 行)。
|
||||
- 顶部加 `import { CategoryIcon } from './CategoryIcon';`(`getCategoryColor` 的 import 保留)。
|
||||
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
|
||||
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`。
|
||||
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
|
||||
- 删除 styles 中的 `icon` 条目。
|
||||
- 注意:`const color = getCategoryColor(cat.id);` 保留(active 底色仍用)。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASS(dateGrid 仍 FAIL,Task 3 修复)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 3: dateGrid 月历网格纯逻辑
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/dateGrid.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
|
||||
* 纯 TS,无 RN 依赖,供 DatePickerField / CalendarView 复用。
|
||||
*/
|
||||
|
||||
export interface DayCell {
|
||||
/** YYYY-MM-DD(本地时区)。 */
|
||||
date: string;
|
||||
inMonth: boolean;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 生成指定年月的月历网格。month 为 1-12。 */
|
||||
export function buildMonthGrid(year: number, month: number): DayCell[][] {
|
||||
const first = new Date(year, month - 1, 1);
|
||||
// 周一为一周起点:getDay() 周日=0 → 偏移 (day+6)%7
|
||||
const offset = (first.getDay() + 6) % 7;
|
||||
const cursor = new Date(year, month - 1, 1 - offset);
|
||||
const weeks: DayCell[][] = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const week: DayCell[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: BottomSheet 通用底部弹层
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/BottomSheet.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
|
||||
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
|
||||
<Pressable
|
||||
style={[styles.sheet, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopLeftRadius: theme.radii.xl,
|
||||
borderTopRightRadius: theme.radii.xl,
|
||||
}]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
||||
{title ? (
|
||||
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||
) : null}
|
||||
{children}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
|
||||
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
|
||||
title: { marginTop: 8, marginBottom: 12 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 5: DatePickerField 日期选择字段
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/DatePickerField.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
|
||||
|
||||
```tsx
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { buildMonthGrid } from './dateGrid';
|
||||
|
||||
interface DatePickerFieldProps {
|
||||
/** YYYY-MM-DD,空串表示未选。 */
|
||||
value: string;
|
||||
onChange: (date: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
|
||||
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewYear, setViewYear] = useState(new Date().getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
|
||||
|
||||
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
|
||||
|
||||
const openPicker = () => {
|
||||
const y = Number(value.slice(0, 4));
|
||||
const m = Number(value.slice(5, 7));
|
||||
if (y) {
|
||||
setViewYear(y);
|
||||
setViewMonth(m || 1);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const m = viewMonth - 1 + delta;
|
||||
setViewYear(viewYear + Math.floor(m / 12));
|
||||
setViewMonth(((m % 12) + 12) % 12 + 1);
|
||||
};
|
||||
|
||||
const pick = (date: string) => {
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
|
||||
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
|
||||
{value || placeholder || 'YYYY-MM-DD'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<BottomSheet visible={open} onClose={() => setOpen(false)}>
|
||||
{/* 月份导航 */}
|
||||
<View style={styles.navRow}>
|
||||
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
|
||||
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
|
||||
{viewYear}-{String(viewMonth).padStart(2, '0')}
|
||||
</Text>
|
||||
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
|
||||
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{/* 星期头 */}
|
||||
<View style={styles.weekRow}>
|
||||
{WEEKDAYS.map(w => (
|
||||
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
|
||||
))}
|
||||
</View>
|
||||
{/* 日期网格 */}
|
||||
{grid.map((week, wi) => (
|
||||
<View key={wi} style={styles.weekRow}>
|
||||
{week.map(cell => {
|
||||
const selected = cell.date === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={cell.date}
|
||||
onPress={() => pick(cell.date)}
|
||||
style={[styles.dayCell, {
|
||||
backgroundColor: selected ? theme.colors.accent : 'transparent',
|
||||
borderRadius: theme.radii.full,
|
||||
}]}
|
||||
>
|
||||
<Text style={[theme.typography.bodySmall, {
|
||||
color: selected
|
||||
? theme.colors.fgInverse
|
||||
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||
opacity: cell.inMonth ? 1 : 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
}]}>
|
||||
{Number(cell.date.slice(8, 10))}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||
weekRow: { flexDirection: 'row' },
|
||||
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
|
||||
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 6: ScreenHeader + StatCard
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/ScreenHeader.tsx`
|
||||
- Create: `src/components/StatCard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
title: string;
|
||||
/** 右侧操作位(图标按钮/文本按钮)。 */
|
||||
right?: React.ReactNode;
|
||||
/** 自定义返回行为;默认 router.back()。 */
|
||||
onBack?: () => void;
|
||||
/** 返回按钮的无障碍标签。 */
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header)。 */
|
||||
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={backLabel}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{right ?? <View style={styles.rightPlaceholder} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
|
||||
title: { flex: 1 },
|
||||
rightPlaceholder: { width: 24 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
caption?: string;
|
||||
/** 默认 fgPrimary;收支场景传 financial.income/expense。 */
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
|
||||
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[styles.card, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.xl,
|
||||
borderColor: theme.colors.border,
|
||||
}, theme.shadows.sm]}
|
||||
>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{caption ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
|
||||
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AppTabBar 中央凸起+导航
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/AppTabBar.tsx`
|
||||
- Modify: `src/app/(tabs)/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import { useTheme } from '../theme';
|
||||
import type { IoniconName } from './categoryIcons';
|
||||
|
||||
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
|
||||
const TAB_ICONS: Record<string, IoniconName> = {
|
||||
index: 'home-outline',
|
||||
transactions: 'list-outline',
|
||||
report: 'pie-chart-outline',
|
||||
settings: 'settings-outline',
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义底部导航:4 个内容 Tab + 中央凸起+。
|
||||
* +不是路由——P2 暂跳转 /transaction/new,P3 改为唤起全局 NumpadSheet。
|
||||
*/
|
||||
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderTab = (route: (typeof state.routes)[number], index: number) => {
|
||||
const focused = state.index === index;
|
||||
const options = descriptors[route.key].options;
|
||||
const label = (options.title ?? route.name) as string;
|
||||
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
|
||||
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
key={route.key}
|
||||
onPress={onPress}
|
||||
style={styles.tab}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: focused }}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
|
||||
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.bar, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingBottom: Math.max(insets.bottom, 8),
|
||||
}]}
|
||||
>
|
||||
{state.routes.slice(0, 2).map(renderTab)}
|
||||
<View style={styles.plusSlot}>
|
||||
<Pressable
|
||||
onPress={() => router.push('/transaction/new')}
|
||||
style={[styles.plus, {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.bgPrimary,
|
||||
}, theme.shadows.lg]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="记一笔"
|
||||
>
|
||||
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{state.routes.slice(2).map(renderTab)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
|
||||
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '600' },
|
||||
plusSlot: { width: 64, alignItems: 'center' },
|
||||
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
|
||||
|
||||
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
|
||||
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */`
|
||||
- `Tabs` 组件加 prop:`tabBar={(props) => <AppTabBar {...props} />}`。
|
||||
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
|
||||
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/FormModal.tsx`
|
||||
- Create: `src/components/ManagementScreen.tsx`
|
||||
- Modify: `src/app/tag/index.tsx`(完整重写)
|
||||
|
||||
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
|
||||
|
||||
- `FormModalProps` 加字段:
|
||||
|
||||
```typescript
|
||||
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
||||
children?: React.ReactNode;
|
||||
```
|
||||
|
||||
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`。
|
||||
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`。
|
||||
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`。
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { ScreenHeader } from './ScreenHeader';
|
||||
import { FormModal, type FormField } from './FormModal';
|
||||
|
||||
/**
|
||||
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
||||
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
|
||||
*/
|
||||
interface ManagementScreenProps<T> {
|
||||
title: string;
|
||||
items: T[];
|
||||
keyExtractor: (item: T) => string;
|
||||
/** 渲染单个条目;handlers.openEdit 打开编辑弹窗,handlers.confirmDelete 弹删除确认。 */
|
||||
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
|
||||
addLabel: string;
|
||||
emptyText?: string;
|
||||
formTitle: (editing: T | null) => string;
|
||||
formFields: (editing: T | null) => FormField[];
|
||||
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
|
||||
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
|
||||
onDelete: (item: T) => void;
|
||||
deleteConfirmText: (item: T) => string;
|
||||
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
|
||||
formExtra?: (editing: T | null) => React.ReactNode;
|
||||
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
|
||||
onOpenForm?: (editing: T | null) => void;
|
||||
/** 列表上方的额外内容(分组 Tab 等)。 */
|
||||
headerContent?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const [editing, setEditing] = useState<T | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openForm = (item: T | null) => {
|
||||
setEditing(item);
|
||||
props.onOpenForm?.(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = (item: T) => {
|
||||
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSubmit = (values: Record<string, string>) => {
|
||||
if (props.onSubmit(values, editing)) setModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<ScreenHeader
|
||||
title={props.title}
|
||||
right={
|
||||
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
|
||||
<Ionicons name="add" size={26} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{props.headerContent}
|
||||
{props.items.length === 0 && props.emptyText ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
|
||||
) : null}
|
||||
{props.items.map(item => (
|
||||
<View key={props.keyExtractor(item)}>
|
||||
{props.renderItem(item, {
|
||||
openEdit: () => openForm(item),
|
||||
confirmDelete: () => confirmDelete(item),
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
{props.footer}
|
||||
</ScrollView>
|
||||
<FormModal
|
||||
visible={modalOpen}
|
||||
title={props.formTitle(editing)}
|
||||
fields={props.formFields(editing)}
|
||||
onConfirm={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
>
|
||||
{props.formExtra?.(editing)}
|
||||
</FormModal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, gap: 12 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 完整重写 src/app/tag/index.tsx(ManagementScreen 试点)**
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
|
||||
*
|
||||
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
||||
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { TAG_COLORS } from '../../theme/palette';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { isValidTagName } from '../../domain/tags';
|
||||
import type { Tag } from '../../domain/tags';
|
||||
|
||||
export default function TagScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
const tags = useMetadataStore(s => s.tags);
|
||||
const addTag = useMetadataStore(s => s.addTag);
|
||||
const updateTag = useMetadataStore(s => s.updateTag);
|
||||
const removeTag = useMetadataStore(s => s.removeTag);
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
|
||||
|
||||
return (
|
||||
<ManagementScreen<Tag>
|
||||
title={t('tag.title')}
|
||||
items={tags}
|
||||
keyExtractor={tag => tag.id}
|
||||
addLabel={t('tag.add')}
|
||||
emptyText={t('tag.empty')}
|
||||
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
|
||||
renderItem={(tag, { openEdit, confirmDelete }) => (
|
||||
<Pressable
|
||||
onPress={openEdit}
|
||||
onLongPress={confirmDelete}
|
||||
style={[styles.chip, { backgroundColor: tag.color }]}
|
||||
>
|
||||
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
|
||||
formFields={editing => [
|
||||
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
|
||||
]}
|
||||
formExtra={() => (
|
||||
<View style={styles.colorRow}>
|
||||
{TAG_COLORS.map(c => (
|
||||
<Pressable
|
||||
key={c}
|
||||
onPress={() => setSelectedColor(c)}
|
||||
style={[styles.colorDot, {
|
||||
backgroundColor: c,
|
||||
borderWidth: selectedColor === c ? 3 : 0,
|
||||
borderColor: theme.colors.fgPrimary,
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
onSubmit={(values, editing) => {
|
||||
const name = values.name?.trim() ?? '';
|
||||
if (!isValidTagName(name)) {
|
||||
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
|
||||
return false;
|
||||
}
|
||||
if (editing) updateTag(editing.id, { name, color: selectedColor });
|
||||
else addTag({ id: generateId('tag'), name, color: selectedColor });
|
||||
return true;
|
||||
}}
|
||||
onDelete={tag => removeTag(tag.id)}
|
||||
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
|
||||
footer={
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
||||
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm test` → Expected: 全部通过(tag 页逻辑变化不影响单测,但全量跑一遍防回归)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Button / Card / SearchBar 重刷
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/Button.tsx`
|
||||
- Modify: `src/components/Card.tsx`
|
||||
- Modify: `src/components/SearchBar.tsx`
|
||||
|
||||
- [ ] **Step 1: Button.tsx**
|
||||
|
||||
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
|
||||
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
|
||||
|
||||
- [ ] **Step 2: Card.tsx**
|
||||
|
||||
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`。
|
||||
|
||||
- [ ] **Step 3: SearchBar.tsx**
|
||||
|
||||
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → Expected: 全部通过
|
||||
|
||||
---
|
||||
|
||||
### Task 10: P2 全量验收
|
||||
|
||||
- [ ] **Step 1: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
- [ ] **Step 2: 审计**
|
||||
|
||||
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
|
||||
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
|
||||
Run: `grep -n "✕" src/components/` → Expected: 无输出
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||
|
||||
Run: `npm run android`
|
||||
走查清单(浅/暗双主题):
|
||||
1. 底部导航为自定义 bar:中央黑色凸起+,4 个 tab 可切换
|
||||
2. + → 跳转记一笔页(P2 占位行为)
|
||||
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
|
||||
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
|
||||
5. 任意 FormModal:关闭按钮为 Ionicons 图标而非 ✕ 字符
|
||||
|
||||
---
|
||||
|
||||
## 后续计划(不在本文件)
|
||||
|
||||
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + AppTabBar + 改全局唤起 + transaction/new 重写 + SpeedDial 退役
|
||||
- **P4 四个 Tab 页**:首页待办条 / 交易时间线(FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
|
||||
- **P5 管理页全面模板化**(tag 页为已验证模板)+ 清零审计
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,233 @@
|
||||
# UI 重设计 P5:管理页模板化 + 清零收尾 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 7 个管理页套 ManagementScreen 模板、account 页与 3 个设置二级页统一 ScreenHeader、图表/fontFamily/emoji/星期头清理、`numpadGlobalEntry` 翻默认(含 modal 未保存守卫)、清零审计验收。
|
||||
|
||||
**Spec:** `docs/ui-redesign-design.md` §7.5/§8。**已确认现状**(2026-07-22 审计):hex 硬编码已清零(`#[0-9A-Fa-f]{6}` 在 src/ 仅剩 presets.ts/palette.ts);fontFamily 残留 14 处;emoji 5 处(monthlySummary.ts 4 + ai/chat.tsx 1);tag 页已是模板试点。
|
||||
|
||||
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
||||
|
||||
**模板适配决策**(已通读 7 页源码):
|
||||
|
||||
- budget/rules/remark-template → 直套(T1)
|
||||
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
||||
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
||||
- credit-card → 富 renderItem(账单盒)直套(T2)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
||||
|
||||
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
||||
|
||||
**budget/index.tsx**:
|
||||
|
||||
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
||||
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
||||
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
||||
- onSubmit:保留校验(amount parseFloat ≤ 0 → Alert + return false);add/edit 分支调 addBudget/updateBudget
|
||||
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
||||
|
||||
**rules/index.tsx**:
|
||||
|
||||
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
||||
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
||||
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
||||
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
||||
|
||||
**remark-template/index.tsx**:
|
||||
|
||||
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
||||
- onSubmit:name 空回退 `t('common.untitled')`
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
||||
|
||||
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
||||
|
||||
- 保留一个本地 state:`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
|
||||
- `headerContent`:两个 chip(支出/收入,commonStyles.chip/chipActive),切换 setCatType
|
||||
- `items={categories.filter(c => c.type === catType)}`
|
||||
- `formTitle`:editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle)
|
||||
- formFields:3 字段(name/linkedAccount/keywords),编辑时带 defaultValue
|
||||
- onSubmit:add 时 `type: catType`,linkedAccount 空回退按类型(Income:/Expenses:Uncategorized),keywords 拆分逻辑保留;edit 保留原逻辑
|
||||
- renderItem:保留名称/linkedAccount/关键词行
|
||||
|
||||
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
|
||||
|
||||
- renderItem:保留信息行 Card;卡片底部两个按钮(编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`(**不**用长按,交互更显式);外层 Pressable 去掉
|
||||
- formFields:6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
|
||||
- onSubmit:保留校验/draft 组装/成功 Alert,返回 true;校验失败 Alert 后 return false
|
||||
- 成功 Alert 在 onSubmit 内(setModal(null) 由模板接管——删除手动 setModal 调用)
|
||||
|
||||
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
|
||||
|
||||
- accountBalances useMemo 保留在页面顶层
|
||||
- renderItem:保留整张卡(银行/账单日/还款日/额度/账单盒),Pressable onPress={openEdit} onLongPress={confirmDelete}
|
||||
- formFields:8 字段;onSubmit 保留组装逻辑
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ScreenHeader 统一(account + settings 三页)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
||||
|
||||
- [ ] **Step 1: account/index.tsx**
|
||||
|
||||
手写 header(arrow-back + title)替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert);删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)` → `toDateString(new Date())`。
|
||||
|
||||
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
|
||||
|
||||
三页手写 header(约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44)替换为 ScreenHeader,标题沿用原文案键;删除对应 styles.header 与不再使用的 import(Ionicons/router 若仅 header 使用)。sync.tsx 546 行,只动 header 块,其余不动。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: fontFamily 清零**
|
||||
|
||||
- `theme.typography.X.fontFamily` 引用(CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处):直接删除该属性(P1 后 typography 无 fontFamily 字段,这些是无效引用)
|
||||
- `fontFamily: 'monospace'`:
|
||||
- 金额/数字场景(CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391)→ `fontVariant: ['tabular-nums']`
|
||||
- 纯文本场景(_error.tsx:70 堆栈文本)→ 保留 monospace(堆栈对齐需要)或删除——保留,加注释说明
|
||||
- NetWorthChart/CalendarHeatmap 顺便检查一遍(grep 未命中但过一眼 token 合规)
|
||||
|
||||
- [ ] **Step 2: emoji 清零**
|
||||
|
||||
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`)
|
||||
- `src/app/ai/chat.tsx:123`:方向 emoji(💰/🔄/💸)替换为符号文本:income `'+'`、transfer `'⇄'`、expense `'-'`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*)
|
||||
|
||||
- [ ] **Step 3: 星期头 i18n(3 处硬编码中文)**
|
||||
|
||||
- i18n 加键(zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`(周一起,DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`(周日起,CalendarView/CalendarHeatmap 用)
|
||||
- `DatePickerField.tsx`、`CalendarView.tsx`、`CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT;注意保持周一/周日起始顺序与各自网格一致,不要换序)
|
||||
|
||||
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
|
||||
|
||||
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
|
||||
- NumpadKeyboard props 加 `backspaceLabel: string`,backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿(i18n 对等校验覆盖新键)
|
||||
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
||||
- Delete: `src/components/charts/MonthlyReport.tsx`
|
||||
|
||||
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
|
||||
|
||||
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false` → `true`。**注意**:已持久化 false 的用户不受影响(hydrate 合并优先级),仅新安装生效——在代码注释中写明。
|
||||
|
||||
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
|
||||
|
||||
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef),加 `useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
|
||||
- `NumpadSheetHost.tsx`:`const dirtyRef = useRef(false)`;NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`;overlay onPress 与 Modal onRequestClose 改调 `requestClose`:
|
||||
|
||||
```typescript
|
||||
const requestClose = () => {
|
||||
if (!dirtyRef.current) { close(); return; }
|
||||
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.discard'), style: 'destructive', onPress: close },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
(Host 需 useT + Alert import;common.discard 键先 grep 确认存在。)
|
||||
|
||||
- [ ] **Step 3: 删除 MonthlyReport.tsx**(P4 确认无引用的死文件)
|
||||
|
||||
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
|
||||
|
||||
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
|
||||
|
||||
读 `src/domain/budgets.ts` 的 `getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date(`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'`;`getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED)。
|
||||
|
||||
- [ ] **Step 5: handleDuplicate 带 tags**
|
||||
|
||||
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags,参照 editId 预填的 tags 回填逻辑(tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时)。cost/price 不带(hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
|
||||
|
||||
- [ ] **Step 6: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 6: P5 清零审计 + 全项目验收
|
||||
|
||||
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
|
||||
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
|
||||
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
|
||||
grep -rn "arrow-back" src/app/ # 无输出(ScreenHeader 内部除外)
|
||||
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test` → 全绿
|
||||
Run: `npm run typecheck` → 无错误
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||
|
||||
Run: `npm run android`
|
||||
走查清单(浅/暗双主题):
|
||||
|
||||
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
||||
2. account 页:类型 Tab、开户、调余额、关户
|
||||
3. settings/sync、ai、preferences:header 统一有返回箭头
|
||||
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal;输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
|
||||
5. 设置→偏好里开关关闭 → +回到跳整页
|
||||
6. 交易页左滑复制带标签的交易 → 标签预填
|
||||
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
|
||||
8. 英文语言:日期选择器/日历星期头为英文缩写
|
||||
|
||||
---
|
||||
|
||||
## 完成后的项目状态
|
||||
|
||||
P1–P5 全部交付,spec §8 验收标准全达成。剩余已知非阻塞项(不修,记录):TransactionCard memo 被内联 onPress 击穿(性能);todayStr useMemo 跨午夜不刷新;NumpadSheet page 模式无可见返回按钮(spec 允许);_error.tsx monospace 保留(堆栈对齐)。
|
||||
@@ -0,0 +1,317 @@
|
||||
# UI 重设计 P6:原生浮层(悬浮球/悬浮窗)重设计 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 原生浮层(FloatingHelper 悬浮球 / FloatingBillView 浮窗 / FloatingTip 提示)视觉与文案对齐新设计系统:颜色与文案由 JS 层(theme tokens + i18n)通过 bridge 下发,原生不再硬编码中文与旧靛蓝;悬浮球位置持久化;浮窗补币种字段;前台账单 Alert i18n 化并去 emoji。
|
||||
|
||||
**背景决策(已确认):**
|
||||
- 原生 View 无法直接用 RN theme,采用 **`FloatingUiConfig` 下发模式**:JS 侧从当前 theme + i18n 构建 config → bridge `setFloatingUiConfig()` → 原生存 SharedPreferences(JSON)→ 三个浮层组件读取。原生保留现有硬编码值作为**默认值兜底**(JS 未推送时行为不变)。
|
||||
- 浮窗卡片**跟随 App 主题**(浅色=白卡近黑 accent,深色=OLED 黑卡白 accent),卡片用实色(不再半透明磨砂),保证在其他 App 上的可读性。
|
||||
- 浮窗金额校验从 `toDoubleOrNull` 改正则 + BigDecimal;自动消失 15s → 30s。
|
||||
- 改动范围:`plugins/accessibility/`(Kotlin)+ `src/services/`(bridge/config)+ `src/app/_layout.tsx` + `src/services/automationPipeline.ts` + `src/i18n/`。**不碰 domain 写路径。**
|
||||
|
||||
---
|
||||
|
||||
## FloatingUiConfig 契约(T1-T5 共同遵守,先读这里)
|
||||
|
||||
### JS → 原生:`setFloatingUiConfig(config: ReadableMap): Promise<boolean>`
|
||||
|
||||
```typescript
|
||||
interface FloatingUiConfig {
|
||||
colors: {
|
||||
accent: string; // 按钮/选中态背景(= theme accent:light #111318 / dark #F3F4F6)
|
||||
accentFg: string; // accent 上的文字色(= fgInverse)
|
||||
cardBg: string; // 卡片底色(= bgSecondary)
|
||||
inputBg: string; // 输入框/未选中 chip 底色(= bgTertiary)
|
||||
fgPrimary: string;
|
||||
fgSecondary: string;
|
||||
border: string;
|
||||
income: string; // financial.income
|
||||
expense: string; // financial.expense
|
||||
transfer: string; // financial.transfer
|
||||
};
|
||||
labels: {
|
||||
billTitle: string; // 浮窗标题
|
||||
dirExpense: string; dirIncome: string; dirTransfer: string;
|
||||
amountLabel: string; payeeLabel: string;
|
||||
narrationLabel: string; narrationHint: string;
|
||||
categoryExpense: string; // 「交易分类」
|
||||
categoryIncome: string; // 「收入分类」
|
||||
transferTarget: string; // 「转入账户」
|
||||
accountExpense: string; // 「资金来源」
|
||||
accountIncome: string; // 「存入账户」
|
||||
accountTransfer: string; // 「转出账户」
|
||||
openApp: string; dismiss: string; confirm: string;
|
||||
ballOcr: string; // 悬浮球「识别账单」
|
||||
ballRemember: string; // 「记住此页」
|
||||
rememberSuccess: string; // 记住页面成功提示(不含 emoji)
|
||||
rememberFail: string; // 失败提示前缀(原生拼接 ': ' + e.message)
|
||||
pageRemembered: string; // BillingAccessibilityService Toast「已记住页面签名」前缀
|
||||
pageSignatureExists: string; // 「该页面签名已存在」前缀
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
- 颜色一律 `"#RRGGBB"` 六位数 hex(theme tokens 里有 rgba 的字段不要直接下发——accent/cardBg/fg/border 等都是纯色,确认无 rgba;border 深色主题是 `rgba(255,255,255,0.08)`,下发前换算为 hex:深色用 `#14202429`? **不许发明值**——正确做法:原生 `Color.parseColor` 支持 `#AARRGGBB`,JS 侧把 rgba(255,255,255,0.08) 换算为 `#14FFFFFF`(alpha 0.08≈0x14)。写一个 `rgbaToHex` 小工具处理这种情况,纯色 hex 原样通过)。
|
||||
- 原生端用 `Color.parseColor` 解析,异常时回退默认值。
|
||||
|
||||
### 原生持久化
|
||||
|
||||
- SharedPreferences 文件名:`floating_ui_config`,单键 `config_json` 存整个 JSON。
|
||||
- 新增 `FloatingUiConfigStore.kt`(object):`save(context, ReadableMap)` / `load(context): FloatingUiConfig`(data class,字段缺省值=现状硬编码值,保证旧 JS 行为不变)。
|
||||
- `FloatingUiConfig` data class 字段名与上面契约一致(camelCase)。
|
||||
|
||||
### 事件契约变更(T3+T5)
|
||||
|
||||
- `showFloatingBill` 增加参数 `currency: String`(放在 `direction` 之后、`draftId` 之前)。
|
||||
- `billingConfirmed` / `billingOpenApp` 事件 map 增加 `putString("currency", currentCurrency)`。
|
||||
- 浮窗金额行左侧显示当前币种 chip,点击在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 中循环切换;初始值 = 传入 currency(不在列表则插入到首位)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: JS 侧 — i18n 键 + floatingUiConfig 服务 + bridge 接口 + 推送接线 + 前台 Alert i18n
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`、`src/services/accessibilityBridge.ts`、`src/app/_layout.tsx`、`src/services/automationPipeline.ts`
|
||||
- Create: `src/services/floatingUiConfig.ts`、`tests/floating-ui-config.test.ts`
|
||||
|
||||
- [ ] **Step 1: i18n 新增 `floating.*` 键组(zh/en 对等,`%{name}` 插值语法)**
|
||||
|
||||
```
|
||||
floating.billTitle 调整交易草稿 / Adjust Draft
|
||||
floating.dirExpense 支出 / Expense (复用现有方向键也行,但浮层独立一组避免耦合)
|
||||
floating.dirIncome 收入 / Income
|
||||
floating.dirTransfer 转账 / Transfer
|
||||
floating.amountLabel 金额 / Amount
|
||||
floating.payeeLabel 交易对手 / Payee
|
||||
floating.narrationLabel 描述/备注 / Narration
|
||||
floating.narrationHint 输入交易叙述 / Enter narration
|
||||
floating.categoryExpense 交易分类 / Category
|
||||
floating.categoryIncome 收入分类 / Income Category
|
||||
floating.transferTarget 转入账户 / To Account
|
||||
floating.accountExpense 资金来源 / From Account
|
||||
floating.accountIncome 存入账户 / To Account
|
||||
floating.accountTransfer 转出账户 / From Account
|
||||
floating.openApp 打开应用 / Open App
|
||||
floating.dismiss 忽略 / Dismiss
|
||||
floating.confirm 确认入账 / Confirm
|
||||
floating.ballOcr 识别账单 / Scan Bill
|
||||
floating.ballRemember 记住此页 / Remember Page
|
||||
floating.rememberSuccess 已将当前页面加入识别白名单 / Page added to recognition whitelist
|
||||
floating.rememberFail 记录失败 / Failed to save
|
||||
floating.pageRemembered 已记住页面签名 / Page signature saved
|
||||
floating.pageSignatureExists 该页面签名已存在 / Page signature already exists
|
||||
```
|
||||
|
||||
(若现有键已有同文案可复用,但键组保持独立 `floating.*`;先 grep 避免重复键名。)
|
||||
|
||||
同时给前台 Alert 加键(放 `automation.*` 下,先读现有 automation 节):
|
||||
```
|
||||
automation.billDetectedTitle 识别到新账单 / New Bill Detected (无 emoji)
|
||||
automation.billDetectedReject 拒绝/丢弃 / Discard
|
||||
automation.billDetectedEdit 修改并入账 / Edit & Save
|
||||
automation.billDetectedConfirm 确认入账 / Confirm
|
||||
```
|
||||
Alert 的多行消息体(日期/商户/金额/分类/账户/叙述)逐行用既有标签键拼,不为每行加新键(行标签可复用表单/详情现有键,先 grep `transaction.payee` 之类;没有合适的就在 automation 节加 `billDetectedLine*` 键)。
|
||||
|
||||
- [ ] **Step 2: `src/services/floatingUiConfig.ts`**
|
||||
|
||||
```typescript
|
||||
export interface FloatingUiConfig { colors: {...}; labels: {...} } // 按契约
|
||||
|
||||
/** rgba(r,g,b,a) → #AARRGGBB;#RRGGBB 原样返回。 */
|
||||
export function colorToHex(color: string): string;
|
||||
|
||||
/** 从 theme tokens + t() 构建完整 config。 */
|
||||
export function buildFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): FloatingUiConfig;
|
||||
|
||||
/** 构建并推送到原生(bridge 不可用时静默返回 false)。 */
|
||||
export async function pushFloatingUiConfig(theme: ThemeTokens, t: TranslateFn): Promise<boolean>;
|
||||
```
|
||||
|
||||
- [ ] **Step 3: bridge 接口扩展**
|
||||
|
||||
`accessibilityBridge.ts`:`NativeAccessibilityBridge` 加 `setFloatingUiConfig(config: FloatingUiConfig): Promise<boolean>;`,文件头注释方法清单补一行。`showFloatingBill` 签名加 `currency: string`(direction 之后、draftId 之前)。
|
||||
|
||||
- [ ] **Step 4: _layout.tsx 推送接线**
|
||||
|
||||
- 启动时(setFloatingBallEnabled 同一处,`src/app/_layout.tsx:139` 附近)也 `pushFloatingUiConfig(...)`。注意此处拿不到 useTheme 的 theme(在 ThemeProvider 外层?先读 _layout 结构确认)——若拿不到,启动这次推送可移到下一步的组件里统一做,避免重复。
|
||||
- 在 ThemeProvider **内部**挂一个小组件(如 `FloatingUiConfigSyncer`,可直接写在 _layout.tsx 里):`const { theme } = useTheme(); const t = useT(); useEffect(() => { pushFloatingUiConfig(theme, t); }, [theme, t])` —— 主题切换/语言切换/启动都会重推。t 引用随 locale 变化(确认 useT 返回的 t 在语言切换时引用变化,先读 src/i18n 实现;若 t 引用稳定,则依赖里加 locale)。
|
||||
|
||||
- [ ] **Step 5: automationPipeline.ts 前台 Alert i18n + 去 emoji**
|
||||
|
||||
`src/services/automationPipeline.ts:298` 的 `Alert.alert('🌟 识别到新账单', ...)`:标题/按钮全部改 t();消息体保留原信息结构。该文件是 service 层非组件——确认文件里如何拿 t(若没有,用 `i18n.t(...)` 直接调,读 src/i18n/index.ts 导出了什么)。`showFloatingBill` 调用处(:262)传第 7 个参数 `currency`(用 :225 已提取的 currency 变量)。
|
||||
|
||||
- [ ] **Step 6: 测试 + 验证**
|
||||
|
||||
`tests/floating-ui-config.test.ts`:
|
||||
- `colorToHex`:`'#111318'` 原样;`'rgba(255,255,255,0.08)'` → `#14FFFFFF`;`'rgba(17,19,24,0.4)'` → `#66111318`(alpha 四舍五入 Math.round(a*255))
|
||||
- `buildFloatingUiConfig`:用 lightTheme + 真实 zh t() 构建,断言 colors.accent === '#111318'、labels.billTitle === '调整交易草稿'、所有契约字段非空(遍历 keys)
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿(i18n parity 测试覆盖新键)
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 原生 config 基础设施(FloatingUiConfigStore + bridge 方法 + FloatingTip/Toast 改读)
|
||||
|
||||
**Files:**
|
||||
- Create: `plugins/accessibility/android/FloatingUiConfigStore.kt`
|
||||
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`、`plugins/accessibility/android/FloatingTip.kt`、`plugins/accessibility/android/BillingAccessibilityService.kt`
|
||||
|
||||
- [ ] **Step 1: FloatingUiConfigStore.kt**
|
||||
|
||||
- `data class FloatingUiConfig(...)`:colors/labels 全部字段,默认值 = 现状硬编码(accent `#5E6AD2`、cardBg `#FF050506`? —— **不**:默认值应是「现状视觉」,但现状 cardBg 是 0x8C050506 半透明。默认 cardBg 用 `#F2050506`? 简化:默认值就用现状各硬编码颜色换算成 `#AARRGGBB`/`#RRGGBB` 字符串,逐字段列注释对应原 Kotlin 常量)。labels 默认值 = 现状中文硬编码文案(去 emoji)。
|
||||
- `object FloatingUiConfigStore`:`private const val PREFS = "floating_ui_config"`;`fun save(context: Context, map: ReadableMap)`(遍历契约字段,缺失字段跳过不覆盖,JSONObject 组装后写入);`fun load(context: Context): FloatingUiConfig`(JSONObject 读取,缺失/解析失败逐字段回退默认;`optString`)。颜色字段提供 `fun parseColorOr(value: String, fallback: Int): Int` 工具(`Color.parseColor` try/catch)。
|
||||
- org.json 可用(Android 内置),无需新依赖。
|
||||
|
||||
- [ ] **Step 2: AccessibilityBridgeModule.setFloatingUiConfig**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun setFloatingUiConfig(config: ReadableMap, promise: Promise) {
|
||||
try {
|
||||
FloatingUiConfigStore.save(reactContext, config)
|
||||
promise.resolve(true)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("CONFIG_SAVE_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: FloatingTip/RepeatToast 改读 config**
|
||||
|
||||
- FloatingTip.show():背景色 `0xF0333333` → config accent(不透明化:解析后 `or 0xFF000000.toInt()`),文字色 → accentFg,ProgressBar `progressTintList` 设 accentFg。构造签名加可选 `config: FloatingUiConfig? = null`,null 时内部 `FloatingUiConfigStore.load(context)`。
|
||||
- RepeatToast 的 `"⚠ $message"` 前缀 emoji 去掉,只留 message(⚠ 属 emoji 审计范围)。
|
||||
- FloatingTip 调用处(FloatingHelper.kt:209/211)改传 config 文案:`labels.rememberSuccess`、`"${labels.rememberFail}: ${e.message}"`(T4 做也可以,此处先改文案读取,FloatingHelper 的彻底重设计在 T4)。
|
||||
|
||||
- [ ] **Step 4: BillingAccessibilityService 两处 Toast 文案**
|
||||
|
||||
:387 `"已记住页面签名:\n$sig"` 与 :419 `"该页面签名已存在:\n$sig"` → 改读 `FloatingUiConfigStore.load(this).labels` 的 pageRemembered / pageSignatureExists + `":\n$sig"`。
|
||||
|
||||
- [ ] **Step 5: 编译验证**
|
||||
|
||||
`android/` 已生成。把改动的 .kt 手动拷到 `android/app/src/main/java/com/beancount/mobile/accessibility/`(与 plugins 目录同名文件覆盖),然后:
|
||||
Run: `cd android && ./gradlew :app:compileDebugKotlin -q` → BUILD SUCCESSFUL
|
||||
(若编译环境不可用,记录为设备验证项并在报告中说明。)
|
||||
|
||||
---
|
||||
|
||||
### Task 3: FloatingBillView 重设计 + 币种
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/FloatingBillView.kt`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(showFloatingBill 加 currency 参数透传)
|
||||
|
||||
**视觉规格(对齐 docs/ui-redesign-design.md §3 Bento):**
|
||||
- 卡片:实色 cardBg、圆角 16dp、1dp border 描边、padding 16dp(原 12/8 加宽)
|
||||
- 标题:fgPrimary 13sp bold;分段选择器:选中=accent 底 accentFg 字,未选中=透明 fgSecondary 字,圆角 6dp
|
||||
- 字段标签:fgSecondary 10sp bold;输入框:inputBg 底、圆角 10dp、1dp border、fgPrimary 字、hint fgSecondary
|
||||
- chips:圆角 999(用 dp(14f) 近似胶囊)、未选中=inputBg+border 描边+fgSecondary 字、选中:分类行支出/收入=accent;转账方向第一行=transfer 色;账户行按方向=income/expense 色(语义色保留,但改从 config 读)
|
||||
- 按钮:高 40dp、圆角 12dp;confirm=accent 底 accentFg 字 bold;openApp/dismiss=inputBg 底 fgPrimary 字
|
||||
- 所有颜色经 `FloatingUiConfigStore.load(context)` + parseColorOr 兜底;所有文案经 labels
|
||||
|
||||
- [ ] **Step 1: 构造签名 + showFloatingBill 参数**
|
||||
|
||||
`FloatingBillView` 构造加 `initialCurrency: String = "CNY"`;`AccessibilityBridgeModule.showFloatingBill` 加 `currency: String` 参数(direction 后 draftId 前)并透传。
|
||||
|
||||
- [ ] **Step 2: 币种 chip**
|
||||
|
||||
金额行:水平布局,左侧币种 chip(inputBg+border,fgPrimary 字,显示 currentCurrency),右侧金额输入框(weight 1)。点击 chip 在 `listOf("CNY","USD","HKD","JPY","EUR","GBP")` 循环(initialCurrency 不在列表则临时插到首位)。sendSaveEvent/sendOpenAppEvent 的 map 加 `putString("currency", currentCurrency)`。
|
||||
|
||||
- [ ] **Step 3: 金额校验改正则 + BigDecimal**
|
||||
|
||||
`afterTextChanged`:`Regex("^\\d+(\\.\\d{1,2})?$")` 匹配且 `BigDecimal(text) > BigDecimal.ZERO` 才启用保存(BigDecimal 构造 try/catch)。禁用时按钮底色 inputBg + fgSecondary 字。
|
||||
|
||||
- [ ] **Step 4: 全面替换颜色与文案 + 自动消失 30s**
|
||||
|
||||
逐段按上面视觉规格重写 show() 与 rebuildChips()(结构不变,只换色值来源、文案来源、尺寸);`15000L` → `30000L`。标签按方向切换的文案(交易分类/收入分类/转入账户、资金来源/存入账户/转出账户)全部走 labels。
|
||||
|
||||
- [ ] **Step 5: 编译验证**(同 T2 Step 5 流程)
|
||||
|
||||
---
|
||||
|
||||
### Task 4: FloatingHelper 重设计 + 位置持久化
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/FloatingHelper.kt`
|
||||
|
||||
- [ ] **Step 1: 颜色与文案走 config**
|
||||
|
||||
- 指示竖线:accent(保持 70% 透明度:解析后 alpha 设为 0xB0)
|
||||
- 菜单:实色 cardBg、圆角 12dp、1dp border
|
||||
- 「识别账单」按钮:accent 底 accentFg 字(主操作);「记住此页」:inputBg 底 fgPrimary 字;按压态透明度变化保留(按下时 alpha 0.8)
|
||||
- ScanIconDrawable/PinIconDrawable 颜色:Scan 用 accentFg(在 accent 底按钮上),Pin 用 fgSecondary
|
||||
- 文案 labels.ballOcr / ballRemember / rememberSuccess / rememberFail(带 ': ' + message 拼接)
|
||||
|
||||
- [ ] **Step 2: 位置持久化**
|
||||
|
||||
- SharedPreferences 复用 `billing_accessibility_prefs`:键 `floating_ball_x` / `floating_ball_y`
|
||||
- `show()` 初始化:`params.x/y = prefs.getInt(...)`,无键时用现状默认(0/400);拖动抬起(吸附后)写 prefs
|
||||
- companion 的 lastX/lastY 保留为进程内缓存但初始从 prefs 读(或直接用局部变量+prefs,简化则删 companion——注意多实例语义,BillingAccessibilityService 单例,安全)
|
||||
|
||||
- [ ] **Step 3: 编译验证**(同 T2 Step 5 流程)
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 币种链路 JS 消费侧
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/services/automationPipeline.ts`、`src/app/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: billingConfirmed 处理器用 res.currency**
|
||||
|
||||
读 `src/services/automationPipeline.ts:50-110` 的 handleBillingConfirmed:`const currency = pending?.event?.currency || 'CNY'`(:78)→ 优先 `res.currency`(浮窗用户改过),`res.currency || pending?.event?.currency || 'CNY'`。确认 res 类型定义处加 currency 字段(NativeOpenAppEvent 之类,grep 类型声明一起改)。
|
||||
|
||||
- [ ] **Step 2: billingOpenApp 处理器**
|
||||
|
||||
`src/app/_layout.tsx:255` `const currency = 'CNY'` → `const currency = res.currency || 'CNY'`。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 6: P6 审计 + 验收
|
||||
|
||||
- [ ] **Step 1: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "0xFF5E6AD2\|0x995E6AD2\|0xB05E6AD2\|0x1F5E6AD2\|0x405E6AD2" plugins/accessibility/android/ # 无输出(旧靛蓝清零,默认值除外——FloatingUiConfigStore 默认值允许保留并注释)
|
||||
grep -rn '"[^"]*[一-鿿]' plugins/accessibility/android/*.kt | grep -v "FloatingUiConfigStore\|//" # 除 Store 默认值与注释外无硬编码中文 UI 字符串
|
||||
grep -rn "⚠\|📌\|🌟" src/ plugins/ # 无输出
|
||||
grep -n "currency" src/services/accessibilityBridge.ts # showFloatingBill 含 currency
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量测试 + typecheck + Kotlin 编译**
|
||||
|
||||
Run: `npm test` → 全绿;`npm run typecheck` → 无错误;`cd android && ./gradlew :app:compileDebugKotlin -q` → 成功
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备)**
|
||||
|
||||
Run: `npm run android`
|
||||
1. 设置里切换 浅/深主题 → 触发一次浮窗(可用 automation 页调试入口或真实账单)→ 浮窗颜色跟随主题
|
||||
2. 切换英文 → 浮窗/悬浮球/提示文案全英文
|
||||
3. 拖动悬浮球 → 杀进程重启无障碍服务 → 位置保持
|
||||
4. 浮窗点币种 chip 切换 USD → 确认入账 → 交易币种为 USD
|
||||
5. 金额输入 `1e10` / `0` / `0.005` → 保存按钮禁用;`12.50` → 可用
|
||||
6. 浮窗 30 秒无操作自动消失;触摸后不消失
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 修复 |
|
||||
|---|---|---|---|
|
||||
| C1 | Critical | FloatingBillView 标题 `setTextColor(colorAccentFg)` — 浅色主题下白卡白字、深色主题下黑底黑字,标题不可见 | 改为 `colorFgPrimary` |
|
||||
| I1 | Important | 容器边框从 accent 派生 alpha 而非用 config.border | 删除 `colorContainerBorder`,直接使用 `colorBorder` |
|
||||
| I2 | Important | FloatingHelper btnRemember 背景从 fgPrimary 派生,计划要求 inputBg | 加 `colorInputBg` 解析,btnRemember 改从 inputBg 派生 |
|
||||
| I3 | Important | 悬浮球菜单背景半透明(65% cardBg),计划要求实色 | 菜单背景直接使用 `colorCardBg`(full opacity),仅描边保留半透明 |
|
||||
| M1 | Minor | FloatingTip 背景未强制不透明化(plan §T2 Step 3) | 加 `or 0xFF000000.toInt()` 保护 |
|
||||
| M2 | Minor | 金额校验先 BigDecimal 解析再正则,正则先匹配可早期短路 | 调整为先 `pattern.matches(text)` 再 `value != null && value > BigDecimal.ZERO` |
|
||||
|
||||
**偏差说明(无需修复)**:
|
||||
- FloatingHelper 菜单背景 / 按钮背景 / 指示线的 alpha 分量组合(`(colorX and 0x00FFFFFF) or alpha`)是原生浮层叠加其他 App 所必需的透明度控制,不是颜色硬编码。颜色 RGB 分量完全来自 config。
|
||||
- FloatingTip 默认 fallback `#FF000000` 是纯黑(非靛蓝),因为 accent 在极浅色主题下是 `#111318`(近黑),fallback 用黑色是安全的极限退化。
|
||||
- ScanIconDrawable laserColor `0xFFF87171` 保留为语义色(扫描红光),不受主题控制。
|
||||
@@ -0,0 +1,192 @@
|
||||
# P7:权限快捷引导 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax. **不执行任何 git 操作**(用户要求,改动留工作区)。不创建额外任务清单。
|
||||
|
||||
**Goal:** 引导页权限步骤从纯文字改可操作状态清单;automation 页补齐通知监听/短信/存储三项权限的状态检测与快速跳转按钮。不新增原生权限,只加 UI 入口和一座通知监听状态检测 bridge 方法。
|
||||
|
||||
---
|
||||
|
||||
## 背景决策
|
||||
|
||||
- **不新增权限**。所有权限已在 AndroidManifest 中声明(通过 Config Plugin),P7 只加 UI 入口。
|
||||
- **通知监听状态检测**需要一个原生 bridge 方法(`isNotificationListenerEnabled`),因为 RN 侧无法直接检查。用 `NotificationManager.getEnabledListenerPackages()`。
|
||||
- **短信 + 存储**用 React Native 内置 `PermissionsAndroid` API。
|
||||
- **引导页 UI** 沿用现有 Card + Button 组件,与 P1-P5 设计系统一致。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新增桥接方法 `isNotificationListenerEnabled`
|
||||
|
||||
**Files:**
|
||||
- Modify: `plugins/accessibility/android/AccessibilityBridgeModule.kt`
|
||||
- Modify: `src/services/accessibilityBridge.ts`
|
||||
|
||||
- [ ] **Step 1: AccessibilityBridgeModule.kt 加方法**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun isNotificationListenerEnabled(promise: Promise) {
|
||||
try {
|
||||
val pm = reactContext.packageManager
|
||||
val enabledListeners = android.app.NotificationManager::class.java
|
||||
.getMethod("getEnabledListenerPackages")
|
||||
.invoke(reactContext.getSystemService(Context.NOTIFICATION_SERVICE)) as? List<String>
|
||||
val myPkg = reactContext.packageName
|
||||
promise.resolve(enabledListeners?.any { it.startsWith(myPkg) } == true)
|
||||
} catch (e: Exception) {
|
||||
promise.resolve(false)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**注意**:`getEnabledListenerPackages()` 在 Android 11+ 需 `isNotificationListenerEnabled(ComponentName)`;优先用反射避免 API level lint 错误,反射失败返回 false。
|
||||
|
||||
- [ ] **Step 2: JS 侧 bridge 接口**
|
||||
|
||||
`NativeAccessibilityBridge` 加 `isNotificationListenerEnabled(): Promise<boolean>;`
|
||||
|
||||
- [ ] **Step 3: 编译验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 引导页权限步骤(`_onboarding.tsx` 重写 permissions 步骤)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/_onboarding.tsx`
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||
|
||||
- [ ] **Step 1: i18n 新键(zh/en 对等)**
|
||||
|
||||
在 `onboarding` section 加:
|
||||
```
|
||||
permAccessibility 无障碍服务 / Accessibility Service
|
||||
permNotification 通知监听 / Notification Listener
|
||||
permOverlay 悬浮窗 / Overlay
|
||||
permSms 短信读取 / SMS
|
||||
permStorage 相册/存储 / Photos & Storage
|
||||
permGranted 已授权 / Granted
|
||||
permNotGranted 未授权 / Not Granted
|
||||
permOpenSettings 前往设置 / Open Settings
|
||||
permRequest 请求权限 / Request
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 重写 permissions 步骤**
|
||||
|
||||
替换 `_onboarding.tsx:48` 的 `key: 'permissions'` 步骤内容。在当前 description 下方加权限状态清单(Card 包裹,每行:图标 + 名称 + 状态点 + 操作按钮)。
|
||||
|
||||
权限列表:
|
||||
1. **无障碍服务**(`BIND_ACCESSIBILITY_SERVICE`)—— 最核心。检测 `serviceRunning`(从 `getAccessibilityBridge().isServiceRunning()` 异步取)。未授权 → 跳转 `ACCESSIBILITY_SETTINGS`
|
||||
2. **通知监听**(`BIND_NOTIFICATION_LISTENER_SERVICE`)—— 检测 `bridge.isNotificationListenerEnabled()`。未授权 → 跳转 `ACTION_NOTIFICATION_LISTENER_SETTINGS`
|
||||
3. **短信**(`RECEIVE_SMS`)—— `PermissionsAndroid.check()`。未授权 → `PermissionsAndroid.request()`
|
||||
4. **存储**(`READ_MEDIA_IMAGES`,Android 13+ 用 `READ_MEDIA_IMAGES`,否则 `READ_EXTERNAL_STORAGE`)—— 同上
|
||||
|
||||
**实现细节**:
|
||||
- `const [permStates, setPermStates] = useState<Record<string, boolean | null>>({})` —— null = 加载中
|
||||
- `useEffect(() => { checkAllPermissions(); }, [])` —— 步骤切到 permissions 时触发
|
||||
- `checkAllPermissions`:调 `PermissionsAndroid.check()` 检查短信和存储;调 bridge 检查无障碍和通知监听
|
||||
- 每行渲染:`Ionicons` 图标 + `permLabel` + 右侧状态文字(绿「已授权」/ 灰「未授权」)+ 未授权时显示操作按钮
|
||||
- 「全部已授权」时显示一个大绿色勾 + 提示文字,按钮不显示
|
||||
|
||||
**权限 API 版本适配**:
|
||||
- `PermissionsAndroid.PERMISSIONS` 里 Android 13+ 是 `READ_MEDIA_IMAGES`,低版本用 `READ_EXTERNAL_STORAGE`
|
||||
- 统一用 `Platform.Version >= 33 ? 'android.permission.READ_MEDIA_IMAGES' : 'android.permission.READ_EXTERNAL_STORAGE'`
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -rn "onboarding.perm" src/i18n/zh.ts src/i18n/en.ts` → 确认键对等
|
||||
|
||||
---
|
||||
|
||||
### Task 3: automation 页补齐权限入口
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/automation/index.tsx`
|
||||
- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts`(少量新键)
|
||||
|
||||
- [ ] **Step 1: i18n 新键**
|
||||
|
||||
在 `automation` section 加:
|
||||
```
|
||||
notificationTitle 通知监听服务 / Notification Listener
|
||||
notificationEnabled 已启用 / Enabled
|
||||
notificationDisabled 未启用 / Disabled
|
||||
notificationOpenSettings 前往系统设置 / Open Settings
|
||||
smsPermissionTitle 短信读取权限 / SMS Permission
|
||||
smsPermissionGranted 已授权 / Granted
|
||||
smsPermissionRequest 请求权限 / Request
|
||||
storagePermissionTitle 存储权限 / Storage Permission
|
||||
storagePermissionGranted 已授权 / Granted
|
||||
storagePermissionRequest 请求权限 / Request
|
||||
```
|
||||
|
||||
- [ ] **Step 2: automation 页加权限卡片**
|
||||
|
||||
在现有「无障碍服务状态与控制」Card **之后**加一个新 Card:`<Card title={t('automation.otherPermissions')}>`。
|
||||
|
||||
内容三行:
|
||||
1. **通知监听**:异步 `bridge.isNotificationListenerEnabled()` → 状态文字 + 未启用时「前往系统设置」按钮(`Linking.sendIntent('android.settings.ACTION_NOTIFICATION_LISTENER_SETTINGS')`)
|
||||
2. **短信**:`PermissionsAndroid.check('android.permission.RECEIVE_SMS')` + `PermissionsAndroid.request()`
|
||||
3. **存储**:`PermissionsAndroid.check(StoragePermission)` + `PermissionsAndroid.request()`
|
||||
|
||||
**实现模式**:
|
||||
```tsx
|
||||
const [notifEnabled, setNotifEnabled] = useState(false);
|
||||
const [smsGranted, setSmsGranted] = useState(false);
|
||||
const [storageGranted, setStorageGranted] = useState(false);
|
||||
useEffect(() => {
|
||||
// 异步获取各权限状态
|
||||
checkNotif(); checkSms(); checkStorage();
|
||||
}, []);
|
||||
```
|
||||
|
||||
每行渲染:`Ionicons` 图标 + 权限名称 + 状态文字 + 未授权时的按钮。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 编译 + 测试 + 终审
|
||||
|
||||
- [ ] **Step 1: 编译验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: 全量测试**
|
||||
|
||||
`npm test` → 全绿;`npm run typecheck` → 0 错误
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出
|
||||
grep -rnE "📊|💰|💸|📝|🔄|🎉|✨|⚠|📌|🌟" src/i18n/ src/app/_onboarding.tsx src/app/automation/index.tsx # 无输出(预存 i18n fallbackWarn ⚠ 除外)
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手工走查(需设备)**
|
||||
|
||||
1. 清数据 → 启动应用 → 引导页第 5 步(权限)→ 看到 4 个权限状态
|
||||
2. 点未授权的无障碍 → 跳系统无障碍设置 → 返回看到状态已变
|
||||
3. 点未授权的通知监听 → 跳系统通知设置
|
||||
4. 点短信 → 弹出系统权限对话框
|
||||
5. 引导页全部完成后 → 进入 automation 页 → 看到新增的权限卡片
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**终审日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 处理 |
|
||||
|---|---|---|---|
|
||||
| — | — | 无 Critical / Important 发现 | — |
|
||||
|
||||
**偏差说明**:
|
||||
1. `onboarding.permOverlay`(悬浮窗)未加入引导页——计划 Task 2 决策列为可选项,实际未实现。不影响核心功能(sideload 自动授予 + 无障碍运行时浮窗走 ACCESSIBILITY_OVERLAY 不依赖此权限)。
|
||||
2. T3 中 `Platform.Version >= 33` 改为 `Number(Platform.Version) >= 33`——React Native 的 `Platform.Version` 类型为 `string | number`,直接比较会 TS 报错。
|
||||
3. T1 的 `isNotificationListenerEnabled` 使用反射而非 `enabledListenerPackages` 属性——minSdk=24 < API 27,反射是兼容方案。
|
||||
@@ -0,0 +1,338 @@
|
||||
# P8:自动记账可配置化 + 模型按需下载 Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git 操作**。不创建额外任务清单。
|
||||
|
||||
**Goal:** automation 页统一管控所有自动记账能力(规则/OCR/AI Vision 三层独立开关 + 模型下载管理 + AI 配置整合),`settings/ai.tsx` 删除并重定向;OCR 模型从 APK assets 改为应用内下载(filesystem 路径加载 ONNX)。
|
||||
|
||||
**背景决策**:
|
||||
- 三层开关默认值:L1 规则 `true`、L2 OCR `true`、L3 AI Vision `false`(需用户配 API Key 后才可开启)
|
||||
- 模型下载:首次启用 L2 时检查模型是否存在,无则弹出下载提示
|
||||
- `settings/ai.tsx` 删除,原入口重定向到 automation 页
|
||||
- 去重/转账识别两个 setting 字段不动(它们不是「层」而是管道的独立步骤),但在 automation 页加开关
|
||||
- 模型从 assets 复制到 filesystem:预构建时仍打 APK assets 以兼容旧用户,应用首次启动将模型文件从 assets 拷贝到 documentDirectory,OcrModule 从 filesystem 加载。**这样之后卸载 assets 打包就变成了纯删除**(未来版本切到纯下载只需移除 assets + app.plugin.js 禁拷贝)。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: settingsStore 新增字段 + 迁移
|
||||
|
||||
**Files:** `src/store/settingsStore.ts`
|
||||
|
||||
- [ ] **Step 1: 接口 + DEFAULTS + 持久化 + setter**
|
||||
|
||||
在 `SettingsState` 接口加 5 个新字段:
|
||||
```typescript
|
||||
/** 自动记账 L1:正则规则匹配 */
|
||||
layer1RuleEnabled: boolean;
|
||||
/** 自动记账 L2:本地 OCR 识别 */
|
||||
layer2OcrEnabled: boolean;
|
||||
/** 自动记账 L3:云端 AI Vision */
|
||||
layer3AiEnabled: boolean;
|
||||
/** OCR 模型版本(已下载),空字符串表示未安装 */
|
||||
ocrModelVersion: string;
|
||||
/** OCR 模型下载路径 */
|
||||
ocrModelDir: string;
|
||||
```
|
||||
|
||||
DEFAULTS:
|
||||
- `layer1RuleEnabled: true`
|
||||
- `layer2OcrEnabled: true`
|
||||
- `layer3AiEnabled: false`
|
||||
- `ocrModelVersion: ''`
|
||||
- `ocrModelDir: ''`
|
||||
|
||||
`PersistableSettings` 加对应 5 字段;`toPersistable` 加 5 行;新增 5 个 setter 函数。现有 `aiEnabled` 保留作为 LLM 的总开关语义(`layer3AiEnabled` 是在 automation 页独立控制,「启用」需同时满足 `aiEnabled && aiApiKey`)。
|
||||
|
||||
**注意**:`isNotificationListenerEnabled` 这类 bridge 方法在 `setFloatingBallEnabled` 附近——新字段与它们无关,不加 bridge 方法。
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 2: OcrProcessor 逐级回退逻辑
|
||||
|
||||
**Files:** `src/domain/ocrProcessor.ts`、`src/services/ocrBridge.ts`、`src/services/automationPipeline.ts`
|
||||
|
||||
- [ ] **Step 1: OcrProcessorConfig 扩展**
|
||||
|
||||
`OcrProcessorConfig` 接口加:
|
||||
```typescript
|
||||
layer1Enabled: boolean; // 默认 true
|
||||
layer2Enabled: boolean; // 默认 true
|
||||
layer3Enabled: boolean; // 默认 false
|
||||
```
|
||||
|
||||
- [ ] **Step 2: doProcess() 逐级跳过**
|
||||
|
||||
在 `OcrProcessor.doProcess()` 中修改流程(约 :103-162):
|
||||
|
||||
```
|
||||
// 1. 横屏 / 图片去重 守卫不变
|
||||
|
||||
// 2. L1+L2 需要 OCR 文本
|
||||
if (config.layer1Enabled || config.layer2Enabled) {
|
||||
if (!ocrEngine) 返回 none(引擎不可用)
|
||||
ocrText = await ocrEngine.recognizeText(imageBase64)
|
||||
if (!ocrText) → 如果 L3 启用则 goto L3,否则返回 none
|
||||
} else {
|
||||
ocrText = ''
|
||||
}
|
||||
|
||||
// 3. Layer 1(仅当启用)
|
||||
if (config.layer1Enabled && ocrText && !isDetailPage) {
|
||||
result = matchOcrRule(ocrText, packageName)
|
||||
if (result) return { layer: 'layer1-rule', ... }
|
||||
}
|
||||
|
||||
// 4. Layer 2(仅当启用)
|
||||
if (config.layer2Enabled && ocrText) {
|
||||
result = parseOcrBill(ocrText, packageName)
|
||||
if (result) return { layer: 'layer2-ocr', ... }
|
||||
}
|
||||
|
||||
// 5. Layer 3(仅当启用)
|
||||
if (config.layer3Enabled) {
|
||||
return tryLayer3(imageBase64, ocrText)
|
||||
}
|
||||
|
||||
// 6. 全部未命中或全部关闭
|
||||
return { event: null, layer: 'none', skipped: 'non-bill' }
|
||||
```
|
||||
|
||||
- [ ] **Step 3: automationPipeline.ts 的 getOcrProcessor 改读新字段**
|
||||
|
||||
`getOcrProcessor()` 约 :116-147 行:从 `settingsStore` 读取 `layer1RuleEnabled` / `layer2OcrEnabled` / `layer3AiEnabled` 传入 `OcrProcessorConfig`。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 3: 模型文件从 assets 拷贝到 filesystem + OcrModule 改路径
|
||||
|
||||
**Files:** `plugins/ppocr/android/OcrModule.kt`、`plugins/ppocr/app.plugin.js`、`plugins/accessibility/android/AccessibilityBridgeModule.kt`(新 bridge 方法)
|
||||
|
||||
- [ ] **Step 1: AccessibilityBridgeModule 加 copyModelFromAssets 方法**
|
||||
|
||||
```kotlin
|
||||
@ReactMethod
|
||||
fun copyOcrModelsFromAssets(promise: Promise) {
|
||||
try {
|
||||
val assetManager = reactContext.assets
|
||||
val destDir = java.io.File(reactContext.filesDir, "ocr_models_v6")
|
||||
if (!destDir.exists()) destDir.mkdirs()
|
||||
|
||||
val files = listOf("ppocrv6_det.onnx", "ppocrv6_rec.onnx", "ppocrv6_dict.txt")
|
||||
for (filename in files) {
|
||||
val destFile = java.io.File(destDir, filename)
|
||||
if (destFile.exists()) continue // skip existing
|
||||
val input = assetManager.open(filename)
|
||||
val output = java.io.FileOutputStream(destFile)
|
||||
val buffer = ByteArray(8192)
|
||||
var bytesRead: Int
|
||||
while (input.read(buffer).also { bytesRead = it } != -1) {
|
||||
output.write(buffer, 0, bytesRead)
|
||||
}
|
||||
input.close()
|
||||
output.close()
|
||||
}
|
||||
promise.resolve(destDir.absolutePath)
|
||||
} catch (e: Exception) {
|
||||
promise.reject("COPY_MODEL_FAIL", e.message)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: OcrModule.kt 改为 filesystem 路径加载**
|
||||
|
||||
读 `OcrModule.kt` 中 `createSession` 调用的位置,改从传入路径加载而非 asset 文件名。当前构造可能接受 asset 路径——改为接收 filesystem 绝对路径。新增一个 `@ReactMethod setModelDir(dir: String)` 存储路径,或直接在 `initialize(assetManager: ...)` 的参数中改为 `initialize(modelDir: String, ...)`。
|
||||
|
||||
**注意**:ONNX Runtime Android 的 `OrtEnvironment.createSession(filePath)` 接受文件系统路径(String),不需要 asset 特殊处理——只需确认 `createSession` 的参数类支持文件路径,否则改用 `OrtEnvironment.createSession(byte[] modelBytes)` 从内存加载。**最终方案**:保持 `OrtEnvironment.createSession(filePath)` ,将 `modelDir + "/ppocrv6_det.onnx"` 作为绝对路径传入。
|
||||
|
||||
- [ ] **Step 3: JS 侧 bridge 接口 + modelDownloader 服务**
|
||||
|
||||
`NativeAccessibilityBridge` 加 `copyOcrModelsFromAssets(): Promise<string>` → 返回模型目录绝对路径。
|
||||
|
||||
新建 `src/services/modelDownloader.ts`:
|
||||
```typescript
|
||||
export async function ensureOcrModels(): Promise<string> {
|
||||
// 1. 如果 settingsStore.ocrModelDir 有效且文件存在 → 直接返回
|
||||
// 2. 尝试从 assets 拷贝到 documentDirectory/ocr_models_v6
|
||||
// 3. 返回 modelsDir 绝对路径,存储到 settingsStore.ocrModelDir + ocrModelVersion
|
||||
}
|
||||
```
|
||||
|
||||
`ocrModelVersion` 在从 assets 拷贝成功后写 `'v6-assets'`(区分下载版本和打包版本)。
|
||||
|
||||
- [ ] **Step 4: automationPipeline.ts 的 OcrProcessor 从 filesystem 初始化**
|
||||
|
||||
`getNativeOcrBridge()`或 `getOcrProcessor()` 中,在初始化 OCR 引擎前调用 `ensureOcrModels()` 获取模型路径,传入 `NativeOcrBridge` 构造(或 OcrModule initialize)。
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
---
|
||||
|
||||
### Task 4: automation 页 UI —— 三层开关 + 模型管理 + AI 配置整合
|
||||
|
||||
**Files:** `src/app/automation/index.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: i18n 新键(zh/en 对等)**
|
||||
|
||||
在 `automation` section 加:
|
||||
```
|
||||
autoBookkeeping 自动记账层级 / Auto Bookkeeping Layers
|
||||
layer1Rule 规则匹配 / Rule Matching
|
||||
layer1RuleDesc 基于正则规则的快速账单识别 / Fast regex-based bill recognition
|
||||
layer2Ocr OCR 识别 / OCR Recognition
|
||||
layer2OcrDesc 本地 PP-OCRv6 模型识别 / On-device PP-OCRv6 model
|
||||
layer3Ai AI 视觉识别 / AI Vision
|
||||
layer3AiDesc 云端多模态大模型识别 / Cloud multimodal LLM
|
||||
layer3AiDisabled 请先配置 AI Key / Configure AI Key first
|
||||
ocrModelTitle OCR 模型 / OCR Model
|
||||
ocrModelNotInstalled 未安装 / Not Installed
|
||||
ocrModelInstalled 已安装 ({version}) / Installed ({version})
|
||||
ocrModelInstall 安装模型 / Install Model
|
||||
ocrModelInstalling 安装中... / Installing...
|
||||
ocrModelCopying 正在复制模型文件... / Copying model files...
|
||||
dedupSwitch 多通道联合去重 / Cross-channel Dedup
|
||||
transferSwitch 转账智能识别 / Transfer Recognition
|
||||
```
|
||||
|
||||
- [ ] **Step 2: automation 页 UI 重组织**
|
||||
|
||||
在现有截图监控按钮 Card **之后**加以下新 Card 区块:
|
||||
|
||||
**Card A:「自动记账层级」**
|
||||
- 三行 Switch:L1 规则匹配(always enabled,不依赖外部条件)
|
||||
- L2 OCR 识别(Switch;开启时检查模型→无模型则弹下载提示 Dialog)
|
||||
- L3 AI 视觉(Switch;如果 aiEnabled=false 或 aiApiKey 为空则 disabled+提示文字;开启后展开 Provider/Key/Model 输入)
|
||||
|
||||
**Card B:「OCR 模型」**
|
||||
- 状态行:版本号(未安装显示灰色「未安装」;已安装显示绿色「已安装 (v6-assets)」)
|
||||
- 操作按钮:安装/重新安装(从 assets 拷贝 → 进度状态 → 完成)
|
||||
|
||||
**Card C:「管道设置」**(复用现有 Card B 和 C 部分)
|
||||
- `dedupEnabled` Switch
|
||||
- `transferRecognitionEnabled` Switch
|
||||
|
||||
- [ ] **Step 3: 操作逻辑**
|
||||
|
||||
```typescript
|
||||
// 模型安装
|
||||
const handleInstallModel = async () => {
|
||||
setModelStatus('installing');
|
||||
try {
|
||||
const bridge = getAccessibilityBridge();
|
||||
if (!bridge) throw new Error('Bridge unavailable');
|
||||
const modelDir = await bridge.copyOcrModelsFromAssets();
|
||||
setOcrModelVersion('v6-assets');
|
||||
setOcrModelDir(modelDir);
|
||||
setModelStatus('done');
|
||||
} catch (e) {
|
||||
setModelStatus('error');
|
||||
Alert.alert('安装失败', String(e));
|
||||
}
|
||||
};
|
||||
|
||||
// L2 开关变化时检查模型
|
||||
const handleL2Toggle = (val: boolean) => {
|
||||
setLayer2OcrEnabled(val);
|
||||
if (val && !ocrModelVersion) {
|
||||
Alert.alert(t('automation.ocrModelTitle'), t('automation.ocrModelNotInstalled'), [
|
||||
{ text: t('common.cancel'), onPress: () => setLayer2OcrEnabled(false) },
|
||||
{ text: t('automation.ocrModelInstall'), onPress: handleInstallModel },
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
// L3 开关变化时检查 AI Key
|
||||
const handleL3Toggle = (val: boolean) => {
|
||||
if (val && (!aiEnabled || !aiApiKey)) {
|
||||
Alert.alert(t('automation.layer3Ai'), t('automation.layer3AiDisabled'));
|
||||
return;
|
||||
}
|
||||
setLayer3AiEnabled(val);
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 4: AI 配置内联**
|
||||
|
||||
L3 启用时展开的子区域:
|
||||
- `aiProviderId`:三选一 chip(openai/gemini/deepseek)
|
||||
- `aiApiKey`:输入框(secureTextEntry)
|
||||
- `aiBaseUrl`:输入框(默认值 placeholder)
|
||||
- `aiModel`:输入框(默认值 placeholder)
|
||||
- `aiEnabled`:Switch(总开关,控制 L3 的实际可用性)
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 5: AI 设置页清理 + settings 导航更新
|
||||
|
||||
**Files:** `src/app/settings/ai.tsx`(重写为跳转)、`src/app/(tabs)/settings.tsx`(更新入口文案)
|
||||
|
||||
- [ ] **Step 1: settings/ai.tsx 改为 redirect**
|
||||
|
||||
不删除文件(保留路由),但内容改为:`useEffect(() => { router.replace('/automation'); }, [])` 跳转到 automation 页,期间显示 loading。
|
||||
|
||||
- [ ] **Step 2: settings.tsx 入口文案**
|
||||
|
||||
`src/app/(tabs)/settings.tsx` 中「智能记账 & AI」入口的文案保持不变,但实际点击跳转已由 expo-router 自动处理(ai.tsx → redirect → automation),不需要改路由注册。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
`npm run typecheck` → 0 错误;`npm test` → 全绿
|
||||
`grep -rn "settings/ai" src/app/(tabs)/settings.tsx` → 确认无需改动即可跳转
|
||||
|
||||
---
|
||||
|
||||
### Task 6: 编译 + 测试 + 终审
|
||||
|
||||
- [ ] **Step 1: Kotlin 编译**
|
||||
|
||||
`cd android && ./gradlew :app:compileDebugKotlin` → BUILD SUCCESSFUL
|
||||
|
||||
- [ ] **Step 2: 全量测试**
|
||||
|
||||
`npm test` → 全绿;`npm run typecheck` → 0 错误
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/app/automation/index.tsx # 无输出
|
||||
grep -rn "📊|💰|💸|📝" src/app/automation/ # 无输出
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 手工走查清单**
|
||||
|
||||
1. settings 页点「智能记账」→ 跳转到 automation 页
|
||||
2. automation 页看到三个层级开关(L1 默认开 / L2 默认开 / L3 默认关灰)
|
||||
3. 关闭 L1 → 仍可记账(走 L2 OCR)
|
||||
4. 关闭 L2 → L1 失败不再调用 OCR
|
||||
5. 开启 L3 → 提示配 AI Key → 配置后可用
|
||||
6. OCR 模型未安装 → 点安装 → 从 assets 拷贝 → 显示绿色已安装
|
||||
7. L2 开关关闭再打开 → 已有模型直接可用,不再提示
|
||||
8. AI Provider 切换 openai/gemini/deepseek → 对应的 baseUrl 默认值变化
|
||||
|
||||
---
|
||||
|
||||
## 终审修订(已实施,以实际代码为准)
|
||||
|
||||
**日期**:2026-07-22,审计 598 测试全绿、typecheck 干净、Kotlin BUILD SUCCESSFUL。
|
||||
|
||||
| # | 级别 | 问题 | 处理 |
|
||||
|---|---|---|---|
|
||||
| — | — | 无 Critical / Important 发现 | — |
|
||||
|
||||
**偏差说明**:
|
||||
1. 管道设置 Card 复用现有 `settings.dedupLabel`/`settings.transferLabel` i18n 键(已在 settings/ai.tsx 中使用),未新键 `dedupSwitch`/`transferSwitch`——避免重复键。
|
||||
2. OcrModule.kt 从 assets 加载路径改为 `modelDir` 字段 + `setModelDir` 方法——保留原 assets 加载作为兜底(modelDir 为空时走原路径),保证向后兼容。
|
||||
3. `ensureOcrModels()` 在 `_layout.tsx` ready 前调用一次(异步非阻塞),`getOcrProcessor()` 中也传 `ocrModelDir` 作为安全网。
|
||||
Reference in New Issue
Block a user