diff --git a/design-system/beancount-mobile/MASTER.md b/design-system/beancount-mobile/MASTER.md index a72519b..2ede5d1 100644 --- a/design-system/beancount-mobile/MASTER.md +++ b/design-system/beancount-mobile/MASTER.md @@ -1,206 +1,72 @@ -# Design System Master File +# Beancount Mobile 设计系统(MASTER) -> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. -> If that file exists, its rules **override** this Master file. -> If not, strictly follow the rules below. +> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。 +> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。 ---- +## 设计方向:明亮 Bento 现代风 -**Project:** beancount-mobile -**Generated:** 2026-07-16 16:32:57 -**Category:** Personal Finance Tracker +浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。 +品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。 ---- +## 色板(= presets.ts) -## Global Rules +### 浅色(默认) +| Token | 值 | 用途 | +|---|---|---| +| bgPrimary | `#F6F7F9` | 页面背景 | +| bgSecondary | `#FFFFFF` | 卡片 | +| bgTertiary | `#EFF1F4` | 输入框 / chip | +| fgPrimary | `#111318` | 主文字 | +| fgSecondary | `#6B7280` | 次要文字 | +| fgInverse | `#FFFFFF` | 反色文字 | +| accent | `#111318` | 按钮 / 选中态(近黑) | +| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 | +| accentDark | `#000000` | 按压态 | +| financial.income | `#10B981` | 收入 | +| financial.expense | `#EF4444` | 支出 | +| financial.transfer | `#3B82F6` | 转账 | +| border | `#E5E7EB` | 边框 | +| overlay | `rgba(17,19,24,0.4)` | 遮罩 | -### Color Palette +### 暗色(OLED) +| Token | 值 | +|---|---| +| bgPrimary | `#040508` | +| bgSecondary | `#101218` | +| bgTertiary | `#1A1D24` | +| fgPrimary / accent | `#F3F4F6` | +| fgSecondary | `#9CA3AF` | +| fgInverse | `#111318` | +| financial.income | `#34D399`(提亮) | +| financial.expense | `#F87171`(提亮) | +| financial.transfer | `#60A5FA`(提亮) | +| border | `rgba(255,255,255,0.08)` | -| Role | Hex | CSS Variable | -|------|-----|--------------| -| Primary | `#1E40AF` | `--color-primary` | -| On Primary | `#FFFFFF` | `--color-on-primary` | -| Secondary | `#3B82F6` | `--color-secondary` | -| Accent/CTA | `#059669` | `--color-accent` | -| Background | `#0F172A` | `--color-background` | -| Foreground | `#FFFFFF` | `--color-foreground` | -| Muted | `#101A34` | `--color-muted` | -| Border | `rgba(255,255,255,0.08)` | `--color-border` | -| Destructive | `#DC2626` | `--color-destructive` | -| Ring | `#1E40AF` | `--color-ring` | +分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色)。 +其余 token(divider / skeleton / progressBg / success / warning / error / info)见 `presets.ts`。 +全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。 -**Color Notes:** Trust blue + profit green on dark +## 字体 -### Typography +系统字体(iOS SF / Android Roboto),不加载自定义字体。 +金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。 -- **Heading Font:** Caveat -- **Body Font:** Quicksand -- **Mood:** handwritten, personal, friendly, casual, warm, charming -- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap) +字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。 -**CSS Import:** -```css -@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap'); -``` +## 圆角 / 间距 / 阴影 -### Spacing Variables +- 圆角:sm 8 · md 12 · lg 16 · **xl 24(弹窗默认;卡片组件 P2 统一升级)** · full +- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32 +- 阴影:浅色 4–12px 弥散轻阴影;暗色阴影减重,辅以半透边框 -| Token | Value | Usage | -|-------|-------|-------| -| `--space-xs` | `4px` / `0.25rem` | Tight gaps | -| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing | -| `--space-md` | `16px` / `1rem` | Standard padding | -| `--space-lg` | `24px` / `1.5rem` | Section padding | -| `--space-xl` | `32px` / `2rem` | Large gaps | -| `--space-2xl` | `48px` / `3rem` | Section margins | -| `--space-3xl` | `64px` / `4rem` | Hero padding | +## 图标 -### Shadow Depths +统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标; +分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。 -| Level | Value | Usage | -|-------|-------|-------| -| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift | -| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons | -| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns | -| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards | +## 反模式 ---- - -## Component Specs - -### Buttons - -```css -/* Primary Button */ -.btn-primary { - background: #059669; - color: white; - padding: 12px 24px; - border-radius: 8px; - font-weight: 600; - transition: all 200ms ease; - cursor: pointer; -} - -.btn-primary:hover { - opacity: 0.9; - transform: translateY(-1px); -} - -/* Secondary Button */ -.btn-secondary { - background: transparent; - color: #1E40AF; - border: 2px solid #1E40AF; - padding: 12px 24px; - border-radius: 8px; - font-weight: 600; - transition: all 200ms ease; - cursor: pointer; -} -``` - -### Cards - -```css -.card { - background: #0F172A; - border-radius: 12px; - padding: 24px; - box-shadow: var(--shadow-md); - transition: all 200ms ease; - cursor: pointer; -} - -.card:hover { - box-shadow: var(--shadow-lg); - transform: translateY(-2px); -} -``` - -### Inputs - -```css -.input { - padding: 12px 16px; - border: 1px solid #E2E8F0; - border-radius: 8px; - font-size: 16px; - transition: border-color 200ms ease; -} - -.input:focus { - border-color: #1E40AF; - outline: none; - box-shadow: 0 0 0 3px #1E40AF20; -} -``` - -### Modals - -```css -.modal-overlay { - background: rgba(0, 0, 0, 0.5); - backdrop-filter: blur(4px); -} - -.modal { - background: white; - border-radius: 16px; - padding: 32px; - box-shadow: var(--shadow-xl); - max-width: 500px; - width: 90%; -} -``` - ---- - -## Style Guidelines - -**Style:** Dark Mode (OLED) - -**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient - -**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light - -**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus - -### Page Pattern - -**Pattern Name:** Interactive Product Demo - -- **CTA Placement:** Above fold -- **Section Order:** Hero > Features > CTA - ---- - -## Anti-Patterns (Do NOT Use) - -- ❌ Pure white backgrounds - -### Additional Forbidden Patterns - -- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons) -- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer -- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout -- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio -- ❌ **Instant state changes** — Always use transitions (150-300ms) -- ❌ **Invisible focus states** — Focus states must be visible for a11y - ---- - -## Pre-Delivery Checklist - -Before delivering any UI code, verify: - -- [ ] No emojis used as icons (use SVG instead) -- [ ] All icons from consistent icon set (Heroicons/Lucide) -- [ ] `cursor-pointer` on all clickable elements -- [ ] Hover states with smooth transitions (150-300ms) -- [ ] Light mode: text contrast 4.5:1 minimum -- [ ] Focus states visible for keyboard navigation -- [ ] `prefers-reduced-motion` respected -- [ ] Responsive: 375px, 768px, 1024px, 1440px -- [ ] No content hidden behind fixed navbars -- [ ] No horizontal scroll on mobile +- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token +- 禁止低对比文字(< 4.5:1) +- 禁止瞬间状态变化,交互反馈 150–300ms +- 禁止绕过 commonStyles 重复造 input/chip/modal 样式 diff --git a/docs/ui-redesign-design.md b/docs/ui-redesign-design.md new file mode 100644 index 0000000..47b0ced --- /dev/null +++ b/docs/ui-redesign-design.md @@ -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/存储/同步层;平板/桌面布局适配。 diff --git a/docs/ui-redesign-p1-plan.md b/docs/ui-redesign-p1-plan.md new file mode 100644 index 0000000..1127e38 --- /dev/null +++ b/docs/ui-redesign-p1-plan.md @@ -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 = { + 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 = { + 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 管理页模板化 + 清零审计** + +每阶段完成后基于实际代码编写下一阶段计划。 diff --git a/docs/ui-redesign-p2-plan.md b/docs/ui-redesign-p2-plan.md new file mode 100644 index 0000000..d21af55 --- /dev/null +++ b/docs/ui-redesign-p2-plan.md @@ -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['name']; + +export const CATEGORY_ICON_NAMES: Record = { + 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 ( + + + + ); +} + +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 中 `{icon}` 替换为 ``。 +- 删除 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 ( + + + e.stopPropagation()} + > + + {title ? ( + {title} + ) : null} + {children} + + + + ); +} + +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 ( + <> + + + {value || placeholder || 'YYYY-MM-DD'} + + + setOpen(false)}> + {/* 月份导航 */} + + shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月"> + + + + {viewYear}-{String(viewMonth).padStart(2, '0')} + + shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月"> + + + + {/* 星期头 */} + + {WEEKDAYS.map(w => ( + {w} + ))} + + {/* 日期网格 */} + {grid.map((week, wi) => ( + + {week.map(cell => { + const selected = cell.date === value; + return ( + pick(cell.date)} + style={[styles.dayCell, { + backgroundColor: selected ? theme.colors.accent : 'transparent', + borderRadius: theme.radii.full, + }]} + > + + {Number(cell.date.slice(8, 10))} + + + ); + })} + + ))} + + + ); +} + +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 ( + + router.back())} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={backLabel} + > + + + + {title} + + {right ?? } + + ); +} + +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 ( + + {label} + + {value} + + {caption ? ( + {caption} + ) : null} + + ); +} + +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 = { + 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 ( + + + {label} + + ); + }; + + return ( + + {state.routes.slice(0, 2).map(renderTab)} + + router.push('/transaction/new')} + style={[styles.plus, { + backgroundColor: theme.colors.accent, + borderColor: theme.colors.bgPrimary, + }, theme.shadows.lg]} + accessibilityRole="button" + accessibilityLabel="记一笔" + > + + + + {state.routes.slice(2).map(renderTab)} + + ); +} + +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) => }`。 +- 删除 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`;在 `` 之后、`` 之前插入 `{children}`。 +- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `` 替换为 ``。 +- 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 { + 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, 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(props: ManagementScreenProps) { + const { theme } = useTheme(); + const t = useT(); + const [editing, setEditing] = useState(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) => { + if (props.onSubmit(values, editing)) setModalOpen(false); + }; + + return ( + + openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}> + + + } + /> + + {props.headerContent} + {props.items.length === 0 && props.emptyText ? ( + {props.emptyText} + ) : null} + {props.items.map(item => ( + + {props.renderItem(item, { + openEdit: () => openForm(item), + confirmDelete: () => confirmDelete(item), + })} + + ))} + {props.footer} + + setModalOpen(false)} + > + {props.formExtra?.(editing)} + + + ); +} + +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(TAG_COLORS[0]); + + return ( + + 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 }) => ( + + #{tag.name} + + )} + formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))} + formFields={editing => [ + { key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name }, + ]} + formExtra={() => ( + + {TAG_COLORS.map(c => ( + setSelectedColor(c)} + style={[styles.colorDot, { + backgroundColor: c, + borderWidth: selectedColor === c ? 3 : 0, + borderColor: theme.colors.fgPrimary, + }]} + /> + ))} + + )} + 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={ + + {t('common.clickEditLongDelete')} + + } + /> + ); +} + +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 页为已验证模板)+ 清零审计 diff --git a/docs/ui-redesign-p3-plan.md b/docs/ui-redesign-p3-plan.md new file mode 100644 index 0000000..85ac646 --- /dev/null +++ b/docs/ui-redesign-p3-plan.md @@ -0,0 +1,1474 @@ +# UI 重设计 P3:录入闭环(NumpadSheet)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:** 用金额优先的数字键盘面板(NumpadSheet)重写录入闭环:双腿账户 chip 选择、+/- 连续计算、全局+唤起(灰度开关)、transaction/new 整页重写为面板宿主、SpeedDial 退役。 + +**Architecture:** 纯逻辑(键盘表达式、postings→双腿解析)抽成可测模块;NumpadSheet 是唯一录入 UI,同时服务整页路由(/transaction/new,含编辑/草稿/OCR 预填)与全局 Modal(NumpadSheetHost 挂根布局);落账仍走 `buildAndSaveTransaction()`/`editTransaction`,domain 写路径零改动。 + +**Tech Stack:** React Native + Expo Router + Zustand + Ionicons + Vitest。 + +**Spec:** `docs/ui-redesign-design.md` §4(NumpadSheet)、§5(导航)。**与 spec 的偏差**:分录预览行常驻显示,不做设置开关(YAGNI);灰度开关只控制「+ 开全局面板 vs 跳整页」,默认 **false**(P5 稳定后翻默认)。 + +**前置状态:** P1/P2 已完成(547 测试全绿;BottomSheet/DatePickerField/CategoryIcon/CategoryPicker 已可用)。 + +--- + +### Task 1: 纯逻辑失败测试 + +**Files:** +- Create: `tests/numpad.test.ts` + +- [ ] **Step 1: 新建 tests/numpad.test.ts** + +```typescript +import { describe, expect, it } from 'vitest'; +import { pressKey, evaluateExpression } from '../src/components/numpadExpression'; +import { parsePostingsToLegs } from '../src/domain/postingLegs'; +import { buildPostings } from '../src/domain/transactionBuilder'; + +describe('pressKey 表达式编辑', () => { + it('数字键追加', () => { + expect(pressKey('', '5')).toBe('5'); + expect(pressKey('5', '3')).toBe('53'); + }); + + it('前导零被替换', () => { + expect(pressKey('0', '5')).toBe('5'); + }); + + it('小数点:空段补 0,同段不重复', () => { + expect(pressKey('', '.')).toBe('0.'); + expect(pressKey('5', '.')).toBe('5.'); + expect(pressKey('5.5', '.')).toBe('5.5'); + }); + + it('小数最多两位', () => { + expect(pressKey('5.55', '5')).toBe('5.55'); + expect(pressKey('5.5', '5')).toBe('5.55'); + }); + + it('整数位最多 9 位', () => { + expect(pressKey('123456789', '0')).toBe('123456789'); + expect(pressKey('12345678', '9')).toBe('123456789'); + }); + + it('运算符:数字后可加,连续运算符替换,空表达式不可加', () => { + expect(pressKey('5', '+')).toBe('5+'); + expect(pressKey('5+', '-')).toBe('5-'); + expect(pressKey('', '+')).toBe(''); + expect(pressKey('', '-')).toBe(''); + }); + + it('运算符后开启新数字段(小数规则独立)', () => { + expect(pressKey('5.5+', '.')).toBe('5.5+0.'); + expect(pressKey('5.55+3', '.')).toBe('5.55+3.'); + }); + + it('backspace 删除末字符', () => { + expect(pressKey('5.5', 'backspace')).toBe('5.'); + expect(pressKey('5', 'backspace')).toBe(''); + expect(pressKey('', 'backspace')).toBe(''); + }); +}); + +describe('evaluateExpression 求值', () => { + it('单值', () => { + expect(evaluateExpression('35')).toBe('35'); + }); + + it('加减混合(字符串十进制,无浮点误差)', () => { + expect(evaluateExpression('20+15')).toBe('35'); + expect(evaluateExpression('20+15-5.5')).toBe('29.5'); + expect(evaluateExpression('0.1+0.2')).toBe('0.3'); + }); + + it('尾随运算符被忽略', () => { + expect(evaluateExpression('20+')).toBe('20'); + expect(evaluateExpression('20+15-')).toBe('35'); + }); + + it('空表达式返回空串', () => { + expect(evaluateExpression('')).toBe(''); + }); +}); + +describe('parsePostingsToLegs 双腿解析', () => { + it('与 buildPostings 往返一致:支出', () => { + const legs = parsePostingsToLegs(buildPostings('expense', 'Assets:招行', 'Expenses:餐饮', '35', 'CNY')); + expect(legs).toEqual({ + direction: 'expense', + sourceAccount: 'Assets:招行', + targetAccount: 'Expenses:餐饮', + amount: '35', + currency: 'CNY', + }); + }); + + it('与 buildPostings 往返一致:收入', () => { + const legs = parsePostingsToLegs(buildPostings('income', 'Assets:招行', 'Income:工资', '12000', 'CNY')); + expect(legs).toEqual({ + direction: 'income', + sourceAccount: 'Assets:招行', + targetAccount: 'Income:工资', + amount: '12000', + currency: 'CNY', + }); + }); + + it('与 buildPostings 往返一致:转账', () => { + const legs = parsePostingsToLegs(buildPostings('transfer', 'Assets:招行', 'Liabilities:花呗', '2000', 'CNY')); + expect(legs).toEqual({ + direction: 'transfer', + sourceAccount: 'Assets:招行', + targetAccount: 'Liabilities:花呗', + amount: '2000', + currency: 'CNY', + }); + }); + + it('金额取绝对值(去掉负号)', () => { + const legs = parsePostingsToLegs([ + { account: 'Assets:招行', amount: '-35', currency: 'CNY' }, + { account: 'Expenses:餐饮', amount: '35', currency: 'CNY' }, + ]); + expect(legs?.amount).toBe('35'); + }); + + it('无法识别时返回 null', () => { + expect(parsePostingsToLegs([])).toBeNull(); + expect(parsePostingsToLegs([{ account: 'Assets:招行', amount: '35', currency: 'CNY' }])).toBeNull(); + }); +}); +``` + +- [ ] **Step 2: 运行确认失败** + +Run: `npx vitest run tests/numpad.test.ts` +Expected: FAIL —— `Cannot find module '../src/components/numpadExpression'`。**若意外通过,报告 BLOCKED。** + +--- + +### Task 2: numpadExpression + postingLegs 纯逻辑 + +**Files:** +- Create: `src/components/numpadExpression.ts` +- Create: `src/domain/postingLegs.ts` +- Modify: `src/domain/index.ts`(barrel 加一行导出) + +- [ ] **Step 1: 创建 src/components/numpadExpression.ts** + +```typescript +/** + * 数字键盘表达式编辑与求值(+/- 连续记账,如 20+15 直接出 35)。 + * 金额运算走 domain/decimal 的字符串十进制,无浮点误差。 + */ +import { addDecimals, subtractDecimals } from '../domain/decimal'; + +export type NumpadKey = + | '0' | '1' | '2' | '3' | '4' | '5' | '6' | '7' | '8' | '9' + | '.' | '+' | '-' | 'backspace'; + +const MAX_INT_DIGITS = 9; +const MAX_DECIMAL_DIGITS = 2; + +/** 处理一次按键,返回新表达式字符串。 */ +export function pressKey(expr: string, key: NumpadKey): string { + if (key === 'backspace') return expr.slice(0, -1); + + if (key === '+' || key === '-') { + if (expr === '') return ''; // 金额恒正,不允许运算符开头 + const last = expr[expr.length - 1]; + if (last === '+' || last === '-') return expr.slice(0, -1) + key; // 替换运算符 + return expr + key; + } + + // 当前数字段(最后一个运算符之后) + const seg = expr.split(/[+-]/).pop() ?? ''; + + if (key === '.') { + if (seg.includes('.')) return expr; + if (seg === '') return expr + '0.'; + return expr + '.'; + } + + // 数字键 + if (seg.includes('.')) { + const decimals = seg.split('.')[1] ?? ''; + if (decimals.length >= MAX_DECIMAL_DIGITS) return expr; + } else if (seg === '0') { + return expr.slice(0, -1) + key; // 前导零替换 + } + const intPart = seg.split('.')[0]; + if (intPart.length >= MAX_INT_DIGITS) return expr; + return expr + key; +} + +/** 求值:忽略尾随运算符;空表达式返回空串。 */ +export function evaluateExpression(expr: string): string { + const cleaned = expr.replace(/[+-]$/, ''); + if (!cleaned) return ''; + // 按符号切分:'20+15-5.5' → ['20', '+15', '-5.5'] + const parts = cleaned.split(/(?=[+-])/).filter(Boolean); + let sum = '0'; + for (const part of parts) { + if (part.startsWith('-')) sum = subtractDecimals(sum, part.slice(1)); + else if (part.startsWith('+')) sum = addDecimals([sum, part.slice(1)]); + else sum = addDecimals([sum, part]); + } + return sum; +} +``` + +- [ ] **Step 2: 创建 src/domain/postingLegs.ts** + +```typescript +/** + * postings → 「双腿」解析(NumpadSheet 编辑/草稿预填用)。 + * 是 buildPostings 的逆操作:从一组分录推断方向、资金账户、目标账户与绝对值金额。 + */ +import type { Posting } from './types'; + +export interface TransactionLegs { + direction: 'expense' | 'income' | 'transfer'; + /** 资金腿(Assets/Liabilities);转账时为转出方。 */ + sourceAccount: string; + /** 分类账户(Expenses/Income)或转账的目标资金账户。 */ + targetAccount: string; + /** 绝对值金额(无负号)。 */ + amount: string; + currency: string; +} + +function isAssetLike(account: string): boolean { + return account.startsWith('Assets') || account.startsWith('Liabilities'); +} + +function abs(amount: string | undefined): string { + return (amount ?? '').replace(/^-/, ''); +} + +/** 从分录推断双腿;无法识别(少于 2 条或无资金腿)时返回 null。 */ +export function parsePostingsToLegs(postings: Posting[]): TransactionLegs | null { + if (postings.length < 2) return null; + const expensePosting = postings.find(p => p.account.startsWith('Expenses')); + const incomePosting = postings.find(p => p.account.startsWith('Income')); + const assetPosting = postings.find(p => isAssetLike(p.account)); + + if (expensePosting) { + return { + direction: 'expense', + sourceAccount: assetPosting?.account ?? '', + targetAccount: expensePosting.account, + amount: abs(expensePosting.amount), + currency: expensePosting.currency ?? 'CNY', + }; + } + if (incomePosting) { + return { + direction: 'income', + sourceAccount: assetPosting?.account ?? '', + targetAccount: incomePosting.account, + amount: abs(incomePosting.amount), + currency: incomePosting.currency ?? 'CNY', + }; + } + // 转账:两条资金腿,第一条为转出 + if (isAssetLike(postings[0].account) && isAssetLike(postings[1].account)) { + return { + direction: 'transfer', + sourceAccount: postings[0].account, + targetAccount: postings[1].account, + amount: abs(postings[0].amount), + currency: postings[0].currency ?? 'CNY', + }; + } + return null; +} +``` + +- [ ] **Step 3: domain/index.ts 加 barrel 导出** + +在 `src/domain/index.ts` 中追加(保持字母序附近的合适位置): + +```typescript +export * from './postingLegs'; +``` + +- [ ] **Step 4: 验证** + +Run: `npx vitest run tests/numpad.test.ts tests/transactionBuilder.test.ts` → Expected: 全部 PASS +Run: `npm run typecheck` → Expected: 无错误 + +> **评审修订(已实施,以实际代码为准):** 代码评审后已修复并补回归测试——①`evaluateExpression` 剥段尾 `.`(`5.`/`5.+3` 不再崩溃);②`pressKey` 整数 9 位上限仅对无小数段生效;③`parsePostingsToLegs` 对 `postings.length !== 2` 一律返回 null(拆分交易走高级模式);④识别腿缺金额回退对侧资金腿;⑤转账按负号腿判定转出方;⑥currency 兜底顺序:识别腿 → 对侧腿 → CNY。 + +--- + +### Task 3: settingsStore 新字段 + 偏好开关 + i18n + +**Files:** +- Modify: `src/store/settingsStore.ts` +- Modify: `src/app/settings/preferences.tsx` +- Modify: `src/i18n/zh.ts`、`src/i18n/en.ts` + +- [ ] **Step 1: settingsStore.ts 加 4 个字段(完全镜像 floatingBallEnabled 的模式)** + +- State 接口加: + +```typescript + /** +按钮是否打开全局记账面板(P3 灰度,默认 false;P5 稳定后翻默认)。 */ + numpadGlobalEntry: boolean; + setNumpadGlobalEntry: (enabled: boolean) => void; + /** 上次记账的资金账户(NumpadSheet 默认选中)。 */ + lastUsedAccount: string | null; + /** 上次使用的分类 id。 */ + lastUsedCategoryId: string | null; + setLastUsedEntry: (account: string | null, categoryId: string | null) => void; +``` + +- DEFAULTS 加:`numpadGlobalEntry: false, lastUsedAccount: null, lastUsedCategoryId: null,` +- 持久化选择器(pick PersistableSettings 处,参照 `floatingBallEnabled: state.floatingBallEnabled,`)加: + +```typescript + numpadGlobalEntry: state.numpadGlobalEntry, + lastUsedAccount: state.lastUsedAccount, + lastUsedCategoryId: state.lastUsedCategoryId, +``` + +- 实现加: + +```typescript + setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }), + setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }), +``` + +注意:`PersistableSettings` 类型若显式列字段,需同步加这 4 个字段(lastUsed* 为 string | null)。 + +- [ ] **Step 2: preferences.tsx 加「记账」Card(放在「安全设置」Card 之前)** + +```tsx + {/* 记账 */} + + + + {t('settings.numpadEntry')} + + + + + {t('settings.numpadEntryDesc')} + + +``` + +组件顶部加 store 订阅: + +```typescript + const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry); + const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry); +``` + +- [ ] **Step 3: i18n 加键(zh.ts 与 en.ts 必须同步加,i18n.test.ts 校验键位对等)** + +zh.ts 加: + +```typescript + 'settings.entryTitle': '记账', + 'settings.numpadEntry': '全局记账面板', + 'settings.numpadEntryDesc': '开启后,底部 + 在任意页面直接弹出记账面板(灰度功能)', + 'numpad.from': '从', + 'numpad.to': '到', + 'numpad.outFrom': '转出', + 'numpad.transferTo': '转入', + 'numpad.swap': '互换', + 'numpad.today': '今天', + 'numpad.done': '完成', + 'numpad.more': '更多', + 'numpad.less': '收起', + 'numpad.allAccounts': '全部账户', + 'numpad.allCategories': '全部', + 'numpad.advanced': '高级模式', + 'numpad.noAccount': '去创建账户', +``` + +en.ts 加对应英文: + +```typescript + 'settings.entryTitle': 'Entry', + 'settings.numpadEntry': 'Global entry panel', + 'settings.numpadEntryDesc': 'When enabled, the + button opens the entry panel on any screen (beta)', + 'numpad.from': 'From', + 'numpad.to': 'To', + 'numpad.outFrom': 'From', + 'numpad.transferTo': 'To', + 'numpad.swap': 'Swap', + 'numpad.today': 'Today', + 'numpad.done': 'Done', + 'numpad.more': 'More', + 'numpad.less': 'Less', + 'numpad.allAccounts': 'All accounts', + 'numpad.allCategories': 'All', + 'numpad.advanced': 'Advanced mode', + 'numpad.noAccount': 'Create account', +``` + +- [ ] **Step 4: 验证** + +Run: `npx vitest run tests/i18n.test.ts tests/stores.test.ts tests/automationStore.test.ts` → Expected: PASS +Run: `npm run typecheck` → Expected: 无错误 + +--- + +### Task 4: NumpadKeyboard + useOcrScan + numpadUiStore + +**Files:** +- Create: `src/components/NumpadKeyboard.tsx` +- Create: `src/components/useOcrScan.ts` +- Create: `src/store/numpadUiStore.ts` + +- [ ] **Step 1: 创建 src/components/NumpadKeyboard.tsx** + +```tsx +import React from 'react'; +import { Pressable, StyleSheet, Text, View } from 'react-native'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme } from '../theme'; + +/** 键值:数字/小数点/加减走 numpadExpression;today/done 由宿主处理。 */ +export type KeyboardKey = string; + +interface NumpadKeyboardProps { + onKey: (key: KeyboardKey) => void; + todayLabel: string; + doneLabel: string; +} + +const ROWS: KeyboardKey[][] = [ + ['1', '2', '3', 'today'], + ['4', '5', '6', '+'], + ['7', '8', '9', '-'], + ['.', '0', 'backspace', 'done'], +]; + +/** 内嵌数字键盘(4×4):金额优先,+/- 连续计算,「今天」快速设日期。 */ +export function NumpadKeyboard({ onKey, todayLabel, doneLabel }: NumpadKeyboardProps) { + const { theme } = useTheme(); + + const renderKey = (key: KeyboardKey) => { + if (key === 'done') { + return ( + onKey(key)} + style={[styles.key, styles.doneKey, { backgroundColor: theme.colors.accent, borderRadius: theme.radii.lg }]} + accessibilityRole="button" + accessibilityLabel={doneLabel} + > + {doneLabel} + + ); + } + if (key === 'backspace') { + return ( + onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel="backspace"> + + + ); + } + if (key === 'today') { + return ( + onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={todayLabel}> + {todayLabel} + + ); + } + if (key === '+' || key === '-') { + return ( + onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}> + {key === '+' ? '+' : '-'} + + ); + } + return ( + onKey(key)} style={styles.key} accessibilityRole="button" accessibilityLabel={key}> + {key} + + ); + }; + + return ( + + {ROWS.map((row, i) => ( + + {row.map(renderKey)} + + ))} + + ); +} + +const styles = StyleSheet.create({ + grid: { gap: 2 }, + row: { flexDirection: 'row' }, + key: { flex: 1, alignItems: 'center', justifyContent: 'center', paddingVertical: 14 }, + digitText: { fontSize: 22, fontWeight: '600', fontVariant: ['tabular-nums'] }, + opText: { fontSize: 16, fontWeight: '600' }, + doneKey: { margin: 4 }, + doneText: { fontSize: 15, fontWeight: '700' }, +}); +``` + +- [ ] **Step 2: 创建 src/components/useOcrScan.ts** + +```typescript +/** + * OCR 拍照识账 hook(从旧 transaction/new.tsx 提取,逻辑不变)。 + * 返回 scan():选图 → OcrProcessor 三级管线 → 解析出账单事件(或 null)。 + */ +import { useState } from 'react'; +import { Alert } from 'react-native'; +import * as ImagePicker from 'expo-image-picker'; +import { getNativeOcrModule, getNativeOcrBridge } from '../services/ocrBridge'; +import { OcrProcessor } from '../domain/ocrProcessor'; +import { useT } from '../i18n'; + +export type OcrEvent = NonNullable>['event']>; + +let processor: OcrProcessor | null = null; + +export function useOcrScan() { + const t = useT(); + const [status, setStatus] = useState(''); + + const scan = async (): Promise => { + try { + const result = await ImagePicker.launchImageLibraryAsync({ + mediaTypes: ImagePicker.MediaTypeOptions.Images, + allowsEditing: false, + quality: 0.8, + base64: true, + }); + if (result.canceled || !result.assets?.[0]?.base64) return null; + + const nativeModule = getNativeOcrModule(); + if (nativeModule && !(await nativeModule.isReady())) { + Alert.alert(t('ocr.notReady')); + return null; + } + + setStatus(t('ocr.scanning')); + if (!processor) processor = new OcrProcessor(getNativeOcrBridge()); + const ocrResult = await processor.process(result.assets[0].base64); + setStatus(ocrResult.event ? t('ocr.success') : t('ocr.failed')); + return ocrResult.event; + } catch (e) { + setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e))); + return null; + } + }; + + return { scan, status }; +} +``` + +- [ ] **Step 3: 创建 src/store/numpadUiStore.ts** + +```typescript +/** + * 全局记账面板 UI 状态(+按钮唤起 NumpadSheet 全局弹层)。 + */ +import { create } from 'zustand'; + +export interface NumpadPrefill { + editId?: string; + draftJson?: string; + autoOcr?: boolean; +} + +interface NumpadUiState { + visible: boolean; + prefill: NumpadPrefill | null; + open: (prefill?: NumpadPrefill) => void; + close: () => void; +} + +export const useNumpadUiStore = create((set) => ({ + visible: false, + prefill: null, + open: (prefill) => set({ visible: true, prefill: prefill ?? null }), + close: () => set({ visible: false, prefill: null }), +})); +``` + +- [ ] **Step 4: 验证** + +Run: `npm run typecheck` → Expected: 无错误(若 OcrEvent 类型推导报错,读 src/domain/ocrProcessor.ts 确认 process 返回类型后如实报告,不要改 domain) + +--- + +### Task 5: NumpadSheet 主组件 + +**Files:** +- Create: `src/components/NumpadSheet.tsx` + +- [ ] **Step 1: 创建 src/components/NumpadSheet.tsx** + +完整代码(此为 P3 核心,逐字实现): + +```tsx +/** + * NumpadSheet 记一笔面板(P3 录入闭环核心)。 + * + * 金额优先:方向 chip → 大金额 → 账户 chip 行(双腿之一)→ 分类快捷行(另一腿)→ 数字键盘。 + * 「更多」抽屉:日期/摘要/收款方/标签/高级模式(PostingEditor)。 + * 同时服务两种载体: + * - presentation="page":/transaction/new 整页宿主(含 editId/draftJson 预填、未保存守卫) + * - presentation="modal":全局弹层(NumpadSheetHost,+按钮唤起) + * 落账走 buildAndSaveTransaction()/editTransaction,与旧表单完全一致。 + */ +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import { + Alert, KeyboardAvoidingView, Platform, Pressable, ScrollView, + StyleSheet, Switch, Text, TextInput, View, +} from 'react-native'; +import { useNavigation, useRouter } from 'expo-router'; +import { Ionicons } from '@expo/vector-icons'; +import { useTheme, createCommonStyles } from '../theme'; +import { useT } from '../i18n'; +import { useLedgerStore } from '../store/ledgerStore'; +import { useMetadataStore } from '../store/metadataStore'; +import { useSettingsStore } from '../store/settingsStore'; +import { TAG_COLORS } from '../theme/palette'; +import { BottomSheet } from './BottomSheet'; +import { Button } from './Button'; +import { CategoryIcon } from './CategoryIcon'; +import { CategoryPicker } from './CategoryPicker'; +import { DatePickerField } from './DatePickerField'; +import { NumpadKeyboard } from './NumpadKeyboard'; +import { PostingEditor } from './PostingEditor'; +import { TagPicker } from './TagPicker'; +import { pressKey, evaluateExpression, type NumpadKey } from './numpadExpression'; +import { useOcrScan } from './useOcrScan'; +import { buildAndSaveTransaction, buildPostings } from '../domain/transactionBuilder'; +import { parsePostingsToLegs } from '../domain/postingLegs'; +import { serializeTransaction } from '../domain/ledger'; +import { addDecimals, compareDecimals, toDateString } from '../domain/decimal'; +import { matchCategory } from '../domain/categories'; +import type { Category } from '../domain/categories'; +import type { Tag } from '../domain/tags'; +import type { Posting } from '../domain/types'; + +type Direction = 'expense' | 'income' | 'transfer'; + +interface NumpadSheetProps { + presentation: 'page' | 'modal'; + editId?: string; + draftJson?: string; + autoOcr?: boolean; + onClose?: () => void; +} + +export function NumpadSheet({ presentation, editId, draftJson, autoOcr, onClose }: NumpadSheetProps) { + const { theme } = useTheme(); + const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); + const t = useT(); + const router = useRouter(); + const navigation = useNavigation(); + + const ledger = useLedgerStore(s => s.ledger); + const editTransaction = useLedgerStore(s => s.editTransaction); + const categories = useMetadataStore(s => s.categories); + const tags = useMetadataStore(s => s.tags); + const lastUsedAccount = useSettingsStore(s => s.lastUsedAccount); + const lastUsedCategoryId = useSettingsStore(s => s.lastUsedCategoryId); + const setLastUsedEntry = useSettingsStore(s => s.setLastUsedEntry); + + const [direction, setDirection] = useState('expense'); + const [expr, setExpr] = useState(''); + const [currency] = useState('CNY'); + const [date, setDate] = useState(() => toDateString(new Date())); + const [narration, setNarration] = useState(''); + const [payee, setPayee] = useState(''); + const [sourceAccount, setSourceAccount] = useState(''); + const [targetAccount, setTargetAccount] = useState(''); + const [selectedCategory, setSelectedCategory] = useState(null); + const [selectedTags, setSelectedTags] = useState([]); + const [showMore, setShowMore] = useState(false); + const [isAdvanced, setIsAdvanced] = useState(false); + const [postings, setPostings] = useState([]); + const [status, setStatus] = useState(''); + const [submitting, setSubmitting] = useState(false); + const [isSubmitted, setIsSubmitted] = useState(false); + const [accountPicker, setAccountPicker] = useState<'source' | 'target' | null>(null); + const [categoryPickerOpen, setCategoryPickerOpen] = useState(false); + + // 编辑模式保留字段 + const [oldRaw, setOldRaw] = useState(''); + const [editFlag, setEditFlag] = useState(undefined); + const [editLinks, setEditLinks] = useState(undefined); + const [editMetadata, setEditMetadata] = useState | undefined>(undefined); + + const amount = useMemo(() => evaluateExpression(expr), [expr]); + const { scan, status: ocrStatus } = useOcrScan(); + + // ===== 账户列表(双腿之一的资金账户:Assets + Liabilities) ===== + const assetAccounts = useMemo(() => { + if (!ledger) return [] as string[]; + return Array.from(ledger.accounts.keys()).filter( + a => a.startsWith('Assets') || a.startsWith('Liabilities'), + ); + }, [ledger]); + + // chip 行:上次使用优先,最多 4 个 + 「⋯」 + const chipAccounts = useMemo(() => { + const rest = assetAccounts.filter(a => a !== lastUsedAccount); + const pinned = lastUsedAccount && assetAccounts.includes(lastUsedAccount) ? [lastUsedAccount] : []; + return [...pinned, ...rest].slice(0, 4); + }, [assetAccounts, lastUsedAccount]); + + // 默认资金账户:上次使用 > 列表第一个 + useEffect(() => { + if (sourceAccount) return; + const def = lastUsedAccount && assetAccounts.includes(lastUsedAccount) + ? lastUsedAccount + : assetAccounts[0]; + if (def) setSourceAccount(def); + }, [assetAccounts, lastUsedAccount, sourceAccount]); + + // ===== 分类快捷行 ===== + const availableCategories = useMemo( + () => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')), + [categories, direction], + ); + const quickCategories = useMemo(() => { + const pinned = availableCategories.find(c => c.id === lastUsedCategoryId); + const rest = availableCategories.filter(c => c.id !== lastUsedCategoryId); + return [...(pinned ? [pinned] : []), ...rest].slice(0, 3); + }, [availableCategories, lastUsedCategoryId]); + + // ===== 预填:draftJson(草稿) ===== + useEffect(() => { + if (!draftJson) return; + try { + const draft = JSON.parse(draftJson); + if (draft.date) setDate(String(draft.date).slice(0, 10)); + setNarration(draft.narration || ''); + setPayee(draft.payee || ''); + const legs = parsePostingsToLegs(draft.postings ?? []); + if (legs) { + setDirection(legs.direction); + setExpr(legs.amount); + setSourceAccount(legs.sourceAccount); + setTargetAccount(legs.targetAccount); + if (legs.direction !== 'transfer') { + const matched = categories.find(c => c.linkedAccount === legs.targetAccount); + if (matched) setSelectedCategory(matched); + } + } + if (Array.isArray(draft.postings) && draft.postings.length > 0) { + setPostings(draft.postings.map((p: any) => ({ + account: p.account, amount: p.amount, currency: p.currency, + cost: p.cost, price: p.price, metadata: p.metadata, + }))); + // 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿 + if (!legs) setIsAdvanced(true); + } + } catch (e) { + console.error('Failed to parse draftJson', e); + } + }, [draftJson, categories]); + + // ===== 预填:editId(编辑模式) ===== + const editingTx = useMemo( + () => (editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined), + [editId, ledger], + ); + useEffect(() => { + if (!editingTx) return; + setOldRaw(editingTx.raw); + setDate(editingTx.date.slice(0, 10)); + setNarration(editingTx.narration || ''); + setPayee(editingTx.payee || ''); + setEditFlag(editingTx.flag || '*'); + setEditLinks(editingTx.links || []); + setEditMetadata(editingTx.metadata); + const legs = parsePostingsToLegs(editingTx.postings); + if (legs) { + setDirection(legs.direction); + setExpr(legs.amount); + setSourceAccount(legs.sourceAccount); + setTargetAccount(legs.targetAccount); + if (legs.direction !== 'transfer') { + const matched = categories.find(c => c.linkedAccount === legs.targetAccount); + if (matched) setSelectedCategory(matched); + } + } + setPostings(editingTx.postings.map(p => ({ + account: p.account, amount: p.amount, currency: p.currency, + cost: p.cost, price: p.price, metadata: p.metadata, + }))); + // 无法双腿解析(拆分交易等)→ 直接进高级模式,避免丢腿 + if (!legs && editingTx.postings.length > 0) setIsAdvanced(true); + if (editingTx.tags.length > 0) { + setSelectedTags(editingTx.tags.map(name => { + const found = tags.find(tg => tg.name === name); + return found ?? { id: name, name, color: TAG_COLORS[3] }; + })); + } + }, [editingTx, categories, tags]); + + // ===== OCR ===== + const applyOcrEvent = (event: import('./useOcrScan').OcrEvent) => { + if (event.amount) setExpr(event.amount.replace(/^-/, '')); + if (event.direction === 'income' || event.direction === 'transfer') setDirection(event.direction); + else setDirection('expense'); + const text = [event.counterparty, event.memo].filter(Boolean).join(' - '); + if (text) setNarration(text); + const matchText = [event.counterparty, event.memo].filter(Boolean).join(' '); + if (matchText) { + const type = event.direction === 'income' ? 'income' : 'expense'; + const match = matchCategory(matchText, categories.filter(c => c.type === type)); + if (match.category) setSelectedCategory(match.category); + } + }; + const handleOcr = async () => { + const event = await scan(); + if (event) applyOcrEvent(event); + }; + useEffect(() => { + if (autoOcr) handleOcr(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [autoOcr]); + + // ===== 未保存守卫(仅整页模式) ===== + const formRef = useRef({ expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted }); + formRef.current = { expr, narration, payee, selectedTags, isAdvanced, postings, isSubmitted }; + useEffect(() => { + if (presentation !== 'page') return; + const unsubscribe = navigation.addListener('beforeRemove', (e) => { + const s = formRef.current; + const dirty = + evaluateExpression(s.expr) !== '' || + s.narration.trim() !== '' || + s.payee.trim() !== '' || + s.selectedTags.length > 0 || + (s.isAdvanced && s.postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== '')); + if (!dirty || s.isSubmitted) return; + e.preventDefault(); + Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [ + { text: t('common.cancel'), style: 'cancel', onPress: () => {} }, + { text: t('common.discard'), style: 'destructive', onPress: () => navigation.dispatch(e.data.action) }, + ]); + }); + return unsubscribe; + }, [navigation, presentation, t]); + + // ===== 键盘 ===== + const handleKey = (key: string) => { + if (key === 'done') { submit(); return; } + if (key === 'today') { setDate(toDateString(new Date())); return; } + setExpr(prev => pressKey(prev, key as NumpadKey)); + }; + + const swapAccounts = () => { + const s = sourceAccount; + setSourceAccount(targetAccount); + setTargetAccount(s); + }; + + const closeSoon = () => { + if (presentation === 'modal') onClose?.(); + else setTimeout(() => router.back(), 600); + }; + + // ===== 提交 ===== + const submit = async () => { + if (submitting) return; + setSubmitting(true); + const tagNames = selectedTags.map(tg => tg.name); + try { + if (isAdvanced) { + // ===== 高级模式:多行分录 ===== + const validPostings = postings.filter(p => p.account.trim()); + if (validPostings.length < 2) { setStatus(t('transaction.atLeast2Postings')); return; } + const currencySum: Record = {}; + for (const p of validPostings) { + if (p.amount && p.currency) { + (currencySum[p.currency] = currencySum[p.currency] || []).push(p.amount); + } + } + const balanced = Object.values(currencySum).every(amounts => compareDecimals(addDecimals(amounts), '0') === 0); + if (!balanced) { setStatus(t('transaction.unbalanced')); return; } + const draft = { + date, + flag: editId ? editFlag : undefined, + narration: narration.trim() || t('transaction.defaultAdvancedNarration'), + postings: validPostings, + tags: tagNames, + links: editId && editLinks && editLinks.length > 0 ? editLinks : undefined, + metadata: editId ? editMetadata : undefined, + }; + if (editId && oldRaw) { + await editTransaction(oldRaw, serializeTransaction(draft)); + setStatus(t('transaction.editSuccess')); + } else { + await useLedgerStore.getState().addTransaction(draft); + setStatus(t('transaction.appended')); + } + setIsSubmitted(true); + closeSoon(); + return; + } + + // ===== 简单模式:双腿 ===== + const amt = amount; + if (!amt || compareDecimals(amt, '0') <= 0) { setStatus(t('transaction.amountRequired')); return; } + if (direction === 'transfer' && sourceAccount === targetAccount) { + setStatus(t('transaction.sameAccountError')); + return; + } + const categoryAccount = direction === 'transfer' + ? targetAccount + : selectedCategory?.linkedAccount ?? (direction === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'); + const defaultNarration = + direction === 'expense' ? t('transaction.narrationDefaultExpense') : + direction === 'income' ? t('transaction.narrationDefaultIncome') : + t('transaction.narrationDefaultTransfer'); + + if (editId && oldRaw) { + const draft = { + date, + payee: payee.trim() || undefined, + flag: editFlag, + narration: narration.trim() || defaultNarration, + postings: buildPostings(direction, sourceAccount, categoryAccount, amt, currency), + tags: tagNames, + links: editLinks && editLinks.length > 0 ? editLinks : undefined, + metadata: editMetadata, + }; + await editTransaction(oldRaw, serializeTransaction(draft)); + setStatus(t('transaction.editSuccess')); + setIsSubmitted(true); + closeSoon(); + } else { + await buildAndSaveTransaction({ + date, + amount: amt, + payee: payee.trim() || undefined, + narration: narration.trim() || defaultNarration, + categoryAccount, + sourceAccount, + direction, + currency, + tags: tagNames, + }, useLedgerStore.getState()); + setLastUsedEntry(sourceAccount || null, selectedCategory?.id ?? null); + setStatus(t('transaction.appended')); + setIsSubmitted(true); + setExpr(''); + setNarration(''); + setSelectedCategory(null); + setSelectedTags([]); + closeSoon(); + } + } catch (e) { + setStatus(String(e instanceof Error ? e.message : e)); + } finally { + setSubmitting(false); + } + }; + + // ===== 高级模式切换时初始化分录 ===== + const toggleAdvanced = (val: boolean) => { + setIsAdvanced(val); + if (val) { + const amt = amount; + const categoryAccount = direction === 'transfer' + ? targetAccount + : selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized'; + setPostings(buildPostings(direction, sourceAccount, categoryAccount, amt || '0', currency) + .map(p => ({ ...p, amount: p.amount === '-0' || p.amount === '0' ? '' : p.amount }))); + } + }; + + const directions: { key: Direction; label: string }[] = [ + { key: 'expense', label: t('transaction.directionExpense') }, + { key: 'income', label: t('transaction.directionIncome') }, + { key: 'transfer', label: t('transaction.directionTransfer') }, + ]; + + const shortName = (acct: string) => acct.split(':').pop() || acct; + const previewTarget = direction === 'transfer' + ? targetAccount + : selectedCategory?.linkedAccount ?? ''; + + const renderAccountRow = (label: string, value: string, onSelect: (a: string) => void, pickerKey: 'source' | 'target') => ( + + {label} + + {assetAccounts.length === 0 ? ( + router.push('/account')} style={commonStyles.chip}> + {t('numpad.noAccount')} + + ) : ( + <> + {chipAccounts.map(acct => { + const active = value === acct; + return ( + onSelect(acct)} + style={[commonStyles.chip, active && commonStyles.chipActive]} + > + {shortName(acct)} + + ); + })} + setAccountPicker(pickerKey)} style={commonStyles.chip}> + + + + )} + + + ); + + return ( + + + + {/* 工具行 */} + + {presentation === 'modal' ? ( + + + + ) : null} + + + + + {presentation === 'page' ? ( + router.push('/import')} hitSlop={8} accessibilityRole="button" accessibilityLabel={t('tab.import')} style={{ marginLeft: 16 }}> + + + ) : null} + + + {/* 方向 */} + + {directions.map(d => ( + { setDirection(d.key); setSelectedCategory(null); }} + style={[commonStyles.chip, direction === d.key && commonStyles.chipActive, styles.directionChip]} + > + {d.label} + + ))} + + + {/* 金额 */} + + ¥ {amount || '0'} + + + {/* 账户行(双腿之一) */} + {direction === 'transfer' ? ( + <> + {renderAccountRow(t('numpad.outFrom'), sourceAccount, setSourceAccount, 'source')} + + + + {renderAccountRow(t('numpad.transferTo'), targetAccount, setTargetAccount, 'target')} + + ) : ( + renderAccountRow(t('numpad.from'), sourceAccount, setSourceAccount, 'source') + )} + + {/* 分类快捷行(另一腿,非转账) */} + {direction !== 'transfer' && ( + + {quickCategories.map(cat => { + const active = selectedCategory?.id === cat.id; + return ( + setSelectedCategory(active ? null : cat)} + style={[styles.categoryCell, { + backgroundColor: active ? theme.colors.accentLight : theme.colors.bgTertiary, + borderColor: active ? theme.colors.accent : 'transparent', + borderRadius: theme.radii.md, + }]} + > + + {cat.name} + + ); + })} + setCategoryPickerOpen(true)} + style={[styles.categoryCell, { backgroundColor: theme.colors.bgTertiary, borderColor: 'transparent', borderRadius: theme.radii.md }]} + > + + {t('numpad.allCategories')} + + + )} + + {/* 数字键盘(简单模式) */} + {!isAdvanced && ( + + )} + + {/* 分录预览(常驻小字) */} + {!isAdvanced && amount && sourceAccount && previewTarget ? ( + + {shortName(sourceAccount)} → {shortName(previewTarget)} · ¥{amount} · {date} + + ) : null} + + {/* 更多抽屉 */} + setShowMore(v => !v)} style={styles.moreToggle} accessibilityRole="button"> + + {showMore ? t('numpad.less') : t('numpad.more')} + + + + + {showMore && ( + + {t('transaction.formDate')} + + + {t('transaction.formNarration')} + + + {t('transaction.formPayee')} + + + {t('transaction.formTags')} + tg.name)} + onToggle={(tag) => setSelectedTags(prev => + prev.some(x => x.name === tag.name) ? prev.filter(x => x.name !== tag.name) : [...prev, tag], + )} + /> + + {/* 高级模式 */} + + + {t('numpad.advanced')} + + + + + )} + + {/* 高级模式分录编辑器 */} + {isAdvanced && ( + + + + +