feat: 个人记账与预算管理系统 MVP 初始版本

This commit is contained in:
2026-04-28 22:57:38 +08:00
commit d2b2e9887a
88 changed files with 29512 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
# 前端环境变量配置
# 后端 API 基础地址(开发环境)
VITE_API_BASE_URL=http://localhost:3001
# 生产环境请修改为实际的后端地址
# VITE_API_BASE_URL=http://your-api-domain.com
+19
View File
@@ -0,0 +1,19 @@
// ESLint 配置
// 由于Vite已经配置了TypeScript,此文件主要用于基本代码规范检查
module.exports = {
env: {
browser: true,
es2021: true,
node: true,
},
extends: ['eslint:recommended'],
parserOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
},
rules: {
'no-unused-vars': 'warn',
'no-console': 'off',
'no-debugger': 'warn',
},
};
+129
View File
@@ -0,0 +1,129 @@
const { chromium } = require('playwright');
async function runChartSwitchTest() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
const results = [];
const switchSequence = ['line', 'pie', 'line', 'pie', 'bar', 'pie', 'bar', 'pie', 'line', 'bar', 'pie', 'bar', 'line', 'pie', 'line', 'bar', 'pie', 'bar', 'line', 'pie'];
console.log('打开统计页面...');
await page.goto('http://localhost:5173/statistics', { waitUntil: 'networkidle', timeout: 15000 });
// 等待更长时间让数据加载
console.log('等待数据加载 (15秒)...');
await page.waitForTimeout(15000);
// 检查 DOM 状态
const domCheck = await page.evaluate(() => {
const echartsContainer = document.querySelector('.echarts-container');
const pieContainer = document.querySelector('.pie-chart-container');
const canvases = document.querySelectorAll('canvas');
return {
hasEchartsContainer: !!echartsContainer,
hasPieContainer: !!pieContainer,
canvasCount: canvases.length,
bodyText: document.body.innerText.substring(0, 500)
};
});
console.log('\n页面 DOM 状态:');
console.log(' .echarts-container:', domCheck.hasEchartsContainer ? '存在' : '不存在');
console.log(' .pie-chart-container:', domCheck.hasPieContainer ? '存在' : '不存在');
console.log(' canvas 数量:', domCheck.canvasCount);
console.log(' 页面文本:', domCheck.bodyText.replace(/\n/g, ' ').substring(0, 100));
console.log('\n开始20次图表切换测试...');
for (let i = 0; i < switchSequence.length; i++) {
const chartType = switchSequence[i];
const btnSelector = `[data-type="${chartType}"]`;
try {
await page.click(btnSelector, { timeout: 5000 });
await page.waitForTimeout(1500);
let chartStatus = 'OK';
let errorMsg = '';
if (chartType === 'pie') {
const pieCanvas = await page.$$('.pie-chart-container canvas');
const trendCanvas = await page.$$('.echarts-container canvas');
if (pieCanvas.length === 0) {
chartStatus = 'FAIL';
errorMsg = '饼图 canvas 为空';
} else if (pieCanvas.length > 1) {
chartStatus = 'WARN';
errorMsg = `饼图 canvas 重复(${pieCanvas.length})`;
}
if (trendCanvas.length > 0) {
chartStatus = 'FAIL';
errorMsg = `趋势图未销毁(${trendCanvas.length})`;
}
} else {
const trendCanvas = await page.$$('.echarts-container canvas');
if (trendCanvas.length === 0) {
chartStatus = 'FAIL';
errorMsg = '趋势图 canvas 为空';
} else if (trendCanvas.length > 1) {
chartStatus = 'WARN';
errorMsg = `趋势图 canvas 重复(${trendCanvas.length})`;
}
const pieCanvas = await page.$$('.pie-chart-container canvas');
if (pieCanvas.length > 0) {
chartStatus = 'FAIL';
errorMsg = `饼图未销毁(${pieCanvas.length})`;
}
}
results.push({
step: i + 1,
to: chartType,
status: chartStatus,
error: errorMsg
});
console.log(`[${i + 1}/20] ${chartType.padEnd(5)}: ${chartStatus}${errorMsg ? ' - ' + errorMsg : ''}`);
} catch (e) {
results.push({
step: i + 1,
to: chartType,
status: 'FAIL',
error: e.message.substring(0, 80)
});
console.log(`[${i + 1}/20] ${chartType.padEnd(5)}: FAIL - ${e.message.substring(0, 50)}`);
}
}
console.log('\n========== 测试结果统计 ==========');
const passCount = results.filter(r => r.status === 'OK').length;
const warnCount = results.filter(r => r.status === 'WARN').length;
const failCount = results.filter(r => r.status === 'FAIL').length;
console.log(`通过: ${passCount}/20 (${(passCount/20*100).toFixed(1)}%)`);
console.log(`警告: ${warnCount}/20`);
console.log(`失败: ${failCount}/20`);
if (failCount > 0) {
console.log('\n失败详情:');
results.filter(r => r.status === 'FAIL').forEach(r => {
console.log(` Step ${r.step}: ${r.to} - ${r.error}`);
});
}
await browser.close();
return { passCount, failCount, warnCount, details: results };
}
runChartSwitchTest()
.then(result => {
console.log('\n测试完成');
process.exit(result.failCount > 0 ? 1 : 0);
})
.catch(e => {
console.error('测试失败:', e);
process.exit(1);
});
+55
View File
@@ -0,0 +1,55 @@
const { chromium } = require('playwright');
async function runChartSwitchTest() {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
console.log('打开统计页面...');
await page.goto('http://localhost:5173/statistics', { waitUntil: 'networkidle', timeout: 15000 });
await page.waitForTimeout(3000);
// 检查页面内容
const bodyHTML = await page.evaluate(() => document.body.innerHTML.substring(0, 2000));
console.log('\n页面 body 前2000字符:');
console.log(bodyHTML);
// 查找所有包含 chart 的 class
const chartElements = await page.evaluate(() => {
const all = document.querySelectorAll('*');
const result = [];
all.forEach(el => {
if (el.className && typeof el.className === 'string' && el.className.includes('chart')) {
result.push({
tag: el.tagName,
class: el.className,
hasCanvas: el.querySelector('canvas') ? 'has canvas' : 'no canvas'
});
}
});
return result;
});
console.log('\n包含 chart 的元素:');
console.log(JSON.stringify(chartElements, null, 2));
// 查找 echarts 相关的 canvas
const allCanvases = await page.evaluate(() => {
const canvases = document.querySelectorAll('canvas');
return Array.from(canvases).map(c => ({
parent: c.parentElement?.className || c.parentElement?.tagName,
width: c.width,
height: c.height
}));
});
console.log('\n页面中所有 canvas:');
console.log(JSON.stringify(allCanvases, null, 2));
await browser.close();
}
runChartSwitchTest()
.then(() => process.exit(0))
.catch(e => {
console.error('测试失败:', e);
process.exit(1);
});
+13
View File
@@ -0,0 +1,13 @@
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>个人记账与预算管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+6346
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "personal-finance-frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"build:check": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"echarts": "^6.0.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"react-router-dom": "^7.14.2",
"xlsx": "^0.18.5",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.17.0",
"@playwright/test": "^1.59.1",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"@typescript-eslint/eslint-plugin": "^8.59.0",
"@typescript-eslint/parser": "^8.59.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.20",
"eslint": "^9.17.0",
"eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.16",
"globals": "^15.14.0",
"postcss": "^8.4.49",
"tailwindcss": "^3.4.17",
"typescript": "~5.6.2",
"typescript-eslint": "^8.18.2",
"vite": "^6.0.11"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

+28
View File
@@ -0,0 +1,28 @@
:root {
--primary: #0052ff;
--primary-hover: #3761ff;
--primary-light: rgba(0, 82, 255, 0.1);
--success: #00c853;
--danger: #ff3d00;
--bg: #f5f5f5;
--surface: #ffffff;
--border: #e5e5e5;
--text-primary: #1a1a1a;
--text-secondary: #737373;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--shadow-sm: 0 2px 8px rgba(0, 0, 0, 0.06);
--shadow-md: 0 4px 12px rgba(0, 0, 0, 0.1);
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-5: 20px;
--space-6: 24px;
}
#root {
width: 100%;
min-height: 100vh;
}
+24
View File
@@ -0,0 +1,24 @@
import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import Layout from './components/layout/Layout'
import Dashboard from './pages/Dashboard'
import Record from './pages/Record'
import Budget from './pages/Budget'
import Statistics from './pages/Statistics'
import './App.css'
function App() {
return (
<Router>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/record" element={<Record />} />
<Route path="/budget" element={<Budget />} />
<Route path="/statistics" element={<Statistics />} />
</Routes>
</Layout>
</Router>
)
}
export default App
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-11.5 -10.23174 23 20.46348"><circle cx="0" cy="0" r="2.05" fill="#61dafb"/><g stroke="#61dafb" stroke-width="1" fill="none"><ellipse rx="11" ry="4.2"/><ellipse rx="11" ry="4.2" transform="rotate(60)"/><ellipse rx="11" ry="4.2" transform="rotate(120)"/></g></svg>

After

Width:  |  Height:  |  Size: 313 B

