feat: UI 全面重设计(Bento 明亮风)+ 记一笔 NumpadSheet + 管理页模板 + OCR v5→v6 升级

UI 重设计(P1-P5):
- 主题从靛蓝 accent 切换为近黑白单色(#111318),品牌感靠排版与财务语义色
- 暗色模式适配 OLED 纯黑,财务色整体提亮一档保证对比度
- 圆角体系新增 xl(24),卡片/弹窗统一大圆角
- 移除 Caveat/Quicksand 自定义字体,切换为系统字体
- 新增 display(34/800) 字阶用于净资产等大数字展示
- 分类/标签/渠道颜色统一到 palette.ts 色板(12 色循环 + FNV 哈希)
记一笔 NumpadSheet(P3 录入闭环):
- 全新计算器风格录入面板:方向 chip → 大金额 → 账户 chip → 分类快捷行 → 数字键盘
- 支持表达式输入(20+15-5.5)与字符串十进制求值,避免浮点精度问题
- 高级模式保留多行分录编辑器,简单模式自动推断双腿
- postingLegs.ts 实现 buildPostings 的逆操作(编辑/草稿预填)
- 未保存守卫:整页 beforeRemove + modal 脏状态上报
- 全局弹层(NumpadSheetHost)由+按钮唤起,可通过设置开关
Tab 栏重构:
- AppTabBar 替换默认 tab bar,中央+按钮唤起全局记账面板
- Tab 从 5 个精简为 4 个(首页/交易/报表/设置),+按钮独立
管理页模板 ManagementScreen:
- 统一「列表 + 新增 + 点按编辑 + 长按删除 + FormModal」模式
- 预算/分类/标签/信用卡等管理页消除手写样板
其他:
- OCR 模型从 PP-OCRv5 升级到 v6(det/rec/dict 全部替换)
- GBK 解码器 import 修复(text-encoding-gbk ESM 兼容)
- 批量确认失败数计算修正(用 failedPayees.length 替代 store 快照差值)
- 新增 diagnostics 诊断页、TodoStrip 首页待办条、SwipeableTransactionCard 左滑操作
- periodNav.ts 报表周期导航(anchor+period 统一状态管理,本地时区运算)
- 测试覆盖 numpadExpression/postingLegs/periodNav/palette/search/transactionGroups/categoryIcons/dateGrid
This commit is contained in:
fengmengqi 2026-07-22 13:33:29 +08:00
parent 7fa345b558
commit 2e73b5a2c6
96 changed files with 26951 additions and 20619 deletions

View File

@ -1,206 +1,72 @@
# Design System Master File # Beancount Mobile 设计系统MASTER
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`. > 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> If that file exists, its rules **override** this Master file. > 修改配色/字阶/圆角时必须先改 presets.ts再同步本文件。
> If not, strictly follow the rules below.
--- ## 设计方向:明亮 Bento 现代风
**Project:** beancount-mobile 浅色为主、大圆角卡片Bento、近黑白单色 + 财务语义色点缀。
**Generated:** 2026-07-16 16:32:57 品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
**Category:** Personal Finance Tracker
--- ## 色板(= presets.ts
## Global Rules ### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### Color Palette ### 暗色OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
| Role | Hex | CSS Variable | 分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色
|------|-----|--------------| 其余 tokendivider / skeleton / progressBg / success / warning / error / info`presets.ts`
| Primary | `#1E40AF` | `--color-primary` | 全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
| On Primary | `#FFFFFF` | `--color-on-primary` |
| Secondary | `#3B82F6` | `--color-secondary` |
| Accent/CTA | `#059669` | `--color-accent` |
| Background | `#0F172A` | `--color-background` |
| Foreground | `#FFFFFF` | `--color-foreground` |
| Muted | `#101A34` | `--color-muted` |
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
| Destructive | `#DC2626` | `--color-destructive` |
| Ring | `#1E40AF` | `--color-ring` |
**Color Notes:** Trust blue + profit green on dark ## 字体
### Typography 系统字体iOS SF / Android Roboto不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
- **Heading Font:** Caveat 字阶display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
- **Body Font:** Quicksand
- **Mood:** handwritten, personal, friendly, casual, warm, charming
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
**CSS Import:** ## 圆角 / 间距 / 阴影
```css
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
```
### Spacing Variables - 圆角sm 8 · md 12 · lg 16 · **xl 24弹窗默认卡片组件 P2 统一升级)** · full
- 间距xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 412px 弥散轻阴影;暗色阴影减重,辅以半透边框
| Token | Value | Usage | ## 图标
|-------|-------|-------|
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
| `--space-md` | `16px` / `1rem` | Standard padding |
| `--space-lg` | `24px` / `1.5rem` | Section padding |
| `--space-xl` | `32px` / `2rem` | Large gaps |
| `--space-2xl` | `48px` / `3rem` | Section margins |
| `--space-3xl` | `64px` / `4rem` | Hero padding |
### Shadow Depths 统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底CategoryIcon 组件P2 落地)。
| Level | Value | Usage | ## 反模式
|-------|-------|-------|
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
--- - 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
## Component Specs - 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
### Buttons
```css
/* Primary Button */
.btn-primary {
background: #059669;
color: white;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
.btn-primary:hover {
opacity: 0.9;
transform: translateY(-1px);
}
/* Secondary Button */
.btn-secondary {
background: transparent;
color: #1E40AF;
border: 2px solid #1E40AF;
padding: 12px 24px;
border-radius: 8px;
font-weight: 600;
transition: all 200ms ease;
cursor: pointer;
}
```
### Cards
```css
.card {
background: #0F172A;
border-radius: 12px;
padding: 24px;
box-shadow: var(--shadow-md);
transition: all 200ms ease;
cursor: pointer;
}
.card:hover {
box-shadow: var(--shadow-lg);
transform: translateY(-2px);
}
```
### Inputs
```css
.input {
padding: 12px 16px;
border: 1px solid #E2E8F0;
border-radius: 8px;
font-size: 16px;
transition: border-color 200ms ease;
}
.input:focus {
border-color: #1E40AF;
outline: none;
box-shadow: 0 0 0 3px #1E40AF20;
}
```
### Modals
```css
.modal-overlay {
background: rgba(0, 0, 0, 0.5);
backdrop-filter: blur(4px);
}
.modal {
background: white;
border-radius: 16px;
padding: 32px;
box-shadow: var(--shadow-xl);
max-width: 500px;
width: 90%;
}
```
---
## Style Guidelines
**Style:** Dark Mode (OLED)
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
### Page Pattern
**Pattern Name:** Interactive Product Demo
- **CTA Placement:** Above fold
- **Section Order:** Hero > Features > CTA
---
## Anti-Patterns (Do NOT Use)
- ❌ Pure white backgrounds
### Additional Forbidden Patterns
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
- ❌ **Instant state changes** — Always use transitions (150-300ms)
- ❌ **Invisible focus states** — Focus states must be visible for a11y
---
## Pre-Delivery Checklist
Before delivering any UI code, verify:
- [ ] No emojis used as icons (use SVG instead)
- [ ] All icons from consistent icon set (Heroicons/Lucide)
- [ ] `cursor-pointer` on all clickable elements
- [ ] Hover states with smooth transitions (150-300ms)
- [ ] Light mode: text contrast 4.5:1 minimum
- [ ] Focus states visible for keyboard navigation
- [ ] `prefers-reduced-motion` respected
- [ ] Responsive: 375px, 768px, 1024px, 1440px
- [ ] No content hidden behind fixed navbars
- [ ] No horizontal scroll on mobile

183
docs/ui-redesign-design.md Normal file
View File

@ -0,0 +1,183 @@
# Beancount Mobile 前端 UI 全面重设计 Spec
- 日期2026-07-21
- 状态:已确认(经逐节评审)
- 范围:`src/app`、`src/components`、`src/theme`、`design-system/`**不动** `src/domain`、`src/storage`、`src/services`、`plugins/`
## 1. 背景与问题
对现有 UI 的摸底发现以下问题(按严重度):
1. **品牌色混乱**:代码主题 accent = 靛蓝 `#4F46E5``design-system/beancount-mobile/MASTER.md` 规定 CTA = 绿 `#059669`、背景深蓝 `#0F172A`(代码暗色实为 OLED 纯黑 `#040508`)。设计文档与实现脱节。
2. **图标体系三套并存**Ionicons主力+ emoji 分类图标CategoryPicker+ MASTER.md 要求的 Lucide/Heroicons未落地
3. **硬编码颜色绕过 token**:首页 Hero 卡片写死白字;`CATEGORY_COLORS`、标签默认色 `#2196F3`、渠道色 `#1677FF/#07C160/#E53935`
4. **样式重复爆炸**40+ 处 `StyleSheet.create``header` 样式在 ~15 个页面重复8 个管理页(账户/分类/标签/预算/周期/规则/信用卡/备注模板)是同一"列表+FormModal"模式却各自实现。
5. **录入流程偏长**:方向 chip → 金额 → 账户 → 分类网格 → 折叠详情,不是金额优先;无 KeyboardAvoidingView日期靠手输 `YYYY-MM-DD`
6. **信息架构问题**:设置页 13 入口平铺;报表页周/月/年三套独立状态;年报内嵌月报与月 Tab 重复AI/导出图标无文字标签。
7. **Header 策略不统一**:二级页手写返回栏,唯独 transaction/new 用原生 header。
## 2. 已确认的关键决策
| 决策点 | 结论 |
|---|---|
| 重设计深度 | **全面重做**(所有页面) |
| 视觉方向 | **明亮 Bento 现代风**:浅色为主 + 大圆角黑白对比,财务语义色点缀 |
| 暗色模式 | **完整保留**OLED 纯黑,与浅色对等) |
| 记一笔交互 | **金额优先数字键盘面板**NumpadSheet |
| 底部导航 | **中央凸起 **4 个内容 Tab首页/交易/报表/我的) |
| 实施策略 | **设计系统先行,逐页替换**5 个阶段) |
## 3. 视觉语言Design Tokens
### 3.1 色板
**浅色(默认)**
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框/chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | **近黑**,按钮/选中态 |
| accentLight / accentDark | 重定义为中性色阶accentLight = accent 8% 透明度的底色选中高亮accentDark = accent 的按压加深态 | 高亮背景/按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
**暗色OLED完整对等**
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6`accent 反转为白) |
| fgSecondary | `#9CA3AF` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
原则:**accent 从靛蓝改为近黑白单色**,品牌感靠排版与财务语义色表达;对比度 ≥ 4.5:1暗色下用 1px 半透边框代替阴影。
### 3.2 字体 / 圆角 / 阴影 / 图标
- **移除 Caveat/Quicksand 自定义字体**改系统字体iOS SF / Android Roboto消除字体加载失败风险删除 `_layout.tsx` 字体加载代码。
- 金额数字统一 `fontVariant: ['tabular-nums']`
- 字阶display 34 / h1 28 / h2 22 / h3 17 / body 16 / bodySmall 14 / caption 12。
- 圆角sm 8 / md 12 / lg 16 / **xl 24卡片默认** / full。
- 阴影减重:浅色 212px 弥散阴影;暗色以边框代替。
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`Ionicon + 圆形彩色底)。
- 硬编码分类色收归 `theme.categoryPalette`12 色循环);渠道色、标签默认色等一并 token 化。
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
## 4. 核心交互NumpadSheet 记一笔面板
### 4.1 结构(一屏完成 90% 记账)
自上而下:方向 chip支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格4 列,常用分类 + "全部" → 内嵌数字键盘0-9、小数点、⌫、**/ 连续计算**(如 20+15 直接出 35、"今天"日期键、完成键)。下滑展开"更多"抽屉备注、标签、日期、高级模式PostingEditor入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
### 4.2 两个账户(复式记账的"两条腿"
| 方向 | 腿 1 | 腿 2 |
|---|---|---|
| 支出 | 资金来源账户Assets/Liabilities账户 chip 行,"从" | 分类账户Expenses:*,分类网格) |
| 收入 | 分类账户Income:*,分类网格) | 到账账户Assets账户 chip 行,"到" |
| 转账 | 转出账户chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
配套规则:
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
- 落账走 `buildAndSaveTransaction()` → BillPipeline与手动/自动渠道完全一致,不产生第二套写入逻辑。
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
- 编辑模式按 posting 方向回填两条腿。
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
## 5. 导航
自定义 **AppTabBar**(替换 expo-router 默认 tabBar4 个内容 Tab首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 ModalNumpadSheet不丢失上下文。**SpeedDial 退役**。
## 6. 核心组件库P2 交付物)
新增:
- **NumpadSheet** — 见第 4 节
- **AppTabBar** — 见第 5 节
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位替换十几份手写返回栏transaction/new 的原生 header 一并撤掉
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
- **ManagementScreen** — 管理页模板ScreenHeader+ / 可选分组 Tab / FlatList / 删除确认 / FormModal8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
改造/退役Button/Card/Chip/SearchBar 按新 token 重刷CategoryPicker 改底部弹层网格并 Ionicon 化FormModal 的 "✕" 字符换 Ioniconstransaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
## 7. 逐页重设计要点
### 7.1 首页:从"数据墙"到"今日视角"
- 顶部:日期 + 问候语(替代应用名标题)。
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
- 最近 5 条交易 + "查看全部 →"。
- 月度趋势卡TrendLine 重刷新色板)。
- 账户余额树下沉到「我的」页。
### 7.2 交易页:搜索优先 + 分组时间线
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
- TransactionCard 重刷CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
- 解析诊断从页面底部移入设置 → 数据组。
### 7.3 报表页:统一时间导航 + 去重
- 周/月/年 Tab 保留三套独立状态viewYear/viewMonth/viewWeekDate合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
- 删除年报内嵌的 MonthlyReport与月 Tab 重复年报只留年度收支汇总、月度节奏迷你图、Top 分类。
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
- CalendarView 保留在月 Tab配色收归 token热力图用 expense 色 5 级透明度。
- CategoryPie / StatCard / NetWorthChart 统一新 token。
### 7.4 设置 →「我的」4 分组重构
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
- **数据**同步WebDAV/Git/iCloud、备份恢复、导出、解析诊断。
- **偏好**主题、语言、应用锁、AI 设置、每日提醒、关于。
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
### 7.5 管理页模板化
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen预计删除上千行重复代码。
## 8. 阶段计划
| 阶段 | 内容 | 验收 |
|---|---|---|
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过token 名不变只改值,旧组件接口兼容 |
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板pipeline 集成测试不回归 |
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表anchor 统一)/ 我的4 分组) | 逐页替换,每页替换后全量测试 |
| P5 管理页+收尾 | 8 页套模板图表重刷emoji 清零硬编码色清零grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
## 9. 测试策略
- **不动 domain 层**billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
- 新增纯逻辑单测numpad 表达式求值(+/ 连续计算)、双腿账户解析(方向 → posting 映射、anchor 日期导航(周/月/年加减)。
- 每阶段结束跑 `npm test` + `npm run typecheck`UI 层靠深浅双主题手工走查清单。
- 硬编码色审计grep 全量扫描。
## 10. 风险与 YAGNI
风险对策:
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
- 移除字体无数据迁移,删加载代码即可。
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide改 domain/存储/同步层;平板/桌面布局适配。

678
docs/ui-redesign-p1-plan.md Normal file
View File

@ -0,0 +1,678 @@
# UI 重设计 P1设计系统 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** 将主题系统切换到「明亮 Bento 现代风」新设计令牌(双主题),移除自定义字体,硬编码颜色收归 palette并重写 MASTER.md 使设计文档与实现一致。
**Architecture:** 只改 `src/theme/`、`src/app/_layout.tsx`(字体加载)、`src/domain/channelConfig.ts`(品牌色引用)、`src/components/CategoryPicker.tsx`(色板引用)、`design-system/`。Token 名不变只改值,组件接口向后兼容,现有页面自动"粗换皮"。
**Tech Stack:** React Native + Expo + TypeScript + Vitest。
**Spec:** `docs/ui-redesign-design.md` §3视觉语言、§10P1 验收)。
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*``presets.ts` 的 typography 无 `fontFamily`
---
### Task 1: 新设计令牌的失败测试
**Files:**
- Modify: `tests/theme.test.ts`
- Create: `tests/palette.test.ts`
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
完整替换文件内容为:
```typescript
import { describe, expect, it } from 'vitest';
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题(明亮 Bento 设计系统)', () => {
it('lightTheme 与 darkTheme 关键色彩不同', () => {
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
});
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
expect(darkTheme.colors.bgPrimary).toBe('#040508');
});
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
expect(lightTheme.colors.accent).toBe('#111318');
expect(darkTheme.colors.accent).toBe('#F3F4F6');
});
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
});
it('财务语义色:暗色整体提亮一档', () => {
expect(lightTheme.colors.financial.income).toBe('#10B981');
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
expect(darkTheme.colors.financial.income).toBe('#34D399');
expect(darkTheme.colors.financial.expense).toBe('#F87171');
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
});
it('圆角含 xl(24),卡片默认大圆角', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.radii.sm).toBe(8);
expect(theme.radii.md).toBe(12);
expect(theme.radii.lg).toBe(16);
expect(theme.radii.xl).toBe(24);
expect(theme.radii.full).toBe(9999);
}
});
it('字阶含 display(34),且不指定 fontFamily系统字体', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.typography.display.fontSize).toBe(34);
expect(theme.typography.h1.fontSize).toBe(28);
expect(theme.typography.h2.fontSize).toBe(22);
expect(theme.typography.h3.fontSize).toBe(17);
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
expect(theme.typography[key].fontFamily).toBeUndefined();
}
}
});
it('presetThemes 注册表含 light 与 dark', () => {
expect(presetThemes.light).toBe(lightTheme);
expect(presetThemes.dark).toBe(darkTheme);
});
it('spacing/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.spacing).toHaveProperty('xs');
expect(theme.spacing).toHaveProperty('xl');
expect(theme.shadows).toHaveProperty('sm');
expect(theme.shadows).toHaveProperty('lg');
}
});
});
describe('createTheme 自定义主题工厂', () => {
it('未覆盖时等价于 lightTheme', () => {
const custom = createTheme({});
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.spacing).toEqual(lightTheme.spacing);
});
it('覆盖 accent 颜色时其他颜色保留', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
expect(custom.colors.accent).toBe('#FF5722');
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.colors.success).toBe(lightTheme.colors.success);
});
it('覆盖 financial 子对象时深度合并', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
expect(custom.colors.financial.income).toBe('#000');
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
});
it('覆盖 spacing 部分键时其他键保留', () => {
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
expect(custom.spacing.lg).toBe(32);
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
});
});
```
- [ ] **Step 2: 新建 tests/palette.test.ts分类色板 + 渠道品牌色)**
```typescript
import { describe, expect, it } from 'vitest';
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
describe('categoryPalette', () => {
it('色板为 12 色循环', () => {
expect(CATEGORY_PALETTE).toHaveLength(12);
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
});
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
expect(getCategoryColor('food')).toBe('#F59E0B');
expect(getCategoryColor('transport')).toBe('#3B82F6');
expect(getCategoryColor('salary')).toBe('#22C55E');
});
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
const a = getCategoryColor('user_custom_abc');
expect(CATEGORY_PALETTE).toContain(a);
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
});
it('空字符串 id 不崩溃', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
});
it('渠道品牌色常量', () => {
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
});
});
```
- [ ] **Step 3: 运行测试确认失败**
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
- [ ] **Step 4: Commit**
```bash
git add tests/theme.test.ts tests/palette.test.ts
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
```
---
### Task 2: tokens.ts — display 字阶 + radii.xl
**Files:**
- Modify: `src/theme/tokens.ts`
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
`ThemeRadii` 改为(加 `xl`
```typescript
export interface ThemeRadii {
sm: number;
md: number;
lg: number;
xl: number; // 卡片默认大圆角24
full: number;
}
```
`ThemeTypographyEntry``fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
```typescript
export interface ThemeTypographyEntry {
fontSize: number;
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
lineHeight: number;
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
fontFamily?: string;
}
```
`ThemeTypography``display`
```typescript
export interface ThemeTypography {
display: ThemeTypographyEntry; // 34/800净资产等大数字
h1: ThemeTypographyEntry;
h2: ThemeTypographyEntry;
h3: ThemeTypographyEntry;
body: ThemeTypographyEntry;
bodySmall: ThemeTypographyEntry;
caption: ThemeTypographyEntry;
}
```
- [ ] **Step 2: 运行 typecheck 确认报错点**
Run: `npm run typecheck`
Expected: FAIL —— `presets.ts` 缺少 `display``radii.xl`Task 3 修复)。
- [ ] **Step 3: 暂不 commit与 Task 3 一起)**
---
### Task 3: presets.ts — 新双主题色板
**Files:**
- Modify: `src/theme/presets.ts`
- [ ] **Step 1: 完整替换 presets.ts**
```typescript
import type { ThemeTokens } from './tokens';
/**
* 预置主题 —— 「明亮 Bento 现代风」docs/ui-redesign-design.md §3
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
*/
export const lightTheme: ThemeTokens = {
colors: {
bgPrimary: '#F6F7F9',
bgSecondary: '#FFFFFF',
bgTertiary: '#EFF1F4',
fgPrimary: '#111318',
fgSecondary: '#6B7280',
fgInverse: '#FFFFFF',
accent: '#111318', // 近黑:按钮/选中态
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
accentDark: '#000000', // 按压加深态
success: '#10B981',
warning: '#F59E0B',
error: '#EF4444',
info: '#3B82F6',
financial: {
income: '#10B981',
expense: '#EF4444',
transfer: '#3B82F6',
},
border: '#E5E7EB',
divider: '#F0F1F3',
overlay: 'rgba(17,19,24,0.4)',
skeleton: '#E5E7EB',
progressBg: 'rgba(17,19,24,0.06)',
},
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
typography: {
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
},
shadows: {
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
},
};
export const darkTheme: ThemeTokens = {
...lightTheme,
colors: {
bgPrimary: '#040508', // OLED 纯黑
bgSecondary: '#101218',
bgTertiary: '#1A1D24',
fgPrimary: '#F3F4F6',
fgSecondary: '#9CA3AF',
fgInverse: '#111318',
accent: '#F3F4F6', // 反转为白
accentLight: 'rgba(243,244,246,0.10)',
accentDark: '#FFFFFF',
success: '#34D399',
warning: '#FBBF24',
error: '#F87171',
info: '#60A5FA',
financial: {
income: '#34D399', // 提亮一档保证对比度
expense: '#F87171',
transfer: '#60A5FA',
},
border: 'rgba(255,255,255,0.08)',
divider: 'rgba(255,255,255,0.04)',
overlay: 'rgba(0,0,0,0.7)',
skeleton: '#1A1D24',
progressBg: 'rgba(243,244,246,0.08)',
},
shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
},
};
/** 预置主题注册表。 */
export const presetThemes: Record<string, ThemeTokens> = {
light: lightTheme,
dark: darkTheme,
};
```
- [ ] **Step 2: 运行 Task 1 的测试**
Run: `npx vitest run tests/theme.test.ts`
Expected: PASSpalette.test.ts 仍 FAILTask 4 修复)。
- [ ] **Step 3: 运行 typecheck**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 4: Commit**
```bash
git add src/theme/tokens.ts src/theme/presets.ts
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
```
---
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
**Files:**
- Create: `src/theme/palette.ts`
- Modify: `src/domain/channelConfig.ts:32,40,48`
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
- [ ] **Step 1: 创建 src/theme/palette.ts纯 TS无 RN/domain 依赖,可被双方向引用)**
```typescript
/**
* 分类/渠道颜色统一色板docs/ui-redesign-design.md §3.2)。
*
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
* - components/CategoryPicker 等)用它给分类配色;
* - domain/channelConfig.ts 用它引用渠道品牌色;
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件P5 grep 审计)。
*/
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
export const CATEGORY_PALETTE = [
'#F59E0B', // Amber
'#3B82F6', // Blue
'#EC4899', // Pink
'#06B6D4', // Cyan
'#6366F1', // Indigo
'#8B5CF6', // Purple
'#10B981', // Emerald
'#64748B', // Slate
'#F43F5E', // Rose
'#14B8A6', // Teal
'#EF4444', // Red
'#84CC16', // Lime
] as const;
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
const NAMED_CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B',
transport: '#3B82F6',
shopping: '#EC4899',
housing_utility: '#06B6D4',
housing_rent: '#6366F1',
housing_communication: '#8B5CF6',
entertainment: '#10B981',
services: '#64748B',
personal_care: '#F43F5E',
clothing: '#14B8A6',
health: '#EF4444',
learning: '#84CC16',
salary: '#22C55E',
income_activity: '#EF4444',
income_investment: '#F59E0B',
};
/**
* 本地 FNV-1a 哈希。
* 注domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
*/
function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
export function getCategoryColor(categoryId: string): string {
const named = NAMED_CATEGORY_COLORS[categoryId];
if (named) return named;
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
}
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
export const CHANNEL_BRAND_COLORS = {
alipay: '#1677FF',
wechat: '#07C160',
bank: '#E53935',
} as const;
```
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
文件顶部加 import
```typescript
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
```
三处字面量替换:`color: '#1677FF'` → `color: CHANNEL_BRAND_COLORS.alipay``color: '#07C160'` → `color: CHANNEL_BRAND_COLORS.wechat``color: '#E53935'` → `color: CHANNEL_BRAND_COLORS.bank`
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
删除第 3148 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
```typescript
import { getCategoryColor } from '../theme/palette';
```
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
```typescript
const color = getCategoryColor(cat.id);
```
- [ ] **Step 4: 运行测试**
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
Expected: PASS渠道色值未变channelConfig 测试不受影响)。
- [ ] **Step 5: Commit**
```bash
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette消除组件与 domain 硬编码色"
```
---
### Task 5: 移除自定义字体Caveat/Quicksand
**Files:**
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
- Modify: `package.json:14-15`
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
删除第 2739 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
- [ ] **Step 2: 删除 useFonts 调用**
删除第 7079 行的 `const [fontsLoaded, fontError] = useFonts({...});`
- [ ] **Step 3: 简化加载门禁**
第 370 行:
```typescript
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
```
改为:
```typescript
if (phase === 'loading') {
```
- [ ] **Step 4: 移除 package.json 依赖并重装**
删除 `package.json``"@expo-google-fonts/caveat"``"@expo-google-fonts/quicksand"` 两行,然后:
Run: `npm install --legacy-peer-deps`
Expected: lockfile 更新,无字体包残留。
- [ ] **Step 5: 验证无残留引用**
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
Expected: 无输出typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefinedRN 回退系统字体,无需在 P1 清理)。
- [ ] **Step 6: 全量测试 + typecheck**
Run: `npm test && npm run typecheck`
Expected: 全绿。
- [ ] **Step 7: Commit**
```bash
git add src/app/_layout.tsx package.json package-lock.json
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
```
---
### Task 6: commonStyles 适配新圆角
**Files:**
- Modify: `src/theme/commonStyles.ts`
- [ ] **Step 1: modalCard 圆角 lg → xl**
`modalCard``borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
```typescript
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
```
`input` 的 `theme.radii.md` 现在即 12px无需改动仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
- [ ] **Step 2: 验证**
Run: `npm run typecheck`
Expected: PASS。
- [ ] **Step 3: Commit**
```bash
git add src/theme/commonStyles.ts
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
```
---
### Task 7: 重写 MASTER.md 使其与实现一致
**Files:**
- Modify: `design-system/beancount-mobile/MASTER.md`
- [ ] **Step 1: 完整替换 MASTER.md**
```markdown
# Beancount Mobile 设计系统MASTER
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
> 修改配色/字阶/圆角时必须先改 presets.ts再同步本文件。
## 设计方向:明亮 Bento 现代风
浅色为主、大圆角卡片Bento、近黑白单色 + 财务语义色点缀。
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
## 色板(= presets.ts
### 浅色(默认)
| Token | 值 | 用途 |
|---|---|---|
| bgPrimary | `#F6F7F9` | 页面背景 |
| bgSecondary | `#FFFFFF` | 卡片 |
| bgTertiary | `#EFF1F4` | 输入框 / chip |
| fgPrimary | `#111318` | 主文字 |
| fgSecondary | `#6B7280` | 次要文字 |
| fgInverse | `#FFFFFF` | 反色文字 |
| accent | `#111318` | 按钮 / 选中态(近黑) |
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
| accentDark | `#000000` | 按压态 |
| financial.income | `#10B981` | 收入 |
| financial.expense | `#EF4444` | 支出 |
| financial.transfer | `#3B82F6` | 转账 |
| border | `#E5E7EB` | 边框 |
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
### 暗色OLED
| Token | 值 |
|---|---|
| bgPrimary | `#040508` |
| bgSecondary | `#101218` |
| bgTertiary | `#1A1D24` |
| fgPrimary / accent | `#F3F4F6` |
| fgSecondary | `#9CA3AF` |
| fgInverse | `#111318` |
| financial.income | `#34D399`(提亮) |
| financial.expense | `#F87171`(提亮) |
| financial.transfer | `#60A5FA`(提亮) |
| border | `rgba(255,255,255,0.08)` |
分类/渠道颜色见 `src/theme/palette.ts`12 色循环 + 具名映射 + 渠道品牌色)。
全工程颜色字面量只允许存在于 `presets.ts``palette.ts`
## 字体
系统字体iOS SF / Android Roboto不加载自定义字体。
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
字阶display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
## 圆角 / 间距 / 阴影
- 圆角sm 8 · md 12 · lg 16 · **xl 24卡片与弹窗默认** · full
- 间距xs 4 · sm 8 · md 16 · lg 24 · xl 32
- 阴影:浅色 412px 弥散轻阴影;暗色以 1px 半透边框代替阴影
## 图标
统一 Ionicons`@expo/vector-icons`)。禁止用 emoji 充当图标;
分类图标 = Ionicon + 圆形彩色底CategoryIcon 组件P2 落地)。
## 反模式
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
- 禁止低对比文字(< 4.5:1
- 禁止瞬间状态变化,交互反馈 150300ms
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
```
- [ ] **Step 2: Commit**
```bash
git add design-system/beancount-mobile/MASTER.md
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
```
---
### Task 8: P1 全量验收
- [ ] **Step 1: 全量测试**
Run: `npm test`
Expected: 全部通过30+ 文件,含新 theme/palette 测试)。
- [ ] **Step 2: typecheck**
Run: `npm run typecheck`
Expected: 无错误。
- [ ] **Step 3: 审计**
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219` 的 `#2196F3`P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用P4/P5 逐页清理)。
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
Run: `npm run android`(或 expo start
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
---
## 后续计划(不在本文件)
- **P2 组件库**ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
- **P5 管理页模板化 + 清零审计**
每阶段完成后基于实际代码编写下一阶段计划。

934
docs/ui-redesign-p2-plan.md Normal file
View File

@ -0,0 +1,934 @@
# UI 重设计 P2核心组件库 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 建立新设计系统的核心组件库CategoryIcon去 emoji、BottomSheet、DatePickerField、ScreenHeader、StatCard、AppTabBar中央凸起、ManagementScreen 模板tag 页试点),并将 Button/Card/SearchBar/FormModal 重刷到新 token。
**Architecture:** 全部为 `src/components/` 下的新组件或既有组件改造;纯逻辑(图标映射、月历网格)抽成无 RN 依赖的独立模块以便 Vitest(node) 测试。AppTabBar 通过 expo-router Tabs 的 `tabBar` 插槽接入;+按钮 P2 暂跳 `/transaction/new`P3 换 NumpadSheet。
**Tech Stack:** React Native + Expo Router + Ionicons + Vitest。
**Spec:** `docs/ui-redesign-design.md` §5导航、§6组件库。**与 spec 的偏差**FilterSheet 移至 P4其形态依赖交易页筛选状态P4 一并设计CategoryPicker 的"改底部弹层"移至 P3随 NumpadSheet 一起设计P2 仅完成 Ionicon 化FormModal 增加 `children` 支持ManagementScreen 的颜色选择器需要)。
**前置状态:** P1 已完成(新 tokens/presets/palette 已生效系统字体538 测试全绿)。
---
### Task 1: 组件纯逻辑的失败测试
**Files:**
- Create: `tests/components-p2.test.ts`
- [ ] **Step 1: 新建 tests/components-p2.test.ts**
```typescript
import { describe, expect, it } from 'vitest';
import { getCategoryIcon, CATEGORY_ICON_NAMES } from '../src/components/categoryIcons';
import { buildMonthGrid } from '../src/components/dateGrid';
describe('getCategoryIcon', () => {
it('15 个内置分类都有具名图标', () => {
const builtin = [
'food', 'transport', 'shopping', 'housing_utility', 'housing_rent',
'housing_communication', 'entertainment', 'services', 'personal_care',
'clothing', 'health', 'learning', 'salary', 'income_activity', 'income_investment',
];
for (const id of builtin) {
const icon = getCategoryIcon(id);
expect(typeof icon).toBe('string');
expect(icon.length).toBeGreaterThan(0);
}
});
it('未知分类返回 fallback 图标', () => {
expect(getCategoryIcon('whatever_custom')).toBe('pricetag-outline');
});
it('原型链属性名 id 不穿透(返回 fallback', () => {
expect(getCategoryIcon('constructor')).toBe('pricetag-outline');
});
it('所有图标名以 -outline 结尾(风格统一)', () => {
for (const name of Object.values(CATEGORY_ICON_NAMES)) {
expect(name).toMatch(/-outline$/);
}
});
});
describe('buildMonthGrid', () => {
it('固定 6 行 × 7 列', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid).toHaveLength(6);
for (const week of grid) expect(week).toHaveLength(7);
});
it('2026-07首日周三周一开头填充 6 月末两天', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid[0][0]).toEqual({ date: '2026-06-29', inMonth: false });
expect(grid[0][1]).toEqual({ date: '2026-06-30', inMonth: false });
expect(grid[0][2]).toEqual({ date: '2026-07-01', inMonth: true });
expect(grid[5][6]).toEqual({ date: '2026-08-09', inMonth: false });
});
it('2026-02首日周日填充 1 月最后 6 天', () => {
const grid = buildMonthGrid(2026, 2);
expect(grid[0][0].date).toBe('2026-01-26');
expect(grid[0][6]).toEqual({ date: '2026-02-01', inMonth: true });
});
it('首日恰为周一时无跨月填充2026-06', () => {
const grid = buildMonthGrid(2026, 6);
expect(grid[0][0]).toEqual({ date: '2026-06-01', inMonth: true });
});
it('inMonth 标记与当月天数一致2026-07 共 31 天)', () => {
const grid = buildMonthGrid(2026, 7);
expect(grid.flat().filter(c => c.inMonth)).toHaveLength(31);
});
});
```
- [ ] **Step 2: 运行确认失败**
Run: `npx vitest run tests/components-p2.test.ts`
Expected: FAIL —— `Cannot find module '../src/components/categoryIcons'`。**若意外通过,报告 BLOCKED。**
---
### Task 2: categoryIcons + CategoryIcon + CategoryPicker 去 emoji
**Files:**
- Create: `src/components/categoryIcons.ts`
- Create: `src/components/CategoryIcon.tsx`
- Modify: `src/components/CategoryPicker.tsx`
- [ ] **Step 1: 创建 src/components/categoryIcons.ts**
```typescript
/**
* 分类 id → Ionicons 图标名映射(替换原 CategoryPicker 的 emoji 表)。
* 纯数据模块:仅含类型导入(运行时零依赖),可在 Vitest(node) 中直接测试。
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallbackhasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}
```
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
```tsx
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});
```
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
- 删除整个 `CATEGORY_ICONS` 常量(第 1330 行)。
- 顶部加 `import { CategoryIcon } from './CategoryIcon';``getCategoryColor` 的 import 保留)。
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
- 删除 styles 中的 `icon` 条目。
- 注意:`const color = getCategoryColor(cat.id);` 保留active 底色仍用)。
- [ ] **Step 4: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASSdateGrid 仍 FAILTask 3 修复)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 3: dateGrid 月历网格纯逻辑
**Files:**
- Create: `src/components/dateGrid.ts`
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
```typescript
/**
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
* 纯 TS无 RN 依赖,供 DatePickerField / CalendarView 复用。
*/
export interface DayCell {
/** YYYY-MM-DD本地时区。 */
date: string;
inMonth: boolean;
}
function pad2(n: number): string {
return n < 10 ? `0${n}` : `${n}`;
}
function fmt(d: Date): string {
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
}
/** 生成指定年月的月历网格。month 为 1-12。 */
export function buildMonthGrid(year: number, month: number): DayCell[][] {
const first = new Date(year, month - 1, 1);
// 周一为一周起点getDay() 周日=0 → 偏移 (day+6)%7
const offset = (first.getDay() + 6) % 7;
const cursor = new Date(year, month - 1, 1 - offset);
const weeks: DayCell[][] = [];
for (let w = 0; w < 6; w++) {
const week: DayCell[] = [];
for (let d = 0; d < 7; d++) {
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
cursor.setDate(cursor.getDate() + 1);
}
weeks.push(week);
}
return weeks;
}
```
- [ ] **Step 2: 验证**
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
---
### Task 4: BottomSheet 通用底部弹层
**Files:**
- Create: `src/components/BottomSheet.tsx`
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
```tsx
import React from 'react';
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface BottomSheetProps {
visible: boolean;
onClose: () => void;
title?: string;
children: React.ReactNode;
}
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
const { theme } = useTheme();
return (
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
<Pressable
style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary,
borderTopLeftRadius: theme.radii.xl,
borderTopRightRadius: theme.radii.xl,
}]}
onPress={(e) => e.stopPropagation()}
>
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
{title ? (
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
) : null}
{children}
</Pressable>
</Pressable>
</Modal>
);
}
const styles = StyleSheet.create({
overlay: { flex: 1, justifyContent: 'flex-end' },
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
title: { marginTop: 8, marginBottom: 12 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 5: DatePickerField 日期选择字段
**Files:**
- Create: `src/components/DatePickerField.tsx`
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
```tsx
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{WEEKDAYS.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});
```
- [ ] **Step 2: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 6: ScreenHeader + StatCard
**Files:**
- Create: `src/components/ScreenHeader.tsx`
- Create: `src/components/StatCard.tsx`
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Pressable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});
```
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
```tsx
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});
```
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 7: AppTabBar 中央凸起+导航
**Files:**
- Create: `src/components/AppTabBar.tsx`
- Modify: `src/app/(tabs)/_layout.tsx`
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
```tsx
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 自定义底部导航4 个内容 Tab + 中央凸起+。
* 不是路由——P2 暂跳转 /transaction/newP3 改为唤起全局 NumpadSheet。
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const renderTab = (route: (typeof state.routes)[number], index: number) => {
const focused = state.index === index;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => router.push('/transaction/new')}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});
```
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央AppTabBar。 */`
- `Tabs` 组件加 prop`tabBar={(props) => <AppTabBar {...props} />}`。
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`
- [ ] **Step 3: 验证**
Run: `npm run typecheck` → Expected: 无错误
---
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
**Files:**
- Modify: `src/components/FormModal.tsx`
- Create: `src/components/ManagementScreen.tsx`
- Modify: `src/app/tag/index.tsx`(完整重写)
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
- `FormModalProps` 加字段:
```typescript
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
```
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
```tsx
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
/**
* 管理页模板P2统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目handlers.openEdit 打开编辑弹窗handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) setModalOpen(false);
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onConfirm={handleSubmit}
onCancel={() => setModalOpen(false)}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});
```
- [ ] **Step 3: 完整重写 src/app/tag/index.tsxManagementScreen 试点)**
```tsx
/**
* 标签管理页面 —— ManagementScreen 模板试点P2
*
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
*/
import React, { useState } from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n';
import { ManagementScreen } from '../../components/ManagementScreen';
import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags';
export default function TagScreen() {
const { theme } = useTheme();
const t = useT();
const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag);
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
return (
<ManagementScreen<Tag>
title={t('tag.title')}
items={tags}
keyExtractor={tag => tag.id}
addLabel={t('tag.add')}
emptyText={t('tag.empty')}
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
renderItem={(tag, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={[styles.chip, { backgroundColor: tag.color }]}
>
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
</Pressable>
)}
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
formFields={editing => [
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
]}
formExtra={() => (
<View style={styles.colorRow}>
{TAG_COLORS.map(c => (
<Pressable
key={c}
onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
backgroundColor: c,
borderWidth: selectedColor === c ? 3 : 0,
borderColor: theme.colors.fgPrimary,
}]}
/>
))}
</View>
)}
onSubmit={(values, editing) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return false;
}
if (editing) updateTag(editing.id, { name, color: selectedColor });
else addTag({ id: generateId('tag'), name, color: selectedColor });
return true;
}}
onDelete={tag => removeTag(tag.id)}
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')}
</Text>
}
/>
);
}
const styles = StyleSheet.create({
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 },
});
```
- [ ] **Step 4: 验证**
Run: `npm test` → Expected: 全部通过tag 页逻辑变化不影响单测,但全量跑一遍防回归)
Run: `npm run typecheck` → Expected: 无错误
---
### Task 9: Button / Card / SearchBar 重刷
**Files:**
- Modify: `src/components/Button.tsx`
- Modify: `src/components/Card.tsx`
- Modify: `src/components/SearchBar.tsx`
- [ ] **Step 1: Button.tsx**
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
- [ ] **Step 2: Card.tsx**
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`
- [ ] **Step 3: SearchBar.tsx**
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → Expected: 全部通过
---
### Task 10: P2 全量验收
- [ ] **Step 1: 全量测试 + typecheck**
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
Run: `npm run typecheck` → Expected: 无错误
- [ ] **Step 2: 审计**
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
Run: `grep -n "✕" src/components/` → Expected: 无输出
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 底部导航为自定义 bar中央黑色凸起4 个 tab 可切换
2. → 跳转记一笔页P2 占位行为)
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
5. 任意 FormModal关闭按钮为 Ionicons 图标而非 ✕ 字符
---
## 后续计划(不在本文件)
- **P3 录入闭环**NumpadSheet + 双腿账户选择 + AppTabBar 改全局唤起 + transaction/new 重写 + SpeedDial 退役
- **P4 四个 Tab 页**:首页待办条 / 交易时间线FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
- **P5 管理页全面模板化**tag 页为已验证模板)+ 清零审计

1474
docs/ui-redesign-p3-plan.md Normal file

File diff suppressed because it is too large Load Diff

1381
docs/ui-redesign-p4-plan.md Normal file

File diff suppressed because it is too large Load Diff

223
docs/ui-redesign-p5-plan.md Normal file
View File

@ -0,0 +1,223 @@
# UI 重设计 P5管理页模板化 + 清零收尾 Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax. **不执行任何 git add/commit**(用户要求,改动留工作区)。不创建额外任务清单。
**Goal:** 7 个管理页套 ManagementScreen 模板、account 页与 3 个设置二级页统一 ScreenHeader、图表/fontFamily/emoji/星期头清理、`numpadGlobalEntry` 翻默认(含 modal 未保存守卫)、清零审计验收。
**Spec:** `docs/ui-redesign-design.md` §7.5/§8。**已确认现状**2026-07-22 审计hex 硬编码已清零(`#[0-9A-Fa-f]{6}` 在 src/ 仅剩 presets.ts/palette.tsfontFamily 残留 14 处emoji 5 处monthlySummary.ts 4 + ai/chat.tsx 1tag 页已是模板试点。
**与 spec 的偏差**①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
**模板适配决策**(已通读 7 页源码):
- budget/rules/remark-template → 直套T1
- category → `headerContent` 放支出/收入 chips新增按当前类型T2
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete不用长按T2
- credit-card → 富 renderItem账单盒直套T2
---
### Task 1: 管理页模板化批次 1budget / rules / remark-template
**Files:**
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`
通用映射:页面 state 消失modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`
**budget/index.tsx**
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
- renderItem保留现有 Card + 进度条结构Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
- formFields5 字段name/amount decimal-pad/period/categoryAccount/startDatedefaultValue 从 editing 取
- onSubmit保留校验amount parseFloat ≤ 0 → Alert + return falseadd/edit 分支调 addBudget/updateBudget
- onDelete`removeBudget(budget.id)`deleteConfirmText`t('budget.deleteConfirm', { name: budget.name })`
**rules/index.tsx**
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
- renderItem保留规则行narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
- formFields7 字段priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags
- onSubmit保留现有 Rule 组装逻辑addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
**remark-template/index.tsx**
- 2 字段name/templaterenderItem 的模板文本 `fontFamily: 'monospace'``fontVariant: ['tabular-nums']`(审计项)
- onSubmitname 空回退 `t('common.untitled')`
- [ ] **Step 2: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
---
### Task 2: 管理页模板化批次 2category / recurring / credit-card
**Files:**
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
- 保留一个本地 state`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
- `headerContent`:两个 chip支出/收入commonStyles.chip/chipActive切换 setCatType
- `items={categories.filter(c => c.type === catType)}`
- `formTitle`editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle
- formFields3 字段name/linkedAccount/keywords编辑时带 defaultValue
- onSubmitadd 时 `type: catType`linkedAccount 空回退按类型Income:/Expenses:Uncategorizedkeywords 拆分逻辑保留edit 保留原逻辑
- renderItem保留名称/linkedAccount/关键词行
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
- renderItem保留信息行 Card卡片底部两个按钮编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`**不**用长按,交互更显式);外层 Pressable 去掉
- formFields6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
- onSubmit保留校验/draft 组装/成功 Alert返回 true校验失败 Alert 后 return false
- 成功 Alert 在 onSubmit 内setModal(null) 由模板接管——删除手动 setModal 调用)
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
- accountBalances useMemo 保留在页面顶层
- renderItem保留整张卡银行/账单日/还款日/额度/账单盒Pressable onPress={openEdit} onLongPress={confirmDelete}
- formFields8 字段onSubmit 保留组装逻辑
- [ ] **Step 4: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
---
### Task 3: ScreenHeader 统一account + settings 三页)
**Files:**
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
- [ ] **Step 1: account/index.tsx**
手写 headerarrow-back + title替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)``toDateString(new Date())`
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
三页手写 header约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44替换为 ScreenHeader标题沿用原文案键删除对应 styles.header 与不再使用的 importIonicons/router 若仅 header 使用。sync.tsx 546 行,只动 header 块,其余不动。
- [ ] **Step 3: 验证**
Run: `npm run typecheck && npm test` → 全绿
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
---
### Task 4: 图表与杂项清理fontFamily / emoji / 星期头 i18n
**Files:**
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
- [ ] **Step 1: fontFamily 清零**
- `theme.typography.X.fontFamily` 引用CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处直接删除该属性P1 后 typography 无 fontFamily 字段,这些是无效引用)
- `fontFamily: 'monospace'`
- 金额/数字场景CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391`fontVariant: ['tabular-nums']`
- 纯文本场景_error.tsx:70 堆栈文本)→ 保留 monospace堆栈对齐需要或删除——保留加注释说明
- NetWorthChart/CalendarHeatmap 顺便检查一遍grep 未命中但过一眼 token 合规)
- [ ] **Step 2: emoji 清零**
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`
- `src/app/ai/chat.tsx:123`:方向 emoji💰/🔄/💸替换为符号文本income `''`、transfer `'⇄'`、expense `''`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*
- [ ] **Step 3: 星期头 i18n3 处硬编码中文)**
- i18n 加键zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`周一起DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`周日起CalendarView/CalendarHeatmap 用)
- `DatePickerField.tsx`、`CalendarView.tsx`、`CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT注意保持周一/周日起始顺序与各自网格一致,不要换序)
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
- NumpadKeyboard props 加 `backspaceLabel: string`backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
- [ ] **Step 5: 验证**
Run: `npm run typecheck && npm test` → 全绿i18n 对等校验覆盖新键)
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
---
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
**Files:**
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
- Delete: `src/components/charts/MonthlyReport.tsx`
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false``true`。**注意**:已持久化 false 的用户不受影响hydrate 合并优先级),仅新安装生效——在代码注释中写明。
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef`useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
- `NumpadSheetHost.tsx``const dirtyRef = useRef(false)`NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`overlay onPress 与 Modal onRequestClose 改调 `requestClose`
```typescript
const requestClose = () => {
if (!dirtyRef.current) { close(); return; }
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.discard'), style: 'destructive', onPress: close },
]);
};
```
Host 需 useT + Alert importcommon.discard 键先 grep 确认存在。)
- [ ] **Step 3: 删除 MonthlyReport.tsx**P4 确认无引用的死文件)
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
`src/domain/budgets.ts``getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'``getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED
- [ ] **Step 5: handleDuplicate 带 tags**
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags参照 editId 预填的 tags 回填逻辑tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时。cost/price 不带hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
- [ ] **Step 6: 验证**
Run: `npm run typecheck && npm test` → 全绿
---
### Task 6: P5 清零审计 + 全项目验收
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
```bash
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
grep -rn "arrow-back" src/app/ # 无输出ScreenHeader 内部除外)
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n
```
- [ ] **Step 2: 全量测试 + typecheck**
Run: `npm test` → 全绿
Run: `npm run typecheck` → 无错误
- [ ] **Step 3: 手工走查(需设备/模拟器)**
Run: `npm run android`
走查清单(浅/暗双主题):
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板新增、点按编辑、删除recurring 为显式按钮,其余长按)、空态文案
2. account 页:类型 Tab、开户、调余额、关户
3. settings/sync、ai、preferencesheader 统一有返回箭头
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
5. 设置→偏好里开关关闭 → +回到跳整页
6. 交易页左滑复制带标签的交易 → 标签预填
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
8. 英文语言:日期选择器/日历星期头为英文缩写
---
## 完成后的项目状态
P1P5 全部交付spec §8 验收标准全达成。剩余已知非阻塞项不修记录TransactionCard memo 被内联 onPress 击穿性能todayStr useMemo 跨午夜不刷新NumpadSheet page 模式无可见返回按钮spec 允许_error.tsx monospace 保留(堆栈对齐)。

18
package-lock.json generated
View File

@ -1,15 +1,13 @@
{ {
"name": "beancount-mobile", "name": "drift-ledger",
"version": "0.1.0", "version": "0.1.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "beancount-mobile", "name": "drift-ledger",
"version": "0.1.0", "version": "0.1.0",
"dependencies": { "dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0", "@react-navigation/drawer": "^7.5.0",
@ -2017,18 +2015,6 @@
"node": ">=18" "node": ">=18"
} }
}, },
"node_modules/@expo-google-fonts/caveat": {
"version": "0.4.2",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/caveat/-/caveat-0.4.2.tgz",
"integrity": "sha512-kCG35tRUX8Wi+u0Jh5ck3K1u7vZkdLnwRTuKMrEnlVmikvxQFXN1KjF5cL2vqRG7Hwm7648Zl8xe1BWAoIsPCA==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo-google-fonts/quicksand": {
"version": "0.4.1",
"resolved": "https://registry.npmmirror.com/@expo-google-fonts/quicksand/-/quicksand-0.4.1.tgz",
"integrity": "sha512-LpGdmY+CDm/GTv29qDPy8Nz1a3idWO1R6Bb+ty7flS6vfHXsdOQTRKNz93SFHdXQj0FSvVhvMLe3wTtMgf312A==",
"license": "MIT AND OFL-1.1"
},
"node_modules/@expo/code-signing-certificates": { "node_modules/@expo/code-signing-certificates": {
"version": "0.0.6", "version": "0.0.6",
"resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz", "resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",

View File

@ -11,8 +11,6 @@
"typecheck": "tsc --noEmit" "typecheck": "tsc --noEmit"
}, },
"dependencies": { "dependencies": {
"@expo-google-fonts/caveat": "^0.4.2",
"@expo-google-fonts/quicksand": "^0.4.1",
"@expo/metro-runtime": "^6.1.2", "@expo/metro-runtime": "^6.1.2",
"@expo/vector-icons": "^15.0.3", "@expo/vector-icons": "^15.0.3",
"@react-navigation/drawer": "^7.5.0", "@react-navigation/drawer": "^7.5.0",

View File

@ -1,9 +1,19 @@
# PP-OCRv5 (ONNX Runtime) Config Plugin # PP-OCR (ONNX Runtime) Config Plugin
本插件在 `expo prebuild` 时注入 PP-OCRv5 本地 OCR 原生模块plan.md 决策 4 本插件在 `expo prebuild` 时注入 PP-OCR 本地 OCR 原生模块plan.md 决策 4
引擎选用 **ONNX Runtime**跨平台、微软官方、Windows 友好),替代原 NCNN 方案。 引擎选用 **ONNX Runtime**跨平台、微软官方、Windows 友好),替代原 NCNN 方案。
## 当前版本
**PP-OCRv6 small**det 2.5M 参数 + rec 5.3M 参数,精度显著优于 v5 mobile
| 指标 | PP-OCRv5 mobile | PP-OCRv6 small |
|------|----------------|----------------|
| det Hmean | 75.2% | 84.1% |
| rec W-Avg | 73.7% | 81.3% |
| rec ONNX 大小 | ~17 MB | ~21 MB |
## 文件结构 ## 文件结构
``` ```
@ -13,43 +23,41 @@ plugins/ppocr/
│ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理 │ ├── OcrModule.kt # React Native BridgeONNX Runtime 推理 + det/rec 前后处理
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages │ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages
└── assets/ # ONNX 模型 + 字典(需自行下载放置) └── assets/ # ONNX 模型 + 字典(需自行下载放置)
├── ppocrv5_det.onnx # 文本检测模型 ├── ppocrv6_det.onnx # 文本检测模型PP-OCRv6 small
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维 ├── ppocrv6_rec.onnx # 文本识别模型PP-OCRv6 small多语言
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典18383 字符,CTC 解码用) └── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
``` ```
## 模型获取(一键下载) ## 模型获取(一键下载)
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失) PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6)
```bash ```bash
# 在项目根目录执行 # 在项目根目录执行
mkdir -p plugins/ppocr/assets mkdir -p plugins/ppocr/assets
cd plugins/ppocr/assets cd plugins/ppocr/assets
# det 模型4.8 MB # det 模型PP-OCRv6 small
curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx curl -L -o ppocrv6_det.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
# rec 模型16.6 MB # rec 模型PP-OCRv6 small
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx curl -L -o ppocrv6_rec.onnx \
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
# PP-OCRv5 多语言字典74 KB必须与上面的 rec 模型配套) # PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt curl -L -o ppocrv6_dict.txt \
https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv6_dict.txt
``` ```
或用 HuggingFace CLI首次下载原生模型再转 ONNX 的方式,参见历史 git log > ⚠️ **字典必须与 rec 模型配套**v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码。
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`)。
> ⚠️ **字典必须与 rec 模型配套**ppocrv5_rec.onnx 输出 18385 维(= 18383 字符 + blank + 特殊位),
> 必须使用 `ppocrv5_dict.txt`18383 行)。若错用旧版 `ppocr_keys_v1.txt`(仅 6623 行),
> CTC 解码会把真实字符的高索引全部丢弃,只输出形如 `'消'青'露'仰'` 的单引号穿插单字符乱码。
> 来源说明:[ilaylow/PP_OCRv5_mobile_onnx](https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx) 是社区维护的 PP-OCRv5 mobile ONNX 镜像,基于官方 [PaddlePaddle/PP-OCRv5_mobile_det](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_det) 与 [_rec](https://huggingface.co/PaddlePaddle/PP-OCRv5_mobile_rec) 转换而来。
## 性能配置(参考 AutoAccounting OcrProcessor.kt ## 性能配置(参考 AutoAccounting OcrProcessor.kt
| 优化项 | 配置 | | 优化项 | 配置 |
|--------|------| |--------|------|
| 引擎 | ONNX Runtime Android 1.20.1 | | 引擎 | ONNX Runtime Android |
| 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) | | 执行器 | CPU兼容性最稳部分设备 GPU 会崩溃) |
| 线程 | intraOp=2 / interOp=2 | | 线程 | intraOp=2 / interOp=2 |
| det 图像 | 最大边 960px短边压缩 720px | | det 图像 | 最大边 960px短边压缩 720px |
@ -81,4 +89,14 @@ JS 层通过 `src/services/ocrBridge.ts` 的 `NativeOcrBridge` 调用,桥接
真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android` 真机构建步骤:放置模型文件 → `npx expo prebuild --platform android`Config Plugin 会把 Kotlin 源码与 `assets/` 下的模型/字典复制进 `android/`)→ `npx expo run:android`
> 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧字典(如 `ppocr_keys_v1.txt`),导致新代码找不到配套字典。 > 若之前已 prebuild 过且更换过字典/模型文件,务必重新执行 `npx expo prebuild --clean`,否则 `android/app/src/main/assets/` 下可能残留旧模型/字典。
## v5 → v6 迁移说明
若从 PP-OCRv5 升级,需完成以下步骤:
1. **下载新模型**:按上述「模型获取」章节下载 v6 模型和字典
2. **删除旧文件**:移除 `ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`
3. **代码已自动适配**`OcrModule.kt` 中的常量已更新为 v6 文件名
4. **重新 prebuild**`npx expo prebuild --clean` 确保旧资产被清理
5. **验证 tensor 名称**v6 ONNX 模型的输入 tensor 名可能与 v5 不同,若推理报错需用 Netron 检查并调整 `OcrModule.kt` 中的 `detInputs`/`recInputs` map key

View File

@ -29,11 +29,11 @@ import kotlin.math.max
import kotlin.math.min import kotlin.math.min
/** /**
* PP-OCRv5 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin * PP-OCRv6 (ONNX Runtime) React Native Bridgeplan.md3.4 Layer 2+决策 4 Config Plugin
* *
* 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线 * 引擎ONNX Runtime跨平台微软官方Windows 友好替代 NCNN 路线
* 模型ppocrv5_det.onnx + ppocrv5_rec.onnx ilaylow/PP_OCRv5_mobile_onnx 下载 * 模型ppocrv6_det.onnx + ppocrv6_rec.onnxPP-OCRv6 small PaddlePaddle 官方 HuggingFace 下载
* 字典ppocrv5_dict.txtPP-OCRv5 多语言字典18383 字符rec 模型 18385 维输出 = 字典 + blank + 特殊位 * 字典ppocrv6_dict.txtPP-OCRv6 多语言字典18708 字符rec 模型 18710 维输出 = 字典 + blank + 特殊位
* *
* 流水线 * 流水线
* 1. det文本检测bitmap DB 后处理得到文本框 * 1. det文本检测bitmap DB 后处理得到文本框
@ -94,7 +94,7 @@ class OcrModule(private val context: ReactApplicationContext) :
ortEnv = env ortEnv = env
dictionary = dict dictionary = dict
initialized = true initialized = true
Log.i(OCR_MODULE_NAME, "PP-OCRv5 ONNX 模型加载成功det+rec, dict=${dict.size}") Log.i(OCR_MODULE_NAME, "PP-OCRv6 ONNX 模型加载成功det+rec, dict=${dict.size}")
} catch (e: Exception) { } catch (e: Exception) {
initFailed = true initFailed = true
Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e) Log.e(OCR_MODULE_NAME, "OCR 初始化失败: ${e.message}", e)
@ -108,7 +108,7 @@ class OcrModule(private val context: ReactApplicationContext) :
private val recEnv: OrtEnvironment? get() = ortEnv private val recEnv: OrtEnvironment? get() = ortEnv
/** /**
* 加载 ppocrv5_dict.txt 字典 * 加载 ppocrv6_dict.txt 字典
* *
* PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始 * PaddleOCR CTC 约定模型输出 logits index 0 blank字符从 index 1 开始
* 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1 * 字典条目 dictionary[i] 对应模型输出 index i+1解码时 dictIdx = argmaxIdx - 1
@ -509,8 +509,9 @@ class OcrModule(private val context: ReactApplicationContext) :
* *
* PaddleOCR 约定logits index 0 固定是 blank字符从 index 1 * PaddleOCR 约定logits index 0 固定是 blank字符从 index 1
* dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1 * dictionary[i] 对应模型输出 index i+1因此 dictIdx = argmaxIdx - 1
* 已用 onnxruntime 实证argmax 序列中 0 占多数 blank真实字符索引 *
* 90'支' idx-1 映射到 dictionary 即可正确还原中文 * PP-OCRv6 模型输出已经是概率分布值域 [0,1]无需额外 softmax
* 直接取 argmax 对应的值作为该时间步的置信度
*/ */
private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> { private fun ctcGreedyDecode(logits: Array<FloatArray>): Pair<String, Float> {
if (logits.isEmpty()) return "" to 0f if (logits.isEmpty()) return "" to 0f
@ -527,16 +528,12 @@ class OcrModule(private val context: ReactApplicationContext) :
for (i in 1 until numClasses) { for (i in 1 until numClasses) {
if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i } if (logits[t][i] > maxVal) { maxVal = logits[t][i]; maxIdx = i }
} }
// softmax 概率(用于置信度统计)
var expSum = 0.0
for (i in 0 until numClasses) expSum += Math.exp(logits[t][i].toDouble())
val prob = Math.exp(maxVal.toDouble()) / expSum
if (maxIdx != blankIdx && maxIdx != lastIdx) { if (maxIdx != blankIdx && maxIdx != lastIdx) {
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1] val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
if (dictIdx in 0 until dictionary.size) { if (dictIdx in 0 until dictionary.size) {
sb.append(dictionary[dictIdx]) sb.append(dictionary[dictIdx])
confSum += prob.toFloat() confSum += maxVal // v6 输出已是概率,直接用
confCount++ confCount++
} }
} }
@ -650,10 +647,10 @@ class OcrModule(private val context: ReactApplicationContext) :
/** rec 单行最大宽度。 */ /** rec 单行最大宽度。 */
private const val REC_MAX_WIDTH = 320 private const val REC_MAX_WIDTH = 320
/** assets 中的模型/字典文件名。 */ /** assets 中的模型/字典文件名。 */
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx" private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx" private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
// PP-OCRv5 多语言识别模型的配套字典18383 字符 + 运行时 1 blank = 18385 维输出)。 // PP-OCRv6 多语言识别模型的配套字典18708 字符 + 运行时 1 blank = 18710 维输出)。
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt6623会导致 CTC 解码乱码 // 注意:必须与 rec 模型配套,错用 v5 字典18383会导致 CTC 解码丢字符
private const val ASSET_DICT = "ppocrv5_dict.txt" private const val ASSET_DICT = "ppocrv6_dict.txt"
} }
} }

Binary file not shown.

View File

@ -78,14 +78,14 @@ export async function generateMonthlySummary(
/** 无 AI 的纯文本总结(降级,不调 AI。 */ /** 无 AI 的纯文本总结(降级,不调 AI。 */
export function generatePlainTextSummary(stats: MonthlyStats): string { export function generatePlainTextSummary(stats: MonthlyStats): string {
const lines: string[] = []; const lines: string[] = [];
lines.push(`📊 ${stats.year}${stats.month}月财务总结`); lines.push(`${stats.year}${stats.month}月财务总结`);
lines.push(''); lines.push('');
lines.push(`💰 总收入:${stats.totalIncome}`); lines.push(`总收入:${stats.totalIncome}`);
lines.push(`💸 总支出:${stats.totalExpense}`); lines.push(`总支出:${stats.totalExpense}`);
lines.push(`📝 交易笔数:${stats.transactionCount}`); lines.push(`交易笔数:${stats.transactionCount}`);
lines.push(''); lines.push('');
if (stats.topCategories.length > 0) { if (stats.topCategories.length > 0) {
lines.push('🏷️ 主要支出分类:'); lines.push('主要支出分类:');
for (const cat of stats.topCategories) { for (const cat of stats.topCategories) {
lines.push(` ${cat.category}: ${cat.amount}`); lines.push(` ${cat.category}: ${cat.amount}`);
} }

View File

@ -1,53 +1,32 @@
import React from 'react'; import React from 'react';
import { Tabs } from 'expo-router'; import { Tabs } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { AppTabBar } from '../../components/AppTabBar';
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */ /** 底部 Tab 导航:首页/交易/报表/设置 + 中央AppTabBar。 */
export default function TabsLayout() { export default function TabsLayout() {
const { theme } = useTheme();
const t = useT(); const t = useT();
return ( return (
<Tabs <Tabs
screenOptions={{ screenOptions={{ headerShown: false }}
headerShown: false, tabBar={(props) => <AppTabBar {...props} />}
tabBarActiveTintColor: theme.colors.accent,
tabBarInactiveTintColor: theme.colors.fgSecondary,
tabBarStyle: {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
},
}}
> >
<Tabs.Screen <Tabs.Screen
name="index" name="index"
options={{ options={{ title: t('tab.home') }}
title: t('tab.home'),
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="transactions" name="transactions"
options={{ options={{ title: t('tab.transactions') }}
title: t('tab.transactions'),
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="report" name="report"
options={{ options={{ title: t('tab.report') }}
title: t('tab.report'),
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
}}
/> />
<Tabs.Screen <Tabs.Screen
name="settings" name="settings"
options={{ options={{ title: t('tab.settings') }}
title: t('tab.settings'),
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
}}
/> />
</Tabs> </Tabs>
); );

View File

@ -1,5 +1,10 @@
import React, { useEffect, useMemo, useRef } from 'react'; /**
import { Pressable, ScrollView, StyleSheet, Text, View, Alert } from 'react-native'; * spec §7.1
* header / 5
* /account
*/
import React, { useEffect, useMemo, useRef } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
@ -7,44 +12,35 @@ import { useMetadataStore } from '../../store/metadataStore';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { SpeedDial } from '../../components/SpeedDial';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { AccountTree } from '../../components/AccountTree'; import { TodoStrip } from '../../components/TodoStrip';
import { TrendLine } from '../../components/charts/TrendLine'; import { TrendLine } from '../../components/charts/TrendLine';
import { calculateNetWorth } from '../../domain/netWorth'; import { calculateNetWorth } from '../../domain/netWorth';
import { groupByMonth } from '../../domain/chartStats'; import { groupByMonth } from '../../domain/chartStats';
import { buildAccountTree } from '../../domain/accountTree';
import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring'; import { getDueRecurring, instantiateRecurring, calculateNextDueDate } from '../../domain/recurring';
import { calculateBudgetProgress } from '../../domain/budgets';
import { addDecimals, toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
export default function HomeScreen() { export default function HomeScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction); const addTransaction = useLedgerStore(s => s.addTransaction);
const recurringTransactions = useMetadataStore(s => s.recurringTransactions); const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction); const updateRecurringTransaction = useMetadataStore(s => s.updateRecurringTransaction);
const budgets = useMetadataStore(s => s.budgets);
const categories = useMetadataStore(s => s.categories);
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]); const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]); const netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]); const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
const currentMonth = useMemo(() => { const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
if (monthlyData.length === 0) return null;
return monthlyData[monthlyData.length - 1];
}, [monthlyData]);
// 1. 账户树计算
const accountNodes = useMemo(() => {
if (!ledger || !ledger.accounts) return [];
return buildAccountTree(ledger.accounts, transactions, ledger);
}, [ledger, transactions]);
// 2. 周期性记账到期计算 const todayStr = useMemo(() => toDateString(new Date()), []);
const todayStr = useMemo(() => { const d = new Date(); return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; }, []); const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
const dueRecurring = useMemo(() => {
return getDueRecurring(recurringTransactions, todayStr);
}, [recurringTransactions, todayStr]);
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项 // 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
const autoConfirmed = useRef(false); const autoConfirmed = useRef(false);
@ -70,19 +66,34 @@ export default function HomeScreen() {
return () => { isMounted = false; }; return () => { isMounted = false; };
}, [dueRecurring, addTransaction, updateRecurringTransaction]); }, [dueRecurring, addTransaction, updateRecurringTransaction]);
const handleConfirmRecurring = async (rec: any) => { // 月度预算剩余合计(无月度预算则不显示)
try { const budgetRemaining = useMemo(() => {
const draft = instantiateRecurring(rec); const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
await addTransaction(draft); if (monthlyBudgets.length === 0) return null;
const nextDueDate = calculateNextDueDate(rec.frequency, rec.interval, rec.nextDueDate); const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
updateRecurringTransaction(rec.id, { nextDueDate }); return remainings.reduce((a, b) => addDecimals([a, b]), '0');
Alert.alert(t('home.recurringSuccess'), t('home.recurringSuccessDesc', { name: rec.name })); }, [budgets, transactions, todayStr]);
} catch (e) {
Alert.alert(t('home.recurringFail'), String(e)); // 最近 5 条(按日期+序号倒序)
} const recentTxs = useMemo(() =>
transactions.map((tx, idx) => ({ tx, idx }))
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
.slice(0, 5)
.map(item => item.tx),
[transactions]);
// 分类匹配TransactionCard 图标)
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
}; };
// 3. 千分位格式化金额 // 问候语 + 日期行
const hour = new Date().getHours();
const greeting = hour < 12 ? t('home.greetingMorning') : hour < 18 ? t('home.greetingAfternoon') : t('home.greetingEvening');
const dateLine = new Date().toLocaleDateString('zh-CN', { month: 'long', day: 'numeric', weekday: 'long' });
// 千分位格式化金额
const fmtAmount = (s: string) => { const fmtAmount = (s: string) => {
if (!s || s === '0') return '¥0.00'; if (!s || s === '0') return '¥0.00';
const num = parseFloat(s); const num = parseFloat(s);
@ -94,104 +105,52 @@ export default function HomeScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}> <Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
{t('app.name')} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
</Text>
</View> </View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 净资产白卡 */}
{/* 净资产 Hero 卡片 */} <Card>
<Card <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
style={{ <Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
backgroundColor: theme.colors.accent,
borderColor: theme.colors.accentLight,
shadowColor: theme.colors.accent,
shadowOpacity: 0.15,
shadowRadius: 10,
}}
>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontSize: 13, fontWeight: '600', fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.netWorthTitle')}
</Text>
<Text style={[theme.typography.h1, { color: '#FFFFFF', fontSize: 32, fontWeight: '800', marginVertical: 8, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.netWorth)} {fmtAmount(netWorth.netWorth)}
</Text> </Text>
<View style={styles.heroFooter}> <View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
<View> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
{t('home.assets')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> -{fmtAmount(currentMonth?.expense.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.assets)}
</Text> </Text>
</View> </View>
<View style={{ alignItems: 'flex-end' }}> <View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
{t('home.liabilities')} <Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
</Text> +{fmtAmount(currentMonth?.income.toString() ?? '0')}
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
{fmtAmount(netWorth.liabilities)}
</Text> </Text>
</View> </View>
{budgetRemaining !== null && (
<View style={styles.heroStat}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.budgetRemaining')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
{fmtAmount(budgetRemaining)}
</Text>
</View>
)}
</View> </View>
</Card> </Card>
{/* Bento 双格排列 */} <TodoStrip />
{(dueRecurring.length > 0 || currentMonth) && (
<View style={styles.bentoRow}>
{dueRecurring.length > 0 ? (
<Card title={t('home.dueRecurring')} style={styles.bentoCol}>
<View style={styles.recurringList}>
{dueRecurring.slice(0, 2).map(rec => (
<View key={rec.id} style={[styles.recurringItem, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.caption.fontFamily }]} numberOfLines={1}>
{rec.name}
</Text>
<Pressable
onPress={() => handleConfirmRecurring(rec)}
style={({ pressed }) => [
styles.confirmBtnMini,
{
backgroundColor: theme.colors.bgTertiary,
borderColor: theme.colors.border,
borderRadius: theme.radii.sm,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Text style={{ color: theme.colors.accent, fontSize: 10, fontWeight: '800', textAlign: 'center' }}>
{rec.draft.postings[0]?.amount} CNY
</Text>
</Pressable>
</View>
))}
</View>
</Card>
) : null}
{currentMonth ? ( {/* 最近 5 条交易 */}
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}> {recentTxs.length > 0 && (
<View style={styles.bentoStats}> <Card title={t('home.recentTransactions')}>
<View> {recentTxs.map(tx => (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
{t('home.income')} ))}
</Text> <Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}> <Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} </Text>
+{fmtAmount(currentMonth.income.toString())} </Pressable>
</Text> </Card>
</View>
<View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.expense')}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
-{fmtAmount(currentMonth.expense.toString())}
</Text>
</View>
</View>
</Card>
) : null}
</View>
)} )}
{/* 月度趋势 */} {/* 月度趋势 */}
@ -200,19 +159,7 @@ export default function HomeScreen() {
<TrendLine transactions={transactions} /> <TrendLine transactions={transactions} />
</Card> </Card>
)} )}
{/* 账户与资产余额树 */}
{accountNodes.length > 0 && (
<Card title={t('home.accountTree')}>
<AccountTree nodes={accountNodes} />
</Card>
)}
</ScrollView> </ScrollView>
<SpeedDial actions={[
{ key: 'new', icon: 'create-outline', label: t('home.newTransaction'), onPress: () => router.push('/transaction/new') },
{ key: 'ocr', icon: 'camera-outline', label: t('home.ocrScan'), onPress: () => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } }) },
{ key: 'import', icon: 'download-outline', label: t('tab.import'), onPress: () => router.push('/import') },
]} />
</SafeAreaView> </SafeAreaView>
); );
} }
@ -220,13 +167,8 @@ export default function HomeScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 96 }, content: { padding: 16, paddingBottom: 40 },
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 }, heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
bentoRow: { flexDirection: 'row', gap: 12 }, heroStat: { gap: 2 },
bentoCol: { flex: 1 }, viewAll: { alignItems: 'center', paddingVertical: 6 },
recurringList: { gap: 8 },
recurringItem: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingBottom: 6, borderBottomWidth: StyleSheet.hairlineWidth },
confirmBtnMini: { paddingVertical: 4, paddingHorizontal: 8, borderWidth: StyleSheet.hairlineWidth },
bentoStats: { gap: 8 },
}); });

View File

@ -1,11 +1,10 @@
/** /**
* plan.md5.2 +5.3 * plan.md5.2 +5.3 P4 spec §7.3
* *
* *
* - / * - anchor + //periodNav
* - //// * - ////
* - react-native-view-shot * - AI react-native-view-shot
* - AI generatePlainTextSummary / generateMonthlySummary
*/ */
import React, { useMemo, useRef, useState } from 'react'; import React, { useMemo, useRef, useState } from 'react';
import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Modal, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
@ -20,7 +19,7 @@ import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { MonthlyReport } from '../../components/charts/MonthlyReport'; import { BottomSheet } from '../../components/BottomSheet';
import { CategoryPie } from '../../components/charts/CategoryPie'; import { CategoryPie } from '../../components/charts/CategoryPie';
import { CalendarView } from '../../components/CalendarView'; import { CalendarView } from '../../components/CalendarView';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
@ -28,7 +27,8 @@ import { NetWorthChart } from '../../components/charts/NetWorthChart';
import { AnnualReport } from '../../components/charts/AnnualReport'; import { AnnualReport } from '../../components/charts/AnnualReport';
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary'; import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
import { BaseOpenAIProvider } from '../../domain/ai'; import { BaseOpenAIProvider } from '../../domain/ai';
import { addDecimals, negateDecimal } from '../../domain/decimal'; import { addDecimals, negateDecimal, toDateString } from '../../domain/decimal';
import { periodRange, shiftAnchor, isoWeekNumber, type ReportPeriod } from '../../domain/periodNav';
export default function ReportScreen() { export default function ReportScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -41,16 +41,12 @@ export default function ReportScreen() {
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl); const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
const aiModel = useSettingsStore(s => s.aiModel); const aiModel = useSettingsStore(s => s.aiModel);
// Tab 状态 // 周期 + 单一 anchor 日期P4替代旧版年/月/周三套独立切换状态)
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly'); const [period, setPeriod] = useState<ReportPeriod>('monthly');
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
// 月份切换状态 // ⋯ 菜单
const now = new Date(); const [menuOpen, setMenuOpen] = useState(false);
const [viewYear, setViewYear] = useState(now.getFullYear());
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
// 周切换状态
const [viewWeekDate, setViewWeekDate] = useState(new Date());
// 日历选中日期用于明细展开 // 日历选中日期用于明细展开
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null); const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
@ -64,113 +60,60 @@ export default function ReportScreen() {
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]); const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
// 周报表的时间范围:计算周一和周日 // anchor 所在周期的起止日期与周期内交易
const weeklyRange = useMemo(() => { const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
const d = new Date(viewWeekDate); const rangeTxs = useMemo(
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat () => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
const diffToMonday = day === 0 ? -6 : 1 - day; [transactions, range],
);
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
monday.setHours(0,0,0,0);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
sunday.setHours(23,59,59,999);
// 计算 ISO 周数
const date = new Date(monday.getTime());
date.setHours(0, 0, 0, 0);
date.setDate(date.getDate() + 3 - (date.getDay() + 6) % 7);
const week1 = new Date(date.getFullYear(), 0, 4);
const weekNum = 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + (week1.getDay() + 6) % 7) / 7);
return { // 周期收支统计Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
startStr: monday.toISOString().slice(0, 10), const rangeStats = useMemo(() => {
endStr: sunday.toISOString().slice(0, 10), const incomeAmounts: string[] = [];
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})` const expenseAmounts: string[] = [];
}; for (const tx of rangeTxs) {
}, [viewWeekDate]); for (const p of tx.postings) {
if (!p.amount) continue;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: rangeTxs.length };
}, [rangeTxs]);
const changeWeek = (days: number) => { // 周期切换器 label
setSelectedReportDate(null); const periodLabel = useMemo(() => {
const next = new Date(viewWeekDate); if (period === 'weekly') {
next.setDate(next.getDate() + days); const monday = range.start;
setViewWeekDate(next); const sunday = range.end;
}; return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
}
if (period === 'monthly') return anchor.slice(0, 7);
return `${anchor.slice(0, 4)}`;
}, [period, anchor, range]);
// 净资产趋势的日期序列(基于选中月份,往前 6 个月) // 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
const netWorthDates = useMemo(() => { const netWorthDates = useMemo(() => {
const dates: string[] = []; const dates: string[] = [];
for (let i = 5; i >= 0; i--) { for (let i = 5; i >= 0; i--) {
const d = new Date(viewYear, viewMonth - 1 - i, 1); const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
dates.push(d.toISOString().slice(0, 10)); dates.push(toDateString(d));
} }
return dates; return dates;
}, [viewYear, viewMonth]); }, [anchor]);
const prevMonth = () => { const shift = (delta: number) => {
setSelectedReportDate(null); setSelectedReportDate(null);
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); } setAnchor(a => shiftAnchor(a, period, delta));
else setViewMonth(m => m - 1);
}; };
const nextMonth = () => {
setSelectedReportDate(null);
if (viewMonth === 12) { setViewMonth(1); setViewYear(y => y + 1); }
else setViewMonth(m => m + 1);
};
// 过滤后的交易
const weeklyTxs = useMemo(() => {
return transactions.filter(t => t.date >= weeklyRange.startStr && t.date <= weeklyRange.endStr);
}, [transactions, weeklyRange]);
const monthlyTxs = useMemo(() => {
const prefix = `${viewYear}-${String(viewMonth).padStart(2, '0')}`;
return transactions.filter(t => t.date.startsWith(prefix));
}, [transactions, viewYear, viewMonth]);
const annualTxs = useMemo(() => {
return transactions.filter(t => t.date.startsWith(String(viewYear)));
}, [transactions, viewYear]);
// 周收支统计
const weeklyStats = useMemo(() => {
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const t of weeklyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: weeklyTxs.length };
}, [weeklyTxs]);
// 月度收支统计
const monthlyStats = useMemo(() => {
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const t of monthlyTxs) {
for (const p of t.postings) {
if (!p.amount) continue;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
}
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
return { income, expense, net: income - expense, count: monthlyTxs.length };
}, [monthlyTxs]);
// 选中日期的交易明细 // 选中日期的交易明细
const selectedDayTxs = useMemo(() => { const selectedDayTxs = useMemo(() => {
if (!selectedReportDate) return []; if (!selectedReportDate) return [];
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate); return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
}, [monthlyTxs, selectedReportDate]); }, [rangeTxs, selectedReportDate]);
// 千分位格式化金额 // 千分位格式化金额
const fmtAmount = (num: number) => { const fmtAmount = (num: number) => {
@ -193,9 +136,11 @@ export default function ReportScreen() {
} }
}; };
// AI / 纯文本月度总结 // AI / 纯文本月度总结(基于 anchor 所在月)
const handleMonthlySummary = async () => { const handleMonthlySummary = async () => {
const stats = calculateMonthlyStats(transactions, viewYear, viewMonth); const year = Number(anchor.slice(0, 4));
const month = Number(anchor.slice(5, 7));
const stats = calculateMonthlyStats(transactions, year, month);
if (aiEnabled && aiApiKey && aiBaseUrl) { if (aiEnabled && aiApiKey && aiBaseUrl) {
try { try {
const provider = new (class extends BaseOpenAIProvider {})({ const provider = new (class extends BaseOpenAIProvider {})({
@ -205,7 +150,7 @@ export default function ReportScreen() {
baseUrl: aiBaseUrl, baseUrl: aiBaseUrl,
model: aiModel || 'glm-4-flash', model: aiModel || 'glm-4-flash',
}); });
const result = await generateMonthlySummary(transactions, viewYear, viewMonth, provider); const result = await generateMonthlySummary(transactions, year, month, provider);
setSummaryText(result.summary || generatePlainTextSummary(stats)); setSummaryText(result.summary || generatePlainTextSummary(stats));
} catch { } catch {
setSummaryText(generatePlainTextSummary(stats)); setSummaryText(generatePlainTextSummary(stats));
@ -216,20 +161,22 @@ export default function ReportScreen() {
setSummaryModal(true); setSummaryModal(true);
}; };
const monthLabel = `${viewYear}-${String(viewMonth).padStart(2, '0')}`; const monthLabel = anchor.slice(0, 7);
const anchorYear = Number(anchor.slice(0, 4));
const anchorMonth = Number(anchor.slice(5, 7));
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
<View style={styles.headerActions}> <Pressable
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}> onPress={() => setMenuOpen(true)}
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} /> style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
</Pressable> accessibilityRole="button"
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}> accessibilityLabel={t('report.menu')}
<Ionicons name="share-outline" size={22} color={theme.colors.accent} /> >
</Pressable> <Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
</View> </Pressable>
</View> </View>
{/* Tab 选择切换栏 */} {/* Tab 选择切换栏 */}
@ -242,20 +189,20 @@ export default function ReportScreen() {
<Pressable <Pressable
key={tab.key} key={tab.key}
onPress={() => { onPress={() => {
setActiveTab(tab.key); setPeriod(tab.key);
setSelectedReportDate(null); setSelectedReportDate(null);
}} }}
style={[ style={[
styles.tabBtn, styles.tabBtn,
activeTab === tab.key && { borderBottomColor: theme.colors.accent } period === tab.key && { borderBottomColor: theme.colors.accent }
]} ]}
> >
<Text <Text
style={[ style={[
theme.typography.body, theme.typography.body,
{ {
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary, color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
fontWeight: activeTab === tab.key ? '700' : '400', fontWeight: period === tab.key ? '700' : '400',
paddingVertical: 10 paddingVertical: 10
} }
]} ]}
@ -266,57 +213,25 @@ export default function ReportScreen() {
))} ))}
</View> </View>
{/* 日期选择切换器 */} {/* 统一周期切换器 */}
{activeTab === 'weekly' && ( <View style={styles.monthSwitcher}>
<View style={styles.monthSwitcher}> <Pressable onPress={() => shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Pressable onPress={() => changeWeek(-7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> <Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} /> </Pressable>
</Pressable> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}> {periodLabel}
{weeklyRange.label} </Text>
</Text> <Pressable onPress={() => shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Pressable onPress={() => changeWeek(7)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} /> </Pressable>
</Pressable> </View>
</View>
)}
{activeTab === 'monthly' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={prevMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{monthLabel}
</Text>
<Pressable onPress={nextMonth} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
{activeTab === 'annual' && (
<View style={styles.monthSwitcher}>
<Pressable onPress={() => setViewYear(y => y - 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{viewYear}
</Text>
<Pressable onPress={() => setViewYear(y => y + 1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
</Pressable>
</View>
)}
<ScrollView <ScrollView
ref={scrollRef} ref={scrollRef}
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]} contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
> >
{/* 数据空值判断 */} {/* 数据空值判断 */}
{((activeTab === 'weekly' && weeklyTxs.length === 0) || {rangeTxs.length === 0 ? (
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
<Card title={t('report.title')}> <Card title={t('report.title')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{t('report.empty')} {t('report.empty')}
@ -324,94 +239,94 @@ export default function ReportScreen() {
</Card> </Card>
) : ( ) : (
<> <>
{/* 周报表看板 */} {/* 周/月收支看板 */}
{activeTab === 'weekly' && ( {period !== 'annual' && (
<> <>
<Card title={t('report.weeklyTitle')}> <Card title={t(period === 'weekly' ? 'report.weeklyTitle' : 'report.monthlyTitle')}>
<View style={styles.statsRow}> <View style={styles.statsRow}>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyIncome' : 'report.monthlyIncome')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyExpense' : 'report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-weeklyStats.expense)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
</View> </View>
<View style={styles.statItem}> <View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t(period === 'weekly' ? 'report.weeklyNet' : 'report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</Text> <Text style={[theme.typography.h3, { color: theme.colors.accent, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
</View> </View>
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.weeklyStats', { count: weeklyStats.count })} {t(period === 'weekly' ? 'report.weeklyStats' : 'report.monthlyStats', { count: rangeStats.count })}
</Text> </Text>
</Card> </Card>
<CategoryPie transactions={weeklyTxs} /> <CategoryPie transactions={rangeTxs} />
</>
)}
{/* 月度报表看板 */} {/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
{activeTab === 'monthly' && ( {period === 'monthly' && (
<> <>
<Card title={`${monthLabel} 收支报告`}> <CalendarView
<View style={styles.statsRow}> transactions={transactions}
<View style={styles.statItem}> year={anchorYear}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text> month={anchorMonth}
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text> onDayPress={setSelectedReportDate}
</View> />
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-monthlyStats.expense)}</Text>
</View>
<View style={styles.statItem}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
</View>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
{t('report.monthlyStats', { count: monthlyStats.count })}
</Text>
</Card>
<CategoryPie transactions={monthlyTxs} /> {selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
<CalendarView {selectedDayTxs.map(tx => (
transactions={transactions} <TransactionCard
year={viewYear} key={tx.id}
month={viewMonth} transaction={tx}
onDayPress={setSelectedReportDate} onPress={() => router.push(`/transaction/${tx.id}`)}
/> />
))}
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && ( {selectedDayTxs.length === 0 && (
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{selectedDayTxs.map(t => ( {t('calendar.noDayTx')}
<TransactionCard </Text>
key={t.id} )}
transaction={t} </Card>
onPress={() => router.push(`/transaction/${t.id}`)}
/>
))}
{selectedDayTxs.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
{t('calendar.noDayTx')}
</Text>
)} )}
</Card>
)}
<NetWorthChart transactions={transactions} dates={netWorthDates} /> <NetWorthChart transactions={transactions} dates={netWorthDates} />
</>
)}
</> </>
)} )}
{/* 年度报表看板 */} {/* 年度报表看板 */}
{activeTab === 'annual' && ( {period === 'annual' && (
<AnnualReport transactions={transactions} year={viewYear} /> <AnnualReport transactions={transactions} year={anchorYear} />
)} )}
</> </>
)} )}
</ScrollView> </ScrollView>
{/* ⋯ 菜单 */}
<BottomSheet visible={menuOpen} onClose={() => setMenuOpen(false)} title={t('report.menu')}>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleMonthlySummary(); }}
accessibilityRole="button"
accessibilityLabel={t('report.aiSummary')}
>
<Ionicons name="sparkles-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.aiSummary')}</Text>
</Pressable>
<Pressable
style={({ pressed }) => [styles.menuRow, { opacity: pressed ? 0.6 : 1 }]}
onPress={() => { setMenuOpen(false); handleExportImage(); }}
accessibilityRole="button"
accessibilityLabel={t('report.exportImage')}
>
<Ionicons name="share-outline" size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{t('report.exportImage')}</Text>
</Pressable>
</BottomSheet>
{/* AI 总结弹窗 */} {/* AI 总结弹窗 */}
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}> <Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
<View style={commonStyles.modalOverlay}> <View style={commonStyles.modalOverlay}>
@ -437,7 +352,6 @@ export default function ReportScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 }, header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
headerActions: { flexDirection: 'row', gap: 16 },
iconBtn: { padding: 4 }, iconBtn: { padding: 4 },
monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 }, monthSwitcher: { flexDirection: 'row', alignItems: 'center', justifyContent: 'center', gap: 20, paddingBottom: 12 },
monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' }, monthBtn: { width: 32, height: 32, borderRadius: 16, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center' },
@ -446,5 +360,5 @@ const styles = StyleSheet.create({
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' }, tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
}); });

