From 5a46402d60e89dc4f83e908a9922ea114dea94d4 Mon Sep 17 00:00:00 2001 From: sqliang Date: Mon, 2 Mar 2026 23:23:17 +0800 Subject: [PATCH 1/2] feat(bookkeeping): add bookkeeping feature with record management Add complete bookkeeping module with: - AddRecordForm for creating new financial records - RecordTable for displaying and managing records - SummaryCards for financial overview - Full test coverage for all components - Update App routing and navigation to include bookkeeping page --- .husky/commit-msg | 4 + package.json | 1 + pnpm-lock.yaml | 3 + src/App.tsx | 22 +- src/config/navigation.tsx | 7 + src/pages/Bookkeeping/AddRecordForm.test.tsx | 153 ++++++++++ src/pages/Bookkeeping/AddRecordForm.tsx | 135 +++++++++ src/pages/Bookkeeping/README.md | 291 +++++++++++++++++++ src/pages/Bookkeeping/RecordTable.test.tsx | 172 +++++++++++ src/pages/Bookkeeping/RecordTable.tsx | 121 ++++++++ src/pages/Bookkeeping/SummaryCards.test.tsx | 92 ++++++ src/pages/Bookkeeping/SummaryCards.tsx | 77 +++++ src/pages/Bookkeeping/index.test.tsx | 178 ++++++++++++ src/pages/Bookkeeping/index.tsx | 81 ++++++ src/pages/Bookkeeping/types.ts | 34 +++ 15 files changed, 1361 insertions(+), 10 deletions(-) create mode 100755 .husky/commit-msg create mode 100644 src/pages/Bookkeeping/AddRecordForm.test.tsx create mode 100644 src/pages/Bookkeeping/AddRecordForm.tsx create mode 100644 src/pages/Bookkeeping/README.md create mode 100644 src/pages/Bookkeeping/RecordTable.test.tsx create mode 100644 src/pages/Bookkeeping/RecordTable.tsx create mode 100644 src/pages/Bookkeeping/SummaryCards.test.tsx create mode 100644 src/pages/Bookkeeping/SummaryCards.tsx create mode 100644 src/pages/Bookkeeping/index.test.tsx create mode 100644 src/pages/Bookkeeping/index.tsx create mode 100644 src/pages/Bookkeeping/types.ts diff --git a/.husky/commit-msg b/.husky/commit-msg new file mode 100755 index 0000000..e998e31 --- /dev/null +++ b/.husky/commit-msg @@ -0,0 +1,4 @@ +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit "${1}" diff --git a/package.json b/package.json index 24a7dff..3c5faa8 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "@ant-design/icons": "^6.1.0", "@rsbuild/plugin-sass": "^1.4.0", "antd": "^6.1.0", + "dayjs": "^1.11.19", "graphql": "^16.9.0", "react": "^19.2.1", "react-dom": "^19.2.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5a88f63..e7fc85d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,9 @@ importers: antd: specifier: ^6.1.0 version: 6.1.0(react-dom@19.2.1(react@19.2.1))(react@19.2.1) + dayjs: + specifier: ^1.11.19 + version: 1.11.19 graphql: specifier: ^16.9.0 version: 16.12.0 diff --git a/src/App.tsx b/src/App.tsx index f93ef84..fde3b0e 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,7 @@ import Posts from './pages/Posts'; import { ShoppingCart } from './pages/ShoppingCart'; import RelayExample from './pages/RelayExample'; import Todo from './pages/Todo'; +import Bookkeeping from './pages/Bookkeeping'; import './index.css'; import { Loading } from './components/Loading'; @@ -52,7 +53,7 @@ const AppContent = () => {

Build & Experiment