@@ -0,0 +1,119 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
const BottomTab: React.FC = () => {
const location = useLocation();
const navItems = [
{ path: '/', label: '首页', icon: HomeIcon },
{ path: '/record', label: '记账', icon: RecordIcon },
{ path: '/budget', label: '预算', icon: BudgetIcon },
{ path: '/statistics', label: '统计', icon: StatisticsIcon },
];
return (
<nav className="bottom-tab-bar" role="navigation" aria-label="底部导航">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`tab-item ${isActive ? 'active' : ''}`
}
aria-current={location.pathname === item.path ? 'page' : undefined}
>
<item.icon />
<span>{item.label}</span>
</NavLink>
))}
<style>{`
:root {
--primary: #0052ff;
--surface: #ffffff;
--border: #e5e5e5;
--text-secondary: #737373;
}
.bottom-tab-bar {
position: fixed;
bottom: 0;
left: 0;
right: 0;
height: 64px;
background: var(--surface);
border-top: 1px solid var(--border);
display: flex;
justify-content: space-around;
align-items: center;
padding: 0;
z-index: 100;
}
.tab-item {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 2px;
flex: 1;
height: 100%;
color: var(--text-secondary);
text-decoration: none;
font-size: 11px;
font-weight: 500;
transition: color 0.15s ease;
}
.tab-item.active {
color: var(--primary);
}
@media (min-width: 768px) {
.bottom-tab-bar {
display: none;
}
}
`}</style>
</nav>
);
};
function HomeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
}
function RecordIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
);
}
function BudgetIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
);
}
function StatisticsIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="24" height="24">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
);
}
export default BottomTab;
+71
View File
@@ -0,0 +1,71 @@
import React from 'react';
import Sidebar from './Sidebar';
import BottomTab from './BottomTab';
import { useUiStore } from '../../stores';
interface LayoutProps {
children: React.ReactNode;
}
/**
* 布局组件 - Layout
* 功能:定义应用整体布局结构,包含侧边导航栏、主内容区和底部标签栏
* 布局结构:
* - Sidebar(左侧边栏):桌面端固定显示,可折叠(宽度 240px / 64px
* - main(主内容区):根据侧边栏状态动态调整左边距
* - BottomTab(底部标签栏):移动端底部导航,固定定位
* 响应式策略:
* - 桌面端(≥768px):显示侧边栏,主内容区留出左边距
* - 移动端(<768px):隐藏侧边栏,使用底部标签栏导航
*/
const Layout: React.FC<LayoutProps> = ({ children }) => {
// 从 UI 状态中获取侧边栏折叠状态,用于动态调整主内容区宽度
const { sidebarCollapsed } = useUiStore();
return (
<div className="app-layout">
<Sidebar />
<main
className="main-content"
role="main"
style={{
marginLeft: sidebarCollapsed ? '64px' : '240px',
padding: '32px',
maxWidth: '1280px',
transition: 'margin-left 0.3s ease'
}}
>
{children}
</main>
<BottomTab />
<style>{`
.app-layout {
display: flex;
min-height: 100vh;
}
.main-content {
flex: 1;
padding-bottom: 88px;
}
@media (max-width: 767px) {
.main-content {
margin-left: 0 !important;
padding: 20px;
padding-bottom: 88px;
}
}
@media (min-width: 1440px) {
.main-content {
max-width: 1440px;
}
}
`}</style>
</div>
);
};
export default Layout;
+290
View File
@@ -0,0 +1,290 @@
import React from 'react';
import { NavLink, useLocation } from 'react-router-dom';
import { useUiStore } from '../../stores';
const Sidebar: React.FC = () => {
const location = useLocation();
const { sidebarCollapsed, toggleSidebar } = useUiStore();
const navItems = [
{ path: '/', label: '首页', icon: HomeIcon },
{ path: '/record', label: '记账', icon: RecordIcon },
{ path: '/budget', label: '预算', icon: BudgetIcon },
{ path: '/statistics', label: '统计', icon: StatisticsIcon },
];
return (
<aside
className={`sidebar ${sidebarCollapsed ? 'collapsed' : ''}`}
role="navigation"
aria-label="侧边导航"
>
<div className="sidebar-logo">
<div className="sidebar-logo-content">
<LogoIcon />
{!sidebarCollapsed && <span></span>}
</div>
<div
className="sidebar-toggle-wrapper"
data-tooltip={sidebarCollapsed ? '展开侧边栏' : '收起侧边栏'}
>
<button
className="sidebar-toggle"
type="button"
aria-label="折叠/展开侧边栏"
onClick={toggleSidebar}
>
<ChevronIcon />
</button>
</div>
</div>
<nav className="sidebar-nav">
{navItems.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
`sidebar-nav-item ${isActive ? 'active' : ''}`
}
aria-current={location.pathname === item.path ? 'page' : undefined}
>
<item.icon />
{!sidebarCollapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
<style>{`
:root {
--primary: #0052ff;
--primary-hover: #3761ff;
--primary-light: rgba(0, 82, 255, 0.1);
--surface: #ffffff;
--border: #e5e5e5;
--text-primary: #1a1a1a;
--text-secondary: #737373;
--radius-md: 8px;
--space-1: 4px;
--space-2: 8px;
--space-3: 12px;
--space-4: 16px;
--space-6: 24px;
}
.sidebar {
width: 240px;
background: var(--surface);
border-right: 1px solid var(--border);
padding: var(--space-6);
position: fixed;
top: 0;
left: 0;
bottom: 0;
overflow-y: auto;
z-index: 100;
transition: width 0.3s ease, padding 0.3s ease;
}
.sidebar.collapsed {
width: 64px;
padding: var(--space-6) var(--space-2);
}
.sidebar-logo {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 18px;
font-weight: 600;
color: var(--text-primary);
margin-bottom: var(--space-6);
padding-bottom: var(--space-4);
border-bottom: 1px solid var(--border);
}
.sidebar-logo-content {
display: flex;
align-items: center;
gap: var(--space-2);
}
.sidebar-nav {
display: flex;
flex-direction: column;
gap: var(--space-1);
}
.sidebar-nav-item {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-3) var(--space-4);
color: var(--text-secondary);
text-decoration: none;
border-radius: var(--radius-md);
font-size: 14px;
font-weight: 500;
transition: all 0.15s ease;
}
.sidebar-nav-item:hover {
background: var(--primary-light);
color: var(--text-primary);
}
.sidebar-nav-item.active {
background: var(--primary-light);
color: var(--primary);
}
.sidebar.collapsed .sidebar-nav-item {
justify-content: center;
padding: var(--space-3);
}
.sidebar.collapsed .sidebar-nav-item span {
display: none;
}
.sidebar-toggle {
width: 36px;
height: 36px;
border: 1px solid var(--border);
background: var(--surface);
border-radius: var(--radius-md);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.15s ease;
color: var(--text-secondary);
}
.sidebar-toggle:hover {
background: var(--primary-light);
color: var(--primary);
border-color: var(--primary);
}
.sidebar.collapsed .sidebar-toggle svg {
transform: rotate(180deg);
}
.sidebar-toggle svg {
transition: transform 0.3s ease;
}
.sidebar-toggle-wrapper {
position: relative;
display: inline-flex;
}
.sidebar-toggle-wrapper::after {
content: attr(data-tooltip);
position: absolute;
left: calc(100% + 12px);
top: 50%;
transform: translateY(-50%);
background: #1a1a1a;
color: #fff;
padding: 6px 12px;
border-radius: 6px;
font-size: 12px;
font-weight: 500;
white-space: nowrap;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease;
z-index: 1000;
pointer-events: none;
}
.sidebar-toggle-wrapper::before {
content: '';
position: absolute;
left: calc(100% + 4px);
top: 50%;
transform: translateY(-50%);
border: 6px solid transparent;
border-right-color: #1a1a1a;
opacity: 0;
visibility: hidden;
transition: all 0.15s ease;
z-index: 1000;
pointer-events: none;
}
.sidebar-toggle-wrapper:hover::after,
.sidebar-toggle-wrapper:hover::before {
opacity: 1;
visibility: visible;
}
@media (max-width: 767px) {
.sidebar {
display: none;
}
}
`}</style>
</aside>
);
};
// SVG Icons as React components
function LogoIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="28" height="28" style={{ color: '#0052ff' }}>
<path d="M21 12V7H5a2 2 0 0 1 0-4h14v4"/>
<path d="M3 5v14a2 2 0 0 0 2 2h16v-5"/>
<path d="M18 12a2 2 0 0 0 0 4h4v-4h-4z"/>
</svg>
);
}
function ChevronIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" width="18" height="18">
<polyline points="15 18 9 12 15 6"/>
</svg>
);
}
function HomeIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<path d="M3 9l9-7 9 7v11a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>
<polyline points="9 22 9 12 15 12 15 22"/>
</svg>
);
}
function RecordIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<path d="M12 2v20M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/>
</svg>
);
}
function BudgetIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<rect x="2" y="3" width="20" height="14" rx="2" ry="2"/>
<line x1="8" y1="21" x2="16" y2="21"/>
<line x1="12" y1="17" x2="12" y2="21"/>
</svg>
);
}
function StatisticsIcon() {
return (
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" width="20" height="20">
<line x1="18" y1="20" x2="18" y2="10"/>
<line x1="12" y1="20" x2="12" y2="4"/>
<line x1="6" y1="20" x2="6" y2="14"/>
</svg>
);
}
export default Sidebar;
+36
View File
@@ -0,0 +1,36 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
:root {
font-family: Inter, system-ui, Avenir, Helvetica, Arial, sans-serif;
line-height: 1.5;
font-weight: 400;
color: #1a1a1a;
background-color: #f5f5f5;
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: #f5f5f5;
color: #1a1a1a;
line-height: 1.5;
font-size: 14px;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
width: 100%;
min-height: 100vh;
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+47
View File
@@ -0,0 +1,47 @@
/**
* 账户服务 - Accounts API Service
* 功能:封装账户相关的 API 调用,提供账户 CRUD 操作
* API 依赖:
* - GET /api/accounts - 获取账户列表
* - GET /api/accounts/:id - 获取单个账户
* - POST /api/accounts - 创建账户
* - PUT /api/accounts/:id - 更新账户
* - DELETE /api/accounts/:id - 删除账户
*/
import { apiClient } from './apiClient';
import type { Account } from '../types';
// Mock 用户 ID - 后续应从认证上下文动态获取
const USER_ID = 6;
export const accountsApi = {
// API: GET /api/accounts - 获取当前用户的所有账户
async getAccounts(): Promise<Account[]> {
const response = await apiClient.get<Account[]>('/accounts', { userId: USER_ID });
return response.data;
},
// API: GET /api/accounts/:id - 获取指定账户详情
async getAccount(id: number): Promise<Account> {
const response = await apiClient.get<Account>(`/accounts/${id}`);
return response.data;
},
// API: POST /api/accounts - 创建新账户,自动关联当前用户
async createAccount(data: Omit<Account, 'id' | 'createdAt' | 'updatedAt'>): Promise<Account> {
const response = await apiClient.post<Account>('/accounts', { ...data, userId: USER_ID });
return response.data;
},
// API: PUT /api/accounts/:id - 更新账户信息,禁止修改 userId 和时间字段
async updateAccount(id: number, data: Partial<Omit<Account, 'id' | 'userId' | 'createdAt' | 'updatedAt'>>): Promise<Account> {
const response = await apiClient.put<Account>(`/accounts/${id}`, data);
return response.data;
},
// API: DELETE /api/accounts/:id - 删除指定账户
async deleteAccount(id: number): Promise<void> {
const response = await apiClient.delete<void>(`/accounts/${id}`);
return response.data;
},
};
+100
View File
@@ -0,0 +1,100 @@
/**
* API 客户端 - ApiClient
* 功能:封装 fetch API,提供统一的 HTTP 请求处理、错误捕获和响应解析
* 设计模式:类封装 + 泛型支持,确保类型安全
* 使用方式:通过导出单例 apiClient 调用,避免重复实例化
*/
import type { ApiResponse } from '../types';
// 直接使用后端 URL,避免 Vite 代理问题
// 生产环境应改为环境变量 VITE_API_BASE_URL
const API_BASE_URL = 'http://localhost:3001/api';
class ApiClient {
private baseUrl: string;
constructor(baseUrl: string) {
this.baseUrl = baseUrl;
}
/**
* 核心请求方法 - 处理 URL 拼接、请求配置、响应解析和错误捕获
* @param endpoint - API 路径(如 '/accounts'
* @param options - fetch 配置对象,支持 method、headers、body 等
* @returns 标准化的 ApiResponse 对象
*/
private async request<T>(
endpoint: string,
options: RequestInit = {}
): Promise<ApiResponse<T>> {
const url = `${this.baseUrl}${endpoint}`;
// 默认设置 Content-Type 为 application/json,允许调用方覆盖
const config: RequestInit = {
headers: {
'Content-Type': 'application/json',
...options.headers,
},
...options,
};
try {
const response = await fetch(url, config);
const data = await response.json();
// 根据 HTTP 状态码判断请求是否成功
if (!response.ok) {
throw new Error(data.message || 'Request failed');
}
return data;
} catch (error) {
// 统一错误日志输出,方便调试
console.error('API Error:', error);
throw error;
}
}
/**
* GET 请求 - 支持 URL 查询参数拼接
* @param endpoint - API 路径
* @param params - 查询参数对象,自动忽略 undefined/null 值
*/
async get<T>(endpoint: string, params?: Record<string, string | number>): Promise<ApiResponse<T>> {
let url = endpoint;
if (params) {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
url = `${endpoint}?${searchParams.toString()}`;
}
return this.request<T>(url, { method: 'GET' });
}
// API: POST 请求 - 用于创建资源
async post<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'POST',
body: data ? JSON.stringify(data) : undefined,
});
}
// API: PUT 请求 - 用于全量更新资源
async put<T>(endpoint: string, data?: unknown): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, {
method: 'PUT',
body: data ? JSON.stringify(data) : undefined,
});
}
// API: DELETE 请求 - 用于删除资源
async delete<T>(endpoint: string): Promise<ApiResponse<T>> {
return this.request<T>(endpoint, { method: 'DELETE' });
}
}
// 导出单例 - 全局共享一个 ApiClient 实例
export const apiClient = new ApiClient(API_BASE_URL);
+49
View File
@@ -0,0 +1,49 @@
/**
* 预算服务 - Budgets API Service
* 功能:封装预算相关的 API 调用,提供预算 CRUD 操作
* API 依赖:
* - GET /api/budgets - 获取预算列表(可按月份筛选)
* - GET /api/budgets/:id - 获取单条预算
* - POST /api/budgets - 创建预算
* - PUT /api/budgets/:id - 更新预算
* - DELETE /api/budgets/:id - 删除预算
*/
import { apiClient } from './apiClient';
import type { Budget, BudgetFormData } from '../types';
// Mock 用户 ID - 后续应从认证上下文动态获取
const USER_ID = 6;
export const budgetsApi = {
// API: GET /api/budgets - 获取预算列表,支持按月份筛选
async getBudgets(month?: string): Promise<Budget[]> {
const params: Record<string, string | number> = { userId: USER_ID };
if (month) params.month = month;
const response = await apiClient.get<Budget[]>('/budgets', params);
return response.data;
},
// API: GET /api/budgets/:id - 获取指定预算详情
async getBudget(id: number): Promise<Budget> {
const response = await apiClient.get<Budget>(`/budgets/${id}`);
return response.data;
},
// API: POST /api/budgets - 创建新预算,自动关联当前用户
async createBudget(data: Omit<BudgetFormData, 'userId'>): Promise<Budget> {
const response = await apiClient.post<Budget>('/budgets', { ...data, userId: USER_ID });
return response.data;
},
// API: PUT /api/budgets/:id - 更新预算,支持部分更新
async updateBudget(id: number, data: Partial<BudgetFormData>): Promise<Budget> {
const response = await apiClient.put<Budget>(`/budgets/${id}`, data);
return response.data;
},
// API: DELETE /api/budgets/:id - 删除指定预算
async deleteBudget(id: number): Promise<void> {
const response = await apiClient.delete<void>(`/budgets/${id}`);
return response.data;
},
};
+6
View File
@@ -0,0 +1,6 @@
// Export all API services
export * from './apiClient';
export * from './accounts';
export * from './records';
export * from './budgets';
export * from './statistics';
+53
View File
@@ -0,0 +1,53 @@
/**
* 记录服务 - Records API Service
* 功能:封装账单记录相关的 API 调用,提供账单 CRUD 操作
* API 依赖:
* - GET /api/records - 获取账单记录列表(支持多条件筛选)
* - GET /api/records/:id - 获取单条账单记录
* - POST /api/records - 创建账单记录
* - PUT /api/records/:id - 更新账单记录
* - DELETE /api/records/:id - 删除账单记录
*/
import { apiClient } from './apiClient';
import type { Record, RecordFormData } from '../types';
// Mock 用户 ID - 后续应从认证上下文动态获取
const USER_ID = 6;
export const recordsApi = {
// API: GET /api/records - 获取账单记录列表,支持按账户、类型、分类、日期范围筛选
async getRecords(params?: {
accountId?: number;
type?: 'income' | 'expense';
category?: string;
startDate?: string;
endDate?: string;
}): Promise<Record[]> {
const response = await apiClient.get<Record[]>('/records', { userId: USER_ID, ...params });
return response.data;
},
// API: GET /api/records/:id - 获取指定账单记录详情
async getRecord(id: number): Promise<Record> {
const response = await apiClient.get<Record>(`/records/${id}`);
return response.data;
},
// API: POST /api/records - 创建账单记录,自动关联当前用户
async createRecord(data: Omit<RecordFormData, 'accountId'> & { accountId: number }): Promise<Record> {
const response = await apiClient.post<Record>('/records', { ...data, userId: USER_ID });
return response.data;
},
// API: PUT /api/records/:id - 更新账单记录,支持部分更新
async updateRecord(id: number, data: Partial<RecordFormData>): Promise<Record> {
const response = await apiClient.put<Record>(`/records/${id}`, data);
return response.data;
},
// API: DELETE /api/records/:id - 删除指定账单记录
async deleteRecord(id: number): Promise<void> {
const response = await apiClient.delete<void>(`/records/${id}`);
return response.data;
},
};
+34
View File
@@ -0,0 +1,34 @@
// Statistics API Service
import { apiClient } from './apiClient';
import type { DashboardSummary, MonthlyStats, TrendStat, MonthlyCompare } from '../types';
const USER_ID = 6; // Mock user ID
export const statisticsApi = {
// API: GET /api/dashboard/summary - 获取仪表盘汇总数据(余额、本月收入/支出、预算进度)
async getDashboardSummary(): Promise<DashboardSummary> {
const response = await apiClient.get<DashboardSummary>('/dashboard/summary', { userId: USER_ID });
return response.data;
},
// API: GET /api/statistics/monthly - 获取月度分类统计数据(按支出分类聚合)
async getMonthlyStats(month: string): Promise<MonthlyStats> {
const response = await apiClient.get<MonthlyStats>('/statistics/monthly', { userId: USER_ID, month });
return response.data;
},
// API: GET /api/statistics/trend - 获取日期趋势统计(按天聚合收入/支出)
async getTrendStats(startDate?: string, endDate?: string): Promise<TrendStat[]> {
const params: Record<string, string | number> = { userId: USER_ID };
if (startDate) params.startDate = startDate;
if (endDate) params.endDate = endDate;
const response = await apiClient.get<TrendStat[]>('/statistics/trend', params);
return response.data;
},
// API: GET /api/statistics/compare - 获取本月与上月对比数据
async getMonthlyCompare(month: string): Promise<MonthlyCompare> {
const response = await apiClient.get<MonthlyCompare>('/statistics/compare', { userId: USER_ID, month });
return response.data;
},
};
+226
View File
@@ -0,0 +1,226 @@
/**
* 数据状态管理 - DataStore
* 功能:使用 Zustand 管理全局数据状态,包括账户、账单、预算、统计数据的 CRUD
* 状态流转:每个 action 遵循 loading -> 请求 -> success/error -> loading=false 的流程
* 级联刷新:CRUD 操作成功后自动重新拉取列表和仪表盘汇总数据,保证跨页面数据一致性
* API 依赖:accountsApi、recordsApi、budgetsApi、statisticsApi
*/
import { create } from 'zustand';
import type { Account, Record, Budget, DashboardSummary, TrendStat, MonthlyCompare } from '../types';
import { accountsApi, recordsApi, budgetsApi, statisticsApi } from '../services';
/**
* DataState 接口 - 定义全局数据状态的结构
*/
interface DataState {
// ===== 数据实体 =====
accounts: Account[]; // 账户列表
records: Record[]; // 账单记录列表
budgets: Budget[]; // 预算配置列表
dashboardSummary: DashboardSummary | null; // 仪表盘汇总数据(余额、收支、预算使用率)
trendData: TrendStat[]; // 趋势统计数据(用于折线图)
monthlyCompare: MonthlyCompare | null; // 月度对比数据(用于柱状对比图)
loading: boolean; // 全局加载状态
error: string | null; // 全局错误信息
// ===== Actions - 数据查询 =====
fetchAccounts: () => Promise<void>;
fetchRecords: (params?: {
accountId?: number;
type?: 'income' | 'expense';
category?: string;
startDate?: string;
endDate?: string;
}) => Promise<void>;
fetchBudgets: (month?: string) => Promise<void>;
fetchDashboardSummary: () => Promise<void>;
fetchTrendData: (startDate: string, endDate: string) => Promise<void>;
fetchMonthlyCompare: (month: string) => Promise<void>;
// ===== Actions - 数据操作 =====
createRecord: (data: any) => Promise<void>;
updateRecord: (id: number, data: any) => Promise<void>;
deleteRecord: (id: number) => Promise<void>;
createBudget: (data: any) => Promise<void>;
updateBudget: (id: number, data: any) => Promise<void>;
deleteBudget: (id: number) => Promise<void>;
clearError: () => void;
}
export const useDataStore = create<DataState>((set, get) => ({
accounts: [],
records: [],
budgets: [],
dashboardSummary: null,
trendData: [],
monthlyCompare: null,
loading: false,
error: null,
// API: GET /api/accounts - 获取账户列表
fetchAccounts: async () => {
set({ loading: true, error: null });
try {
const data = await accountsApi.getAccounts();
set({ accounts: data });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: GET /api/records - 获取账单记录列表(支持多条件筛选)
fetchRecords: async (params) => {
set({ loading: true, error: null });
try {
const data = await recordsApi.getRecords(params);
set({ records: data });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: GET /api/budgets - 获取预算列表(可按月份筛选)
fetchBudgets: async (month) => {
set({ loading: true, error: null });
try {
const data = await budgetsApi.getBudgets(month);
set({ budgets: data });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: GET /api/statistics/dashboard - 获取仪表盘汇总数据
// 同时更新 accounts 状态,因为汇总数据中包含账户信息
fetchDashboardSummary: async () => {
set({ loading: true, error: null });
try {
const data = await statisticsApi.getDashboardSummary();
set({ dashboardSummary: data, accounts: data.accounts });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: fetchTrendData - 获取日期趋势数据(用于折线图)
fetchTrendData: async (startDate: string, endDate: string) => {
set({ loading: true, error: null });
try {
const data = await statisticsApi.getTrendStats(startDate, endDate);
set({ trendData: data });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: fetchMonthlyCompare - 获取本月与上月对比数据(用于柱状对比图)
fetchMonthlyCompare: async (month: string) => {
set({ loading: true, error: null });
try {
const data = await statisticsApi.getMonthlyCompare(month);
set({ monthlyCompare: data });
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: POST /api/records - 创建账单记录,成功后级联刷新列表和仪表盘
// 级联刷新目的:确保其他页面(如 Dashboard、Budget)能立即看到最新数据
createRecord: async (data) => {
set({ loading: true, error: null });
try {
await recordsApi.createRecord(data);
await get().fetchRecords();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: PUT /api/records/:id - 更新账单记录,成功后级联刷新
updateRecord: async (id, data) => {
set({ loading: true, error: null });
try {
await recordsApi.updateRecord(id, data);
await get().fetchRecords();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: DELETE /api/records/:id - 删除账单记录,成功后级联刷新
deleteRecord: async (id) => {
set({ loading: true, error: null });
try {
await recordsApi.deleteRecord(id);
await get().fetchRecords();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: POST /api/budgets - 创建预算,成功后级联刷新预算列表和仪表盘
createBudget: async (data) => {
set({ loading: true, error: null });
try {
await budgetsApi.createBudget(data);
await get().fetchBudgets();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: PUT /api/budgets/:id - 更新预算,成功后级联刷新
updateBudget: async (id, data) => {
set({ loading: true, error: null });
try {
await budgetsApi.updateBudget(id, data);
await get().fetchBudgets();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// API: DELETE /api/budgets/:id - 删除预算,成功后级联刷新
deleteBudget: async (id) => {
set({ loading: true, error: null });
try {
await budgetsApi.deleteBudget(id);
await get().fetchBudgets();
await get().fetchDashboardSummary();
} catch (error) {
set({ error: (error as Error).message });
} finally {
set({ loading: false });
}
},
// 清除全局错误信息,用于错误状态重置
clearError: () => set({ error: null }),
}));
+3
View File
@@ -0,0 +1,3 @@
// Export all stores
export * from './uiStore';
export * from './dataStore';
+24
View File
@@ -0,0 +1,24 @@
// UI State Store
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
interface UiState {
sidebarCollapsed: boolean;
toggleSidebar: () => void;
setSidebarCollapsed: (collapsed: boolean) => void;
}
export const useUiStore = create<UiState>()(
persist(
(set) => ({
sidebarCollapsed: false,
toggleSidebar: () =>
set((state) => ({ sidebarCollapsed: !state.sidebarCollapsed })),
setSidebarCollapsed: (collapsed) =>
set(() => ({ sidebarCollapsed: collapsed })),
}),
{
name: 'ui-storage',
}
)
);
+162
View File
@@ -0,0 +1,162 @@
/**
* 类型定义 - Type Definitions
* 功能:定义个人财务系统前后端数据契约,确保类型安全
* 使用场景:API 请求/响应、状态管理、组件 props、表单数据
*/
/**
* 用户接口 - 对应数据库 fa_user 表
*/
export interface User {
id: number;
name: string;
email: string;
createdAt: string;
}
/**
* 账户接口 - 对应数据库 fa_account 表
* 用于区分不同资金账户(如微信、支付宝、银行卡、现金)
*/
export interface Account {
id: number;
userId: number; // 所属用户 ID,用于权限校验
name: string; // 账户名称(如"微信钱包")
type: string; // 账户类型(如"cash"、"bank"、"digital"
color: string; // 账户图标颜色
balance: number; // 当前余额
createdAt: string;
updatedAt: string;
}
/**
* 账单记录接口 - 对应数据库 fa_record 表
* 核心业务实体,记录每一笔收支
*/
export interface Record {
id: number;
userId: number; // 所属用户 ID
accountId: number; // 关联账户 ID
type: 'income' | 'expense'; // 收支类型
amount: number; // 金额(正数)
category: string; // 分类名称(如"餐饮"、"工资"
description: string; // 备注说明
date: string; // 账单日期(用户选择的日期)
account: Account; // 关联账户详情(JOIN 查询填充)
createdAt: string; // 创建时间(用于排序和相对时间显示)
updatedAt: string;
}
/**
* 预算配置接口 - 对应数据库 fa_budget 表
* 用于设定各分类的月度预算上限
*/
export interface Budget {
id: number;
userId: number; // 所属用户 ID
category: string; // 预算分类
amount: number; // 预算金额上限
month: string; // 预算月份(格式:YYYY-MM
createdAt: string;
updatedAt: string;
}
/**
* 预算使用状态接口 - 扩展 Budget,增加实际支出和使用率
* 用于仪表盘和预算页面的进度展示
*/
export interface BudgetWithUsage extends Budget {
spent: number; // 当月该分类实际支出总额
percentage: number; // 预算使用百分比(spent / amount * 100
}
/**
* 仪表盘汇总接口 - GET /api/statistics/dashboard 返回数据
* 聚合数据,避免前端多次请求
*/
export interface DashboardSummary {
totalBalance: number; // 所有账户总余额
monthIncome: number; // 本月总收入
monthExpense: number; // 本月总支出
accounts: Account[]; // 账户列表
budgetUsage: BudgetWithUsage[]; // 预算使用情况列表
}
/**
* 月度统计接口 - 用于月度报表页面
*/
export interface MonthlyStats {
totalIncome: number;
totalExpense: number;
balance: number; // 本月结余(收入 - 支出)
categoryStats: Array<{
category: string;
amount: number;
}>;
}
/**
* 趋势统计接口 - GET /api/statistics/trend 返回数据
* 用于折线图展示每日收支趋势
*/
export interface TrendStat {
date: string; // 日期(格式:YYYY-MM-DD
income: number; // 当日收入
expense: number; // 当日支出
}
/**
* 月度对比接口 - GET /api/statistics/monthly-compare 返回数据
* 用于柱状图对比本月与上月的收支差异
*/
export interface MonthlyCompare {
currentMonth: {
label: string; // 月份标签(如"2024年1月"
income: number;
expense: number;
};
lastMonth: {
label: string;
income: number;
expense: number;
};
}
/**
* 统一 API 响应接口 - 所有后端接口返回的标准格式
*/
export interface ApiResponse<T> {
success: boolean; // 请求是否成功
data: T; // 响应数据(泛型支持)
message?: string; // 错误信息(可选)
}
/**
* 账单表单数据接口 - 新增/编辑账单时的表单输入
* 与 Record 的区别:不含 id、createdAt、updatedAt 等系统字段
*/
export interface RecordFormData {
type: 'income' | 'expense';
amount: number;
category: string;
description: string;
date: string;
accountId: number;
}
/**
* 预算表单数据接口 - 新增/编辑预算时的表单输入
*/
export interface BudgetFormData {
category: string;
amount: number;
month: string;
}
/**
* 按日期分组的记录接口 - 用于按日期分组展示账单列表
* key 为日期字符串(如 "2024-01-15"),值为该日期的记录数组
*/
export interface DateGroupedRecords {
[key: string]: Record[];
}
+218
View File
@@ -0,0 +1,218 @@
// 导出工具函数 - 将统计数据导出为 Excel 文件
import * as XLSX from 'xlsx';
import type { TrendStat, MonthlyCompare } from '../types';
/**
* 导出趋势数据到 Excel
* @param data 趋势数据 [{date, income, expense}]
* @param filename 文件名(不含扩展名)
*/
export function exportTrendData(data: TrendStat[], filename: string): void {
if (!data || data.length === 0) {
throw new Error('没有可导出的趋势数据');
}
// 构建工作表数据
const wsData = [
['个人记账 - 收支趋势报表', '', '', ''],
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', '', ''],
[], // 空行
['日期', '收入(元)', '支出(元)', '盈余(元)'],
...data.map(d => [
d.date,
d.income,
d.expense,
d.income - d.expense
]),
[], // 空行
// 汇总行
['合计',
data.reduce((sum, d) => sum + d.income, 0),
data.reduce((sum, d) => sum + d.expense, 0),
data.reduce((sum, d) => sum + (d.income - d.expense), 0)
],
];
// 创建工作表
const ws = XLSX.utils.aoa_to_sheet(wsData);
// 合并单元格(标题)
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 3 } }];
// 设置列宽
ws['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
// 创建工作簿
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '收支趋势');
// 导出文件
XLSX.writeFile(wb, `${filename}.xlsx`);
}
/**
* 导出月度对比数据
* @param data 月度对比数据
* @param filename 文件名(不含扩展名)
*/
export function exportCompareData(data: MonthlyCompare, filename: string): void {
if (!data) {
throw new Error('没有可导出的月度对比数据');
}
const wsData = [
['个人记账 - 月度对比报表', '', '', '', ''],
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', '', '', ''],
[], // 空行
['项目', data.currentMonth.label, '上月环比', data.lastMonth.label, ''],
['收入(元)', data.currentMonth.income, '', data.lastMonth.income, ''],
['支出(元)', data.currentMonth.expense, '', data.lastMonth.expense, ''],
['盈余(元)',
data.currentMonth.income - data.currentMonth.expense,
'',
data.lastMonth.income - data.lastMonth.expense,
''
],
[], // 空行
// 收支占比分析
['支出占比',
data.currentMonth.income > 0
? `${((data.currentMonth.expense / data.currentMonth.income) * 100).toFixed(1)}%`
: '0%',
'',
data.lastMonth.income > 0
? `${((data.lastMonth.expense / data.lastMonth.income) * 100).toFixed(1)}%`
: '0%',
''],
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 4 } }];
ws['!cols'] = [{ wch: 12 }, { wch: 14 }, { wch: 12 }, { wch: 14 }, { wch: 12 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '月度对比');
XLSX.writeFile(wb, `${filename}.xlsx`);
}
/**
* 导出支出构成数据
* @param data 分类数据 [{name, value, color}]
* @param totalExpense 总支出
* @param filename 文件名(不含扩展名)
*/
export function exportCategoryData(
data: { name: string; value: number; color: string }[],
totalExpense: number,
filename: string
): void {
if (!data || data.length === 0) {
throw new Error('没有可导出的支出构成数据');
}
const wsData = [
['个人记账 - 支出构成报表', '', ''],
[`导出时间: ${new Date().toLocaleString('zh-CN')}`, '', ''],
[`总支出: ¥${totalExpense.toLocaleString()}`, '', ''],
[], // 空行
['分类', '金额(元)', '占比'],
...data.map(item => [
item.name,
item.value,
totalExpense > 0 ? `${((item.value / totalExpense) * 100).toFixed(1)}%` : '0%',
]),
[], // 空行
// 按金额排序的排名
['排名', '分类', '金额(元)'],
...data
.sort((a, b) => b.value - a.value)
.map((item, index) => [
index + 1,
item.name,
item.value,
]),
];
const ws = XLSX.utils.aoa_to_sheet(wsData);
ws['!merges'] = [{ s: { r: 0, c: 0 }, e: { r: 0, c: 2 } }];
ws['!cols'] = [{ wch: 10 }, { wch: 14 }, { wch: 10 }];
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '支出构成');
XLSX.writeFile(wb, `${filename}.xlsx`);
}
/**
* 导出全部统计数据(综合报表)
* @param params 所有统计数据
* @param filename 文件名(不含扩展名)
*/
export function exportAllStats(
params: {
trendData?: TrendStat[];
monthlyCompare?: MonthlyCompare;
categoryData?: { name: string; value: number; color: string }[];
totalExpense?: number;
},
filename: string
): void {
const wb = XLSX.utils.book_new();
// 添加趋势数据工作表
if (params.trendData && params.trendData.length > 0) {
const trendWsData = [
['日期', '收入(元)', '支出(元)', '盈余(元)'],
...params.trendData.map(d => [
d.date,
d.income,
d.expense,
d.income - d.expense
]),
['合计',
params.trendData.reduce((sum, d) => sum + d.income, 0),
params.trendData.reduce((sum, d) => sum + d.expense, 0),
params.trendData.reduce((sum, d) => sum + (d.income - d.expense), 0)
],
];
const ws = XLSX.utils.aoa_to_sheet(trendWsData);
ws['!cols'] = [{ wch: 14 }, { wch: 14 }, { wch: 14 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws, '收支趋势');
}
// 添加月度对比工作表
if (params.monthlyCompare) {
const { monthlyCompare } = params;
const compareWsData = [
['项目', monthlyCompare.currentMonth.label, monthlyCompare.lastMonth.label],
['收入(元)', monthlyCompare.currentMonth.income, monthlyCompare.lastMonth.income],
['支出(元)', monthlyCompare.currentMonth.expense, monthlyCompare.lastMonth.expense],
['盈余(元)',
monthlyCompare.currentMonth.income - monthlyCompare.currentMonth.expense,
monthlyCompare.lastMonth.income - monthlyCompare.lastMonth.expense
],
];
const ws = XLSX.utils.aoa_to_sheet(compareWsData);
ws['!cols'] = [{ wch: 12 }, { wch: 14 }, { wch: 14 }];
XLSX.utils.book_append_sheet(wb, ws, '月度对比');
}
// 添加支出构成工作表
if (params.categoryData && params.categoryData.length > 0) {
const totalExpense = params.totalExpense || 0;
const categoryWsData = [
['分类', '金额(元)', '占比'],
...params.categoryData.map(item => [
item.name,
item.value,
totalExpense > 0 ? `${((item.value / totalExpense) * 100).toFixed(1)}%` : '0%',
]),
];
const ws = XLSX.utils.aoa_to_sheet(categoryWsData);
ws['!cols'] = [{ wch: 10 }, { wch: 14 }, { wch: 10 }];
XLSX.utils.book_append_sheet(wb, ws, '支出构成');
}
XLSX.writeFile(wb, `${filename}.xlsx`);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+68
View File
@@ -0,0 +1,68 @@
/** @type {import('tailwindcss').Config} */
export default {
content: [
"./index.html",
"./src/**/*.{js,ts,jsx,tsx}",
],
theme: {
extend: {
colors: {
primary: {
DEFAULT: '#0052ff',
hover: '#3761ff',
light: 'rgba(0, 82, 255, 0.1)',
},
success: {
DEFAULT: '#00c853',
hover: '#00b14a',
},
danger: {
DEFAULT: '#ff3d00',
hover: '#e63600',
},
warning: '#ffb300',
bg: '#f5f5f5',
surface: '#ffffff',
border: '#e5e5e5',
text: {
primary: '#1a1a1a',
secondary: '#737373',
disabled: '#a3a3a3',
},
},
borderRadius: {
sm: '4px',
md: '8px',
lg: '12px',
xl: '16px',
full: '9999px',
},
boxShadow: {
sm: '0 2px 8px rgba(0, 0, 0, 0.06)',
md: '0 4px 12px rgba(0, 0, 0, 0.1)',
lg: '0 8px 24px rgba(0, 0, 0, 0.15)',
},
spacing: {
'1': '4px',
'xs': '4px',
'2': '8px',
'sm': '8px',
'3': '12px',
'md': '16px',
'4': '16px',
'5': '20px',
'6': '24px',
'lg': '24px',
'8': '32px',
'10': '40px',
'xl': '48px',
},
maxWidth: {
'lg': '1280px',
'xl': '1440px',
'xxl': '1680px',
},
},
},
plugins: [],
}
+24
View File
@@ -0,0 +1,24 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"module": "ESNext",
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}
+12
View File
@@ -0,0 +1,12 @@
{
"compilerOptions": {
"composite": true,
"skipLibCheck": true,
"module": "ESNext",
"moduleResolution": "bundler",
"allowSyntheticDefaultImports": true,
"strict": true,
"noEmit": false
},
"include": ["vite.config.ts"]
}
File diff suppressed because one or more lines are too long
+1
View File
@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/components/layout/bottomtab.tsx","./src/components/layout/layout.tsx","./src/components/layout/sidebar.tsx","./src/pages/budget/index.tsx","./src/pages/dashboard/index.tsx","./src/pages/record/index.tsx","./src/pages/statistics/index.tsx","./src/services/accounts.ts","./src/services/apiclient.ts","./src/services/budgets.ts","./src/services/index.ts","./src/services/records.ts","./src/services/statistics.ts","./src/stores/datastore.ts","./src/stores/index.ts","./src/stores/uistore.ts","./src/types/index.ts","./src/utils/exporthelper.ts"],"version":"5.6.3"}
+95
View File
@@ -0,0 +1,95 @@
// 验证统计页面修复 - 添加5元交通支出并验证饼图显示
const { chromium } = require('playwright');
(async () => {
const browser = await chromium.launch({ headless: true });
const context = await browser.newContext();
const page = await context.newPage();
try {
console.log('1. 导航到记账页面...');
await page.goto('http://localhost:5174/record', { waitUntil: 'networkidle' });
// 等待页面加载
await page.waitForSelector('.record-page', { timeout: 10000 });
console.log(' 记账页面已加载');
// 选择支出类型
console.log('2. 选择支出类型...');
const expenseTab = page.locator('.type-tab.expense');
if (await expenseTab.isVisible()) {
await expenseTab.click();
await page.waitForTimeout(300);
}
// 选择交通类别
console.log('3. 选择交通类别...');
const trafficCategory = page.locator('[data-category="交通"]');
if (await trafficCategory.isVisible()) {
await trafficCategory.click();
await page.waitForTimeout(300);
} else {
console.log(' 警告: 交通类别按钮未找到,尝试其他方式');
}
// 输入金额 5 元
console.log('4. 输入金额 5 元...');
const amountInput = page.locator('input[type="number"], .amount-input input, input[placeholder*="金"]');
await amountInput.fill('5');
await page.waitForTimeout(200);
// 点击保存按钮
console.log('5. 点击保存按钮...');
const saveButton = page.locator('button[type="submit"], .save-btn, button:has-text("保存")');
await saveButton.click();
await page.waitForTimeout(1500);
console.log(' 记录已保存');
// 导航到统计页面
console.log('6. 导航到统计页面...');
await page.goto('http://localhost:5174/statistics', { waitUntil: 'networkidle' });
await page.waitForTimeout(1000);
// 切换到饼图视图
console.log('7. 切换到饼图视图...');
const pieButton = page.locator('[data-type="pie"], button:has-text("饼图")');
if (await pieButton.isVisible()) {
await pieButton.click();
await page.waitForTimeout(1000);
}
// 截图统计页面
console.log('8. 截图统计页面...');
await page.screenshot({
path: 'd:/Users/kaifa/Trae_cn260425/personal-finance-budget-system/frontend/statistics-verification.png',
fullPage: false
});
console.log(' 截图已保存到 statistics-verification.png');
// 检查饼图图例中的交通显示
console.log('9. 检查交通支出显示...');
const legendItems = await page.locator('.legend-item').allTextContents();
console.log(' 图例数据:', legendItems);
// 查找交通分类的值
const trafficLegend = page.locator('.legend-item:has-text("交通")');
if (await trafficLegend.isVisible()) {
const trafficText = await trafficLegend.textContent();
console.log(' 交通图例内容:', trafficText);
}
console.log('\n验证完成!');
} catch (error) {
console.error('测试过程中出错:', error.message);
// 即使出错也截图
await page.screenshot({
path: 'd:/Users/kaifa/Trae_cn260425/personal-finance-budget-system/frontend/statistics-error.png',
fullPage: true
});
console.log('错误截图已保存');
} finally {
await browser.close();
}
})();
+589
View File
@@ -0,0 +1,589 @@
/**
* 前端页面数据验证测试脚本 - 修正版
* 验证 4 个页面的数据是否正确显示
*
* 测试环境:
* - 前端地址: http://localhost:5173
* - 数据库用户 ID: 6
*/
const { chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
// 配置
const BASE_URL = 'http://localhost:5173';
const SCREENSHOT_DIR = path.join(__dirname, 'test-screenshots');
// 测试结果
const testResults = {
summary: {
total: 0,
passed: 0,
failed: 0,
warnings: 0
},
pages: {}
};
// 确保截图目录存在
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
/**
* 延迟函数
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 测试首页 (/)
*/
async function testDashboard(page) {
console.log('\n========== 测试首页 (/) ==========');
const result = {
url: `${BASE_URL}/`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到首页
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await delay(2000); // 等待数据加载
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '01-dashboard.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
console.log('\n页面文本片段:', pageText.substring(0, 500));
// 检查总余额显示
console.log('\n--- 检查总余额 ---');
const hasBalance = pageText.includes('当前余额') || pageText.includes('余额');
const hasAmount = /¥\s*[\d,]+\.?\d*/.test(pageText) || /\d{4,}/.test(pageText);
result.checks.push({
item: '总余额标题',
expected: '当前余额',
found: hasBalance,
status: hasBalance ? 'PASS' : 'FAIL'
});
result.checks.push({
item: '金额显示',
expected: '¥ 格式金额',
found: hasAmount,
status: hasAmount ? 'PASS' : 'FAIL'
});
if (hasBalance && hasAmount) {
console.log('[PASS] 总余额显示正确');
} else {
console.log('[FAIL] 总余额未正确显示');
result.issues.push('总余额未正确显示');
}
// 检查收支统计
console.log('\n--- 检查收支统计 ---');
const hasIncome = pageText.includes('本月收入') || pageText.includes('收入');
const hasExpense = pageText.includes('本月支出') || pageText.includes('支出');
result.checks.push({
item: '本月收入',
found: hasIncome,
status: hasIncome ? 'PASS' : 'FAIL'
});
result.checks.push({
item: '本月支出',
found: hasExpense,
status: hasExpense ? 'PASS' : 'FAIL'
});
if (hasIncome) console.log('[PASS] 本月收入显示');
if (hasExpense) console.log('[PASS] 本月支出显示');
// 检查预算进度
console.log('\n--- 检查预算进度 ---');
const hasBudget = pageText.includes('预算进度') || pageText.includes('预算');
const hasCategory = pageText.includes('餐饮') || pageText.includes('交通');
result.checks.push({
item: '预算进度区域',
found: hasBudget,
status: hasBudget ? 'PASS' : 'WARN'
});
result.checks.push({
item: '预算分类',
found: hasCategory,
status: hasCategory ? 'PASS' : 'WARN'
});
if (hasBudget) console.log('[PASS] 预算进度区域显示');
if (hasCategory) console.log('[PASS] 预算分类显示');
// 检查最近记录
console.log('\n--- 检查最近记录 ---');
const hasRecords = pageText.includes('最近记录') || pageText.includes('记录');
const hasRecordData = pageText.includes('午餐') ||
pageText.includes('地铁') ||
pageText.includes('工资') ||
pageText.includes('购物');
result.checks.push({
item: '最近记录区域',
found: hasRecords,
status: hasRecords ? 'PASS' : 'WARN'
});
result.checks.push({
item: '记录数据',
found: hasRecordData,
status: hasRecordData ? 'PASS' : 'WARN'
});
if (hasRecords) console.log('[PASS] 最近记录区域显示');
if (hasRecordData) console.log('[PASS] 记录数据显示');
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 首页测试失败: ${error.message}`);
}
return result;
}
/**
* 测试记账页面 (/record)
*/
async function testRecord(page) {
console.log('\n========== 测试记账页面 (/record) ==========');
const result = {
url: `${BASE_URL}/record`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到记账页面
await page.goto(`${BASE_URL}/record`, { waitUntil: 'networkidle' });
await delay(2000);
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '02-record.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
// 检查交易记录列表
console.log('\n--- 检查交易记录列表 ---');
// 预期记录数据
const expectedRecords = ['午餐', '地铁', '工资', '购物', '电影', '房租', '还款'];
let foundCount = 0;
for (const record of expectedRecords) {
if (pageText.includes(record)) {
foundCount++;
console.log(`[PASS] 找到记录: ${record}`);
}
}
result.checks.push({
item: '交易记录数据',
expected: `至少 5 条记录`,
found: foundCount >= 5,
status: foundCount >= 5 ? 'PASS' : 'FAIL'
});
if (foundCount < 5) {
result.issues.push(`只找到 ${foundCount} 条记录`);
}
// 检查记录类型(支出/收入)
console.log('\n--- 检查记录类型 ---');
const hasExpense = pageText.includes('支出');
const hasIncome = pageText.includes('收入');
result.checks.push({
item: '记录类型 - 支出',
found: hasExpense,
status: hasExpense ? 'PASS' : 'WARN'
});
result.checks.push({
item: '记录类型 - 收入',
found: hasIncome,
status: hasIncome ? 'PASS' : 'WARN'
});
if (hasExpense) console.log('[PASS] 支出类型显示');
if (hasIncome) console.log('[PASS] 收入类型显示');
// 检查金额显示
const hasAmount = /\d+\.?\d*/.test(pageText);
result.checks.push({
item: '金额显示',
found: hasAmount,
status: hasAmount ? 'PASS' : 'FAIL'
});
if (hasAmount) {
console.log('[PASS] 金额数据存在');
} else {
console.log('[FAIL] 未找到金额数据');
result.issues.push('金额数据未显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 记账页面测试失败: ${error.message}`);
}
return result;
}
/**
* 测试预算页面 (/budget)
*/
async function testBudget(page) {
console.log('\n========== 测试预算页面 (/budget) ==========');
const result = {
url: `${BASE_URL}/budget`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到预算页面
await page.goto(`${BASE_URL}/budget`, { waitUntil: 'networkidle' });
await delay(2000);
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '03-budget.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
// 检查预算数据
console.log('\n--- 检查预算数据 ---');
// 预期预算类别
const expectedCategories = ['餐饮', '交通', '购物', '娱乐'];
for (const category of expectedCategories) {
const hasCategory = pageText.includes(category);
result.checks.push({
item: `预算类别 - ${category}`,
found: hasCategory,
status: hasCategory ? 'PASS' : 'WARN'
});
if (hasCategory) {
console.log(`[PASS] 预算类别 "${category}" 显示`);
} else {
console.log(`[WARN] 预算类别 "${category}" 未找到`);
}
}
// 检查预算进度
console.log('\n--- 检查预算进度 ---');
// 查找进度条元素
const progressBars = await page.locator('[class*="progress"], [role="progressbar"]').all();
console.log(`找到 ${progressBars.length} 个进度条元素`);
const hasProgress = progressBars.length > 0 ||
pageText.includes('%') ||
pageText.includes('进度');
result.checks.push({
item: '预算进度显示',
found: hasProgress,
status: hasProgress ? 'PASS' : 'WARN'
});
if (hasProgress) {
console.log('[PASS] 预算进度显示');
} else {
console.log('[WARN] 预算进度可能未正确显示');
result.issues.push('预算进度显示可能有问题');
}
// 检查预算金额
const hasBudgetAmount = /\d+/.test(pageText);
result.checks.push({
item: '预算金额显示',
found: hasBudgetAmount,
status: hasBudgetAmount ? 'PASS' : 'FAIL'
});
if (hasBudgetAmount) {
console.log('[PASS] 预算金额数据存在');
} else {
console.log('[FAIL] 未找到预算金额数据');
result.issues.push('预算金额数据未显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 预算页面测试失败: ${error.message}`);
}
return result;
}
/**
* 测试统计页面 (/statistics)
*/
async function testStatistics(page) {
console.log('\n========== 测试统计页面 (/statistics) ==========');
const result = {
url: `${BASE_URL}/statistics`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到统计页面
await page.goto(`${BASE_URL}/statistics`, { waitUntil: 'networkidle' });
await delay(3000); // 图表加载需要更多时间
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '04-statistics.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
// 检查图表显示
console.log('\n--- 检查图表显示 ---');
// 查找图表容器
const chartContainers = await page.locator('[class*="chart"], [id*="chart"], canvas').all();
console.log(`找到 ${chartContainers.length} 个图表元素`);
const hasChart = chartContainers.length > 0;
result.checks.push({
item: '图表容器',
expected: '至少 1 个图表',
found: hasChart,
status: hasChart ? 'PASS' : 'FAIL'
});
if (hasChart) {
console.log('[PASS] 图表容器存在');
} else {
console.log('[FAIL] 未找到图表容器');
result.issues.push('图表未正确渲染');
}
// 检查图表切换按钮
console.log('\n--- 检查图表切换功能 ---');
// 查找切换按钮
const switchButtons = await page.locator('button').all();
let foundButtons = [];
for (const button of switchButtons) {
const text = await button.textContent();
if (text && (text.includes('饼图') || text.includes('折线') || text.includes('柱状'))) {
foundButtons.push(text.trim());
}
}
console.log(`找到切换按钮: ${foundButtons.join(', ')}`);
result.checks.push({
item: '图表切换按钮',
expected: '饼图、折线图、柱状图',
found: foundButtons.length >= 3,
status: foundButtons.length >= 3 ? 'PASS' : 'WARN'
});
if (foundButtons.length >= 3) {
console.log('[PASS] 图表切换按钮存在');
// 测试切换功能
console.log('\n--- 测试图表切换 ---');
// 尝试点击饼图按钮
const pieButton = await page.locator('button:has-text("饼图")').first();
if (await pieButton.isVisible()) {
await pieButton.click();
await delay(1000);
console.log('[INFO] 点击了饼图按钮');
// 截图
const pieScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-pie.png');
await page.screenshot({ path: pieScreenshot, fullPage: true });
}
// 尝试点击折线图按钮
const lineButton = await page.locator('button:has-text("折线")').first();
if (await lineButton.isVisible()) {
await lineButton.click();
await delay(1000);
console.log('[INFO] 点击了折线图按钮');
// 截图
const lineScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-line.png');
await page.screenshot({ path: lineScreenshot, fullPage: true });
}
// 尝试点击柱状图按钮
const barButton = await page.locator('button:has-text("柱状")').first();
if (await barButton.isVisible()) {
await barButton.click();
await delay(1000);
console.log('[INFO] 点击了柱状图按钮');
// 截图
const barScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-bar.png');
await page.screenshot({ path: barScreenshot, fullPage: true });
}
result.checks.push({
item: '图表切换功能',
found: true,
status: 'PASS'
});
console.log('[PASS] 图表切换功能正常');
} else {
console.log('[WARN] 未找到完整的图表切换按钮');
result.issues.push('图表切换按钮不完整');
}
// 检查是否有数据标签
const hasDataLabels = pageText.includes('餐饮') ||
pageText.includes('交通') ||
pageText.includes('购物') ||
pageText.includes('娱乐') ||
pageText.includes('支出') ||
pageText.includes('收入');
result.checks.push({
item: '数据标签',
found: hasDataLabels,
status: hasDataLabels ? 'PASS' : 'WARN'
});
if (hasDataLabels) {
console.log('[PASS] 数据标签存在');
} else {
console.log('[WARN] 数据标签可能未正确显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 统计页面测试失败: ${error.message}`);
}
return result;
}
/**
* 主测试函数
*/
async function runTests() {
console.log('========================================');
console.log(' 前端页面数据验证测试 - 修正版');
console.log(' 测试时间:', new Date().toLocaleString());
console.log(' 前端地址:', BASE_URL);
console.log('========================================');
// 启动浏览器
const browser = await chromium.launch({
headless: false, // 可视化模式,方便观察
slowMo: 100
});
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }
});
const page = await context.newPage();
try {
// 测试首页
testResults.pages.dashboard = await testDashboard(page);
// 测试记账页面
testResults.pages.record = await testRecord(page);
// 测试预算页面
testResults.pages.budget = await testBudget(page);
// 测试统计页面
testResults.pages.statistics = await testStatistics(page);
} finally {
await browser.close();
}
// 统计结果
console.log('\n========================================');
console.log(' 测试结果汇总');
console.log('========================================');
for (const [pageName, result] of Object.entries(testResults.pages)) {
console.log(`\n${pageName.toUpperCase()}`);
console.log(` URL: ${result.url}`);
console.log(` 截图: ${result.screenshot || '无'}`);
const passed = result.checks.filter(c => c.status === 'PASS').length;
const failed = result.checks.filter(c => c.status === 'FAIL').length;
const warned = result.checks.filter(c => c.status === 'WARN').length;
console.log(` 检查项: ${passed} 通过, ${failed} 失败, ${warned} 警告`);
if (result.issues.length > 0) {
console.log(` 问题列表:`);
result.issues.forEach(issue => console.log(` - ${issue}`));
}
testResults.summary.total += result.checks.length;
testResults.summary.passed += passed;
testResults.summary.failed += failed;
testResults.summary.warnings += warned;
}
console.log('\n----------------------------------------');
console.log(`总计: ${testResults.summary.passed}/${testResults.summary.total} 通过`);
console.log(`失败: ${testResults.summary.failed}`);
console.log(`警告: ${testResults.summary.warnings}`);
console.log('----------------------------------------');
// 保存测试报告
const reportPath = path.join(SCREENSHOT_DIR, 'test-report.json');
fs.writeFileSync(reportPath, JSON.stringify(testResults, null, 2));
console.log(`\n测试报告已保存: ${reportPath}`);
return testResults;
}
// 执行测试
runTests().catch(console.error);
+573
View File
@@ -0,0 +1,573 @@
/**
* 前端页面数据验证测试脚本
* 验证 4 个页面的数据是否正确显示
*
* 测试环境:
* - 前端地址: http://localhost:5173
* - 数据库用户 ID: 6
*/
const { chromium } = require('@playwright/test');
const path = require('path');
const fs = require('fs');
// 配置
const BASE_URL = 'http://localhost:5173';
const SCREENSHOT_DIR = path.join(__dirname, 'test-screenshots');
// 测试结果
const testResults = {
summary: {
total: 0,
passed: 0,
failed: 0,
warnings: 0
},
pages: {}
};
// 确保截图目录存在
if (!fs.existsSync(SCREENSHOT_DIR)) {
fs.mkdirSync(SCREENSHOT_DIR, { recursive: true });
}
/**
* 延迟函数
*/
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
/**
* 测试首页 (/)
*/
async function testDashboard(page) {
console.log('\n========== 测试首页 (/) ==========');
const result = {
url: `${BASE_URL}/`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到首页
await page.goto(BASE_URL, { waitUntil: 'networkidle' });
await delay(2000); // 等待数据加载
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '01-dashboard.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 检查账户余额
console.log('\n--- 检查账户余额 ---');
const balanceCards = await page.locator('[class*="card"], [class*="balance"]').all();
console.log(`找到 ${balanceCards.length} 个卡片元素`);
// 检查是否有金额显示
const pageText = await page.textContent('body');
// 预期数据
const expectedBalances = [
{ name: '支付宝', amount: 5000 },
{ name: '微信', amount: 3000 },
{ name: '银行卡', amount: 10000 }
];
for (const balance of expectedBalances) {
const hasName = pageText.includes(balance.name);
const hasAmount = pageText.includes(balance.amount.toString()) ||
pageText.includes(balance.amount.toLocaleString());
const check = {
item: `账户余额 - ${balance.name}`,
expected: `${balance.name}: ${balance.amount}`,
found: hasName && hasAmount,
status: (hasName && hasAmount) ? 'PASS' : 'FAIL'
};
result.checks.push(check);
if (hasName && hasAmount) {
console.log(`[PASS] ${balance.name} 余额显示正确`);
} else {
console.log(`[FAIL] ${balance.name} 余额未找到`);
result.issues.push(`${balance.name} 余额未正确显示`);
}
}
// 检查收支统计
console.log('\n--- 检查收支统计 ---');
const incomePattern = /收入|Income/i;
const expensePattern = /支出|Expense/i;
const hasIncome = incomePattern.test(pageText);
const hasExpense = expensePattern.test(pageText);
result.checks.push({
item: '收支统计 - 收入',
found: hasIncome,
status: hasIncome ? 'PASS' : 'WARN'
});
result.checks.push({
item: '收支统计 - 支出',
found: hasExpense,
status: hasExpense ? 'PASS' : 'WARN'
});
if (hasIncome) console.log('[PASS] 收入统计显示');
if (hasExpense) console.log('[PASS] 支出统计显示');
if (!hasIncome || !hasExpense) {
result.issues.push('收支统计可能未正确显示');
}
// 检查是否有数据加载错误
const hasError = pageText.includes('加载失败') ||
pageText.includes('错误') ||
pageText.includes('Error');
if (hasError) {
result.issues.push('页面存在加载错误');
console.log('[FAIL] 页面存在加载错误');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 首页测试失败: ${error.message}`);
}
return result;
}
/**
* 测试记账页面 (/record)
*/
async function testRecord(page) {
console.log('\n========== 测试记账页面 (/record) ==========');
const result = {
url: `${BASE_URL}/record`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到记账页面
await page.goto(`${BASE_URL}/record`, { waitUntil: 'networkidle' });
await delay(2000);
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '02-record.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 检查交易记录列表
console.log('\n--- 检查交易记录列表 ---');
// 查找记录元素
const recordItems = await page.locator('tr, [class*="record"], [class*="item"]').all();
console.log(`找到 ${recordItems.length} 个可能的记录元素`);
// 获取页面文本
const pageText = await page.textContent('body');
// 预期有 7 条记录
const expectedRecordCount = 7;
// 检查是否有记录数据显示
const hasRecords = pageText.includes('早餐') ||
pageText.includes('午餐') ||
pageText.includes('工资') ||
pageText.includes('地铁') ||
pageText.includes('购物') ||
pageText.includes('电影') ||
pageText.includes('晚餐');
result.checks.push({
item: '交易记录数据',
expected: `至少 ${expectedRecordCount} 条记录`,
found: hasRecords,
status: hasRecords ? 'PASS' : 'FAIL'
});
if (hasRecords) {
console.log('[PASS] 交易记录数据存在');
} else {
console.log('[FAIL] 未找到交易记录数据');
result.issues.push('交易记录列表无数据');
}
// 检查记录类型(支出/收入)
console.log('\n--- 检查记录类型 ---');
const hasExpense = pageText.includes('支出');
const hasIncome = pageText.includes('收入');
result.checks.push({
item: '记录类型 - 支出',
found: hasExpense,
status: hasExpense ? 'PASS' : 'WARN'
});
result.checks.push({
item: '记录类型 - 收入',
found: hasIncome,
status: hasIncome ? 'PASS' : 'WARN'
});
if (hasExpense) console.log('[PASS] 支出类型显示');
if (hasIncome) console.log('[PASS] 收入类型显示');
// 检查金额显示
const hasAmount = /\d+\.?\d*/.test(pageText);
result.checks.push({
item: '金额显示',
found: hasAmount,
status: hasAmount ? 'PASS' : 'FAIL'
});
if (hasAmount) {
console.log('[PASS] 金额数据存在');
} else {
console.log('[FAIL] 未找到金额数据');
result.issues.push('金额数据未显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 记账页面测试失败: ${error.message}`);
}
return result;
}
/**
* 测试预算页面 (/budget)
*/
async function testBudget(page) {
console.log('\n========== 测试预算页面 (/budget) ==========');
const result = {
url: `${BASE_URL}/budget`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到预算页面
await page.goto(`${BASE_URL}/budget`, { waitUntil: 'networkidle' });
await delay(2000);
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '03-budget.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
// 检查预算数据
console.log('\n--- 检查预算数据 ---');
// 预期预算类别
const expectedCategories = ['餐饮', '交通', '购物', '娱乐'];
for (const category of expectedCategories) {
const hasCategory = pageText.includes(category);
result.checks.push({
item: `预算类别 - ${category}`,
found: hasCategory,
status: hasCategory ? 'PASS' : 'WARN'
});
if (hasCategory) {
console.log(`[PASS] 预算类别 "${category}" 显示`);
} else {
console.log(`[WARN] 预算类别 "${category}" 未找到`);
}
}
// 检查预算进度
console.log('\n--- 检查预算进度 ---');
// 查找进度条元素
const progressBars = await page.locator('[class*="progress"], [role="progressbar"]').all();
console.log(`找到 ${progressBars.length} 个进度条元素`);
const hasProgress = progressBars.length > 0 ||
pageText.includes('%') ||
pageText.includes('进度');
result.checks.push({
item: '预算进度显示',
found: hasProgress,
status: hasProgress ? 'PASS' : 'WARN'
});
if (hasProgress) {
console.log('[PASS] 预算进度显示');
} else {
console.log('[WARN] 预算进度可能未正确显示');
result.issues.push('预算进度显示可能有问题');
}
// 检查预算金额
const hasBudgetAmount = /\d+/.test(pageText);
result.checks.push({
item: '预算金额显示',
found: hasBudgetAmount,
status: hasBudgetAmount ? 'PASS' : 'FAIL'
});
if (hasBudgetAmount) {
console.log('[PASS] 预算金额数据存在');
} else {
console.log('[FAIL] 未找到预算金额数据');
result.issues.push('预算金额数据未显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 预算页面测试失败: ${error.message}`);
}
return result;
}
/**
* 测试统计页面 (/statistics)
*/
async function testStatistics(page) {
console.log('\n========== 测试统计页面 (/statistics) ==========');
const result = {
url: `${BASE_URL}/statistics`,
checks: [],
issues: [],
screenshot: null
};
try {
// 导航到统计页面
await page.goto(`${BASE_URL}/statistics`, { waitUntil: 'networkidle' });
await delay(3000); // 图表加载需要更多时间
// 截图
const screenshotPath = path.join(SCREENSHOT_DIR, '04-statistics.png');
await page.screenshot({ path: screenshotPath, fullPage: true });
result.screenshot = screenshotPath;
console.log(`截图已保存: ${screenshotPath}`);
// 获取页面文本
const pageText = await page.textContent('body');
// 检查图表显示
console.log('\n--- 检查图表显示 ---');
// 查找图表容器
const chartContainers = await page.locator('[class*="chart"], [id*="chart"], canvas').all();
console.log(`找到 ${chartContainers.length} 个图表元素`);
const hasChart = chartContainers.length > 0;
result.checks.push({
item: '图表容器',
expected: '至少 1 个图表',
found: hasChart,
status: hasChart ? 'PASS' : 'FAIL'
});
if (hasChart) {
console.log('[PASS] 图表容器存在');
} else {
console.log('[FAIL] 未找到图表容器');
result.issues.push('图表未正确渲染');
}
// 检查图表切换按钮
console.log('\n--- 检查图表切换功能 ---');
// 查找切换按钮
const switchButtons = await page.locator('button').all();
let hasSwitchButtons = false;
for (const button of switchButtons) {
const text = await button.textContent();
if (text && (text.includes('饼图') || text.includes('折线') || text.includes('柱状'))) {
hasSwitchButtons = true;
console.log(`找到切换按钮: ${text.trim()}`);
}
}
result.checks.push({
item: '图表切换按钮',
found: hasSwitchButtons,
status: hasSwitchButtons ? 'PASS' : 'WARN'
});
if (hasSwitchButtons) {
console.log('[PASS] 图表切换按钮存在');
// 测试切换功能
console.log('\n--- 测试图表切换 ---');
// 尝试点击饼图按钮
const pieButton = await page.locator('button:has-text("饼图")').first();
if (await pieButton.isVisible()) {
await pieButton.click();
await delay(1000);
console.log('[INFO] 点击了饼图按钮');
// 截图
const pieScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-pie.png');
await page.screenshot({ path: pieScreenshot, fullPage: true });
}
// 尝试点击折线图按钮
const lineButton = await page.locator('button:has-text("折线")').first();
if (await lineButton.isVisible()) {
await lineButton.click();
await delay(1000);
console.log('[INFO] 点击了折线图按钮');
// 截图
const lineScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-line.png');
await page.screenshot({ path: lineScreenshot, fullPage: true });
}
// 尝试点击柱状图按钮
const barButton = await page.locator('button:has-text("柱状")').first();
if (await barButton.isVisible()) {
await barButton.click();
await delay(1000);
console.log('[INFO] 点击了柱状图按钮');
// 截图
const barScreenshot = path.join(SCREENSHOT_DIR, '04-statistics-bar.png');
await page.screenshot({ path: barScreenshot, fullPage: true });
}
result.checks.push({
item: '图表切换功能',
found: true,
status: 'PASS'
});
console.log('[PASS] 图表切换功能正常');
} else {
console.log('[WARN] 未找到图表切换按钮');
result.issues.push('图表切换按钮未找到');
}
// 检查是否有数据
const hasDataIndicators = pageText.includes('餐饮') ||
pageText.includes('交通') ||
pageText.includes('购物') ||
pageText.includes('娱乐');
result.checks.push({
item: '统计数据',
found: hasDataIndicators,
status: hasDataIndicators ? 'PASS' : 'WARN'
});
if (hasDataIndicators) {
console.log('[PASS] 统计数据存在');
} else {
console.log('[WARN] 统计数据可能未正确显示');
}
} catch (error) {
result.issues.push(`测试异常: ${error.message}`);
console.error(`[ERROR] 统计页面测试失败: ${error.message}`);
}
return result;
}
/**
* 主测试函数
*/
async function runTests() {
console.log('========================================');
console.log(' 前端页面数据验证测试');
console.log(' 测试时间:', new Date().toLocaleString());
console.log(' 前端地址:', BASE_URL);
console.log('========================================');
// 启动浏览器
const browser = await chromium.launch({
headless: false, // 可视化模式,方便观察
slowMo: 100
});
const context = await browser.newContext({
viewport: { width: 1280, height: 800 }
});
const page = await context.newPage();
try {
// 测试首页
testResults.pages.dashboard = await testDashboard(page);
// 测试记账页面
testResults.pages.record = await testRecord(page);
// 测试预算页面
testResults.pages.budget = await testBudget(page);
// 测试统计页面
testResults.pages.statistics = await testStatistics(page);
} finally {
await browser.close();
}
// 统计结果
console.log('\n========================================');
console.log(' 测试结果汇总');
console.log('========================================');
for (const [pageName, result] of Object.entries(testResults.pages)) {
console.log(`\n${pageName.toUpperCase()}`);
console.log(` URL: ${result.url}`);
console.log(` 截图: ${result.screenshot || '无'}`);
const passed = result.checks.filter(c => c.status === 'PASS').length;
const failed = result.checks.filter(c => c.status === 'FAIL').length;
const warned = result.checks.filter(c => c.status === 'WARN').length;
console.log(` 检查项: ${passed} 通过, ${failed} 失败, ${warned} 警告`);
if (result.issues.length > 0) {
console.log(` 问题列表:`);
result.issues.forEach(issue => console.log(` - ${issue}`));
}
testResults.summary.total += result.checks.length;
testResults.summary.passed += passed;
testResults.summary.failed += failed;
testResults.summary.warnings += warned;
}
console.log('\n----------------------------------------');
console.log(`总计: ${testResults.summary.passed}/${testResults.summary.total} 通过`);
console.log(`失败: ${testResults.summary.failed}`);
console.log(`警告: ${testResults.summary.warnings}`);
console.log('----------------------------------------');
// 保存测试报告
const reportPath = path.join(SCREENSHOT_DIR, 'test-report.json');
fs.writeFileSync(reportPath, JSON.stringify(testResults, null, 2));
console.log(`\n测试报告已保存: ${reportPath}`);
return testResults;
}
// 执行测试
runTests().catch(console.error);
+2
View File
@@ -0,0 +1,2 @@
declare const _default: import("vite").UserConfig;
export default _default;
+17
View File
@@ -0,0 +1,17 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
rewrite: function (path) { return path; },
},
},
},
});
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:3001',
changeOrigin: true,
secure: false,
rewrite: (path) => path,
},
},
},
})