View File

@ -26,7 +26,7 @@ export default function SettingsScreen() {
]} ]}
> >
<Ionicons name={icon} size={20} color={theme.colors.accent} /> <Ionicons name={icon} size={20} color={theme.colors.accent} />
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12, fontFamily: theme.typography.body.fontFamily }]}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 12 }]}>
{label} {label}
</Text> </Text>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} /> <Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
@ -36,32 +36,46 @@ export default function SettingsScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <View style={styles.header}>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('settings.title')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.settings')}</Text>
</View> </View>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
{/* 数据与基础配置跳转 */} {/* 账户与分类 */}
<Card title={t('settings.dataManagement')}> <Card title={t('settings.groupAccount')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('wallet-outline', t('account.title'), '/account')} {renderNavLink('wallet-outline', t('account.title'), '/account')}
{renderNavLink('list', t('settings.categories'), '/category')} {renderNavLink('list', t('settings.categories'), '/category')}
{renderNavLink('pricetags', t('settings.tags'), '/tag')} {renderNavLink('pricetags', t('settings.tags'), '/tag')}
{renderNavLink('pie-chart', t('settings.budgets'), '/budget')} {renderNavLink('pie-chart', t('settings.budgets'), '/budget')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)} {renderNavLink('card-outline', t('settings.creditCards'), '/credit-card' as Href)}
{renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)} {renderNavLink('text-outline', t('remark.title'), '/remark-template' as Href)}
</View> </View>
</Card> </Card>
{/* 系统及高级配置跳转 */} {/* 记账自动化 */}
<Card title={t('settings.systemFeaturesTitle')}> <Card title={t('settings.groupAutomation')}>
<View style={styles.listGroup}> <View style={styles.listGroup}>
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
{renderNavLink('repeat-outline', t('settings.recurringTitle'), '/recurring')}
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
{renderNavLink('download-outline', t('settings.import'), '/import' as Href)}
</View>
</Card>
{/* 数据 */}
<Card title={t('settings.groupData')}>
<View style={styles.listGroup}>
{renderNavLink('cloud-upload-outline', t('settings.syncBackup'), '/settings/sync')}
{renderNavLink('pulse-outline', t('settings.diagnostics'), '/settings/diagnostics' as Href)}
</View>
</Card>
{/* 偏好 */}
<Card title={t('settings.groupPreferences')}>
<View style={styles.listGroup}>
{renderNavLink('phone-portrait-outline', t('settings.preferencesEntry'), '/settings/preferences')}
{renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')} {renderNavLink('cog-outline', t('settings.aiSettingsTitle'), '/settings/ai')}
{renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)} {renderNavLink('chatbubble-ellipses-outline', t('ai.chatTitle'), '/ai/chat' as Href)}
{renderNavLink('cloud-upload-outline', t('settings.syncSettingsTitle'), '/settings/sync')}
{renderNavLink('phone-portrait-outline', t('settings.preferencesTitle'), '/settings/preferences')}
{renderNavLink('flash-outline', t('automation.title'), '/automation' as Href)}
</View> </View>
</Card> </Card>

