294 lines
8.8 KiB
TypeScript
294 lines
8.8 KiB
TypeScript
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;
|