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:
parent
7fa345b558
commit
2e73b5a2c6
@ -1,206 +1,72 @@
|
||||
# Design System Master File
|
||||
# Beancount Mobile 设计系统(MASTER)
|
||||
|
||||
> **LOGIC:** When building a specific page, first check `design-system/pages/[page-name].md`.
|
||||
> If that file exists, its rules **override** this Master file.
|
||||
> If not, strictly follow the rules below.
|
||||
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
|
||||
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
|
||||
|
||||
---
|
||||
## 设计方向:明亮 Bento 现代风
|
||||
|
||||
**Project:** beancount-mobile
|
||||
**Generated:** 2026-07-16 16:32:57
|
||||
**Category:** Personal Finance Tracker
|
||||
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
|
||||
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
|
||||
|
||||
---
|
||||
## 色板(= presets.ts)
|
||||
|
||||
## Global Rules
|
||||
### 浅色(默认)
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||
| bgTertiary | `#EFF1F4` | 输入框 / chip |
|
||||
| fgPrimary | `#111318` | 主文字 |
|
||||
| fgSecondary | `#6B7280` | 次要文字 |
|
||||
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||
| accent | `#111318` | 按钮 / 选中态(近黑) |
|
||||
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
|
||||
| accentDark | `#000000` | 按压态 |
|
||||
| financial.income | `#10B981` | 收入 |
|
||||
| financial.expense | `#EF4444` | 支出 |
|
||||
| financial.transfer | `#3B82F6` | 转账 |
|
||||
| border | `#E5E7EB` | 边框 |
|
||||
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
|
||||
|
||||
### Color Palette
|
||||
### 暗色(OLED)
|
||||
| Token | 值 |
|
||||
|---|---|
|
||||
| bgPrimary | `#040508` |
|
||||
| bgSecondary | `#101218` |
|
||||
| bgTertiary | `#1A1D24` |
|
||||
| fgPrimary / accent | `#F3F4F6` |
|
||||
| fgSecondary | `#9CA3AF` |
|
||||
| fgInverse | `#111318` |
|
||||
| financial.income | `#34D399`(提亮) |
|
||||
| financial.expense | `#F87171`(提亮) |
|
||||
| financial.transfer | `#60A5FA`(提亮) |
|
||||
| border | `rgba(255,255,255,0.08)` |
|
||||
|
||||
| Role | Hex | CSS Variable |
|
||||
|------|-----|--------------|
|
||||
| Primary | `#1E40AF` | `--color-primary` |
|
||||
| On Primary | `#FFFFFF` | `--color-on-primary` |
|
||||
| Secondary | `#3B82F6` | `--color-secondary` |
|
||||
| Accent/CTA | `#059669` | `--color-accent` |
|
||||
| Background | `#0F172A` | `--color-background` |
|
||||
| Foreground | `#FFFFFF` | `--color-foreground` |
|
||||
| Muted | `#101A34` | `--color-muted` |
|
||||
| Border | `rgba(255,255,255,0.08)` | `--color-border` |
|
||||
| Destructive | `#DC2626` | `--color-destructive` |
|
||||
| Ring | `#1E40AF` | `--color-ring` |
|
||||
分类/标签/渠道颜色见 `src/theme/palette.ts`(分类 12 色循环 + 具名映射、TAG_COLORS、渠道品牌色)。
|
||||
其余 token(divider / skeleton / progressBg / success / warning / error / info)见 `presets.ts`。
|
||||
全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。
|
||||
|
||||
**Color Notes:** Trust blue + profit green on dark
|
||||
## 字体
|
||||
|
||||
### Typography
|
||||
系统字体(iOS SF / Android Roboto),不加载自定义字体。
|
||||
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
|
||||
|
||||
- **Heading Font:** Caveat
|
||||
- **Body Font:** Quicksand
|
||||
- **Mood:** handwritten, personal, friendly, casual, warm, charming
|
||||
- **Google Fonts:** [Caveat + Quicksand](https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap)
|
||||
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
|
||||
|
||||
**CSS Import:**
|
||||
```css
|
||||
@import url('https://fonts.googleapis.com/css2?family=Caveat:wght@400;500;600;700&family=Quicksand:wght@300;400;500;600;700&display=swap');
|
||||
```
|
||||
## 圆角 / 间距 / 阴影
|
||||
|
||||
### Spacing Variables
|
||||
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(弹窗默认;卡片组件 P2 统一升级)** · full
|
||||
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
|
||||
- 阴影:浅色 4–12px 弥散轻阴影;暗色阴影减重,辅以半透边框
|
||||
|
||||
| Token | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--space-xs` | `4px` / `0.25rem` | Tight gaps |
|
||||
| `--space-sm` | `8px` / `0.5rem` | Icon gaps, inline spacing |
|
||||
| `--space-md` | `16px` / `1rem` | Standard padding |
|
||||
| `--space-lg` | `24px` / `1.5rem` | Section padding |
|
||||
| `--space-xl` | `32px` / `2rem` | Large gaps |
|
||||
| `--space-2xl` | `48px` / `3rem` | Section margins |
|
||||
| `--space-3xl` | `64px` / `4rem` | Hero padding |
|
||||
## 图标
|
||||
|
||||
### Shadow Depths
|
||||
统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标;
|
||||
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
|
||||
|
||||
| Level | Value | Usage |
|
||||
|-------|-------|-------|
|
||||
| `--shadow-sm` | `0 1px 2px rgba(0,0,0,0.05)` | Subtle lift |
|
||||
| `--shadow-md` | `0 4px 6px rgba(0,0,0,0.1)` | Cards, buttons |
|
||||
| `--shadow-lg` | `0 10px 15px rgba(0,0,0,0.1)` | Modals, dropdowns |
|
||||
| `--shadow-xl` | `0 20px 25px rgba(0,0,0,0.15)` | Hero images, featured cards |
|
||||
## 反模式
|
||||
|
||||
---
|
||||
|
||||
## Component Specs
|
||||
|
||||
### Buttons
|
||||
|
||||
```css
|
||||
/* Primary Button */
|
||||
.btn-primary {
|
||||
background: #059669;
|
||||
color: white;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.btn-primary:hover {
|
||||
opacity: 0.9;
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Secondary Button */
|
||||
.btn-secondary {
|
||||
background: transparent;
|
||||
color: #1E40AF;
|
||||
border: 2px solid #1E40AF;
|
||||
padding: 12px 24px;
|
||||
border-radius: 8px;
|
||||
font-weight: 600;
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
```
|
||||
|
||||
### Cards
|
||||
|
||||
```css
|
||||
.card {
|
||||
background: #0F172A;
|
||||
border-radius: 12px;
|
||||
padding: 24px;
|
||||
box-shadow: var(--shadow-md);
|
||||
transition: all 200ms ease;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
box-shadow: var(--shadow-lg);
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
```
|
||||
|
||||
### Inputs
|
||||
|
||||
```css
|
||||
.input {
|
||||
padding: 12px 16px;
|
||||
border: 1px solid #E2E8F0;
|
||||
border-radius: 8px;
|
||||
font-size: 16px;
|
||||
transition: border-color 200ms ease;
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: #1E40AF;
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 3px #1E40AF20;
|
||||
}
|
||||
```
|
||||
|
||||
### Modals
|
||||
|
||||
```css
|
||||
.modal-overlay {
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
backdrop-filter: blur(4px);
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: white;
|
||||
border-radius: 16px;
|
||||
padding: 32px;
|
||||
box-shadow: var(--shadow-xl);
|
||||
max-width: 500px;
|
||||
width: 90%;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Style Guidelines
|
||||
|
||||
**Style:** Dark Mode (OLED)
|
||||
|
||||
**Keywords:** Dark theme, low light, high contrast, deep black, midnight blue, eye-friendly, OLED, night mode, power efficient
|
||||
|
||||
**Best For:** Night-mode apps, coding platforms, entertainment, eye-strain prevention, OLED devices, low-light
|
||||
|
||||
**Key Effects:** Minimal glow (text-shadow: 0 0 10px), dark-to-light transitions, low white emission, high readability, visible focus
|
||||
|
||||
### Page Pattern
|
||||
|
||||
**Pattern Name:** Interactive Product Demo
|
||||
|
||||
- **CTA Placement:** Above fold
|
||||
- **Section Order:** Hero > Features > CTA
|
||||
|
||||
---
|
||||
|
||||
## Anti-Patterns (Do NOT Use)
|
||||
|
||||
- ❌ Pure white backgrounds
|
||||
|
||||
### Additional Forbidden Patterns
|
||||
|
||||
- ❌ **Emojis as icons** — Use SVG icons (Heroicons, Lucide, Simple Icons)
|
||||
- ❌ **Missing cursor:pointer** — All clickable elements must have cursor:pointer
|
||||
- ❌ **Layout-shifting hovers** — Avoid scale transforms that shift layout
|
||||
- ❌ **Low contrast text** — Maintain 4.5:1 minimum contrast ratio
|
||||
- ❌ **Instant state changes** — Always use transitions (150-300ms)
|
||||
- ❌ **Invisible focus states** — Focus states must be visible for a11y
|
||||
|
||||
---
|
||||
|
||||
## Pre-Delivery Checklist
|
||||
|
||||
Before delivering any UI code, verify:
|
||||
|
||||
- [ ] No emojis used as icons (use SVG instead)
|
||||
- [ ] All icons from consistent icon set (Heroicons/Lucide)
|
||||
- [ ] `cursor-pointer` on all clickable elements
|
||||
- [ ] Hover states with smooth transitions (150-300ms)
|
||||
- [ ] Light mode: text contrast 4.5:1 minimum
|
||||
- [ ] Focus states visible for keyboard navigation
|
||||
- [ ] `prefers-reduced-motion` respected
|
||||
- [ ] Responsive: 375px, 768px, 1024px, 1440px
|
||||
- [ ] No content hidden behind fixed navbars
|
||||
- [ ] No horizontal scroll on mobile
|
||||
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
|
||||
- 禁止低对比文字(< 4.5:1)
|
||||
- 禁止瞬间状态变化,交互反馈 150–300ms
|
||||
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
|
||||
|
||||
183
docs/ui-redesign-design.md
Normal file
183
docs/ui-redesign-design.md
Normal 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。
|
||||
- 阴影减重:浅色 2–12px 弥散阴影;暗色以边框代替。
|
||||
- **图标统一 Ionicons**;全部 emoji 图标替换为 `CategoryIcon`(Ionicon + 圆形彩色底)。
|
||||
- 硬编码分类色收归 `theme.categoryPalette`(12 色循环);渠道色、标签默认色等一并 token 化。
|
||||
- `design-system/beancount-mobile/MASTER.md` 重写以匹配实现,结束"两份宪法"。
|
||||
|
||||
## 4. 核心交互:NumpadSheet 记一笔面板
|
||||
|
||||
### 4.1 结构(一屏完成 90% 记账)
|
||||
|
||||
自上而下:方向 chip(支出/收入/转账) → 大金额显示(等宽数字) → **账户 chip 行** → 分类快捷网格(4 列,常用分类 + "全部") → 内嵌数字键盘(0-9、小数点、⌫、**+/- 连续计算**(如 20+15 直接出 35)、"今天"日期键、完成键)。下滑展开"更多"抽屉:备注、标签、日期、高级模式(PostingEditor)入口。原 SpeedDial 的 OCR/导入入口移入面板顶部工具行。
|
||||
|
||||
### 4.2 两个账户(复式记账的"两条腿")
|
||||
|
||||
| 方向 | 腿 1 | 腿 2 |
|
||||
|---|---|---|
|
||||
| 支出 | 资金来源账户(Assets/Liabilities,账户 chip 行,"从") | 分类账户(Expenses:*,分类网格) |
|
||||
| 收入 | 分类账户(Income:*,分类网格) | 到账账户(Assets,账户 chip 行,"到") |
|
||||
| 转账 | 转出账户(chip 行) | 转入账户(第二 chip 行 + ⇅ 互换按钮),分类网格隐藏 |
|
||||
|
||||
配套规则:
|
||||
|
||||
- 账户 chip 行显示最常用的 3~4 个 Assets/Liabilities 账户(按使用频率排序),**默认选中上次使用的账户**(settingsStore 持久化),分类同理;"⋯"弹出全部账户树。
|
||||
- 转账双方限定 Assets/Liabilities 账户(与 transferRecognizer 校验一致);还信用卡 = 转账到 Liabilities 账户,天然支持。
|
||||
- 落账走 `buildAndSaveTransaction()` → BillPipeline,与手动/自动渠道完全一致,不产生第二套写入逻辑。
|
||||
- 无 Assets 账户时 chip 行显示"去创建账户"引导,不死锁。
|
||||
- 完成键上方可选显示分录预览 `Assets:招行 → Expenses:餐饮 ¥35`(可在设置关闭)。
|
||||
- 编辑模式按 posting 方向回填两条腿。
|
||||
- P3 阶段先在设置加"新版录入"开关灰度,稳定后默认开启。
|
||||
|
||||
## 5. 导航
|
||||
|
||||
自定义 **AppTabBar**(替换 expo-router 默认 tabBar):4 个内容 Tab(首页/交易/报表/我的)+ 中央凸起 +。+ **不是路由**,在任何页面唤起全局 Modal(NumpadSheet),不丢失上下文。**SpeedDial 退役**。
|
||||
|
||||
## 6. 核心组件库(P2 交付物)
|
||||
|
||||
新增:
|
||||
|
||||
- **NumpadSheet** — 见第 4 节
|
||||
- **AppTabBar** — 见第 5 节
|
||||
- **ScreenHeader** — 统一二级页头(返回 + 标题 + 右操作位),替换十几份手写返回栏;transaction/new 的原生 header 一并撤掉
|
||||
- **StatCard** — "标题 + 大数字 + caption"统计卡(首页/报表/年报共用)
|
||||
- **ManagementScreen** — 管理页模板(ScreenHeader++ / 可选分组 Tab / FlatList / 删除确认 / FormModal),8 个管理页共用,各页只声明字段配置 + 数据读写 hooks
|
||||
- **DatePickerField** — 日历选择器,终结手输 `YYYY-MM-DD`
|
||||
- **FilterSheet** — 底部弹层高级筛选(账户/日期/金额区间)
|
||||
- **CategoryIcon** — Ionicon + 彩色圆底,替换 emoji
|
||||
|
||||
改造/退役:Button/Card/Chip/SearchBar 按新 token 重刷;CategoryPicker 改底部弹层网格并 Ionicon 化;FormModal 的 "✕" 字符换 Ionicons;transaction/new 整页重写为 NumpadSheet 宿主(编辑模式复用同面板)。
|
||||
|
||||
## 7. 逐页重设计要点
|
||||
|
||||
### 7.1 首页:从"数据墙"到"今日视角"
|
||||
|
||||
- 顶部:日期 + 问候语(替代应用名标题)。
|
||||
- 净资产卡:去 accent 底色改白卡 + tabular 大数字,下方一行小字:本月支出 / 收入 / 预算剩余。
|
||||
- 新增**待办条**:周期记账到期、信用卡还款提醒、未确认自动账单——首页回答"今天我要做什么"。
|
||||
- 最近 5 条交易 + "查看全部 →"。
|
||||
- 月度趋势卡(TrendLine 重刷新色板)。
|
||||
- 账户余额树下沉到「我的」页。
|
||||
|
||||
### 7.2 交易页:搜索优先 + 分组时间线
|
||||
|
||||
- 搜索框常驻;方向筛选 chip 保留;高级筛选收进 FilterSheet。
|
||||
- 列表**按日期分组**(今天/昨天/具体日期),组头显示当日收支小计。
|
||||
- TransactionCard 重刷:CategoryIcon 圆底图标 + 商户/备注 + 账户小字 + 右侧等宽金额(支出黑色、收入绿色带 + 号,降低色彩噪音)。
|
||||
- 左滑卡片:快捷"再记一笔(复制)/ 删除"。
|
||||
- 解析诊断从页面底部移入设置 → 数据组。
|
||||
|
||||
### 7.3 报表页:统一时间导航 + 去重
|
||||
|
||||
- 周/月/年 Tab 保留;三套独立状态(viewYear/viewMonth/viewWeekDate)合并为**单一 anchor 日期 + 周期类型**,左右箭头统一切换。
|
||||
- 删除年报内嵌的 MonthlyReport(与月 Tab 重复);年报只留:年度收支汇总、月度节奏迷你图、Top 分类。
|
||||
- AI 总结/导出入口加文字标签,收进 "⋯" 菜单。
|
||||
- CalendarView 保留在月 Tab,配色收归 token;热力图用 expense 色 5 级透明度。
|
||||
- CategoryPie / StatCard / NetWorthChart 统一新 token。
|
||||
|
||||
### 7.4 设置 →「我的」:4 分组重构
|
||||
|
||||
- **账户与分类**:账户树(含余额,从首页移来)、分类、标签、信用卡、备注模板。
|
||||
- **记账自动化**:规则、周期记账、自动记账通道(无障碍/通知/短信)、导入。
|
||||
- **数据**:同步(WebDAV/Git/iCloud)、备份恢复、导出、解析诊断。
|
||||
- **偏好**:主题、语言、应用锁、AI 设置、每日提醒、关于。
|
||||
- 每组一张 Bento 卡,条目 = 图标 + 名称 + 右箭头;所有二级页用统一 ScreenHeader。
|
||||
|
||||
### 7.5 管理页模板化
|
||||
|
||||
账户/分类/标签/预算/周期/规则/信用卡/备注模板 8 页统一套 ManagementScreen,预计删除上千行重复代码。
|
||||
|
||||
## 8. 阶段计划
|
||||
|
||||
| 阶段 | 内容 | 验收 |
|
||||
|---|---|---|
|
||||
| P1 设计系统 | 新 tokens → 双主题 presets → 移除自定义字体 → categoryPalette → 重写 MASTER.md | typecheck 通过;token 名不变只改值,旧组件接口兼容 |
|
||||
| P2 组件库 | ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen | 组件调试页可逐个查看;纯逻辑单测 |
|
||||
| P3 录入闭环 | NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役;"新版录入"开关灰度 | 手动记账/编辑/转账/还款全走面板;pipeline 集成测试不回归 |
|
||||
| P4 四个 Tab | 首页(待办条)/ 交易(时间线+左滑)/ 报表(anchor 统一)/ 我的(4 分组) | 逐页替换,每页替换后全量测试 |
|
||||
| P5 管理页+收尾 | 8 页套模板;图表重刷;emoji 清零;硬编码色清零(grep 审计);无障碍标签补全 | `#[0-9A-Fa-f]{6}` 在 src/ 下只剩 presets.ts 与 categoryPalette |
|
||||
|
||||
## 9. 测试策略
|
||||
|
||||
- **不动 domain 层**:billPipeline/dedup/rules 等纯逻辑零改动,现有 30+ 单测是安全网,必须保持全绿。
|
||||
- 新增纯逻辑单测:numpad 表达式求值(+/- 连续计算)、双腿账户解析(方向 → posting 映射)、anchor 日期导航(周/月/年加减)。
|
||||
- 每阶段结束跑 `npm test` + `npm run typecheck`;UI 层靠深浅双主题手工走查清单。
|
||||
- 硬编码色审计:grep 全量扫描。
|
||||
|
||||
## 10. 风险与 YAGNI
|
||||
|
||||
风险对策:
|
||||
|
||||
- 主题切换过渡期"半新半旧" → P1 保持 token 名不变只改值,组件接口向后兼容。
|
||||
- NumpadSheet 全新交互 → 设置开关灰度后再默认开启。
|
||||
- 移除字体无数据迁移,删加载代码即可。
|
||||
|
||||
明确不做:自定义主题编辑器(保留 light/dark/system 三档);迁移图标库到 Lucide;改 domain/存储/同步层;平板/桌面布局适配。
|
||||
678
docs/ui-redesign-p1-plan.md
Normal file
678
docs/ui-redesign-p1-plan.md
Normal 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(视觉语言)、§10(P1 验收)。
|
||||
|
||||
**验收标准:** `npm test` 全绿;`npm run typecheck` 通过;`src/` 下不再引用 `@expo-google-fonts/*`;`presets.ts` 的 typography 无 `fontFamily`。
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 新设计令牌的失败测试
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/theme.test.ts`
|
||||
- Create: `tests/palette.test.ts`
|
||||
|
||||
- [ ] **Step 1: 重写 tests/theme.test.ts 的断言以匹配新令牌**
|
||||
|
||||
完整替换文件内容为:
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
|
||||
import { createTheme } from '../src/theme/createTheme';
|
||||
import type { ThemeTokens } from '../src/theme/tokens';
|
||||
|
||||
describe('预置主题(明亮 Bento 设计系统)', () => {
|
||||
it('lightTheme 与 darkTheme 关键色彩不同', () => {
|
||||
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
|
||||
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
|
||||
});
|
||||
|
||||
it('浅色背景为 #F6F7F9,暗色为 OLED 纯黑 #040508', () => {
|
||||
expect(lightTheme.colors.bgPrimary).toBe('#F6F7F9');
|
||||
expect(darkTheme.colors.bgPrimary).toBe('#040508');
|
||||
});
|
||||
|
||||
it('accent 为近黑白单色:浅色 #111318,暗色反转为 #F3F4F6', () => {
|
||||
expect(lightTheme.colors.accent).toBe('#111318');
|
||||
expect(darkTheme.colors.accent).toBe('#F3F4F6');
|
||||
});
|
||||
|
||||
it('accentLight 为 accent 的半透明底色(不再是靛蓝族)', () => {
|
||||
expect(lightTheme.colors.accentLight).toBe('rgba(17,19,24,0.08)');
|
||||
expect(darkTheme.colors.accentLight).toBe('rgba(243,244,246,0.10)');
|
||||
});
|
||||
|
||||
it('财务语义色:暗色整体提亮一档', () => {
|
||||
expect(lightTheme.colors.financial.income).toBe('#10B981');
|
||||
expect(lightTheme.colors.financial.expense).toBe('#EF4444');
|
||||
expect(lightTheme.colors.financial.transfer).toBe('#3B82F6');
|
||||
expect(darkTheme.colors.financial.income).toBe('#34D399');
|
||||
expect(darkTheme.colors.financial.expense).toBe('#F87171');
|
||||
expect(darkTheme.colors.financial.transfer).toBe('#60A5FA');
|
||||
});
|
||||
|
||||
it('圆角含 xl(24),卡片默认大圆角', () => {
|
||||
for (const theme of [lightTheme, darkTheme]) {
|
||||
expect(theme.radii.sm).toBe(8);
|
||||
expect(theme.radii.md).toBe(12);
|
||||
expect(theme.radii.lg).toBe(16);
|
||||
expect(theme.radii.xl).toBe(24);
|
||||
expect(theme.radii.full).toBe(9999);
|
||||
}
|
||||
});
|
||||
|
||||
it('字阶含 display(34),且不指定 fontFamily(系统字体)', () => {
|
||||
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||
expect(theme.typography.display.fontSize).toBe(34);
|
||||
expect(theme.typography.h1.fontSize).toBe(28);
|
||||
expect(theme.typography.h2.fontSize).toBe(22);
|
||||
expect(theme.typography.h3.fontSize).toBe(17);
|
||||
for (const key of ['display', 'h1', 'h2', 'h3', 'body', 'bodySmall', 'caption'] as const) {
|
||||
expect(theme.typography[key].fontFamily).toBeUndefined();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('presetThemes 注册表含 light 与 dark', () => {
|
||||
expect(presetThemes.light).toBe(lightTheme);
|
||||
expect(presetThemes.dark).toBe(darkTheme);
|
||||
});
|
||||
|
||||
it('spacing/shadows 结构完整', () => {
|
||||
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||
expect(theme.spacing).toHaveProperty('xs');
|
||||
expect(theme.spacing).toHaveProperty('xl');
|
||||
expect(theme.shadows).toHaveProperty('sm');
|
||||
expect(theme.shadows).toHaveProperty('lg');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('createTheme 自定义主题工厂', () => {
|
||||
it('未覆盖时等价于 lightTheme', () => {
|
||||
const custom = createTheme({});
|
||||
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||
expect(custom.spacing).toEqual(lightTheme.spacing);
|
||||
});
|
||||
|
||||
it('覆盖 accent 颜色时其他颜色保留', () => {
|
||||
const custom = createTheme({ colors: { ...lightTheme.colors, accent: '#FF5722' } });
|
||||
expect(custom.colors.accent).toBe('#FF5722');
|
||||
expect(custom.colors.bgPrimary).toBe(lightTheme.colors.bgPrimary);
|
||||
expect(custom.colors.success).toBe(lightTheme.colors.success);
|
||||
});
|
||||
|
||||
it('覆盖 financial 子对象时深度合并', () => {
|
||||
const custom = createTheme({ colors: { ...lightTheme.colors, financial: { ...lightTheme.colors.financial, income: '#000' } } });
|
||||
expect(custom.colors.financial.income).toBe('#000');
|
||||
expect(custom.colors.financial.expense).toBe(lightTheme.colors.financial.expense);
|
||||
});
|
||||
|
||||
it('覆盖 spacing 部分键时其他键保留', () => {
|
||||
const custom = createTheme({ spacing: { ...lightTheme.spacing, lg: 32 } });
|
||||
expect(custom.spacing.lg).toBe(32);
|
||||
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 新建 tests/palette.test.ts(分类色板 + 渠道品牌色)**
|
||||
|
||||
```typescript
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from '../src/theme/palette';
|
||||
|
||||
describe('categoryPalette', () => {
|
||||
it('色板为 12 色循环', () => {
|
||||
expect(CATEGORY_PALETTE).toHaveLength(12);
|
||||
for (const c of CATEGORY_PALETTE) expect(c).toMatch(/^#[0-9A-Fa-f]{6}$/);
|
||||
});
|
||||
|
||||
it('内置分类有具名颜色(与原 CategoryPicker 一致)', () => {
|
||||
expect(getCategoryColor('food')).toBe('#F59E0B');
|
||||
expect(getCategoryColor('transport')).toBe('#3B82F6');
|
||||
expect(getCategoryColor('salary')).toBe('#22C55E');
|
||||
});
|
||||
|
||||
it('未知分类 id 走哈希循环,结果确定且在色板内', () => {
|
||||
const a = getCategoryColor('user_custom_abc');
|
||||
expect(CATEGORY_PALETTE).toContain(a);
|
||||
expect(getCategoryColor('user_custom_abc')).toBe(a); // 幂等
|
||||
});
|
||||
|
||||
it('空字符串 id 不崩溃', () => {
|
||||
expect(CATEGORY_PALETTE).toContain(getCategoryColor(''));
|
||||
});
|
||||
|
||||
it('渠道品牌色常量', () => {
|
||||
expect(CHANNEL_BRAND_COLORS.alipay).toBe('#1677FF');
|
||||
expect(CHANNEL_BRAND_COLORS.wechat).toBe('#07C160');
|
||||
expect(CHANNEL_BRAND_COLORS.bank).toBe('#E53935');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 运行测试确认失败**
|
||||
|
||||
Run: `npx vitest run tests/theme.test.ts tests/palette.test.ts`
|
||||
Expected: FAIL —— `Cannot find module '../src/theme/palette'`,且 theme 断言多项不通过(旧色板)。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add tests/theme.test.ts tests/palette.test.ts
|
||||
git commit -m "test: P1 设计系统新令牌与色板的失败测试"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 2: tokens.ts — display 字阶 + radii.xl
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/tokens.ts`
|
||||
|
||||
- [ ] **Step 1: 修改 ThemeRadii 与 ThemeTypography**
|
||||
|
||||
`ThemeRadii` 改为(加 `xl`):
|
||||
|
||||
```typescript
|
||||
export interface ThemeRadii {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number; // 卡片默认大圆角(24)
|
||||
full: number;
|
||||
}
|
||||
```
|
||||
|
||||
`ThemeTypographyEntry` 的 `fontFamily` 标记废弃(保留字段以兼容现存 `theme.typography.X.fontFamily` 引用,后续阶段逐页清理):
|
||||
|
||||
```typescript
|
||||
export interface ThemeTypographyEntry {
|
||||
fontSize: number;
|
||||
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
lineHeight: number;
|
||||
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
|
||||
fontFamily?: string;
|
||||
}
|
||||
```
|
||||
|
||||
`ThemeTypography` 加 `display`:
|
||||
|
||||
```typescript
|
||||
export interface ThemeTypography {
|
||||
display: ThemeTypographyEntry; // 34/800,净资产等大数字
|
||||
h1: ThemeTypographyEntry;
|
||||
h2: ThemeTypographyEntry;
|
||||
h3: ThemeTypographyEntry;
|
||||
body: ThemeTypographyEntry;
|
||||
bodySmall: ThemeTypographyEntry;
|
||||
caption: ThemeTypographyEntry;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 typecheck 确认报错点**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: FAIL —— `presets.ts` 缺少 `display` 与 `radii.xl`(Task 3 修复)。
|
||||
|
||||
- [ ] **Step 3: 暂不 commit(与 Task 3 一起)**
|
||||
|
||||
---
|
||||
|
||||
### Task 3: presets.ts — 新双主题色板
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/presets.ts`
|
||||
|
||||
- [ ] **Step 1: 完整替换 presets.ts**
|
||||
|
||||
```typescript
|
||||
import type { ThemeTokens } from './tokens';
|
||||
|
||||
/**
|
||||
* 预置主题 —— 「明亮 Bento 现代风」(docs/ui-redesign-design.md §3)。
|
||||
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
|
||||
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
|
||||
*/
|
||||
|
||||
export const lightTheme: ThemeTokens = {
|
||||
colors: {
|
||||
bgPrimary: '#F6F7F9',
|
||||
bgSecondary: '#FFFFFF',
|
||||
bgTertiary: '#EFF1F4',
|
||||
fgPrimary: '#111318',
|
||||
fgSecondary: '#6B7280',
|
||||
fgInverse: '#FFFFFF',
|
||||
accent: '#111318', // 近黑:按钮/选中态
|
||||
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
|
||||
accentDark: '#000000', // 按压加深态
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444',
|
||||
info: '#3B82F6',
|
||||
financial: {
|
||||
income: '#10B981',
|
||||
expense: '#EF4444',
|
||||
transfer: '#3B82F6',
|
||||
},
|
||||
border: '#E5E7EB',
|
||||
divider: '#F0F1F3',
|
||||
overlay: 'rgba(17,19,24,0.4)',
|
||||
skeleton: '#E5E7EB',
|
||||
progressBg: 'rgba(17,19,24,0.06)',
|
||||
},
|
||||
spacing: { xs: 4, sm: 8, md: 16, lg: 24, xl: 32 },
|
||||
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
|
||||
typography: {
|
||||
display: { fontSize: 34, fontWeight: '800', lineHeight: 42 },
|
||||
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36 },
|
||||
h2: { fontSize: 22, fontWeight: '700', lineHeight: 30 },
|
||||
h3: { fontSize: 17, fontWeight: '600', lineHeight: 24 },
|
||||
body: { fontSize: 16, fontWeight: '400', lineHeight: 24 },
|
||||
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20 },
|
||||
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16 },
|
||||
},
|
||||
shadows: {
|
||||
sm: { shadowColor: '#111318', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 4, elevation: 1 },
|
||||
md: { shadowColor: '#111318', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.06, shadowRadius: 8, elevation: 2 },
|
||||
lg: { shadowColor: '#111318', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.10, shadowRadius: 12, elevation: 4 },
|
||||
},
|
||||
};
|
||||
|
||||
export const darkTheme: ThemeTokens = {
|
||||
...lightTheme,
|
||||
colors: {
|
||||
bgPrimary: '#040508', // OLED 纯黑
|
||||
bgSecondary: '#101218',
|
||||
bgTertiary: '#1A1D24',
|
||||
fgPrimary: '#F3F4F6',
|
||||
fgSecondary: '#9CA3AF',
|
||||
fgInverse: '#111318',
|
||||
accent: '#F3F4F6', // 反转为白
|
||||
accentLight: 'rgba(243,244,246,0.10)',
|
||||
accentDark: '#FFFFFF',
|
||||
success: '#34D399',
|
||||
warning: '#FBBF24',
|
||||
error: '#F87171',
|
||||
info: '#60A5FA',
|
||||
financial: {
|
||||
income: '#34D399', // 提亮一档保证对比度
|
||||
expense: '#F87171',
|
||||
transfer: '#60A5FA',
|
||||
},
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
divider: 'rgba(255,255,255,0.04)',
|
||||
overlay: 'rgba(0,0,0,0.7)',
|
||||
skeleton: '#1A1D24',
|
||||
progressBg: 'rgba(243,244,246,0.08)',
|
||||
},
|
||||
shadows: {
|
||||
sm: { shadowColor: '#000', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.4, shadowRadius: 2, elevation: 1 },
|
||||
md: { shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.5, shadowRadius: 4, elevation: 3 },
|
||||
lg: { shadowColor: '#000', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.6, shadowRadius: 8, elevation: 5 },
|
||||
},
|
||||
};
|
||||
|
||||
/** 预置主题注册表。 */
|
||||
export const presetThemes: Record<string, ThemeTokens> = {
|
||||
light: lightTheme,
|
||||
dark: darkTheme,
|
||||
};
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 运行 Task 1 的测试**
|
||||
|
||||
Run: `npx vitest run tests/theme.test.ts`
|
||||
Expected: PASS(palette.test.ts 仍 FAIL,Task 4 修复)。
|
||||
|
||||
- [ ] **Step 3: 运行 typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 4: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/tokens.ts src/theme/presets.ts
|
||||
git commit -m "feat(theme): P1 明亮 Bento 令牌 —— 近黑 accent、xl 圆角、display 字阶、系统字体"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 4: theme/palette.ts — 分类色板 + 渠道品牌色
|
||||
|
||||
**Files:**
|
||||
- Create: `src/theme/palette.ts`
|
||||
- Modify: `src/domain/channelConfig.ts:32,40,48`
|
||||
- Modify: `src/components/CategoryPicker.tsx:31-48,59`
|
||||
|
||||
- [ ] **Step 1: 创建 src/theme/palette.ts(纯 TS,无 RN/domain 依赖,可被双方向引用)**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 分类/渠道颜色统一色板(docs/ui-redesign-design.md §3.2)。
|
||||
*
|
||||
* 纯 TS 模块,不依赖 React Native 与 domain 层,因此:
|
||||
* - components/(CategoryPicker 等)用它给分类配色;
|
||||
* - domain/channelConfig.ts 用它引用渠道品牌色;
|
||||
* 全工程的颜色字面量只允许存在于 presets.ts 与本文件(P5 grep 审计)。
|
||||
*/
|
||||
|
||||
/** 12 色循环色板:未知/用户自定义分类按 id 哈希取色。 */
|
||||
export const CATEGORY_PALETTE = [
|
||||
'#F59E0B', // Amber
|
||||
'#3B82F6', // Blue
|
||||
'#EC4899', // Pink
|
||||
'#06B6D4', // Cyan
|
||||
'#6366F1', // Indigo
|
||||
'#8B5CF6', // Purple
|
||||
'#10B981', // Emerald
|
||||
'#64748B', // Slate
|
||||
'#F43F5E', // Rose
|
||||
'#14B8A6', // Teal
|
||||
'#EF4444', // Red
|
||||
'#84CC16', // Lime
|
||||
] as const;
|
||||
|
||||
/** 内置分类的具名颜色(沿用原 CategoryPicker CATEGORY_COLORS 的映射,视觉不变)。 */
|
||||
const NAMED_CATEGORY_COLORS: Record<string, string> = {
|
||||
food: '#F59E0B',
|
||||
transport: '#3B82F6',
|
||||
shopping: '#EC4899',
|
||||
housing_utility: '#06B6D4',
|
||||
housing_rent: '#6366F1',
|
||||
housing_communication: '#8B5CF6',
|
||||
entertainment: '#10B981',
|
||||
services: '#64748B',
|
||||
personal_care: '#F43F5E',
|
||||
clothing: '#14B8A6',
|
||||
health: '#EF4444',
|
||||
learning: '#84CC16',
|
||||
salary: '#22C55E',
|
||||
income_activity: '#EF4444',
|
||||
income_investment: '#F59E0B',
|
||||
};
|
||||
|
||||
/**
|
||||
* 本地 FNV-1a 哈希。
|
||||
* 注:domain/ledger.ts 也有 hash(),但 theme 层不反向依赖 domain 解析器,故保留这份 8 行实现。
|
||||
*/
|
||||
function fnv1a(input: string): number {
|
||||
let h = 0x811c9dc5;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
h ^= input.charCodeAt(i);
|
||||
h = Math.imul(h, 0x01000193);
|
||||
}
|
||||
return h >>> 0;
|
||||
}
|
||||
|
||||
/** 取分类颜色:具名映射优先,否则按 id 哈希在 12 色板内确定性循环。 */
|
||||
export function getCategoryColor(categoryId: string): string {
|
||||
const named = NAMED_CATEGORY_COLORS[categoryId];
|
||||
if (named) return named;
|
||||
return CATEGORY_PALETTE[fnv1a(categoryId) % CATEGORY_PALETTE.length];
|
||||
}
|
||||
|
||||
/** 渠道品牌色(供 domain/channelConfig.ts 引用)。 */
|
||||
export const CHANNEL_BRAND_COLORS = {
|
||||
alipay: '#1677FF',
|
||||
wechat: '#07C160',
|
||||
bank: '#E53935',
|
||||
} as const;
|
||||
```
|
||||
|
||||
- [ ] **Step 2: channelConfig.ts 改用品牌色常量**
|
||||
|
||||
文件顶部加 import:
|
||||
|
||||
```typescript
|
||||
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
|
||||
```
|
||||
|
||||
三处字面量替换:`color: '#1677FF'` → `color: CHANNEL_BRAND_COLORS.alipay`,`color: '#07C160'` → `color: CHANNEL_BRAND_COLORS.wechat`,`color: '#E53935'` → `color: CHANNEL_BRAND_COLORS.bank`。
|
||||
|
||||
- [ ] **Step 3: CategoryPicker.tsx 改用 getCategoryColor**
|
||||
|
||||
删除第 31–48 行的 `CATEGORY_COLORS` 常量(含 `fallback`),文件顶部加:
|
||||
|
||||
```typescript
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
```
|
||||
|
||||
第 59 行 `const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;` 改为:
|
||||
|
||||
```typescript
|
||||
const color = getCategoryColor(cat.id);
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 运行测试**
|
||||
|
||||
Run: `npx vitest run tests/palette.test.ts tests/channelConfig.test.ts`
|
||||
Expected: PASS(渠道色值未变,channelConfig 测试不受影响)。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/palette.ts src/domain/channelConfig.ts src/components/CategoryPicker.tsx
|
||||
git commit -m "feat(theme): 分类/渠道颜色收归 theme/palette,消除组件与 domain 硬编码色"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 移除自定义字体(Caveat/Quicksand)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/_layout.tsx:27-39,70-79,370`
|
||||
- Modify: `package.json:14-15`
|
||||
|
||||
- [ ] **Step 1: 删除 _layout.tsx 的字体导入**
|
||||
|
||||
删除第 27–39 行两个 `import { useFonts, ... } from '@expo-google-fonts/...'` 块。
|
||||
|
||||
- [ ] **Step 2: 删除 useFonts 调用**
|
||||
|
||||
删除第 70–79 行的 `const [fontsLoaded, fontError] = useFonts({...});`。
|
||||
|
||||
- [ ] **Step 3: 简化加载门禁**
|
||||
|
||||
第 370 行:
|
||||
|
||||
```typescript
|
||||
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
|
||||
```
|
||||
|
||||
改为:
|
||||
|
||||
```typescript
|
||||
if (phase === 'loading') {
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 移除 package.json 依赖并重装**
|
||||
|
||||
删除 `package.json` 中 `"@expo-google-fonts/caveat"` 与 `"@expo-google-fonts/quicksand"` 两行,然后:
|
||||
|
||||
Run: `npm install --legacy-peer-deps`
|
||||
Expected: lockfile 更新,无字体包残留。
|
||||
|
||||
- [ ] **Step 5: 验证无残留引用**
|
||||
|
||||
Run: `grep -rn "expo-google-fonts\|Caveat_\|Quicksand_" src/ package.json`
|
||||
Expected: 无输出(typography 中 `theme.typography.X.fontFamily` 的运行时引用返回 undefined,RN 回退系统字体,无需在 P1 清理)。
|
||||
|
||||
- [ ] **Step 6: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test && npm run typecheck`
|
||||
Expected: 全绿。
|
||||
|
||||
- [ ] **Step 7: Commit**
|
||||
|
||||
```bash
|
||||
git add src/app/_layout.tsx package.json package-lock.json
|
||||
git commit -m "feat(theme): 移除 Caveat/Quicksand 自定义字体,切换系统字体"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 6: commonStyles 适配新圆角
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/theme/commonStyles.ts`
|
||||
|
||||
- [ ] **Step 1: modalCard 圆角 lg → xl**
|
||||
|
||||
`modalCard` 中 `borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)` 改为:
|
||||
|
||||
```typescript
|
||||
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
|
||||
```
|
||||
|
||||
(`input` 的 `theme.radii.md` 现在即 12px,无需改动,仅更新注释 `// 输入框标准 radii 为 md (12px)`。)
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: PASS。
|
||||
|
||||
- [ ] **Step 3: Commit**
|
||||
|
||||
```bash
|
||||
git add src/theme/commonStyles.ts
|
||||
git commit -m "feat(theme): commonStyles 弹窗圆角升级为 xl(24)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 7: 重写 MASTER.md 使其与实现一致
|
||||
|
||||
**Files:**
|
||||
- Modify: `design-system/beancount-mobile/MASTER.md`
|
||||
|
||||
- [ ] **Step 1: 完整替换 MASTER.md**
|
||||
|
||||
```markdown
|
||||
# Beancount Mobile 设计系统(MASTER)
|
||||
|
||||
> 本文件与 `src/theme/presets.ts` 一一对应,是实现的事实描述而非平行标准。
|
||||
> 修改配色/字阶/圆角时必须先改 presets.ts,再同步本文件。
|
||||
|
||||
## 设计方向:明亮 Bento 现代风
|
||||
|
||||
浅色为主、大圆角卡片(Bento)、近黑白单色 + 财务语义色点缀。
|
||||
品牌感靠排版与财务色表达,不依赖彩色 accent。暗色为 OLED 纯黑完整对等主题。
|
||||
|
||||
## 色板(= presets.ts)
|
||||
|
||||
### 浅色(默认)
|
||||
| Token | 值 | 用途 |
|
||||
|---|---|---|
|
||||
| bgPrimary | `#F6F7F9` | 页面背景 |
|
||||
| bgSecondary | `#FFFFFF` | 卡片 |
|
||||
| bgTertiary | `#EFF1F4` | 输入框 / chip |
|
||||
| fgPrimary | `#111318` | 主文字 |
|
||||
| fgSecondary | `#6B7280` | 次要文字 |
|
||||
| fgInverse | `#FFFFFF` | 反色文字 |
|
||||
| accent | `#111318` | 按钮 / 选中态(近黑) |
|
||||
| accentLight | `rgba(17,19,24,0.08)` | 选中高亮底色 |
|
||||
| accentDark | `#000000` | 按压态 |
|
||||
| financial.income | `#10B981` | 收入 |
|
||||
| financial.expense | `#EF4444` | 支出 |
|
||||
| financial.transfer | `#3B82F6` | 转账 |
|
||||
| border | `#E5E7EB` | 边框 |
|
||||
| overlay | `rgba(17,19,24,0.4)` | 遮罩 |
|
||||
|
||||
### 暗色(OLED)
|
||||
| Token | 值 |
|
||||
|---|---|
|
||||
| bgPrimary | `#040508` |
|
||||
| bgSecondary | `#101218` |
|
||||
| bgTertiary | `#1A1D24` |
|
||||
| fgPrimary / accent | `#F3F4F6` |
|
||||
| fgSecondary | `#9CA3AF` |
|
||||
| fgInverse | `#111318` |
|
||||
| financial.income | `#34D399`(提亮) |
|
||||
| financial.expense | `#F87171`(提亮) |
|
||||
| financial.transfer | `#60A5FA`(提亮) |
|
||||
| border | `rgba(255,255,255,0.08)` |
|
||||
|
||||
分类/渠道颜色见 `src/theme/palette.ts`(12 色循环 + 具名映射 + 渠道品牌色)。
|
||||
全工程颜色字面量只允许存在于 `presets.ts` 与 `palette.ts`。
|
||||
|
||||
## 字体
|
||||
|
||||
系统字体(iOS SF / Android Roboto),不加载自定义字体。
|
||||
金额数字一律 `fontVariant: ['tabular-nums']` 等宽对齐。
|
||||
|
||||
字阶:display 34/800 · h1 28/700 · h2 22/700 · h3 17/600 · body 16/400 · bodySmall 14/400 · caption 12/400。
|
||||
|
||||
## 圆角 / 间距 / 阴影
|
||||
|
||||
- 圆角:sm 8 · md 12 · lg 16 · **xl 24(卡片与弹窗默认)** · full
|
||||
- 间距:xs 4 · sm 8 · md 16 · lg 24 · xl 32
|
||||
- 阴影:浅色 4–12px 弥散轻阴影;暗色以 1px 半透边框代替阴影
|
||||
|
||||
## 图标
|
||||
|
||||
统一 Ionicons(`@expo/vector-icons`)。禁止用 emoji 充当图标;
|
||||
分类图标 = Ionicon + 圆形彩色底(CategoryIcon 组件,P2 落地)。
|
||||
|
||||
## 反模式
|
||||
|
||||
- 禁止在组件中写颜色字面量(`#xxxxxx` / `rgba(...)`),必须走 token
|
||||
- 禁止低对比文字(< 4.5:1)
|
||||
- 禁止瞬间状态变化,交互反馈 150–300ms
|
||||
- 禁止绕过 commonStyles 重复造 input/chip/modal 样式
|
||||
```
|
||||
|
||||
- [ ] **Step 2: Commit**
|
||||
|
||||
```bash
|
||||
git add design-system/beancount-mobile/MASTER.md
|
||||
git commit -m "docs: MASTER.md 重写为与实现一致的明亮 Bento 设计系统"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Task 8: P1 全量验收
|
||||
|
||||
- [ ] **Step 1: 全量测试**
|
||||
|
||||
Run: `npm test`
|
||||
Expected: 全部通过(30+ 文件,含新 theme/palette 测试)。
|
||||
|
||||
- [ ] **Step 2: typecheck**
|
||||
|
||||
Run: `npm run typecheck`
|
||||
Expected: 无错误。
|
||||
|
||||
- [ ] **Step 3: 审计**
|
||||
|
||||
Run: `grep -rn "expo-google-fonts" src/ package.json` → 无输出
|
||||
Run: `grep -rn "4F46E5\|EEF2FF\|3730A3" src/` → 无输出(靛蓝族清零)
|
||||
|
||||
已知遗留(不在 P1 处理,已在后续阶段计划内):`src/app/transaction/new.tsx:219` 的 `#2196F3`(P3 重写 new.tsx 时消除);`src/app/(tabs)/index.tsx` Hero 卡硬编码白字(P4 首页重写时消除);组件内 `fontFamily: theme.typography.X.fontFamily` 引用(P4/P5 逐页清理)。
|
||||
|
||||
- [ ] **Step 4: 手工走查(需要设备/模拟器)**
|
||||
|
||||
Run: `npm run android`(或 expo start)
|
||||
走查清单:浅色/暗色各过一遍四个 Tab —— 页面背景为米白/纯黑;chip 选中态为黑底白字/白底黑字;卡片圆角变大;无字体加载等待。
|
||||
|
||||
---
|
||||
|
||||
## 后续计划(不在本文件)
|
||||
|
||||
- **P2 组件库**:ScreenHeader / StatCard / AppTabBar / CategoryIcon / DatePickerField / FilterSheet / ManagementScreen
|
||||
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + 全局+ + transaction/new 重写 + SpeedDial 退役
|
||||
- **P4 四个 Tab 页**:首页待办条 / 交易时间线 / 报表 anchor 统一 / 我的 4 分组
|
||||
- **P5 管理页模板化 + 清零审计**
|
||||
|
||||
每阶段完成后基于实际代码编写下一阶段计划。
|
||||
934
docs/ui-redesign-p2-plan.md
Normal file
934
docs/ui-redesign-p2-plan.md
Normal 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';
|
||||
|
||||
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||||
export function getCategoryIcon(categoryId: string): IoniconName {
|
||||
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
|
||||
? CATEGORY_ICON_NAMES[categoryId]
|
||||
: FALLBACK_ICON;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/CategoryIcon.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { StyleSheet, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
import { getCategoryIcon } from './categoryIcons';
|
||||
|
||||
interface CategoryIconProps {
|
||||
categoryId: string;
|
||||
/** 圆形底色直径,默认 36。 */
|
||||
size?: number;
|
||||
}
|
||||
|
||||
/** 分类图标:Ionicon + 分类色圆形浅底(设计系统 §图标:替换 emoji)。 */
|
||||
export function CategoryIcon({ categoryId, size = 36 }: CategoryIconProps) {
|
||||
const color = getCategoryColor(categoryId);
|
||||
return (
|
||||
<View
|
||||
style={[styles.circle, {
|
||||
width: size,
|
||||
height: size,
|
||||
borderRadius: size / 2,
|
||||
backgroundColor: color + '1A',
|
||||
}]}
|
||||
>
|
||||
<Ionicons name={getCategoryIcon(categoryId)} size={size * 0.55} color={color} />
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
circle: { alignItems: 'center', justifyContent: 'center' },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: CategoryPicker.tsx 接入 CategoryIcon**
|
||||
|
||||
- 删除整个 `CATEGORY_ICONS` 常量(第 13–30 行)。
|
||||
- 顶部加 `import { CategoryIcon } from './CategoryIcon';`(`getCategoryColor` 的 import 保留)。
|
||||
- 删除 `const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;` 一行。
|
||||
- JSX 中 `<Text style={[styles.icon, { color }]}>{icon}</Text>` 替换为 `<CategoryIcon categoryId={cat.id} size={32} />`。
|
||||
- 删除 label 样式里的 `fontFamily: theme.typography.caption.fontFamily,` 一行。
|
||||
- 删除 styles 中的 `icon` 条目。
|
||||
- 注意:`const color = getCategoryColor(cat.id);` 保留(active 底色仍用)。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npx vitest run tests/components-p2.test.ts` → categoryIcons 相关 4 条 PASS(dateGrid 仍 FAIL,Task 3 修复)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 3: dateGrid 月历网格纯逻辑
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/dateGrid.ts`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/dateGrid.ts**
|
||||
|
||||
```typescript
|
||||
/**
|
||||
* 月历网格生成(周一开头,固定 6 行×7 列,含前后月填充)。
|
||||
* 纯 TS,无 RN 依赖,供 DatePickerField / CalendarView 复用。
|
||||
*/
|
||||
|
||||
export interface DayCell {
|
||||
/** YYYY-MM-DD(本地时区)。 */
|
||||
date: string;
|
||||
inMonth: boolean;
|
||||
}
|
||||
|
||||
function pad2(n: number): string {
|
||||
return n < 10 ? `0${n}` : `${n}`;
|
||||
}
|
||||
|
||||
function fmt(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
/** 生成指定年月的月历网格。month 为 1-12。 */
|
||||
export function buildMonthGrid(year: number, month: number): DayCell[][] {
|
||||
const first = new Date(year, month - 1, 1);
|
||||
// 周一为一周起点:getDay() 周日=0 → 偏移 (day+6)%7
|
||||
const offset = (first.getDay() + 6) % 7;
|
||||
const cursor = new Date(year, month - 1, 1 - offset);
|
||||
const weeks: DayCell[][] = [];
|
||||
for (let w = 0; w < 6; w++) {
|
||||
const week: DayCell[] = [];
|
||||
for (let d = 0; d < 7; d++) {
|
||||
week.push({ date: fmt(cursor), inMonth: cursor.getMonth() === month - 1 });
|
||||
cursor.setDate(cursor.getDate() + 1);
|
||||
}
|
||||
weeks.push(week);
|
||||
}
|
||||
return weeks;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npx vitest run tests/components-p2.test.ts` → Expected: 全部 9 条 PASS
|
||||
|
||||
---
|
||||
|
||||
### Task 4: BottomSheet 通用底部弹层
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/BottomSheet.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/BottomSheet.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Modal, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface BottomSheetProps {
|
||||
visible: boolean;
|
||||
onClose: () => void;
|
||||
title?: string;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
|
||||
/** 通用底部弹层容器:顶部 xl 圆角 + 把手、遮罩点击关闭、Android 返回键关闭。 */
|
||||
export function BottomSheet({ visible, onClose, title, children }: BottomSheetProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<Modal visible={visible} transparent animationType="slide" onRequestClose={onClose}>
|
||||
<Pressable style={[styles.overlay, { backgroundColor: theme.colors.overlay }]} onPress={onClose}>
|
||||
<Pressable
|
||||
style={[styles.sheet, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopLeftRadius: theme.radii.xl,
|
||||
borderTopRightRadius: theme.radii.xl,
|
||||
}]}
|
||||
onPress={(e) => e.stopPropagation()}
|
||||
>
|
||||
<View style={[styles.handle, { backgroundColor: theme.colors.border }]} />
|
||||
{title ? (
|
||||
<Text style={[theme.typography.h3, styles.title, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||
) : null}
|
||||
{children}
|
||||
</Pressable>
|
||||
</Pressable>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
overlay: { flex: 1, justifyContent: 'flex-end' },
|
||||
sheet: { maxHeight: '85%', paddingHorizontal: 20, paddingBottom: 36 },
|
||||
handle: { width: 40, height: 4, borderRadius: 2, alignSelf: 'center', marginTop: 8, marginBottom: 4 },
|
||||
title: { marginTop: 8, marginBottom: 12 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 5: DatePickerField 日期选择字段
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/DatePickerField.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/DatePickerField.tsx**
|
||||
|
||||
```tsx
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { BottomSheet } from './BottomSheet';
|
||||
import { buildMonthGrid } from './dateGrid';
|
||||
|
||||
interface DatePickerFieldProps {
|
||||
/** YYYY-MM-DD,空串表示未选。 */
|
||||
value: string;
|
||||
onChange: (date: string) => void;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['一', '二', '三', '四', '五', '六', '日'];
|
||||
|
||||
/** 日期选择字段:输入框外观 + 底部弹层月历(终结手输 YYYY-MM-DD)。 */
|
||||
export function DatePickerField({ value, onChange, placeholder }: DatePickerFieldProps) {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const [open, setOpen] = useState(false);
|
||||
const [viewYear, setViewYear] = useState(new Date().getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(new Date().getMonth() + 1);
|
||||
|
||||
const grid = useMemo(() => buildMonthGrid(viewYear, viewMonth), [viewYear, viewMonth]);
|
||||
|
||||
const openPicker = () => {
|
||||
const y = Number(value.slice(0, 4));
|
||||
const m = Number(value.slice(5, 7));
|
||||
if (y) {
|
||||
setViewYear(y);
|
||||
setViewMonth(m || 1);
|
||||
}
|
||||
setOpen(true);
|
||||
};
|
||||
|
||||
const shiftMonth = (delta: number) => {
|
||||
const m = viewMonth - 1 + delta;
|
||||
setViewYear(viewYear + Math.floor(m / 12));
|
||||
setViewMonth(((m % 12) + 12) % 12 + 1);
|
||||
};
|
||||
|
||||
const pick = (date: string) => {
|
||||
onChange(date);
|
||||
setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<Pressable style={commonStyles.input} onPress={openPicker} accessibilityRole="button">
|
||||
<Text style={{ color: value ? theme.colors.fgPrimary : theme.colors.fgSecondary }}>
|
||||
{value || placeholder || 'YYYY-MM-DD'}
|
||||
</Text>
|
||||
</Pressable>
|
||||
<BottomSheet visible={open} onClose={() => setOpen(false)}>
|
||||
{/* 月份导航 */}
|
||||
<View style={styles.navRow}>
|
||||
<Pressable onPress={() => shiftMonth(-1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="上一月">
|
||||
<Ionicons name="chevron-back" size={22} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary, fontVariant: ['tabular-nums'] }]}>
|
||||
{viewYear}-{String(viewMonth).padStart(2, '0')}
|
||||
</Text>
|
||||
<Pressable onPress={() => shiftMonth(1)} hitSlop={8} accessibilityRole="button" accessibilityLabel="下一月">
|
||||
<Ionicons name="chevron-forward" size={22} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{/* 星期头 */}
|
||||
<View style={styles.weekRow}>
|
||||
{WEEKDAYS.map(w => (
|
||||
<Text key={w} style={[styles.weekCell, theme.typography.caption, { color: theme.colors.fgSecondary }]}>{w}</Text>
|
||||
))}
|
||||
</View>
|
||||
{/* 日期网格 */}
|
||||
{grid.map((week, wi) => (
|
||||
<View key={wi} style={styles.weekRow}>
|
||||
{week.map(cell => {
|
||||
const selected = cell.date === value;
|
||||
return (
|
||||
<Pressable
|
||||
key={cell.date}
|
||||
onPress={() => pick(cell.date)}
|
||||
style={[styles.dayCell, {
|
||||
backgroundColor: selected ? theme.colors.accent : 'transparent',
|
||||
borderRadius: theme.radii.full,
|
||||
}]}
|
||||
>
|
||||
<Text style={[theme.typography.bodySmall, {
|
||||
color: selected
|
||||
? theme.colors.fgInverse
|
||||
: cell.inMonth ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||
opacity: cell.inMonth ? 1 : 0.5,
|
||||
fontVariant: ['tabular-nums'],
|
||||
}]}>
|
||||
{Number(cell.date.slice(8, 10))}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
</BottomSheet>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
navRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingVertical: 8 },
|
||||
weekRow: { flexDirection: 'row' },
|
||||
weekCell: { flex: 1, textAlign: 'center', paddingVertical: 6 },
|
||||
dayCell: { flex: 1, aspectRatio: 1, alignItems: 'center', justifyContent: 'center', margin: 1 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 6: ScreenHeader + StatCard
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/ScreenHeader.tsx`
|
||||
- Create: `src/components/StatCard.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/ScreenHeader.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface ScreenHeaderProps {
|
||||
title: string;
|
||||
/** 右侧操作位(图标按钮/文本按钮)。 */
|
||||
right?: React.ReactNode;
|
||||
/** 自定义返回行为;默认 router.back()。 */
|
||||
onBack?: () => void;
|
||||
/** 返回按钮的无障碍标签。 */
|
||||
backLabel?: string;
|
||||
}
|
||||
|
||||
/** 统一二级页头:返回 + 标题 + 右操作位(替换各页手写 header)。 */
|
||||
export function ScreenHeader({ title, right, onBack, backLabel = '返回' }: ScreenHeaderProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
return (
|
||||
<View style={styles.row}>
|
||||
<Pressable
|
||||
onPress={onBack ?? (() => router.back())}
|
||||
hitSlop={8}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={backLabel}
|
||||
>
|
||||
<Ionicons name="chevron-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, styles.title, { color: theme.colors.fgPrimary }]} numberOfLines={1}>
|
||||
{title}
|
||||
</Text>
|
||||
{right ?? <View style={styles.rightPlaceholder} />}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
row: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, gap: 8 },
|
||||
title: { flex: 1 },
|
||||
rightPlaceholder: { width: 24 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/StatCard.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
|
||||
interface StatCardProps {
|
||||
label: string;
|
||||
value: string;
|
||||
caption?: string;
|
||||
/** 默认 fgPrimary;收支场景传 financial.income/expense。 */
|
||||
valueColor?: string;
|
||||
}
|
||||
|
||||
/** 统计数字卡:标题 + 大数字(等宽)+ 说明(首页/报表/年报共用)。 */
|
||||
export function StatCard({ label, value, caption, valueColor }: StatCardProps) {
|
||||
const { theme } = useTheme();
|
||||
return (
|
||||
<View
|
||||
style={[styles.card, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.xl,
|
||||
borderColor: theme.colors.border,
|
||||
}, theme.shadows.sm]}
|
||||
>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{label}</Text>
|
||||
<Text
|
||||
style={[theme.typography.h2, styles.value, { color: valueColor ?? theme.colors.fgPrimary }]}
|
||||
numberOfLines={1}
|
||||
>
|
||||
{value}
|
||||
</Text>
|
||||
{caption ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{caption}</Text>
|
||||
) : null}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { padding: 16, gap: 4, borderWidth: StyleSheet.hairlineWidth },
|
||||
value: { fontWeight: '800', fontVariant: ['tabular-nums'] },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 7: AppTabBar 中央凸起+导航
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/AppTabBar.tsx`
|
||||
- Modify: `src/app/(tabs)/_layout.tsx`
|
||||
|
||||
- [ ] **Step 1: 创建 src/components/AppTabBar.tsx**
|
||||
|
||||
```tsx
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useRouter } from 'expo-router';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import type { BottomTabBarProps } from '@react-navigation/bottom-tabs';
|
||||
import { useTheme } from '../theme';
|
||||
import type { IoniconName } from './categoryIcons';
|
||||
|
||||
/** 路由名 → 图标(与 (tabs)/_layout.tsx 的 4 个 Screen 对应)。 */
|
||||
const TAB_ICONS: Record<string, IoniconName> = {
|
||||
index: 'home-outline',
|
||||
transactions: 'list-outline',
|
||||
report: 'pie-chart-outline',
|
||||
settings: 'settings-outline',
|
||||
};
|
||||
|
||||
/**
|
||||
* 自定义底部导航:4 个内容 Tab + 中央凸起+。
|
||||
* +不是路由——P2 暂跳转 /transaction/new,P3 改为唤起全局 NumpadSheet。
|
||||
*/
|
||||
export function AppTabBar({ state, descriptors, navigation }: BottomTabBarProps) {
|
||||
const { theme } = useTheme();
|
||||
const router = useRouter();
|
||||
const insets = useSafeAreaInsets();
|
||||
|
||||
const renderTab = (route: (typeof state.routes)[number], index: number) => {
|
||||
const focused = state.index === index;
|
||||
const options = descriptors[route.key].options;
|
||||
const label = (options.title ?? route.name) as string;
|
||||
const color = focused ? theme.colors.accent : theme.colors.fgSecondary;
|
||||
const onPress = () => {
|
||||
const event = navigation.emit({ type: 'tabPress', target: route.key, canPreventDefault: true });
|
||||
if (!focused && !event.defaultPrevented) navigation.navigate(route.name);
|
||||
};
|
||||
return (
|
||||
<Pressable
|
||||
key={route.key}
|
||||
onPress={onPress}
|
||||
style={styles.tab}
|
||||
accessibilityRole="button"
|
||||
accessibilityState={{ selected: focused }}
|
||||
accessibilityLabel={label}
|
||||
>
|
||||
<Ionicons name={TAB_ICONS[route.name] ?? 'ellipse-outline'} size={22} color={color} />
|
||||
<Text style={[styles.tabLabel, { color }]}>{label}</Text>
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[styles.bar, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopColor: theme.colors.border,
|
||||
paddingBottom: Math.max(insets.bottom, 8),
|
||||
}]}
|
||||
>
|
||||
{state.routes.slice(0, 2).map(renderTab)}
|
||||
<View style={styles.plusSlot}>
|
||||
<Pressable
|
||||
onPress={() => router.push('/transaction/new')}
|
||||
style={[styles.plus, {
|
||||
backgroundColor: theme.colors.accent,
|
||||
borderColor: theme.colors.bgPrimary,
|
||||
}, theme.shadows.lg]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel="记一笔"
|
||||
>
|
||||
<Ionicons name="add" size={30} color={theme.colors.fgInverse} />
|
||||
</Pressable>
|
||||
</View>
|
||||
{state.routes.slice(2).map(renderTab)}
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
bar: { flexDirection: 'row', alignItems: 'center', borderTopWidth: StyleSheet.hairlineWidth },
|
||||
tab: { flex: 1, alignItems: 'center', paddingTop: 8, gap: 2 },
|
||||
tabLabel: { fontSize: 10, fontWeight: '600' },
|
||||
plusSlot: { width: 64, alignItems: 'center' },
|
||||
plus: { width: 52, height: 52, borderRadius: 26, alignItems: 'center', justifyContent: 'center', marginTop: -24, borderWidth: 4 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 接入 (tabs)/_layout.tsx**
|
||||
|
||||
- 顶部加 `import { AppTabBar } from '../../components/AppTabBar';`,删除 `import { Ionicons } from '@expo/vector-icons';`(不再需要)。
|
||||
- 第 7 行注释改为 `/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */`
|
||||
- `Tabs` 组件加 prop:`tabBar={(props) => <AppTabBar {...props} />}`。
|
||||
- 删除 4 个 `Tabs.Screen` 里的 `tabBarIcon` 属性(图标由 AppTabBar 的 TAB_ICONS 决定),`title` 保留。
|
||||
- screenOptions 中 `tabBarActiveTintColor` / `tabBarInactiveTintColor` / `tabBarStyle` 可删除(自定义 tabBar 不用),仅保留 `headerShown: false`。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 8: FormModal children 支持 + ManagementScreen + tag 页试点
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/FormModal.tsx`
|
||||
- Create: `src/components/ManagementScreen.tsx`
|
||||
- Modify: `src/app/tag/index.tsx`(完整重写)
|
||||
|
||||
- [ ] **Step 1: FormModal 增加 children + ✕ 换图标 + 圆角 xl**
|
||||
|
||||
- `FormModalProps` 加字段:
|
||||
|
||||
```typescript
|
||||
/** 可选自定义内容(渲染在字段与按钮之间,如颜色选择器)。 */
|
||||
children?: React.ReactNode;
|
||||
```
|
||||
|
||||
- 函数签名解构加 `children`;在 `</ScrollView>` 之后、`<View style={styles.actions}>` 之前插入 `{children}`。
|
||||
- 顶部加 `import { Ionicons } from '@expo/vector-icons';`;把关闭按钮的 `<Text ...>✕</Text>` 替换为 `<Ionicons name="close" size={20} color={theme.colors.fgSecondary} />`。
|
||||
- sheet 的 `borderRadius: theme.radii.lg` 改为 `theme.radii.xl`。
|
||||
|
||||
- [ ] **Step 2: 创建 src/components/ManagementScreen.tsx**
|
||||
|
||||
```tsx
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { ScreenHeader } from './ScreenHeader';
|
||||
import { FormModal, type FormField } from './FormModal';
|
||||
|
||||
/**
|
||||
* 管理页模板(P2):统一「列表 + +新增 + 点按编辑 + 长按删除 + FormModal」模式。
|
||||
* 各管理页只声明字段配置与数据读写,不再复制 header/Alert/Modal 样板。
|
||||
*/
|
||||
interface ManagementScreenProps<T> {
|
||||
title: string;
|
||||
items: T[];
|
||||
keyExtractor: (item: T) => string;
|
||||
/** 渲染单个条目;handlers.openEdit 打开编辑弹窗,handlers.confirmDelete 弹删除确认。 */
|
||||
renderItem: (item: T, handlers: { openEdit: () => void; confirmDelete: () => void }) => React.ReactNode;
|
||||
addLabel: string;
|
||||
emptyText?: string;
|
||||
formTitle: (editing: T | null) => string;
|
||||
formFields: (editing: T | null) => FormField[];
|
||||
/** 返回 true 关闭弹窗(校验失败 Alert 后返回 false 保持打开)。 */
|
||||
onSubmit: (values: Record<string, string>, editing: T | null) => boolean;
|
||||
onDelete: (item: T) => void;
|
||||
deleteConfirmText: (item: T) => string;
|
||||
/** FormModal 附加内容(如颜色选择器),渲染在字段与按钮之间。 */
|
||||
formExtra?: (editing: T | null) => React.ReactNode;
|
||||
/** 打开表单时的回调(用于重置附加状态,如颜色选择)。 */
|
||||
onOpenForm?: (editing: T | null) => void;
|
||||
/** 列表上方的额外内容(分组 Tab 等)。 */
|
||||
headerContent?: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function ManagementScreen<T>(props: ManagementScreenProps<T>) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const [editing, setEditing] = useState<T | null>(null);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
|
||||
const openForm = (item: T | null) => {
|
||||
setEditing(item);
|
||||
props.onOpenForm?.(item);
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const confirmDelete = (item: T) => {
|
||||
Alert.alert(t('common.delete'), props.deleteConfirmText(item), [
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.delete'), style: 'destructive', onPress: () => props.onDelete(item) },
|
||||
]);
|
||||
};
|
||||
|
||||
const handleSubmit = (values: Record<string, string>) => {
|
||||
if (props.onSubmit(values, editing)) setModalOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<ScreenHeader
|
||||
title={props.title}
|
||||
right={
|
||||
<Pressable onPress={() => openForm(null)} hitSlop={8} accessibilityRole="button" accessibilityLabel={props.addLabel}>
|
||||
<Ionicons name="add" size={26} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
}
|
||||
/>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{props.headerContent}
|
||||
{props.items.length === 0 && props.emptyText ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{props.emptyText}</Text>
|
||||
) : null}
|
||||
{props.items.map(item => (
|
||||
<View key={props.keyExtractor(item)}>
|
||||
{props.renderItem(item, {
|
||||
openEdit: () => openForm(item),
|
||||
confirmDelete: () => confirmDelete(item),
|
||||
})}
|
||||
</View>
|
||||
))}
|
||||
{props.footer}
|
||||
</ScrollView>
|
||||
<FormModal
|
||||
visible={modalOpen}
|
||||
title={props.formTitle(editing)}
|
||||
fields={props.formFields(editing)}
|
||||
onConfirm={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
>
|
||||
{props.formExtra?.(editing)}
|
||||
</FormModal>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, gap: 12 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 3: 完整重写 src/app/tag/index.tsx(ManagementScreen 试点)**
|
||||
|
||||
```tsx
|
||||
/**
|
||||
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
|
||||
*
|
||||
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
||||
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
||||
*/
|
||||
import React, { useState } from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { TAG_COLORS } from '../../theme/palette';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { isValidTagName } from '../../domain/tags';
|
||||
import type { Tag } from '../../domain/tags';
|
||||
|
||||
export default function TagScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
const tags = useMetadataStore(s => s.tags);
|
||||
const addTag = useMetadataStore(s => s.addTag);
|
||||
const updateTag = useMetadataStore(s => s.updateTag);
|
||||
const removeTag = useMetadataStore(s => s.removeTag);
|
||||
|
||||
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
|
||||
|
||||
return (
|
||||
<ManagementScreen<Tag>
|
||||
title={t('tag.title')}
|
||||
items={tags}
|
||||
keyExtractor={tag => tag.id}
|
||||
addLabel={t('tag.add')}
|
||||
emptyText={t('tag.empty')}
|
||||
onOpenForm={editing => setSelectedColor(editing?.color ?? TAG_COLORS[0])}
|
||||
renderItem={(tag, { openEdit, confirmDelete }) => (
|
||||
<Pressable
|
||||
onPress={openEdit}
|
||||
onLongPress={confirmDelete}
|
||||
style={[styles.chip, { backgroundColor: tag.color }]}
|
||||
>
|
||||
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
||||
</Pressable>
|
||||
)}
|
||||
formTitle={editing => (editing ? t('tag.editTitle') : t('tag.add'))}
|
||||
formFields={editing => [
|
||||
{ key: 'name', label: t('tag.fieldName'), placeholder: 'food', defaultValue: editing?.name },
|
||||
]}
|
||||
formExtra={() => (
|
||||
<View style={styles.colorRow}>
|
||||
{TAG_COLORS.map(c => (
|
||||
<Pressable
|
||||
key={c}
|
||||
onPress={() => setSelectedColor(c)}
|
||||
style={[styles.colorDot, {
|
||||
backgroundColor: c,
|
||||
borderWidth: selectedColor === c ? 3 : 0,
|
||||
borderColor: theme.colors.fgPrimary,
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)}
|
||||
onSubmit={(values, editing) => {
|
||||
const name = values.name?.trim() ?? '';
|
||||
if (!isValidTagName(name)) {
|
||||
Alert.alert(t('tag.invalidName'), t('tag.invalidDesc'));
|
||||
return false;
|
||||
}
|
||||
if (editing) updateTag(editing.id, { name, color: selectedColor });
|
||||
else addTag({ id: generateId('tag'), name, color: selectedColor });
|
||||
return true;
|
||||
}}
|
||||
onDelete={tag => removeTag(tag.id)}
|
||||
deleteConfirmText={tag => t('tag.deleteConfirm', { name: tag.name })}
|
||||
footer={
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
chip: { alignSelf: 'flex-start', paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
||||
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm test` → Expected: 全部通过(tag 页逻辑变化不影响单测,但全量跑一遍防回归)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
---
|
||||
|
||||
### Task 9: Button / Card / SearchBar 重刷
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/Button.tsx`
|
||||
- Modify: `src/components/Card.tsx`
|
||||
- Modify: `src/components/SearchBar.tsx`
|
||||
|
||||
- [ ] **Step 1: Button.tsx**
|
||||
|
||||
- `borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`
|
||||
- 删除 Text 样式中的 `fontFamily: theme.typography.body.fontFamily`(系统字体)。
|
||||
|
||||
- [ ] **Step 2: Card.tsx**
|
||||
|
||||
- cardStyle 中 `borderRadius: theme.radii.lg,` 改为 `borderRadius: theme.radii.xl,`。
|
||||
|
||||
- [ ] **Step 3: SearchBar.tsx**
|
||||
|
||||
- `borderRadius: theme.radii.lg, // 升级为 lg 圆角` 改为 `borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)`。
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → Expected: 全部通过
|
||||
|
||||
---
|
||||
|
||||
### Task 10: P2 全量验收
|
||||
|
||||
- [ ] **Step 1: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test` → Expected: 全部通过(含 components-p2 的 9 条新测试)
|
||||
Run: `npm run typecheck` → Expected: 无错误
|
||||
|
||||
- [ ] **Step 2: 审计**
|
||||
|
||||
Run: `grep -rn "🍔\|🚗\|🛍\|💧\|🏠\|📞\|🎮\|⚙️\|💅\|👕\|🏥\|📚\|💰\|🧧\|📈\|🏷" src/` → Expected: 无输出(分类 emoji 清零)
|
||||
Run: `grep -n "fontFamily" src/components/Button.tsx src/components/CategoryPicker.tsx` → Expected: 无输出
|
||||
Run: `grep -n "✕" src/components/` → Expected: 无输出
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||
|
||||
Run: `npm run android`
|
||||
走查清单(浅/暗双主题):
|
||||
1. 底部导航为自定义 bar:中央黑色凸起+,4 个 tab 可切换
|
||||
2. + → 跳转记一笔页(P2 占位行为)
|
||||
3. 记一笔页分类网格:图标为 Ionicons 圆底彩色,无 emoji
|
||||
4. 标签管理页:新 ScreenHeader +号新增、点按编辑、长按删除、颜色选择器在弹窗内正常工作
|
||||
5. 任意 FormModal:关闭按钮为 Ionicons 图标而非 ✕ 字符
|
||||
|
||||
---
|
||||
|
||||
## 后续计划(不在本文件)
|
||||
|
||||
- **P3 录入闭环**:NumpadSheet + 双腿账户选择 + AppTabBar + 改全局唤起 + transaction/new 重写 + SpeedDial 退役
|
||||
- **P4 四个 Tab 页**:首页待办条 / 交易时间线(FilterSheet 在此设计)/ 报表 anchor 统一 / 我的 4 分组
|
||||
- **P5 管理页全面模板化**(tag 页为已验证模板)+ 清零审计
|
||||
1474
docs/ui-redesign-p3-plan.md
Normal file
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
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
223
docs/ui-redesign-p5-plan.md
Normal 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.ts);fontFamily 残留 14 处;emoji 5 处(monthlySummary.ts 4 + ai/chat.tsx 1);tag 页已是模板试点。
|
||||
|
||||
**与 spec 的偏差**:①account 页是「账户浏览器」(类型 Tab + 开户/调余额/关户三个交互),不套 ManagementScreen,只做 ScreenHeader 统一;②`numpadGlobalEntry` 翻默认只影响新安装(已持久化的 false 不覆盖,尊重用户选择)。
|
||||
|
||||
**模板适配决策**(已通读 7 页源码):
|
||||
- budget/rules/remark-template → 直套(T1)
|
||||
- category → `headerContent` 放支出/收入 chips,新增按当前类型(T2)
|
||||
- recurring → 卡片保留显式编辑/删除按钮(用 handlers.openEdit/confirmDelete),不用长按(T2)
|
||||
- credit-card → 富 renderItem(账单盒)直套(T2)
|
||||
|
||||
---
|
||||
|
||||
### Task 1: 管理页模板化批次 1(budget / rules / remark-template)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/budget/index.tsx`、`src/app/rules/index.tsx`、`src/app/remark-template/index.tsx`
|
||||
|
||||
- [ ] **Step 1: 三页套 ManagementScreen**(参照试点 `src/app/tag/index.tsx` 与模板 `src/components/ManagementScreen.tsx`)
|
||||
|
||||
通用映射:页面 state 消失(modal/editing 由模板持有);手写 header/addBtn/Alert/FormModal 全删;`footer` 传 `common.clickEditLongDelete` 提示;`deleteConfirmTitle` 传各页 `t('xxx.deleteTitle')`。
|
||||
|
||||
**budget/index.tsx**:
|
||||
- `items={budgets}`、`keyExtractor={b => b.id}`、`addLabel={t('budget.add')}`、`emptyText={t('budget.empty')}`
|
||||
- renderItem:保留现有 Card + 进度条结构(Pressable onPress={openEdit} onLongPress={confirmDelete} 包裹)。`today` 改用 `toDateString(new Date())`(修掉 `toISOString().slice(0,10)` 的 UTC 偏移)
|
||||
- formFields:5 字段(name/amount decimal-pad/period/categoryAccount/startDate),defaultValue 从 editing 取
|
||||
- onSubmit:保留校验(amount parseFloat ≤ 0 → Alert + return false);add/edit 分支调 addBudget/updateBudget
|
||||
- onDelete:`removeBudget(budget.id)`;deleteConfirmText:`t('budget.deleteConfirm', { name: budget.name })`
|
||||
|
||||
**rules/index.tsx**:
|
||||
- `items={[...rules].sort((a, b) => b.priority - a.priority)}`
|
||||
- renderItem:保留规则行(narration/P 徽标/条件/目标账户+命中次数);**删除 `fontFamily: theme.typography.caption.fontFamily`**(审计项)
|
||||
- formFields:7 字段(priority numeric/counterpartyContains/memoContains/sourceAccount/categoryAccount/narration/tags)
|
||||
- onSubmit:保留现有 Rule 组装逻辑;addRule `{...rule, id: generateId('rule'), hits: 0}` / updateRule
|
||||
|
||||
**remark-template/index.tsx**:
|
||||
- 2 字段(name/template);renderItem 的模板文本 `fontFamily: 'monospace'` 改 `fontVariant: ['tabular-nums']`(审计项)
|
||||
- onSubmit:name 空回退 `t('common.untitled')`
|
||||
|
||||
- [ ] **Step 2: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -ln "FormModal\|arrow-back" src/app/budget/index.tsx src/app/rules/index.tsx src/app/remark-template/index.tsx` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 2: 管理页模板化批次 2(category / recurring / credit-card)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/category/index.tsx`、`src/app/recurring/index.tsx`、`src/app/credit-card/index.tsx`
|
||||
|
||||
- [ ] **Step 1: category/index.tsx — headerContent 类型 chips**
|
||||
|
||||
- 保留一个本地 state:`const [catType, setCatType] = useState<'expense' | 'income'>('expense')`
|
||||
- `headerContent`:两个 chip(支出/收入,commonStyles.chip/chipActive),切换 setCatType
|
||||
- `items={categories.filter(c => c.type === catType)}`
|
||||
- `formTitle`:editing ? `t('category.editTitle')` : `t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })`(插值语法以现有键为准,先 grep category.addTitle)
|
||||
- formFields:3 字段(name/linkedAccount/keywords),编辑时带 defaultValue
|
||||
- onSubmit:add 时 `type: catType`,linkedAccount 空回退按类型(Income:/Expenses:Uncategorized),keywords 拆分逻辑保留;edit 保留原逻辑
|
||||
- renderItem:保留名称/linkedAccount/关键词行
|
||||
|
||||
- [ ] **Step 2: recurring/index.tsx — 显式按钮用 handlers**
|
||||
|
||||
- renderItem:保留信息行 Card;卡片底部两个按钮(编辑/删除)分别调 `handlers.openEdit()` / `handlers.confirmDelete()`(**不**用长按,交互更显式);外层 Pressable 去掉
|
||||
- formFields:6 字段;`new Date().toISOString().slice(0, 10)` 两处 → `toDateString(new Date())`
|
||||
- onSubmit:保留校验/draft 组装/成功 Alert,返回 true;校验失败 Alert 后 return false
|
||||
- 成功 Alert 在 onSubmit 内(setModal(null) 由模板接管——删除手动 setModal 调用)
|
||||
|
||||
- [ ] **Step 3: credit-card/index.tsx — 富 renderItem 直套**
|
||||
|
||||
- accountBalances useMemo 保留在页面顶层
|
||||
- renderItem:保留整张卡(银行/账单日/还款日/额度/账单盒),Pressable onPress={openEdit} onLongPress={confirmDelete}
|
||||
- formFields:8 字段;onSubmit 保留组装逻辑
|
||||
|
||||
- [ ] **Step 4: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -ln "FormModal\|arrow-back" src/app/category/index.tsx src/app/recurring/index.tsx src/app/credit-card/index.tsx` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 3: ScreenHeader 统一(account + settings 三页)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/app/account/index.tsx`、`src/app/settings/sync.tsx`、`src/app/settings/ai.tsx`、`src/app/settings/preferences.tsx`
|
||||
|
||||
- [ ] **Step 1: account/index.tsx**
|
||||
|
||||
手写 header(arrow-back + title)替换为 `<ScreenHeader title={t('account.title')} />`(组件保持其余逻辑不变:类型 Tab/开户 FormModal/调余额 FormModal/关户 Alert);删除 styles.header。调余额表单的 date 默认值 `new Date().toISOString().slice(0, 10)` → `toDateString(new Date())`。
|
||||
|
||||
- [ ] **Step 2: settings/sync.tsx、ai.tsx、preferences.tsx**
|
||||
|
||||
三页手写 header(约在 sync.tsx:460、ai.tsx:51、preferences.tsx:44)替换为 ScreenHeader,标题沿用原文案键;删除对应 styles.header 与不再使用的 import(Ionicons/router 若仅 header 使用)。sync.tsx 546 行,只动 header 块,其余不动。
|
||||
|
||||
- [ ] **Step 3: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
Run: `grep -rn "arrow-back" src/app/ | grep -v ScreenHeader` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 4: 图表与杂项清理(fontFamily / emoji / 星期头 i18n)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/components/charts/CategoryPie.tsx`、`src/components/charts/TrendLine.tsx`、`src/components/charts/NetWorthChart.tsx`、`src/components/CalendarView.tsx`、`src/components/charts/CalendarHeatmap.tsx`、`src/components/DatePickerField.tsx`、`src/components/NumpadKeyboard.tsx`、`src/components/NumpadSheet.tsx`、`src/ai/monthlySummary.ts`、`src/app/ai/chat.tsx`、`src/app/transaction/[id].tsx`、`src/app/_error.tsx`、`src/app/import/index.tsx`、`src/components/PostingEditor.tsx`、`src/i18n/zh.ts`、`src/i18n/en.ts`
|
||||
|
||||
- [ ] **Step 1: fontFamily 清零**
|
||||
|
||||
- `theme.typography.X.fontFamily` 引用(CategoryPie 2 处、TrendLine 4 处、ai/chat.tsx 1 处):直接删除该属性(P1 后 typography 无 fontFamily 字段,这些是无效引用)
|
||||
- `fontFamily: 'monospace'`:
|
||||
- 金额/数字场景(CategoryPie:35、transaction/[id].tsx:314、PostingEditor:63、import/index.tsx:391)→ `fontVariant: ['tabular-nums']`
|
||||
- 纯文本场景(_error.tsx:70 堆栈文本)→ 保留 monospace(堆栈对齐需要)或删除——保留,加注释说明
|
||||
- NetWorthChart/CalendarHeatmap 顺便检查一遍(grep 未命中但过一眼 token 合规)
|
||||
|
||||
- [ ] **Step 2: emoji 清零**
|
||||
|
||||
- `src/ai/monthlySummary.ts:81-85`:剥离 `📊/💰/💸/📝` 前缀,保留纯文本行(如 `'总收入:...'`)
|
||||
- `src/app/ai/chat.tsx:123`:方向 emoji(💰/🔄/💸)替换为符号文本:income `'+'`、transfer `'⇄'`、expense `'-'`(或直接金额上色——读上下文选与周边一致的处理;金额色用 theme.colors.financial.*)
|
||||
|
||||
- [ ] **Step 3: 星期头 i18n(3 处硬编码中文)**
|
||||
|
||||
- i18n 加键(zh/en 对等,单键逗号分隔):`'datepicker.weekdays': '一,二,三,四,五,六,日'` / `'Mo,Tu,We,Th,Fr,Sa,Su'`(周一起,DatePickerField 用);`'calendar.weekdays': '日,一,二,三,四,五,六'` / `'Su,Mo,Tu,We,Th,Fr,Sa'`(周日起,CalendarView/CalendarHeatmap 用)
|
||||
- `DatePickerField.tsx`、`CalendarView.tsx`、`CalendarHeatmap.tsx` 的 WEEKDAYS 常量改为 `t('datepicker.weekdays').split(',')` / `t('calendar.weekdays').split(',')`(组件内 useT;注意保持周一/周日起始顺序与各自网格一致,不要换序)
|
||||
|
||||
- [ ] **Step 4: NumpadKeyboard backspace 标签 i18n**
|
||||
|
||||
- i18n 加键:`'numpad.backspace': '退格'` / `'Backspace'`
|
||||
- NumpadKeyboard props 加 `backspaceLabel: string`,backspace 键 accessibilityLabel 用它;`NumpadSheet.tsx` 调用处传 `t('numpad.backspace')`
|
||||
|
||||
- [ ] **Step 5: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿(i18n 对等校验覆盖新键)
|
||||
Run: `grep -rn "fontFamily" src/ --include="*.tsx" | grep -v "_error.tsx"` → 无输出
|
||||
|
||||
---
|
||||
|
||||
### Task 5: 收尾(翻默认 + modal 守卫 + MonthlyReport 删除 + budgets 时区修复 + duplicate 增强)
|
||||
|
||||
**Files:**
|
||||
- Modify: `src/store/settingsStore.ts`、`src/components/NumpadSheet.tsx`、`src/components/NumpadSheetHost.tsx`、`src/domain/budgets.ts`、`src/app/(tabs)/transactions.tsx`、`tests/budgets.test.ts`(或现有 budget 测试文件)
|
||||
- Delete: `src/components/charts/MonthlyReport.tsx`
|
||||
|
||||
- [ ] **Step 1: numpadGlobalEntry 默认翻 true**
|
||||
|
||||
`src/store/settingsStore.ts` DEFAULTS 中 `numpadGlobalEntry: false` → `true`。**注意**:已持久化 false 的用户不受影响(hydrate 合并优先级),仅新安装生效——在代码注释中写明。
|
||||
|
||||
- [ ] **Step 2: modal 模式未保存守卫(翻默认的前置)**
|
||||
|
||||
- `NumpadSheet.tsx`:加可选 prop `onDirtyChange?: (dirty: boolean) => void`;把 beforeRemove 守卫里的 dirty 计算抽成 `computeDirty()`(读 formRef),加 `useEffect(() => { onDirtyChange?.(computeDirty()); })`(每次渲染后上报,轻量)
|
||||
- `NumpadSheetHost.tsx`:`const dirtyRef = useRef(false)`;NumpadSheet 传 `onDirtyChange={d => { dirtyRef.current = d; }}`;overlay onPress 与 Modal onRequestClose 改调 `requestClose`:
|
||||
|
||||
```typescript
|
||||
const requestClose = () => {
|
||||
if (!dirtyRef.current) { close(); return; }
|
||||
Alert.alert(t('transaction.unsavedTitle'), t('transaction.unsavedMessage'), [
|
||||
{ text: t('common.cancel'), style: 'cancel' },
|
||||
{ text: t('common.discard'), style: 'destructive', onPress: close },
|
||||
]);
|
||||
};
|
||||
```
|
||||
|
||||
(Host 需 useT + Alert import;common.discard 键先 grep 确认存在。)
|
||||
|
||||
- [ ] **Step 3: 删除 MonthlyReport.tsx**(P4 确认无引用的死文件)
|
||||
|
||||
Run: `rm src/components/charts/MonthlyReport.tsx`,再 `grep -rn "MonthlyReport" src/` → 无输出
|
||||
|
||||
- [ ] **Step 4: budgets.ts getPeriodRange 本地时区修复**
|
||||
|
||||
读 `src/domain/budgets.ts` 的 `getPeriodRange`(约 93 行):`new Date('YYYY-MM-DD')` 是 UTC 解析,负时区会把边界日算到前一天。改为 split 构造本地 Date(`const [y, m, d] = dateStr.split('-').map(Number); new Date(y, m - 1, d)`),与 periodNav 的 parse 一致。在现有 budget 测试文件(`ls tests/ | grep -i budget`)追加边界用例:`getPeriodRange('monthly', '2026-07-01')` 的 start 必须是 `'2026-07-01'`、end `'2026-07-31'`;`getPeriodRange('weekly', ...)`/`yearly` 各补一例(先读现有测试风格与函数实际行为写期望值——若修复改变了现有行为导致旧测试失败,停止报告 BLOCKED)。
|
||||
|
||||
- [ ] **Step 5: handleDuplicate 带 tags**
|
||||
|
||||
`src/app/(tabs)/transactions.tsx` 的 handleDuplicate draftJson 加 `tags: tx.tags`;同时检查 `NumpadSheet.tsx` 的 draftJson 预填 effect——目前不解析 tags,参照 editId 预填的 tags 回填逻辑(tags store 查名 → fallback TAG_COLORS[3])补上 draft.tags 解析(`Array.isArray(draft.tags)` 时)。cost/price 不带(hasExtras 逻辑已保证含 cost 交易从编辑入口进高级模式;复制是新建,丢 cost 属可接受简化,注释说明)。
|
||||
|
||||
- [ ] **Step 6: 验证**
|
||||
|
||||
Run: `npm run typecheck && npm test` → 全绿
|
||||
|
||||
---
|
||||
|
||||
### Task 6: P5 清零审计 + 全项目验收
|
||||
|
||||
- [ ] **Step 1: 清零审计(全部应无输出或仅剩允许项)**
|
||||
|
||||
```bash
|
||||
grep -rn "#[0-9A-Fa-f]\{6\}" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rnE "#[0-9A-Fa-f]{3}\b" src/ --include="*.ts" --include="*.tsx" | grep -v "theme/presets.ts\|theme/palette.ts" # 无输出
|
||||
grep -rn "fontFamily" src/ --include="*.tsx" # 仅 _error.tsx monospace
|
||||
grep -rn "📊\|💰\|💸\|📝\|🔄\|🎉\|✨" src/ --include="*.ts" --include="*.tsx" # 无输出
|
||||
grep -rn "MonthlyReport\|SpeedDial" src/ # 无输出
|
||||
grep -rn "arrow-back" src/app/ # 无输出(ScreenHeader 内部除外)
|
||||
grep -rn "WEEKDAYS" src/components/ # 无输出(全部走 i18n)
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 全量测试 + typecheck**
|
||||
|
||||
Run: `npm test` → 全绿
|
||||
Run: `npm run typecheck` → 无错误
|
||||
|
||||
- [ ] **Step 3: 手工走查(需设备/模拟器)**
|
||||
|
||||
Run: `npm run android`
|
||||
走查清单(浅/暗双主题):
|
||||
1. 6 个管理页(分类/预算/规则/周期/信用卡/备注模板):+新增、点按编辑、删除(recurring 为显式按钮,其余长按)、空态文案
|
||||
2. account 页:类型 Tab、开户、调余额、关户
|
||||
3. settings/sync、ai、preferences:header 统一有返回箭头
|
||||
4. **+按钮默认开全局面板**(新装/清数据后):任意页面弹 modal;输入金额后点遮罩/Android 返回 → 弹「放弃修改」确认;无输入直接关
|
||||
5. 设置→偏好里开关关闭 → +回到跳整页
|
||||
6. 交易页左滑复制带标签的交易 → 标签预填
|
||||
7. 报表月 Tab 日历、年报节奏图、TrendLine/CategoryPie 显示正常
|
||||
8. 英文语言:日期选择器/日历星期头为英文缩写
|
||||
|
||||
---
|
||||
|
||||
## 完成后的项目状态
|
||||
|
||||
P1–P5 全部交付,spec §8 验收标准全达成。剩余已知非阻塞项(不修,记录):TransactionCard memo 被内联 onPress 击穿(性能);todayStr useMemo 跨午夜不刷新;NumpadSheet page 模式无可见返回按钮(spec 允许);_error.tsx monospace 保留(堆栈对齐)。
|
||||
18
package-lock.json
generated
18
package-lock.json
generated
@ -1,15 +1,13 @@
|
||||
{
|
||||
"name": "beancount-mobile",
|
||||
"name": "drift-ledger",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "beancount-mobile",
|
||||
"name": "drift-ledger",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/caveat": "^0.4.2",
|
||||
"@expo-google-fonts/quicksand": "^0.4.1",
|
||||
"@expo/metro-runtime": "^6.1.2",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/drawer": "^7.5.0",
|
||||
@ -2017,18 +2015,6 @@
|
||||
"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": {
|
||||
"version": "0.0.6",
|
||||
"resolved": "https://registry.npmmirror.com/@expo/code-signing-certificates/-/code-signing-certificates-0.0.6.tgz",
|
||||
|
||||
@ -11,8 +11,6 @@
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"dependencies": {
|
||||
"@expo-google-fonts/caveat": "^0.4.2",
|
||||
"@expo-google-fonts/quicksand": "^0.4.1",
|
||||
"@expo/metro-runtime": "^6.1.2",
|
||||
"@expo/vector-icons": "^15.0.3",
|
||||
"@react-navigation/drawer": "^7.5.0",
|
||||
|
||||
@ -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 方案。
|
||||
|
||||
## 当前版本
|
||||
|
||||
**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 Bridge:ONNX Runtime 推理 + det/rec 前后处理
|
||||
│ └── OcrPackage.kt # RN Package 注册(注入到 MainApplication.getPackages)
|
||||
└── assets/ # ONNX 模型 + 字典(需自行下载放置)
|
||||
├── ppocrv5_det.onnx # 文本检测模型
|
||||
├── ppocrv5_rec.onnx # 文本识别模型(多语言,输出 18385 维)
|
||||
└── ppocrv5_dict.txt # PP-OCRv5 多语言字典(18383 字符,CTC 解码用)
|
||||
├── ppocrv6_det.onnx # 文本检测模型(PP-OCRv6 small)
|
||||
├── ppocrv6_rec.onnx # 文本识别模型(PP-OCRv6 small,多语言)
|
||||
└── ppocrv6_dict.txt # PP-OCRv6 多语言字典(CTC 解码用)
|
||||
```
|
||||
|
||||
## 模型获取(一键下载)
|
||||
|
||||
社区已转好的 ONNX 版本(来自官方 Paddle 权重,无质量损失):
|
||||
PP-OCRv6 官方 ONNX 模型来自 [PaddlePaddle/PP-OCRv6 系列](https://huggingface.co/collections/PaddlePaddle/pp-ocrv6):
|
||||
|
||||
```bash
|
||||
# 在项目根目录执行
|
||||
mkdir -p plugins/ppocr/assets
|
||||
cd plugins/ppocr/assets
|
||||
|
||||
# det 模型(4.8 MB)
|
||||
curl -L -o ppocrv5_det.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_det.onnx
|
||||
# det 模型(PP-OCRv6 small)
|
||||
curl -L -o ppocrv6_det.onnx \
|
||||
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_det_onnx/resolve/main/inference.onnx
|
||||
|
||||
# rec 模型(16.6 MB)
|
||||
curl -L -o ppocrv5_rec.onnx https://huggingface.co/ilaylow/PP_OCRv5_mobile_onnx/resolve/main/ppocrv5_rec.onnx
|
||||
# rec 模型(PP-OCRv6 small)
|
||||
curl -L -o ppocrv6_rec.onnx \
|
||||
https://huggingface.co/PaddlePaddle/PP-OCRv6_small_rec_onnx/resolve/main/inference.onnx
|
||||
|
||||
# PP-OCRv5 多语言字典(74 KB,必须与上面的 rec 模型配套)
|
||||
curl -L -o ppocrv5_dict.txt https://raw.githubusercontent.com/PaddlePaddle/PaddleOCR/main/ppocr/utils/dict/ppocrv5_dict.txt
|
||||
# PP-OCRv6 多语言字典(必须与上面的 rec 模型配套)
|
||||
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 模型配套**: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) 转换而来。
|
||||
> ⚠️ **字典必须与 rec 模型配套**:v6 字典字符集与 v5 完全不同,混用会导致 CTC 解码乱码。
|
||||
> 若之前使用过 v5 模型,务必删除旧文件(`ppocrv5_det.onnx`、`ppocrv5_rec.onnx`、`ppocrv5_dict.txt`)。
|
||||
|
||||
## 性能配置(参考 AutoAccounting OcrProcessor.kt)
|
||||
|
||||
| 优化项 | 配置 |
|
||||
|--------|------|
|
||||
| 引擎 | ONNX Runtime Android 1.20.1 |
|
||||
| 引擎 | ONNX Runtime Android |
|
||||
| 执行器 | CPU(兼容性最稳,部分设备 GPU 会崩溃) |
|
||||
| 线程 | intraOp=2 / interOp=2 |
|
||||
| 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`。
|
||||
|
||||
> 若之前已 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
|
||||
|
||||
@ -29,11 +29,11 @@ import kotlin.math.max
|
||||
import kotlin.math.min
|
||||
|
||||
/**
|
||||
* PP-OCRv5 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||
* PP-OCRv6 (ONNX Runtime) React Native Bridge(plan.md「3.4 Layer 2」+「决策 4 Config Plugin」)。
|
||||
*
|
||||
* 引擎:ONNX Runtime(跨平台、微软官方、Windows 友好),替代 NCNN 路线。
|
||||
* 模型:ppocrv5_det.onnx + ppocrv5_rec.onnx(从 ilaylow/PP_OCRv5_mobile_onnx 下载)。
|
||||
* 字典:ppocrv5_dict.txt(PP-OCRv5 多语言字典,18383 字符;rec 模型 18385 维输出 = 字典 + blank + 特殊位)。
|
||||
* 模型:ppocrv6_det.onnx + ppocrv6_rec.onnx(PP-OCRv6 small,从 PaddlePaddle 官方 HuggingFace 下载)。
|
||||
* 字典:ppocrv6_dict.txt(PP-OCRv6 多语言字典,18708 字符;rec 模型 18710 维输出 = 字典 + blank + 特殊位)。
|
||||
*
|
||||
* 流水线:
|
||||
* 1. det(文本检测):bitmap → DB 后处理得到文本框
|
||||
@ -94,7 +94,7 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
ortEnv = env
|
||||
dictionary = dict
|
||||
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) {
|
||||
initFailed = true
|
||||
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
|
||||
|
||||
/**
|
||||
* 加载 ppocrv5_dict.txt 字典。
|
||||
* 加载 ppocrv6_dict.txt 字典。
|
||||
*
|
||||
* PaddleOCR CTC 约定:模型输出 logits 的 index 0 是 blank,字符从 index 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 起,
|
||||
* 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> {
|
||||
if (logits.isEmpty()) return "" to 0f
|
||||
@ -527,16 +528,12 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
for (i in 1 until numClasses) {
|
||||
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) {
|
||||
val dictIdx = maxIdx - 1 // index 1..N → dictionary[0..N-1]
|
||||
if (dictIdx in 0 until dictionary.size) {
|
||||
sb.append(dictionary[dictIdx])
|
||||
confSum += prob.toFloat()
|
||||
confSum += maxVal // v6 输出已是概率,直接用
|
||||
confCount++
|
||||
}
|
||||
}
|
||||
@ -650,10 +647,10 @@ class OcrModule(private val context: ReactApplicationContext) :
|
||||
/** rec 单行最大宽度。 */
|
||||
private const val REC_MAX_WIDTH = 320
|
||||
/** assets 中的模型/字典文件名。 */
|
||||
private const val ASSET_DET_MODEL = "ppocrv5_det.onnx"
|
||||
private const val ASSET_REC_MODEL = "ppocrv5_rec.onnx"
|
||||
// PP-OCRv5 多语言识别模型的配套字典(18383 字符 + 运行时 1 blank = 18385 维输出)。
|
||||
// 注意:必须与 rec 模型配套,错用旧版 ppocr_keys_v1.txt(6623)会导致 CTC 解码乱码。
|
||||
private const val ASSET_DICT = "ppocrv5_dict.txt"
|
||||
private const val ASSET_DET_MODEL = "ppocrv6_det.onnx"
|
||||
private const val ASSET_REC_MODEL = "ppocrv6_rec.onnx"
|
||||
// PP-OCRv6 多语言识别模型的配套字典(18708 字符 + 运行时 1 blank = 18710 维输出)。
|
||||
// 注意:必须与 rec 模型配套,错用 v5 字典(18383)会导致 CTC 解码丢字符。
|
||||
private const val ASSET_DICT = "ppocrv6_dict.txt"
|
||||
}
|
||||
}
|
||||
|
||||
Binary file not shown.
BIN
plugins/ppocr/assets/ppocrv6_det.onnx
Normal file
BIN
plugins/ppocr/assets/ppocrv6_det.onnx
Normal file
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@ -78,14 +78,14 @@ export async function generateMonthlySummary(
|
||||
/** 无 AI 的纯文本总结(降级,不调 AI)。 */
|
||||
export function generatePlainTextSummary(stats: MonthlyStats): string {
|
||||
const lines: string[] = [];
|
||||
lines.push(`📊 ${stats.year}年${stats.month}月财务总结`);
|
||||
lines.push(`${stats.year}年${stats.month}月财务总结`);
|
||||
lines.push('');
|
||||
lines.push(`💰 总收入:${stats.totalIncome} 元`);
|
||||
lines.push(`💸 总支出:${stats.totalExpense} 元`);
|
||||
lines.push(`📝 交易笔数:${stats.transactionCount}`);
|
||||
lines.push(`总收入:${stats.totalIncome} 元`);
|
||||
lines.push(`总支出:${stats.totalExpense} 元`);
|
||||
lines.push(`交易笔数:${stats.transactionCount}`);
|
||||
lines.push('');
|
||||
if (stats.topCategories.length > 0) {
|
||||
lines.push('🏷️ 主要支出分类:');
|
||||
lines.push('主要支出分类:');
|
||||
for (const cat of stats.topCategories) {
|
||||
lines.push(` ${cat.category}: ${cat.amount} 元`);
|
||||
}
|
||||
|
||||
@ -1,53 +1,32 @@
|
||||
import React from 'react';
|
||||
import { Tabs } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { AppTabBar } from '../../components/AppTabBar';
|
||||
|
||||
/** 底部 Tab 导航:首页/交易/报表/导入/规则/设置。 */
|
||||
/** 底部 Tab 导航:首页/交易/报表/设置 + 中央+(AppTabBar)。 */
|
||||
export default function TabsLayout() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
|
||||
return (
|
||||
<Tabs
|
||||
screenOptions={{
|
||||
headerShown: false,
|
||||
tabBarActiveTintColor: theme.colors.accent,
|
||||
tabBarInactiveTintColor: theme.colors.fgSecondary,
|
||||
tabBarStyle: {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderTopColor: theme.colors.border,
|
||||
},
|
||||
}}
|
||||
screenOptions={{ headerShown: false }}
|
||||
tabBar={(props) => <AppTabBar {...props} />}
|
||||
>
|
||||
<Tabs.Screen
|
||||
name="index"
|
||||
options={{
|
||||
title: t('tab.home'),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="home-outline" size={size} color={color} />,
|
||||
}}
|
||||
options={{ title: t('tab.home') }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="transactions"
|
||||
options={{
|
||||
title: t('tab.transactions'),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="list-outline" size={size} color={color} />,
|
||||
}}
|
||||
options={{ title: t('tab.transactions') }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="report"
|
||||
options={{
|
||||
title: t('tab.report'),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="pie-chart-outline" size={size} color={color} />,
|
||||
}}
|
||||
options={{ title: t('tab.report') }}
|
||||
/>
|
||||
<Tabs.Screen
|
||||
name="settings"
|
||||
options={{
|
||||
title: t('tab.settings'),
|
||||
tabBarIcon: ({ color, size }) => <Ionicons name="settings-outline" size={size} color={color} />,
|
||||
}}
|
||||
options={{ title: t('tab.settings') }}
|
||||
/>
|
||||
</Tabs>
|
||||
);
|
||||
|
||||
@ -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 { useRouter } from 'expo-router';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
@ -7,44 +12,35 @@ import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { SpeedDial } from '../../components/SpeedDial';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { AccountTree } from '../../components/AccountTree';
|
||||
import { TodoStrip } from '../../components/TodoStrip';
|
||||
import { TrendLine } from '../../components/charts/TrendLine';
|
||||
import { calculateNetWorth } from '../../domain/netWorth';
|
||||
import { groupByMonth } from '../../domain/chartStats';
|
||||
import { buildAccountTree } from '../../domain/accountTree';
|
||||
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() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||||
|
||||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||||
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 netWorth = useMemo(() => calculateNetWorth(transactions), [transactions]);
|
||||
const monthlyData = useMemo(() => groupByMonth(transactions), [transactions]);
|
||||
const currentMonth = useMemo(() => {
|
||||
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]);
|
||||
const currentMonth = useMemo(() => monthlyData.length ? monthlyData[monthlyData.length - 1] : null, [monthlyData]);
|
||||
|
||||
// 2. 周期性记账到期计算
|
||||
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(() => {
|
||||
return getDueRecurring(recurringTransactions, todayStr);
|
||||
}, [recurringTransactions, todayStr]);
|
||||
const todayStr = useMemo(() => toDateString(new Date()), []);
|
||||
const dueRecurring = useMemo(() => getDueRecurring(recurringTransactions, todayStr), [recurringTransactions, todayStr]);
|
||||
|
||||
// 周期记账自动触发:启动时自动确认标记了 autoConfirm 的到期项
|
||||
const autoConfirmed = useRef(false);
|
||||
@ -70,19 +66,34 @@ export default function HomeScreen() {
|
||||
return () => { isMounted = false; };
|
||||
}, [dueRecurring, addTransaction, updateRecurringTransaction]);
|
||||
|
||||
const handleConfirmRecurring = async (rec: any) => {
|
||||
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 budgetRemaining = useMemo(() => {
|
||||
const monthlyBudgets = budgets.filter(b => b.period === 'monthly');
|
||||
if (monthlyBudgets.length === 0) return null;
|
||||
const remainings = monthlyBudgets.map(b => calculateBudgetProgress(b, transactions, todayStr).remaining);
|
||||
return remainings.reduce((a, b) => addDecimals([a, b]), '0');
|
||||
}, [budgets, transactions, todayStr]);
|
||||
|
||||
// 最近 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) => {
|
||||
if (!s || s === '0') return '¥0.00';
|
||||
const num = parseFloat(s);
|
||||
@ -94,104 +105,52 @@ export default function HomeScreen() {
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, fontFamily: theme.typography.h1.fontFamily }]}>
|
||||
{t('app.name')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary }]}>{greeting}</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]}>{dateLine}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
|
||||
{/* 净资产 Hero 卡片 */}
|
||||
<Card
|
||||
style={{
|
||||
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' }]}>
|
||||
{/* 净资产白卡 */}
|
||||
<Card>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.netWorthTitle')}</Text>
|
||||
<Text style={[theme.typography.display, { color: theme.colors.fgPrimary, marginVertical: 6, fontVariant: ['tabular-nums'] }]}>
|
||||
{fmtAmount(netWorth.netWorth)}
|
||||
</Text>
|
||||
<View style={styles.heroFooter}>
|
||||
<View>
|
||||
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('home.assets')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h3, { color: '#FFFFFF', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
|
||||
{fmtAmount(netWorth.assets)}
|
||||
<View style={[styles.heroFooter, { borderTopColor: theme.colors.divider }]}>
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthExpense')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
-{fmtAmount(currentMonth?.expense.toString() ?? '0')}
|
||||
</Text>
|
||||
</View>
|
||||
<View style={{ alignItems: 'flex-end' }}>
|
||||
<Text style={[theme.typography.caption, { color: 'rgba(255,255,255,0.7)', fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('home.liabilities')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.h3, { color: 'rgba(255,255,255,0.9)', fontSize: 15, fontWeight: '700', marginTop: 2, fontFamily: 'monospace' }]}>
|
||||
{fmtAmount(netWorth.liabilities)}
|
||||
<View style={styles.heroStat}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('home.monthIncome')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontVariant: ['tabular-nums'] }]}>
|
||||
+{fmtAmount(currentMonth?.income.toString() ?? '0')}
|
||||
</Text>
|
||||
</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>
|
||||
</Card>
|
||||
|
||||
{/* Bento 双格排列 */}
|
||||
{(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}
|
||||
<TodoStrip />
|
||||
|
||||
{currentMonth ? (
|
||||
<Card title={`${currentMonth.month.slice(5)} ${t('home.title')}`} style={styles.bentoCol}>
|
||||
<View style={styles.bentoStats}>
|
||||
<View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('home.income')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.income, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
||||
+{fmtAmount(currentMonth.income.toString())}
|
||||
</Text>
|
||||
</View>
|
||||
<View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('home.expense')}
|
||||
</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.financial.expense, fontWeight: '700', fontFamily: 'monospace', marginTop: 2 }]} numberOfLines={1}>
|
||||
-{fmtAmount(currentMonth.expense.toString())}
|
||||
</Text>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
) : null}
|
||||
</View>
|
||||
{/* 最近 5 条交易 */}
|
||||
{recentTxs.length > 0 && (
|
||||
<Card title={t('home.recentTransactions')}>
|
||||
{recentTxs.map(tx => (
|
||||
<TransactionCard key={tx.id} transaction={tx} categoryId={categoryIdFor(tx)} onPress={() => router.push(`/transaction/${tx.id}`)} />
|
||||
))}
|
||||
<Pressable onPress={() => router.push('/(tabs)/transactions')} style={styles.viewAll} accessibilityRole="button" accessibilityLabel={t('home.viewAll')}>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700' }]}>{t('home.viewAll')} →</Text>
|
||||
</Pressable>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 月度趋势 */}
|
||||
@ -200,19 +159,7 @@ export default function HomeScreen() {
|
||||
<TrendLine transactions={transactions} />
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 账户与资产余额树 */}
|
||||
{accountNodes.length > 0 && (
|
||||
<Card title={t('home.accountTree')}>
|
||||
<AccountTree nodes={accountNodes} />
|
||||
</Card>
|
||||
)}
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -220,13 +167,8 @@ export default function HomeScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, paddingBottom: 96 },
|
||||
heroFooter: { flexDirection: 'row', justifyContent: 'space-between', borderTopWidth: StyleSheet.hairlineWidth, borderTopColor: 'rgba(255,255,255,0.15)', paddingTop: 10, marginTop: 4 },
|
||||
bentoRow: { flexDirection: 'row', gap: 12 },
|
||||
bentoCol: { flex: 1 },
|
||||
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 },
|
||||
content: { padding: 16, paddingBottom: 40 },
|
||||
heroFooter: { flexDirection: 'row', gap: 16, borderTopWidth: StyleSheet.hairlineWidth, paddingTop: 10, marginTop: 4 },
|
||||
heroStat: { gap: 2 },
|
||||
viewAll: { alignItems: 'center', paddingVertical: 6 },
|
||||
});
|
||||
|
||||
|
||||
@ -1,11 +1,10 @@
|
||||
/**
|
||||
* 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」)。
|
||||
/**
|
||||
* 报表页(plan.md「5.2 图表与可视化」+「5.3 年度报告」;P4 spec §7.3 重写)。
|
||||
*
|
||||
* 功能:
|
||||
* - 月份切换器(上一月/下一月)
|
||||
* - 月度收支/分类占比/热力图/净资产趋势/年度报告
|
||||
* - 导出报表为图片(react-native-view-shot)
|
||||
* - AI 月度总结(generatePlainTextSummary / generateMonthlySummary)
|
||||
* - 单一 anchor 日期 + 周期(周/月/年)统一导航(periodNav),替代三套独立切换器
|
||||
* - 收支看板/分类占比/日历/净资产趋势/年度报告
|
||||
* - ⋯ 菜单:AI 月度总结、导出报表为图片(react-native-view-shot)
|
||||
*/
|
||||
import React, { useMemo, useRef, useState } from 'react';
|
||||
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 { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { MonthlyReport } from '../../components/charts/MonthlyReport';
|
||||
import { BottomSheet } from '../../components/BottomSheet';
|
||||
import { CategoryPie } from '../../components/charts/CategoryPie';
|
||||
import { CalendarView } from '../../components/CalendarView';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
@ -28,7 +27,8 @@ import { NetWorthChart } from '../../components/charts/NetWorthChart';
|
||||
import { AnnualReport } from '../../components/charts/AnnualReport';
|
||||
import { calculateMonthlyStats, generatePlainTextSummary, generateMonthlySummary } from '../../ai/monthlySummary';
|
||||
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() {
|
||||
const { theme } = useTheme();
|
||||
@ -41,16 +41,12 @@ export default function ReportScreen() {
|
||||
const aiBaseUrl = useSettingsStore(s => s.aiBaseUrl);
|
||||
const aiModel = useSettingsStore(s => s.aiModel);
|
||||
|
||||
// Tab 状态
|
||||
const [activeTab, setActiveTab] = useState<'weekly' | 'monthly' | 'annual'>('monthly');
|
||||
// 周期 + 单一 anchor 日期(P4:替代旧版年/月/周三套独立切换状态)
|
||||
const [period, setPeriod] = useState<ReportPeriod>('monthly');
|
||||
const [anchor, setAnchor] = useState(() => toDateString(new Date()));
|
||||
|
||||
// 月份切换状态
|
||||
const now = new Date();
|
||||
const [viewYear, setViewYear] = useState(now.getFullYear());
|
||||
const [viewMonth, setViewMonth] = useState(now.getMonth() + 1);
|
||||
|
||||
// 周切换状态
|
||||
const [viewWeekDate, setViewWeekDate] = useState(new Date());
|
||||
// ⋯ 菜单
|
||||
const [menuOpen, setMenuOpen] = useState(false);
|
||||
|
||||
// 日历选中日期用于明细展开
|
||||
const [selectedReportDate, setSelectedReportDate] = useState<string | null>(null);
|
||||
@ -64,113 +60,60 @@ export default function ReportScreen() {
|
||||
|
||||
const transactions = useMemo(() => ledger?.transactions ?? [], [ledger]);
|
||||
|
||||
// 周报表的时间范围:计算周一和周日
|
||||
const weeklyRange = useMemo(() => {
|
||||
const d = new Date(viewWeekDate);
|
||||
const day = d.getDay(); // 0 is Sunday, 1-6 is Mon-Sat
|
||||
const diffToMonday = day === 0 ? -6 : 1 - day;
|
||||
|
||||
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);
|
||||
// anchor 所在周期的起止日期与周期内交易
|
||||
const range = useMemo(() => periodRange(anchor, period), [anchor, period]);
|
||||
const rangeTxs = useMemo(
|
||||
() => transactions.filter(tx => tx.date >= range.start && tx.date <= range.end),
|
||||
[transactions, range],
|
||||
);
|
||||
|
||||
return {
|
||||
startStr: monday.toISOString().slice(0, 10),
|
||||
endStr: sunday.toISOString().slice(0, 10),
|
||||
label: `${monday.getFullYear()}年第${weekNum}周 (${monday.getMonth()+1}/${monday.getDate()} ~ ${sunday.getMonth()+1}/${sunday.getDate()})`
|
||||
};
|
||||
}, [viewWeekDate]);
|
||||
// 周期收支统计(Income 腿取负、Expenses 腿原值,与旧 weeklyStats/monthlyStats 同算法)
|
||||
const rangeStats = useMemo(() => {
|
||||
const incomeAmounts: string[] = [];
|
||||
const expenseAmounts: string[] = [];
|
||||
for (const tx of rangeTxs) {
|
||||
for (const p of tx.postings) {
|
||||
if (!p.amount) continue;
|
||||
if (p.account.startsWith('Income')) incomeAmounts.push(negateDecimal(p.amount));
|
||||
if (p.account.startsWith('Expenses')) expenseAmounts.push(p.amount);
|
||||
}
|
||||
}
|
||||
const income = parseFloat(addDecimals(incomeAmounts.length ? incomeAmounts : ['0']));
|
||||
const expense = parseFloat(addDecimals(expenseAmounts.length ? expenseAmounts : ['0']));
|
||||
return { income, expense, net: income - expense, count: rangeTxs.length };
|
||||
}, [rangeTxs]);
|
||||
|
||||
const changeWeek = (days: number) => {
|
||||
setSelectedReportDate(null);
|
||||
const next = new Date(viewWeekDate);
|
||||
next.setDate(next.getDate() + days);
|
||||
setViewWeekDate(next);
|
||||
};
|
||||
// 周期切换器 label
|
||||
const periodLabel = useMemo(() => {
|
||||
if (period === 'weekly') {
|
||||
const monday = range.start;
|
||||
const sunday = range.end;
|
||||
return `${anchor.slice(0, 4)}年第${isoWeekNumber(anchor)}周 (${Number(monday.slice(5, 7))}/${Number(monday.slice(8))} ~ ${Number(sunday.slice(5, 7))}/${Number(sunday.slice(8))})`;
|
||||
}
|
||||
if (period === 'monthly') return anchor.slice(0, 7);
|
||||
return `${anchor.slice(0, 4)}年`;
|
||||
}, [period, anchor, range]);
|
||||
|
||||
// 净资产趋势的日期序列(基于选中月份,往前 6 个月)
|
||||
// 净资产趋势的日期序列(基于 anchor 所在月,往前 6 个月)
|
||||
const netWorthDates = useMemo(() => {
|
||||
const dates: string[] = [];
|
||||
for (let i = 5; i >= 0; i--) {
|
||||
const d = new Date(viewYear, viewMonth - 1 - i, 1);
|
||||
dates.push(d.toISOString().slice(0, 10));
|
||||
const d = new Date(Number(anchor.slice(0, 4)), Number(anchor.slice(5, 7)) - 1 - i, 1);
|
||||
dates.push(toDateString(d));
|
||||
}
|
||||
return dates;
|
||||
}, [viewYear, viewMonth]);
|
||||
}, [anchor]);
|
||||
|
||||
const prevMonth = () => {
|
||||
const shift = (delta: number) => {
|
||||
setSelectedReportDate(null);
|
||||
if (viewMonth === 1) { setViewMonth(12); setViewYear(y => y - 1); }
|
||||
else setViewMonth(m => m - 1);
|
||||
setAnchor(a => shiftAnchor(a, period, delta));
|
||||
};
|
||||
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(() => {
|
||||
if (!selectedReportDate) return [];
|
||||
return monthlyTxs.filter(t => t.date.slice(0, 10) === selectedReportDate);
|
||||
}, [monthlyTxs, selectedReportDate]);
|
||||
return rangeTxs.filter(tx => tx.date.slice(0, 10) === selectedReportDate);
|
||||
}, [rangeTxs, selectedReportDate]);
|
||||
|
||||
// 千分位格式化金额
|
||||
const fmtAmount = (num: number) => {
|
||||
@ -193,9 +136,11 @@ export default function ReportScreen() {
|
||||
}
|
||||
};
|
||||
|
||||
// AI / 纯文本月度总结
|
||||
// AI / 纯文本月度总结(基于 anchor 所在月)
|
||||
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) {
|
||||
try {
|
||||
const provider = new (class extends BaseOpenAIProvider {})({
|
||||
@ -205,7 +150,7 @@ export default function ReportScreen() {
|
||||
baseUrl: aiBaseUrl,
|
||||
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));
|
||||
} catch {
|
||||
setSummaryText(generatePlainTextSummary(stats));
|
||||
@ -216,20 +161,22 @@ export default function ReportScreen() {
|
||||
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 (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary }]}>{t('tab.report')}</Text>
|
||||
<View style={styles.headerActions}>
|
||||
<Pressable onPress={handleMonthlySummary} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
|
||||
<Ionicons name="sparkles-outline" size={22} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
<Pressable onPress={handleExportImage} style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}>
|
||||
<Ionicons name="share-outline" size={22} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
<Pressable
|
||||
onPress={() => setMenuOpen(true)}
|
||||
style={({ pressed }) => [styles.iconBtn, { opacity: pressed ? 0.6 : 1 }]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('report.menu')}
|
||||
>
|
||||
<Ionicons name="ellipsis-horizontal" size={22} color={theme.colors.accent} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* Tab 选择切换栏 */}
|
||||
@ -242,20 +189,20 @@ export default function ReportScreen() {
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => {
|
||||
setActiveTab(tab.key);
|
||||
setPeriod(tab.key);
|
||||
setSelectedReportDate(null);
|
||||
}}
|
||||
style={[
|
||||
styles.tabBtn,
|
||||
activeTab === tab.key && { borderBottomColor: theme.colors.accent }
|
||||
period === tab.key && { borderBottomColor: theme.colors.accent }
|
||||
]}
|
||||
>
|
||||
<Text
|
||||
style={[
|
||||
theme.typography.body,
|
||||
{
|
||||
color: activeTab === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
|
||||
fontWeight: activeTab === tab.key ? '700' : '400',
|
||||
color: period === tab.key ? theme.colors.accent : theme.colors.fgSecondary,
|
||||
fontWeight: period === tab.key ? '700' : '400',
|
||||
paddingVertical: 10
|
||||
}
|
||||
]}
|
||||
@ -266,57 +213,25 @@ export default function ReportScreen() {
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 日期选择切换器 */}
|
||||
{activeTab === 'weekly' && (
|
||||
<View style={styles.monthSwitcher}>
|
||||
<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} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||
{weeklyRange.label}
|
||||
</Text>
|
||||
<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} />
|
||||
</Pressable>
|
||||
</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>
|
||||
)}
|
||||
{/* 统一周期切换器 */}
|
||||
<View style={styles.monthSwitcher}>
|
||||
<Pressable onPress={() => shift(-1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
||||
<Ionicons name="chevron-back" size={18} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>
|
||||
{periodLabel}
|
||||
</Text>
|
||||
<Pressable onPress={() => shift(1)} style={({ pressed }) => [styles.monthBtn, { borderColor: theme.colors.border, opacity: pressed ? 0.6 : 1 }]}>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
<ScrollView
|
||||
ref={scrollRef}
|
||||
contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}
|
||||
>
|
||||
{/* 数据空值判断 */}
|
||||
{((activeTab === 'weekly' && weeklyTxs.length === 0) ||
|
||||
(activeTab === 'monthly' && monthlyTxs.length === 0) ||
|
||||
(activeTab === 'annual' && annualTxs.length === 0)) ? (
|
||||
{rangeTxs.length === 0 ? (
|
||||
<Card title={t('report.title')}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
{t('report.empty')}
|
||||
@ -324,94 +239,94 @@ export default function ReportScreen() {
|
||||
</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.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyIncome')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.income)}</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, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.income)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyExpense')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-weeklyStats.expense)}</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, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(-rangeStats.expense)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.weeklyNet')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(weeklyStats.net)}</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, fontVariant: ['tabular-nums'], fontWeight: '700' }]}>{fmtAmount(rangeStats.net)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<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>
|
||||
</Card>
|
||||
|
||||
<CategoryPie transactions={weeklyTxs} />
|
||||
</>
|
||||
)}
|
||||
<CategoryPie transactions={rangeTxs} />
|
||||
|
||||
{/* 月度报表看板 */}
|
||||
{activeTab === 'monthly' && (
|
||||
<>
|
||||
<Card title={`${monthLabel} 收支报告`}>
|
||||
<View style={styles.statsRow}>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyIncome')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.income, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.income)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyExpense')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.financial.expense, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(-monthlyStats.expense)}</Text>
|
||||
</View>
|
||||
<View style={styles.statItem}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('report.monthlyNet')}</Text>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.accent, fontFamily: 'monospace', fontWeight: '700' }]}>{fmtAmount(monthlyStats.net)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
{t('report.monthlyStats', { count: monthlyStats.count })}
|
||||
</Text>
|
||||
</Card>
|
||||
{/* 月 Tab 专属:日历 + 选中日期明细 + 净资产趋势 */}
|
||||
{period === 'monthly' && (
|
||||
<>
|
||||
<CalendarView
|
||||
transactions={transactions}
|
||||
year={anchorYear}
|
||||
month={anchorMonth}
|
||||
onDayPress={setSelectedReportDate}
|
||||
/>
|
||||
|
||||
<CategoryPie transactions={monthlyTxs} />
|
||||
|
||||
<CalendarView
|
||||
transactions={transactions}
|
||||
year={viewYear}
|
||||
month={viewMonth}
|
||||
onDayPress={setSelectedReportDate}
|
||||
/>
|
||||
|
||||
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
|
||||
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
|
||||
{selectedDayTxs.map(t => (
|
||||
<TransactionCard
|
||||
key={t.id}
|
||||
transaction={t}
|
||||
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>
|
||||
{selectedReportDate && selectedReportDate.startsWith(monthLabel) && (
|
||||
<Card title={`${selectedReportDate} 交易明细 (${selectedDayTxs.length})`}>
|
||||
{selectedDayTxs.map(tx => (
|
||||
<TransactionCard
|
||||
key={tx.id}
|
||||
transaction={tx}
|
||||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||
/>
|
||||
))}
|
||||
{selectedDayTxs.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginVertical: 8 }]}>
|
||||
{t('calendar.noDayTx')}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||||
<NetWorthChart transactions={transactions} dates={netWorthDates} />
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 年度报表看板 */}
|
||||
{activeTab === 'annual' && (
|
||||
<AnnualReport transactions={transactions} year={viewYear} />
|
||||
{period === 'annual' && (
|
||||
<AnnualReport transactions={transactions} year={anchorYear} />
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</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 总结弹窗 */}
|
||||
<Modal visible={summaryModal} animationType="slide" transparent onRequestClose={() => setSummaryModal(false)}>
|
||||
<View style={commonStyles.modalOverlay}>
|
||||
@ -437,7 +352,6 @@ export default function ReportScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 8 },
|
||||
headerActions: { flexDirection: 'row', gap: 16 },
|
||||
iconBtn: { padding: 4 },
|
||||
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' },
|
||||
@ -446,5 +360,5 @@ const styles = StyleSheet.create({
|
||||
tabBtn: { flex: 1, alignItems: 'center', borderBottomWidth: 2, borderBottomColor: 'transparent' },
|
||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around', marginVertical: 8, width: '100%' },
|
||||
statItem: { alignItems: 'center', gap: 4 },
|
||||
menuRow: { flexDirection: 'row', alignItems: 'center', gap: 12, paddingVertical: 14 },
|
||||
});
|
||||
|
||||
|
||||
@ -26,7 +26,7 @@ export default function SettingsScreen() {
|
||||
]}
|
||||
>
|
||||
<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}
|
||||
</Text>
|
||||
<Ionicons name="chevron-forward" size={18} color={theme.colors.fgSecondary} />
|
||||
@ -36,32 +36,46 @@ export default function SettingsScreen() {
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<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>
|
||||
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 数据与基础配置跳转 */}
|
||||
<Card title={t('settings.dataManagement')}>
|
||||
{/* 账户与分类 */}
|
||||
<Card title={t('settings.groupAccount')}>
|
||||
<View style={styles.listGroup}>
|
||||
{renderNavLink('git-branch-outline', t('tab.rules'), '/rules')}
|
||||
{renderNavLink('wallet-outline', t('account.title'), '/account')}
|
||||
{renderNavLink('list', t('settings.categories'), '/category')}
|
||||
{renderNavLink('pricetags', t('settings.tags'), '/tag')}
|
||||
{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('text-outline', t('remark.title'), '/remark-template' as Href)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 系统及高级配置跳转 */}
|
||||
<Card title={t('settings.systemFeaturesTitle')}>
|
||||
{/* 记账自动化 */}
|
||||
<Card title={t('settings.groupAutomation')}>
|
||||
<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('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>
|
||||
</Card>
|
||||
|
||||
|
||||
@ -1,71 +1,104 @@
|
||||
/**
|
||||
* 交易列表页(plan.md「1.4 交易搜索页面」)。
|
||||
*
|
||||
* 功能:
|
||||
* - SearchBar 关键字搜索(narration/payee)
|
||||
* - 方向筛选(全部/支出/收入/转账)
|
||||
* - useSearch 多维筛选
|
||||
* - TransactionCard 渲染 + 点击跳详情
|
||||
* - 底部折叠的解析诊断
|
||||
* 交易页(spec §7.2):搜索优先 + 日期分组时间线。
|
||||
* 搜索框常驻;方向 chip;高级筛选收进 FilterSheet(账户/日期/金额区间);
|
||||
* 列表按日期分组(今天/昨天/具体日期 + 当日收支小计);左滑复制/删除。
|
||||
* 解析诊断已移至 我的 → 数据 → 解析诊断(/settings/diagnostics)。
|
||||
*/
|
||||
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 { useRouter } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
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 { SearchBar } from '../../components/SearchBar';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { FilterSheet, type AdvancedFilterValues } from '../../components/FilterSheet';
|
||||
import { SwipeableTransactionCard } from '../../components/SwipeableTransactionCard';
|
||||
import { groupTransactionsByDate } from '../../components/transactionGroups';
|
||||
import { useSearch, type SearchFilters } from '../../hooks/useSearch';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
|
||||
type DirectionFilter = 'all' | 'expense' | 'income' | 'transfer';
|
||||
|
||||
const EMPTY_FILTERS: AdvancedFilterValues = { account: '', dateFrom: '', dateTo: '', amountMin: '', amountMax: '' };
|
||||
|
||||
export default function TransactionsScreen() {
|
||||
const { theme } = useTheme();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const deleteTransaction = useLedgerStore(s => s.deleteTransaction);
|
||||
const categories = useMetadataStore(s => s.categories);
|
||||
|
||||
const [keyword, setKeyword] = useState('');
|
||||
const [direction, setDirection] = useState<DirectionFilter>('all');
|
||||
const [showDiagnostics, setShowDiagnostics] = useState(false);
|
||||
const [showAdvancedFilters, setShowAdvancedFilters] = useState(false);
|
||||
const [accountFilter, setAccountFilter] = useState('');
|
||||
const [dateFrom, setDateFrom] = useState('');
|
||||
const [dateTo, setDateTo] = useState('');
|
||||
const [filterSheetOpen, setFilterSheetOpen] = useState(false);
|
||||
const [advanced, setAdvanced] = useState<AdvancedFilterValues>(EMPTY_FILTERS);
|
||||
|
||||
const allTransactions = useMemo(() => {
|
||||
if (!ledger?.transactions) return [];
|
||||
return ledger.transactions
|
||||
.map((tx, idx) => ({ tx, idx }))
|
||||
.sort((a, b) => {
|
||||
const dateCompare = b.tx.date.localeCompare(a.tx.date);
|
||||
if (dateCompare !== 0) return dateCompare;
|
||||
return b.idx - a.idx;
|
||||
})
|
||||
.sort((a, b) => b.tx.date.localeCompare(a.tx.date) || b.idx - a.idx)
|
||||
.map(item => item.tx);
|
||||
}, [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(() => ({
|
||||
keyword: keyword || undefined,
|
||||
direction: direction === 'all' ? undefined : direction,
|
||||
account: accountFilter || undefined,
|
||||
dateFrom: dateFrom || undefined,
|
||||
dateTo: dateTo || undefined,
|
||||
}), [keyword, direction, accountFilter, dateFrom, dateTo]);
|
||||
account: advanced.account || undefined,
|
||||
dateFrom: advanced.dateFrom || undefined,
|
||||
dateTo: advanced.dateTo || undefined,
|
||||
amountMin: advanced.amountMin || undefined,
|
||||
amountMax: advanced.amountMax || undefined,
|
||||
}), [keyword, direction, advanced]);
|
||||
|
||||
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 }[] = [
|
||||
{ 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>
|
||||
</View>
|
||||
|
||||
<SearchBar
|
||||
value={keyword}
|
||||
onChangeText={setKeyword}
|
||||
placeholder={t('transactions.searchPlaceholder')}
|
||||
/>
|
||||
<SearchBar value={keyword} onChangeText={setKeyword} placeholder={t('transactions.searchPlaceholder')} />
|
||||
|
||||
{/* 方向筛选 */}
|
||||
{/* 方向筛选 + 高级筛选入口 */}
|
||||
<View style={styles.filterRow}>
|
||||
{directionTabs.map(tab => (
|
||||
<Pressable
|
||||
key={tab.key}
|
||||
onPress={() => setDirection(tab.key)}
|
||||
style={[
|
||||
commonStyles.chip,
|
||||
direction === tab.key && commonStyles.chipActive,
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
commonStyles.chipText,
|
||||
direction === tab.key && commonStyles.chipTextActive,
|
||||
]}>
|
||||
{tab.label}
|
||||
</Text>
|
||||
<Pressable key={tab.key} onPress={() => setDirection(tab.key)}
|
||||
style={[commonStyles.chip, direction === tab.key && commonStyles.chipActive]}>
|
||||
<Text style={[commonStyles.chipText, direction === tab.key && commonStyles.chipTextActive]}>{tab.label}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
|
||||
{/* 高级筛选切换 */}
|
||||
<View style={[styles.filterRow, { paddingBottom: 0 }]}>
|
||||
<View style={styles.flex1} />
|
||||
<Pressable
|
||||
onPress={() => setShowAdvancedFilters(!showAdvancedFilters)}
|
||||
style={({ pressed }) => [
|
||||
commonStyles.chip,
|
||||
showAdvancedFilters && commonStyles.chipActive,
|
||||
{ opacity: pressed ? 0.8 : 1 },
|
||||
]}
|
||||
onPress={() => setFilterSheetOpen(true)}
|
||||
style={[commonStyles.chip, advancedActive && commonStyles.chipActive, styles.filterBtn]}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={t('transactions.filter')}
|
||||
>
|
||||
<Text style={[
|
||||
commonStyles.chipText,
|
||||
showAdvancedFilters && commonStyles.chipTextActive,
|
||||
{ fontSize: 12 },
|
||||
]}>
|
||||
{t('transactions.advancedFilter')}
|
||||
</Text>
|
||||
<Ionicons name="options-outline" size={14} color={advancedActive ? theme.colors.fgInverse : theme.colors.fgSecondary} />
|
||||
<Text style={[commonStyles.chipText, advancedActive && commonStyles.chipTextActive]}>{t('transactions.filter')}</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
|
||||
{/* 高级筛选面板 */}
|
||||
{showAdvancedFilters && (
|
||||
<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}
|
||||
<SectionList
|
||||
sections={sections}
|
||||
keyExtractor={(item) => item.id}
|
||||
renderItem={({ item: tx }) => (
|
||||
<TransactionCard
|
||||
<SwipeableTransactionCard
|
||||
transaction={tx}
|
||||
categoryId={categoryIdFor(tx)}
|
||||
showDate={false}
|
||||
onPress={() => router.push(`/transaction/${tx.id}`)}
|
||||
onDuplicate={handleDuplicate}
|
||||
onDelete={handleDelete}
|
||||
/>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<View>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, paddingHorizontal: 16, paddingBottom: 8 }]}>
|
||||
{filtered.length === allTransactions.length
|
||||
? t('transactions.countTotal', { count: filtered.length })
|
||||
: t('transactions.countFiltered', { shown: filtered.length, total: allTransactions.length })}
|
||||
renderSectionHeader={({ section }) => (
|
||||
<View style={styles.sectionHeader}>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||
{dateLabel(section.date)}
|
||||
</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
|
||||
{t('transactions.daySummary', { income: `+${section.income}`, expense: `-${section.expense}` })}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
ListHeaderComponent={
|
||||
<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={
|
||||
<Card title={t('transactions.noMatchTitle')}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
{allTransactions.length === 0
|
||||
? t('transactions.noMatchEmpty')
|
||||
: t('transactions.noMatchFiltered')}
|
||||
{allTransactions.length === 0 ? t('transactions.noMatchEmpty') : t('transactions.noMatchFiltered')}
|
||||
</Text>
|
||||
</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}
|
||||
stickySectionHeadersEnabled={false}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
/>
|
||||
|
||||
<FilterSheet
|
||||
visible={filterSheetOpen}
|
||||
onClose={() => setFilterSheetOpen(false)}
|
||||
values={advanced}
|
||||
onChange={setAdvanced}
|
||||
onReset={() => setAdvanced(EMPTY_FILTERS)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
@ -231,7 +193,9 @@ export default function TransactionsScreen() {
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
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 },
|
||||
diagToggle: { paddingVertical: 12, alignItems: 'center' },
|
||||
sectionHeader: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center', paddingVertical: 8 },
|
||||
});
|
||||
|
||||
@ -67,6 +67,7 @@ export function ErrorScreen({ error, resetError }: ErrorBoundaryProps) {
|
||||
</Text>
|
||||
{data.stack && (
|
||||
<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}>
|
||||
{data.stack}
|
||||
</Text>
|
||||
|
||||
@ -3,6 +3,7 @@ import { StatusBar } from 'expo-status-bar';
|
||||
import { Stack } from 'expo-router';
|
||||
import { AppState, DeviceEventEmitter, Platform, Text, View } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { GestureHandlerRootView } from 'react-native-gesture-handler';
|
||||
import { ThemeProvider, useTheme } from '../theme';
|
||||
import { useLedgerStore } from '../store/ledgerStore';
|
||||
import { useImportStore } from '../store/importStore';
|
||||
@ -13,6 +14,7 @@ import { useAutomationStore } from '../store/automationStore';
|
||||
import { FileSystemBackend } from '../services/fileSystemBackend';
|
||||
import { useT } from '../i18n';
|
||||
import { LockScreen } from '../components/LockScreen';
|
||||
import { NumpadSheetHost } from '../components/NumpadSheetHost';
|
||||
import { OnboardingScreen } from './_onboarding';
|
||||
import { setupDeepLinking } from '../services/deepLink';
|
||||
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 { getAccessibilityBridge } from '../services/accessibilityBridge';
|
||||
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 lastProcessedTime = 0;
|
||||
@ -67,16 +56,6 @@ function AppShell() {
|
||||
const t = useT();
|
||||
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 setContext = useImportStore(s => s.setContext);
|
||||
const appLockEnabled = useSettingsStore(s => s.appLockEnabled);
|
||||
@ -367,7 +346,7 @@ function AppShell() {
|
||||
}, [phase, checkAndReloadLedger]);
|
||||
|
||||
// loading 状态
|
||||
if (phase === 'loading' || (!fontsLoaded && !fontError)) {
|
||||
if (phase === 'loading') {
|
||||
return (
|
||||
<SafeAreaView style={{ flex: 1, backgroundColor: theme.colors.bgPrimary, alignItems: 'center', justifyContent: 'center' }}>
|
||||
<Text style={{ color: theme.colors.fgSecondary }}>{t('app.name')}</Text>
|
||||
@ -398,7 +377,7 @@ function AppShell() {
|
||||
<StatusBar style={isDark ? 'light' : 'dark'} />
|
||||
<Stack screenOptions={{ headerShown: false, contentStyle: { backgroundColor: theme.colors.bgPrimary } }}>
|
||||
<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="category/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/sync" 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="ai/chat" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="remark-template/index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="automation/index" options={{ headerShown: false }} />
|
||||
<Stack.Screen name="credit-card/index" options={{ headerShown: false }} />
|
||||
</Stack>
|
||||
<NumpadSheetHost />
|
||||
{/* 隐私遮罩(plan.md「0.4」):App 切后台时遮盖内容并拦截所有手势 */}
|
||||
{privacyOverlay && (
|
||||
<View
|
||||
@ -427,11 +408,13 @@ function AppShell() {
|
||||
);
|
||||
}
|
||||
|
||||
/** 根布局:包裹 ThemeProvider。 */
|
||||
/** 根布局:GestureHandlerRootView(左滑手势)包裹 ThemeProvider。 */
|
||||
export default function RootLayout() {
|
||||
return (
|
||||
<ThemeProvider>
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
<GestureHandlerRootView style={{ flex: 1 }}>
|
||||
<ThemeProvider>
|
||||
<AppShell />
|
||||
</ThemeProvider>
|
||||
</GestureHandlerRootView>
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,14 +1,14 @@
|
||||
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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
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';
|
||||
|
||||
type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||
@ -16,7 +16,6 @@ type TabType = 'assets' | 'liabilities' | 'expenses' | 'income';
|
||||
export default function AccountScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||
@ -111,7 +110,7 @@ export default function AccountScreen() {
|
||||
const handleConfirmAdjust = async (values: Record<string, string>) => {
|
||||
if (!adjustingAccount) return;
|
||||
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) {
|
||||
Alert.alert(t('account.adjustFail'), t('account.adjustFailEmpty'));
|
||||
@ -157,15 +156,7 @@ export default function AccountScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
{/* 头部导航 */}
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('account.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('account.title')} />
|
||||
|
||||
{/* Tab 选项卡 */}
|
||||
<View style={[styles.tabBar, { borderBottomColor: theme.colors.border }]}>
|
||||
@ -289,7 +280,7 @@ export default function AccountScreen() {
|
||||
key: 'date',
|
||||
label: t('account.fieldDate'),
|
||||
placeholder: 'YYYY-MM-DD',
|
||||
defaultValue: new Date().toISOString().slice(0, 10),
|
||||
defaultValue: toDateString(new Date()),
|
||||
},
|
||||
]}
|
||||
onConfirm={handleConfirmAdjust}
|
||||
@ -301,7 +292,6 @@ export default function AccountScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
tabBar: { flexDirection: 'row', borderBottomWidth: 1, paddingHorizontal: 8 },
|
||||
tabItem: { flex: 1, alignItems: 'center', paddingVertical: 12 },
|
||||
content: { padding: 16, gap: 12 },
|
||||
|
||||
@ -19,6 +19,7 @@ import { useT } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
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 { ScreenHeader } from '../../components/ScreenHeader';
|
||||
|
||||
interface UiMessage {
|
||||
role: 'user' | 'assistant';
|
||||
@ -113,26 +114,33 @@ export default function AIChatScreen() {
|
||||
{item.content}
|
||||
</Text>
|
||||
{/* 账单卡片 */}
|
||||
{item.billCards && item.billCards.map((card, i) => (
|
||||
{item.billCards && item.billCards.map((card, i) => {
|
||||
// 方向符号 + 金额上色,与全局方向色约定一致(TransactionCard:income 绿 / 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
|
||||
key={i}
|
||||
onPress={() => router.push({ pathname: '/transaction/new', params: { mode: 'ocr' } })}
|
||||
style={[styles.billCard, { backgroundColor: theme.colors.bgPrimary, borderColor: theme.colors.accent }]}
|
||||
>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary, fontWeight: '700' }]}>
|
||||
{card.type === 'income' ? '💰' : card.type === 'transfer' ? '🔄' : '💸'} {card.amount} {card.currency}
|
||||
<Text style={[theme.typography.bodySmall, { color: dirColor, fontWeight: '700' }]}>
|
||||
{dirSign} {card.amount} {card.currency}
|
||||
</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>
|
||||
{card.counterparty} · {card.narration}
|
||||
</Text>
|
||||
<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')}
|
||||
</Text>
|
||||
<Ionicons name="arrow-forward" size={12} color={theme.colors.accent} />
|
||||
</View>
|
||||
</Pressable>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
@ -140,14 +148,7 @@ export default function AIChatScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={[styles.header, { borderBottomColor: theme.colors.border }]}>
|
||||
<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>
|
||||
<ScreenHeader title={t('ai.chatTitle')} />
|
||||
|
||||
<FlatList
|
||||
ref={listRef}
|
||||
@ -192,7 +193,6 @@ export default function AIChatScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12, borderBottomWidth: 1 },
|
||||
msgRow: { flexDirection: 'row', maxWidth: '100%' },
|
||||
bubble: { maxWidth: '85%', borderRadius: 12, padding: 12, borderWidth: 1 },
|
||||
billCard: { marginTop: 8, padding: 10, borderRadius: 8, borderWidth: 1 },
|
||||
|
||||
@ -18,7 +18,6 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, AppState, FlatList, Linking, NativeModules, Platform, Pressable, StyleSheet, Text, View, Switch } 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 { useT } from '../../i18n';
|
||||
@ -28,6 +27,7 @@ import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import type { AutomationSource } from '../../store/automationStore';
|
||||
import {
|
||||
getAccessibilityBridge,
|
||||
@ -46,7 +46,6 @@ const SOURCE_LABELS: Record<AutomationSource, string> = {
|
||||
export default function AutomationScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const detected = useAutomationStore(s => s.detected);
|
||||
const drafts = useAutomationStore(s => s.drafts);
|
||||
@ -250,14 +249,7 @@ export default function AutomationScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('automation.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('automation.title')} />
|
||||
|
||||
<FlatList
|
||||
data={drafts}
|
||||
@ -444,7 +436,6 @@ export default function AutomationScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, paddingBottom: 64 },
|
||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||
statItem: { alignItems: 'center', gap: 4 },
|
||||
|
||||
@ -3,27 +3,23 @@
|
||||
*
|
||||
* 功能:预算列表(含进度条) + 添加/编辑/删除。
|
||||
* 进度计算调用 calculateBudgetProgress 纯函数,展示已用/剩余/百分比。
|
||||
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||
*/
|
||||
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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { Alert, Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { calculateBudgetProgress } from '../../domain/budgets';
|
||||
import { toDateString } from '../../domain/decimal';
|
||||
import type { Budget } from '../../domain/budgets';
|
||||
|
||||
type ModalMode = { type: 'add' } | { type: 'edit'; budget: Budget } | null;
|
||||
|
||||
export default function BudgetScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const budgets = useMetadataStore(s => s.budgets);
|
||||
const addBudget = useMetadataStore(s => s.addBudget);
|
||||
@ -31,130 +27,94 @@ export default function BudgetScreen() {
|
||||
const removeBudget = useMetadataStore(s => s.removeBudget);
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
|
||||
const [modal, setModal] = useState<ModalMode>(null);
|
||||
|
||||
const transactions = ledger?.transactions ?? [];
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
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);
|
||||
};
|
||||
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
|
||||
const today = toDateString(new Date());
|
||||
|
||||
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 (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('budget.title')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('budget.add')}</Text>
|
||||
</Pressable>
|
||||
{budgets.map(budget => {
|
||||
const progress = calculateBudgetProgress(budget, transactions, today);
|
||||
return (
|
||||
<Pressable
|
||||
key={budget.id}
|
||||
onPress={() => setModal({ type: 'edit', budget })}
|
||||
onLongPress={() => handleDelete(budget)}
|
||||
>
|
||||
<Card title={budget.name}>
|
||||
<View style={styles.budgetRow}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
|
||||
</Text>
|
||||
<Text style={[theme.typography.caption, {
|
||||
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
|
||||
}]}>
|
||||
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 进度条 */}
|
||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||
<View style={[
|
||||
styles.bar,
|
||||
{
|
||||
width: `${Math.min(progress.percentage, 100)}%`,
|
||||
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
|
||||
},
|
||||
]} />
|
||||
</View>
|
||||
{budget.categoryAccount && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
||||
{budget.categoryAccount}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{budgets.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
||||
{t('budget.empty')}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
<ManagementScreen<Budget>
|
||||
title={t('budget.title')}
|
||||
items={budgets}
|
||||
keyExtractor={budget => budget.id}
|
||||
addLabel={t('budget.add')}
|
||||
emptyText={t('budget.empty')}
|
||||
renderItem={(budget, { openEdit, confirmDelete }) => {
|
||||
const progress = calculateBudgetProgress(budget, transactions, today);
|
||||
return (
|
||||
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
||||
<Card title={budget.name}>
|
||||
<View style={styles.budgetRow}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary }]}>
|
||||
{t('budget.yuanPer', { amount: budget.amount, period: periodLabel(budget.period) })}
|
||||
</Text>
|
||||
<Text style={[theme.typography.caption, {
|
||||
color: progress.overBudget ? theme.colors.error : theme.colors.fgSecondary,
|
||||
}]}>
|
||||
{t('budget.used', { spent: progress.spent, pct: progress.percentage.toFixed(0) })}
|
||||
</Text>
|
||||
</View>
|
||||
{/* 进度条 */}
|
||||
<View style={[styles.barWrap, { backgroundColor: theme.colors.bgTertiary }]}>
|
||||
<View style={[
|
||||
styles.bar,
|
||||
{
|
||||
width: `${Math.min(progress.percentage, 100)}%`,
|
||||
backgroundColor: progress.overBudget ? theme.colors.error : theme.colors.accent,
|
||||
},
|
||||
]} />
|
||||
</View>
|
||||
{budget.categoryAccount && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 4 }]}>
|
||||
{budget.categoryAccount}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
}}
|
||||
formTitle={editing => (editing ? t('budget.editTitle') : t('budget.add'))}
|
||||
formFields={editing => [
|
||||
{ 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' },
|
||||
{ key: 'period', label: t('budget.fieldPeriod'), placeholder: 'monthly', defaultValue: editing?.period },
|
||||
{ key: 'categoryAccount', label: t('budget.fieldCategoryAccount'), placeholder: t('budget.fieldCategoryPlaceholder'), defaultValue: editing?.categoryAccount ?? '' },
|
||||
{ key: 'startDate', label: t('budget.fieldStartDate'), placeholder: '2026-01-01', defaultValue: editing?.startDate },
|
||||
]}
|
||||
onSubmit={(values, editing) => {
|
||||
const name = values.name?.trim() || t('budget.unnamed');
|
||||
const amount = values.amount?.trim() || '0';
|
||||
const period = (values.period as Budget['period']) || 'monthly';
|
||||
const categoryAccount = values.categoryAccount?.trim() || undefined;
|
||||
const startDate = values.startDate?.trim() || today;
|
||||
|
||||
const amtNum = parseFloat(amount);
|
||||
if (isNaN(amtNum) || amtNum <= 0) {
|
||||
Alert.alert(t('budget.invalidAmountTitle'), t('budget.invalidAmountDesc'));
|
||||
return false;
|
||||
}
|
||||
|
||||
if (editing) {
|
||||
updateBudget(editing.id, { name, amount, period, categoryAccount, startDate });
|
||||
} else {
|
||||
addBudget({ id: generateId('bud'), name, amount, period, categoryAccount, startDate });
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onDelete={budget => removeBudget(budget.id)}
|
||||
deleteConfirmText={budget => t('budget.deleteConfirm', { name: budget.name })}
|
||||
deleteConfirmTitle={t('budget.deleteTitle')}
|
||||
footer={
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</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({
|
||||
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' },
|
||||
barWrap: { height: 8, borderRadius: 4, overflow: 'hidden', marginTop: 8 },
|
||||
bar: { height: '100%', borderRadius: 4 },
|
||||
|
||||
@ -1,136 +1,107 @@
|
||||
/**
|
||||
/**
|
||||
* 分类管理页面(plan.md「1.1 category/index」+ 决策 1 双轨制)。
|
||||
*
|
||||
* 功能:分类列表(支出/收入分组)+ 添加/编辑/删除。
|
||||
* 功能:分类列表(支出/收入 chips 切换)+ 添加/编辑/删除。
|
||||
* 数据来源:metadataStore(持久化),linkedAccount 映射到 Beancount 账户。
|
||||
* P5:套 ManagementScreen 模板,headerContent 放支出/收入 chips。
|
||||
*/
|
||||
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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import React, { useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import type { Category } from '../../domain/categories';
|
||||
|
||||
type ModalMode = { type: 'add'; catType: 'expense' | 'income' } | { type: 'edit'; category: Category } | null;
|
||||
|
||||
export default function CategoryScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
|
||||
const categories = useMetadataStore(s => s.categories);
|
||||
const addCategory = useMetadataStore(s => s.addCategory);
|
||||
const updateCategory = useMetadataStore(s => s.updateCategory);
|
||||
const removeCategory = useMetadataStore(s => s.removeCategory);
|
||||
|
||||
const [modal, setModal] = useState<ModalMode>(null);
|
||||
|
||||
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(', ') },
|
||||
] : [];
|
||||
/** 当前展示的分类类型(支出/收入),新增分支按此落库。 */
|
||||
const [catType, setCatType] = useState<'expense' | 'income'>('expense');
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('category.title')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Card title={t('category.expense')}>
|
||||
<Pressable onPress={() => setModal({ type: 'add', catType: 'expense' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addExpense')}</Text>
|
||||
</Pressable>
|
||||
{expenseCats.map(renderRow)}
|
||||
</Card>
|
||||
<Card title={t('category.income')}>
|
||||
<Pressable onPress={() => setModal({ type: 'add', catType: 'income' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('category.addIncome')}</Text>
|
||||
</Pressable>
|
||||
{incomeCats.map(renderRow)}
|
||||
</Card>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
<ManagementScreen<Category>
|
||||
title={t('category.title')}
|
||||
items={categories.filter(c => c.type === catType)}
|
||||
keyExtractor={cat => cat.id}
|
||||
addLabel={t('category.addTitle', { type: catType === 'income' ? t('category.income') : t('category.expense') })}
|
||||
headerContent={
|
||||
<View style={styles.chipRow}>
|
||||
{(['expense', 'income'] as const).map(tp => (
|
||||
<Pressable
|
||||
key={tp}
|
||||
onPress={() => setCatType(tp)}
|
||||
style={[commonStyles.chip, catType === tp && commonStyles.chipActive]}
|
||||
>
|
||||
<Text style={[commonStyles.chipText, catType === tp && commonStyles.chipTextActive]}>
|
||||
{tp === 'expense' ? t('category.expense') : t('category.income')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
</View>
|
||||
}
|
||||
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')}
|
||||
</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({
|
||||
page: { flex: 1 },
|
||||
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 },
|
||||
chipRow: { flexDirection: 'row', gap: 8 },
|
||||
});
|
||||
|
||||
|
||||
@ -1,29 +1,24 @@
|
||||
/**
|
||||
* 信用卡管理页面(plan.md「1.1 credit-card/index」+ 决策 1 双轨制)。
|
||||
*
|
||||
* 功能:信用卡列表(银行/尾号/账单日/还款日/额度) + 添加/编辑/删除。
|
||||
* 功能:信用卡列表(银行/尾号/账单日/还款日/额度/账单盒) + 添加/编辑/删除。
|
||||
* linkedAccount 映射到 Beancount 的 Liabilities 账户。
|
||||
* P5:套 ManagementScreen 模板,富 renderItem 直套。
|
||||
*/
|
||||
import React, { useMemo, 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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React, { useMemo } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import type { CreditCard } from '../../domain/creditCards';
|
||||
import { calculateBillingPeriod, calculateStatementAmount, calculateAvailableCredit } from '../../domain/creditCards';
|
||||
|
||||
type ModalMode = { type: 'add' } | { type: 'edit'; card: CreditCard } | null;
|
||||
|
||||
export default function CreditCardScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const creditCards = useMetadataStore(s => s.creditCards);
|
||||
const addCreditCard = useMetadataStore(s => s.addCreditCard);
|
||||
@ -33,8 +28,6 @@ export default function CreditCardScreen() {
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const transactions = ledger?.transactions ?? [];
|
||||
|
||||
const [modal, setModal] = useState<ModalMode>(null);
|
||||
|
||||
/** 预计算所有账户余额 Map(避免在渲染循环中重复遍历)。 */
|
||||
const accountBalances = useMemo(() => {
|
||||
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 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 (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('creditCard.title')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('creditCard.add')}</Text>
|
||||
</Pressable>
|
||||
{creditCards.map(card => {
|
||||
const period = calculateBillingPeriod(card, new Date());
|
||||
const statementAmount = calculateStatementAmount(transactions, card, period);
|
||||
const currentBalance = getAccountBalance(card.linkedAccount);
|
||||
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
||||
return (
|
||||
<Pressable
|
||||
key={card.id}
|
||||
onPress={() => setModal({ type: 'edit', card })}
|
||||
onLongPress={() => handleDelete(card)}
|
||||
>
|
||||
<Card title={card.name}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<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>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<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>
|
||||
<ManagementScreen<CreditCard>
|
||||
title={t('creditCard.title')}
|
||||
items={creditCards}
|
||||
keyExtractor={card => card.id}
|
||||
addLabel={t('creditCard.add')}
|
||||
emptyText={t('creditCard.empty')}
|
||||
renderItem={(card, { openEdit, confirmDelete }) => {
|
||||
const period = calculateBillingPeriod(card, new Date());
|
||||
const statementAmount = calculateStatementAmount(transactions, card, period);
|
||||
const currentBalance = getAccountBalance(card.linkedAccount);
|
||||
const availableCredit = calculateAvailableCredit(card, currentBalance);
|
||||
return (
|
||||
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
||||
<Card title={card.name}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('creditCard.labelBank')}</Text>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgPrimary }]}>{card.bankName} ({card.lastFour})</Text>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<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>
|
||||
</View>
|
||||
<View style={styles.infoRow}>
|
||||
<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.infoRow}>
|
||||
<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>
|
||||
</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 style={[styles.billingBox, { borderColor: theme.colors.divider }]}>
|
||||
<View style={styles.infoRow}>
|
||||
<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>
|
||||
</View>
|
||||
</Card>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
{creditCards.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
||||
{t('creditCard.empty')}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
<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>
|
||||
</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')}
|
||||
</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({
|
||||
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 },
|
||||
billingBox: { marginTop: 8, padding: 8, borderRadius: 6, borderWidth: 1 },
|
||||
});
|
||||
|
||||
@ -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 { 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 FileSystem from 'expo-file-system/legacy';
|
||||
import * as XLSX from 'xlsx';
|
||||
|
||||
// 直接实例化 GBK 解码器,不修改全局 TextDecoder/TextEncoder
|
||||
// 实例化纯 JS GBK 解码器(Hermes 原生 TextDecoder 仅支持 UTF-8,需强迫 text-encoding-gbk 返回其纯 JS 实现)
|
||||
const GbkTextDecoder = (() => {
|
||||
const origDecoder = (globalThis as any).TextDecoder;
|
||||
const origEncoder = (globalThis as any).TextEncoder;
|
||||
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 {
|
||||
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 { Button } from '../../components/Button';
|
||||
import { DedupBanner } from '../../components/DedupBanner';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { classifyWithCategories } from '../../domain/rules';
|
||||
import { validateTransaction } from '../../domain/ledger';
|
||||
import type { ImportedEvent } from '../../domain/types';
|
||||
@ -60,7 +68,6 @@ function base64ToBytes(base64: string): Uint8Array {
|
||||
export default function ImportScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
const ledger = useLedgerStore(s => s.ledger);
|
||||
const addTransaction = useLedgerStore(s => s.addTransaction);
|
||||
const autoOpenAccounts = useLedgerStore(s => s.autoOpenAccounts);
|
||||
@ -237,10 +244,8 @@ export default function ImportScreen() {
|
||||
// 从待确认中批量移除
|
||||
confirmDrafts(indices);
|
||||
|
||||
// 重新读取最新的 pendingDrafts 计算失败数(避免闭包过时)
|
||||
const latestDrafts = useImportStore.getState().pendingDrafts;
|
||||
const successCount = drafts.length;
|
||||
const failCount = latestDrafts.length - successCount;
|
||||
const failCount = failedPayees.length;
|
||||
|
||||
if (failCount === 0) {
|
||||
setStatus(t('importFlow.batchAllSuccess', { count: successCount }));
|
||||
@ -279,12 +284,7 @@ export default function ImportScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.import')}</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('tab.import')} />
|
||||
<FlatList
|
||||
data={pendingDrafts}
|
||||
keyExtractor={(item, index) => `${item.draft.sourceEventIds?.[0] || index}-${index}`}
|
||||
@ -385,10 +385,9 @@ export default function ImportScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, paddingBottom: 64 },
|
||||
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 },
|
||||
duplicatesList: { gap: 12, marginTop: 8 },
|
||||
});
|
||||
|
||||
@ -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 { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
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';
|
||||
|
||||
type ModalMode = { type: 'add' } | { type: 'edit'; item: RecurringTransaction } | null;
|
||||
|
||||
export default function RecurringScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const recurringTransactions = useMetadataStore(s => s.recurringTransactions);
|
||||
const addRecurring = useMetadataStore(s => s.addRecurringTransaction);
|
||||
const updateRecurring = useMetadataStore(s => s.updateRecurringTransaction);
|
||||
const removeRecurring = useMetadataStore(s => s.removeRecurringTransaction);
|
||||
|
||||
const [modal, setModal] = useState<ModalMode>(null);
|
||||
|
||||
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) },
|
||||
];
|
||||
// 用本地时区格式化,避免 toISOString 的 UTC 偏移导致负时区日期错位
|
||||
const today = toDateString(new Date());
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('recurring.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<ManagementScreen<RecurringTransaction>
|
||||
title={t('recurring.title')}
|
||||
items={recurringTransactions}
|
||||
keyExtractor={item => item.id}
|
||||
addLabel={t('recurring.add')}
|
||||
emptyText={t('recurring.empty')}
|
||||
renderItem={(item, { openEdit, confirmDelete }) => {
|
||||
const amount = item.draft.postings[1]?.amount || '0.00';
|
||||
const fromAcc = item.draft.postings[0]?.account || '';
|
||||
const toAcc = item.draft.postings[1]?.account || '';
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>
|
||||
{t('recurring.add')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
return (
|
||||
<Card title={item.name}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
|
||||
<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>
|
||||
</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';
|
||||
const fromAcc = item.draft.postings[0]?.account || '';
|
||||
const toAcc = item.draft.postings[1]?.account || '';
|
||||
|
||||
return (
|
||||
<Card key={item.id} title={item.name}>
|
||||
<View style={styles.infoRow}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('recurring.frequency')}</Text>
|
||||
<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')}
|
||||
{/* 显式编辑/删除按钮(本页不用长按手势,交互更显式) */}
|
||||
<View style={styles.cardActions}>
|
||||
<Pressable
|
||||
onPress={openEdit}
|
||||
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>
|
||||
</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()}
|
||||
</Pressable>
|
||||
<Pressable
|
||||
onPress={confirmDelete}
|
||||
style={[styles.actionBtn, { 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>
|
||||
</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>
|
||||
</Pressable>
|
||||
</View>
|
||||
</Card>
|
||||
);
|
||||
}}
|
||||
formTitle={editing => (editing ? t('recurring.editTitle') : t('recurring.addTitle'))}
|
||||
formFields={editing => [
|
||||
{ key: 'name', label: t('recurring.fieldName'), placeholder: t('recurring.fieldNamePlaceholder'), defaultValue: editing?.name },
|
||||
{ key: 'fromAccount', label: t('recurring.fieldFromAccount'), placeholder: '例如: Assets:支付宝余额', defaultValue: editing?.draft.postings[0]?.account?.replace(/^-/, '') || 'Assets:支付宝余额' },
|
||||
{ key: 'toAccount', label: t('recurring.fieldToAccount'), placeholder: '例如: Expenses:Shopping', defaultValue: editing?.draft.postings[1]?.account || 'Expenses:Shopping' },
|
||||
{ key: 'amount', label: t('recurring.fieldAmount'), placeholder: '例如: 6.00', defaultValue: editing?.draft.postings[1]?.amount },
|
||||
{ key: 'frequency', label: t('recurring.fieldFrequency'), placeholder: 'monthly', defaultValue: editing?.frequency || 'monthly' },
|
||||
{ key: 'nextDueDate', label: t('recurring.fieldNextDue'), placeholder: 'YYYY-MM-DD', defaultValue: editing?.nextDueDate || today },
|
||||
]}
|
||||
onSubmit={(values, editing) => {
|
||||
const nameVal = values.name?.trim();
|
||||
const fromAccount = values.fromAccount?.trim();
|
||||
const toAccount = values.toAccount?.trim();
|
||||
const amountVal = values.amount?.trim();
|
||||
const freq = (values.frequency?.trim() || 'monthly') as RecurringTransaction['frequency'];
|
||||
const dateVal = values.nextDueDate?.trim() || today;
|
||||
|
||||
<View style={styles.cardActions}>
|
||||
<Pressable
|
||||
onPress={() => setModal({ type: 'edit', item })}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
if (!nameVal || !fromAccount || !toAccount || !amountVal) {
|
||||
Alert.alert(t('recurring.inputError'), t('recurring.inputErrorDesc'));
|
||||
return false;
|
||||
}
|
||||
|
||||
{recurringTransactions.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 40 }]}>
|
||||
{t('recurring.empty')}
|
||||
</Text>
|
||||
)}
|
||||
</ScrollView>
|
||||
const draft = {
|
||||
date: dateVal,
|
||||
narration: nameVal,
|
||||
postings: [
|
||||
{ account: fromAccount, amount: `-${amountVal}`, currency: 'CNY' },
|
||||
{ account: toAccount, amount: amountVal, currency: 'CNY' },
|
||||
],
|
||||
};
|
||||
|
||||
<FormModal
|
||||
visible={modal !== null}
|
||||
title={modal?.type === 'edit' ? t('recurring.editTitle') : t('recurring.addTitle')}
|
||||
fields={fieldsFor(modal?.type === 'edit' ? modal.item : undefined)}
|
||||
onConfirm={handleConfirm}
|
||||
onCancel={() => setModal(null)}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
if (editing) {
|
||||
updateRecurring(editing.id, {
|
||||
name: nameVal,
|
||||
draft,
|
||||
frequency: freq,
|
||||
nextDueDate: dateVal,
|
||||
});
|
||||
Alert.alert(t('recurring.editSuccess'), t('recurring.editSuccessDesc', { name: nameVal }));
|
||||
} else {
|
||||
addRecurring({
|
||||
id: 'rec_' + generateId(),
|
||||
name: nameVal,
|
||||
draft,
|
||||
frequency: freq,
|
||||
interval: 1,
|
||||
nextDueDate: dateVal,
|
||||
enabled: true,
|
||||
});
|
||||
Alert.alert(t('recurring.addSuccess'), t('recurring.addSuccessDesc', { name: nameVal }));
|
||||
}
|
||||
// 弹窗关闭由模板接管(返回 true)
|
||||
return true;
|
||||
}}
|
||||
onDelete={item => removeRecurring(item.id)}
|
||||
deleteConfirmText={item => t('recurring.deleteConfirm', { name: item.name })}
|
||||
deleteConfirmTitle={t('recurring.deleteTitle')}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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' },
|
||||
cardActions: { flexDirection: 'row', justifyContent: 'flex-end', marginTop: 8 },
|
||||
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 },
|
||||
});
|
||||
|
||||
@ -1,106 +1,70 @@
|
||||
/**
|
||||
* 备注模板管理页面。
|
||||
* 模板用 ${placeholder} 语法,导入账单时自动填充。
|
||||
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||
*/
|
||||
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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import React from 'react';
|
||||
import { Pressable, Text } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
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() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const templates = useMetadataStore(s => s.remarkTemplates);
|
||||
const addTemplate = useMetadataStore(s => s.addRemarkTemplate);
|
||||
const updateTemplate = useMetadataStore(s => s.updateRemarkTemplate);
|
||||
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 (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('remark.title')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('remark.add')}</Text>
|
||||
<ManagementScreen<RemarkTemplate>
|
||||
title={t('remark.title')}
|
||||
items={templates}
|
||||
keyExtractor={tpl => tpl.id}
|
||||
addLabel={t('remark.add')}
|
||||
emptyText={t('remark.empty')}
|
||||
renderItem={(tpl, { openEdit, confirmDelete }) => (
|
||||
<Pressable onPress={openEdit} onLongPress={confirmDelete}>
|
||||
<Card title={tpl.name}>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontVariant: ['tabular-nums'] }]}>
|
||||
{tpl.template}
|
||||
</Text>
|
||||
</Card>
|
||||
</Pressable>
|
||||
{templates.map(tpl => (
|
||||
<Pressable
|
||||
key={tpl.id}
|
||||
onPress={() => setModal({ type: 'edit', tpl })}
|
||||
onLongPress={() => handleDelete(tpl)}
|
||||
>
|
||||
<Card title={tpl.name}>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.fgSecondary, fontFamily: 'monospace' }]}>
|
||||
{tpl.template}
|
||||
</Text>
|
||||
</Card>
|
||||
</Pressable>
|
||||
))}
|
||||
{templates.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 20 }]}>
|
||||
{t('remark.empty')}
|
||||
</Text>
|
||||
)}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
)}
|
||||
formTitle={editing => (editing ? t('remark.editTitle') : t('remark.add'))}
|
||||
formFields={editing => [
|
||||
{ key: 'name', label: t('remark.fieldName'), placeholder: '日常餐饮', defaultValue: editing?.name },
|
||||
{ key: 'template', label: t('remark.fieldTemplate'), placeholder: '${counterparty} ${time}', defaultValue: editing?.template },
|
||||
]}
|
||||
onSubmit={(values, editing) => {
|
||||
const name = values.name?.trim() || t('common.untitled');
|
||||
const template = values.template?.trim() || '';
|
||||
if (editing) {
|
||||
updateTemplate(editing.id, { name, template });
|
||||
} else {
|
||||
addTemplate({ id: generateId('tpl'), name, template });
|
||||
}
|
||||
return true;
|
||||
}}
|
||||
onDelete={tpl => removeTemplate(tpl.id)}
|
||||
deleteConfirmText={tpl => t('remark.deleteConfirm', { name: tpl.name })}
|
||||
deleteConfirmTitle={t('remark.deleteTitle')}
|
||||
footer={
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</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 },
|
||||
});
|
||||
|
||||
@ -3,71 +3,27 @@
|
||||
*
|
||||
* 功能:规则列表(匹配条件 → 分类账户) + 添加/编辑/删除。
|
||||
* 规则用于 BillPipeline 的自动分类(参考 AutoAccounting RuleGenerator)。
|
||||
* P5:套 ManagementScreen 模板,消除手写头部/新增按钮/弹窗样板。
|
||||
*/
|
||||
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';
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { useMetadataStore, generateId } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import type { Rule } from '../../domain/types';
|
||||
|
||||
type ModalMode = { type: 'add' } | { type: 'edit'; rule: Rule } | null;
|
||||
|
||||
export default function RulesScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
const rules = useMetadataStore(s => s.rules);
|
||||
const addRule = useMetadataStore(s => s.addRule);
|
||||
const updateRule = useMetadataStore(s => s.updateRule);
|
||||
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 parts: string[] = [];
|
||||
if (rule.counterpartyContains) parts.push(t('rules.condCounterparty', { val: rule.counterpartyContains }));
|
||||
@ -76,68 +32,74 @@ export default function RulesScreen() {
|
||||
};
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h1, { color: theme.colors.fgPrimary, marginLeft: 8 }]}>{t('tab.rules')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
<Pressable onPress={() => setModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('rules.add')}</Text>
|
||||
</Pressable>
|
||||
<Card title={t('rules.title')}>
|
||||
{sorted.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('rules.empty')}</Text>
|
||||
)}
|
||||
{sorted.map(rule => (
|
||||
<Pressable
|
||||
key={rule.id}
|
||||
onPress={() => setModal({ type: 'edit', rule })}
|
||||
onLongPress={() => handleDelete(rule)}
|
||||
style={({ pressed }) => [styles.row, { borderTopColor: theme.colors.divider, opacity: pressed ? 0.6 : 1 }]}
|
||||
>
|
||||
<View style={{ flex: 1 }}>
|
||||
<View style={styles.ruleHeader}>
|
||||
<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>
|
||||
</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>
|
||||
))}
|
||||
<ManagementScreen<Rule>
|
||||
title={t('tab.rules')}
|
||||
items={[...rules].sort((a, b) => b.priority - a.priority)}
|
||||
keyExtractor={rule => rule.id}
|
||||
addLabel={t('rules.add')}
|
||||
emptyText={t('rules.empty')}
|
||||
renderItem={(rule, { openEdit, confirmDelete }) => (
|
||||
<Card>
|
||||
<Pressable
|
||||
onPress={openEdit}
|
||||
onLongPress={confirmDelete}
|
||||
style={({ pressed }) => [{ opacity: pressed ? 0.6 : 1 }]}
|
||||
>
|
||||
<View style={styles.ruleHeader}>
|
||||
<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>
|
||||
</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 }]}>
|
||||
{rule.categoryAccount} ({t('rules.hitsSuffix', { count: rule.hits })})
|
||||
</Text>
|
||||
</View>
|
||||
</Pressable>
|
||||
</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' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</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({
|
||||
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' },
|
||||
});
|
||||
|
||||
@ -1,19 +1,17 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View, Switch, Alert } 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 { useT } from '../../i18n';
|
||||
import { useSettingsStore } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
|
||||
export default function AISettingsScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
// Settings State
|
||||
const dedupEnabled = useSettingsStore(s => s.dedupEnabled);
|
||||
@ -47,14 +45,7 @@ export default function AISettingsScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('settings.aiSettingsTitle')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('settings.aiSettingsTitle')} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* 自动记账与算法开关 */}
|
||||
@ -148,7 +139,6 @@ export default function AISettingsScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, gap: 16 },
|
||||
switchRow: { flexDirection: 'row', alignItems: 'center', justifyContent: 'space-between' },
|
||||
optionRow: { flexDirection: 'row', gap: 8 },
|
||||
|
||||
42
src/app/settings/diagnostics.tsx
Normal file
42
src/app/settings/diagnostics.tsx
Normal 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 },
|
||||
});
|
||||
@ -1,17 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Alert, Pressable, ScrollView, StyleSheet, Text, View, Switch } 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 { useT } from '../../i18n';
|
||||
import { useSettingsStore, type Locale, type ThemeMode } from '../../store/settingsStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
|
||||
export default function PreferencesScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
// Settings State
|
||||
const themeMode = useSettingsStore(s => s.themeMode);
|
||||
@ -24,6 +23,8 @@ export default function PreferencesScreen() {
|
||||
const reminderHour = useSettingsStore(s => s.reminderHour);
|
||||
const reminderMinute = useSettingsStore(s => s.reminderMinute);
|
||||
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 }[] = [
|
||||
{ mode: 'light', labelKey: 'settings.themeLight' },
|
||||
@ -38,14 +39,7 @@ export default function PreferencesScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('settings.preferencesTitle')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('settings.preferencesTitle')} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* 外观设置 */}
|
||||
@ -106,6 +100,24 @@ export default function PreferencesScreen() {
|
||||
</View>
|
||||
</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')}>
|
||||
<View style={styles.switchRow}>
|
||||
@ -233,7 +245,6 @@ export default function PreferencesScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, gap: 16 },
|
||||
optionRow: { flexDirection: 'row', gap: 8 },
|
||||
option: { flex: 1, alignItems: 'center', paddingVertical: 8, borderWidth: 1, borderRadius: 4 },
|
||||
|
||||
@ -1,8 +1,6 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Pressable, ScrollView, StyleSheet, Text, View, Alert, Platform } from 'react-native';
|
||||
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 FileSystem from 'expo-file-system/legacy';
|
||||
import * as Sharing from 'expo-sharing';
|
||||
@ -15,6 +13,7 @@ import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
import { syncWithWebDAV, WebDAVClient, type FetchImpl } from '../../services/sync/webdavSync';
|
||||
import { syncWithGit, ExpoGitBackend } from '../../services/sync/gitSync';
|
||||
import { syncWithICloud, MockICloudFileSystem } from '../../services/sync/icloudSync';
|
||||
@ -24,7 +23,6 @@ import { runMaintenance, ExpoMaintenanceFs, ExpoMaintenanceDb } from '../../serv
|
||||
export default function SyncSettingsScreen() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
// Sync Configuration
|
||||
const webdavUrl = useSettingsStore(s => s.webdavUrl) || '';
|
||||
@ -456,14 +454,7 @@ export default function SyncSettingsScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}>
|
||||
<Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} />
|
||||
</Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>
|
||||
{t('sync.title')}
|
||||
</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('sync.title')} />
|
||||
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
{/* 云同步配置与云端操作 */}
|
||||
@ -539,7 +530,6 @@ export default function SyncSettingsScreen() {
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, gap: 16 },
|
||||
syncBtnRow: { flexDirection: 'row', gap: 8 },
|
||||
buttonRow: { flexDirection: 'row', gap: 8 },
|
||||
|
||||
@ -1,141 +1,92 @@
|
||||
/**
|
||||
* 标签管理页面(plan.md「1.1 tag/index」+ 决策 1 双轨制)。
|
||||
* 标签管理页面 —— ManagementScreen 模板试点(P2)。
|
||||
*
|
||||
* 功能:标签列表(彩色芯片) + 添加/编辑/删除。
|
||||
* 标签名写入 .bean 的 #tag 语法,需符合 [\w-]。
|
||||
*/
|
||||
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';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
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 { Card } from '../../components/Card';
|
||||
import { FormModal, type FormField } from '../../components/FormModal';
|
||||
import { ManagementScreen } from '../../components/ManagementScreen';
|
||||
import { isValidTagName } 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() {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const router = useRouter();
|
||||
|
||||
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 [modal, setModal] = useState<ModalMode>(null);
|
||||
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' }];
|
||||
const [selectedColor, setSelectedColor] = useState<string>(TAG_COLORS[0]);
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['top']}>
|
||||
<View style={styles.header}>
|
||||
<Pressable onPress={() => router.back()}><Ionicons name="arrow-back" size={24} color={theme.colors.fgPrimary} /></Pressable>
|
||||
<Text style={[theme.typography.h2, { color: theme.colors.fgPrimary, flex: 1, marginLeft: 8 }]}>{t('tag.title')}</Text>
|
||||
</View>
|
||||
<ScrollView contentContainerStyle={styles.content}>
|
||||
<Card title={t('tag.count', { count: tags.length })}>
|
||||
<Pressable onPress={() => openModal({ type: 'add' })} style={styles.addBtn}>
|
||||
<Ionicons name="add-circle" size={20} color={theme.colors.accent} />
|
||||
<Text style={[theme.typography.body, { color: theme.colors.accent, marginLeft: 6 }]}>{t('tag.add')}</Text>
|
||||
</Pressable>
|
||||
<View style={[styles.grid, { gap: 8, paddingTop: 4 }]}>
|
||||
{tags.map(tag => (
|
||||
<Pressable
|
||||
key={tag.id}
|
||||
onPress={() => openModal({ type: 'edit', tag })}
|
||||
onLongPress={() => handleDelete(tag)}
|
||||
style={[styles.chip, { backgroundColor: tag.color }]}
|
||||
>
|
||||
<Text style={{ color: theme.colors.fgInverse, fontWeight: '700' }}>#{tag.name}</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
{tags.length === 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary }]}>{t('tag.empty')}</Text>
|
||||
)}
|
||||
</View>
|
||||
</Card>
|
||||
|
||||
{/* 颜色选择器(模态打开时显示) */}
|
||||
{modal && (
|
||||
<Card title={t('tag.selectColor')}>
|
||||
<View style={styles.colorRow}>
|
||||
{COLORS.map(c => (
|
||||
<Pressable
|
||||
key={c}
|
||||
onPress={() => setSelectedColor(c)}
|
||||
style={[styles.colorDot, {
|
||||
backgroundColor: c,
|
||||
borderWidth: selectedColor === c ? 3 : 0,
|
||||
borderColor: theme.colors.fgPrimary,
|
||||
}]}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', marginTop: 8 }]}>
|
||||
<ManagementScreen<Tag>
|
||||
title={t('tag.title')}
|
||||
items={tags}
|
||||
keyExtractor={tag => tag.id}
|
||||
addLabel={t('tag.add')}
|
||||
emptyText={t('tag.empty')}
|
||||
listStyle={styles.chipGrid}
|
||||
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 })}
|
||||
deleteConfirmTitle={t('tag.deleteTitle')}
|
||||
footer={
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center' }]}>
|
||||
{t('common.clickEditLongDelete')}
|
||||
</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({
|
||||
page: { flex: 1 },
|
||||
header: { flexDirection: 'row', alignItems: 'center', paddingHorizontal: 16, paddingTop: 8, paddingBottom: 12 },
|
||||
content: { padding: 16, gap: 12 },
|
||||
grid: { flexDirection: 'row', flexWrap: 'wrap' },
|
||||
chipGrid: { flexDirection: 'row', flexWrap: 'wrap', gap: 8 },
|
||||
chip: { paddingVertical: 6, paddingHorizontal: 14, borderRadius: 16 },
|
||||
addBtn: { flexDirection: 'row', alignItems: 'center', paddingBottom: 8 },
|
||||
colorRow: { flexDirection: 'row', flexWrap: 'wrap', gap: 12, paddingVertical: 4 },
|
||||
colorDot: { width: 36, height: 36, borderRadius: 18 },
|
||||
});
|
||||
|
||||
@ -18,6 +18,7 @@ import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { hash } from '../../domain/ledger';
|
||||
import { useT } from '../../i18n';
|
||||
import { TransactionCard } from '../../components/TransactionCard';
|
||||
import { ScreenHeader } from '../../components/ScreenHeader';
|
||||
|
||||
export default function TransactionDetailScreen() {
|
||||
const { theme } = useTheme();
|
||||
@ -154,10 +155,7 @@ export default function TransactionDetailScreen() {
|
||||
if (!tx) {
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||
<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('common.notFound')}</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('common.notFound')} />
|
||||
<View style={styles.content}>
|
||||
<Card title={t('transaction.notFoundTitle')}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgSecondary }]}>
|
||||
@ -178,10 +176,7 @@ export default function TransactionDetailScreen() {
|
||||
|
||||
return (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]}>
|
||||
<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('transaction.detailTitle')}</Text>
|
||||
</View>
|
||||
<ScreenHeader title={t('transaction.detailTitle')} />
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: 12 }]}>
|
||||
{/* 摘要卡片 */}
|
||||
<Card title={t('transaction.summary')}>
|
||||
@ -311,7 +306,7 @@ export default function TransactionDetailScreen() {
|
||||
|
||||
{/* 原始 .bean */}
|
||||
<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}
|
||||
</Text>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8 }]}>
|
||||
|
||||
@ -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)/ OCR(mode=ocr)预填复用同一面板。
|
||||
*/
|
||||
import React from 'react';
|
||||
import { StyleSheet } from 'react-native';
|
||||
import { SafeAreaView } from 'react-native-safe-area-context';
|
||||
import { useRouter, useLocalSearchParams, useNavigation } from 'expo-router';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import * as ImagePicker from 'expo-image-picker';
|
||||
import * as FileSystem from 'expo-file-system/legacy';
|
||||
import { useLedgerStore } from '../../store/ledgerStore';
|
||||
import { useMetadataStore } from '../../store/metadataStore';
|
||||
import { useTheme, createCommonStyles } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { Card } from '../../components/Card';
|
||||
import { Button } from '../../components/Button';
|
||||
import { CategoryPicker } from '../../components/CategoryPicker';
|
||||
import { TagPicker } from '../../components/TagPicker';
|
||||
import { PostingEditor } from '../../components/PostingEditor';
|
||||
import { tagsToBeanSyntax } from '../../domain/tags';
|
||||
import { serializeTransaction } from '../../domain/ledger';
|
||||
import { buildAndSaveTransaction, 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;
|
||||
import { useLocalSearchParams } from 'expo-router';
|
||||
import { useTheme } from '../../theme';
|
||||
import { NumpadSheet } from '../../components/NumpadSheet';
|
||||
|
||||
export default function NewTransactionScreen() {
|
||||
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 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 (
|
||||
<SafeAreaView style={[styles.page, { backgroundColor: theme.colors.bgPrimary }]} edges={['bottom', 'left', 'right']}>
|
||||
<ScrollView contentContainerStyle={[styles.content, { gap: theme.spacing.md }]}>
|
||||
{/* 高级模式模式切换开关 */}
|
||||
<View style={commonStyles.switchRow}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '700', fontFamily: theme.typography.body.fontFamily }]}>
|
||||
{t('transaction.advancedMode')}
|
||||
</Text>
|
||||
<Switch
|
||||
value={isAdvanced}
|
||||
onValueChange={(val) => {
|
||||
setIsAdvanced(val);
|
||||
// 同步初始化当前分录
|
||||
if (val) {
|
||||
const amt = amount.trim();
|
||||
const categoryAccount = selectedCategory?.linkedAccount ?? 'Expenses:Uncategorized';
|
||||
if (direction === 'expense') {
|
||||
setPostings([
|
||||
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
||||
{ account: categoryAccount, amount: amt || '', currency: 'CNY' },
|
||||
]);
|
||||
} else if (direction === 'income') {
|
||||
setPostings([
|
||||
{ account: sourceAccount, amount: amt || '', currency: 'CNY' },
|
||||
{ account: categoryAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
||||
]);
|
||||
} else {
|
||||
setPostings([
|
||||
{ account: sourceAccount, amount: amt ? `-${amt}` : '', currency: 'CNY' },
|
||||
{ account: targetAccount, amount: amt || '', currency: 'CNY' },
|
||||
]);
|
||||
}
|
||||
}
|
||||
}}
|
||||
trackColor={{ false: theme.colors.bgPrimary, true: theme.colors.accent }}
|
||||
thumbColor={theme.colors.fgInverse}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 简单模式下的方向选择 */}
|
||||
{!isAdvanced && (
|
||||
<View style={styles.directionRow}>
|
||||
{directions.map(d => (
|
||||
<Pressable
|
||||
key={d.key}
|
||||
onPress={() => { setDirection(d.key); setSelectedCategory(null); }}
|
||||
style={[
|
||||
commonStyles.chip,
|
||||
direction === d.key && commonStyles.chipActive,
|
||||
{ flex: 1, alignItems: 'center' }
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
commonStyles.chipText,
|
||||
direction === d.key && commonStyles.chipTextActive,
|
||||
{ fontFamily: theme.typography.caption.fontFamily }
|
||||
]}>
|
||||
{d.label}
|
||||
</Text>
|
||||
</Pressable>
|
||||
))}
|
||||
<Pressable
|
||||
onPress={handleOcrScan}
|
||||
style={[
|
||||
commonStyles.chip,
|
||||
{ alignItems: 'center', minWidth: 60, borderColor: theme.colors.accent }
|
||||
]}
|
||||
>
|
||||
<Text style={[commonStyles.chipText, { color: theme.colors.accent, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('home.ocrScan')}
|
||||
</Text>
|
||||
</Pressable>
|
||||
</View>
|
||||
)}
|
||||
|
||||
{/* 主输入区域:金额与账户 (简单模式) 或 分录编辑器 (高级模式) */}
|
||||
{!isAdvanced ? (
|
||||
<Card title={t('transaction.newTitle')}>
|
||||
{/* 金额 */}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.amountPlaceholder')}
|
||||
</Text>
|
||||
<View style={{ flexDirection: 'row', gap: 8 }}>
|
||||
<TextInput
|
||||
value={amount}
|
||||
onChangeText={setAmount}
|
||||
autoFocus={editId ? false : true}
|
||||
keyboardType="decimal-pad"
|
||||
placeholder={t('transaction.amountPlaceholder')}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace', fontSize: 16 }]}
|
||||
/>
|
||||
<TextInput
|
||||
value={currency}
|
||||
onChangeText={setCurrency}
|
||||
placeholder="CNY"
|
||||
maxLength={3}
|
||||
autoCapitalize="characters"
|
||||
style={[commonStyles.input, { width: 70, textAlign: 'center' }]}
|
||||
/>
|
||||
</View>
|
||||
|
||||
{/* 来源账户 */}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.formSourceAccount')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={sourceAccount}
|
||||
onChangeText={setSourceAccount}
|
||||
placeholder="Assets:支付宝余额"
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={commonStyles.input}
|
||||
/>
|
||||
|
||||
{/* 快捷来源账户选择 */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{assetAccounts.map(acct => {
|
||||
const active = sourceAccount === acct;
|
||||
const shortName = acct.split(':').pop() || acct;
|
||||
return (
|
||||
<Pressable
|
||||
key={`src-${acct}`}
|
||||
onPress={() => setSourceAccount(acct)}
|
||||
style={[
|
||||
commonStyles.chip,
|
||||
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
|
||||
{ paddingVertical: 4, paddingHorizontal: 8 }
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
commonStyles.chipText,
|
||||
{ fontSize: 11 },
|
||||
active && { color: theme.colors.accent, fontWeight: '700' }
|
||||
]}>
|
||||
{shortName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
|
||||
{/* 目标账户 (仅在转账时显示) */}
|
||||
{direction === 'transfer' && (
|
||||
<View style={{ marginTop: 10 }}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.targetAccount')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={targetAccount}
|
||||
onChangeText={setTargetAccount}
|
||||
placeholder="Assets:WeChat"
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={commonStyles.input}
|
||||
/>
|
||||
{/* 快捷目标账户选择 */}
|
||||
<View style={{ flexDirection: 'row', flexWrap: 'wrap', gap: 6, marginTop: 8 }}>
|
||||
{assetAccounts.map(acct => {
|
||||
const active = targetAccount === acct;
|
||||
const shortName = acct.split(':').pop() || acct;
|
||||
return (
|
||||
<Pressable
|
||||
key={`tgt-${acct}`}
|
||||
onPress={() => setTargetAccount(acct)}
|
||||
style={[
|
||||
commonStyles.chip,
|
||||
active && { backgroundColor: theme.colors.accentLight, borderColor: theme.colors.accent },
|
||||
{ paddingVertical: 4, paddingHorizontal: 8 }
|
||||
]}
|
||||
>
|
||||
<Text style={[
|
||||
commonStyles.chipText,
|
||||
{ fontSize: 11 },
|
||||
active && { color: theme.colors.accent, fontWeight: '700' }
|
||||
]}>
|
||||
{shortName}
|
||||
</Text>
|
||||
</Pressable>
|
||||
);
|
||||
})}
|
||||
</View>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
) : (
|
||||
<Card title={t('transaction.newTitle')}>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.postingsList')}
|
||||
</Text>
|
||||
|
||||
<PostingEditor
|
||||
postings={postings}
|
||||
onChange={(p) => setPostings(p)}
|
||||
/>
|
||||
|
||||
<View style={styles.postingBtnRow}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button
|
||||
label={t('transaction.addPosting')}
|
||||
onPress={() => setPostings([...postings, { account: '', amount: '', currency: 'CNY' }])}
|
||||
variant="secondary"
|
||||
/>
|
||||
</View>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Button
|
||||
label={t('transaction.deleteLastPosting')}
|
||||
onPress={() => postings.length > 2 && setPostings(postings.slice(0, -1))}
|
||||
variant="secondary"
|
||||
/>
|
||||
</View>
|
||||
</View>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 简单模式下的分类选择 */}
|
||||
{!isAdvanced && direction !== 'transfer' && (
|
||||
<Card title={direction === 'income' ? t('transaction.formCategoryIncome') : t('transaction.formCategoryExpense')}>
|
||||
<CategoryPicker
|
||||
categories={availableCategories}
|
||||
selectedId={selectedCategory?.id}
|
||||
onSelect={(cat) => setSelectedCategory(cat)}
|
||||
/>
|
||||
{selectedCategory && (
|
||||
<View style={{ flexDirection: 'row', alignItems: 'center', gap: 4, marginTop: 8 }}>
|
||||
<Ionicons name="arrow-forward-outline" size={12} color={theme.colors.fgSecondary} />
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{selectedCategory.linkedAccount}
|
||||
</Text>
|
||||
</View>
|
||||
)}
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{/* 详情与高级字段收纳区 */}
|
||||
<Pressable
|
||||
onPress={() => {
|
||||
LayoutAnimation.easeInEaseOut();
|
||||
setShowDetails(!showDetails);
|
||||
}}
|
||||
style={({ pressed }) => [
|
||||
styles.detailsToggle,
|
||||
{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
flexDirection: 'row',
|
||||
gap: 6
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text style={[theme.typography.bodySmall, { color: theme.colors.accent, fontWeight: '700', fontFamily: theme.typography.bodySmall.fontFamily }]}>
|
||||
{showDetails ? t('transaction.hideDetails') : t('transaction.addDetails')}
|
||||
</Text>
|
||||
<Ionicons
|
||||
name={showDetails ? 'chevron-up-outline' : 'chevron-down-outline'}
|
||||
size={16}
|
||||
color={theme.colors.accent}
|
||||
/>
|
||||
</Pressable>
|
||||
|
||||
{showDetails && (
|
||||
<View style={{ gap: theme.spacing.md }}>
|
||||
<Card title={t('transaction.extraDetails')}>
|
||||
{/* 日期 */}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.formDate')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={date}
|
||||
onChangeText={setDate}
|
||||
placeholder="YYYY-MM-DD"
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={commonStyles.input}
|
||||
/>
|
||||
|
||||
{/* 摘要 */}
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.formNarration')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={narration}
|
||||
onChangeText={setNarration}
|
||||
placeholder={t('transaction.formNarration')}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={commonStyles.input}
|
||||
/>
|
||||
|
||||
{/* 收款方(简单模式才显示) */}
|
||||
{!isAdvanced && (
|
||||
<>
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginBottom: 4, marginTop: 10, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.formPayee')}
|
||||
</Text>
|
||||
<TextInput
|
||||
value={payee}
|
||||
onChangeText={setPayee}
|
||||
placeholder={t('transaction.formPayeePlaceholder')}
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={commonStyles.input}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{/* 标签选择 */}
|
||||
<Card title={t('transaction.formTags')}>
|
||||
<TagPicker
|
||||
tags={tags}
|
||||
selectedNames={selectedTags.map(tg => tg.name)}
|
||||
onToggle={toggleTag}
|
||||
/>
|
||||
{selectedTags.length > 0 && (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 8, fontFamily: theme.typography.caption.fontFamily }]}>
|
||||
{t('transaction.formTagsWillWrite')}: {tagsToBeanSyntax(selectedTags)}
|
||||
</Text>
|
||||
)}
|
||||
</Card>
|
||||
</View>
|
||||
)}
|
||||
|
||||
<Button label={t('transaction.submit')} onPress={submit} 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>
|
||||
<NumpadSheet
|
||||
presentation="page"
|
||||
editId={editId}
|
||||
draftJson={draftJson}
|
||||
autoOcr={mode === 'ocr'}
|
||||
/>
|
||||
</SafeAreaView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
page: { flex: 1 },
|
||||
content: { padding: 16, paddingBottom: 40 },
|
||||
directionRow: { flexDirection: 'row', gap: 8, marginBottom: 4 },
|
||||
postingBtnRow: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
detailsToggle: { paddingVertical: 12, paddingHorizontal: 16, borderRadius: 12, borderWidth: StyleSheet.hairlineWidth, alignItems: 'center', justifyContent: 'center', marginTop: 4 },
|
||||
});
|
||||
|
||||
|
||||
89
src/components/AppTabBar.tsx
Normal file
89
src/components/AppTabBar.tsx
Normal 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/new,P3 改为唤起全局 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 },
|
||||
});
|
||||
51
src/components/BottomSheet.tsx
Normal file
51
src/components/BottomSheet.tsx
Normal 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 },
|
||||
});
|
||||
@ -53,7 +53,7 @@ export function Button({ label, onPress, variant = 'primary', disabled }: Button
|
||||
styles.button,
|
||||
{
|
||||
backgroundColor: bg,
|
||||
borderRadius: theme.radii.lg, // 使用 radii.lg 实现现代大圆角
|
||||
borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
|
||||
opacity: disabled ? 0.5 : 1,
|
||||
borderWidth: variant === 'secondary' ? StyleSheet.hairlineWidth : 0,
|
||||
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>
|
||||
</Pressable>
|
||||
);
|
||||
|
||||
@ -1,6 +1,7 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { getMonthGrid, type DayCell } from '../domain/calendarGrid';
|
||||
import { dailyExpenseMap } from '../domain/chartStats';
|
||||
import type { Transaction } from '../domain/types';
|
||||
@ -15,10 +16,11 @@ interface CalendarViewProps {
|
||||
onDayPress?: (date: string) => void;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
export function CalendarView({ transactions, year, month, onDayPress }: CalendarViewProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
|
||||
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
|
||||
const [selectedDate, setSelectedDate] = useState<string | null>(null);
|
||||
|
||||
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.weekdayRow}>
|
||||
{WEEKDAYS.map(d => (
|
||||
{weekdays.map(d => (
|
||||
<Text key={d} style={[theme.typography.caption, { color: theme.colors.fgSecondary, textAlign: 'center', flex: 1 }]}>
|
||||
{d}
|
||||
</Text>
|
||||
|
||||
@ -34,7 +34,7 @@ export function Card({ title, children, onPress, style }: CardProps) {
|
||||
|
||||
const cardStyle = {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.lg,
|
||||
borderRadius: theme.radii.xl,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
|
||||
32
src/components/CategoryIcon.tsx
Normal file
32
src/components/CategoryIcon.tsx
Normal 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' },
|
||||
});
|
||||
@ -1,7 +1,9 @@
|
||||
import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { getCategoryColor } from '../theme/palette';
|
||||
import type { Category } from '../domain/categories';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
|
||||
interface CategoryPickerProps {
|
||||
categories: Category[];
|
||||
@ -9,44 +11,6 @@ interface CategoryPickerProps {
|
||||
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) {
|
||||
const { theme } = useTheme();
|
||||
@ -55,8 +19,7 @@ export function CategoryPicker({ categories, selectedId, onSelect }: CategoryPic
|
||||
<View style={styles.grid}>
|
||||
{categories.map(cat => {
|
||||
const active = cat.id === selectedId;
|
||||
const icon = CATEGORY_ICONS[cat.id] || CATEGORY_ICONS.fallback;
|
||||
const color = CATEGORY_COLORS[cat.id] || CATEGORY_COLORS.fallback;
|
||||
const color = getCategoryColor(cat.id);
|
||||
|
||||
return (
|
||||
<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
|
||||
style={[
|
||||
styles.label,
|
||||
{
|
||||
color: active ? theme.colors.fgPrimary : theme.colors.fgSecondary,
|
||||
fontFamily: theme.typography.caption.fontFamily,
|
||||
fontWeight: active ? '700' : '500',
|
||||
}
|
||||
]}
|
||||
@ -109,9 +71,6 @@ const styles = StyleSheet.create({
|
||||
gap: 6,
|
||||
padding: 4,
|
||||
},
|
||||
icon: {
|
||||
fontSize: 22,
|
||||
},
|
||||
label: {
|
||||
fontSize: 12,
|
||||
},
|
||||
|
||||
117
src/components/DatePickerField.tsx
Normal file
117
src/components/DatePickerField.tsx
Normal 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 },
|
||||
});
|
||||
95
src/components/FilterSheet.tsx
Normal file
95
src/components/FilterSheet.tsx
Normal 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 },
|
||||
});
|
||||
@ -20,6 +20,7 @@
|
||||
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Modal, Pressable, ScrollView, StyleSheet, Text, TextInput, View } from 'react-native';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { useTheme, createCommonStyles } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { Button } from './Button';
|
||||
@ -49,9 +50,11 @@ interface FormModalProps {
|
||||
onCancel: () => void;
|
||||
/** 确认按钮文本(默认「确定」)。 */
|
||||
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 t = useT();
|
||||
const commonStyles = useMemo(() => createCommonStyles(theme), [theme]);
|
||||
@ -74,7 +77,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
|
||||
<Pressable
|
||||
style={[styles.sheet, {
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.lg,
|
||||
borderRadius: theme.radii.xl,
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
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 }]}>
|
||||
<Text style={[theme.typography.h3, { color: theme.colors.fgPrimary }]}>{title}</Text>
|
||||
<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>
|
||||
</View>
|
||||
|
||||
@ -107,6 +110,7 @@ export function FormModal({ visible, title, fields, onConfirm, onCancel, confirm
|
||||
))}
|
||||
</ScrollView>
|
||||
|
||||
{children}
|
||||
<View style={styles.actions}>
|
||||
<Button label={t('common.cancel')} onPress={onCancel} variant="secondary" />
|
||||
<View style={{ flex: 1 }} />
|
||||
|
||||
112
src/components/ManagementScreen.tsx
Normal file
112
src/components/ManagementScreen.tsx
Normal 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 },
|
||||
});
|
||||
89
src/components/NumpadKeyboard.tsx
Normal file
89
src/components/NumpadKeyboard.tsx
Normal 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';
|
||||
|
||||
/** 键值:数字/小数点/加减走 numpadExpression;today/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' },
|
||||
});
|
||||
756
src/components/NumpadSheet.tsx
Normal file
756
src/components/NumpadSheet.tsx
Normal 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 },
|
||||
});
|
||||
47
src/components/NumpadSheetHost.tsx
Normal file
47
src/components/NumpadSheetHost.tsx
Normal 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%' },
|
||||
});
|
||||
@ -60,7 +60,7 @@ export function PostingEditor({ postings, onChange }: PostingEditorProps) {
|
||||
placeholder="金额"
|
||||
keyboardType="decimal-pad"
|
||||
placeholderTextColor={theme.colors.fgSecondary}
|
||||
style={[commonStyles.input, { flex: 1, fontFamily: 'monospace' }]}
|
||||
style={[commonStyles.input, { flex: 1, fontVariant: ['tabular-nums'] }]}
|
||||
/>
|
||||
<TextInput
|
||||
value={p.currency ?? ''}
|
||||
|
||||
43
src/components/ScreenHeader.tsx
Normal file
43
src/components/ScreenHeader.tsx
Normal 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 },
|
||||
});
|
||||
@ -17,7 +17,7 @@ export function SearchBar({ value, onChangeText, placeholder }: SearchBarProps)
|
||||
styles.container,
|
||||
{
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
borderRadius: theme.radii.lg, // 升级为 lg 圆角
|
||||
borderRadius: theme.radii.xl, // Bento 大圆角 xl (24px)
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
},
|
||||
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
41
src/components/StatCard.tsx
Normal file
41
src/components/StatCard.tsx
Normal 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'] },
|
||||
});
|
||||
64
src/components/SwipeableTransactionCard.tsx
Normal file
64
src/components/SwipeableTransactionCard.tsx
Normal 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' },
|
||||
});
|
||||
110
src/components/TodoStrip.tsx
Normal file
110
src/components/TodoStrip.tsx
Normal 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' },
|
||||
});
|
||||
@ -2,52 +2,70 @@ import React from 'react';
|
||||
import { Pressable, StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../theme';
|
||||
import { useT } from '../i18n';
|
||||
import { CategoryIcon } from './CategoryIcon';
|
||||
import type { Transaction } from '../domain/types';
|
||||
|
||||
interface TransactionCardProps {
|
||||
transaction: Transaction;
|
||||
onPress?: (t: Transaction) => void;
|
||||
/** 命中分类 id(调用方按 linkedAccount 匹配);缺省用方向兜底图标。 */
|
||||
categoryId?: string;
|
||||
/** 时间线分组下隐藏日期(默认 true 保持旧行为)。 */
|
||||
showDate?: boolean;
|
||||
}
|
||||
|
||||
/** 交易卡片(主题化,展示日期/摘要/金额)。 */
|
||||
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress }: TransactionCardProps) {
|
||||
/** 交易卡片:CategoryIcon 圆底 + 摘要/账户小字 + 右侧等宽金额(支出黑、收入绿+、转账蓝)。 */
|
||||
export const TransactionCard = React.memo(function TransactionCard({ transaction, onPress, categoryId, showDate = true }: TransactionCardProps) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const isExpense = transaction.postings.some(p => p.account.startsWith('Expenses'));
|
||||
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 primaryAmount = transaction.postings.find(p => p.amount)?.amount ?? '';
|
||||
const direction = isExpense ? 'expense' : isIncome ? 'income' : 'transfer';
|
||||
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 (
|
||||
<Pressable
|
||||
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)}
|
||||
style={({ pressed }) => [
|
||||
styles.card,
|
||||
{
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.lg, // 升级为大圆角
|
||||
borderRadius: theme.radii.lg,
|
||||
padding: theme.spacing.md,
|
||||
borderWidth: StyleSheet.hairlineWidth, // 添加极细透白边框
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
opacity: pressed ? 0.8 : 1,
|
||||
},
|
||||
]}
|
||||
>
|
||||
<View style={styles.row}>
|
||||
<View style={{ flex: 1 }}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
|
||||
{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}
|
||||
<CategoryIcon categoryId={categoryId ?? direction} size={36} />
|
||||
<View style={styles.body}>
|
||||
<Text style={[theme.typography.body, { color: theme.colors.fgPrimary, fontWeight: '600' }]} numberOfLines={1}>
|
||||
{transaction.narration || transaction.payee || t('transaction.noSummary')}
|
||||
</Text>
|
||||
{subtitle ? (
|
||||
<Text style={[theme.typography.caption, { color: theme.colors.fgSecondary, marginTop: 2 }]} numberOfLines={1}>
|
||||
{subtitle}
|
||||
</Text>
|
||||
) : null}
|
||||
</View>
|
||||
<Text style={[theme.typography.body, { color: amountColor, fontWeight: '700', fontSize: 15, fontVariant: ['tabular-nums'] }]}>
|
||||
{amountText}
|
||||
</Text>
|
||||
{transaction.tags.length > 0 && (
|
||||
<View style={styles.tags}>
|
||||
{transaction.tags.map(tag => (
|
||||
@ -62,8 +80,8 @@ export const TransactionCard = React.memo(function TransactionCard({ transaction
|
||||
});
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
card: { marginBottom: 8 },
|
||||
row: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
tags: { flexDirection: 'row', gap: 4, marginTop: 6, flexWrap: 'wrap' },
|
||||
card: { marginBottom: 8, flexDirection: 'row', alignItems: 'center', gap: 10, flexWrap: 'wrap' },
|
||||
body: { flex: 1 },
|
||||
tags: { flexDirection: 'row', gap: 4, marginLeft: 46, flexBasis: '100%', flexWrap: 'wrap' },
|
||||
tag: { fontSize: 11, paddingHorizontal: 6, paddingVertical: 2, borderRadius: 4, overflow: 'hidden' },
|
||||
});
|
||||
|
||||
39
src/components/categoryIcons.ts
Normal file
39
src/components/categoryIcons.ts
Normal 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';
|
||||
|
||||
/** 取分类图标:具名映射,否则 fallback(hasOwn 防原型链穿透)。 */
|
||||
export function getCategoryIcon(categoryId: string): IoniconName {
|
||||
return Object.hasOwn(CATEGORY_ICON_NAMES, categoryId)
|
||||
? CATEGORY_ICON_NAMES[categoryId]
|
||||
: FALLBACK_ICON;
|
||||
}
|
||||
@ -1,19 +1,23 @@
|
||||
/**
|
||||
* 年度报告图表(plan.md「5.2 年度报告」)。
|
||||
* 展示年度收支/分类/月度趋势。
|
||||
* 年度报告图表(plan.md「5.2 年度报告」;P4:去内嵌月报,加月度节奏迷你图)。
|
||||
* 展示年度收支/分类/月度节奏。
|
||||
*/
|
||||
import React, { useMemo } from 'react';
|
||||
import { StyleSheet, Text, View } from 'react-native';
|
||||
import { useTheme } from '../../theme';
|
||||
import { useT } from '../../i18n';
|
||||
import { generateAnnualReport } from '../../domain/annualReport';
|
||||
import { MonthlyReport } from './MonthlyReport';
|
||||
import type { Transaction } from '../../domain/types';
|
||||
|
||||
export function AnnualReport({ transactions, year }: { transactions: Transaction[]; year: number }) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
const report = useMemo(() => generateAnnualReport(transactions, year), [transactions, year]);
|
||||
// 展示层比率计算,允许 parseFloat(非记账金额)
|
||||
const maxExpense = useMemo(
|
||||
() => Math.max(...report.monthlyTrend.map(m => parseFloat(m.expense) || 0)),
|
||||
[report],
|
||||
);
|
||||
|
||||
return (
|
||||
<View style={styles.container}>
|
||||
@ -50,7 +54,22 @@ export function AnnualReport({ transactions, year }: { transactions: Transaction
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@ -61,4 +80,9 @@ const styles = StyleSheet.create({
|
||||
statsRow: { flexDirection: 'row', justifyContent: 'space-around' },
|
||||
statItem: { alignItems: 'center', gap: 4 },
|
||||
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 },
|
||||
});
|
||||
|
||||
@ -16,11 +16,11 @@ function heatColor(intensity: number): { opacity: number } {
|
||||
return { opacity: Math.min(0.2 + intensity * 0.8, 1) };
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['日', '一', '二', '三', '四', '五', '六'];
|
||||
|
||||
export function CalendarHeatmap({ transactions, year, month }: { transactions: Transaction[]; year: number; month: number }) {
|
||||
const { theme } = useTheme();
|
||||
const t = useT();
|
||||
// 星期头周日起(与 getMonthGrid 网格一致),文案走 i18n
|
||||
const weekdays = useMemo(() => t('calendar.weekdays').split(','), [t]);
|
||||
const dailyMap = useMemo(() => dailyExpenseMap(transactions), [transactions]);
|
||||
const weeks = useMemo(() => getMonthGrid(year, month, transactions), [year, month, transactions]);
|
||||
const maxDaily = Math.max(...dailyMap.values(), 1);
|
||||
@ -31,7 +31,7 @@ export function CalendarHeatmap({ transactions, year, month }: { transactions: T
|
||||
{t('report.heatmap')}
|
||||
</Text>
|
||||
<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 }]}>
|
||||
{d}
|
||||
</Text>
|
||||
|
||||
@ -17,14 +17,14 @@ export function CategoryPie({ transactions }: { transactions: Transaction[] }) {
|
||||
|
||||
return (
|
||||
<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')}
|
||||
</Text>
|
||||
<View style={styles.list}>
|
||||
{data.map(d => (
|
||||
<View key={d.category} style={styles.row}>
|
||||
<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}
|
||||
>
|
||||
{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.bar, { width: `${(d.amount / maxAmount) * 100}%`, backgroundColor: theme.colors.accent }]} />
|
||||
</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}%
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
@ -1,45 +0,0 @@
|
||||
/**
|
||||
* 月度报告图表(plan.md「5.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 },
|
||||
});
|
||||
@ -73,7 +73,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
|
||||
return (
|
||||
<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')}
|
||||
</Text>
|
||||
|
||||
@ -153,7 +153,7 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
{data.map((d, i) => (
|
||||
<Text
|
||||
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)}
|
||||
</Text>
|
||||
@ -168,11 +168,11 @@ export function TrendLine({ transactions }: { transactions: Transaction[] }) {
|
||||
|
||||
<View style={styles.legend}>
|
||||
<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')}
|
||||
</Text>
|
||||
<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')}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
36
src/components/dateGrid.ts
Normal file
36
src/components/dateGrid.ts
Normal 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;
|
||||
}
|
||||
62
src/components/numpadExpression.ts
Normal file
62
src/components/numpadExpression.ts
Normal 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;
|
||||
}
|
||||
38
src/components/transactionGroups.ts
Normal file
38
src/components/transactionGroups.ts
Normal 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;
|
||||
}
|
||||
51
src/components/useOcrScan.ts
Normal file
51
src/components/useOcrScan.ts
Normal 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 };
|
||||
}
|
||||
@ -91,7 +91,10 @@ function filterByPeriod(
|
||||
|
||||
/** 计算周期的起止日期。 */
|
||||
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 month = d.getMonth();
|
||||
switch (period) {
|
||||
|
||||
@ -7,6 +7,8 @@
|
||||
* 设计见 plan.md「决策 1 双轨制」。
|
||||
*/
|
||||
|
||||
import { CHANNEL_BRAND_COLORS } from '../theme/palette';
|
||||
|
||||
export interface ChannelConfig {
|
||||
id: string;
|
||||
/** 渠道名(与 ImportedEvent.channel 对齐,如 "Alipay", "WeChat", "Bank")。 */
|
||||
@ -29,7 +31,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
|
||||
defaultAccount: 'Assets:支付宝余额',
|
||||
packageNames: ['com.eg.android.AlipayGphone', 'com.eg.android.AlipayGphone.wallet'],
|
||||
icon: 'alipay',
|
||||
color: '#1677FF',
|
||||
color: CHANNEL_BRAND_COLORS.alipay,
|
||||
},
|
||||
{
|
||||
id: 'ch_wechat',
|
||||
@ -37,7 +39,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
|
||||
defaultAccount: 'Assets:微信零钱',
|
||||
packageNames: ['com.tencent.mm', 'com.tencent.mobileqq'],
|
||||
icon: 'wechat',
|
||||
color: '#07C160',
|
||||
color: CHANNEL_BRAND_COLORS.wechat,
|
||||
},
|
||||
{
|
||||
id: 'ch_bank',
|
||||
@ -45,7 +47,7 @@ export const BUILTIN_CHANNELS: ChannelConfig[] = [
|
||||
defaultAccount: 'Assets:银行卡',
|
||||
packageNames: ['com.unionpay', 'com.cmbchina', 'com.icbc'],
|
||||
icon: 'bank',
|
||||
color: '#E53935',
|
||||
color: CHANNEL_BRAND_COLORS.bank,
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
@ -28,3 +28,5 @@ export * from './ocr';
|
||||
export * from './ocrProcessor';
|
||||
export * from './calendarGrid';
|
||||
export * from './chartStats';
|
||||
export * from './postingLegs';
|
||||
export * from './periodNav';
|
||||
|
||||
58
src/domain/periodNav.ts
Normal file
58
src/domain/periodNav.ts
Normal 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
73
src/domain/postingLegs.ts
Normal 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;
|
||||
}
|
||||
@ -5,6 +5,7 @@
|
||||
|
||||
import { useMemo } from 'react';
|
||||
import type { Transaction } from '../domain/types';
|
||||
import { compareDecimals } from '../domain/decimal';
|
||||
import { useDebounce } from './useDebounce';
|
||||
|
||||
export interface SearchFilters {
|
||||
@ -14,6 +15,8 @@ export interface SearchFilters {
|
||||
dateTo?: string;
|
||||
tags?: string[];
|
||||
direction?: 'income' | 'expense' | 'transfer';
|
||||
amountMin?: string;
|
||||
amountMax?: string;
|
||||
}
|
||||
|
||||
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 === '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;
|
||||
});
|
||||
}, [transactions, debouncedFilters]);
|
||||
|
||||
@ -20,6 +20,7 @@ export default {
|
||||
clickEditLongDelete: 'Tap to edit · Long-press to delete',
|
||||
noData: 'No data',
|
||||
discard: 'Discard',
|
||||
error: 'Error',
|
||||
},
|
||||
tab: {
|
||||
home: 'Home',
|
||||
@ -27,7 +28,7 @@ export default {
|
||||
report: 'Report',
|
||||
import: 'Import',
|
||||
rules: 'Rules',
|
||||
settings: 'Settings',
|
||||
settings: 'Me',
|
||||
},
|
||||
home: {
|
||||
title: 'Ledger Status',
|
||||
@ -56,6 +57,21 @@ export default {
|
||||
accountTree: 'Account Balance Tree',
|
||||
balanced: '✓ Balanced',
|
||||
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: {
|
||||
newTitle: 'Manual Entry',
|
||||
@ -78,6 +94,7 @@ export default {
|
||||
directionIncome: 'Income',
|
||||
directionTransfer: 'Transfer',
|
||||
formDate: 'Date',
|
||||
formCurrency: 'Currency',
|
||||
formNarration: 'Narration',
|
||||
formPayee: 'Payee',
|
||||
formPayeePlaceholder: 'Counterparty/merchant',
|
||||
@ -140,6 +157,20 @@ export default {
|
||||
accountFilterPlaceholder: 'Account contains...',
|
||||
dateFrom: 'From 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: {
|
||||
title: 'Report',
|
||||
@ -176,10 +207,15 @@ export default {
|
||||
monthlyExpense: 'Monthly Expense',
|
||||
monthlyNet: 'Monthly Net',
|
||||
monthlyStats: '%{count} transactions',
|
||||
menu: 'More actions',
|
||||
exportImage: 'Export image',
|
||||
monthlyRhythm: 'Monthly rhythm',
|
||||
},
|
||||
calendar: {
|
||||
title: 'Calendar',
|
||||
noDayTx: 'No transactions on this day',
|
||||
/** Weekday headers, Sunday first, comma-separated (split in components). */
|
||||
weekdays: 'Su,Mo,Tu,We,Th,Fr,Sa',
|
||||
},
|
||||
category: {
|
||||
title: 'Categories',
|
||||
@ -390,6 +426,9 @@ export default {
|
||||
algorithmConfig: 'Auto-Classification Rules Settings',
|
||||
selectLang: 'Select language',
|
||||
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',
|
||||
systemFeaturesTitle: 'Features & System',
|
||||
aiSettingsTitle: 'AI & Smart Entry',
|
||||
@ -405,6 +444,36 @@ export default {
|
||||
aiFieldApiKey: 'API Key',
|
||||
aiFieldBaseUrl: 'API Base URL',
|
||||
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: {
|
||||
title: 'Accounts',
|
||||
|
||||
@ -22,6 +22,7 @@ export default {
|
||||
clickEditLongDelete: '点击编辑 · 长按删除',
|
||||
noData: '暂无数据',
|
||||
discard: '放弃',
|
||||
error: '错误',
|
||||
},
|
||||
tab: {
|
||||
home: '首页',
|
||||
@ -29,7 +30,7 @@ export default {
|
||||
report: '报表',
|
||||
import: '导入',
|
||||
rules: '规则',
|
||||
settings: '设置',
|
||||
settings: '我的',
|
||||
},
|
||||
home: {
|
||||
title: '账本状态',
|
||||
@ -58,6 +59,21 @@ export default {
|
||||
accountTree: '账户与资产余额树',
|
||||
balanced: '✓ 平衡',
|
||||
unbalanced: '✗ 不平衡',
|
||||
greetingMorning: '早上好',
|
||||
greetingAfternoon: '下午好',
|
||||
greetingEvening: '晚上好',
|
||||
monthExpense: '本月支出',
|
||||
monthIncome: '本月收入',
|
||||
budgetRemaining: '预算剩余',
|
||||
viewAll: '查看全部',
|
||||
},
|
||||
todo: {
|
||||
title: '待办',
|
||||
dueRecurring: '周期记账「%{name}」到期',
|
||||
creditCardDue: '%{name} 还款日 %{date}',
|
||||
pendingDrafts: '%{count} 条自动账单待确认',
|
||||
confirm: '入账',
|
||||
view: '查看',
|
||||
},
|
||||
transaction: {
|
||||
newTitle: '手工记账',
|
||||
@ -80,6 +96,7 @@ export default {
|
||||
directionIncome: '收入',
|
||||
directionTransfer: '转账',
|
||||
formDate: '日期',
|
||||
formCurrency: '币种',
|
||||
formNarration: '摘要',
|
||||
formPayee: '收款方',
|
||||
formPayeePlaceholder: '对方/商户名称',
|
||||
@ -142,6 +159,20 @@ export default {
|
||||
accountFilterPlaceholder: '账户名包含...',
|
||||
dateFrom: '起始日期',
|
||||
dateTo: '结束日期',
|
||||
filter: '筛选',
|
||||
amountMin: '最小金额',
|
||||
amountMax: '最大金额',
|
||||
today: '今天',
|
||||
yesterday: '昨天',
|
||||
daySummary: '收 %{income} · 支 %{expense}',
|
||||
duplicate: '复制',
|
||||
deleteTitle: '删除交易',
|
||||
deleteMessage: '确定删除「%{name}」吗?此操作会同时从 main.bean 移除。',
|
||||
},
|
||||
filter: {
|
||||
title: '高级筛选',
|
||||
reset: '重置',
|
||||
apply: '完成',
|
||||
},
|
||||
report: {
|
||||
title: '报表',
|
||||
@ -178,10 +209,15 @@ export default {
|
||||
monthlyExpense: '本月支出',
|
||||
monthlyNet: '本月结余',
|
||||
monthlyStats: '交易 %{count} 笔',
|
||||
menu: '更多操作',
|
||||
exportImage: '导出图片',
|
||||
monthlyRhythm: '月度节奏',
|
||||
},
|
||||
calendar: {
|
||||
title: '日历',
|
||||
noDayTx: '当日无交易',
|
||||
/** 星期头(周日起,逗号分隔,组件内 split)。 */
|
||||
weekdays: '日,一,二,三,四,五,六',
|
||||
},
|
||||
category: {
|
||||
title: '分类管理',
|
||||
@ -392,6 +428,9 @@ export default {
|
||||
algorithmConfig: '自动分类匹配配置',
|
||||
selectLang: '选择系统语言',
|
||||
preferencesTitle: '偏好与安全设置',
|
||||
entryTitle: '记账',
|
||||
numpadEntry: '全局记账面板',
|
||||
numpadEntryDesc: '开启后,底部 + 在任意页面直接弹出记账面板(灰度功能)',
|
||||
recurringTitle: '周期性记账管理',
|
||||
systemFeaturesTitle: '功能与系统设置',
|
||||
aiSettingsTitle: '智能记账与 AI 设置',
|
||||
@ -407,6 +446,36 @@ export default {
|
||||
aiFieldApiKey: 'API Key',
|
||||
aiFieldBaseUrl: 'API Base URL',
|
||||
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: {
|
||||
title: '账户管理',
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
*/
|
||||
|
||||
import { create } from 'zustand';
|
||||
import { TAG_COLORS } from '../theme/palette';
|
||||
import type { Category } from '../domain/categories';
|
||||
import type { Tag } from '../domain/tags';
|
||||
import type { Budget } from '../domain/budgets';
|
||||
@ -96,9 +97,9 @@ const DEFAULTS: PersistableMetadata = {
|
||||
{ id: 'income_investment', name: '理财收益', type: 'income', linkedAccount: 'Income:理财收益', keywords: ['收益', '利息', '基金', '余额宝收益'] },
|
||||
],
|
||||
tags: [
|
||||
{ id: 't1', name: 'food', color: '#F44336' },
|
||||
{ id: 't2', name: 'work', color: '#2196F3' },
|
||||
{ id: 't3', name: 'travel', color: '#4CAF50' },
|
||||
{ id: 't1', name: 'food', color: TAG_COLORS[0] },
|
||||
{ id: 't2', name: 'work', color: TAG_COLORS[3] },
|
||||
{ id: 't3', name: 'travel', color: TAG_COLORS[5] },
|
||||
],
|
||||
budgets: [
|
||||
{ id: 'b1', name: '餐饮月预算', amount: '2000', period: 'monthly', categoryAccount: 'Expenses:餐饮', startDate: '2026-01-01' },
|
||||
|
||||
24
src/store/numpadUiStore.ts
Normal file
24
src/store/numpadUiStore.ts
Normal 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 }),
|
||||
}));
|
||||
@ -57,6 +57,16 @@ export interface SettingsState {
|
||||
floatingBallEnabled: boolean;
|
||||
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
|
||||
updateDedupConfig: (patch: Partial<DedupConfig>) => void;
|
||||
updateTransferConfig: (patch: Partial<TransferConfig>) => void;
|
||||
@ -104,6 +114,9 @@ export interface PersistableSettings {
|
||||
reminderHour: number;
|
||||
reminderMinute: number;
|
||||
floatingBallEnabled: boolean;
|
||||
numpadGlobalEntry: boolean;
|
||||
lastUsedAccount: string | null;
|
||||
lastUsedCategoryId: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
@ -139,6 +152,11 @@ const DEFAULTS: PersistableSettings & { skipPersist: boolean; floatingBallEnable
|
||||
reminderHour: 20,
|
||||
reminderMinute: 0,
|
||||
floatingBallEnabled: false,
|
||||
// P5 翻默认:+按钮默认打开全局记账面板。
|
||||
// 注意:hydrate 以持久化值覆盖 DEFAULTS,已持久化 false 的老用户不受影响,仅新安装生效。
|
||||
numpadGlobalEntry: true,
|
||||
lastUsedAccount: null,
|
||||
lastUsedCategoryId: null,
|
||||
skipPersist: false,
|
||||
};
|
||||
|
||||
@ -169,6 +187,9 @@ export function toPersistable(state: SettingsState): PersistableSettings {
|
||||
reminderHour: state.reminderHour,
|
||||
reminderMinute: state.reminderMinute,
|
||||
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 }),
|
||||
|
||||
setNumpadGlobalEntry: (enabled) => set({ numpadGlobalEntry: enabled }),
|
||||
|
||||
setLastUsedEntry: (account, categoryId) => set({ lastUsedAccount: account, lastUsedCategoryId: categoryId }),
|
||||
|
||||
hydrate: (data) => {
|
||||
// hydrate 期间跳过自动持久化
|
||||
// 使用单次 setState 合并所有数据,避免多次触发 subscriber
|
||||
|
||||
@ -11,7 +11,7 @@ export function createCommonStyles(theme: ThemeTokens) {
|
||||
input: {
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
borderRadius: theme.radii.md, // 输入框标准 radii 为 md (8px)
|
||||
borderRadius: theme.radii.md, // 输入框标准 radii 为 md (12px)
|
||||
backgroundColor: theme.colors.bgTertiary,
|
||||
color: theme.colors.fgPrimary,
|
||||
paddingHorizontal: 12,
|
||||
@ -51,7 +51,7 @@ export function createCommonStyles(theme: ThemeTokens) {
|
||||
modalCard: {
|
||||
width: '100%',
|
||||
backgroundColor: theme.colors.bgSecondary,
|
||||
borderRadius: theme.radii.lg, // 弹窗使用大圆角 lg (16px)
|
||||
borderRadius: theme.radii.xl, // 弹窗使用卡片级大圆角 xl (24px)
|
||||
borderWidth: StyleSheet.hairlineWidth,
|
||||
borderColor: theme.colors.border,
|
||||
padding: 20,
|
||||
|
||||
@ -42,6 +42,7 @@ export function useTheme(): ThemeContextValue {
|
||||
return useContext(ThemeContext);
|
||||
}
|
||||
|
||||
export { lightTheme, darkTheme };
|
||||
export { lightTheme, darkTheme, presetThemes };
|
||||
export { createCommonStyles } from './commonStyles';
|
||||
export { CATEGORY_PALETTE, getCategoryColor, CHANNEL_BRAND_COLORS } from './palette';
|
||||
export type { ThemeTokens } from './tokens';
|
||||
|
||||
79
src/theme/palette.ts
Normal file
79
src/theme/palette.ts
Normal 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;
|
||||
@ -1,79 +1,81 @@
|
||||
import type { ThemeTokens } from './tokens';
|
||||
|
||||
/**
|
||||
* 预置主题(plan.md「1.2 预置主题」)。
|
||||
* lightTheme 为基础,darkTheme 覆盖色彩与阴影。
|
||||
* 预置主题 —— 「明亮 Bento 现代风」(docs/ui-redesign-design.md §3)。
|
||||
* 浅色为默认;暗色为 OLED 纯黑完整对等主题。
|
||||
* accent 为近黑白单色:品牌感靠排版与财务语义色表达。
|
||||
*/
|
||||
|
||||
export const lightTheme: ThemeTokens = {
|
||||
colors: {
|
||||
bgPrimary: '#F8FAFC',
|
||||
bgPrimary: '#F6F7F9',
|
||||
bgSecondary: '#FFFFFF',
|
||||
bgTertiary: '#F1F5F9',
|
||||
fgPrimary: '#0F172A',
|
||||
fgSecondary: '#64748B',
|
||||
bgTertiary: '#EFF1F4',
|
||||
fgPrimary: '#111318',
|
||||
fgSecondary: '#6B7280',
|
||||
fgInverse: '#FFFFFF',
|
||||
accent: '#4F46E5', // 经典深靛蓝
|
||||
accentLight: '#EEF2FF',
|
||||
accentDark: '#3730A3',
|
||||
success: '#10B981', // 现代翡翠绿
|
||||
accent: '#111318', // 近黑:按钮/选中态
|
||||
accentLight: 'rgba(17,19,24,0.08)', // accent 8% 底色(选中高亮)
|
||||
accentDark: '#000000', // 按压加深态
|
||||
success: '#10B981',
|
||||
warning: '#F59E0B',
|
||||
error: '#EF4444', // 现代珊瑚红
|
||||
error: '#EF4444',
|
||||
info: '#3B82F6',
|
||||
financial: {
|
||||
income: '#10B981',
|
||||
expense: '#EF4444',
|
||||
transfer: '#3B82F6',
|
||||
},
|
||||
border: '#E2E8F0',
|
||||
divider: '#F1F5F9',
|
||||
overlay: 'rgba(15,23,42,0.3)',
|
||||
skeleton: '#E2E8F0',
|
||||
progressBg: 'rgba(79,70,229,0.08)',
|
||||
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: 4, md: 8, lg: 16, full: 9999 },
|
||||
radii: { sm: 8, md: 12, lg: 16, xl: 24, full: 9999 },
|
||||
typography: {
|
||||
h1: { fontSize: 28, fontWeight: '700', lineHeight: 36, fontFamily: 'Caveat_700Bold' },
|
||||
h2: { fontSize: 20, fontWeight: '600', lineHeight: 28, fontFamily: 'Quicksand_700Bold' },
|
||||
h3: { fontSize: 18, fontWeight: '600', lineHeight: 24, fontFamily: 'Quicksand_600SemiBold' },
|
||||
body: { fontSize: 16, fontWeight: '400', lineHeight: 24, fontFamily: 'Quicksand_500Medium' },
|
||||
bodySmall: { fontSize: 14, fontWeight: '400', lineHeight: 20, fontFamily: 'Quicksand_500Medium' },
|
||||
caption: { fontSize: 12, fontWeight: '400', lineHeight: 16, fontFamily: 'Quicksand_400Regular' },
|
||||
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: '#0F172A', shadowOffset: { width: 0, height: 1 }, shadowOpacity: 0.04, shadowRadius: 6, elevation: 1 },
|
||||
md: { shadowColor: '#0F172A', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.08, shadowRadius: 8, elevation: 3 },
|
||||
lg: { shadowColor: '#0F172A', shadowOffset: { width: 0, height: 4 }, shadowOpacity: 0.12, shadowRadius: 16, elevation: 5 },
|
||||
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: '#0E1017', // 电影院质感深邃灰蓝卡片背景
|
||||
bgTertiary: '#161822', // 输入框/次级背景色
|
||||
fgPrimary: '#F3F4F6', // 明亮灰白文字
|
||||
bgPrimary: '#040508', // OLED 纯黑
|
||||
bgSecondary: '#101218',
|
||||
bgTertiary: '#1A1D24',
|
||||
fgPrimary: '#F3F4F6',
|
||||
fgSecondary: '#9CA3AF',
|
||||
fgInverse: '#040508',
|
||||
accent: '#4F46E5', // 高贵靛蓝
|
||||
accentLight: 'rgba(79,70,229,0.15)',
|
||||
accentDark: '#3730A3',
|
||||
success: '#059669',
|
||||
warning: '#F59E0B',
|
||||
error: '#F43F5E',
|
||||
info: '#3B82F6',
|
||||
fgInverse: '#111318',
|
||||
accent: '#F3F4F6', // 反转为白
|
||||
accentLight: 'rgba(243,244,246,0.10)',
|
||||
accentDark: '#FFFFFF',
|
||||
success: '#34D399',
|
||||
warning: '#FBBF24',
|
||||
error: '#F87171',
|
||||
info: '#60A5FA',
|
||||
financial: {
|
||||
income: '#059669', // 财务收益绿色
|
||||
expense: '#F43F5E', // 财务支出玫瑰红
|
||||
transfer: '#3B82F6',
|
||||
income: '#34D399', // 提亮一档保证对比度
|
||||
expense: '#F87171',
|
||||
transfer: '#60A5FA',
|
||||
},
|
||||
border: 'rgba(255,255,255,0.06)', // 极细微透白边框
|
||||
divider: 'rgba(255,255,255,0.03)',
|
||||
border: 'rgba(255,255,255,0.08)',
|
||||
divider: 'rgba(255,255,255,0.04)',
|
||||
overlay: 'rgba(0,0,0,0.7)',
|
||||
skeleton: '#161822',
|
||||
progressBg: 'rgba(79,70,229,0.15)',
|
||||
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 },
|
||||
|
||||
@ -60,6 +60,7 @@ export interface ThemeRadii {
|
||||
sm: number;
|
||||
md: number;
|
||||
lg: number;
|
||||
xl: number; // 卡片默认大圆角(24)
|
||||
full: number;
|
||||
}
|
||||
|
||||
@ -68,10 +69,12 @@ export interface ThemeTypographyEntry {
|
||||
/** React Native 的 fontWeight 是字面量联合类型,这里对齐以避免类型不兼容。 */
|
||||
fontWeight: 'normal' | 'bold' | '100' | '200' | '300' | '400' | '500' | '600' | '700' | '800' | '900';
|
||||
lineHeight: number;
|
||||
/** @deprecated 设计系统已切换为系统字体,预置主题不再设置该字段;引用处将在 P4/P5 逐页移除。 */
|
||||
fontFamily?: string;
|
||||
}
|
||||
|
||||
export interface ThemeTypography {
|
||||
display: ThemeTypographyEntry; // 34/800,净资产等大数字
|
||||
h1: ThemeTypographyEntry;
|
||||
h2: ThemeTypographyEntry;
|
||||
h3: ThemeTypographyEntry;
|
||||
|
||||
64
tests/components-p2.test.ts
Normal file
64
tests/components-p2.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -77,6 +77,26 @@ describe('budgets', () => {
|
||||
expect(range.start).toBe('2026-01-01');
|
||||
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', () => {
|
||||
|
||||
159
tests/numpad.test.ts
Normal file
159
tests/numpad.test.ts
Normal 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
36
tests/palette.test.ts
Normal 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
57
tests/periodNav.test.ts
Normal 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
58
tests/search.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
@ -3,16 +3,56 @@ import { lightTheme, darkTheme, presetThemes } from '../src/theme/presets';
|
||||
import { createTheme } from '../src/theme/createTheme';
|
||||
import type { ThemeTokens } from '../src/theme/tokens';
|
||||
|
||||
describe('预置主题', () => {
|
||||
describe('预置主题(明亮 Bento 设计系统)', () => {
|
||||
it('lightTheme 与 darkTheme 关键色彩不同', () => {
|
||||
expect(lightTheme.colors.bgPrimary).not.toBe(darkTheme.colors.bgPrimary);
|
||||
expect(lightTheme.colors.fgPrimary).not.toBe(darkTheme.colors.fgPrimary);
|
||||
});
|
||||
|
||||
it('lightTheme 背景为亮色、darkTheme 背景为深色', () => {
|
||||
// 亮色背景接近白,深色背景接近黑
|
||||
expect(lightTheme.colors.bgPrimary).toBe('#F8FAFC');
|
||||
expect(darkTheme.colors.bgPrimary).not.toBe('#F8FAFC');
|
||||
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', () => {
|
||||
@ -20,22 +60,10 @@ describe('预置主题', () => {
|
||||
expect(presetThemes.dark).toBe(darkTheme);
|
||||
});
|
||||
|
||||
it('财务语义色三态齐全', () => {
|
||||
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 结构完整', () => {
|
||||
it('spacing/shadows 结构完整', () => {
|
||||
for (const theme of [lightTheme, darkTheme] as ThemeTokens[]) {
|
||||
expect(theme.spacing).toHaveProperty('xs');
|
||||
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('lg');
|
||||
}
|
||||
@ -52,19 +80,19 @@ describe('createTheme 自定义主题工厂', () => {
|
||||
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); // 保留
|
||||
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); // 保留
|
||||
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); // 保留
|
||||
expect(custom.spacing.md).toBe(lightTheme.spacing.md);
|
||||
});
|
||||
});
|
||||
|
||||
45
tests/transactionGroups.test.ts
Normal file
45
tests/transactionGroups.test.ts
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user