View File

@ -1,71 +1,104 @@
/** /**
* plan.md1.4 * spec §7.2 + 线
* * chip FilterSheet//
* : * // + /
* - SearchBar (narration/payee) * /settings/diagnostics
* - (///)
* - useSearch
* - TransactionCard +
* -
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo, useState } from 'react';
import { FlatList, Pressable, StyleSheet, Text, TextInput, View } from 'react-native'; import { Alert, Pressable, SectionList, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router'; import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme'; import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { SearchBar } from '../../components/SearchBar'; import { SearchBar } from '../../components/SearchBar';
import { TransactionCard } from '../../components/TransactionCard'; import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
import { Ionicons } from '@expo/vector-icons'; import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
import { groupTransactionsByDate } from '../../components/transactionGroups';
import { useSearch, type SearchFilters } from '../../hooks/useSearch'; import { useSearch, type SearchFilters } from '../../hooks/useSearch';
import { toDateString } from '../../domain/decimal';
import type { Transaction } from '../../domain/types';
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer'; type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
export default function TransactionsScreen() { export default function TransactionsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
const categories = useMetadataStore(s => s.categories);
const [keyword, setKeyword] = useState(''); const [keyword, setKeyword] = useState('');
const [direction, setDirection] = useState<DirectionFilter>('all'); const [direction, setDirection] = useState<DirectionFilter>('all');
const [showDiagnostics, setShowDiagnostics] = useState(false); const [filterSheetOpen, setFilterSheetOpen] = useState(false);
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false); const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
const [accountFilter, setAccountFilter] = useState('');
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const allTransactions = useMemo(() => { const allTransactions = useMemo(() => {
if (!ledger?.transactions) return []; if (!ledger?.transactions) return [];
return ledger.transactions return ledger.transactions
.map((tx, idx) => ({ tx, idx })) .map((tx, idx) => ({ tx, idx }))
.sort((a, b) => { .sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
const dateCompare = b.tx.date.localeCompare(a.tx.date);
if (dateCompare !== 0) return dateCompare;
return b.idx - a.idx;
})
.map(item => item.tx); .map(item => item.tx);
}, [ledger]); }, [ledger]);
const allAccounts = useMemo(() => {
const accts = new Set<string>();
for (const tx of allTransactions) {
for (const p of tx.postings) accts.add(p.account);
}
return Array.from(accts).sort();
}, [allTransactions]);
const filters: SearchFilters = useMemo(() => ({ const filters: SearchFilters = useMemo(() => ({
keyword: keyword || undefined, keyword: keyword || undefined,
direction: direction === 'all' ? undefined : direction, direction: direction === 'all' ? undefined : direction,
account: accountFilter || undefined, account: advanced.account || undefined,
dateFrom: dateFrom || undefined, dateFrom: advanced.dateFrom || undefined,
dateTo: dateTo || undefined, dateTo: advanced.dateTo || undefined,
}), [keyword, direction, accountFilter, dateFrom, dateTo]); amountMin: advanced.amountMin || undefined,
amountMax: advanced.amountMax || undefined,
}), [keyword, direction, advanced]);
const filtered = useSearch(allTransactions, filters); const filtered = useSearch(allTransactions, filters);
const sections = useMemo(() =>
groupTransactionsByDate(filtered).map(g => ({ ...g, data: g.items })),
[filtered]);
const advancedActive = advanced.account !== '' || advanced.dateFrom !== '' || advanced.dateTo !== '' || advanced.amountMin !== '' || advanced.amountMax !== '';
const todayStr = toDateString(new Date());
const yesterdayStr = toDateString(new Date(Date.now() - 86400000));
const dateLabel = (date: string) =>
date === todayStr ? t('transactions.today') : date === yesterdayStr ? t('transactions.yesterday') : date;
const categoryIdFor = (tx: Transaction): string | undefined => {
const target = tx.postings.find(p => p.account.startsWith('Expenses') || p.account.startsWith('Income'))?.account;
return target ? categories.find(c => c.linkedAccount === target)?.id : undefined;
};
// 左滑:复制(预填今天的录入页)
const handleDuplicate = (tx: Transaction) => {
const draftJson = JSON.stringify({
date: todayStr,
payee: tx.payee,
narration: tx.narration,
tags: tx.tags,
// cost/price 不带:复制是新建交易,丢 cost 属可接受简化(含 cost 的交易可从编辑入口进高级模式)
postings: tx.postings.map(p => ({ account: p.account, amount: p.amount, currency: p.currency })),
});
router.push({ pathname: '/transaction/new', params: { draftJson } });
};
// 左滑:删除(确认后从 main.bean 移除)
const handleDelete = (tx: Transaction) => {
Alert.alert(
t('transactions.deleteTitle'),
t('transactions.deleteMessage', { name: tx.narration || tx.payee || tx.date.slice(0, 10) }),
[
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => { deleteTransaction(tx.raw).catch(e => Alert.alert(t('common.error'), String(e))); } },
],
);
};
const directionTabs: { key: DirectionFilter; label: string }[] = [ const directionTabs: { key: DirectionFilter; label: string }[] = [
{ key: 'all', label: t('transactions.filterAll') }, { key: 'all', label: t('transactions.filterAll') },
@ -80,150 +113,79 @@ export default function TransactionsScreen() {
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text> <Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.transactions')}</Text>
</View> </View>
<SearchBar <SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
value={keyword}
onChangeText={setKeyword}
placeholder={t('transactions.searchPlaceholder')}
/>
{/* 方向筛选 */} {/* 方向筛选 + 高级筛选入口 */}
<View style={styles.filterRow}> <View style={styles.filterRow}>
{directionTabs.map(tab => ( {directionTabs.map(tab => (
<Pressable <Pressable key={tab.key} onPress={() => setDirection(tab.key)}
key={tab.key} style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
onPress={() => setDirection(tab.key)} <Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
style={[
commonStyles.chip,
direction === tab.key && commonStyles.chipActive,
]}
>
<Text style={[
commonStyles.chipText,
direction === tab.key && commonStyles.chipTextActive,
]}>
{tab.label}
</Text>
</Pressable> </Pressable>
))} ))}
</View> <View style={styles.flex1} />
{/* 高级筛选切换 */}
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
<Pressable <Pressable
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)} onPress={() => setFilterSheetOpen(true)}
style={({ pressed }) => [ style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
commonStyles.chip, accessibilityRole="button"
showAdvancedFilters && commonStyles.chipActive, accessibilityLabel={t('transactions.filter')}
{ opacity: pressed ? 0.8 : 1 },
]}
> >
<Text style={[ <Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
commonStyles.chipText, <Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
showAdvancedFilters && commonStyles.chipTextActive,
{ fontSize: 12 },
]}>
{t('transactions.advancedFilter')}
</Text>
</Pressable> </Pressable>
</View> </View>
{/* 高级筛选面板 */} <SectionList
{showAdvancedFilters && ( sections={sections}
<View style={{ paddingHorizontal: 16, paddingBottom: 8, gap: 6 }}>
{/* 账户筛选 */}
<TextInput
value={accountFilter}
onChangeText={setAccountFilter}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 日期范围 */}
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={dateFrom}
onChangeText={setDateFrom}
placeholder={t('transactions.dateFrom')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
<TextInput
value={dateTo}
onChangeText={setDateTo}
placeholder={t('transactions.dateTo')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1 }]}
/>
</View>
</View>
)}
<FlatList
data={filtered}
keyExtractor={(item) => item.id} keyExtractor={(item) => item.id}
renderItem={({ item: tx }) => ( renderItem={({ item: tx }) => (
<TransactionCard <SwipeableTransactionCard
transaction={tx} transaction={tx}
categoryId={categoryIdFor(tx)}
showDate={false}
onPress={() => router.push(`/transaction/${tx.id}`)} onPress={() => router.push(`/transaction/${tx.id}`)}
onDuplicate={handleDuplicate}
onDelete={handleDelete}
/> />
)} )}
ListHeaderComponent={ renderSectionHeader={({ section }) => (
<View> <View style={styles.sectionHeader}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
{filtered.length === allTransactions.length {dateLabel(section.date)}
? t('transactions.countTotal', { count: filtered.length }) </Text>
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
</Text> </Text>
</View> </View>
)}
ListHeaderComponent={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingBottom: 8 }]}>
{filtered.length === allTransactions.length
? t('transactions.countTotal', { count: filtered.length })
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
</Text>
} }
ListEmptyComponent={ ListEmptyComponent={
<Card title={t('transactions.noMatchTitle')}> <Card title={t('transactions.noMatchTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
{allTransactions.length === 0 {allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
? t('transactions.noMatchEmpty')
: t('transactions.noMatchFiltered')}
</Text> </Text>
</Card> </Card>
} }
ListFooterComponent={
<View>
{/* 诊断(折叠) */}
<Pressable
onPress={() => setShowDiagnostics(!showDiagnostics)}
style={[styles.diagToggle, { flexDirection: 'row', alignItems: 'center', gap: 4 }]}
>
<Ionicons
name={showDiagnostics ? 'chevron-down-outline' : 'chevron-forward-outline'}
size={14}
color={theme.colors.fgSecondary}
/>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{t('diagnostics.title')}
</Text>
</Pressable>
{showDiagnostics && (
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('diagnostics.hint')}
</Text>
</Card>
)}
</View>
}
contentContainerStyle={styles.content} contentContainerStyle={styles.content}
stickySectionHeadersEnabled={false}
initialNumToRender={10} initialNumToRender={10}
maxToRenderPerBatch={10} maxToRenderPerBatch={10}
windowSize={5} windowSize={5}
/> />
<FilterSheet
visible={filterSheetOpen}
onClose={() => setFilterSheetOpen(false)}
values={advanced}
onChange={setAdvanced}
onReset={() => setAdvanced(EMPTY_FILTERS)}
/>
</SafeAreaView> </SafeAreaView>
); );
} }
@ -231,7 +193,9 @@ export default function TransactionsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 }, header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8 }, filterRow: { flexDirection: 'row', gap: 6, paddingHorizontal: 16, paddingBottom: 8, alignItems: 'center' },
filterBtn: { flexDirection: 'row', alignItems: 'center', gap: 4 },
flex1: { flex: 1 },
content: { padding: 16, paddingTop: 8 }, content: { padding: 16, paddingTop: 8 },
diagToggle: { paddingVertical: 12, alignItems: 'center' }, sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
}); });

