feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin } from 'antd';
import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined } 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 [stats, setStats] = useState<any>(null);
const [ganttData, setGanttData] = useState<any[]>([]);
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, g, e, r] = await Promise.all([
api.get('/dashboard/stats'),
api.get('/dashboard/gantt', { params: { periodStart: period[0], periodEnd: period[1] } }),
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);
setGanttData(g as any);
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 ganttOption = () => {
if (!ganttData.length) return {};
const rooms = ganttData.map((d) => d.roomNumber);
const pStart = new Date(period[0]).getTime();
const pEnd = new Date(period[1]).getTime();
const data: any[] = [];
ganttData.forEach((room, roomIdx) => {
room.occupancies.forEach((occ: any, i: number) => {
const start = Math.max(new Date(occ.checkInDate).getTime(), pStart);
const end = occ.checkOutDate ? Math.min(new Date(occ.checkOutDate).getTime(), pEnd) : pEnd;
data.push({
name: occ.studentName,
value: [roomIdx, start, end, occ.studentName],
itemStyle: { color: COLORS[(occ.studentId || i) % COLORS.length] },
});
});
});
return {
tooltip: {
formatter: (p: any) => {
const v = p.value;
return `${p.name}<br/>宿舍: ${rooms[v[0]]}<br/>入住: ${dayjs(v[1]).format('MM-DD')} ~ ${dayjs(v[2]).format('MM-DD')}`;
},
},
grid: { left: 80, right: 30, top: 20, bottom: 30 },
xAxis: { type: 'time', min: pStart, max: pEnd },
yAxis: { type: 'category', data: rooms, inverse: true },
series: [{
type: 'custom',
renderItem: (_params: any, api: any) => {
const catIdx = api.value(0);
const start = api.coord([api.value(1), catIdx]);
const end = api.coord([api.value(2), catIdx]);
const height = api.size([0, 1])[1] * 0.6;
return {
type: 'rect',
shape: { x: start[0], y: start[1] - height / 2, width: end[0] - start[0], height },
style: { ...api.style(), fill: api.visual('color'), stroke: '#fff', lineWidth: 1 },
};
},
encode: { x: [1, 2], y: 0 },
data,
}],
};
};
// 费用饼图
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] } }],
};
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: 'center' }}>
<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} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card><Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="在读学生" value={stats?.totalStudents || 0} prefix={<TeamOutlined />} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="当前在住" value={stats?.occupiedBeds || 0} suffix={`/ ${stats?.totalCapacity || 0}`} prefix={<CheckCircleOutlined />} /></Card>
</Col>
<Col span={6}>
<Card><Statistic title="入住率" value={stats?.occupancyRate || 0} suffix="%" prefix={<DollarOutlined />} /></Card>
</Col>
</Row>
<Card title="入住时间线(甘特图)" style={{ marginBottom: 24 }}>
{ganttData.length > 0 ? (
<ReactECharts option={ganttOption()} style={{ height: Math.max(300, ganttData.length * 40) }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
<Row gutter={16}>
<Col span={12}>
<Card title="费用类型分布">
{expenseStats.length > 0 ? (
<ReactECharts option={pieOption} style={{ height: 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col span={12}>
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts option={barOption} style={{ height: 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
</div>
);
};
export default DashboardPage;