Files
gongxue-base/apps/admin/src/pages/Dashboard/index.tsx

294 lines
8.8 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin, Grid } from 'antd';
import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined, BankOutlined, PercentageOutlined, UserSwitchOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import api from '../../api';
const { RangePicker } = DatePicker;
const COLORS = [
'#007AFF',
'#34C759',
'#FF9500',
'#FF3B30',
'#5AC8FA',
'#AF52DE',
'#FF2D55',
'#FFCC00',
];
const DashboardPage: React.FC = () => {
const screens = Grid.useBreakpoint();
const isMobile = !screens.sm;
const [stats, setStats] = useState<any>(null);
const [expenseStats, setExpenseStats] = useState<any[]>([]);
const [roomRanking, setRoomRanking] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState<[string, string]>([
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('month').format('YYYY-MM-DD'),
]);
const fetchData = async () => {
setLoading(true);
try {
const [s, e, r] = await Promise.all([
api.get('/dashboard/stats'),
api.get('/dashboard/expense-stats', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
api.get('/dashboard/room-ranking', {
params: { periodStart: period[0], periodEnd: period[1] },
}),
]);
setStats(s);
setExpenseStats(e as any);
setRoomRanking(r as any);
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => {
fetchData();
}, [period]);
const expenseTypeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
// 费用饼图
const pieOption = {
tooltip: { trigger: 'item' },
legend: { bottom: 0 },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: expenseStats.map((e) => ({
name: expenseTypeMap[e.type] || e.type,
value: Number(e.total),
})),
},
],
};
// 宿舍费用排行
const barOption = {
tooltip: {},
grid: { left: 80, right: 20, bottom: 30, top: 10 },
xAxis: { type: 'value' },
yAxis: {
type: 'category',
data: roomRanking.map((r) => r.roomNumber).reverse(),
inverse: false,
},
series: [
{
type: 'bar',
data: roomRanking.map((r) => Number(r.total)).reverse(),
itemStyle: { color: '#007AFF', borderRadius: [0, 4, 4, 0] },
},
],
};
// 考勤趋势折线图
const attendanceLineOption = {
tooltip: { trigger: 'axis' },
grid: { left: 50, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: (stats?.attendanceTrend || []).map((d: { date: string }) => d.date),
axisLabel: { rotate: 45, fontSize: 10 },
},
yAxis: { type: 'value', min: 0, max: 100, axisLabel: { formatter: '{value}%' } },
series: [
{
type: 'line',
data: (stats?.attendanceTrend || []).map((d: { rate: string }) => parseFloat(d.rate) || 0),
smooth: true,
lineStyle: { color: '#007AFF', width: 2 },
itemStyle: { color: '#007AFF' },
areaStyle: { color: 'rgba(0,122,255,0.1)' },
},
],
};
// 收入趋势折线图
const incomeLineOption = {
tooltip: { trigger: 'axis', valueFormatter: (v: number) => `¥${v.toLocaleString()}` },
grid: { left: 70, right: 20, bottom: 30, top: 10 },
xAxis: {
type: 'category',
data: (stats?.incomeTrend || []).map((d: { month: string }) => d.month),
},
yAxis: { type: 'value', axisLabel: { formatter: (v: number) => `¥${(v / 10000).toFixed(0)}` } },
series: [
{
type: 'line',
data: (stats?.incomeTrend || []).map((d: { amount: number }) => d.amount),
smooth: true,
lineStyle: { color: '#34C759', width: 2 },
itemStyle: { color: '#34C759' },
areaStyle: { color: 'rgba(52,199,89,0.1)' },
},
],
};
if (loading && !stats)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
return (
<div>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: isMobile ? 'flex-start' : 'center',
flexDirection: isMobile ? 'column' : 'row',
gap: 12,
}}
>
<h2 style={{ margin: 0 }}></h2>
<RangePicker
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
}}
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="在读学生"
value={stats?.totalStudents || 0}
prefix={<TeamOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="当前在住"
value={stats?.occupiedBeds || 0}
suffix={`/ ${stats?.totalCapacity || 0}`}
prefix={<CheckCircleOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="入住率"
value={stats?.occupancyRate || 0}
suffix="%"
prefix={<DollarOutlined />}
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic title="教室总数" value={stats?.classroomCount || 0} prefix={<BankOutlined />} />
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="教室占用率"
value={stats?.classroomOccupancyRate || 0}
suffix="%"
prefix={<PercentageOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="今日出勤率"
value={stats?.todayAttendanceRate || 0}
suffix="%"
prefix={<UserSwitchOutlined />}
/>
</Card>
</Col>
<Col xs={12} sm={12} md={6}>
<Card>
<Statistic
title="本月收入"
value={stats?.monthlyIncome || 0}
precision={2}
prefix="¥"
/>
</Card>
</Col>
</Row>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col xs={24} sm={12}>
<Card title="考勤趋势近30天">
{(stats?.attendanceTrend || []).length > 0 ? (
<ReactECharts option={attendanceLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="收入趋势近6个月">
{(stats?.incomeTrend || []).length > 0 ? (
<ReactECharts option={incomeLineOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
<Row gutter={[16, 16]}>
<Col xs={24} sm={12}>
<Card title="费用类型分布">
{expenseStats.length > 0 ? (
<ReactECharts option={pieOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col xs={24} sm={12}>
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts option={barOption} style={{ width: '100%', height: isMobile ? 250 : 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
</div>
);
};
export default DashboardPage;