View File

@ -67,6 +67,7 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
</Text> </Text>
{data.stack && ( {data.stack && (
<View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}> <View style={[styles.stackBox, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border }]}>
{/* monospace 有意保留堆栈文本需要等宽对齐P5 审计允许项) */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]} numberOfLines={10}>
{data.stack} {data.stack}
</Text> </Text>

View File

@ -3,6 +3,7 @@ import { StatusBar } from 'expo-status-bar';
import { Stack } from 'expo-router'; import { Stack } from 'expo-router';
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native'; import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { GestureHandlerRootView } from 'react-native-gesture-handler';
import { ThemeProvider, useTheme } from '../theme'; import { ThemeProvider, useTheme } from '../theme';
import { useLedgerStore } from '../store/ledgerStore'; import { useLedgerStore } from '../store/ledgerStore';
import { useImportStore } from '../store/importStore'; import { useImportStore } from '../store/importStore';
@ -13,6 +14,7 @@ import { useAutomationStore } from '../store/automationStore';
import { FileSystemBackend } from '../services/fileSystemBackend'; import { FileSystemBackend } from '../services/fileSystemBackend';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { LockScreen } from '../components/LockScreen'; import { LockScreen } from '../components/LockScreen';
import { NumpadSheetHost } from '../components/NumpadSheetHost';
import { OnboardingScreen } from './_onboarding'; import { OnboardingScreen } from './_onboarding';
import { setupDeepLinking } from '../services/deepLink'; import { setupDeepLinking } from '../services/deepLink';
import { initPersistence, flushPersistence } from '../store/storePersistence'; import { initPersistence, flushPersistence } from '../store/storePersistence';
@ -24,19 +26,6 @@ import { parseSms } from '../services/sms';
import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline'; import { processScreenshotEvent, handleIncomingBillEvent, parseAndProcessAccessibilityTexts, loadProcessedTxKeys, pendingDrafts } from '../services/automationPipeline';
import { getAccessibilityBridge } from '../services/accessibilityBridge'; import { getAccessibilityBridge } from '../services/accessibilityBridge';
import { logger } from '../utils/logger'; import { logger } from '../utils/logger';
import {
useFonts,
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
} from '@expo-google-fonts/quicksand';
import {
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
} from '@expo-google-fonts/caveat';
let lastSignature = ''; let lastSignature = '';
let lastProcessedTime = 0; let lastProcessedTime = 0;
@ -67,16 +56,6 @@ function AppShell() {
const t = useT(); const t = useT();
const router = useRouter(); const router = useRouter();
const [fontsLoaded, fontError] = useFonts({
Quicksand_400Regular,
Quicksand_500Medium,
Quicksand_600SemiBold,
Quicksand_700Bold,
Caveat_400Regular,
Caveat_500Medium,
Caveat_600SemiBold,
Caveat_700Bold,
});
const loadLedger = useLedgerStore(s => s.loadLedger); const loadLedger = useLedgerStore(s => s.loadLedger);
const setContext = useImportStore(s => s.setContext); const setContext = useImportStore(s => s.setContext);
const appLockEnabled = useSettingsStore(s => s.appLockEnabled); const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
@ -367,7 +346,7 @@ function AppShell() {
}, [phase, checkAndReloadLedger]); }, [phase, checkAndReloadLedger]);
// loading 状态 // loading 状态
if (phase === 'loading' || (!fontsLoaded && !fontError)) { if (phase === 'loading') {
return ( return (
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}> <SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text> <Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
@ -398,7 +377,7 @@ function AppShell() {
<StatusBar style={isDark ? 'light' : 'dark'} /> <StatusBar style={isDark ? 'light' : 'dark'} />
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}> <Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
<Stack.Screen name="(tabs)" /> <Stack.Screen name="(tabs)" />
<Stack.Screen name="transaction/new" options={{ headerShown: true, title: t('home.newTransaction'), headerTintColor: theme.colors.fgPrimary, headerStyle: { backgroundColor: theme.colors.bgPrimary } }} /> <Stack.Screen name="transaction/new" options={{ headerShown: false }} />
<Stack.Screen name="transaction/[id]" options={{ headerShown: false }} /> <Stack.Screen name="transaction/[id]" options={{ headerShown: false }} />
<Stack.Screen name="category/index" options={{ headerShown: false }} /> <Stack.Screen name="category/index" options={{ headerShown: false }} />
<Stack.Screen name="tag/index" options={{ headerShown: false }} /> <Stack.Screen name="tag/index" options={{ headerShown: false }} />
@ -409,12 +388,14 @@ function AppShell() {
<Stack.Screen name="settings/ai" options={{ headerShown: false }} /> <Stack.Screen name="settings/ai" options={{ headerShown: false }} />
<Stack.Screen name="settings/sync" options={{ headerShown: false }} /> <Stack.Screen name="settings/sync" options={{ headerShown: false }} />
<Stack.Screen name="settings/preferences" options={{ headerShown: false }} /> <Stack.Screen name="settings/preferences" options={{ headerShown: false }} />
<Stack.Screen name="settings/diagnostics" options={{ headerShown: false }} />
<Stack.Screen name="recurring/index" options={{ headerShown: false }} /> <Stack.Screen name="recurring/index" options={{ headerShown: false }} />
<Stack.Screen name="ai/chat" options={{ headerShown: false }} /> <Stack.Screen name="ai/chat" options={{ headerShown: false }} />
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} /> <Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
<Stack.Screen name="automation/index" options={{ headerShown: false }} /> <Stack.Screen name="automation/index" options={{ headerShown: false }} />
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} /> <Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
</Stack> </Stack>
<NumpadSheetHost />
{/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容并拦截所有手势 */} {/* 隐私遮罩plan.md「0.4」App 切后台时遮盖内容并拦截所有手势 */}
{privacyOverlay && ( {privacyOverlay && (
<View <View
@ -427,11 +408,13 @@ function AppShell() {
); );
} }
/** 根布局:包裹 ThemeProvider。 */ /** 根布局:GestureHandlerRootView左滑手势包裹 ThemeProvider。 */
export default function RootLayout() { export default function RootLayout() {
return ( return (
<ThemeProvider> <GestureHandlerRootView style={{ flex: 1 }}>
<AppShell /> <ThemeProvider>
</ThemeProvider> <AppShell />
</ThemeProvider>
</GestureHandlerRootView>
); );
} }

View File

@ -1,14 +1,14 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { addDecimals } from '../../domain/decimal'; import { ScreenHeader } from '../../components/ScreenHeader';
import { toDateString } from '../../domain/decimal';
import { computeAccountBalances } from '../../domain/ledger'; import { computeAccountBalances } from '../../domain/ledger';
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income'; type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
@ -16,7 +16,6 @@ type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
export default function AccountScreen() { export default function AccountScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts); const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@ -111,7 +110,7 @@ export default function AccountScreen() {
const handleConfirmAdjust = async (values: Record<string, string>) => { const handleConfirmAdjust = async (values: Record<string, string>) => {
if (!adjustingAccount) return; if (!adjustingAccount) return;
const balanceVal = values.balance?.trim(); const balanceVal = values.balance?.trim();
const dateVal = values.date?.trim() || new Date().toISOString().slice(0, 10); const dateVal = values.date?.trim() || toDateString(new Date());
if (!balanceVal) { if (!balanceVal) {
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty')); Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
@ -157,15 +156,7 @@ export default function AccountScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
{/* 头部导航 */} <ScreenHeader title={t('account.title')} />
<View style={styles.header}>
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('account.title')}
</Text>
</View>
{/* Tab 选项卡 */} {/* Tab 选项卡 */}
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}> <View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
@ -289,7 +280,7 @@ export default function AccountScreen() {
key: 'date', key: 'date',
label: t('account.fieldDate'), label: t('account.fieldDate'),
placeholder: 'YYYY-MM-DD', placeholder: 'YYYY-MM-DD',
defaultValue: new Date().toISOString().slice(0, 10), defaultValue: toDateString(new Date()),
}, },
]} ]}
onConfirm={handleConfirmAdjust} onConfirm={handleConfirmAdjust}
@ -301,7 +292,6 @@ export default function AccountScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 }, tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 }, tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
content: { padding: 16, gap: 12 }, content: { padding: 16, gap: 12 },

View File

@ -19,6 +19,7 @@ import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai'; import { BaseOpenAIProvider, type AiProviderConfig, type AiProvider, type ChatMessage as AiChatMessage } from '../../domain/ai';
import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant'; import { processChatMessage, createConversation, appendMessage, type ChatConversation, type ChatResponse } from '../../ai/chatAssistant';
import { ScreenHeader } from '../../components/ScreenHeader';
interface UiMessage { interface UiMessage {
role: 'user' | 'assistant'; role: 'user' | 'assistant';
@ -113,26 +114,33 @@ export default function AIChatScreen() {
{item.content} {item.content}
</Text> </Text>
{/* 账单卡片 */} {/* 账单卡片 */}
{item.billCards && item.billCards.map((card, i) => ( {item.billCards && item.billCards.map((card, i) => {
// 方向符号 + 金额上色与全局方向色约定一致TransactionCardincome 绿 / expense 红 / transfer 蓝)
const dirColor = card.type === 'income' ? theme.colors.financial.income
: card.type === 'transfer' ? theme.colors.financial.transfer
: theme.colors.financial.expense;
const dirSign = card.type === 'income' ? '' : card.type === 'transfer' ? '⇄' : '';
return (
<Pressable <Pressable
key={i} key={i}
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })} onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]} style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
> >
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}> <Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency} {dirSign} {card.amount} {card.currency}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{card.counterparty} · {card.narration} {card.counterparty} · {card.narration}
</Text> </Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}> <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 4 }}>
<Text style={[theme.typography.caption, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.accent }]}>
{t('ai.tapToRecord')} {t('ai.tapToRecord')}
</Text> </Text>
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} /> <Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
</View> </View>
</Pressable> </Pressable>
))} );
})}
</View> </View>
</View> </View>
); );
@ -140,14 +148,7 @@ export default function AIChatScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}> <ScreenHeader title={t('ai.chatTitle')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('ai.chatTitle')}
</Text>
</View>
<FlatList <FlatList
ref={listRef} ref={listRef}
@ -192,7 +193,6 @@ export default function AIChatScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
msgRow: { flexDirection: 'row', maxWidth: '100%' }, msgRow: { flexDirection: 'row', maxWidth: '100%' },
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 }, bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 }, billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },

View File

@ -18,7 +18,6 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native'; import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
@ -28,6 +27,7 @@ import { useMetadataStore } from '../../store/metadataStore';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { ScreenHeader } from '../../components/ScreenHeader';
import type { AutomationSource } from '../../store/automationStore'; import type { AutomationSource } from '../../store/automationStore';
import { import {
getAccessibilityBridge, getAccessibilityBridge,
@ -46,7 +46,6 @@ const SOURCE_LABELS: Record<AutomationSource, string> = {
export default function AutomationScreen() { export default function AutomationScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const detected = useAutomationStore(s => s.detected); const detected = useAutomationStore(s => s.detected);
const drafts = useAutomationStore(s => s.drafts); const drafts = useAutomationStore(s => s.drafts);
@ -250,14 +249,7 @@ export default function AutomationScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <ScreenHeader title={t('automation.title')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('automation.title')}
</Text>
</View>
<FlatList <FlatList
data={drafts} data={drafts}
@ -444,7 +436,6 @@ export default function AutomationScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 }, content: { padding: 16, paddingBottom: 64 },
statsRow: { flexDirection: 'row', justifyContent: 'space-around' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },

View File

@ -3,27 +3,23 @@
* *
* () + // * () + //
* calculateBudgetProgress ,// * calculateBudgetProgress ,//
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { calculateBudgetProgress } from '../../domain/budgets'; import { calculateBudgetProgress } from '../../domain/budgets';
import { toDateString } from '../../domain/decimal';
import type { Budget } from '../../domain/budgets'; import type { Budget } from '../../domain/budgets';
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
export default function BudgetScreen() { export default function BudgetScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const budgets = useMetadataStore(s => s.budgets); const budgets = useMetadataStore(s => s.budgets);
const addBudget = useMetadataStore(s => s.addBudget); const addBudget = useMetadataStore(s => s.addBudget);
@ -31,130 +27,94 @@ export default function BudgetScreen() {
const removeBudget = useMetadataStore(s => s.removeBudget); const removeBudget = useMetadataStore(s => s.removeBudget);
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const [modal, setModal] = useState<ModalMode>(null);
const transactions = ledger?.transactions ?? []; const transactions = ledger?.transactions ?? [];
const today = new Date().toISOString().slice(0, 10); // 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const handleDelete = (budget: Budget) => {
Alert.alert(t('budget.deleteTitle'), t('budget.deleteConfirm', { name: budget.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeBudget(budget.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('budget.unnamed');
const amount = values.amount?.trim() || '0';
const period = (values.period as Budget['period']) || 'monthly';
const categoryAccount = values.categoryAccount?.trim() || undefined;
const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return;
}
if (modal?.type === 'add') {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
} else if (modal?.type === 'edit') {
updateBudget(modal.budget.id, { name, amount, period, categoryAccount, startDate });
}
setModal(null);
};
const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly'); const periodLabel = (p: string) => p === 'monthly' ? t('budget.periodMonthly') : p === 'weekly' ? t('budget.periodWeekly') : t('budget.periodYearly');
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: modal.budget.name },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: modal.budget.amount, keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: modal.budget.period },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: modal.budget.categoryAccount ?? '' },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: modal.budget.startDate },
] : [];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Budget>
<View style={styles.header}> title={t('budget.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={budgets}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('budget.title')}</Text> keyExtractor={budget => budget.id}
</View> addLabel={t('budget.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('budget.empty')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> renderItem={(budget, { openEdit, confirmDelete }) => {
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> const progress = calculateBudgetProgress(budget, transactions, today);
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text> return (
</Pressable> <Pressable onPress={openEdit} onLongPress={confirmDelete}>
{budgets.map(budget => { <Card title={budget.name}>
const progress = calculateBudgetProgress(budget, transactions, today); <View style={styles.budgetRow}>
return ( <Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
<Pressable {t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
key={budget.id} </Text>
onPress={() => setModal({ type: 'edit', budget })} <Text style={[theme.typography.caption, {
onLongPress={() => handleDelete(budget)} color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
> }]}>
<Card title={budget.name}> {t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
<View style={styles.budgetRow}> </Text>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}> </View>
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })} {/* 进度条 */}
</Text> <View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
<Text style={[theme.typography.caption, { <View style={[
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary, styles.bar,
}]}> {
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })} width: `${Math.min(progress.percentage, 100)}%`,
</Text> backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
</View> },
{/* 进度条 */} ]} />
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}> </View>
<View style={[ {budget.categoryAccount && (
styles.bar, <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
{ {budget.categoryAccount}
width: `${Math.min(progress.percentage, 100)}%`, </Text>
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent, )}
}, </Card>
]} /> </Pressable>
</View> );
{budget.categoryAccount && ( }}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}> formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
{budget.categoryAccount} formFields={editing => [
</Text> { key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder'), defaultValue: editing?.name },
)} { key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), defaultValue: editing?.amount, keyboardType: 'decimal-pad' },
</Card> { key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
</Pressable> { key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: editing?.categoryAccount ?? '' },
); { key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: editing?.startDate },
})} ]}
{budgets.length === 0 && ( onSubmit={(values, editing) => {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> const name = values.name?.trim() || t('budget.unnamed');
{t('budget.empty')} const amount = values.amount?.trim() || '0';
</Text> const period = (values.period as Budget['period']) || 'monthly';
)} const categoryAccount = values.categoryAccount?.trim() || undefined;
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> const startDate = values.startDate?.trim() || today;
const amtNum = parseFloat(amount);
if (isNaN(amtNum) || amtNum <= 0) {
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
return false;
}
if (editing) {
updateBudget(editing.id, { name, amount, period, categoryAccount, startDate });
} else {
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
}
return true;
}}
onDelete={budget => removeBudget(budget.id)}
deleteConfirmText={budget => t('budget.deleteConfirm', { name: budget.name })}
deleteConfirmTitle={t('budget.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('budget.editTitle') : t('budget.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('budget.fieldName'), placeholder: t('budget.fieldNamePlaceholder') },
{ key: 'amount', label: t('budget.fieldAmount'), placeholder: t('budget.fieldAmountPlaceholder'), keyboardType: 'decimal-pad' },
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly' },
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder') },
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, budgetRow: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 }, barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
bar: { height: '100%', borderRadius: 4 }, bar: { height: '100%', borderRadius: 4 },

View File

