From 1193be1c9d8ae3954c173b181d9480a51c36f13d Mon Sep 17 00:00:00 2001 From: snowgitea Date: Wed, 29 Apr 2026 10:53:30 +0800 Subject: [PATCH] chore: add frontend/src/pages/Budget/index.tsx --- frontend/src/pages/Budget/index.tsx | 1568 +++++++++++++++++++++++++++ 1 file changed, 1568 insertions(+) create mode 100644 frontend/src/pages/Budget/index.tsx diff --git a/frontend/src/pages/Budget/index.tsx b/frontend/src/pages/Budget/index.tsx new file mode 100644 index 0000000..9bdae56 --- /dev/null +++ b/frontend/src/pages/Budget/index.tsx @@ -0,0 +1,1568 @@ +import React, { useState, useEffect, useMemo, useRef } from 'react' +import { useDataStore } from '../../stores' + +/** + * 预算页面 - Budget + * 功能:分类预算设置与进度展示,按月查看预算使用情况 + * 数据来源:通过 useDataStore 获取 budgets(预算列表)和 records(账单记录) + * 核心逻辑: + * - 按月份筛选预算配置 + * - 从 records 中聚合当月各分类实际支出 + * - 计算预算使用百分比并展示进度条/圆环 + * API 依赖: + * - GET /api/budgets - 获取预算列表 + * - GET /api/records - 获取账单记录(用于计算实际支出) + * - POST /api/budgets - 创建/更新预算 + */ +const BudgetPage: React.FC = () => { + const { budgets, fetchBudgets, createBudget, records, fetchRecords } = useDataStore() + const [showModal, setShowModal] = useState(false) + const [showToast, setShowToast] = useState(false) + // 表单数据 - 月份默认当前月 + const [formData, setFormData] = useState({ + category: '餐饮', + amount: 0, + month: new Date().toISOString().split('T')[0].substring(0, 7) + }) + + // 分页状态管理 - 预算分类列表分页展示 + const PAGE_SIZE = 10; // 每页显示10条记录 + const [currentPage, setCurrentPage] = useState(1); + // ref 用于分类列表锚点,切换页码时自动滚动到列表顶部 + const categoryListRef = useRef(null); + + // 预定义分类 - 用于表单选择和颜色/图标映射的基础分类 + const predefinedCategories = ['餐饮', '交通', '购物', '娱乐', '医疗', '其他'] + + // 组件挂载时并行获取预算列表和记录列表 + // 需要记录数据来聚合计算各分类的实际支出 + useEffect(() => { + fetchBudgets() + fetchRecords() + }, [fetchBudgets, fetchRecords]) + + // 按分类聚合当月实际支出 - 从 records 中筛选当月支出类型记录 + // 使用 useMemo 缓存计算结果,仅在 records 或 month 变化时重新计算 + const spendingByCategory = useMemo(() => { + const spending: Record = {} + const currentMonthRecords = records.filter(r => r.date.startsWith(formData.month) && r.type === 'expense') + currentMonthRecords.forEach(record => { + spending[record.category] = (spending[record.category] || 0) + Number(record.amount) + }) + return spending + }, [records, formData.month]) + + // 构建当月预算映射 {分类: 预算金额} - 用于快速查找各分类预算 + const currentMonthBudget = useMemo(() => { + const monthBudgets = budgets.filter(b => b.month === formData.month) + const budgetMap: Record = {} + monthBudgets.forEach(b => { + budgetMap[b.category] = Number(b.amount) + }) + return budgetMap + }, [budgets, formData.month]) + + // 动态合并分类列表:预定义分类 + 有支出的分类 + 有预算的分类 + // 确保所有有实际支出或预算的分类都显示在分类预算区域(如"房租"等不在预定义列表中的分类) + const categories = useMemo(() => { + const categorySet = new Set(predefinedCategories) + // 添加当月有支出的分类 + Object.keys(spendingByCategory).forEach(c => categorySet.add(c)) + // 添加当月有预算的分类 + Object.keys(currentMonthBudget).forEach(c => categorySet.add(c)) + return Array.from(categorySet) + }, [spendingByCategory, currentMonthBudget]) + + const totalBudget = Object.values(currentMonthBudget).reduce((sum, b) => sum + b, 0) + const totalSpent = Object.values(spendingByCategory).reduce((sum, s) => sum + s, 0) + + // 预算表单提交 - 创建新预算成功后刷新预算列表 + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault() + try { + await createBudget(formData) + setShowModal(false) + setShowToast(true) + setTimeout(() => setShowToast(false), 2500) + } catch (error) { + console.error('Failed to create budget:', error) + } + } + + // 计算单个分类的预算使用百分比 - 限制最大值为100%(用于进度条宽度) + // 超支判断通过 isOverBudget 单独计算,不受此限制影响 + const getPercentage = (category: string) => { + const budget = currentMonthBudget[category] || 0 + const spent = spendingByCategory[category] || 0 + if (budget === 0) return 0 + return Math.min((spent / budget) * 100, 100) + } + + // 分类颜色映射 - 用于进度条和图标背景色 + // 未在映射中的分类将回退到灰色 #737373 + const getCategoryColor = (category: string) => { + const colors: Record = { + '餐饮': '#00c853', + '交通': '#3761ff', + '购物': '#ff3d00', + '娱乐': '#f59e0b', + '医疗': '#0052ff', + '房租': '#9333ea', + '其他': '#737373' + } + return colors[category] || '#737373' + } + + // 分类 SVG 图标映射 - 覆盖全部 6 个支出分类 + const getCategoryIcon = (category: string) => { + const iconMap: Record = { + '餐饮': , + '交通': , + '购物': , + '娱乐': , + '医疗': , + '其他': + } + return iconMap[category] || + } + + const getTotalPercentage = () => { + if (totalBudget === 0) return 0 + return Math.min((totalSpent / totalBudget) * 100, 100) + } + + const formatCurrency = (amount: number) => { + return new Intl.NumberFormat('zh-CN', { + style: 'currency', + currency: 'CNY' + }).format(amount) + } + + // 分页逻辑 + const totalCategories = categories.length; + const totalPages = Math.ceil(totalCategories / PAGE_SIZE); + const paginatedCategories = categories.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + // 页码按钮显示逻辑:最多显示7个页码 + const getPageNumbers = (): (number | string)[] => { + const pages: (number | string)[] = []; + if (totalPages <= 7) { + for (let i = 1; i <= totalPages; i++) { + pages.push(i); + } + } else { + pages.push(1); + if (currentPage > 3) pages.push('...'); + const start = Math.max(2, currentPage - 1); + const end = Math.min(totalPages - 1, currentPage + 1); + for (let i = start; i <= end; i++) { + pages.push(i); + } + if (currentPage < totalPages - 2) pages.push('...'); + pages.push(totalPages); + } + return pages; + }; + + // 切换页码 + const handlePageChange = (page: number) => { + if (page < 1 || page > totalPages) return; + setCurrentPage(page); + // 滚动到列表顶部 + categoryListRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); + }; + + // 键盘导航 + const handleKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'ArrowLeft') { + handlePageChange(currentPage - 1); + } else if (e.key === 'ArrowRight') { + handlePageChange(currentPage + 1); + } + }; + + // 月份变化时重置到第一页 + useEffect(() => { + setCurrentPage(1); + }, [formData.month]); + + return ( +
+
+ {/* 页面头部 */} +
+
+

预算管理

+
+
+ + +
+
+ + {/* 预算概览卡片 */} +
+

预算概览

+
+
+

本月总预算

+

{formatCurrency(totalBudget)}

+

已花费 {formatCurrency(totalSpent)} · {totalBudget - totalSpent >= 0 ? '剩余' : '超支'} {formatCurrency(Math.abs(totalBudget - totalSpent))}

+
+
+
+ +
+

{Math.round(getTotalPercentage())}%

+

使用

+
+
+
+
+
+ +
+

分类预算

+
+ {paginatedCategories.map(category => { + const budget = currentMonthBudget[category] || 0 + const spent = spendingByCategory[category] || 0 + const percentage = getPercentage(category) + const isOverBudget = budget > 0 && spent > budget + + return ( +
+
+
+
+ {getCategoryIcon(category)} +
+
+

{category}

+

+ 预算: {formatCurrency(budget)} + {spent > 0 && ( + · 已花: {formatCurrency(spent)} + )} +

+
+
+ {isOverBudget && 超支} +
+ {budget > 0 && ( +
+
+
+
+ )} + {budget === 0 && ( +

暂未设置预算,

+ )} +
+ ) + })} +
+ + {/* 分页组件 */} + {totalPages > 1 && ( +
+ + + {getPageNumbers().map((page, idx) => ( + page === '...' ? ( + ... + ) : ( + + ) + ))} + + + 共 {totalCategories} 条 +
+ )} +
+
+ + {/* 分类预算设置弹窗 - 居中模态框(位于page-container外,使用fixed定位) */} + {showModal && ( +
+
setShowModal(false)} /> +
+
+
+

设置分类预算

+ +
+ +
+ {/* 月份选择 */} +
+ + setFormData(prev => ({ ...prev, month: e.target.value }))} + aria-describedby="month-help" + /> +

选择要设置预算的月份

+
+ + {/* 分类选择 - 小卡片式布局 */} +
+ +
+ {predefinedCategories.map(category => ( + + ))} +
+
+ + {/* 预算金额输入 */} +
+ +
+ ¥ + setFormData(prev => ({ ...prev, amount: parseFloat(e.target.value) || 0 }))} + aria-describedby="amount-help" + /> +
+

设置该分类的月度预算上限

+
+ + {/* 操作按钮 */} +
+ + +
+
+
+
+
+ )} + + {showToast && ( +
+ + 预算已保存 +
+ )} + + +
+ ) +} + +function EatIcon() { + return ( + + + + + + ) +} + +function CarIcon() { + return ( + + + + + + + ) +} + +function ShopIcon() { + return ( + + + + + + ) +} + +function PlayIcon() { + return ( + + + + ) +} + +function HeartIcon() { + return ( + + + + ) +} + +function StarIcon() { + return ( + + + + ) +} + +function CloseIcon() { + return ( + + + + + ) +} + +function PlusIcon() { + return ( + + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} + +export default BudgetPage