- + {/* Main navigation */} { style={{ background: 'transparent' }} /> - + {/* Sidebar with improved styling */} { )} - + {/* Sidebar menu */}
{ defaultOpenKeys={['/']} />
- + {/* Collapse toggle button */} -
setCollapsed(!collapsed)} > - @@ -111,7 +112,7 @@ const AppContent = () => {
- + {/* Main content area */}
@@ -123,6 +124,7 @@ const AppContent = () => { } /> } /> } /> + } /> diff --git a/src/config/navigation.tsx b/src/config/navigation.tsx index 40769e6..e30e16e 100644 --- a/src/config/navigation.tsx +++ b/src/config/navigation.tsx @@ -17,6 +17,7 @@ enum NavPathKey { ShoppingCart = '/shopping-cart', RelayExample = '/relay-example', Todo = '/todo', + Bookkeeping = '/bookkeeping', }; export const mainNavItems: NavItem[] = [ @@ -51,6 +52,12 @@ export const mainNavItems: NavItem[] = [ 待办事项清单 ), }, + { + key: NavPathKey.Bookkeeping, + label: ( + 记账本 + ), + }, ]; export const sideNavItems: Record = { diff --git a/src/pages/Bookkeeping/AddRecordForm.test.tsx b/src/pages/Bookkeeping/AddRecordForm.test.tsx new file mode 100644 index 0000000..73321bf --- /dev/null +++ b/src/pages/Bookkeeping/AddRecordForm.test.tsx @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../test/utils'; +import { AddRecordForm } from './AddRecordForm'; +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './types'; + +/** + * Ant Design v6 Select helper: opens the dropdown by clicking on the + * select's trigger area, then picks the option by title attribute. + */ +async function selectOption(container: HTMLElement, index: number, optionTitle: string) { + const selects = container.querySelectorAll('.ant-select'); + const target = selects[index]; + fireEvent.mouseDown(target.querySelector('.ant-select-content')!); + + await waitFor(() => { + const option = document.querySelector(`.ant-select-item[title="${optionTitle}"]`); + expect(option).toBeTruthy(); + fireEvent.click(option!); + }); +} + +describe('AddRecordForm', () => { + const mockOnAdd = vi.fn(); + + beforeEach(() => { + mockOnAdd.mockClear(); + }); + + describe('渲染', () => { + it('应该渲染类型选择器,默认值为"支出"', () => { + const { container } = render(); + const typeValue = container.querySelector('.ant-select-content-value[title="支出"]'); + expect(typeValue).toBeTruthy(); + expect(typeValue!.textContent).toBe('支出'); + }); + + it('应该渲染金额输入框', () => { + render(); + expect(screen.getByPlaceholderText('金额')).toBeInTheDocument(); + }); + + it('应该渲染分类选择器', () => { + render(); + expect(screen.getByText('选择分类')).toBeInTheDocument(); + }); + + it('应该渲染备注输入框', () => { + render(); + expect(screen.getByPlaceholderText('备注(可选)')).toBeInTheDocument(); + }); + + it('应该渲染"添加"按钮', () => { + render(); + expect(screen.getByRole('button', { name: /添加/ })).toBeInTheDocument(); + }); + }); + + describe('分类联动', () => { + it('当类型为"支出"时,分类下拉应显示支出分类列表', async () => { + const { container } = render(); + const selects = container.querySelectorAll('.ant-select'); + fireEvent.mouseDown(selects[1].querySelector('.ant-select-content')!); + + await waitFor(() => { + EXPENSE_CATEGORIES.forEach((cat) => { + expect(document.querySelector(`.ant-select-item[title="${cat}"]`)).toBeTruthy(); + }); + }); + }); + + it('当类型切换为"收入"时,分类下拉应切换为收入分类列表', async () => { + const { container } = render(); + + await selectOption(container, 0, '收入'); + + const selects = container.querySelectorAll('.ant-select'); + fireEvent.mouseDown(selects[1].querySelector('.ant-select-content')!); + + await waitFor(() => { + INCOME_CATEGORIES.forEach((cat) => { + expect(document.querySelector(`.ant-select-item[title="${cat}"]`)).toBeTruthy(); + }); + }); + }); + }); + + describe('表单校验', () => { + it('金额为空时点击添加,应显示校验错误提示', async () => { + render(); + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(screen.getByText('请输入金额')).toBeInTheDocument(); + }); + expect(mockOnAdd).not.toHaveBeenCalled(); + }); + + it('分类未选择时点击添加,应显示校验错误提示', async () => { + render(); + + const amountInput = screen.getByPlaceholderText('金额'); + fireEvent.change(amountInput, { target: { value: '100' } }); + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(screen.getByText('请选择分类')).toBeInTheDocument(); + }); + expect(mockOnAdd).not.toHaveBeenCalled(); + }); + }); + + describe('提交', () => { + it('填写完整信息后点击添加,应调用 onAdd 并传入正确的记录对象', async () => { + const { container } = render(); + + fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: '50' } }); + await selectOption(container, 1, EXPENSE_CATEGORIES[0]); + fireEvent.change(screen.getByPlaceholderText('备注(可选)'), { target: { value: '测试备注' } }); + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledTimes(1); + }); + + const record = mockOnAdd.mock.calls[0][0]; + expect(record.type).toBe('expense'); + expect(record.amount).toBe(50); + expect(record.category).toBe(EXPENSE_CATEGORIES[0]); + expect(record.description).toBe('测试备注'); + }); + + it('提交的记录应包含唯一 id、type、amount、category、description、date 字段', async () => { + const { container } = render(); + + fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: '200' } }); + await selectOption(container, 1, EXPENSE_CATEGORIES[1]); + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledTimes(1); + }); + + const record = mockOnAdd.mock.calls[0][0]; + expect(record).toHaveProperty('id'); + expect(record).toHaveProperty('type'); + expect(record).toHaveProperty('amount'); + expect(record).toHaveProperty('category'); + expect(record).toHaveProperty('description'); + expect(record).toHaveProperty('date'); + expect(record.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); + }); + }); +}); diff --git a/src/pages/Bookkeeping/AddRecordForm.tsx b/src/pages/Bookkeeping/AddRecordForm.tsx new file mode 100644 index 0000000..ffc23a7 --- /dev/null +++ b/src/pages/Bookkeeping/AddRecordForm.tsx @@ -0,0 +1,135 @@ +import React, { useState, useCallback } from 'react'; +import { Form, Input, InputNumber, Select, DatePicker, Button } from 'antd'; +import { PlusOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { Dayjs } from 'dayjs'; + +import { + INCOME_CATEGORIES, + EXPENSE_CATEGORIES, + type BookkeepingRecord, +} from './types'; + +interface FormValues { + type: 'income' | 'expense'; + amount: number; + category: string; + description: string; + date: Dayjs; +} + +export interface AddRecordFormProps { + onAdd: (record: BookkeepingRecord) => void; +} + +export const AddRecordForm: React.FC = ({ onAdd }) => { + const [form] = Form.useForm(); + const [selectedType, setSelectedType] = useState<'income' | 'expense'>( + 'expense', + ); + + const handleTypeChange = useCallback( + (value: 'income' | 'expense') => { + setSelectedType(value); + form.setFieldValue('category', undefined); + }, + [form], + ); + + const handleSubmit = useCallback( + (values: FormValues) => { + const record: BookkeepingRecord = { + id: `${Date.now()}_${Math.random().toString(36).slice(2, 9)}`, + type: values.type, + amount: values.amount, + category: values.category, + description: values.description || '', + date: values.date.format('YYYY-MM-DD'), + }; + onAdd(record); + form.resetFields(); + form.setFieldsValue({ type: 'expense', date: dayjs() }); + setSelectedType('expense'); + }, + [form, onAdd], + ); + + const categoryOptions = ( + selectedType === 'income' ? INCOME_CATEGORIES : EXPENSE_CATEGORIES + ).map((c) => ({ label: c, value: c })); + + return ( +
+

+ 添加记录 +

+
+ + + + + + + + + + + + + + + +
+
+ ); +}; diff --git a/src/pages/Bookkeeping/README.md b/src/pages/Bookkeeping/README.md new file mode 100644 index 0000000..3015a84 --- /dev/null +++ b/src/pages/Bookkeeping/README.md @@ -0,0 +1,291 @@ +# 记账本页面实现方案 + +## 前置条件 + +项目使用 Ant Design v6,其依赖中包含 `dayjs`,但 pnpm 严格的依赖隔离导致项目无法直接 import。需要先将 `dayjs` 添加到项目的直接依赖中: + +```bash +pnpm add dayjs +``` + +## localStorage 持久化 — 复用已有 Hook + +项目已有 [`src/hooks/useLocalStorage.ts`](../../hooks/useLocalStorage.ts),API 与 `useState` 完全一致: + +```typescript +const [value, setValue] = useLocalStorage(key, fallback); +``` + +在记账页面中直接复用,替代手动的 `loadRecords`/`saveRecords` + `useEffect`: + +```typescript +const [records, setRecords] = useLocalStorage('bookkeeping_records', []); +``` + +## 涉及文件 + +- **新建** `types.ts` — 类型定义与常量 +- **新建** `SummaryCards.tsx` — 汇总卡片组件 +- **新建** `AddRecordForm.tsx` — 添加记录表单 +- **新建** `RecordTable.tsx` — 记录列表表格 +- **新建** `index.tsx` — 主页面(状态管理 + 组装) +- **复用** `src/hooks/useLocalStorage.ts` — localStorage 持久化 +- **修改** `src/App.tsx` — 添加路由 +- **修改** `src/config/navigation.tsx` — 添加导航入口 + +## 组件架构与数据流 + +```mermaid +flowchart TD + subgraph BookkeepingPage ["index.tsx - 主页面"] + State["useLocalStorage\n管理 records 数组"] + DateFilter["useState\n管理 dateRange 筛选"] + Derived["useMemo\n派生: filteredRecords\ntotalIncome / totalExpense / balance"] + end + + State --> Derived + DateFilter --> Derived + + Derived -->|"totalIncome, totalExpense, balance"| SummaryCards + State -->|"onAdd callback"| AddRecordForm + Derived -->|"filteredRecords"| RecordTable + State -->|"onDelete callback"| RecordTable + + AddRecordForm -->|"调用 onAdd(record)"| State + RecordTable -->|"调用 onDelete(id)"| State +``` + +## 各组件设计 + +### 1. types.ts — 数据模型与常量 + +```typescript +export interface BookkeepingRecord { + id: string; + type: 'income' | 'expense'; + amount: number; + category: string; + description: string; + date: string; // YYYY-MM-DD 格式字符串,便于序列化 +} + +export const INCOME_CATEGORIES = ['工资', '奖金', '投资收益', '兼职', '红包', '其他收入']; +export const EXPENSE_CATEGORIES = ['餐饮', '交通', '购物', '住房', '娱乐', '医疗', '教育', '其他支出']; +``` + +### 2. SummaryCards.tsx — 汇总卡片 + +- **Props**: `totalIncome: number`, `totalExpense: number`, `balance: number` +- **职责**: 纯展示组件,三列网格布局,分别展示收入(绿)、支出(红)、余额(紫) +- **内部**: 包含一个 `SummaryCard` 子组件,接收 `title / value / icon / color / bgGradient` +- **Ant Design 组件**: `@ant-design/icons`(RiseOutlined, FallOutlined, WalletOutlined) + +### 3. AddRecordForm.tsx — 添加记录表单 + +- **Props**: `onAdd: (record: BookkeepingRecord) => void` +- **职责**: 表单输入与校验,生成带唯一 id 的记录并回调 `onAdd` +- **内部状态**: `selectedType`(控制分类下拉选项联动切换) +- **Ant Design 组件**: `Form`(inline 布局)、`Select`、`InputNumber`、`DatePicker`、`Input`、`Button` +- **交互**: 类型切换时自动清空分类选择;提交后重置表单,日期默认为今天 + +### 4. RecordTable.tsx — 记录列表 + +- **Props**: `records: BookkeepingRecord[]`, `onDelete: (id: string) => void`, `dateRange / onDateRangeChange`(日期筛选状态提升到父组件,因为筛选结果需同时影响 SummaryCards) +- **职责**: 展示记录列表,提供日期范围筛选 UI 和删除操作 +- **Ant Design 组件**: `Table`(含排序、类型筛选)、`DatePicker.RangePicker`、`Tag`、`Popconfirm`、`Button` +- **表格列**: 日期(可排序)、类型(Tag + 筛选)、分类、金额(带颜色 + 可排序)、备注、操作(删除) + +### 5. index.tsx — 主页面(状态中枢) + +- **状态管理**: + - `useLocalStorage('bookkeeping_records', [])` — 记录数据 + 持久化 + - `useState<[Dayjs | null, Dayjs | null] | null>(null)` — 日期筛选范围 +- **派生数据**(`useMemo`): + - `filteredRecords` — 根据 dateRange 过滤 records + - `totalIncome / totalExpense / balance` — 根据 filteredRecords 汇总 +- **回调函数**(`useCallback`): + - `handleAdd` — prepend 新记录到数组头部 + - `handleDelete` — 按 id 过滤删除 +- **渲染**: 依次组装 SummaryCards -> AddRecordForm -> RecordTable + +## 路由与导航 + +- `src/config/navigation.tsx`: 在 `NavPathKey` 枚举添加 `Bookkeeping = '/bookkeeping'`,在 `mainNavItems` 末尾添加导航项 +- `src/App.tsx`: import Bookkeeping 组件,添加 `} />` + +## UI 风格 + +沿用项目现有 Tailwind CSS 风格:圆角卡片(`rounded-2xl`)、渐变背景、阴影效果,与其他页面保持视觉一致。 + +## 测试方案(TDD) + +### 测试基础设施 + +- 测试框架:Vitest + jsdom + @testing-library/react +- 已有 setup:`src/test/setupTests.ts` 已 mock `matchMedia`、`IntersectionObserver`、`ResizeObserver`(Ant Design 所需) +- 自定义 render:`src/test/utils.tsx` 提供包含 `BrowserRouter` 的 `render` 函数 +- 风格约定:`describe` 中使用中文描述 + +### TDD 流程 + +每个组件遵循 Red-Green-Refactor 循环: + +```mermaid +flowchart LR + WriteTest["1. 编写测试文件\n(测试全部 FAIL)"] --> Implement["2. 实现组件代码\n(使测试 PASS)"] --> Refactor["3. 重构优化\n(保持测试 PASS)"] + Refactor -.-> WriteTest +``` + +执行顺序按依赖关系从底层到顶层:types.ts (无需测试) -> SummaryCards -> AddRecordForm -> RecordTable -> index.tsx + +### 测试文件与用例设计 + +#### 1. SummaryCards.test.tsx + +```typescript +describe('SummaryCards', () => { + describe('渲染', () => { + it('应该渲染三张汇总卡片:总收入、总支出、余额'); + it('应该正确显示传入的总收入金额'); + it('应该正确显示传入的总支出金额'); + it('应该正确显示传入的余额'); + }); + + describe('金额格式化', () => { + it('金额应该保留两位小数'); + it('金额应该带有 ¥ 前缀'); + it('传入 0 时应显示 ¥ 0.00'); + it('大数字应该有千分位分隔符(如 ¥ 12,345.00)'); + }); +}); +``` + +测试策略:纯 props 驱动,直接 `render()` 后用 `screen.getByText` 断言文本内容。 + +#### 2. AddRecordForm.test.tsx + +```typescript +describe('AddRecordForm', () => { + describe('渲染', () => { + it('应该渲染类型选择器,默认值为"支出"'); + it('应该渲染金额输入框'); + it('应该渲染分类选择器'); + it('应该渲染日期选择器,默认值为今天'); + it('应该渲染备注输入框'); + it('应该渲染"添加"按钮'); + }); + + describe('分类联动', () => { + it('当类型为"支出"时,分类下拉应显示支出分类列表'); + it('当类型切换为"收入"时,分类下拉应切换为收入分类列表'); + it('类型切换时应清空已选分类'); + }); + + describe('表单校验', () => { + it('金额为空时点击添加,应显示校验错误提示'); + it('分类未选择时点击添加,应显示校验错误提示'); + }); + + describe('提交', () => { + it('填写完整信息后点击添加,应调用 onAdd 并传入正确的记录对象'); + it('提交的记录应包含唯一 id、type、amount、category、description、date 字段'); + it('提交成功后应重置表单'); + }); +}); +``` + +测试策略:使用 `vi.fn()` mock `onAdd`,`@testing-library/user-event` 模拟交互。 + +#### 3. RecordTable.test.tsx + +```typescript +describe('RecordTable', () => { + const mockRecords: BookkeepingRecord[] = [ + { id: '1', type: 'income', amount: 5000, category: '工资', description: '月薪', date: '2025-03-01' }, + { id: '2', type: 'expense', amount: 30, category: '餐饮', description: '午餐', date: '2025-03-02' }, + { id: '3', type: 'expense', amount: 200, category: '交通', description: '加油', date: '2025-02-15' }, + ]; + + describe('渲染', () => { + it('应该渲染"收支明细"标题'); + it('应该渲染日期范围选择器'); + it('应该渲染包含所有记录的表格'); + it('无记录时应显示空状态提示"暂无记录,开始记账吧!"'); + }); + + describe('表格列展示', () => { + it('应该正确显示每条记录的日期'); + it('收入记录应显示绿色"收入" Tag'); + it('支出记录应显示红色"支出" Tag'); + it('收入金额应以绿色和 + 号前缀展示'); + it('支出金额应以红色和 - 号前缀展示'); + it('应该显示记录的分类和备注'); + }); + + describe('删除操作', () => { + it('点击删除按钮应弹出确认框'); + it('确认删除后应调用 onDelete 并传入对应记录 id'); + it('取消删除后不应调用 onDelete'); + }); + + describe('日期筛选', () => { + it('选择日期范围后应调用 onDateRangeChange'); + it('点击"清除筛选"按钮应将日期范围重置为 null'); + it('无筛选条件时不应显示"清除筛选"按钮'); + }); +}); +``` + +#### 4. index.test.tsx — 集成测试 + +```typescript +describe('Bookkeeping 页面集成测试', () => { + beforeEach(() => { localStorage.clear(); }); + + describe('初始状态', () => { + it('应该渲染页面标题"记账本"'); + it('无记录时汇总卡片应全部显示 ¥ 0.00'); + it('无记录时表格应显示空状态'); + }); + + describe('添加记录', () => { + it('添加一条支出记录后,表格应显示该记录'); + it('添加一条支出记录后,总支出和余额应更新'); + it('添加一条收入记录后,总收入和余额应更新'); + it('连续添加多条记录,汇总数据应正确累计'); + }); + + describe('删除记录', () => { + it('删除一条记录后,表格不再显示该记录'); + it('删除记录后汇总数据应同步更新'); + }); + + describe('localStorage 持久化', () => { + it('添加记录后 localStorage 应写入数据'); + it('localStorage 有已存数据时,页面加载应正确恢复记录'); + }); + + describe('日期筛选与汇总联动', () => { + it('设置日期范围后,表格只显示范围内的记录'); + it('设置日期范围后,汇总卡片只统计范围内的收支'); + it('清除筛选后,恢复显示全部记录和完整汇总'); + }); +}); +``` + +### 测试覆盖矩阵 + +- **types.ts**: 纯类型和常量定义,无需测试 +- **SummaryCards**: 渲染正确性 + 金额格式化 (8 cases) +- **AddRecordForm**: 渲染 + 分类联动 + 校验 + 提交回调 (11 cases) +- **RecordTable**: 渲染 + 列展示 + 删除交互 + 日期筛选 (13 cases) +- **index.tsx 集成**: 初始状态 + 增删 CRUD + localStorage + 筛选联动 (12 cases) +- **合计约 44 个测试用例** + +### Ant Design 组件测试注意事项 + +- **Select**: 需要 `fireEvent.mouseDown` 触发下拉,再 `fireEvent.click` 选项;option 在 portal 中渲染 +- **DatePicker**: 在 jsdom 中交互复杂,集成测试中可通过预设 `localStorage` 数据来绕过 +- **Popconfirm**: 点击触发按钮后,确认框在 portal 中渲染,用 `screen.getByText('确定')` 查找 +- **Table**: 默认渲染在 `` 中,可通过 `screen.getAllByRole('row')` 获取行 +- **message**: 使用 `message.useMessage()` 的静态方法,在测试中通过 `screen.getByText` 验证 diff --git a/src/pages/Bookkeeping/RecordTable.test.tsx b/src/pages/Bookkeeping/RecordTable.test.tsx new file mode 100644 index 0000000..0410bbd --- /dev/null +++ b/src/pages/Bookkeeping/RecordTable.test.tsx @@ -0,0 +1,172 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../test/utils'; +import { RecordTable } from './RecordTable'; +import type { BookkeepingRecord, DateRange } from './types'; +import dayjs from 'dayjs'; + +const mockRecords: BookkeepingRecord[] = [ + { id: '1', type: 'income', amount: 5000, category: '工资', description: '月薪', date: '2025-03-01' }, + { id: '2', type: 'expense', amount: 30, category: '餐饮', description: '午餐', date: '2025-03-02' }, + { id: '3', type: 'expense', amount: 200, category: '交通', description: '加油', date: '2025-02-15' }, +]; + +describe('RecordTable', () => { + const mockOnDelete = vi.fn(); + const mockOnDateRangeChange = vi.fn(); + + beforeEach(() => { + mockOnDelete.mockClear(); + mockOnDateRangeChange.mockClear(); + }); + + const renderTable = ( + records: BookkeepingRecord[] = mockRecords, + dateRange: DateRange = null, + ) => + render( + , + ); + + describe('渲染', () => { + it('应该渲染"收支明细"标题', () => { + renderTable(); + expect(screen.getByText('收支明细')).toBeInTheDocument(); + }); + + it('应该渲染包含所有记录的表格', () => { + renderTable(); + expect(screen.getByText('月薪')).toBeInTheDocument(); + expect(screen.getByText('午餐')).toBeInTheDocument(); + expect(screen.getByText('加油')).toBeInTheDocument(); + }); + + it('无记录时应显示空状态提示"暂无记录,开始记账吧!"', () => { + renderTable([]); + expect(screen.getByText('暂无记录,开始记账吧!')).toBeInTheDocument(); + }); + }); + + describe('表格列展示', () => { + it('应该正确显示每条记录的日期', () => { + renderTable(); + expect(screen.getByText('2025-03-01')).toBeInTheDocument(); + expect(screen.getByText('2025-03-02')).toBeInTheDocument(); + expect(screen.getByText('2025-02-15')).toBeInTheDocument(); + }); + + it('收入记录应显示"收入" Tag', () => { + renderTable(); + const tags = document.querySelectorAll('.ant-tag'); + const incomeTag = Array.from(tags).find((t) => t.textContent === '收入'); + expect(incomeTag).toBeTruthy(); + }); + + it('支出记录应显示"支出" Tag', () => { + renderTable(); + const tags = document.querySelectorAll('.ant-tag'); + const expenseTags = Array.from(tags).filter((t) => t.textContent === '支出'); + expect(expenseTags.length).toBe(2); + }); + + it('收入金额应以 + 号前缀展示', () => { + renderTable(); + expect(screen.getByText(/\+.*5,000\.00/)).toBeInTheDocument(); + }); + + it('支出金额应以 - 号前缀展示', () => { + renderTable(); + expect(screen.getByText(/-.*30\.00/)).toBeInTheDocument(); + expect(screen.getByText(/-.*200\.00/)).toBeInTheDocument(); + }); + + it('应该显示记录的分类和备注', () => { + renderTable(); + expect(screen.getByText('工资')).toBeInTheDocument(); + expect(screen.getByText('餐饮')).toBeInTheDocument(); + expect(screen.getByText('交通')).toBeInTheDocument(); + }); + }); + + describe('删除操作', () => { + it('点击删除按钮应弹出确认框', async () => { + renderTable(); + const deleteButtons = document.querySelectorAll('.ant-btn-dangerous'); + fireEvent.click(deleteButtons[0]!); + + await waitFor(() => { + expect(screen.getByText('确定删除这条记录吗?')).toBeInTheDocument(); + }); + }); + + it('确认删除后应调用 onDelete 并传入对应记录 id', async () => { + renderTable(); + const deleteButtons = document.querySelectorAll('.ant-btn-dangerous'); + fireEvent.click(deleteButtons[0]!); + + let confirmBtn: Element | null = null; + await waitFor(() => { + expect(screen.getByText('确定删除这条记录吗?')).toBeInTheDocument(); + const popover = document.querySelector('.ant-popconfirm'); + const btns = popover?.querySelectorAll('button') ?? []; + confirmBtn = Array.from(btns).find( + (b) => b.textContent?.includes('确定') && !b.textContent?.includes('删除'), + ) ?? null; + if (!confirmBtn) { + confirmBtn = Array.from(btns).pop() ?? null; + } + expect(confirmBtn).toBeTruthy(); + }); + + fireEvent.click(confirmBtn!); + + await waitFor(() => { + expect(mockOnDelete).toHaveBeenCalledTimes(1); + const calledId = mockOnDelete.mock.calls[0][0]; + expect(mockRecords.some((r) => r.id === calledId)).toBe(true); + }); + }); + + it('取消删除后不应调用 onDelete', async () => { + renderTable(); + const deleteButtons = document.querySelectorAll('.ant-btn-dangerous'); + fireEvent.click(deleteButtons[0]!); + + let cancelBtn: Element | null = null; + await waitFor(() => { + expect(screen.getByText('确定删除这条记录吗?')).toBeInTheDocument(); + const popover = document.querySelector('.ant-popconfirm'); + const btns = popover?.querySelectorAll('button') ?? []; + cancelBtn = Array.from(btns).find( + (b) => b.textContent?.includes('取消'), + ) ?? null; + if (!cancelBtn) { + cancelBtn = Array.from(btns)[0] ?? null; + } + expect(cancelBtn).toBeTruthy(); + }); + + fireEvent.click(cancelBtn!); + + expect(mockOnDelete).not.toHaveBeenCalled(); + }); + }); + + describe('日期筛选', () => { + it('点击"清除筛选"按钮应调用 onDateRangeChange(null)', () => { + renderTable(mockRecords, [dayjs('2025-03-01'), dayjs('2025-03-31')]); + const clearBtn = screen.getByText('清除筛选'); + fireEvent.click(clearBtn); + expect(mockOnDateRangeChange).toHaveBeenCalledWith(null); + }); + + it('无筛选条件时不应显示"清除筛选"按钮', () => { + renderTable(mockRecords, null); + expect(screen.queryByText('清除筛选')).not.toBeInTheDocument(); + }); + }); +}); diff --git a/src/pages/Bookkeeping/RecordTable.tsx b/src/pages/Bookkeeping/RecordTable.tsx new file mode 100644 index 0000000..ce7afc5 --- /dev/null +++ b/src/pages/Bookkeeping/RecordTable.tsx @@ -0,0 +1,121 @@ +import React from 'react'; +import { Table, Tag, Button, Popconfirm, DatePicker, Space } from 'antd'; +import { DeleteOutlined } from '@ant-design/icons'; +import dayjs from 'dayjs'; +import type { ColumnsType } from 'antd/es/table'; + +import type { BookkeepingRecord, DateRange } from './types'; + +const { RangePicker } = DatePicker; + +export interface RecordTableProps { + records: BookkeepingRecord[]; + onDelete: (id: string) => void; + dateRange: DateRange; + onDateRangeChange: (range: DateRange) => void; +} + +const columns = (onDelete: (id: string) => void): ColumnsType => [ + { + title: '日期', + dataIndex: 'date', + key: 'date', + width: 120, + sorter: (a, b) => dayjs(a.date).unix() - dayjs(b.date).unix(), + defaultSortOrder: 'descend', + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 90, + filters: [ + { text: '收入', value: 'income' }, + { text: '支出', value: 'expense' }, + ], + onFilter: (value, record) => record.type === value, + render: (type: string) => ( + + {type === 'income' ? '收入' : '支出'} + + ), + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 110, + }, + { + title: '金额', + dataIndex: 'amount', + key: 'amount', + width: 130, + sorter: (a, b) => a.amount - b.amount, + render: (amount: number, record: BookkeepingRecord) => ( + + {record.type === 'income' ? '+' : '-'} ¥ + {amount.toLocaleString('zh-CN', { minimumFractionDigits: 2 })} + + ), + }, + { + title: '备注', + dataIndex: 'description', + key: 'description', + ellipsis: true, + }, + { + title: '操作', + key: 'action', + width: 80, + render: (_: unknown, record: BookkeepingRecord) => ( + onDelete(record.id)} + okText="确定" + cancelText="取消" + > + + )} + +
+ + columns={columns(onDelete)} + dataSource={records} + rowKey="id" + pagination={{ + pageSize: 10, + showTotal: (total) => `共 ${total} 条记录`, + }} + locale={{ emptyText: '暂无记录,开始记账吧!' }} + size="middle" + /> + +); diff --git a/src/pages/Bookkeeping/SummaryCards.test.tsx b/src/pages/Bookkeeping/SummaryCards.test.tsx new file mode 100644 index 0000000..2f7c138 --- /dev/null +++ b/src/pages/Bookkeeping/SummaryCards.test.tsx @@ -0,0 +1,92 @@ +import { describe, it, expect } from 'vitest'; +import { render, screen } from '../../test/utils'; +import { SummaryCards } from './SummaryCards'; + +describe('SummaryCards', () => { + describe('渲染', () => { + it('应该渲染三张汇总卡片:总收入、总支出、余额', () => { + render( + , + ); + expect(screen.getByText('总收入')).toBeInTheDocument(); + expect(screen.getByText('总支出')).toBeInTheDocument(); + expect(screen.getByText('余额')).toBeInTheDocument(); + }); + + it('应该正确显示传入的总收入金额', () => { + render( + , + ); + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('5,000.00'); + }); + + it('应该正确显示传入的总支出金额', () => { + render( + , + ); + const expenseCard = screen.getByText('总支出').closest('div[class*="rounded"]')!; + expect(expenseCard).toHaveTextContent('3,000.00'); + }); + + it('应该正确显示传入的余额', () => { + render( + , + ); + const balanceCard = screen.getByText('余额').closest('div[class*="rounded"]')!; + expect(balanceCard).toHaveTextContent('5,000.00'); + }); + }); + + describe('金额格式化', () => { + it('金额应该保留两位小数', () => { + render( + , + ); + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('100.00'); + }); + + it('金额应该带有 ¥ 前缀', () => { + render( + , + ); + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('¥'); + }); + + it('传入 0 时应显示 ¥ 0.00', () => { + render( + , + ); + const cards = screen.getAllByText(/¥/); + cards.forEach((card) => { + expect(card).toHaveTextContent('0.00'); + }); + }); + + it('大数字应该有千分位分隔符', () => { + render( + , + ); + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('12,345.60'); + }); + }); +}); diff --git a/src/pages/Bookkeeping/SummaryCards.tsx b/src/pages/Bookkeeping/SummaryCards.tsx new file mode 100644 index 0000000..8223739 --- /dev/null +++ b/src/pages/Bookkeeping/SummaryCards.tsx @@ -0,0 +1,77 @@ +import React from 'react'; +import { + RiseOutlined, + FallOutlined, + WalletOutlined, +} from '@ant-design/icons'; + +interface SummaryCardProps { + title: string; + value: number; + icon: React.ReactNode; + color: string; + bgGradient: string; +} + +const formatAmount = (value: number) => + `¥ ${value.toLocaleString('zh-CN', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; + +const SummaryCard: React.FC = ({ + title, + value, + icon, + color, + bgGradient, +}) => ( +
+
+ {title} +
+ {icon} +
+
+
+ {formatAmount(value)} +
+
+); + +export interface SummaryCardsProps { + totalIncome: number; + totalExpense: number; + balance: number; +} + +export const SummaryCards: React.FC = ({ + totalIncome, + totalExpense, + balance, +}) => ( +
+ } + color="bg-green-500" + bgGradient="bg-gradient-to-br from-green-50 to-emerald-50" + /> + } + color="bg-red-500" + bgGradient="bg-gradient-to-br from-red-50 to-rose-50" + /> + } + color="bg-indigo-500" + bgGradient="bg-gradient-to-br from-indigo-50 to-purple-50" + /> +
+); diff --git a/src/pages/Bookkeeping/index.test.tsx b/src/pages/Bookkeeping/index.test.tsx new file mode 100644 index 0000000..5c600a2 --- /dev/null +++ b/src/pages/Bookkeeping/index.test.tsx @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '../../test/utils'; +import Bookkeeping from './index'; +import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, STORAGE_KEY } from './types'; +import type { BookkeepingRecord } from './types'; + +async function selectOption(container: HTMLElement, index: number, optionTitle: string) { + const selects = container.querySelectorAll('.ant-select'); + const target = selects[index]; + fireEvent.mouseDown(target.querySelector('.ant-select-content')!); + + await waitFor(() => { + const option = document.querySelector(`.ant-select-item[title="${optionTitle}"]`); + expect(option).toBeTruthy(); + fireEvent.click(option!); + }); +} + +async function addRecord( + container: HTMLElement, + opts: { type?: 'income' | 'expense'; amount: string; category: string; description?: string }, +) { + if (opts.type === 'income') { + await selectOption(container, 0, '收入'); + } + + fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: opts.amount } }); + + const categorySelectIndex = opts.type === 'income' ? 1 : 1; + await selectOption(container, categorySelectIndex, opts.category); + + if (opts.description) { + fireEvent.change(screen.getByPlaceholderText('备注(可选)'), { + target: { value: opts.description }, + }); + } + + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(screen.getByText(opts.category)).toBeInTheDocument(); + }); +} + +describe('Bookkeeping 页面集成测试', () => { + beforeEach(() => { + localStorage.clear(); + }); + + describe('初始状态', () => { + it('应该渲染页面标题"记账本"', () => { + render(); + expect(screen.getByText('记账本')).toBeInTheDocument(); + }); + + it('无记录时汇总卡片应全部显示 ¥ 0.00', () => { + render(); + const amounts = screen.getAllByText(/¥\s*0\.00/); + expect(amounts.length).toBe(3); + }); + + it('无记录时表格应显示空状态', () => { + render(); + expect(screen.getByText('暂无记录,开始记账吧!')).toBeInTheDocument(); + }); + }); + + describe('添加记录', () => { + it('添加一条支出记录后,表格应显示该记录', async () => { + const { container } = render(); + await addRecord(container, { + amount: '50', + category: EXPENSE_CATEGORIES[0], + description: '午饭', + }); + + expect(screen.getByText('午饭')).toBeInTheDocument(); + expect(screen.getByText(EXPENSE_CATEGORIES[0])).toBeInTheDocument(); + }); + + it('添加一条支出记录后,总支出和余额应更新', async () => { + const { container } = render(); + await addRecord(container, { + amount: '50', + category: EXPENSE_CATEGORIES[0], + }); + + const expenseCard = screen.getByText('总支出').closest('div[class*="rounded"]')!; + expect(expenseCard).toHaveTextContent('50.00'); + }); + + it('添加一条收入记录后,总收入和余额应更新', async () => { + const { container } = render(); + await addRecord(container, { + type: 'income', + amount: '5000', + category: INCOME_CATEGORIES[0], + }); + + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('5,000.00'); + }); + }); + + describe('localStorage 持久化', () => { + it('添加记录后 localStorage 应写入数据', async () => { + const { container } = render(); + await addRecord(container, { + amount: '100', + category: EXPENSE_CATEGORIES[0], + }); + + const stored = localStorage.getItem(STORAGE_KEY); + expect(stored).toBeTruthy(); + const parsed = JSON.parse(stored!) as BookkeepingRecord[]; + expect(parsed.length).toBe(1); + expect(parsed[0].amount).toBe(100); + }); + + it('localStorage 有已存数据时,页面加载应正确恢复记录', () => { + const existingRecords: BookkeepingRecord[] = [ + { + id: 'test-1', + type: 'income', + amount: 8000, + category: '工资', + description: '三月工资', + date: '2025-03-01', + }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(existingRecords)); + + render(); + + expect(screen.getByText('三月工资')).toBeInTheDocument(); + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('8,000.00'); + }); + }); + + describe('删除记录', () => { + it('删除记录后汇总数据应同步更新', async () => { + const existingRecords: BookkeepingRecord[] = [ + { + id: 'del-1', + type: 'expense', + amount: 200, + category: '餐饮', + description: '聚餐', + date: '2025-03-01', + }, + ]; + localStorage.setItem(STORAGE_KEY, JSON.stringify(existingRecords)); + + render(); + expect(screen.getByText('聚餐')).toBeInTheDocument(); + + const deleteBtn = document.querySelector('.ant-btn-dangerous')!; + fireEvent.click(deleteBtn); + + await waitFor(() => { + expect(screen.getByText('确定删除这条记录吗?')).toBeInTheDocument(); + }); + + const popover = document.querySelector('.ant-popconfirm'); + const btns = popover?.querySelectorAll('button') ?? []; + const confirmBtn = Array.from(btns).pop(); + fireEvent.click(confirmBtn!); + + await waitFor(() => { + expect(screen.queryByText('聚餐')).not.toBeInTheDocument(); + }); + + const expenseCard = screen.getByText('总支出').closest('div[class*="rounded"]')!; + expect(expenseCard).toHaveTextContent('0.00'); + }); + }); +}); diff --git a/src/pages/Bookkeeping/index.tsx b/src/pages/Bookkeeping/index.tsx new file mode 100644 index 0000000..d702ba6 --- /dev/null +++ b/src/pages/Bookkeeping/index.tsx @@ -0,0 +1,81 @@ +import React, { useState, useMemo, useCallback } from 'react'; +import dayjs from 'dayjs'; + +import { useLocalStorage } from '../../hooks/useLocalStorage'; +import { SummaryCards } from './SummaryCards'; +import { AddRecordForm } from './AddRecordForm'; +import { RecordTable } from './RecordTable'; +import { STORAGE_KEY, type BookkeepingRecord, type DateRange } from './types'; + +const Bookkeeping: React.FC = () => { + const [records, setRecords] = useLocalStorage( + STORAGE_KEY, + [], + ); + const [dateRange, setDateRange] = useState(null); + + const filteredRecords = useMemo(() => { + if (!dateRange || !dateRange[0] || !dateRange[1]) { return records; } + const start = dateRange[0].startOf('day'); + const end = dateRange[1].endOf('day'); + return records.filter((r) => { + const d = dayjs(r.date); + return ( + d.isAfter(start.subtract(1, 'millisecond')) && + d.isBefore(end.add(1, 'millisecond')) + ); + }); + }, [records, dateRange]); + + const { totalIncome, totalExpense, balance } = useMemo(() => { + const income = filteredRecords + .filter((r) => r.type === 'income') + .reduce((sum, r) => sum + r.amount, 0); + const expense = filteredRecords + .filter((r) => r.type === 'expense') + .reduce((sum, r) => sum + r.amount, 0); + return { totalIncome: income, totalExpense: expense, balance: income - expense }; + }, [filteredRecords]); + + const handleAdd = useCallback( + (record: BookkeepingRecord) => { + setRecords((prev) => [record, ...prev]); + }, + [setRecords], + ); + + const handleDelete = useCallback( + (id: string) => { + setRecords((prev) => prev.filter((r) => r.id !== id)); + }, + [setRecords], + ); + + return ( +
+
+

记账本

+

+ 管理你的收入与支出,掌握财务状况 +

+
+ + + + + + +
+ ); +}; + +export default Bookkeeping; diff --git a/src/pages/Bookkeeping/types.ts b/src/pages/Bookkeeping/types.ts new file mode 100644 index 0000000..db7aca0 --- /dev/null +++ b/src/pages/Bookkeeping/types.ts @@ -0,0 +1,34 @@ +import type { Dayjs } from 'dayjs'; + +export interface BookkeepingRecord { + id: string; + type: 'income' | 'expense'; + amount: number; + category: string; + description: string; + date: string; +} + +export type DateRange = [Dayjs | null, Dayjs | null] | null; + +export const STORAGE_KEY = 'bookkeeping_records'; + +export const INCOME_CATEGORIES = [ + '工资', + '奖金', + '投资收益', + '兼职', + '红包', + '其他收入', +]; + +export const EXPENSE_CATEGORIES = [ + '餐饮', + '交通', + '购物', + '住房', + '娱乐', + '医疗', + '教育', + '其他支出', +]; From 6aa937dc489e7e191e4f5a774e747fa40d1c209c Mon Sep 17 00:00:00 2001 From: sqliang Date: Tue, 3 Mar 2026 01:20:55 +0800 Subject: [PATCH 2/2] feat: add app example landing page with route restructuring --- .gitignore | 6 + .sisyphus/boulder.json | 7 + .sisyphus/plans/git-worktree-commit.md | 124 ++++++++++++++ src/App.tsx | 41 ++++- src/config/navigation.tsx | 49 +++--- src/pages/AppExample/index.tsx | 160 +++++++++++++++++++ src/pages/Bookkeeping/AddRecordForm.test.tsx | 45 +++--- src/pages/Bookkeeping/SummaryCards.test.tsx | 13 ++ src/pages/Bookkeeping/index.test.tsx | 114 +++++++++++-- src/pages/Posts.tsx | 2 - src/test/antdHelpers.ts | 39 +++++ src/test/setupTests.ts | 10 ++ src/test/utils.tsx | 6 +- 13 files changed, 548 insertions(+), 68 deletions(-) create mode 100644 .sisyphus/boulder.json create mode 100644 .sisyphus/plans/git-worktree-commit.md create mode 100644 src/pages/AppExample/index.tsx create mode 100644 src/test/antdHelpers.ts diff --git a/.gitignore b/.gitignore index 9bb30b5..767ea87 100644 --- a/.gitignore +++ b/.gitignore @@ -44,3 +44,9 @@ coverage/ # Vitest .vitest/ + +# worktrees +.claude/worktrees +.opencode/worktrees +.cursor/worktrees +.vscode/worktrees diff --git a/.sisyphus/boulder.json b/.sisyphus/boulder.json new file mode 100644 index 0000000..7247262 --- /dev/null +++ b/.sisyphus/boulder.json @@ -0,0 +1,7 @@ +{ + "active_plan": "/Users/liangshiquan/sqliang-github/full-stack-workplace/react-playround/.sisyphus/plans/git-worktree-commit.md", + "started_at": "2026-03-02T15:19:17Z", + "session_ids": [], + "plan_name": "git-worktree-commit", + "worktree_path": "/Users/liangshiquan/.cursor/worktrees/react-playround/uip" +} diff --git a/.sisyphus/plans/git-worktree-commit.md b/.sisyphus/plans/git-worktree-commit.md new file mode 100644 index 0000000..987dbe9 --- /dev/null +++ b/.sisyphus/plans/git-worktree-commit.md @@ -0,0 +1,124 @@ +# 代码提交计划 - react-playround/uip worktree + +## 任务概述 + +使用 git-workflow 流程将 worktree 中的代码变更进行本地提交。 + +## 当前状态 + +| 项目 | 值 | +|------|-----| +| Worktree 路径 | /Users/liangshiquan/.cursor/worktrees/react-playround/uip | +| 当前分支 | feat/bookkeeping | +| 提交类型 | 本地提交 (不推送到远程) | + +## 提交信息 + +### 提交消息 (Conventional Commits) + +feat(bookkeeping): add bookkeeping feature with record management + +Add complete bookkeeping module with: +- AddRecordForm for creating new financial records +- RecordTable for displaying and managing records +- SummaryCards for financial overview +- Full test coverage for all components +- Update App routing and navigation to include bookkeeping page + +### 提交范围 + +将提交以下文件: + +已跟踪文件的修改: +- package.json - 依赖更新 +- pnpm-lock.yaml - 锁文件更新 +- src/App.tsx - 路由配置更新 +- src/config/navigation.tsx - 导航配置更新 + +新增目录/文件: +- src/pages/Bookkeeping/ - 完整的新功能模块 + - AddRecordForm.tsx + - AddRecordForm.test.tsx + - RecordTable.tsx + - RecordTable.test.tsx + - SummaryCards.tsx + - SummaryCards.test.tsx + - index.tsx + - index.test.tsx + - types.ts + - README.md + +### 不提交的文件 + +- .pnpm-store/ - pnpm 缓存目录 (已在 .gitignore 中) + +## 执行步骤 + +### 步骤 0: 验证 Husky Hooks 状态 + +检查当前 hooks 配置状态: + +git -C /Users/liangshiquan/.cursor/worktrees/react-playround/uip ls-hooks + +当前状态分析: +- .husky/pre-commit 已存在 (运行 lint-staged) +- .husky/commit-msg 不存在 (commitlint 不会在提交时检查) + +### 步骤 1: 创建 commit-msg Hook + +创建 commit-msg hook 以启用 commitlint 验证: + +cat > /Users/liangshiquan/.cursor/worktrees/react-playround/uip/.husky/commit-msg << 'EOF' +#!/usr/bin/env sh +. "$(dirname -- "$0")/_/husky.sh" + +npx --no -- commitlint --edit "${1}" +EOF + +chmod +x /Users/liangshiquan/.cursor/worktrees/react-playround/uip/.husky/commit-msg + +### 步骤 2: 添加所有变更到暂存区 + +git -C /Users/liangshiquan/.cursor/worktrees/react-playround/uip add -A + +### 步骤 3: 验证暂存内容 + +git -C /Users/liangshiquan/.cursor/worktrees/react-playround/uip status + +预期输出:所有变更已暂存,无未跟踪文件(除 .pnpm-store/) + +### 步骤 4: 执行提交 + +git -C /Users/liangshiquan/.cursor/worktrees/react-playround/uip commit -m "feat(bookkeeping): add bookkeeping feature with record management + +Add complete bookkeeping module with: +- AddRecordForm for creating new financial records +- RecordTable for displaying and managing records +- SummaryCards for financial overview +- Full test coverage for all components +- Update App routing and navigation to include bookkeeping page" + +此时 commit-msg hook 会自动运行 commitlint 验证提交消息 + +### 步骤 5: 验证提交结果 + +git -C /Users/liangshiquan/.cursor/worktrees/react-playround/uip log -1 --stat + +预期输出:显示最新的提交包含所有预期文件 + +## 验证清单 + +- [ ] commit-msg hook 已创建并有执行权限 +- [ ] 所有代码变更已暂存 +- [ ] 提交消息符合 Conventional Commits 格式 +- [ ] commitlint 验证通过 +- [ ] 提交成功无错误 +- [ ] git status 显示工作区干净 +- [ ] git log 显示新提交在分支顶部 + +## 注意事项 + +1. 不推送: 用户明确要求仅本地提交,不推送到远程仓库 +2. 单一提交: 所有变更合并为一个提交 +3. 使用 git-workflow 流程: 遵循 Conventional Commits 规范 +4. Hook 验证: commit-msg hook 会在提交时验证消息格式 diff --git a/src/App.tsx b/src/App.tsx index fde3b0e..525f24f 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -12,6 +12,7 @@ import { ShoppingCart } from './pages/ShoppingCart'; import RelayExample from './pages/RelayExample'; import Todo from './pages/Todo'; import Bookkeeping from './pages/Bookkeeping'; +import AppExample from './pages/AppExample'; import './index.css'; import { Loading } from './components/Loading'; @@ -28,11 +29,38 @@ import { import type { MenuProps } from 'antd'; +// 获取匹配路径 +const matchActiveNavKey = (navKeys: string[], pathname: string) => { + if (!navKeys.length) { + return ''; + } + return navKeys + .filter(key => { + if (key === '/') { + return pathname === '/'; + } + // 匹配路径是否完全相等或以指定路径开头 + return pathname === key || pathname.startsWith(key + '/'); + }) + // 按路径长度降序排序,返回最长的匹配路径 + // 如果没有任何匹配,则返回空字符串 + .sort((a, b) => b.length - a.length)[0] || ''; +}; + const AppContent = () => { const location = useLocation(); const [collapsed, setCollapsed] = useState(false); - const secondaryNavItems = sideNavItems[location.pathname] || []; + // 获取主导航的匹配路径 + const activeMainNavKey = matchActiveNavKey( + mainNavItems.map(item => item.key as string), + location.pathname + ) || location.pathname; + + // 获取二级导航的匹配路径 + const matchingKey = matchActiveNavKey(Object.keys(sideNavItems), location.pathname); + // 获取二级导航的菜单项 + const secondaryNavItems = matchingKey ? sideNavItems[matchingKey] : []; return ( }> @@ -57,7 +85,7 @@ const AppContent = () => { {/* Main navigation */} { } /> } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> diff --git a/src/config/navigation.tsx b/src/config/navigation.tsx index e30e16e..bc054d1 100644 --- a/src/config/navigation.tsx +++ b/src/config/navigation.tsx @@ -14,10 +14,11 @@ enum NavPathKey { Hooks = '/hooks', useState = '/hooks/useState', useEffect = '/hooks/useEffect', - ShoppingCart = '/shopping-cart', - RelayExample = '/relay-example', - Todo = '/todo', - Bookkeeping = '/bookkeeping', + AppExample = '/app-example', + ShoppingCart = '/app-example/shopping-cart', + RelayExample = '/app-example/relay-example', + Todo = '/app-example/todo', + Bookkeeping = '/app-example/bookkeeping', }; export const mainNavItems: NavItem[] = [ @@ -35,27 +36,9 @@ export const mainNavItems: NavItem[] = [ ), }, { - key: NavPathKey.ShoppingCart, + key: NavPathKey.AppExample, label: ( - Shopping Cart - ), - }, - { - key: NavPathKey.RelayExample, - label: ( - Relay Example - ), - }, - { - key: NavPathKey.Todo, - label: ( - 待办事项清单 - ), - }, - { - key: NavPathKey.Bookkeeping, - label: ( - 记账本 + App Example ), }, ]; @@ -69,4 +52,22 @@ export const sideNavItems: Record = { { key: NavPathKey.useState, label: 'useState' }, { key: NavPathKey.useEffect, label: 'useEffect' }, ], + [NavPathKey.AppExample]: [ + { + key: NavPathKey.Todo, + label: (待办事项清单), + }, + { + key: NavPathKey.Bookkeeping, + label: (记账本), + }, + { + key: NavPathKey.ShoppingCart, + label: (购物车), + }, + { + key: NavPathKey.RelayExample, + label: (Relay Example), + }, + ], }; diff --git a/src/pages/AppExample/index.tsx b/src/pages/AppExample/index.tsx new file mode 100644 index 0000000..1e1e0c1 --- /dev/null +++ b/src/pages/AppExample/index.tsx @@ -0,0 +1,160 @@ +/** + * @file AppExample/index.tsx + * @description AppExample landing page — introduces all sub-apps and highlights the React / AI capabilities each one demonstrates. + */ +import React, { memo } from 'react'; +import { Tag } from 'antd'; + +interface Capability { + label: string; + color: string; +} + +interface SubApp { + name: string; + path: string; + description: string; + reactCapabilities: Capability[]; + aiCapabilities: Capability[]; + emoji: string; +} + +const SUB_APPS: SubApp[] = [ + { + name: '待办事项清单', + path: '/app-example/todo', + emoji: '✅', + description: '一个功能完整的 Todo 应用,支持新增、完成、删除和筛选任务,是演示 React 状态管理的经典示例。', + reactCapabilities: [ + { label: 'useState', color: 'blue' }, + { label: 'useCallback', color: 'blue' }, + { label: 'useMemo', color: 'blue' }, + { label: 'React.memo', color: 'cyan' }, + ], + aiCapabilities: [ + { label: 'AI 辅助编码', color: 'purple' }, + { label: 'Vibe Coding', color: 'magenta' }, + ], + }, + { + name: '记账本', + path: '/app-example/bookkeeping', + emoji: '💰', + description: '收支记录与统计应用,支持分类记账、汇总展示。演示了组件拆分、表单处理与数据聚合计算。', + reactCapabilities: [ + { label: 'useState', color: 'blue' }, + { label: 'useCallback', color: 'blue' }, + { label: 'useMemo', color: 'blue' }, + { label: 'Ant Design Form', color: 'geekblue' }, + ], + aiCapabilities: [ + { label: 'AI 辅助编码', color: 'purple' }, + { label: 'Vibe Coding', color: 'magenta' }, + ], + }, + { + name: '购物车', + path: '/app-example/shopping-cart', + emoji: '🛒', + description: '带搜索和分类筛选的购物车应用,展示商品列表管理与购物车状态同步,综合运用多种 Hooks。', + reactCapabilities: [ + { label: 'useState', color: 'blue' }, + { label: 'useCallback', color: 'blue' }, + { label: 'useMemo', color: 'blue' }, + { label: 'React.memo', color: 'cyan' }, + ], + aiCapabilities: [ + { label: 'AI 辅助编码', color: 'purple' }, + { label: 'Vibe Coding', color: 'magenta' }, + ], + }, + { + name: 'Relay Example', + path: '/app-example/relay-example', + emoji: '🔗', + description: '使用 React Relay 进行 GraphQL 数据获取的示例,展示声明式数据依赖与 Suspense 加载状态处理。', + reactCapabilities: [ + { label: 'Suspense', color: 'blue' }, + { label: 'useLazyLoadQuery', color: 'blue' }, + { label: 'React Relay', color: 'geekblue' }, + { label: 'GraphQL', color: 'cyan' }, + ], + aiCapabilities: [ + { label: 'AI 辅助编码', color: 'purple' }, + { label: 'Vibe Coding', color: 'magenta' }, + ], + }, +]; + +const AppExample: React.FC = memo(() => { + return ( +
+
+ {/* Hero Section */} +
+

应用示例

+

+ 这里收录了多个完整的小应用示例,每个应用都综合运用了 + React 核心能力,并通过 AI 辅助工作流(Vibe Coding)协作完成开发。 +

+
+ + {/* Sub-app Cards Grid */} +
+ {SUB_APPS.map(app => ( + + {/* Card Header */} +
+ {app.emoji} +

+ {app.name} +

+
+ + {/* Description */} +

+ {app.description} +

+ + {/* React Capabilities */} +
+ + React + + + {app.reactCapabilities.map(cap => ( + + {cap.label} + + ))} + +
+ + {/* AI Capabilities */} +
+ + AI + + + {app.aiCapabilities.map(cap => ( + + {cap.label} + + ))} + +
+
+ ))} +
+
+
+ ); +}); + +AppExample.displayName = 'AppExample'; + +export default AppExample; diff --git a/src/pages/Bookkeeping/AddRecordForm.test.tsx b/src/pages/Bookkeeping/AddRecordForm.test.tsx index 73321bf..0025e56 100644 --- a/src/pages/Bookkeeping/AddRecordForm.test.tsx +++ b/src/pages/Bookkeeping/AddRecordForm.test.tsx @@ -2,22 +2,7 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import { render, screen, fireEvent, waitFor } from '../../test/utils'; import { AddRecordForm } from './AddRecordForm'; import { EXPENSE_CATEGORIES, INCOME_CATEGORIES } from './types'; - -/** - * Ant Design v6 Select helper: opens the dropdown by clicking on the - * select's trigger area, then picks the option by title attribute. - */ -async function selectOption(container: HTMLElement, index: number, optionTitle: string) { - const selects = container.querySelectorAll('.ant-select'); - const target = selects[index]; - fireEvent.mouseDown(target.querySelector('.ant-select-content')!); - - await waitFor(() => { - const option = document.querySelector(`.ant-select-item[title="${optionTitle}"]`); - expect(option).toBeTruthy(); - fireEvent.click(option!); - }); -} +import { selectAntdOption } from '../../test/antdHelpers'; describe('AddRecordForm', () => { const mockOnAdd = vi.fn(); @@ -71,7 +56,7 @@ describe('AddRecordForm', () => { it('当类型切换为"收入"时,分类下拉应切换为收入分类列表', async () => { const { container } = render(); - await selectOption(container, 0, '收入'); + await selectAntdOption(container, 0, '收入'); const selects = container.querySelectorAll('.ant-select'); fireEvent.mouseDown(selects[1].querySelector('.ant-select-content')!); @@ -114,7 +99,7 @@ describe('AddRecordForm', () => { const { container } = render(); fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: '50' } }); - await selectOption(container, 1, EXPENSE_CATEGORIES[0]); + await selectAntdOption(container, 1, EXPENSE_CATEGORIES[0]); fireEvent.change(screen.getByPlaceholderText('备注(可选)'), { target: { value: '测试备注' } }); fireEvent.click(screen.getByRole('button', { name: /添加/ })); @@ -133,7 +118,7 @@ describe('AddRecordForm', () => { const { container } = render(); fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: '200' } }); - await selectOption(container, 1, EXPENSE_CATEGORIES[1]); + await selectAntdOption(container, 1, EXPENSE_CATEGORIES[1]); fireEvent.click(screen.getByRole('button', { name: /添加/ })); await waitFor(() => { @@ -149,5 +134,27 @@ describe('AddRecordForm', () => { expect(record).toHaveProperty('date'); expect(record.date).toMatch(/^\d{4}-\d{2}-\d{2}$/); }); + + it('提交成功后表单应重置:金额清空、分类回到占位提示', async () => { + const { container } = render(); + + fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: '88' } }); + await selectAntdOption(container, 1, EXPENSE_CATEGORIES[0]); + fireEvent.change(screen.getByPlaceholderText('备注(可选)'), { + target: { value: '要重置的备注' }, + }); + fireEvent.click(screen.getByRole('button', { name: /添加/ })); + + await waitFor(() => { + expect(mockOnAdd).toHaveBeenCalledTimes(1); + }); + + // 提交后:金额输入框应清空,分类选择器应回到占位提示 + await waitFor(() => { + expect(screen.getByPlaceholderText('金额')).toHaveValue(''); + expect(screen.getByText('选择分类')).toBeInTheDocument(); + expect(screen.getByPlaceholderText('备注(可选)')).toHaveValue(''); + }); + }); }); }); diff --git a/src/pages/Bookkeeping/SummaryCards.test.tsx b/src/pages/Bookkeeping/SummaryCards.test.tsx index 2f7c138..b0b9000 100644 --- a/src/pages/Bookkeeping/SummaryCards.test.tsx +++ b/src/pages/Bookkeeping/SummaryCards.test.tsx @@ -88,5 +88,18 @@ describe('SummaryCards', () => { const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; expect(incomeCard).toHaveTextContent('12,345.60'); }); + + it('支出大于收入时余额为负数,应正确格式化显示', () => { + render( + , + ); + const balanceCard = screen.getByText('余额').closest('div[class*="rounded"]')!; + // 余额为负时 toLocaleString 会添加负号前缀 + expect(balanceCard).toHaveTextContent('-700.00'); + }); }); }); diff --git a/src/pages/Bookkeeping/index.test.tsx b/src/pages/Bookkeeping/index.test.tsx index 5c600a2..b577ee7 100644 --- a/src/pages/Bookkeeping/index.test.tsx +++ b/src/pages/Bookkeeping/index.test.tsx @@ -1,33 +1,38 @@ -import { describe, it, expect, beforeEach } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '../../test/utils'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor, act } from '../../test/utils'; import Bookkeeping from './index'; import { EXPENSE_CATEGORIES, INCOME_CATEGORIES, STORAGE_KEY } from './types'; -import type { BookkeepingRecord } from './types'; +import type { BookkeepingRecord, DateRange } from './types'; +import { selectAntdOption } from '../../test/antdHelpers'; +import dayjs from 'dayjs'; +import type { RecordTableProps } from './RecordTable'; -async function selectOption(container: HTMLElement, index: number, optionTitle: string) { - const selects = container.querySelectorAll('.ant-select'); - const target = selects[index]; - fireEvent.mouseDown(target.querySelector('.ant-select-content')!); +// 捕获 RecordTable 传入的 onDateRangeChange 回调,供日期区间筛选测试直接调用 +let capturedOnDateRangeChange: ((range: DateRange) => void) | null = null; - await waitFor(() => { - const option = document.querySelector(`.ant-select-item[title="${optionTitle}"]`); - expect(option).toBeTruthy(); - fireEvent.click(option!); - }); -} +vi.mock('./RecordTable', async () => { + const actual = await vi.importActual('./RecordTable'); + const { RecordTable: OrigRecordTable } = actual; + + function WrappedRecordTable(props: RecordTableProps) { + capturedOnDateRangeChange = props.onDateRangeChange; + return ; + } + + return { ...actual, RecordTable: WrappedRecordTable }; +}); async function addRecord( container: HTMLElement, opts: { type?: 'income' | 'expense'; amount: string; category: string; description?: string }, ) { if (opts.type === 'income') { - await selectOption(container, 0, '收入'); + await selectAntdOption(container, 0, '收入'); } fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: opts.amount } }); - const categorySelectIndex = opts.type === 'income' ? 1 : 1; - await selectOption(container, categorySelectIndex, opts.category); + await selectAntdOption(container, 1, opts.category); if (opts.description) { fireEvent.change(screen.getByPlaceholderText('备注(可选)'), { @@ -175,4 +180,81 @@ describe('Bookkeeping 页面集成测试', () => { expect(expenseCard).toHaveTextContent('0.00'); }); }); + + describe('日期区间筛选', () => { + const crossMonthRecords: BookkeepingRecord[] = [ + { + id: 'f-1', + type: 'income', + amount: 5000, + category: '工资', + description: '三月工资', + date: '2025-03-15', + }, + { + id: 'f-2', + type: 'expense', + amount: 200, + category: '餐饮', + description: '三月餐饮', + date: '2025-03-10', + }, + { + id: 'f-3', + type: 'income', + amount: 3000, + category: '兼职', + description: '二月收入', + date: '2025-02-10', + }, + ]; + + it('设置日期区间后,汇总数据应仅反映区间内记录', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(crossMonthRecords)); + render(); + + // 初始状态:全部记录汇总(收入 5000 + 3000 = 8000,支出 200) + const incomeCardBefore = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCardBefore).toHaveTextContent('8,000.00'); + + // 设置日期区间为 2025-03-01 ~ 2025-03-31,仅包含两条三月记录 + act(() => { + capturedOnDateRangeChange?.([dayjs('2025-03-01'), dayjs('2025-03-31')]); + }); + + await waitFor(() => { + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('5,000.00'); // 只有三月工资 + const expenseCard = screen.getByText('总支出').closest('div[class*="rounded"]')!; + expect(expenseCard).toHaveTextContent('200.00'); // 只有三月餐饮 + const balanceCard = screen.getByText('余额').closest('div[class*="rounded"]')!; + expect(balanceCard).toHaveTextContent('4,800.00'); // 5000 - 200 + }); + }); + + it('清除日期区间后,汇总数据应恢复为全部记录', async () => { + localStorage.setItem(STORAGE_KEY, JSON.stringify(crossMonthRecords)); + render(); + + // 先设置日期区间(只包含三月记录) + act(() => { + capturedOnDateRangeChange?.([dayjs('2025-03-01'), dayjs('2025-03-31')]); + }); + + await waitFor(() => { + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('5,000.00'); + }); + + // 清除日期区间,应恢复全部记录 + act(() => { + capturedOnDateRangeChange?.(null); + }); + + await waitFor(() => { + const incomeCard = screen.getByText('总收入').closest('div[class*="rounded"]')!; + expect(incomeCard).toHaveTextContent('8,000.00'); // 5000 + 3000 + }); + }); + }); }); diff --git a/src/pages/Posts.tsx b/src/pages/Posts.tsx index 44eef88..7d2f9fa 100644 --- a/src/pages/Posts.tsx +++ b/src/pages/Posts.tsx @@ -1,5 +1,4 @@ import React from 'react'; -import Header from '../components/Header'; import Footer from '../components/Footer'; import PostCard from '../components/Card'; @@ -45,7 +44,6 @@ const Posts: React.FC = () => { return (
-

All Posts

diff --git a/src/test/antdHelpers.ts b/src/test/antdHelpers.ts new file mode 100644 index 0000000..2fdbc3b --- /dev/null +++ b/src/test/antdHelpers.ts @@ -0,0 +1,39 @@ +/** + * @file antdHelpers.ts + * @description Ant Design 组件的测试辅助工具函数 + * + * Ant Design v6 的 Select 组件使用自定义 DOM 结构,无法通过标准 accessible 查询直接操作。 + * 本模块封装常见的交互操作,避免在各测试文件中重复实现。 + */ + +import { fireEvent, waitFor } from '@testing-library/react'; +import { expect } from 'vitest'; + +/** + * 打开 Ant Design Select 下拉并选中指定选项。 + * + * @param container - 包含 Select 组件的 DOM 容器(通常是 render() 返回的 container) + * @param index - 页面中第几个 `.ant-select` 组件(0-based) + * @param optionTitle - 目标选项的 title 属性值(即选项的文本内容) + * + * @example + * const { container } = render(); + * await selectAntdOption(container, 0, '收入'); + */ +export async function selectAntdOption( + container: HTMLElement, + index: number, + optionTitle: string, +): Promise { + const selects = container.querySelectorAll('.ant-select'); + const target = selects[index]; + fireEvent.mouseDown(target.querySelector('.ant-select-content')!); + + await waitFor(() => { + const option = document.querySelector( + `.ant-select-item[title="${optionTitle}"]`, + ); + expect(option).toBeTruthy(); + fireEvent.click(option!); + }); +} diff --git a/src/test/setupTests.ts b/src/test/setupTests.ts index 64e5443..20ba89d 100644 --- a/src/test/setupTests.ts +++ b/src/test/setupTests.ts @@ -16,6 +16,16 @@ afterEach(() => { cleanup(); }); +// Mock window.getComputedStyle(Ant Design Table 通过 measureScrollbarSize 调用带伪元素参数的版本, +// 但 jsdom 未实现该重载,会在 stderr 产生大量 "Not implemented" 错误) +const _originalGetComputedStyle = window.getComputedStyle; +window.getComputedStyle = (elt: Element, pseudoElt?: string | null) => { + if (pseudoElt) { + return { getPropertyValue: () => '' } as CSSStyleDeclaration; + } + return _originalGetComputedStyle(elt); +}; + // Mock window.matchMedia(某些组件库如 Ant Design 需要) Object.defineProperty(window, 'matchMedia', { writable: true, diff --git a/src/test/utils.tsx b/src/test/utils.tsx index 31fc33d..361b695 100644 --- a/src/test/utils.tsx +++ b/src/test/utils.tsx @@ -15,7 +15,11 @@ import { vi } from 'vitest'; * @returns 渲染结果和工具函数 */ const AllTheProviders = ({ children }: { children: React.ReactNode }) => { - return {children}; + return ( + + {children} + + ); }; const customRender = (