@ -1,136 +1,107 @@
/** /**
* plan.md1.1 category/index+ 1 * plan.md1.1 category/index+ 1
* *
* /+ // * / chips + //
* metadataStorelinkedAccount Beancount * metadataStorelinkedAccount Beancount
* P5 ManagementScreen headerContent / chips
*/ */
import React, { useState } from 'react'; import React, { useMemo, useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { useTheme, createCommonStyles } from '../../theme';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import type { Category } from '../../domain/categories'; import type { Category } from '../../domain/categories';
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
export default function CategoryScreen() { export default function CategoryScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter(); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const categories = useMetadataStore(s => s.categories); const categories = useMetadataStore(s => s.categories);
const addCategory = useMetadataStore(s => s.addCategory); const addCategory = useMetadataStore(s => s.addCategory);
const updateCategory = useMetadataStore(s => s.updateCategory); const updateCategory = useMetadataStore(s => s.updateCategory);
const removeCategory = useMetadataStore(s => s.removeCategory); const removeCategory = useMetadataStore(s => s.removeCategory);
const [modal, setModal] = useState<ModalMode>(null); /** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
const expenseCats = categories.filter(c => c.type === 'expense');
const incomeCats = categories.filter(c => c.type === 'income');
const handleDelete = (cat: Category) => {
Alert.alert(t('category.deleteTitle'), t('category.deleteConfirm', { name: cat.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCategory(cat.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
if (modal?.type === 'add') {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: modal.catType,
linkedAccount: values.linkedAccount || (modal.catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else if (modal?.type === 'edit') {
updateCategory(modal.category.id, {
name: values.name || modal.category.name,
linkedAccount: values.linkedAccount || modal.category.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
setModal(null);
};
const renderRow = (cat: Category) => (
<Pressable
key={cat.id}
onPress={() => setModal({ type: 'edit', category: cat })}
onLongPress={() => handleDelete(cat)}
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
>
<View style={{ flex: 1 }}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>{cat.name}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</View>
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
</Pressable>
);
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: modal.category.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: modal.category.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: modal.category.keywords.join(', ') },
] : [];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Category>
<View style={styles.header}> title={t('category.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={categories.filter(c => c.type === catType)}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('category.title')}</Text> keyExtractor={cat => cat.id}
</View> addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
<ScrollView contentContainerStyle={styles.content}> headerContent={
<Card title={t('category.expense')}> <View style={styles.chipRow}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}> {(['expense', 'income'] as const).map(tp => (
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> <Pressable
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text> key={tp}
</Pressable> onPress={() => setCatType(tp)}
{expenseCats.map(renderRow)} style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
</Card> >
<Card title={t('category.income')}> <Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}> {tp === 'expense' ? t('category.expense') : t('category.income')}
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> </Text>
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text> </Pressable>
</Pressable> ))}
{incomeCats.map(renderRow)} </View>
</Card> }
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> renderItem={(cat, { openEdit, confirmDelete }) => (
<Pressable
onPress={openEdit}
onLongPress={confirmDelete}
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
>
<Card title={cat.name}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{cat.linkedAccount}</Text>
{cat.keywords.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{t('category.keywordsLabel')}: {cat.keywords.join(', ')}
</Text>
)}
</Card>
</Pressable>
)}
formTitle={editing => (editing
? t('category.editTitle')
: t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') }))}
formFields={editing => [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder'), defaultValue: editing?.name },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder'), defaultValue: editing?.linkedAccount },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder'), defaultValue: editing?.keywords.join(', ') },
]}
onSubmit={(values, editing) => {
if (editing) {
updateCategory(editing.id, {
name: values.name || editing.name,
linkedAccount: values.linkedAccount || editing.linkedAccount,
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
} else {
addCategory({
id: generateId('cat'),
name: values.name || t('common.untitled'),
type: catType,
linkedAccount: values.linkedAccount || (catType === 'income' ? 'Income:Uncategorized' : 'Expenses:Uncategorized'),
keywords: values.keywords ? values.keywords.split(/[,\s]+/).filter(Boolean) : [],
});
}
return true;
}}
onDelete={cat => removeCategory(cat.id)}
deleteConfirmText={cat => t('category.deleteConfirm', { name: cat.name })}
deleteConfirmTitle={t('category.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('category.editTitle') : t('category.addTitle', { type: modal?.catType === 'income' ? t('category.income') : t('category.expense') })}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('category.fieldName'), placeholder: t('category.namePlaceholder') },
{ key: 'linkedAccount', label: t('category.fieldLinkedAccount'), placeholder: t('category.accountPlaceholder') },
{ key: 'keywords', label: t('category.fieldKeywords'), placeholder: t('category.keywordsPlaceholder') },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, chipRow: { flexDirection: 'row', gap: 8 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
row: { flexDirection: 'row', alignItems: 'center', borderTopWidth: 1, paddingTop: 10 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 10 },
}); });

View File

@ -1,29 +1,24 @@
/** /**
* plan.md1.1 credit-card/index+ 1 * plan.md1.1 credit-card/index+ 1
* *
* (////) + // * (/////) + //
* linkedAccount Beancount Liabilities * linkedAccount Beancount Liabilities
* P5 ManagementScreen renderItem
*/ */
import React, { useMemo, useState } from 'react'; import React, { useMemo } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useLedgerStore } from '../../store/ledgerStore'; import { useLedgerStore } from '../../store/ledgerStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import type { CreditCard } from '../../domain/creditCards'; import type { CreditCard } from '../../domain/creditCards';
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards'; import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
export default function CreditCardScreen() { export default function CreditCardScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const creditCards = useMetadataStore(s => s.creditCards); const creditCards = useMetadataStore(s => s.creditCards);
const addCreditCard = useMetadataStore(s => s.addCreditCard); const addCreditCard = useMetadataStore(s => s.addCreditCard);
@ -33,8 +28,6 @@ export default function CreditCardScreen() {
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const transactions = ledger?.transactions ?? []; const transactions = ledger?.transactions ?? [];
const [modal, setModal] = useState<ModalMode>(null);
/** 预计算所有账户余额 Map避免在渲染循环中重复遍历。 */ /** 预计算所有账户余额 Map避免在渲染循环中重复遍历。 */
const accountBalances = useMemo(() => { const accountBalances = useMemo(() => {
const map = new Map<string, string>(); const map = new Map<string, string>();
@ -51,133 +44,104 @@ export default function CreditCardScreen() {
const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00'; const getAccountBalance = (account: string): string => accountBalances.get(account) ?? '0.00';
const handleDelete = (card: CreditCard) => {
Alert.alert(t('creditCard.deleteTitle'), t('creditCard.deleteConfirm', { name: card.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeCreditCard(card.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (modal?.type === 'add') {
addCreditCard({ ...card, id: generateId('cc') });
} else if (modal?.type === 'edit') {
updateCreditCard(modal.card.id, card);
}
setModal(null);
};
const fieldsFor = (card?: CreditCard): FormField[] => [
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: card?.name },
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: card?.bankName },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: card?.lastFour, keyboardType: 'numeric' },
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: card ? String(card.billingDay) : '', keyboardType: 'numeric' },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: card ? String(card.paymentDay) : '', keyboardType: 'numeric' },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: card?.creditLimit, keyboardType: 'decimal-pad' },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: card?.currency },
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: card?.linkedAccount },
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<CreditCard>
<View style={styles.header}> title={t('creditCard.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={creditCards}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('creditCard.title')}</Text> keyExtractor={card => card.id}
</View> addLabel={t('creditCard.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('creditCard.empty')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> renderItem={(card, { openEdit, confirmDelete }) => {
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> const period = calculateBillingPeriod(card, new Date());
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text> const statementAmount = calculateStatementAmount(transactions, card, period);
</Pressable> const currentBalance = getAccountBalance(card.linkedAccount);
{creditCards.map(card => { const availableCredit = calculateAvailableCredit(card, currentBalance);
const period = calculateBillingPeriod(card, new Date()); return (
const statementAmount = calculateStatementAmount(transactions, card, period); <Pressable onPress={openEdit} onLongPress={confirmDelete}>
const currentBalance = getAccountBalance(card.linkedAccount); <Card title={card.name}>
const availableCredit = calculateAvailableCredit(card, currentBalance); <View style={styles.infoRow}>
return ( <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
<Pressable <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
key={card.id} </View>
onPress={() => setModal({ type: 'edit', card })} <View style={styles.infoRow}>
onLongPress={() => handleDelete(card)} <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text>
> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text>
<Card title={card.name}> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View> </View>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBillingDay')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.billingDay} {t('creditCard.daySuffix')}</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View> </View>
<View style={styles.infoRow}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelPaymentDay')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.paymentDay} {t('creditCard.daySuffix')}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelLimit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.creditLimit} {card.currency}</Text>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>{card.linkedAccount}</Text>
{/* 账单周期与应还 */} {/* 账单周期与应还 */}
<View style={[styles.billingBox, { borderColor: theme.colors.divider }]}> <View style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
<View style={styles.infoRow}> <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.billingPeriod')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{period.periodStart} ~ {period.periodEnd}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
</View> </View>
</Card> <View style={styles.infoRow}>
</Pressable> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.dueDate')}</Text>
); <Text style={[theme.typography.bodySmall, { color: theme.colors.error }]}>{period.dueDate}</Text>
})} </View>
{creditCards.length === 0 && ( <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.statementAmount')}</Text>
{t('creditCard.empty')} <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>{statementAmount} {card.currency}</Text>
</Text> </View>
)} <View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.availableCredit')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent }]}>{availableCredit} {card.currency}</Text>
</View>
</View>
</Card>
</Pressable>
);
}}
formTitle={editing => (editing ? t('creditCard.editTitle') : t('creditCard.add'))}
formFields={editing => [
{ key: 'name', label: t('creditCard.fieldName'), placeholder: '招行信用卡', defaultValue: editing?.name },
{ key: 'bankName', label: t('creditCard.fieldBank'), placeholder: 'CMB', defaultValue: editing?.bankName },
{ key: 'lastFour', label: t('creditCard.fieldLastFour'), placeholder: '1234', defaultValue: editing?.lastFour, keyboardType: 'numeric' },
{ key: 'billingDay', label: t('creditCard.fieldBillingDay'), placeholder: '5', defaultValue: editing ? String(editing.billingDay) : '', keyboardType: 'numeric' },
{ key: 'paymentDay', label: t('creditCard.fieldPaymentDay'), placeholder: '25', defaultValue: editing ? String(editing.paymentDay) : '', keyboardType: 'numeric' },
{ key: 'creditLimit', label: t('creditCard.fieldLimit'), placeholder: '10000', defaultValue: editing?.creditLimit, keyboardType: 'decimal-pad' },
{ key: 'currency', label: t('creditCard.fieldCurrency'), placeholder: 'CNY', defaultValue: editing?.currency },
{ key: 'linkedAccount', label: t('creditCard.fieldLinkedAccount'), placeholder: 'Liabilities:CreditCard:CMB', defaultValue: editing?.linkedAccount },
]}
onSubmit={(values, editing) => {
const card: Omit<CreditCard, 'id'> = {
name: values.name?.trim() || t('creditCard.unnamed'),
lastFour: values.lastFour?.trim() || '0000',
bankName: values.bankName?.trim() || 'UNKNOWN',
billingDay: parseInt(values.billingDay, 10) || 1,
paymentDay: parseInt(values.paymentDay, 10) || 1,
creditLimit: values.creditLimit?.trim() || '0',
currency: values.currency?.trim() || 'CNY',
linkedAccount: values.linkedAccount?.trim() || 'Liabilities:CreditCard',
};
if (editing) {
updateCreditCard(editing.id, card);
} else {
addCreditCard({ ...card, id: generateId('cc') });
}
return true;
}}
onDelete={card => removeCreditCard(card.id)}
deleteConfirmText={card => t('creditCard.deleteConfirm', { name: card.name })}
deleteConfirmTitle={t('creditCard.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('creditCard.editTitle') : t('creditCard.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.card : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4 },
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 }, billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
}); });

View File

@ -1,18 +1,25 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native'; import { FlatList, StyleSheet, Text, View, Pressable, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as XLSX from 'xlsx'; import * as XLSX from 'xlsx';
// 直接实例化 GBK 解码器,不修改全局 TextDecoder/TextEncoder // 实例化纯 JS GBK 解码器Hermes 原生 TextDecoder 仅支持 UTF-8需强迫 text-encoding-gbk 返回其纯 JS 实现)
const GbkTextDecoder = (() => { const GbkTextDecoder = (() => {
const origDecoder = (globalThis as any).TextDecoder;
const origEncoder = (globalThis as any).TextEncoder;
try { try {
return require('text-encoding-gbk').TextDecoder; (globalThis as any).TextDecoder = undefined;
(globalThis as any).TextEncoder = undefined;
const dec = require('text-encoding-gbk').TextDecoder;
return dec;
} catch { } catch {
return TextDecoder; // 降级到系统默认 return origDecoder;
} finally {
(globalThis as any).TextDecoder = origDecoder;
(globalThis as any).TextEncoder = origEncoder;
} }
})(); })();
@ -24,6 +31,7 @@ import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { DedupBanner } from '../../components/DedupBanner'; import { DedupBanner } from '../../components/DedupBanner';
import { ScreenHeader } from '../../components/ScreenHeader';
import { classifyWithCategories } from '../../domain/rules'; import { classifyWithCategories } from '../../domain/rules';
import { validateTransaction } from '../../domain/ledger'; import { validateTransaction } from '../../domain/ledger';
import type { ImportedEvent } from '../../domain/types'; import type { ImportedEvent } from '../../domain/types';
@ -60,7 +68,6 @@ function base64ToBytes(base64: string): Uint8Array {
export default function ImportScreen() { export default function ImportScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const ledger = useLedgerStore(s => s.ledger); const ledger = useLedgerStore(s => s.ledger);
const addTransaction = useLedgerStore(s => s.addTransaction); const addTransaction = useLedgerStore(s => s.addTransaction);
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts); const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
@ -237,10 +244,8 @@ export default function ImportScreen() {
// 从待确认中批量移除 // 从待确认中批量移除
confirmDrafts(indices); confirmDrafts(indices);
// 重新读取最新的 pendingDrafts 计算失败数(避免闭包过时)
const latestDrafts = useImportStore.getState().pendingDrafts;
const successCount = drafts.length; const successCount = drafts.length;
const failCount = latestDrafts.length - successCount; const failCount = failedPayees.length;
if (failCount === 0) { if (failCount === 0) {
setStatus(t('importFlow.batchAllSuccess', { count: successCount })); setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
@ -279,12 +284,7 @@ export default function ImportScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <ScreenHeader title={t('tab.import')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
</View>
<FlatList <FlatList
data={pendingDrafts} data={pendingDrafts}
keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`} keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
@ -385,10 +385,9 @@ export default function ImportScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, paddingBottom: 64 }, content: { padding: 16, paddingBottom: 64 },
buttonCol: { gap: 8, marginTop: 4 }, buttonCol: { gap: 8, marginTop: 4 },
mono: { fontFamily: 'monospace', lineHeight: 20, marginTop: 8 }, mono: { fontVariant: ['tabular-nums'], lineHeight: 20, marginTop: 8 },
collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 }, collapseHeader: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
duplicatesList: { gap: 12, marginTop: 8 }, duplicatesList: { gap: 12, marginTop: 8 },
}); });

View File

@ -1,191 +1,155 @@
import React, { useState } from 'react'; /**
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; *
import { SafeAreaView } from 'react-native-safe-area-context'; *
import { useRouter } from 'expo-router'; * (///) + //
* P5 ManagementScreen /
* handlers.openEdit/confirmDelete
*/
import React from 'react';
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import { toDateString } from '../../domain/decimal';
import type { RecurringTransaction } from '../../domain/recurring'; import type { RecurringTransaction } from '../../domain/recurring';
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
export default function RecurringScreen() { export default function RecurringScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const recurringTransactions = useMetadataStore(s => s.recurringTransactions); const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
const addRecurring = useMetadataStore(s => s.addRecurringTransaction); const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction); const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction); const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
const [modal, setModal] = useState<ModalMode>(null); // 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
const today = toDateString(new Date());
const handleDelete = (item: RecurringTransaction) => {
Alert.alert(t('recurring.deleteTitle'), t('recurring.deleteConfirm', { name: item.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{
text: t('common.delete'),
style: 'destructive',
onPress: () => removeRecurring(item.id),
},
]);
};
const handleConfirm = (values: Record<string, string>) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as any;
const dateVal = values.nextDueDate?.trim() || new Date().toISOString().slice(0, 10);
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
return;
}
const draft = {
date: dateVal,
narration: nameVal,
postings: [
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
{ account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
if (modal?.type === 'add') {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
setModal(null);
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
} else if (modal?.type === 'edit') {
updateRecurring(modal.item.id, {
name: nameVal,
draft,
frequency: freq,
nextDueDate: dateVal,
});
setModal(null);
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
}
};
const fieldsFor = (item?: RecurringTransaction): FormField[] => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: item?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: item?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: item?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: item?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: item?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: item?.nextDueDate || new Date().toISOString().slice(0, 10) },
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<RecurringTransaction>
<View style={styles.header}> title={t('recurring.title')}
<Pressable onPress={() => router.back()}> items={recurringTransactions}
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /> keyExtractor={item => item.id}
</Pressable> addLabel={t('recurring.add')}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}> emptyText={t('recurring.empty')}
{t('recurring.title')} renderItem={(item, { openEdit, confirmDelete }) => {
</Text> const amount = item.draft.postings[1]?.amount || '0.00';
</View> const fromAcc = item.draft.postings[0]?.account || '';
const toAcc = item.draft.postings[1]?.account || '';
<ScrollView contentContainerStyle={styles.content}> return (
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> <Card title={item.name}>
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> <View style={styles.infoRow}>
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
{t('recurring.add')} <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
</Text> {item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
</Pressable> </Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Text>
</View>
<View style={styles.infoRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text>
</View>
{recurringTransactions.map(item => { {/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
const amount = item.draft.postings[1]?.amount || '0.00'; <View style={styles.cardActions}>
const fromAcc = item.draft.postings[0]?.account || ''; <Pressable
const toAcc = item.draft.postings[1]?.account || ''; onPress={openEdit}
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]}
return ( >
<Card key={item.id} title={item.name}> <Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<View style={styles.infoRow}> <Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text> {t('recurring.edit')}
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{item.frequency === 'monthly' ? t('recurring.freqMonthly') : item.frequency === 'weekly' ? t('recurring.freqWeekly') : item.frequency === 'yearly' ? t('recurring.freqYearly') : t('recurring.freqDaily')}
</Text> </Text>
</View> </Pressable>
<View style={styles.infoRow}> <Pressable
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.nextDue')}</Text> onPress={confirmDelete}
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{item.nextDueDate}</Text> style={[styles.actionBtn, { borderColor: theme.colors.border }]}
</View> >
<View style={styles.infoRow}> <Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.flow')}</Text> <Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}> {t('recurring.delete')}
{fromAcc.split(':').pop()} {toAcc.split(':').pop()}
</Text> </Text>
</View> </Pressable>
<View style={styles.infoRow}> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.perAmount')}</Text> </Card>
<Text style={[theme.typography.body, { color: theme.colors.financial.expense, fontWeight: '700' }]}>{amount} CNY</Text> );
</View> }}
formTitle={editing => (editing ? t('recurring.editTitle') : t('recurring.addTitle'))}
formFields={editing => [
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: editing?.name },
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: editing?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: editing?.draft.postings[1]?.account || 'Expenses:Shopping' },
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: editing?.draft.postings[1]?.amount },
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: editing?.frequency || 'monthly' },
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: editing?.nextDueDate || today },
]}
onSubmit={(values, editing) => {
const nameVal = values.name?.trim();
const fromAccount = values.fromAccount?.trim();
const toAccount = values.toAccount?.trim();
const amountVal = values.amount?.trim();
const freq = (values.frequency?.trim() || 'monthly') as RecurringTransaction['frequency'];
const dateVal = values.nextDueDate?.trim() || today;
<View style={styles.cardActions}> if (!nameVal || !fromAccount || !toAccount || !amountVal) {
<Pressable Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
onPress={() => setModal({ type: 'edit', item })} return false;
style={[styles.actionBtn, { borderColor: theme.colors.border, marginRight: 8 }]} }
>
<Ionicons name="create-outline" size={14} color={theme.colors.accent} />
<Text style={[theme.typography.caption, { color: theme.colors.accent, marginLeft: 4 }]}>
{t('recurring.edit')}
</Text>
</Pressable>
<Pressable
onPress={() => handleDelete(item)}
style={[styles.closeBtn, { borderColor: theme.colors.border }]}
>
<Ionicons name="trash-outline" size={14} color={theme.colors.error} />
<Text style={[theme.typography.caption, { color: theme.colors.error, marginLeft: 4 }]}>
{t('recurring.delete')}
</Text>
</Pressable>
</View>
</Card>
);
})}
{recurringTransactions.length === 0 && ( const draft = {
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}> date: dateVal,
{t('recurring.empty')} narration: nameVal,
</Text> postings: [
)} { account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
</ScrollView> { account: toAccount, amount: amountVal, currency: 'CNY' },
],
};
<FormModal if (editing) {
visible={modal !== null} updateRecurring(editing.id, {
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')} name: nameVal,
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)} draft,
onConfirm={handleConfirm} frequency: freq,
onCancel={() => setModal(null)} nextDueDate: dateVal,
/> });
</SafeAreaView> Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
} else {
addRecurring({
id: 'rec_' + generateId(),
name: nameVal,
draft,
frequency: freq,
interval: 1,
nextDueDate: dateVal,
enabled: true,
});
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
}
// 弹窗关闭由模板接管(返回 true
return true;
}}
onDelete={item => removeRecurring(item.id)}
deleteConfirmText={item => t('recurring.deleteConfirm', { name: item.name })}
deleteConfirmTitle={t('recurring.deleteTitle')}
/>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 8 },
infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' }, infoRow: { flexDirection: 'row', justifyContent: 'space-between', paddingVertical: 4, alignItems: 'center' },
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 }, cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 }, actionBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
closeBtn: { flexDirection: 'row', alignItems: 'center', borderWidth: 1, borderRadius: 4, paddingHorizontal: 8, paddingVertical: 4 },
}); });

View File

@ -1,106 +1,70 @@
/** /**
* *
* ${placeholder} * ${placeholder}
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
type ModalMode = { type: 'add' } | { type: 'edit'; tpl: { id: string; name: string; template: string } } | null; interface RemarkTemplate {
id: string;
name: string;
template: string;
}
export default function RemarkTemplateScreen() { export default function RemarkTemplateScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const templates = useMetadataStore(s => s.remarkTemplates); const templates = useMetadataStore(s => s.remarkTemplates);
const addTemplate = useMetadataStore(s => s.addRemarkTemplate); const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate); const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate); const removeTemplate = useMetadataStore(s => s.removeRemarkTemplate);
const [modal, setModal] = useState<ModalMode>(null);
const handleDelete = (tpl: { id: string; name: string }) => {
Alert.alert(t('remark.deleteTitle'), t('remark.deleteConfirm', { name: tpl.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTemplate(tpl.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() || t('common.untitled');
const template = values.template?.trim() || '';
if (modal?.type === 'add') {
addTemplate({ id: generateId('tpl'), name, template });
} else if (modal?.type === 'edit') {
updateTemplate(modal.tpl.id, { name, template });
}
setModal(null);
};
const editFields: FormField[] = modal?.type === 'edit' ? [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: modal.tpl.name },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: modal.tpl.template },
] : [];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<RemarkTemplate>
<View style={styles.header}> title={t('remark.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={templates}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('remark.title')}</Text> keyExtractor={tpl => tpl.id}
</View> addLabel={t('remark.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('remark.empty')}
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> renderItem={(tpl, { openEdit, confirmDelete }) => (
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> <Pressable onPress={openEdit} onLongPress={confirmDelete}>
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text> <Card title={tpl.name}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
{tpl.template}
</Text>
</Card>
</Pressable> </Pressable>
{templates.map(tpl => ( )}
<Pressable formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
key={tpl.id} formFields={editing => [
onPress={() => setModal({ type: 'edit', tpl })} { key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
onLongPress={() => handleDelete(tpl)} { key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
> ]}
<Card title={tpl.name}> onSubmit={(values, editing) => {
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}> const name = values.name?.trim() || t('common.untitled');
{tpl.template} const template = values.template?.trim() || '';
</Text> if (editing) {
</Card> updateTemplate(editing.id, { name, template });
</Pressable> } else {
))} addTemplate({ id: generateId('tpl'), name, template });
{templates.length === 0 && ( }
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}> return true;
{t('remark.empty')} }}
</Text> onDelete={tpl => removeTemplate(tpl.id)}
)} deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> deleteConfirmTitle={t('remark.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('remark.editTitle') : t('remark.add')}
fields={modal?.type === 'edit' ? editFields : [
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮' },
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}' },
]}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
addBtn: { flexDirection: 'row', alignItems: 'center', marginBottom: 4 },
});

View File

@ -3,71 +3,27 @@
* *
* ( ) + // * ( ) + //
* BillPipeline ( AutoAccounting RuleGenerator) * BillPipeline ( AutoAccounting RuleGenerator)
* P5 ManagementScreen //
*/ */
import React, { useState } from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { FormModal, type FormField } from '../../components/FormModal'; import { ManagementScreen } from '../../components/ManagementScreen';
import type { Rule } from '../../domain/types'; import type { Rule } from '../../domain/types';
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
export default function RulesScreen() { export default function RulesScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const rules = useMetadataStore(s => s.rules); const rules = useMetadataStore(s => s.rules);
const addRule = useMetadataStore(s => s.addRule); const addRule = useMetadataStore(s => s.addRule);
const updateRule = useMetadataStore(s => s.updateRule); const updateRule = useMetadataStore(s => s.updateRule);
const removeRule = useMetadataStore(s => s.removeRule); const removeRule = useMetadataStore(s => s.removeRule);
const [modal, setModal] = useState<ModalMode>(null);
// 按 priority 降序排列
const sorted = [...rules].sort((a, b) => b.priority - a.priority);
const handleDelete = (rule: Rule) => {
Alert.alert(t('rules.deleteTitle'), t('rules.deleteConfirm', { name: rule.id }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeRule(rule.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (modal?.type === 'add') {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
} else if (modal?.type === 'edit') {
updateRule(modal.rule.id, rule);
}
setModal(null);
};
const fieldsFor = (rule?: Rule): FormField[] => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: rule ? String(rule.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: rule?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: rule?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: rule?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: rule?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: rule?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: rule?.tags?.join(', ') ?? '' },
];
const formatCondition = (rule: Rule): string => { const formatCondition = (rule: Rule): string => {
const parts: string[] = []; const parts: string[] = [];
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains })); if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
@ -76,68 +32,74 @@ export default function RulesScreen() {
}; };
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Rule>
<View style={styles.header}> title={t('tab.rules')}
<Pressable onPress={() => router.back()}> items={[...rules].sort((a, b) => b.priority - a.priority)}
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /> keyExtractor={rule => rule.id}
</Pressable> addLabel={t('rules.add')}
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text> emptyText={t('rules.empty')}
</View> renderItem={(rule, { openEdit, confirmDelete }) => (
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <Card>
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}> <Pressable
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> onPress={openEdit}
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text> onLongPress={confirmDelete}
</Pressable> style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
<Card title={t('rules.title')}> >
{sorted.length === 0 && ( <View style={styles.ruleHeader}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text>
)} <Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text>
{sorted.map(rule => ( </View>
<Pressable <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
key={rule.id} {formatCondition(rule)}
onPress={() => setModal({ type: 'edit', rule })} </Text>
onLongPress={() => handleDelete(rule)} <View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]} <Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
<View style={{ flex: 1 }}> {rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
<View style={styles.ruleHeader}> </Text>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]}>{rule.narration || rule.id}</Text> </View>
<Text style={[theme.typography.caption, { color: theme.colors.accent }]}>P{rule.priority}</Text> </Pressable>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{formatCondition(rule)}
</Text>
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 2 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
</Text>
</View>
</View>
</Pressable>
))}
</Card> </Card>
)}
formTitle={editing => (editing ? t('rules.editTitle') : t('rules.add'))}
formFields={editing => [
{ key: 'priority', label: t('rules.fieldPriority'), placeholder: '100', defaultValue: editing ? String(editing.priority) : '', keyboardType: 'numeric' },
{ key: 'counterpartyContains', label: t('rules.fieldCounterparty'), placeholder: '咖啡', defaultValue: editing?.counterpartyContains ?? '' },
{ key: 'memoContains', label: t('rules.fieldMemo'), placeholder: '', defaultValue: editing?.memoContains ?? '' },
{ key: 'sourceAccount', label: t('rules.fieldSourceAccount'), placeholder: 'Assets:支付宝余额', defaultValue: editing?.sourceAccount ?? '' },
{ key: 'categoryAccount', label: t('rules.fieldCategoryAccount'), placeholder: 'Expenses:餐饮', defaultValue: editing?.categoryAccount ?? '' },
{ key: 'narration', label: t('rules.fieldNarration'), placeholder: '咖啡', defaultValue: editing?.narration ?? '' },
{ key: 'tags', label: t('rules.fieldTags'), placeholder: 'food', defaultValue: editing?.tags?.join(', ') ?? '' },
]}
onSubmit={(values, editing) => {
const rule: Omit<Rule, 'id' | 'hits'> = {
priority: parseInt(values.priority, 10) || 0,
counterpartyContains: values.counterpartyContains?.trim() || undefined,
memoContains: values.memoContains?.trim() || undefined,
sourceAccount: values.sourceAccount?.trim() || 'Assets:Unknown',
categoryAccount: values.categoryAccount?.trim() || 'Expenses:Uncategorized',
narration: values.narration?.trim() || undefined,
tags: values.tags ? values.tags.split(/[,\s]+/).filter(Boolean) : [],
};
if (editing) {
updateRule(editing.id, rule);
} else {
addRule({ ...rule, id: generateId('rule'), hits: 0 });
}
return true;
}}
onDelete={rule => removeRule(rule.id)}
deleteConfirmText={rule => t('rules.deleteConfirm', { name: rule.id })}
deleteConfirmTitle={t('rules.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('rules.editTitle') : t('rules.add')}
fields={fieldsFor(modal?.type === 'edit' ? modal.rule : undefined)}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center' },
row: { borderTopWidth: 1, paddingTop: 10, gap: 3 },
ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' }, ruleHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
}); });

View File

@ -1,19 +1,17 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore } from '../../store/settingsStore'; import { useSettingsStore } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function AISettingsScreen() { export default function AISettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
// Settings State // Settings State
const dedupEnabled = useSettingsStore(s => s.dedupEnabled); const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
@ -47,14 +45,7 @@ export default function AISettingsScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <ScreenHeader title={t('settings.aiSettingsTitle')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('settings.aiSettingsTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{/* 自动记账与算法开关 */} {/* 自动记账与算法开关 */}
@ -148,7 +139,6 @@ export default function AISettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 }, content: { padding: 16, gap: 16 },
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' }, switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
optionRow: { flexDirection: 'row', gap: 8 }, optionRow: { flexDirection: 'row', gap: 8 },

View File

@ -0,0 +1,42 @@
/** 解析诊断自交易页迁入spec §7.2/§7.4 数据组)。 */
import React from 'react';
import { ScrollView, StyleSheet, Text } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useLedgerStore } from '../../store/ledgerStore';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function DiagnosticsScreen() {
const { theme } = useTheme();
const t = useT();
const ledger = useLedgerStore(s => s.ledger);
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader title={t('settings.diagnostics')} />
<ScrollView contentContainerStyle={styles.content}>
<Card title={t('diagnostics.title')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.syntaxErrors', { count: ledger?.diagnostics.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.unsupported', { count: ledger?.unsupported.length ?? 0 })}
</Text>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>
{t('diagnostics.balanceAssertions', { count: ledger?.balances.length ?? 0 })}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
{t('diagnostics.hint')}
</Text>
</Card>
</ScrollView>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16 },
});

View File

@ -1,17 +1,16 @@
import React from 'react'; import React from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native'; import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore'; import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function PreferencesScreen() { export default function PreferencesScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
// Settings State // Settings State
const themeMode = useSettingsStore(s => s.themeMode); const themeMode = useSettingsStore(s => s.themeMode);
@ -24,6 +23,8 @@ export default function PreferencesScreen() {
const reminderHour = useSettingsStore(s => s.reminderHour); const reminderHour = useSettingsStore(s => s.reminderHour);
const reminderMinute = useSettingsStore(s => s.reminderMinute); const reminderMinute = useSettingsStore(s => s.reminderMinute);
const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig); const updateReminderConfig = useSettingsStore(s => s.updateReminderConfig);
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const setNumpadGlobalEntry = useSettingsStore(s => s.setNumpadGlobalEntry);
const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [ const THEME_OPTIONS: { mode: ThemeMode; labelKey: string }[] = [
{ mode: 'light', labelKey: 'settings.themeLight' }, { mode: 'light', labelKey: 'settings.themeLight' },
@ -38,14 +39,7 @@ export default function PreferencesScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <ScreenHeader title={t('settings.preferencesTitle')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('settings.preferencesTitle')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{/* 外观设置 */} {/* 外观设置 */}
@ -106,6 +100,24 @@ export default function PreferencesScreen() {
</View> </View>
</Card> </Card>
{/* 记账 */}
<Card title={t('settings.entryTitle')}>
<View style={styles.switchRow}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, flex: 1 }]}>
{t('settings.numpadEntry')}
</Text>
<Switch
value={numpadGlobalEntry}
onValueChange={setNumpadGlobalEntry}
trackColor={{ false: theme.colors.bgTertiary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 6 }]}>
{t('settings.numpadEntryDesc')}
</Text>
</Card>
{/* 安全设置 */} {/* 安全设置 */}
<Card title={t('settings.security')}> <Card title={t('settings.security')}>
<View style={styles.switchRow}> <View style={styles.switchRow}>
@ -233,7 +245,6 @@ export default function PreferencesScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 }, content: { padding: 16, gap: 16 },
optionRow: { flexDirection: 'row', gap: 8 }, optionRow: { flexDirection: 'row', gap: 8 },
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 }, option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },

View File

@ -1,8 +1,6 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native'; import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import * as DocumentPicker from 'expo-document-picker'; import * as DocumentPicker from 'expo-document-picker';
import * as FileSystem from 'expo-file-system/legacy'; import * as FileSystem from 'expo-file-system/legacy';
import * as Sharing from 'expo-sharing'; import * as Sharing from 'expo-sharing';
@ -15,6 +13,7 @@ import { useMetadataStore } from '../../store/metadataStore';
import { Card } from '../../components/Card'; import { Card } from '../../components/Card';
import { Button } from '../../components/Button'; import { Button } from '../../components/Button';
import { FormModal, type FormField } from '../../components/FormModal'; import { FormModal, type FormField } from '../../components/FormModal';
import { ScreenHeader } from '../../components/ScreenHeader';
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync'; import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync'; import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync'; import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
@ -24,7 +23,6 @@ import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../serv
export default function SyncSettingsScreen() { export default function SyncSettingsScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
// Sync Configuration // Sync Configuration
const webdavUrl = useSettingsStore(s => s.webdavUrl) || ''; const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
@ -456,14 +454,7 @@ export default function SyncSettingsScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<View style={styles.header}> <ScreenHeader title={t('sync.title')} />
<Pressable onPress={() => router.back()}>
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
{t('sync.title')}
</Text>
</View>
<ScrollView contentContainerStyle={styles.content}> <ScrollView contentContainerStyle={styles.content}>
{/* 云同步配置与云端操作 */} {/* 云同步配置与云端操作 */}
@ -539,7 +530,6 @@ export default function SyncSettingsScreen() {
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 16 }, content: { padding: 16, gap: 16 },
syncBtnRow: { flexDirection: 'row', gap: 8 }, syncBtnRow: { flexDirection: 'row', gap: 8 },
buttonRow: { flexDirection: 'row', gap: 8 }, buttonRow: { flexDirection: 'row', gap: 8 },

View File

@ -1,141 +1,92 @@
/** /**
* plan.md1.1 tag/index+ 1 * ManagementScreen P2
* *
* () + // * () + //
* .bean #tag , [\w-] * .bean #tag , [\w-]
*/ */
import React, { useState } from 'react'; import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native'; import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter } from 'expo-router';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { TAG_COLORS } from '../../theme/palette';
import { useMetadataStore, generateId } from '../../store/metadataStore'; import { useMetadataStore, generateId } from '../../store/metadataStore';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { Card } from '../../components/Card'; import { ManagementScreen } from '../../components/ManagementScreen';
import { FormModal, type FormField } from '../../components/FormModal';
import { isValidTagName } from '../../domain/tags'; import { isValidTagName } from '../../domain/tags';
import type { Tag } from '../../domain/tags'; import type { Tag } from '../../domain/tags';
type ModalMode = { type: 'add' } | { type: 'edit'; tag: Tag } | null;
const COLORS = ['#F44336', '#E91E63', '#9C27B0', '#2196F3', '#00BCD4', '#4CAF50', '#FF9800', '#795548'];
export default function TagScreen() { export default function TagScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const router = useRouter();
const tags = useMetadataStore(s => s.tags); const tags = useMetadataStore(s => s.tags);
const addTag = useMetadataStore(s => s.addTag); const addTag = useMetadataStore(s => s.addTag);
const updateTag = useMetadataStore(s => s.updateTag); const updateTag = useMetadataStore(s => s.updateTag);
const removeTag = useMetadataStore(s => s.removeTag); const removeTag = useMetadataStore(s => s.removeTag);
const [modal, setModal] = useState<ModalMode>(null); const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
const [selectedColor, setSelectedColor] = useState(COLORS[0]);
const handleDelete = (tag: Tag) => {
Alert.alert(t('tag.deleteTitle'), t('tag.deleteConfirm', { name: tag.name }), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => removeTag(tag.id) },
]);
};
const handleConfirm = (values: Record<string, string>) => {
const name = values.name?.trim() ?? '';
if (!isValidTagName(name)) {
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
return;
}
if (modal?.type === 'add') {
addTag({ id: generateId('tag'), name, color: selectedColor });
} else if (modal?.type === 'edit') {
updateTag(modal.tag.id, { name, color: selectedColor });
}
setModal(null);
};
const openModal = (mode: ModalMode) => {
if (mode?.type === 'edit') setSelectedColor(mode.tag.color);
else setSelectedColor(COLORS[0]);
setModal(mode);
};
const editFields: FormField[] = modal?.type === 'edit'
? [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: modal.tag.name }]
: [{ key: 'name', label: t('tag.fieldName'), placeholder: 'food' }];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}> <ManagementScreen<Tag>
<View style={styles.header}> title={t('tag.title')}
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable> items={tags}
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('tag.title')}</Text> keyExtractor={tag => tag.id}
</View> addLabel={t('tag.add')}
<ScrollView contentContainerStyle={styles.content}> emptyText={t('tag.empty')}
<Card title={t('tag.count', { count: tags.length })}> listStyle={styles.chipGrid}
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}> onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
<Ionicons name="add-circle" size={20} color={theme.colors.accent} /> renderItem={(tag, { openEdit, confirmDelete }) => (
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text> <Pressable
</Pressable> onPress={openEdit}
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}> onLongPress={confirmDelete}
{tags.map(tag => ( style={[styles.chip, { backgroundColor: tag.color }]}
<Pressable >
key={tag.id} <Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
onPress={() => openModal({ type: 'edit', tag })} </Pressable>
onLongPress={() => handleDelete(tag)} )}
style={[styles.chip, { backgroundColor: tag.color }]} formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
> formFields={editing => [
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text> { key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
</Pressable> ]}
))} formExtra={() => (
{tags.length === 0 && ( <View style={styles.colorRow}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text> {TAG_COLORS.map(c => (
)} <Pressable
</View> key={c}
</Card> onPress={() => setSelectedColor(c)}
style={[styles.colorDot, {
{/* 颜色选择器(模态打开时显示) */} backgroundColor: c,
{modal && ( borderWidth: selectedColor === c ? 3 : 0,
<Card title={t('tag.selectColor')}> borderColor: theme.colors.fgPrimary,
<View style={styles.colorRow}> }]}
{COLORS.map(c => ( />
<Pressable ))}
key={c} </View>
onPress={() => setSelectedColor(c)} )}
style={[styles.colorDot, { onSubmit={(values, editing) => {
backgroundColor: c, const name = values.name?.trim() ?? '';
borderWidth: selectedColor === c ? 3 : 0, if (!isValidTagName(name)) {
borderColor: theme.colors.fgPrimary, Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
}]} return false;
/> }
))} if (editing) updateTag(editing.id, { name, color: selectedColor });
</View> else addTag({ id: generateId('tag'), name, color: selectedColor });
</Card> return true;
)} }}
onDelete={tag => removeTag(tag.id)}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}> deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
deleteConfirmTitle={t('tag.deleteTitle')}
footer={
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
{t('common.clickEditLongDelete')} {t('common.clickEditLongDelete')}
</Text> </Text>
</ScrollView> }
/>
<FormModal
visible={modal !== null}
title={modal?.type === 'edit' ? t('tag.editTitle') : t('tag.add')}
fields={editFields}
onConfirm={handleConfirm}
onCancel={() => setModal(null)}
/>
</SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
content: { padding: 16, gap: 12 },
grid: { flexDirection: 'row', flexWrap: 'wrap' },
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 }, chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 }, colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
colorDot: { width: 36, height: 36, borderRadius: 18 }, colorDot: { width: 36, height: 36, borderRadius: 18 },
}); });

View File

@ -18,6 +18,7 @@ import { useLedgerStore } from '../../store/ledgerStore';
import { hash } from '../../domain/ledger'; import { hash } from '../../domain/ledger';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { TransactionCard } from '../../components/TransactionCard'; import { TransactionCard } from '../../components/TransactionCard';
import { ScreenHeader } from '../../components/ScreenHeader';
export default function TransactionDetailScreen() { export default function TransactionDetailScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
@ -154,10 +155,7 @@ export default function TransactionDetailScreen() {
if (!tx) { if (!tx) {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}> <ScreenHeader title={t('common.notFound')} />
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('common.notFound')}</Text>
</View>
<View style={styles.content}> <View style={styles.content}>
<Card title={t('transaction.notFoundTitle')}> <Card title={t('transaction.notFoundTitle')}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}> <Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
@ -178,10 +176,7 @@ export default function TransactionDetailScreen() {
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
<View style={styles.header}> <ScreenHeader title={t('transaction.detailTitle')} />
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('transaction.detailTitle')}</Text>
</View>
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}> <ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
{/* 摘要卡片 */} {/* 摘要卡片 */}
<Card title={t('transaction.summary')}> <Card title={t('transaction.summary')}>
@ -311,7 +306,7 @@ export default function TransactionDetailScreen() {
{/* 原始 .bean */} {/* 原始 .bean */}
<Card title={t('transaction.rawBean')}> <Card title={t('transaction.rawBean')}>
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontFamily: 'monospace', lineHeight: 20 }]}> <Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'], lineHeight: 20 }]}>
{tx.raw} {tx.raw}
</Text> </Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>

View File

@ -1,825 +1,29 @@
import React, { useMemo, useState, useEffect, useRef } from 'react'; /**
import { ScrollView, StyleSheet, Text, TextInput, View, Switch, Alert, Pressable, LayoutAnimation, Platform } from 'react-native'; * NumpadSheet 宿P3
* editId/ 稿draftJson/ OCRmode=ocr
*/
import React from 'react';
import { StyleSheet } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context'; import { SafeAreaView } from 'react-native-safe-area-context';
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router'; import { useLocalSearchParams } from 'expo-router';
import { Ionicons } from '@expo/vector-icons'; import { useTheme } from '../../theme';
import * as ImagePicker from 'expo-image-picker'; import { NumpadSheet } from '../../components/NumpadSheet';
import * as FileSystem from 'expo-file-system/legacy';
import { useLedgerStore } from '../../store/ledgerStore';
import { useMetadataStore } from '../../store/metadataStore';
import { useTheme, createCommonStyles } from '../../theme';
import { useT } from '../../i18n';
import { Card } from '../../components/Card';
import { Button } from '../../components/Button';
import { CategoryPicker } from '../../components/CategoryPicker';
import { TagPicker } from '../../components/TagPicker';
import { PostingEditor } from '../../components/PostingEditor';
import { tagsToBeanSyntax } from '../../domain/tags';
import { serializeTransaction } from '../../domain/ledger';
import { buildAndSaveTransaction, buildPostings } from '../../domain/transactionBuilder';
import { addDecimals, compareDecimals } from '../../domain/decimal';
import { matchCategory } from '../../domain/categories';
import { getNativeOcrModule, getNativeOcrBridge } from '../../services/ocrBridge';
import { OcrProcessor } from '../../domain/ocrProcessor';
import type { Category } from '../../domain/categories';
import type { Tag } from '../../domain/tags';
import type { Posting } from '../../domain/types';
type Direction = 'expense' | 'income' | 'transfer';
let ocrProcessor: OcrProcessor | null = null;
export default function NewTransactionScreen() { export default function NewTransactionScreen() {
const { theme } = useTheme(); const { theme } = useTheme();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const t = useT();
const router = useRouter();
const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>(); const { mode, editId, draftJson } = useLocalSearchParams<{ mode?: string; editId?: string; draftJson?: string }>();
const addTransaction = useLedgerStore(s => s.addTransaction);
const editTransaction = useLedgerStore(s => s.editTransaction);
const ledger = useLedgerStore(s => s.ledger);
const categories = useMetadataStore(s => s.categories);
const tags = useMetadataStore(s => s.tags);
const today = new Date().toISOString().slice(0, 10);
// 基础表单状态
const [date, setDate] = useState(today);
const [narration, setNarration] = useState('');
const [payee, setPayee] = useState('');
const [currency, setCurrency] = useState('CNY');
const [amount, setAmount] = useState('');
const [direction, setDirection] = useState<Direction>('expense');
const [sourceAccount, setSourceAccount] = useState('Assets:支付宝余额');
const [targetAccount, setTargetAccount] = useState('Assets:微信零钱');
const [selectedCategory, setSelectedCategory] = useState<Category | null>(null);
const [selectedTags, setSelectedTags] = useState<Tag[]>([]);
const [status, setStatus] = useState('');
// 高级模式状态
const [isAdvanced, setIsAdvanced] = useState(false);
const [showDetails, setShowDetails] = useState(false);
const [postings, setPostings] = useState<Posting[]>([
{ account: 'Assets:支付宝余额', amount: '', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '', currency: 'CNY' }
]);
const [isSubmitted, setIsSubmitted] = useState(false);
const [submitting, setSubmitting] = useState(false);
const navigation = useNavigation();
const formRef = useRef({ amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted });
formRef.current = { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted };
useEffect(() => {
const unsubscribe = navigation.addListener('beforeRemove', (e) => {
const { amount, payee, narration, selectedTags, postings, isAdvanced, isSubmitted } = formRef.current;
const hasChanges =
amount.trim() !== '' ||
payee.trim() !== '' ||
narration.trim() !== '' ||
selectedTags.length > 0 ||
(isAdvanced && postings.some(p => p.account.trim() !== '' || (p.amount || '').trim() !== ''));
if (!hasChanges || isSubmitted) {
return;
}
e.preventDefault();
Alert.alert(
t('transaction.unsavedTitle'),
t('transaction.unsavedMessage'),
[
{ text: t('common.cancel'), style: 'cancel', onPress: () => {} },
{
text: t('common.discard'),
style: 'destructive',
onPress: () => navigation.dispatch(e.data.action),
},
]
);
});
return unsubscribe;
}, [navigation]);
// === 草稿预填模式 ===
useEffect(() => {
if (draftJson) {
try {
const draft = JSON.parse(draftJson);
setDate(draft.date ? draft.date.slice(0, 10) : today);
setNarration(draft.narration || '');
setPayee(draft.payee || '');
const expensePosting = draft.postings.find((p: any) => p.account.startsWith('Expenses'));
const incomePosting = draft.postings.find((p: any) => p.account.startsWith('Income'));
const assetsPostings = draft.postings.filter((p: any) => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'));
if (expensePosting) {
setDirection('expense');
setCurrency(expensePosting.currency || 'CNY');
setAmount((expensePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else if (incomePosting) {
setDirection('income');
setCurrency(incomePosting.currency || 'CNY');
setAmount((incomePosting.amount || '').replace(/^-/, ''));
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
if (assetsPostings.length > 0) {
setSourceAccount(assetsPostings[0].account);
}
} else {
setDirection('transfer');
if (draft.postings.length >= 2) {
setCurrency(draft.postings[0].currency || 'CNY');
setAmount((draft.postings[0].amount || '').replace(/^-/, ''));
setSourceAccount(draft.postings[0].account);
setTargetAccount(draft.postings[1].account);
}
}
if (draft.postings && draft.postings.length > 0) {
setPostings(draft.postings.map((p: any) => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
}
} catch (e) {
console.error('Failed to parse draftJson', e);
}
}
}, [draftJson, categories]);
// === 编辑模式:预填表单 ===
const editingTx = useMemo(
() => editId ? ledger?.transactions.find(txn => txn.id === editId) : undefined,
[editId, ledger],
);
const [oldRaw, setOldRaw] = useState('');
const [editFlag, setEditFlag] = useState<string>('*');
const [editLinks, setEditLinks] = useState<string[]>([]);
const [editMetadata, setEditMetadata] = useState<Record<string, string> | undefined>(undefined);
useEffect(() => {
if (editingTx) {
setOldRaw(editingTx.raw);
setDate(editingTx.date.slice(0, 10));
setNarration(editingTx.narration || '');
setPayee(editingTx.payee || '');
setCurrency(editingTx.postings[0]?.currency || 'CNY');
// 保留编辑前的 flag/links/metadata
setEditFlag(editingTx.flag || '*');
setEditLinks(editingTx.links || []);
setEditMetadata(editingTx.metadata);
// 从 postings 推断方向和金额
const expensePosting = editingTx.postings.find(p => p.account.startsWith('Expenses'));
const incomePosting = editingTx.postings.find(p => p.account.startsWith('Income'));
if (expensePosting) {
setDirection('expense');
setAmount(expensePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类:匹配 Expenses 账户对应的 category
const matchedCat = categories.find(c => c.linkedAccount === expensePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else if (incomePosting) {
setDirection('income');
setAmount(incomePosting.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings.find(p => p.account.startsWith('Assets'))?.account || 'Assets:Alipay');
// 预填分类
const matchedCat = categories.find(c => c.linkedAccount === incomePosting.account);
if (matchedCat) setSelectedCategory(matchedCat);
} else {
setDirection('transfer');
setAmount(editingTx.postings[0]?.amount?.replace(/^-/, '') || '');
setSourceAccount(editingTx.postings[0]?.account || '');
setTargetAccount(editingTx.postings[1]?.account || '');
}
// 预填 postings高级模式含 cost/price/metadata
setPostings(editingTx.postings.map(p => ({
account: p.account,
amount: p.amount,
currency: p.currency,
cost: p.cost,
price: p.price,
metadata: p.metadata,
})));
// 预填标签
if (editingTx.tags.length > 0) {
setSelectedTags(editingTx.tags.map(name => ({ id: name, name, color: '#2196F3' })));
}
}
}, [editingTx, categories]);
// 按方向过滤分类
const availableCategories = useMemo(
() => categories.filter(c => c.type === (direction === 'income' ? 'income' : 'expense')),
[categories, direction],
);
// 来源账户列表(从 ledger 提取 Assets 账户)
const assetAccounts = useMemo(() => {
if (!ledger) return ['Assets:支付宝余额', 'Assets:微信零钱', 'Assets:兴业银行储蓄卡-2586'];
const accts = Array.from(ledger.accounts.keys()).filter(a => a.startsWith('Assets'));
return accts.length > 0 ? accts : ['Assets:支付宝余额'];
}, [ledger]);
const toggleTag = (tag: Tag) => {
setSelectedTags(prev =>
prev.some(t => t.name === tag.name)
? prev.filter(t => t.name !== tag.name)
: [...prev, tag],
);
};
// === OCR 拍照识账 ===
const handleOcrScan = async () => {
try {
// 1. 选图(相册或拍照)
const result = await ImagePicker.launchImageLibraryAsync({
mediaTypes: ImagePicker.MediaTypeOptions.Images,
allowsEditing: false,
quality: 0.8,
base64: true,
});
if (result.canceled || !result.assets?.[0]?.base64) return;
const base64 = result.assets[0].base64;
// 2. 检查 OCR 引擎是否就绪
const nativeModule = getNativeOcrModule();
if (nativeModule) {
const ready = await nativeModule.isReady();
if (!ready) {
Alert.alert(t('ocr.notReady'));
return;
}
}
setStatus(t('ocr.scanning'));
// 3. 走 OcrProcessor 管线 (Layer1 regex → Layer2 → Layer3 AI)
const ocrBridge = getNativeOcrBridge();
if (!ocrProcessor) {
ocrProcessor = new OcrProcessor(ocrBridge);
}
const ocrResult = await ocrProcessor.process(base64);
if (ocrResult.event) {
// 4. 预填表单
const event = ocrResult.event;
if (event.amount) {
const absAmount = event.amount.replace(/^-/, '');
setAmount(absAmount);
}
if (event.counterparty || event.memo) {
setNarration([event.counterparty, event.memo].filter(Boolean).join(' - '));
}
if (event.direction === 'income') {
setDirection('income');
} else if (event.direction === 'transfer') {
setDirection('transfer');
} else {
setDirection('expense');
}
// 5. 自动分类:根据商户名/备注匹配分类(与 BillPipeline 分类逻辑一致)
const matchText = [event.counterparty, event.memo].filter(Boolean).join(' ');
if (matchText) {
const type = event.direction === 'income' ? 'income' : 'expense';
const candidates = categories.filter(c => c.type === type);
const match = matchCategory(matchText, candidates);
if (match.category) {
setSelectedCategory(match.category);
}
}
setStatus(t('ocr.success'));
} else {
setStatus(t('ocr.failed'));
}
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
}
};
// 从 SpeedDial OCR 入口进入时自动触发
useEffect(() => {
if (mode === 'ocr') {
handleOcrScan();
}
}, [mode]);
const submit = () => {
if (submitting) return;
setSubmitting(true);
const tagNames = selectedTags.map(tg => tg.name);
if (isAdvanced) {
// 高级模式:校验多 postings
const validPostings = postings.filter(p => p.account.trim());
if (validPostings.length < 2) {
setStatus(t('transaction.atLeast2Postings'));
setSubmitting(false);
return;
}
// 实时借贷平衡验证
const currencySum: Record<string, string[]> = {};
for (const p of validPostings) {
if (p.amount && p.currency) {
currencySum[p.currency] = currencySum[p.currency] || [];
currencySum[p.currency].push(p.amount);
}
}
const isBalanced = Object.entries(currencySum).every(([curr, amounts]) => {
const sum = addDecimals(amounts);
return compareDecimals(sum, '0') === 0;
});
if (!isBalanced) {
setStatus(t('transaction.unbalanced'));
setSubmitting(false);
return;
}
const draft = {
date,
flag: editId ? editFlag : undefined,
narration: narration.trim() || t('transaction.defaultAdvancedNarration'),
postings: validPostings,
tags: tagNames,
links: editId && editLinks.length > 0 ? editLinks : undefined,
metadata: editId ? editMetadata : undefined,
};
if (editId && oldRaw) {
// 编辑模式:序列化新内容并替换旧块
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
} else {
addTransaction(draft).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.appended'));
setNarration('');
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
}
} else {
// 简单模式:调用统一的 buildAndSaveTransaction 构建复式 postings 并保存
// 日期格式校验
if (!/^\d{4}-\d{2}-\d{2}$/.test(date)) {
setStatus(t('transaction.invalidDate'));
setSubmitting(false);
return;
}
const amt = amount.trim();
if (!amt) {
setStatus(t('transaction.amountRequired'));
setSubmitting(false);
return;
}
if (direction === 'transfer' && sourceAccount === targetAccount) {
setStatus(t('transaction.sameAccountError'));
setSubmitting(false);
return;
}
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (editId && oldRaw) {
// 编辑模式:由于编辑模式需要保留原始元数据并替换原始代码块,我们仍然就地拼装 draft 并执行 editTransaction
const finalPostings = buildPostings(
direction,
sourceAccount,
direction === 'transfer' ? targetAccount : categoryAccount,
amt,
currency
);
const draft = {
date,
payee: payee.trim() || undefined,
flag: editFlag,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
postings: finalPostings,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
};
const newRaw = serializeTransaction(draft);
editTransaction(oldRaw, newRaw).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.editSuccess'));
setTimeout(() => router.back(), 600);
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
} else {
// 新建交易:直接调用统一的共享逻辑函数
buildAndSaveTransaction({
date,
amount: amt,
payee: payee.trim() || undefined,
narration: narration.trim() || (
direction === 'expense' ? t('transaction.narrationDefaultExpense') :
direction === 'income' ? t('transaction.narrationDefaultIncome') :
t('transaction.narrationDefaultTransfer')
),
categoryAccount: direction === 'transfer' ? targetAccount : categoryAccount,
sourceAccount,
direction,
currency,
tags: tagNames,
links: editLinks.length > 0 ? editLinks : undefined,
metadata: editMetadata,
}, useLedgerStore.getState()).then(() => {
setIsSubmitted(true);
setSubmitting(false);
setStatus(t('transaction.appended'));
setAmount('');
setNarration('');
setSelectedCategory(null);
setSelectedTags([]);
setTimeout(() => router.back(), 600);
}).catch(e => {
setStatus(String(e));
setSubmitting(false);
});
}
}
};
const directions: { key: Direction; label: string; color: string }[] = [
{ key: 'expense', label: t('transaction.directionExpense'), color: theme.colors.financial.expense },
{ key: 'income', label: t('transaction.directionIncome'), color: theme.colors.financial.income },
{ key: 'transfer', label: t('transaction.directionTransfer'), color: theme.colors.financial.transfer },
];
return ( return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}> <SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}> <NumpadSheet
{/* 高级模式模式切换开关 */} presentation="page"
<View style={commonStyles.switchRow}> editId={editId}
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}> draftJson={draftJson}
{t('transaction.advancedMode')} autoOcr={mode === 'ocr'}
</Text> />
<Switch
value={isAdvanced}
onValueChange={(val) => {
setIsAdvanced(val);
// 同步初始化当前分录
if (val) {
const amt = amount.trim();
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
if (direction === 'expense') {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
]);
} else if (direction === 'income') {
setPostings([
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
]);
} else {
setPostings([
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
]);
}
}
}}
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
thumbColor={theme.colors.fgInverse}
/>
</View>
{/* 简单模式下的方向选择 */}
{!isAdvanced && (
<View style={styles.directionRow}>
{directions.map(d => (
<Pressable
key={d.key}
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
style={[
commonStyles.chip,
direction === d.key && commonStyles.chipActive,
{ flex: 1, alignItems: 'center' }
]}
>
<Text style={[
commonStyles.chipText,
direction === d.key && commonStyles.chipTextActive,
{ fontFamily: theme.typography.caption.fontFamily }
]}>
{d.label}
</Text>
</Pressable>
))}
<Pressable
onPress={handleOcrScan}
style={[
commonStyles.chip,
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
]}
>
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
{t('home.ocrScan')}
</Text>
</Pressable>
</View>
)}
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
{!isAdvanced ? (
<Card title={t('transaction.newTitle')}>
{/* 金额 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.amountPlaceholder')}
</Text>
<View style={{ flexDirection: 'row', gap: 8 }}>
<TextInput
value={amount}
onChangeText={setAmount}
autoFocus={editId ? false : true}
keyboardType="decimal-pad"
placeholder={t('transaction.amountPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
/>
<TextInput
value={currency}
onChangeText={setCurrency}
placeholder="CNY"
maxLength={3}
autoCapitalize="characters"
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
/>
</View>
{/* 来源账户 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formSourceAccount')}
</Text>
<TextInput
value={sourceAccount}
onChangeText={setSourceAccount}
placeholder="Assets:支付宝余额"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷来源账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = sourceAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`src-${acct}`}
onPress={() => setSourceAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
{/* 目标账户 (仅在转账时显示) */}
{direction === 'transfer' && (
<View style={{ marginTop: 10 }}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.targetAccount')}
</Text>
<TextInput
value={targetAccount}
onChangeText={setTargetAccount}
placeholder="Assets:WeChat"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 快捷目标账户选择 */}
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
{assetAccounts.map(acct => {
const active = targetAccount === acct;
const shortName = acct.split(':').pop() || acct;
return (
<Pressable
key={`tgt-${acct}`}
onPress={() => setTargetAccount(acct)}
style={[
commonStyles.chip,
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
{ paddingVertical: 4, paddingHorizontal: 8 }
]}
>
<Text style={[
commonStyles.chipText,
{ fontSize: 11 },
active && { color: theme.colors.accent, fontWeight: '700' }
]}>
{shortName}
</Text>
</Pressable>
);
})}
</View>
</View>
)}
</Card>
) : (
<Card title={t('transaction.newTitle')}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.postingsList')}
</Text>
<PostingEditor
postings={postings}
onChange={(p) => setPostings(p)}
/>
<View style={styles.postingBtnRow}>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.addPosting')}
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
variant="secondary"
/>
</View>
<View style={{ flex: 1 }}>
<Button
label={t('transaction.deleteLastPosting')}
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
variant="secondary"
/>
</View>
</View>
</Card>
)}
{/* 简单模式下的分类选择 */}
{!isAdvanced && direction !== 'transfer' && (
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
<CategoryPicker
categories={availableCategories}
selectedId={selectedCategory?.id}
onSelect={(cat) => setSelectedCategory(cat)}
/>
{selectedCategory && (
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
{selectedCategory.linkedAccount}
</Text>
</View>
)}
</Card>
)}
{/* 详情与高级字段收纳区 */}
<Pressable
onPress={() => {
LayoutAnimation.easeInEaseOut();
setShowDetails(!showDetails);
}}
style={({ pressed }) => [
styles.detailsToggle,
{
borderColor: theme.colors.border,
backgroundColor: theme.colors.bgSecondary,
opacity: pressed ? 0.8 : 1,
flexDirection: 'row',
gap: 6
}
]}
>
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
</Text>
<Ionicons
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
size={16}
color={theme.colors.accent}
/>
</Pressable>
{showDetails && (
<View style={{ gap: theme.spacing.md }}>
<Card title={t('transaction.extraDetails')}>
{/* 日期 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formDate')}
</Text>
<TextInput
value={date}
onChangeText={setDate}
placeholder="YYYY-MM-DD"
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 摘要 */}
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formNarration')}
</Text>
<TextInput
value={narration}
onChangeText={setNarration}
placeholder={t('transaction.formNarration')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
{/* 收款方(简单模式才显示) */}
{!isAdvanced && (
<>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formPayee')}
</Text>
<TextInput
value={payee}
onChangeText={setPayee}
placeholder={t('transaction.formPayeePlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
</>
)}
</Card>
{/* 标签选择 */}
<Card title={t('transaction.formTags')}>
<TagPicker
tags={tags}
selectedNames={selectedTags.map(tg => tg.name)}
onToggle={toggleTag}
/>
{selectedTags.length > 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
</Text>
)}
</Card>
</View>
)}
<Button label={t('transaction.submit')} onPress={submit} disabled={submitting} />
{status ? <Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8, fontFamily: theme.typography.bodySmall.fontFamily }]}>{status}</Text> : null}
</ScrollView>
</SafeAreaView> </SafeAreaView>
); );
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
page: { flex: 1 }, page: { flex: 1 },
content: { padding: 16, paddingBottom: 40 },
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
}); });

View File

@ -0,0 +1,89 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useSafeAreaInsets } from 'react-native-safe-area-context';
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
import { useTheme } from '../theme';
import { useSettingsStore } from '../store/settingsStore';
import { useNumpadUiStore } from '../store/numpadUiStore';
import type { IoniconName } from './categoryIcons';
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
const TAB_ICONS: Record<string, IoniconName> = {
index: 'home-outline',
transactions: 'list-outline',
report: 'pie-chart-outline',
settings: 'settings-outline',
};
/**
* 4 Tab +
* P2 /transaction/newP3 NumpadSheet
*/
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
const { theme } = useTheme();
const router = useRouter();
const insets = useSafeAreaInsets();
const numpadGlobalEntry = useSettingsStore(s => s.numpadGlobalEntry);
const openNumpad = useNumpadUiStore(s => s.open);
const renderTab = (route: (typeof state.routes)[number]) => {
const routeIndex = state.routes.indexOf(route);
const focused = state.index === routeIndex;
const options = descriptors[route.key].options;
const label = (options.title ?? route.name) as string;
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
const onPress = () => {
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
};
return (
<Pressable
key={route.key}
onPress={onPress}
style={styles.tab}
accessibilityRole="button"
accessibilityState={{ selected: focused }}
accessibilityLabel={label}
>
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
</Pressable>
);
};
return (
<View
style={[styles.bar, {
backgroundColor: theme.colors.bgSecondary,
borderTopColor: theme.colors.border,
paddingBottom: Math.max(insets.bottom, 8),
}]}
>
{state.routes.slice(0, 2).map(renderTab)}
<View style={styles.plusSlot}>
<Pressable
onPress={() => { if (numpadGlobalEntry) openNumpad(); else router.navigate('/transaction/new'); }}
style={[styles.plus, {
backgroundColor: theme.colors.accent,
borderColor: theme.colors.bgPrimary,
}, theme.shadows.lg]}
accessibilityRole="button"
accessibilityLabel="记一笔"
>
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
</Pressable>
</View>
{state.routes.slice(2).map(renderTab)}
</View>
);
}
const styles = StyleSheet.create({
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
tabLabel: { fontSize: 10, fontWeight: '600' },
plusSlot: { width: 64, alignItems: 'center' },
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
});

View File

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

View File

@ -53,7 +53,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
styles.button, styles.button,
{ {
backgroundColor: bg, backgroundColor: bg,
borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角 borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
opacity: disabled ? 0.5 : 1, opacity: disabled ? 0.5 : 1,
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0, borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
borderColor: theme.colors.border, borderColor: theme.colors.border,
@ -61,7 +61,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
}, },
]} ]}
> >
<Text style={[styles.text, { color: fg, fontFamily: theme.typography.body.fontFamily }]}>{label}</Text> <Text style={[styles.text, { color: fg }]}>{label}</Text>
</Animated.View> </Animated.View>
</Pressable> </Pressable>
); );

View File

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

View File

@ -34,7 +34,7 @@ export function Card({ title, children, onPress, style }: CardProps) {
const cardStyle = { const cardStyle = {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, borderRadius: theme.radii.xl,
padding: theme.spacing.md, padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,

View File

@ -0,0 +1,32 @@
import React from 'react';
import { StyleSheet, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { getCategoryColor } from '../theme/palette';
import { getCategoryIcon } from './categoryIcons';
interface CategoryIconProps {
categoryId: string;
/** 圆形底色直径,默认 36。 */
size?: number;
}
/** 分类图标Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji。 */
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
const color = getCategoryColor(categoryId);
return (
<View
style={[styles.circle, {
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: color + '1A',
}]}
>
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
</View>
);
}
const styles = StyleSheet.create({
circle: { alignItems: 'center', justifyContent: 'center' },
});

View File

@ -1,7 +1,9 @@
import React from 'react'; import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { getCategoryColor } from '../theme/palette';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
import { CategoryIcon } from './CategoryIcon';
interface CategoryPickerProps { interface CategoryPickerProps {
categories: Category[]; categories: Category[];
@ -9,44 +11,6 @@ interface CategoryPickerProps {
onSelect: (cat: Category) => void; onSelect: (cat: Category) => void;
} }
const CATEGORY_ICONS: Record<string, string> = {
food: '🍔',
transport: '🚗',
shopping: '🛍️',
housing_utility: '💧',
housing_rent: '🏠',
housing_communication: '📞',
entertainment: '🎮',
services: '⚙️',
personal_care: '💅',
clothing: '👕',
health: '🏥',
learning: '📚',
salary: '💰',
income_activity: '🧧',
income_investment: '📈',
fallback: '🏷️',
};
const CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B', // Amber
transport: '#3B82F6', // Blue
shopping: '#EC4899', // Pink
housing_utility: '#06B6D4',// Cyan
housing_rent: '#6366F1', // Indigo
housing_communication: '#8B5CF6', // Purple
entertainment: '#10B981', // Emerald
services: '#64748B', // Slate
personal_care: '#F43F5E', // Rose
clothing: '#14B8A6', // Teal
health: '#EF4444', // Red
learning: '#84CC16', // Lime
salary: '#22C55E', // Green
income_activity: '#EF4444',// Red/Orange
income_investment: '#F59E0B', // Amber
fallback: '#64748B',
};
/** 分类选择器(网格卡片化布局)。 */ /** 分类选择器(网格卡片化布局)。 */
export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) { export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPickerProps) {
const { theme } = useTheme(); const { theme } = useTheme();
@ -55,8 +19,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
<View style={styles.grid}> <View style={styles.grid}>
{categories.map(cat => { {categories.map(cat => {
const active = cat.id === selectedId; const active = cat.id === selectedId;
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback; const color = getCategoryColor(cat.id);
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
return ( return (
<Pressable <Pressable
@ -71,13 +34,12 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
} }
]} ]}
> >
<Text style={[styles.icon, { color }]}>{icon}</Text> <CategoryIcon categoryId={cat.id} size={32} />
<Text <Text
style={[ style={[
styles.label, styles.label,
{ {
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary, color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
fontFamily: theme.typography.caption.fontFamily,
fontWeight: active ? '700' : '500', fontWeight: active ? '700' : '500',
} }
]} ]}
@ -109,9 +71,6 @@ const styles = StyleSheet.create({
gap: 6, gap: 6,
padding: 4, padding: 4,
}, },
icon: {
fontSize: 22,
},
label: { label: {
fontSize: 12, fontSize: 12,
}, },

View File

@ -0,0 +1,117 @@
import React, { useMemo, useState } from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { BottomSheet } from './BottomSheet';
import { buildMonthGrid } from './dateGrid';
interface DatePickerFieldProps {
/** YYYY-MM-DD空串表示未选。 */
value: string;
onChange: (date: string) => void;
placeholder?: string;
}
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD。 */
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
const { theme } = useTheme();
const t = useT();
// 星期头周一起(与 buildMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('datepicker.weekdays').split(','), [t]);
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
const [open, setOpen] = useState(false);
const [viewYear, setViewYear] = useState(new Date().getFullYear());
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
const openPicker = () => {
const y = Number(value.slice(0, 4));
const m = Number(value.slice(5, 7));
if (y) {
setViewYear(y);
setViewMonth(m || 1);
}
setOpen(true);
};
const shiftMonth = (delta: number) => {
const m = viewMonth - 1 + delta;
setViewYear(viewYear + Math.floor(m / 12));
setViewMonth(((m % 12) + 12) % 12 + 1);
};
const pick = (date: string) => {
onChange(date);
setOpen(false);
};
return (
<>
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button" accessibilityLabel={value || placeholder || '选择日期'}>
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
{value || placeholder || 'YYYY-MM-DD'}
</Text>
</Pressable>
<BottomSheet visible={open} onClose={() => setOpen(false)}>
{/* 月份导航 */}
<View style={styles.navRow}>
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
{viewYear}-{String(viewMonth).padStart(2, '0')}
</Text>
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
</Pressable>
</View>
{/* 星期头 */}
<View style={styles.weekRow}>
{weekdays.map(w => (
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
))}
</View>
{/* 日期网格 */}
{grid.map((week, wi) => (
<View key={wi} style={styles.weekRow}>
{week.map(cell => {
const selected = cell.date === value;
return (
<Pressable
key={cell.date}
onPress={() => pick(cell.date)}
accessibilityRole="button"
accessibilityLabel={cell.date}
accessibilityState={{ selected }}
style={[styles.dayCell, {
backgroundColor: selected ? theme.colors.accent : 'transparent',
borderRadius: theme.radii.full,
}]}
>
<Text style={[theme.typography.bodySmall, {
color: selected
? theme.colors.fgInverse
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
opacity: cell.inMonth ? 1 : 0.5,
fontVariant: ['tabular-nums'],
}]}>
{Number(cell.date.slice(8, 10))}
</Text>
</Pressable>
);
})}
</View>
))}
</BottomSheet>
</>
);
}
const styles = StyleSheet.create({
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
weekRow: { flexDirection: 'row' },
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
});

View File

@ -0,0 +1,95 @@
/**
* spec §6 / /
*
*/
import React from 'react';
import { StyleSheet, Text, TextInput, View } from 'react-native';
import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n';
import { BottomSheet } from './BottomSheet';
import { DatePickerField } from './DatePickerField';
import { Button } from './Button';
export interface AdvancedFilterValues {
account: string;
dateFrom: string;
dateTo: string;
amountMin: string;
amountMax: string;
}
interface FilterSheetProps {
visible: boolean;
onClose: () => void;
values: AdvancedFilterValues;
onChange: (values: AdvancedFilterValues) => void;
onReset: () => void;
}
export function FilterSheet({ visible, onClose, values, onChange, onReset }: FilterSheetProps) {
const { theme } = useTheme();
const commonStyles = createCommonStyles(theme);
const t = useT();
const set = (patch: Partial<AdvancedFilterValues>) => onChange({ ...values, ...patch });
return (
<BottomSheet visible={visible} onClose={onClose} title={t('filter.title')} scrollable>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.accountFilterPlaceholder')}
</Text>
<TextInput
value={values.account}
onChangeText={v => set({ account: v })}
placeholder={t('transactions.accountFilterPlaceholder')}
placeholderTextColor={theme.colors.fgSecondary}
style={commonStyles.input}
/>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.dateFrom')} ~ {t('transactions.dateTo')}
</Text>
<View style={styles.row}>
<View style={styles.flex1}><DatePickerField value={values.dateFrom} onChange={v => set({ dateFrom: v })} placeholder={t('transactions.dateFrom')} /></View>
<View style={styles.flex1}><DatePickerField value={values.dateTo} onChange={v => set({ dateTo: v })} placeholder={t('transactions.dateTo')} /></View>
</View>
<Text style={[theme.typography.caption, styles.label, { color: theme.colors.fgSecondary }]}>
{t('transactions.amountMin')} ~ {t('transactions.amountMax')}
</Text>
<View style={styles.row}>
<TextInput
value={values.amountMin}
onChangeText={v => set({ amountMin: v })}
placeholder={t('transactions.amountMin')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
<TextInput
value={values.amountMax}
onChangeText={v => set({ amountMax: v })}
placeholder={t('transactions.amountMax')}
placeholderTextColor={theme.colors.fgSecondary}
keyboardType="decimal-pad"
style={[commonStyles.input, styles.flex1]}
/>
</View>
<View style={[styles.row, styles.actions]}>
<View style={styles.flex1}>
<Button label={t('filter.reset')} variant="secondary" onPress={onReset} />
</View>
<View style={styles.flex1}>
<Button label={t('filter.apply')} onPress={onClose} />
</View>
</View>
</BottomSheet>
);
}
const styles = StyleSheet.create({
label: { marginTop: 10, marginBottom: 4 },
row: { flexDirection: 'row', gap: 8 },
flex1: { flex: 1 },
actions: { marginTop: 16 },
});

View File

@ -20,6 +20,7 @@
import React, { useEffect, useMemo, useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native'; import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useTheme, createCommonStyles } from '../theme'; import { useTheme, createCommonStyles } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { Button } from './Button'; import { Button } from './Button';
@ -49,9 +50,11 @@ interface FormModalProps {
onCancel: () => void; onCancel: () => void;
/** 确认按钮文本(默认「确定」)。 */ /** 确认按钮文本(默认「确定」)。 */
confirmLabel?: string; confirmLabel?: string;
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
children?: React.ReactNode;
} }
export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel }: FormModalProps) { export function FormModal({ visible, title, fields, onConfirm, onCancel, confirmLabel, children }: FormModalProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]); const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
@ -74,7 +77,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
<Pressable <Pressable
style={[styles.sheet, { style={[styles.sheet, {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, borderRadius: theme.radii.xl,
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
}]} }]}
@ -83,7 +86,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
<View style={[styles.header, { borderBottomColor: theme.colors.divider }]}> <View style={[styles.header, { borderBottomColor: theme.colors.divider }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
<Pressable onPress={onCancel} hitSlop={8}> <Pressable onPress={onCancel} hitSlop={8}>
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}></Text> <Ionicons name="close" size={20} color={theme.colors.fgSecondary} />
</Pressable> </Pressable>
</View> </View>
@ -107,6 +110,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
))} ))}
</ScrollView> </ScrollView>
{children}
<View style={styles.actions}> <View style={styles.actions}>
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" /> <Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
<View style={{ flex: 1 }} /> <View style={{ flex: 1 }} />

View File

@ -0,0 +1,112 @@
import React, { useState } from 'react';
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, type ViewStyle } from 'react-native';
import { SafeAreaView } from 'react-native-safe-area-context';
import { Ionicons } from '@expo/vector-icons';
import { useTheme } from '../theme';
import { useT } from '../i18n';
import { ScreenHeader } from './ScreenHeader';
import { FormModal, type FormField } from './FormModal';
/**
* P2 + + + + FormModal
* header/Alert/Modal
*/
interface ManagementScreenProps<T> {
title: string;
items: T[];
keyExtractor: (item: T) => string;
/** 渲染单个条目handlers.openEdit 打开编辑弹窗handlers.confirmDelete 弹删除确认。 */
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
addLabel: string;
emptyText?: string;
formTitle: (editing: T | null) => string;
formFields: (editing: T | null) => FormField[];
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
onDelete: (item: T) => void;
deleteConfirmText: (item: T) => string;
/** 删除确认弹窗标题(默认通用「删除」)。 */
deleteConfirmTitle?: string;
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
formExtra?: (editing: T | null) => React.ReactNode;
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
onOpenForm?: (editing: T | null) => void;
/** 列表上方的额外内容(分组 Tab 等)。 */
headerContent?: React.ReactNode;
footer?: React.ReactNode;
/** 条目容器的额外样式(如标签页的 flexDirection row + flexWrap。 */
listStyle?: ViewStyle;
}
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
const { theme } = useTheme();
const t = useT();
const [editing, setEditing] = useState<T | null>(null);
const [modalOpen, setModalOpen] = useState(false);
const openForm = (item: T | null) => {
setEditing(item);
props.onOpenForm?.(item);
setModalOpen(true);
};
const confirmDelete = (item: T) => {
Alert.alert(props.deleteConfirmTitle ?? t('common.delete'), props.deleteConfirmText(item), [
{ text: t('common.cancel'), style: 'cancel' },
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
]);
};
const closeModal = () => {
setModalOpen(false);
setEditing(null);
};
const handleSubmit = (values: Record<string, string>) => {
if (props.onSubmit(values, editing)) closeModal();
};
return (
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
<ScreenHeader
title={props.title}
right={
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
<Ionicons name="add" size={26} color={theme.colors.accent} />
</Pressable>
}
/>
<ScrollView contentContainerStyle={styles.content}>
{props.headerContent}
{props.items.length === 0 && props.emptyText ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
) : null}
<View style={props.listStyle}>
{props.items.map(item => (
<View key={props.keyExtractor(item)}>
{props.renderItem(item, {
openEdit: () => openForm(item),
confirmDelete: () => confirmDelete(item),
})}
</View>
))}
</View>
{props.footer}
</ScrollView>
<FormModal
visible={modalOpen}
title={props.formTitle(editing)}
fields={props.formFields(editing)}
onConfirm={handleSubmit}
onCancel={closeModal}
>
{props.formExtra?.(editing)}
</FormModal>
</SafeAreaView>
);
}
const styles = StyleSheet.create({
page: { flex: 1 },
content: { padding: 16, gap: 12 },
});

View File

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

View File

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

View File

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

View File

@ -60,7 +60,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
placeholder="金额" placeholder="金额"
keyboardType="decimal-pad" keyboardType="decimal-pad"
placeholderTextColor={theme.colors.fgSecondary} placeholderTextColor={theme.colors.fgSecondary}
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]} style={[commonStyles.input, { flex: 1, fontVariant: ['tabular-nums'] }]}
/> />
<TextInput <TextInput
value={p.currency ?? ''} value={p.currency ?? ''}

View File

@ -0,0 +1,43 @@
import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
interface ScreenHeaderProps {
title: string;
/** 右侧操作位(图标按钮/文本按钮)。 */
right?: React.ReactNode;
/** 自定义返回行为;默认 router.back()。 */
onBack?: () => void;
/** 返回按钮的无障碍标签。 */
backLabel?: string;
}
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header。 */
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
const { theme } = useTheme();
const router = useRouter();
return (
<View style={styles.row}>
<Pressable
onPress={onBack ?? (() => router.back())}
hitSlop={8}
accessibilityRole="button"
accessibilityLabel={backLabel}
>
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
</Pressable>
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
{title}
</Text>
{right ?? <View style={styles.rightPlaceholder} />}
</View>
);
}
const styles = StyleSheet.create({
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
title: { flex: 1 },
rightPlaceholder: { width: 24 },
});

View File

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

View File

@ -1,197 +0,0 @@
import React, { useCallback, useState } from 'react';
import { Animated, Pressable, StyleSheet, Text, View } from 'react-native';
import { Ionicons } from '@expo/vector-icons';
import { useRouter } from 'expo-router';
import { useTheme } from '../theme';
import { useT } from '../i18n';
/** 快捷操作项。 */
export interface SpeedDialAction {
key: string;
icon: keyof typeof Ionicons.glyphMap;
label: string;
onPress: () => void;
}
interface SpeedDialProps {
/** 展开式快捷操作列表。不传时保持单按钮行为(记一笔)。 */
actions?: SpeedDialAction[];
}
/**
*
* - actions "+"
* - actions mini-FAB //
*/
export function SpeedDial({ actions }: SpeedDialProps) {
const { theme } = useTheme();
const t = useT();
const router = useRouter();
const [open, setOpen] = useState(false);
const [spin] = useState(new Animated.Value(0));
const hasActions = actions && actions.length > 0;
const toggle = useCallback(() => {
setOpen(prev => {
const next = !prev;
Animated.spring(spin, {
toValue: next ? 1 : 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
return next;
});
}, [spin]);
const handleAction = useCallback((action: SpeedDialAction) => {
setOpen(false);
Animated.spring(spin, {
toValue: 0,
useNativeDriver: true,
tension: 80,
friction: 8,
}).start();
action.onPress();
}, [spin]);
const mainRotation = spin.interpolate({
inputRange: [0, 1],
outputRange: ['0deg', '45deg'],
});
const mainIcon = hasActions ? (
<Animated.View style={{ transform: [{ rotate: mainRotation }] }}>
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
</Animated.View>
) : (
<Ionicons name="add" size={28} color={theme.colors.fgInverse} />
);
return (
<>
{/* 遮罩层:展开时点击空白收起 */}
{hasActions && open && (
<Pressable
onPress={toggle}
style={[styles.overlay, { backgroundColor: theme.colors.overlay }]}
/>
)}
{/* 展开的快捷操作列表 */}
{hasActions && (
<View style={styles.actionsContainer} pointerEvents={open ? 'auto' : 'none'}>
{actions!.map((action, i) => {
const offset = 72 + 56 * (actions!.length - 1 - i);
const translateY = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, -offset],
});
const opacity = spin.interpolate({
inputRange: [0, 1],
outputRange: [0, 1],
});
const scale = spin.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
});
return (
<Animated.View
key={action.key}
style={[
styles.actionRow,
{ transform: [{ translateY }, { scale }], opacity },
]}
>
<View style={[styles.actionLabel, { backgroundColor: theme.colors.bgSecondary }]}>
<Text style={[theme.typography.caption, { color: theme.colors.fgPrimary }]}>
{action.label}
</Text>
</View>
<Pressable
onPress={() => handleAction(action)}
accessibilityRole="button"
accessibilityLabel={action.label}
style={({ pressed }) => [
styles.miniFab,
{
backgroundColor: theme.colors.bgSecondary,
borderColor: theme.colors.border,
opacity: pressed ? 0.7 : 1,
},
]}
>
<Ionicons name={action.icon} size={20} color={theme.colors.accent} />
</Pressable>
</Animated.View>
);
})}
</View>
)}
{/* 主按钮 */}
<Pressable
onPress={hasActions ? toggle : () => router.push('/transaction/new')}
style={({ pressed }) => [
styles.fab,
{
backgroundColor: theme.colors.accent,
opacity: pressed ? 0.85 : 1,
...theme.shadows.lg,
},
]}
accessibilityRole="button"
accessibilityLabel={t('home.newTransaction')}
accessibilityHint={hasActions ? t('speedDial.expandHint') : undefined}
>
{mainIcon}
</Pressable>
</>
);
}
const styles = StyleSheet.create({
overlay: {
position: 'absolute',
top: 0, left: 0, right: 0, bottom: 0,
zIndex: 9998,
},
fab: {
position: 'absolute',
right: 20,
bottom: 20,
width: 56,
height: 56,
borderRadius: 28,
alignItems: 'center',
justifyContent: 'center',
zIndex: 9999,
},
actionsContainer: {
position: 'absolute',
right: 28,
bottom: 28,
zIndex: 9999,
},
actionRow: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'flex-end',
gap: 8,
marginBottom: 8,
},
actionLabel: {
paddingHorizontal: 10,
paddingVertical: 4,
borderRadius: 6,
},
miniFab: {
width: 44,
height: 44,
borderRadius: 22,
alignItems: 'center',
justifyContent: 'center',
borderWidth: 1,
},
});

View File

@ -0,0 +1,41 @@
import React from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme';
interface StatCardProps {
label: string;
value: string;
caption?: string;
/** 默认 fgPrimary收支场景传 financial.income/expense。 */
valueColor?: string;
}
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
const { theme } = useTheme();
return (
<View
style={[styles.card, {
backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.xl,
borderColor: theme.colors.border,
}, theme.shadows.sm]}
>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
<Text
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
numberOfLines={1}
>
{value}
</Text>
{caption ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
) : null}
</View>
);
}
const styles = StyleSheet.create({
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
});

View File

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

View File

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

View File

@ -2,52 +2,70 @@ import React from 'react';
import { Pressable, StyleSheet, Text, View } from 'react-native'; import { Pressable, StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../theme'; import { useTheme } from '../theme';
import { useT } from '../i18n'; import { useT } from '../i18n';
import { CategoryIcon } from './CategoryIcon';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
interface TransactionCardProps { interface TransactionCardProps {
transaction: Transaction; transaction: Transaction;
onPress?: (t: Transaction) => void; onPress?: (t: Transaction) => void;
/** 命中分类 id调用方按 linkedAccount 匹配);缺省用方向兜底图标。 */
categoryId?: string;
/** 时间线分组下隐藏日期(默认 true 保持旧行为)。 */
showDate?: boolean;
} }
/** 交易卡片(主题化,展示日期/摘要/金额)。 */ /** 交易卡片CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额(支出黑、收入绿+、转账蓝)。 */
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress }: TransactionCardProps) { export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses')); const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
const isIncome = transaction.postings.some(p => p.account.startsWith('Income')); const isIncome = transaction.postings.some(p => p.account.startsWith('Income'));
const color = isExpense ? theme.colors.financial.expense : isIncome ? theme.colors.financial.income : theme.colors.financial.transfer; const direction = isExpense ? 'expense' : isIncome ? 'income' : 'transfer';
const primaryAmount = transaction.postings.find(p => p.amount)?.amount ?? ''; const rawAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
const absAmount = rawAmount.replace(/^-/, '');
const amountColor = direction === 'income' ? theme.colors.financial.income
: direction === 'transfer' ? theme.colors.financial.transfer
: theme.colors.fgPrimary;
const amountText = direction === 'income' ? `+${absAmount}` : direction === 'expense' ? `-${absAmount}` : absAmount;
// 账户小字:第一条资金腿的短名
const assetAccount = transaction.postings.find(p => p.account.startsWith('Assets') || p.account.startsWith('Liabilities'))?.account;
const accountShort = assetAccount?.split(':').pop();
const subtitle = [
showDate ? transaction.date.slice(0, 10) : '',
accountShort ?? '',
].filter(Boolean).join(' · ');
return ( return (
<Pressable <Pressable
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${primaryAmount} ${transaction.date.slice(0, 10)}`} accessibilityLabel={`${transaction.narration || transaction.payee || ''} ${amountText} ${transaction.date.slice(0, 10)}`}
onPress={() => onPress?.(transaction)} onPress={() => onPress?.(transaction)}
style={({ pressed }) => [ style={({ pressed }) => [
styles.card, styles.card,
{ {
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, // 升级为大圆角 borderRadius: theme.radii.lg,
padding: theme.spacing.md, padding: theme.spacing.md,
borderWidth: StyleSheet.hairlineWidth, // 添加极细透白边框 borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
opacity: pressed ? 0.8 : 1, opacity: pressed ? 0.8 : 1,
}, },
]} ]}
> >
<View style={styles.row}> <CategoryIcon categoryId={categoryId ?? direction} size={36} />
<View style={{ flex: 1 }}> <View style={styles.body}>
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}> <Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
{transaction.narration || transaction.payee || t('transaction.noSummary')} {transaction.narration || transaction.payee || t('transaction.noSummary')}
</Text>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>
{transaction.date.slice(0, 10)}
</Text>
</View>
<Text style={[theme.typography.body, { color, fontWeight: '700', fontFamily: 'monospace', fontSize: 15 }]}>
{primaryAmount}
</Text> </Text>
{subtitle ? (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
{subtitle}
</Text>
) : null}
</View> </View>
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
{amountText}
</Text>
{transaction.tags.length > 0 && ( {transaction.tags.length > 0 && (
<View style={styles.tags}> <View style={styles.tags}>
{transaction.tags.map(tag => ( {transaction.tags.map(tag => (
@ -62,8 +80,8 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
}); });
const styles = StyleSheet.create({ const styles = StyleSheet.create({
card: { marginBottom: 8 }, card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 }, body: { flex: 1 },
tags: { flexDirection: 'row', gap: 4, marginTop: 6, flexWrap: 'wrap' }, tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' },
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' }, tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
}); });

View File

@ -0,0 +1,39 @@
/**
* id Ionicons CategoryPicker emoji
* Vitest(node)
*/
import type { ComponentProps } from 'react';
import type { Ionicons } from '@expo/vector-icons';
export type IoniconName = ComponentProps<typeof Ionicons>['name'];
export const CATEGORY_ICON_NAMES: Record<string, IoniconName> = {
food: 'fast-food-outline',
transport: 'bus-outline',
shopping: 'bag-handle-outline',
housing_utility: 'water-outline',
housing_rent: 'home-outline',
housing_communication: 'call-outline',
entertainment: 'game-controller-outline',
services: 'construct-outline',
personal_care: 'sparkles-outline',
clothing: 'shirt-outline',
health: 'medkit-outline',
learning: 'book-outline',
salary: 'wallet-outline',
income_activity: 'gift-outline',
income_investment: 'trending-up-outline',
// 方向兜底无分类交易TransactionCard 传 expense/income/transfer
expense: 'cart-outline',
income: 'cash-outline',
transfer: 'swap-horizontal-outline',
};
const FALLBACK_ICON: IoniconName = 'pricetag-outline';
/** 取分类图标:具名映射,否则 fallbackhasOwn 防原型链穿透)。 */
export function getCategoryIcon(categoryId: string): IoniconName {
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
? CATEGORY_ICON_NAMES[categoryId]
: FALLBACK_ICON;
}

View File

@ -1,19 +1,23 @@
/** /**
* plan.md5.2 * plan.md5.2 P4
* // * //
*/ */
import React, { useMemo } from 'react'; import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native'; import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme'; import { useTheme } from '../../theme';
import { useT } from '../../i18n'; import { useT } from '../../i18n';
import { generateAnnualReport } from '../../domain/annualReport'; import { generateAnnualReport } from '../../domain/annualReport';
import { MonthlyReport } from './MonthlyReport';
import type { Transaction } from '../../domain/types'; import type { Transaction } from '../../domain/types';
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) { export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]); const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
// 展示层比率计算,允许 parseFloat非记账金额
const maxExpense = useMemo(
() => Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0)),
[report],
);
return ( return (
<View style={styles.container}> <View style={styles.container}>
@ -50,7 +54,22 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
</View> </View>
)} )}
<MonthlyReport transactions={transactions.filter(t => t.date.startsWith(String(year)))} /> <View style={[styles.card, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyRhythm')}</Text>
<View style={styles.rhythmRow}>
{report.monthlyTrend.map(m => {
const ratio = maxExpense > 0 ? Math.min(parseFloat(m.expense) / maxExpense, 1) : 0;
return (
<View key={m.month} style={styles.rhythmCol}>
<View style={[styles.rhythmTrack, { backgroundColor: theme.colors.bgTertiary }]}>
<View style={[styles.rhythmFill, { height: `${Math.max(ratio * 100, 2)}%`, backgroundColor: theme.colors.financial.expense }]} />
</View>
<Text style={[styles.rhythmLabel, { color: theme.colors.fgSecondary }]}>{Number(m.month.slice(5))}</Text>
</View>
);
})}
</View>
</View>
</View> </View>
); );
} }
@ -61,4 +80,9 @@ const styles = StyleSheet.create({
statsRow: { flexDirection: 'row', justifyContent: 'space-around' }, statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
statItem: { alignItems: 'center', gap: 4 }, statItem: { alignItems: 'center', gap: 4 },
catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 }, catRow: { flexDirection: 'row', borderTopWidth: 1, paddingTop: 8 },
rhythmRow: { flexDirection: 'row', gap: 4, height: 64, marginTop: 8 },
rhythmCol: { flex: 1, alignItems: 'center', gap: 4 },
rhythmTrack: { flex: 1, width: '100%', borderRadius: 4, justifyContent: 'flex-end', overflow: 'hidden' },
rhythmFill: { width: '100%', borderRadius: 4 },
rhythmLabel: { fontSize: 9 },
}); });

