@@ -120,9 +149,11 @@ const AppContent = () => {
} />
} />
- } />
- } />
- } />
+ } />
+ } />
+ } />
+ } />
+ } />
diff --git a/src/config/navigation.tsx b/src/config/navigation.tsx
index 40769e6..bc054d1 100644
--- a/src/config/navigation.tsx
+++ b/src/config/navigation.tsx
@@ -14,9 +14,11 @@ enum NavPathKey {
Hooks = '/hooks',
useState = '/hooks/useState',
useEffect = '/hooks/useEffect',
- ShoppingCart = '/shopping-cart',
- RelayExample = '/relay-example',
- Todo = '/todo',
+ 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[] = [
@@ -34,21 +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: (
-
待办事项清单
+
App Example
),
},
];
@@ -62,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 */}
+
+
+
+ );
+});
+
+AppExample.displayName = 'AppExample';
+
+export default AppExample;
diff --git a/src/pages/Bookkeeping/AddRecordForm.test.tsx b/src/pages/Bookkeeping/AddRecordForm.test.tsx
new file mode 100644
index 0000000..0025e56
--- /dev/null
+++ b/src/pages/Bookkeeping/AddRecordForm.test.tsx
@@ -0,0 +1,160 @@
+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';
+import { selectAntdOption } from '../../test/antdHelpers';
+
+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 selectAntdOption(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 selectAntdOption(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 selectAntdOption(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}$/);
+ });
+
+ 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/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="取消"
+ >
+ } size="small" />
+
+ ),
+ },
+];
+
+export const RecordTable: React.FC = ({
+ records,
+ onDelete,
+ dateRange,
+ onDateRangeChange,
+}) => (
+
+
+
收支明细
+
+ onDateRangeChange(dates as DateRange)}
+ placeholder={['开始日期', '结束日期']}
+ />
+ {dateRange && (
+
+ )}
+
+
+
+ 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..b0b9000
--- /dev/null
+++ b/src/pages/Bookkeeping/SummaryCards.test.tsx
@@ -0,0 +1,105 @@
+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');
+ });
+
+ it('支出大于收入时余额为负数,应正确格式化显示', () => {
+ render(
+ ,
+ );
+ const balanceCard = screen.getByText('余额').closest('div[class*="rounded"]')!;
+ // 余额为负时 toLocaleString 会添加负号前缀
+ expect(balanceCard).toHaveTextContent('-700.00');
+ });
+ });
+});
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,
+}) => (
+
+
+
+ {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..b577ee7
--- /dev/null
+++ b/src/pages/Bookkeeping/index.test.tsx
@@ -0,0 +1,260 @@
+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, DateRange } from './types';
+import { selectAntdOption } from '../../test/antdHelpers';
+import dayjs from 'dayjs';
+import type { RecordTableProps } from './RecordTable';
+
+// 捕获 RecordTable 传入的 onDateRangeChange 回调,供日期区间筛选测试直接调用
+let capturedOnDateRangeChange: ((range: DateRange) => void) | null = null;
+
+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 selectAntdOption(container, 0, '收入');
+ }
+
+ fireEvent.change(screen.getByPlaceholderText('金额'), { target: { value: opts.amount } });
+
+ await selectAntdOption(container, 1, 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');
+ });
+ });
+
+ 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/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 = [
+ '餐饮',
+ '交通',
+ '购物',
+ '住房',
+ '娱乐',
+ '医疗',
+ '教育',
+ '其他支出',
+];
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 = (