View File

@ -16,11 +16,11 @@ function heatColor(intensity: number): { opacity: number } {
return { opacity: Math.min(0.2 + intensity * 0.8, 1) }; return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
} }
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) { export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
const { theme } = useTheme(); const { theme } = useTheme();
const t = useT(); const t = useT();
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]); const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]); const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
const maxDaily = Math.max(...dailyMap.values(), 1); const maxDaily = Math.max(...dailyMap.values(), 1);
@ -31,7 +31,7 @@ export function CalendarHeatmap({ transactions, year, month }: { transactions: T
{t('report.heatmap')} {t('report.heatmap')}
</Text> </Text>
<View style={styles.weekdayRow}> <View style={styles.weekdayRow}>
{WEEKDAYS.map(d => ( {weekdays.map(d => (
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1, fontSize: 10 }]}> <Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1, fontSize: 10 }]}>
{d} {d}
</Text> </Text>

View File

@ -17,14 +17,14 @@ export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.categoryTitle')} {t('report.categoryTitle')}
</Text> </Text>
<View style={styles.list}> <View style={styles.list}>
{data.map(d => ( {data.map(d => (
<View key={d.category} style={styles.row}> <View key={d.category} style={styles.row}>
<Text <Text
style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600', fontFamily: theme.typography.bodySmall.fontFamily }]} style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, flex: 1, fontWeight: '600' }]}
numberOfLines={1} numberOfLines={1}
> >
{d.category.replace('Expenses:', '').replace('Income:', '')} {d.category.replace('Expenses:', '').replace('Income:', '')}
@ -32,7 +32,7 @@ export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}> <View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary, borderColor: theme.colors.border, borderWidth: StyleSheet.hairlineWidth }]}>
<View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} /> <View style={[styles.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
</View> </View>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontFamily: 'monospace', fontSize: 13 }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 55, textAlign: 'right', fontVariant: ['tabular-nums'], fontSize: 13 }]}>
{d.percentage}% {d.percentage}%
</Text> </Text>
</View> </View>

View File

@ -1,45 +0,0 @@
/**
* plan.md5.2
* RN
*/
import React, { useMemo } from 'react';
import { StyleSheet, Text, View } from 'react-native';
import { useTheme } from '../../theme';
import { useT } from '../../i18n';
import { groupByMonth } from '../../domain/chartStats';
import type { Transaction } from '../../domain/types';
export function MonthlyReport({ transactions }: { transactions: Transaction[] }) {
const { theme } = useTheme();
const t = useT();
const data = useMemo(() => groupByMonth(transactions), [transactions]);
const maxExpense = Math.max(...data.map(d => d.expense), 1);
return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.md, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{t('report.monthlyTitle')}</Text>
{data.map(d => (
<View key={d.month} style={styles.row}>
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, width: 60 }]}>{d.month.slice(5)}</Text>
<View style={[styles.barWrap, { backgroundColor: theme.colors.progressBg }]}>
<View style={[styles.bar, {
width: `${(d.expense / maxExpense) * 100}%`,
backgroundColor: theme.colors.financial.expense,
}]} />
</View>
<Text style={[theme.typography.caption, { color: theme.colors.financial.expense }]}>{d.expense.toFixed(0)}</Text>
</View>
))}
{data.length === 0 && (
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('common.noData')}</Text>
)}
</View>
);
}
const styles = StyleSheet.create({
container: { gap: 6 },
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
barWrap: { flex: 1, height: 12, borderRadius: 6, overflow: 'hidden' },
bar: { height: '100%', borderRadius: 6 },
});

View File

@ -73,7 +73,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
return ( return (
<View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}> <View style={[styles.container, { backgroundColor: theme.colors.bgSecondary, borderRadius: theme.radii.lg, padding: theme.spacing.md }]}>
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h3.fontFamily }]}> <Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
{t('report.trendTitle')} {t('report.trendTitle')}
</Text> </Text>
@ -153,7 +153,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
{data.map((d, i) => ( {data.map((d, i) => (
<Text <Text
key={`x-label-${i}`} key={`x-label-${i}`}
style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10, fontFamily: theme.typography.caption.fontFamily }]} style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontSize: 10 }]}
> >
{d.month.slice(5)} {d.month.slice(5)}
</Text> </Text>
@ -168,11 +168,11 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
<View style={styles.legend}> <View style={styles.legend}>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.income }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.income')} {t('home.income')}
</Text> </Text>
<View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} /> <View style={[styles.legendDot, { backgroundColor: theme.colors.financial.expense }]} />
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}> <Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
{t('home.expense')} {t('home.expense')}
</Text> </Text>
</View> </View>

View File

@ -0,0 +1,36 @@
/**
* 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;
}

View File

@ -0,0 +1,62 @@
/**
* / 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; // 前导零替换
} else {
// 整数位上限仅在段内无小数点时生效('123456789.' 后仍可输小数)
if (seg.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) {
const negative = part.startsWith('-');
const body = (negative || part.startsWith('+') ? part.slice(1) : part).replace(/\.$/, '');
if (!body) continue; // 防御:剥完为空的段跳过(键盘不可达,但避免 parseDecimal 抛错)
if (negative) sum = subtractDecimals(sum, body);
else sum = addDecimals([sum, body]);
}
return sum;
}

View File

@ -0,0 +1,38 @@
/**
* P4 线
*
*/
import type { Transaction } from '../domain/types';
import { addDecimals, negateDecimal } from '../domain/decimal';
export interface DayGroup {
date: string; // YYYY-MM-DD
income: string;
expense: string;
items: Transaction[];
}
export function groupTransactionsByDate(txs: Transaction[]): DayGroup[] {
const groups: DayGroup[] = [];
const byDate = new Map<string, DayGroup>();
for (const t of txs) {
const date = t.date.slice(0, 10);
let group = byDate.get(date);
if (!group) {
group = { date, income: '0', expense: '0', items: [] };
byDate.set(date, group);
groups.push(group);
}
group.items.push(t);
const incomeAmounts: string[] = [];
const expenseAmounts: string[] = [];
for (const p of t.postings) {
if (!p.amount) continue;
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
}
if (incomeAmounts.length) group.income = addDecimals([group.income, ...incomeAmounts]);
if (expenseAmounts.length) group.expense = addDecimals([group.expense, ...expenseAmounts]);
}
return groups;
}

View File

@ -0,0 +1,51 @@
/**
* 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<Awaited<ReturnType<OcrProcessor['process']>>['event']>;
let processor: OcrProcessor | null = null;
export function useOcrScan() {
const t = useT();
const [status, setStatus] = useState('');
const scan = async (): Promise<OcrEvent | null> => {
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'));
// 成功/失败提示 3 秒后自动清除,避免常驻
setTimeout(() => setStatus(''), 3000);
return ocrResult.event;
} catch (e) {
setStatus(t('ocr.failed') + ': ' + (e instanceof Error ? e.message : String(e)));
setTimeout(() => setStatus(''), 3000);
return null;
}
};
return { scan, status };
}

View File

@ -91,7 +91,10 @@ function filterByPeriod(
/** 计算周期的起止日期。 */ /** 计算周期的起止日期。 */
export function getPeriodRange(period: Budget['period'], currentDate: string): { start: string; end: string } { export function getPeriodRange(period: Budget['period'], currentDate: string): { start: string; end: string } {
const d = new Date(currentDate); // new Date('YYYY-MM-DD') 按 UTC 解析,负时区会把边界日(如 07-01算到前一天
// 改为 split 构造本地 Date与 periodNav 的 parse 一致
const [y, m, dNum] = currentDate.slice(0, 10).split('-').map(Number);
const d = new Date(y, m - 1, dNum);
const year = d.getFullYear(); const year = d.getFullYear();
const month = d.getMonth(); const month = d.getMonth();
switch (period) { switch (period) {

View File

@ -7,6 +7,8 @@
* plan.md 1 * plan.md 1
*/ */
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
export interface ChannelConfig { export interface ChannelConfig {
id: string; id: string;
/** 渠道名(与 ImportedEvent.channel 对齐,如 "Alipay", "WeChat", "Bank")。 */ /** 渠道名(与 ImportedEvent.channel 对齐,如 "Alipay", "WeChat", "Bank")。 */
@ -29,7 +31,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
defaultAccount: 'Assets:支付宝余额', defaultAccount: 'Assets:支付宝余额',
packageNames: ['com.eg.android.AlipayGphone', 'com.eg.android.AlipayGphone.wallet'], packageNames: ['com.eg.android.AlipayGphone', 'com.eg.android.AlipayGphone.wallet'],
icon: 'alipay', icon: 'alipay',
color: '#1677FF', color: CHANNEL_BRAND_COLORS.alipay,
}, },
{ {
id: 'ch_wechat', id: 'ch_wechat',
@ -37,7 +39,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
defaultAccount: 'Assets:微信零钱', defaultAccount: 'Assets:微信零钱',
packageNames: ['com.tencent.mm', 'com.tencent.mobileqq'], packageNames: ['com.tencent.mm', 'com.tencent.mobileqq'],
icon: 'wechat', icon: 'wechat',
color: '#07C160', color: CHANNEL_BRAND_COLORS.wechat,
}, },
{ {
id: 'ch_bank', id: 'ch_bank',
@ -45,7 +47,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
defaultAccount: 'Assets:银行卡', defaultAccount: 'Assets:银行卡',
packageNames: ['com.unionpay', 'com.cmbchina', 'com.icbc'], packageNames: ['com.unionpay', 'com.cmbchina', 'com.icbc'],
icon: 'bank', icon: 'bank',
color: '#E53935', color: CHANNEL_BRAND_COLORS.bank,
}, },
]; ];

View File

@ -28,3 +28,5 @@ export * from './ocr';
export * from './ocrProcessor'; export * from './ocrProcessor';
export * from './calendarGrid'; export * from './calendarGrid';
export * from './chartStats'; export * from './chartStats';
export * from './postingLegs';
export * from './periodNav';

58
src/domain/periodNav.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* P4 anchor + //
* toISOString UTC
*/
import { toDateString } from './decimal';
export type ReportPeriod = 'weekly' | 'monthly' | 'annual';
function parse(dateStr: string): Date {
const [y, m, d] = dateStr.split('-').map(Number);
return new Date(y, m - 1, d);
}
/** 平移 anchor周 ±7 天;月 ±N月末日钳制年 ±N。 */
export function shiftAnchor(anchor: string, period: ReportPeriod, delta: number): string {
const d = parse(anchor);
if (period === 'weekly') {
d.setDate(d.getDate() + 7 * delta);
} else if (period === 'monthly') {
const day = d.getDate();
d.setDate(1);
d.setMonth(d.getMonth() + delta);
// 月末钳制:目标月天数不足时退到月末
const lastDay = new Date(d.getFullYear(), d.getMonth() + 1, 0).getDate();
d.setDate(Math.min(day, lastDay));
} else {
d.setFullYear(d.getFullYear() + delta);
}
return toDateString(d);
}
/** anchor 所在周期的起止日期(含两端)。周 = 周一~周日。 */
export function periodRange(anchor: string, period: ReportPeriod): { start: string; end: string } {
const d = parse(anchor);
if (period === 'weekly') {
const day = d.getDay(); // 0=周日
const diffToMonday = day === 0 ? -6 : 1 - day;
const monday = new Date(d);
monday.setDate(d.getDate() + diffToMonday);
const sunday = new Date(monday);
sunday.setDate(monday.getDate() + 6);
return { start: toDateString(monday), end: toDateString(sunday) };
}
if (period === 'monthly') {
const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end = new Date(d.getFullYear(), d.getMonth() + 1, 0);
return { start: toDateString(start), end: toDateString(end) };
}
return { start: `${d.getFullYear()}-01-01`, end: `${d.getFullYear()}-12-31` };
}
/** ISO-8601 周数(周一开头)。 */
export function isoWeekNumber(dateStr: string): number {
const date = parse(dateStr);
date.setDate(date.getDate() + 3 - ((date.getDay() + 6) % 7));
const week1 = new Date(date.getFullYear(), 0, 4);
return 1 + Math.round(((date.getTime() - week1.getTime()) / 86400000 - 3 + ((week1.getDay() + 6) % 7)) / 7);
}

73
src/domain/postingLegs.ts Normal file
View File

@ -0,0 +1,73 @@
/**
* 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(/^-/, '');
}
/**
* null
* 2 2 2
* null UI
*/
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) || abs(assetPosting?.amount),
currency: expensePosting.currency ?? assetPosting?.currency ?? 'CNY',
};
}
if (incomePosting) {
return {
direction: 'income',
sourceAccount: assetPosting?.account ?? '',
targetAccount: incomePosting.account,
amount: abs(incomePosting.amount) || abs(assetPosting?.amount),
currency: incomePosting.currency ?? assetPosting?.currency ?? 'CNY',
};
}
// 转账:两条资金腿,带负号的一条为转出方;都不带负号则保持原顺序
if (isAssetLike(postings[0].account) && isAssetLike(postings[1].account)) {
const [first, second] = postings;
const source = first.amount?.startsWith('-')
? first
: second.amount?.startsWith('-')
? second
: first;
const target = source === first ? second : first;
return {
direction: 'transfer',
sourceAccount: source.account,
targetAccount: target.account,
amount: abs(source.amount) || abs(target.amount),
currency: source.currency ?? target.currency ?? 'CNY',
};
}
return null;
}

View File

@ -5,6 +5,7 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import type { Transaction } from '../domain/types'; import type { Transaction } from '../domain/types';
import { compareDecimals } from '../domain/decimal';
import { useDebounce } from './useDebounce'; import { useDebounce } from './useDebounce';
export interface SearchFilters { export interface SearchFilters {
@ -14,6 +15,8 @@ export interface SearchFilters {
dateTo?: string; dateTo?: string;
tags?: string[]; tags?: string[];
direction?: 'income' | 'expense' | 'transfer'; direction?: 'income' | 'expense' | 'transfer';
amountMin?: string;
amountMax?: string;
} }
export function useSearch(transactions: Transaction[], filters: SearchFilters): Transaction[] { export function useSearch(transactions: Transaction[], filters: SearchFilters): Transaction[] {
@ -48,6 +51,12 @@ export function useSearch(transactions: Transaction[], filters: SearchFilters):
if (debouncedFilters.direction === 'income' && !isIncome) return false; if (debouncedFilters.direction === 'income' && !isIncome) return false;
if (debouncedFilters.direction === 'transfer' && !isTransfer) return false; if (debouncedFilters.direction === 'transfer' && !isTransfer) return false;
} }
if (debouncedFilters.amountMin || debouncedFilters.amountMax) {
const raw = t.postings.find(p => p.amount)?.amount;
const abs = raw?.replace(/^-/, '');
if (debouncedFilters.amountMin && (!abs || compareDecimals(abs, debouncedFilters.amountMin) < 0)) return false;
if (debouncedFilters.amountMax && abs && compareDecimals(abs, debouncedFilters.amountMax) > 0) return false;
}
return true; return true;
}); });
}, [transactions, debouncedFilters]); }, [transactions, debouncedFilters]);

View File

@ -20,6 +20,7 @@ export default {
clickEditLongDelete: 'Tap to edit · Long-press to delete', clickEditLongDelete: 'Tap to edit · Long-press to delete',
noData: 'No data', noData: 'No data',
discard: 'Discard', discard: 'Discard',
error: 'Error',
}, },
tab: { tab: {
home: 'Home', home: 'Home',
@ -27,7 +28,7 @@ export default {
report: 'Report', report: 'Report',
import: 'Import', import: 'Import',
rules: 'Rules', rules: 'Rules',
settings: 'Settings', settings: 'Me',
}, },
home: { home: {
title: 'Ledger Status', title: 'Ledger Status',
@ -56,6 +57,21 @@ export default {
accountTree: 'Account Balance Tree', accountTree: 'Account Balance Tree',
balanced: '✓ Balanced', balanced: '✓ Balanced',
unbalanced: '✗ Unbalanced', unbalanced: '✗ Unbalanced',
greetingMorning: 'Good morning',
greetingAfternoon: 'Good afternoon',
greetingEvening: 'Good evening',
monthExpense: 'Spent this month',
monthIncome: 'Earned this month',
budgetRemaining: 'Budget left',
viewAll: 'View all',
},
todo: {
title: 'To-do',
dueRecurring: 'Recurring "%{name}" is due',
creditCardDue: '%{name} payment due %{date}',
pendingDrafts: '%{count} auto bills to confirm',
confirm: 'Post',
view: 'View',
}, },
transaction: { transaction: {
newTitle: 'Manual Entry', newTitle: 'Manual Entry',
@ -78,6 +94,7 @@ export default {
directionIncome: 'Income', directionIncome: 'Income',
directionTransfer: 'Transfer', directionTransfer: 'Transfer',
formDate: 'Date', formDate: 'Date',
formCurrency: 'Currency',
formNarration: 'Narration', formNarration: 'Narration',
formPayee: 'Payee', formPayee: 'Payee',
formPayeePlaceholder: 'Counterparty/merchant', formPayeePlaceholder: 'Counterparty/merchant',
@ -140,6 +157,20 @@ export default {
accountFilterPlaceholder: 'Account contains...', accountFilterPlaceholder: 'Account contains...',
dateFrom: 'From date', dateFrom: 'From date',
dateTo: 'To date', dateTo: 'To date',
filter: 'Filter',
amountMin: 'Min amount',
amountMax: 'Max amount',
today: 'Today',
yesterday: 'Yesterday',
daySummary: '%{income} · %{expense}',
duplicate: 'Duplicate',
deleteTitle: 'Delete transaction',
deleteMessage: 'Delete "%{name}"? This also removes it from main.bean.',
},
filter: {
title: 'Advanced filters',
reset: 'Reset',
apply: 'Done',
}, },
report: { report: {
title: 'Report', title: 'Report',
@ -176,10 +207,15 @@ export default {
monthlyExpense: 'Monthly Expense', monthlyExpense: 'Monthly Expense',
monthlyNet: 'Monthly Net', monthlyNet: 'Monthly Net',
monthlyStats: '%{count} transactions', monthlyStats: '%{count} transactions',
menu: 'More actions',
exportImage: 'Export image',
monthlyRhythm: 'Monthly rhythm',
}, },
calendar: { calendar: {
title: 'Calendar', title: 'Calendar',
noDayTx: 'No transactions on this day', noDayTx: 'No transactions on this day',
/** Weekday headers, Sunday first, comma-separated (split in components). */
weekdays: 'Su,Mo,Tu,We,Th,Fr,Sa',
}, },
category: { category: {
title: 'Categories', title: 'Categories',
@ -390,6 +426,9 @@ export default {
algorithmConfig: 'Auto-Classification Rules Settings', algorithmConfig: 'Auto-Classification Rules Settings',
selectLang: 'Select language', selectLang: 'Select language',
preferencesTitle: 'Preferences & Security', preferencesTitle: 'Preferences & Security',
entryTitle: 'Entry',
numpadEntry: 'Global entry panel',
numpadEntryDesc: 'When enabled, the button opens the entry panel on any screen (beta)',
recurringTitle: 'Recurring Transactions', recurringTitle: 'Recurring Transactions',
systemFeaturesTitle: 'Features & System', systemFeaturesTitle: 'Features & System',
aiSettingsTitle: 'AI & Smart Entry', aiSettingsTitle: 'AI & Smart Entry',
@ -405,6 +444,36 @@ export default {
aiFieldApiKey: 'API Key', aiFieldApiKey: 'API Key',
aiFieldBaseUrl: 'API Base URL', aiFieldBaseUrl: 'API Base URL',
aiFieldModel: 'Model name', aiFieldModel: 'Model name',
groupAccount: 'Accounts & categories',
groupAutomation: 'Automation',
groupData: 'Data',
groupPreferences: 'Preferences',
diagnostics: 'Parse diagnostics',
syncBackup: 'Sync & backup',
preferencesEntry: 'Preferences',
import: 'Import bills',
},
numpad: {
from: 'From',
to: 'To',
outFrom: 'From',
transferTo: 'To',
swap: 'Swap',
today: 'Today',
done: 'Done',
backspace: 'Backspace',
close: 'Close',
more: 'More',
less: 'Less',
allAccounts: 'All accounts',
allCategories: 'All',
advanced: 'Advanced mode',
noAccount: 'Create account',
transferTargetError: 'Transfer accounts must be Assets or Liabilities',
},
datepicker: {
/** Weekday headers, Monday first, comma-separated (split in components). */
weekdays: 'Mo,Tu,We,Th,Fr,Sa,Su',
}, },
account: { account: {
title: 'Accounts', title: 'Accounts',

View File

@ -22,6 +22,7 @@ export default {
clickEditLongDelete: '点击编辑 · 长按删除', clickEditLongDelete: '点击编辑 · 长按删除',
noData: '暂无数据', noData: '暂无数据',
discard: '放弃', discard: '放弃',
error: '错误',
}, },
tab: { tab: {
home: '首页', home: '首页',
@ -29,7 +30,7 @@ export default {
report: '报表', report: '报表',
import: '导入', import: '导入',
rules: '规则', rules: '规则',
settings: '设置', settings: '我的',
}, },
home: { home: {
title: '账本状态', title: '账本状态',
@ -58,6 +59,21 @@ export default {
accountTree: '账户与资产余额树', accountTree: '账户与资产余额树',
balanced: '✓ 平衡', balanced: '✓ 平衡',
unbalanced: '✗ 不平衡', unbalanced: '✗ 不平衡',
greetingMorning: '早上好',
greetingAfternoon: '下午好',
greetingEvening: '晚上好',
monthExpense: '本月支出',
monthIncome: '本月收入',
budgetRemaining: '预算剩余',
viewAll: '查看全部',
},
todo: {
title: '待办',
dueRecurring: '周期记账「%{name}」到期',
creditCardDue: '%{name} 还款日 %{date}',
pendingDrafts: '%{count} 条自动账单待确认',
confirm: '入账',
view: '查看',
}, },
transaction: { transaction: {
newTitle: '手工记账', newTitle: '手工记账',
@ -80,6 +96,7 @@ export default {
directionIncome: '收入', directionIncome: '收入',
directionTransfer: '转账', directionTransfer: '转账',
formDate: '日期', formDate: '日期',
formCurrency: '币种',
formNarration: '摘要', formNarration: '摘要',
formPayee: '收款方', formPayee: '收款方',
formPayeePlaceholder: '对方/商户名称', formPayeePlaceholder: '对方/商户名称',
@ -142,6 +159,20 @@ export default {
accountFilterPlaceholder: '账户名包含...', accountFilterPlaceholder: '账户名包含...',
dateFrom: '起始日期', dateFrom: '起始日期',
dateTo: '结束日期', dateTo: '结束日期',
filter: '筛选',
amountMin: '最小金额',
amountMax: '最大金额',
today: '今天',
yesterday: '昨天',
daySummary: '收 %{income} · 支 %{expense}',
duplicate: '复制',
deleteTitle: '删除交易',
deleteMessage: '确定删除「%{name}」吗?此操作会同时从 main.bean 移除。',
},
filter: {
title: '高级筛选',
reset: '重置',
apply: '完成',
}, },
report: { report: {
title: '报表', title: '报表',
@ -178,10 +209,15 @@ export default {
monthlyExpense: '本月支出', monthlyExpense: '本月支出',
monthlyNet: '本月结余', monthlyNet: '本月结余',
monthlyStats: '交易 %{count} 笔', monthlyStats: '交易 %{count} 笔',
menu: '更多操作',
exportImage: '导出图片',
monthlyRhythm: '月度节奏',
}, },
calendar: { calendar: {
title: '日历', title: '日历',
noDayTx: '当日无交易', noDayTx: '当日无交易',
/** 星期头(周日起,逗号分隔,组件内 split。 */
weekdays: '日,一,二,三,四,五,六',
}, },
category: { category: {
title: '分类管理', title: '分类管理',
@ -392,6 +428,9 @@ export default {
algorithmConfig: '自动分类匹配配置', algorithmConfig: '自动分类匹配配置',
selectLang: '选择系统语言', selectLang: '选择系统语言',
preferencesTitle: '偏好与安全设置', preferencesTitle: '偏好与安全设置',
entryTitle: '记账',
numpadEntry: '全局记账面板',
numpadEntryDesc: '开启后,底部 在任意页面直接弹出记账面板(灰度功能)',
recurringTitle: '周期性记账管理', recurringTitle: '周期性记账管理',
systemFeaturesTitle: '功能与系统设置', systemFeaturesTitle: '功能与系统设置',
aiSettingsTitle: '智能记账与 AI 设置', aiSettingsTitle: '智能记账与 AI 设置',
@ -407,6 +446,36 @@ export default {
aiFieldApiKey: 'API Key', aiFieldApiKey: 'API Key',
aiFieldBaseUrl: 'API Base URL', aiFieldBaseUrl: 'API Base URL',
aiFieldModel: '模型名称', aiFieldModel: '模型名称',
groupAccount: '账户与分类',
groupAutomation: '记账自动化',
groupData: '数据',
groupPreferences: '偏好',
diagnostics: '解析诊断',
syncBackup: '同步与备份',
preferencesEntry: '偏好设置',
import: '导入账单',
},
numpad: {
from: '从',
to: '到',
outFrom: '转出',
transferTo: '转入',
swap: '互换',
today: '今天',
done: '完成',
backspace: '退格',
close: '关闭',
more: '更多',
less: '收起',
allAccounts: '全部账户',
allCategories: '全部',
advanced: '高级模式',
noAccount: '去创建账户',
transferTargetError: '转账双方必须是资金或负债账户',
},
datepicker: {
/** 星期头(周一起,逗号分隔,组件内 split。 */
weekdays: '一,二,三,四,五,六,日',
}, },
account: { account: {
title: '账户管理', title: '账户管理',

View File

@ -10,6 +10,7 @@
*/ */
import { create } from 'zustand'; import { create } from 'zustand';
import { TAG_COLORS } from '../theme/palette';
import type { Category } from '../domain/categories'; import type { Category } from '../domain/categories';
import type { Tag } from '../domain/tags'; import type { Tag } from '../domain/tags';
import type { Budget } from '../domain/budgets'; import type { Budget } from '../domain/budgets';
@ -96,9 +97,9 @@ const DEFAULTS: PersistableMetadata = {
{ id: 'income_investment', name: '理财收益', type: 'income', linkedAccount: 'Income:理财收益', keywords: ['收益', '利息', '基金', '余额宝收益'] }, { id: 'income_investment', name: '理财收益', type: 'income', linkedAccount: 'Income:理财收益', keywords: ['收益', '利息', '基金', '余额宝收益'] },
], ],
tags: [ tags: [
{ id: 't1', name: 'food', color: '#F44336' }, { id: 't1', name: 'food', color: TAG_COLORS[0] },
{ id: 't2', name: 'work', color: '#2196F3' }, { id: 't2', name: 'work', color: TAG_COLORS[3] },
{ id: 't3', name: 'travel', color: '#4CAF50' }, { id: 't3', name: 'travel', color: TAG_COLORS[5] },
], ],
budgets: [ budgets: [
{ id: 'b1', name: '餐饮月预算', amount: '2000', period: 'monthly', categoryAccount: 'Expenses:餐饮', startDate: '2026-01-01' }, { id: 'b1', name: '餐饮月预算', amount: '2000', period: 'monthly', categoryAccount: 'Expenses:餐饮', startDate: '2026-01-01' },

View File

@ -0,0 +1,24 @@
/**
* 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<NumpadUiState>((set) => ({
visible: false,
prefill: null,
open: (prefill) => set({ visible: true, prefill: prefill ?? null }),
close: () => set({ visible: false, prefill: null }),
}));

View File

@ -57,6 +57,16 @@ export interface SettingsState {
floatingBallEnabled: boolean; floatingBallEnabled: boolean;
setFloatingBallEnabled: (enabled: boolean) => void; setFloatingBallEnabled: (enabled: boolean) => void;
// 记账面板
/** 按钮是否打开全局记账面板P5 起默认 true已持久化 false 的用户保持关闭)。 */
numpadGlobalEntry: boolean;
setNumpadGlobalEntry: (enabled: boolean) => void;
/** 上次记账的资金账户NumpadSheet 默认选中)。 */
lastUsedAccount: string | null;
/** 上次使用的分类 id。 */
lastUsedCategoryId: string | null;
setLastUsedEntry: (account: string | null, categoryId: string | null) => void;
// actions // actions
updateDedupConfig: (patch: Partial<DedupConfig>) => void; updateDedupConfig: (patch: Partial<DedupConfig>) => void;
updateTransferConfig: (patch: Partial<TransferConfig>) => void; updateTransferConfig: (patch: Partial<TransferConfig>) => void;
@ -104,6 +114,9 @@ export interface PersistableSettings {
reminderHour: number; reminderHour: number;
reminderMinute: number; reminderMinute: number;
floatingBallEnabled: boolean; floatingBallEnabled: boolean;
numpadGlobalEntry: boolean;
lastUsedAccount: string | null;
lastUsedCategoryId: string | null;
} }
/** /**
@ -139,6 +152,11 @@ const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnable
reminderHour: 20, reminderHour: 20,
reminderMinute: 0, reminderMinute: 0,
floatingBallEnabled: false, floatingBallEnabled: false,
// P5 翻默认:+按钮默认打开全局记账面板。
// 注意hydrate 以持久化值覆盖 DEFAULTS已持久化 false 的老用户不受影响,仅新安装生效。
numpadGlobalEntry: true,
lastUsedAccount: null,
lastUsedCategoryId: null,
skipPersist: false, skipPersist: false,
}; };
@ -169,6 +187,9 @@ export function toPersistable(state: SettingsState): PersistableSettings {
reminderHour: state.reminderHour, reminderHour: state.reminderHour,
reminderMinute: state.reminderMinute, reminderMinute: state.reminderMinute,
floatingBallEnabled: state.floatingBallEnabled, floatingBallEnabled: state.floatingBallEnabled,
numpadGlobalEntry: state.numpadGlobalEntry,
lastUsedAccount: state.lastUsedAccount,
lastUsedCategoryId: state.lastUsedCategoryId,
}; };
} }
@ -199,6 +220,10 @@ export const useSettingsStore = create<SettingsState>((set) => ({
setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }), setFloatingBallEnabled: (enabled) => set({ floatingBallEnabled: enabled }),
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
hydrate: (data) => { hydrate: (data) => {
// hydrate 期间跳过自动持久化 // hydrate 期间跳过自动持久化
// 使用单次 setState 合并所有数据,避免多次触发 subscriber // 使用单次 setState 合并所有数据,避免多次触发 subscriber

View File

@ -11,7 +11,7 @@ export function createCommonStyles(theme: ThemeTokens) {
input: { input: {
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
borderRadius: theme.radii.md, // 输入框标准 radii 为 md (8px) borderRadius: theme.radii.md, // 输入框标准 radii 为 md (12px)
backgroundColor: theme.colors.bgTertiary, backgroundColor: theme.colors.bgTertiary,
color: theme.colors.fgPrimary, color: theme.colors.fgPrimary,
paddingHorizontal: 12, paddingHorizontal: 12,
@ -51,7 +51,7 @@ export function createCommonStyles(theme: ThemeTokens) {
modalCard: { modalCard: {
width: '100%', width: '100%',
backgroundColor: theme.colors.bgSecondary, backgroundColor: theme.colors.bgSecondary,
borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px) borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
borderWidth: StyleSheet.hairlineWidth, borderWidth: StyleSheet.hairlineWidth,
borderColor: theme.colors.border, borderColor: theme.colors.border,
padding: 20, padding: 20,

View File

@ -42,6 +42,7 @@ export function useTheme(): ThemeContextValue {
return useContext(ThemeContext); return useContext(ThemeContext);
} }
export { lightTheme, darkTheme }; export { lightTheme, darkTheme, presetThemes };
export { createCommonStyles } from './commonStyles'; export { createCommonStyles } from './commonStyles';
export { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from './palette';
export type { ThemeTokens } from './tokens'; export type { ThemeTokens } from './tokens';

79
src/theme/palette.ts Normal file
View File

@ -0,0 +1,79 @@
/**
* /docs/ui-redesign-design.md §3.2
*
* TS React Native domain
* - components/CategoryPicker
* - domain/channelConfig.ts
* presets.ts P5 grep
*/
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
export const CATEGORY_PALETTE = [
'#F59E0B', // Amber
'#3B82F6', // Blue
'#EC4899', // Pink
'#06B6D4', // Cyan
'#6366F1', // Indigo
'#8B5CF6', // Purple
'#10B981', // Emerald
'#64748B', // Slate
'#F43F5E', // Rose
'#14B8A6', // Teal
'#EF4444', // Red
'#84CC16', // Lime
] as const;
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
const NAMED_CATEGORY_COLORS: Record<string, string> = {
food: '#F59E0B',
transport: '#3B82F6',
shopping: '#EC4899',
housing_utility: '#06B6D4',
housing_rent: '#6366F1',
housing_communication: '#8B5CF6',
entertainment: '#10B981',
services: '#64748B',
personal_care: '#F43F5E',
clothing: '#14B8A6',
health: '#EF4444',
learning: '#84CC16',
salary: '#22C55E',
income_activity: '#EF4444',
income_investment: '#F59E0B',
};
/**
* FNV-1a
* domain/ledger.ts hash() theme domain 8
*/
function fnv1a(input: string): number {
let h = 0x811c9dc5;
for (let i = 0; i < input.length; i++) {
h ^= input.charCodeAt(i);
h = Math.imul(h, 0x01000193);
}
return h >>> 0;
}
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
export function getCategoryColor(categoryId: string): string {
// 用 hasOwn 防止 'constructor'/'toString' 等 id 经原型链命中假值
const named = Object.hasOwn(NAMED_CATEGORY_COLORS, categoryId)
? NAMED_CATEGORY_COLORS[categoryId]
: undefined;
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;
/** 标签可选色tag 页颜色选择器与内置默认标签共用)。 */
export const TAG_COLORS = [
'#F44336', '#E91E63', '#9C27B0', '#2196F3',
'#00BCD4', '#4CAF50', '#FF9800', '#795548',
] as const;

View File

@ -1,79 +1,81 @@
import type { ThemeTokens } from './tokens'; import type { ThemeTokens } from './tokens';
/** /**
* plan.md1.2 * Bento docs/ui-redesign-design.md §3
* lightTheme darkTheme * OLED
* accent
*/ */
export const lightTheme: ThemeTokens = { export const lightTheme: ThemeTokens = {
colors: { colors: {
bgPrimary: '#F8FAFC', bgPrimary: '#F6F7F9',
bgSecondary: '#FFFFFF', bgSecondary: '#FFFFFF',
bgTertiary: '#F1F5F9', bgTertiary: '#EFF1F4',
fgPrimary: '#0F172A', fgPrimary: '#111318',
fgSecondary: '#64748B', fgSecondary: '#6B7280',
fgInverse: '#FFFFFF', fgInverse: '#FFFFFF',
accent: '#4F46E5', // 经典深靛蓝 accent: '#111318', // 近黑:按钮/选中态
accentLight: '#EEF2FF', accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
accentDark: '#3730A3', accentDark: '#000000', // 按压加深态
success: '#10B981', // 现代翡翠绿 success: '#10B981',
warning: '#F59E0B', warning: '#F59E0B',
error: '#EF4444', // 现代珊瑚红 error: '#EF4444',
info: '#3B82F6', info: '#3B82F6',
financial: { financial: {
income: '#10B981', income: '#10B981',
expense: '#EF4444', expense: '#EF4444',
transfer: '#3B82F6', transfer: '#3B82F6',
}, },
border: '#E2E8F0', border: '#E5E7EB',
divider: '#F1F5F9', divider: '#F0F1F3',
overlay: 'rgba(15,23,42,0.3)', overlay: 'rgba(17,19,24,0.4)',
skeleton: '#E2E8F0', skeleton: '#E5E7EB',
progressBg: 'rgba(79,70,229,0.08)', progressBg: 'rgba(17,19,24,0.06)',
}, },
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 }, spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
radii: { sm: 4, md: 8, lg: 16, full: 9999 }, radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
typography: { typography: {
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36, fontFamily: 'Caveat_700Bold' }, display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
h2: { fontSize: 20, fontWeight: '600', lineHeight: 28, fontFamily: 'Quicksand_700Bold' }, h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
h3: { fontSize: 18, fontWeight: '600', lineHeight: 24, fontFamily: 'Quicksand_600SemiBold' }, h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
body: { fontSize: 16, fontWeight: '400', lineHeight: 24, fontFamily: 'Quicksand_500Medium' }, h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20, fontFamily: 'Quicksand_500Medium' }, body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16, fontFamily: 'Quicksand_400Regular' }, bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
}, },
shadows: { shadows: {
sm: { shadowColor: '#0F172A', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 6, elevation: 1 }, sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
md: { shadowColor: '#0F172A', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8, elevation: 3 }, md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
lg: { shadowColor: '#0F172A', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.12, shadowRadius: 16, elevation: 5 }, lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
}, },
}; };
export const darkTheme: ThemeTokens = { export const darkTheme: ThemeTokens = {
...lightTheme, ...lightTheme,
colors: { colors: {
bgPrimary: '#040508', // 极黑 OLED bgPrimary: '#040508', // OLED 纯黑
bgSecondary: '#0E1017', // 电影院质感深邃灰蓝卡片背景 bgSecondary: '#101218',
bgTertiary: '#161822', // 输入框/次级背景色 bgTertiary: '#1A1D24',
fgPrimary: '#F3F4F6', // 明亮灰白文字 fgPrimary: '#F3F4F6',
fgSecondary: '#9CA3AF', fgSecondary: '#9CA3AF',
fgInverse: '#040508', fgInverse: '#111318',
accent: '#4F46E5', // 高贵靛蓝 accent: '#F3F4F6', // 反转为白
accentLight: 'rgba(79,70,229,0.15)', accentLight: 'rgba(243,244,246,0.10)',
accentDark: '#3730A3', accentDark: '#FFFFFF',
success: '#059669', success: '#34D399',
warning: '#F59E0B', warning: '#FBBF24',
error: '#F43F5E', error: '#F87171',
info: '#3B82F6', info: '#60A5FA',
financial: { financial: {
income: '#059669', // 财务收益绿色 income: '#34D399', // 提亮一档保证对比度
expense: '#F43F5E', // 财务支出玫瑰红 expense: '#F87171',
transfer: '#3B82F6', transfer: '#60A5FA',
}, },
border: 'rgba(255,255,255,0.06)', // 极细微透白边框 border: 'rgba(255,255,255,0.08)',
divider: 'rgba(255,255,255,0.03)', divider: 'rgba(255,255,255,0.04)',
overlay: 'rgba(0,0,0,0.7)', overlay: 'rgba(0,0,0,0.7)',
skeleton: '#161822', skeleton: '#1A1D24',
progressBg: 'rgba(79,70,229,0.15)', progressBg: 'rgba(243,244,246,0.08)',
}, },
shadows: { shadows: {
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 }, sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },

View File

@ -60,6 +60,7 @@ export interface ThemeRadii {
sm: number; sm: number;
md: number; md: number;
lg: number; lg: number;
xl: number; // 卡片默认大圆角24
full: number; full: number;
} }
@ -68,10 +69,12 @@ export interface ThemeTypographyEntry {
/** React Native 的 fontWeight 是字面量联合类型,这里对齐以避免类型不兼容。 */ /** React Native 的 fontWeight 是字面量联合类型,这里对齐以避免类型不兼容。 */
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900'; fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
lineHeight: number; lineHeight: number;
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
fontFamily?: string; fontFamily?: string;
} }
export interface ThemeTypography { export interface ThemeTypography {
display: ThemeTypographyEntry; // 34/800净资产等大数字
h1: ThemeTypographyEntry; h1: ThemeTypographyEntry;
h2: ThemeTypographyEntry; h2: ThemeTypographyEntry;
h3: ThemeTypographyEntry; h3: ThemeTypographyEntry;

View File

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

View File

@ -77,6 +77,26 @@ describe('budgets', () => {
expect(range.start).toBe('2026-01-01'); expect(range.start).toBe('2026-01-01');
expect(range.end).toBe('2026-12-31'); expect(range.end).toBe('2026-12-31');
}); });
// 边界日回归:旧实现 new Date('YYYY-MM-DD') 按 UTC 解析,负时区会把 07-01 算到 06-30
it('getPeriodRange monthly 边界日(月初)', () => {
const range = getPeriodRange('monthly', '2026-07-01');
expect(range.start).toBe('2026-07-01');
expect(range.end).toBe('2026-07-31');
});
it('getPeriodRange weekly 边界日(周三,跨月起止)', () => {
// 2026-07-01 是周三 → 本周一起 2026-06-29周日止 2026-07-05
const range = getPeriodRange('weekly', '2026-07-01');
expect(range.start).toBe('2026-06-29');
expect(range.end).toBe('2026-07-05');
});
it('getPeriodRange yearly 边界日(元旦)', () => {
const range = getPeriodRange('yearly', '2026-01-01');
expect(range.start).toBe('2026-01-01');
expect(range.end).toBe('2026-12-31');
});
}); });
describe('recurring', () => { describe('recurring', () => {

159
tests/numpad.test.ts Normal file
View File

@ -0,0 +1,159 @@
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('9 位整数后仍可输入小数', () => {
expect(pressKey('123456789.', '5')).toBe('123456789.5');
expect(pressKey('123456789.5', '5')).toBe('123456789.55');
});
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('');
});
it('段尾小数点不崩溃(键盘可达状态)', () => {
expect(evaluateExpression('5.')).toBe('5');
expect(evaluateExpression('5.+3')).toBe('8');
});
it('净额可为负', () => {
expect(evaluateExpression('5-10')).toBe('-5');
});
});
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();
});
it('拆分交易3 条分录)返回 null走高级模式', () => {
expect(
parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮', amount: '30', currency: 'CNY' },
{ account: 'Expenses:小费', amount: '5', currency: 'CNY' },
])
).toBeNull();
});
it('识别腿缺金额时回退用对侧资金腿', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:招行', amount: '-35', currency: 'CNY' },
{ account: 'Expenses:餐饮' },
]);
expect(legs?.amount).toBe('35');
});
it('转账正腿在前时按负号判定转出方', () => {
const legs = parsePostingsToLegs([
{ account: 'Assets:A', amount: '100', currency: 'CNY' },
{ account: 'Assets:B', amount: '-100', currency: 'CNY' },
]);
expect(legs?.sourceAccount).toBe('Assets:B');
expect(legs?.targetAccount).toBe('Assets:A');
});
});

36
tests/palette.test.ts Normal file
View File

@ -0,0 +1,36 @@
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('原型链属性名 id如 constructor走哈希兜底返回合法色值', () => {
expect(CATEGORY_PALETTE).toContain(getCategoryColor('constructor'));
expect(CATEGORY_PALETTE).toContain(getCategoryColor('toString'));
});
it('渠道品牌色常量', () => {
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
});
});

57
tests/periodNav.test.ts Normal file
View File

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest';
import { shiftAnchor, periodRange, isoWeekNumber } from '../src/domain/periodNav';
describe('shiftAnchor 周期导航', () => {
it('周±7 天', () => {
expect(shiftAnchor('2026-07-21', 'weekly', 1)).toBe('2026-07-28');
expect(shiftAnchor('2026-07-21', 'weekly', -1)).toBe('2026-07-14');
});
it('月月末回退钳制1月31日 → 2月28日', () => {
expect(shiftAnchor('2026-01-31', 'monthly', 1)).toBe('2026-02-28');
expect(shiftAnchor('2026-03-31', 'monthly', -1)).toBe('2026-02-28');
});
it('月:跨年', () => {
expect(shiftAnchor('2026-12-15', 'monthly', 1)).toBe('2027-01-15');
expect(shiftAnchor('2026-01-15', 'monthly', -1)).toBe('2025-12-15');
});
it('年±1 年', () => {
expect(shiftAnchor('2026-07-21', 'annual', 1)).toBe('2027-07-21');
expect(shiftAnchor('2026-07-21', 'annual', -1)).toBe('2025-07-21');
});
it('多步 delta', () => {
expect(shiftAnchor('2026-07-21', 'monthly', 3)).toBe('2026-10-21');
expect(shiftAnchor('2026-07-21', 'weekly', -2)).toBe('2026-07-07');
});
});
describe('periodRange 周期范围', () => {
it('周:周一起周日止(周二 anchor', () => {
expect(periodRange('2026-07-21', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
});
it('周:周日 anchor 属于本周(周一开头)', () => {
expect(periodRange('2026-07-26', 'weekly')).toEqual({ start: '2026-07-20', end: '2026-07-26' });
});
it('月:整月', () => {
expect(periodRange('2026-02-10', 'monthly')).toEqual({ start: '2026-02-01', end: '2026-02-28' });
});
it('年:整年', () => {
expect(periodRange('2026-07-21', 'annual')).toEqual({ start: '2026-01-01', end: '2026-12-31' });
});
});
describe('isoWeekNumber ISO 周数', () => {
it('2026-01-01 是第 1 周', () => {
expect(isoWeekNumber('2026-01-01')).toBe(1);
});
it('2026-07-20 是第 30 周', () => {
expect(isoWeekNumber('2026-07-20')).toBe(30);
});
});

58
tests/search.test.ts Normal file
View File

@ -0,0 +1,58 @@
/**
* useSearch P4
* testing-library node_modules react-dom(19.2.7) react(19.1.0)
* SSR vi.mock('react') hook useSearch
* useState useDebounce timer
*/
import { vi, describe, expect, it } from 'vitest';
vi.mock('react', () => ({
useMemo: (fn: () => unknown) => fn(),
useState: (init: unknown) => [init, () => {}],
useEffect: () => {},
useCallback: (fn: unknown) => fn,
useRef: (v: unknown) => ({ current: v }),
}));
import { useSearch, type SearchFilters } from '../src/hooks/useSearch';
import type { Transaction } from '../src/domain/types';
function tx(id: string, postings: { account: string; amount?: string }[]): Transaction {
return {
id, date: '2026-07-21', flag: '*', narration: id,
postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
tags: [], links: [], source: 'test', raw: '',
};
}
describe('金额区间筛选', () => {
const txs = [
tx('expense35', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
tx('expense200', [{ account: 'Assets:招行', amount: '-200' }, { account: 'Expenses:购物', amount: '200' }]),
tx('income5000', [{ account: 'Assets:招行', amount: '5000' }, { account: 'Income:工资', amount: '-5000' }]),
];
it('amountMin/amountMax 按主金额绝对值过滤', () => {
// 三笔:支出 35、支出 200、收入 5000收入主金额为负 → 绝对值 5000
const range = useSearch(txs, { amountMin: '30', amountMax: '300' } as SearchFilters);
expect(range.map(t => t.id)).toEqual(['expense35', 'expense200']);
const minOnly = useSearch(txs, { amountMin: '1000' } as SearchFilters);
expect(minOnly.map(t => t.id)).toEqual(['income5000']);
});
it('无金额 posting 的交易在设了下限时被排除', () => {
const withNoAmount = [
...txs,
tx('noAmount', [{ account: 'Assets:招行' }, { account: 'Expenses:其他' }]),
];
const result = useSearch(withNoAmount, { amountMin: '1' } as SearchFilters);
expect(result.map(t => t.id)).not.toContain('noAmount');
expect(result).toHaveLength(3);
});
it('不设区间时不过滤(回归兜底)', () => {
const result = useSearch(txs, {});
expect(result).toHaveLength(3);
});
});

View File

@ -3,16 +3,56 @@ import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
import { createTheme } from '../src/theme/createTheme'; import { createTheme } from '../src/theme/createTheme';
import type { ThemeTokens } from '../src/theme/tokens'; import type { ThemeTokens } from '../src/theme/tokens';
describe('预置主题', () => { describe('预置主题(明亮 Bento 设计系统)', () => {
it('lightTheme 与 darkTheme 关键色彩不同', () => { it('lightTheme 与 darkTheme 关键色彩不同', () => {
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary); expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary); expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
}); });
it('lightTheme 背景为亮色、darkTheme 背景为深色', () => { it('浅色背景为 #F6F7F9暗色为 OLED 纯黑 #040508', () => {
// 亮色背景接近白,深色背景接近黑 expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
expect(lightTheme.colors.bgPrimary).toBe('#F8FAFC'); expect(darkTheme.colors.bgPrimary).toBe('#040508');
expect(darkTheme.colors.bgPrimary).not.toBe('#F8FAFC'); });
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', () => { it('presetThemes 注册表含 light 与 dark', () => {
@ -20,22 +60,10 @@ describe('预置主题', () => {
expect(presetThemes.dark).toBe(darkTheme); expect(presetThemes.dark).toBe(darkTheme);
}); });
it('财务语义色三态齐全', () => { it('spacing/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme]) {
expect(theme.colors.financial.income).toBeTruthy();
expect(theme.colors.financial.expense).toBeTruthy();
expect(theme.colors.financial.transfer).toBeTruthy();
}
});
it('spacing/radii/typography/shadows 结构完整', () => {
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) { for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
expect(theme.spacing).toHaveProperty('xs'); expect(theme.spacing).toHaveProperty('xs');
expect(theme.spacing).toHaveProperty('xl'); expect(theme.spacing).toHaveProperty('xl');
expect(theme.radii).toHaveProperty('sm');
expect(theme.radii).toHaveProperty('full');
expect(theme.typography).toHaveProperty('h1');
expect(theme.typography).toHaveProperty('caption');
expect(theme.shadows).toHaveProperty('sm'); expect(theme.shadows).toHaveProperty('sm');
expect(theme.shadows).toHaveProperty('lg'); expect(theme.shadows).toHaveProperty('lg');
} }
@ -52,19 +80,19 @@ describe('createTheme 自定义主题工厂', () => {
it('覆盖 accent 颜色时其他颜色保留', () => { it('覆盖 accent 颜色时其他颜色保留', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } }); const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
expect(custom.colors.accent).toBe('#FF5722'); expect(custom.colors.accent).toBe('#FF5722');
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary); // 保留 expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
expect(custom.colors.success).toBe(lightTheme.colors.success); // 保留 expect(custom.colors.success).toBe(lightTheme.colors.success);
}); });
it('覆盖 financial 子对象时深度合并', () => { it('覆盖 financial 子对象时深度合并', () => {
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } }); const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
expect(custom.colors.financial.income).toBe('#000'); // 覆盖 expect(custom.colors.financial.income).toBe('#000');
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense); // 保留 expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
}); });
it('覆盖 spacing 部分键时其他键保留', () => { it('覆盖 spacing 部分键时其他键保留', () => {
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } }); const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
expect(custom.spacing.lg).toBe(32); expect(custom.spacing.lg).toBe(32);
expect(custom.spacing.md).toBe(lightTheme.spacing.md); // 保留 expect(custom.spacing.md).toBe(lightTheme.spacing.md);
}); });
}); });

View File

@ -0,0 +1,45 @@
import { describe, expect, it } from 'vitest';
import { groupTransactionsByDate } from '../src/components/transactionGroups';
import type { Transaction } from '../src/domain/types';
function tx(id: string, date: string, postings: { account: string; amount?: string }[]): Transaction {
return {
id, date, flag: '*', narration: id,
postings: postings.map(p => ({ account: p.account, amount: p.amount, currency: 'CNY' })),
tags: [], links: [], source: 'test', raw: '',
};
}
describe('groupTransactionsByDate 日期分组', () => {
it('同日合并并计算收支小计', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Assets:招行', amount: '-35' }, { account: 'Expenses:餐饮', amount: '35' }]),
tx('b', '2026-07-21', [{ account: 'Assets:招行', amount: '100' }, { account: 'Income:工资', amount: '-100' }]),
]);
expect(groups).toHaveLength(1);
expect(groups[0].date).toBe('2026-07-21');
expect(groups[0].expense).toBe('35');
expect(groups[0].income).toBe('100');
expect(groups[0].items.map(t => t.id)).toEqual(['a', 'b']);
});
it('保持输入顺序(调用方已按日期倒序)', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Expenses:餐饮', amount: '35' }]),
tx('b', '2026-07-20', [{ account: 'Expenses:餐饮', amount: '20' }]),
]);
expect(groups.map(g => g.date)).toEqual(['2026-07-21', '2026-07-20']);
});
it('转账日收支小计为零', () => {
const groups = groupTransactionsByDate([
tx('a', '2026-07-21', [{ account: 'Assets:A', amount: '-50' }, { account: 'Assets:B', amount: '50' }]),
]);
expect(groups[0].income).toBe('0');
expect(groups[0].expense).toBe('0');
});
it('空输入返回空数组', () => {
expect(groupTransactionsByDate([])).toEqual([]);
});
});