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,228 @@
import React, { useEffect, useState, useMemo } from 'react';
import { DatePicker, Card, Row, Col, Statistic, Tag, Space, Button, Modal, Spin, Empty, Tooltip } from 'antd';
import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
interface ScheduleData {
year: number;
month: number;
days: number;
classrooms: any[];
tenants: any[];
matrix: Record<number, Record<number, any>>;
summary: Record<number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }>;
}
const ClassroomSchedulePage: React.FC = () => {
const [month, setMonth] = useState<Dayjs>(dayjs());
const [loading, setLoading] = useState(false);
const [data, setData] = useState<ScheduleData | null>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/classroom-rentals/schedule', {
params: { year: month.year(), month: month.month() + 1 },
});
setData(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [month]);
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
if (!data) return [];
const map = new Map<string, any[]>();
for (const c of data.classrooms) {
const key = `${c.building || '其他'}${c.floor ? ` · ${c.floor}` : ''}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(c);
}
return Array.from(map.entries()).map(([name, classrooms]) => ({ name, classrooms }));
}, [data]);
// 整体统计
const overall = useMemo(() => {
if (!data) return { total: 0, rented: 0, rate: 0 };
let rented = 0;
const total = data.classrooms.length * data.days;
for (const cid of Object.keys(data.summary)) {
rented += data.summary[+cid].rentedDays;
}
return {
total,
rented,
rate: total > 0 ? Math.round((rented / total) * 100) : 0,
};
}, [data]);
const showDetail = async (rentalId: number) => {
try {
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
setDetailModal(res);
} catch (e) { console.error(e); }
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
});
};
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<Space>
<CalendarOutlined style={{ fontSize: 20 }} />
<h3 style={{ margin: 0 }}></h3>
</Space>
<Space>
<Button onClick={() => setMonth(month.subtract(1, 'month'))}></Button>
<DatePicker picker="month" value={month} onChange={(v) => v && setMonth(v)} allowClear={false} placeholder="选择月份" format="YYYY年M月" />
<Button onClick={() => setMonth(month.add(1, 'month'))}></Button>
<Button type="primary" onClick={() => setMonth(dayjs())}></Button>
</Space>
</div>
{/* 统计卡片 */}
<Row gutter={[16, 16]} style={{ marginBottom: 16 }}>
<Col xs={12} sm={6}><Card size="small"><Statistic title="教室总数" value={data?.classrooms.length || 0} /></Card></Col>
<Col xs={12} sm={6}><Card size="small"><Statistic title="本月天数" value={data?.days || 0} /></Card></Col>
<Col xs={12} sm={6}><Card size="small"><Statistic title="总占用天数" value={overall.rented} suffix={`/${overall.total}`} /></Card></Col>
<Col xs={12} sm={6}><Card size="small"><Statistic title="整体占用率" value={overall.rate} suffix="%" valueStyle={{ color: overall.rate > 70 ? '#cf1322' : overall.rate > 40 ? '#fa8c16' : '#3f8600' }} /></Card></Col>
</Row>
{/* 租赁方图例 */}
{data && data.tenants.length > 0 && (
<Card size="small" style={{ marginBottom: 16 }} title="租赁方图例">
<Space wrap>
{data.tenants.map(t => (
<Tag key={t.id} color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>{t.name}</Tag>
))}
</Space>
</Card>
)}
<Spin spinning={loading}>
{!data || data.classrooms.length === 0 ? (
<Empty description="暂无教室数据" />
) : (
<div style={{ overflowX: 'auto' }}>
{groups.map(group => (
<Card
key={group.name}
size="small"
title={group.name}
style={{ marginBottom: 12 }}
bodyStyle={{ padding: 0 }}
>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 12 }}>
<thead>
<tr style={{ background: '#fafafa' }}>
<th style={{ position: 'sticky', left: 0, background: '#fafafa', zIndex: 2, padding: '8px', border: '1px solid #f0f0f0', minWidth: 120, textAlign: 'left' }}></th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 60 }}></th>
<th style={{ padding: '8px 6px', border: '1px solid #f0f0f0', minWidth: 70 }}></th>
{Array.from({ length: data.days }, (_, i) => i + 1).map(d => (
<th key={d} style={{ padding: '8px 4px', border: '1px solid #f0f0f0', minWidth: 26, textAlign: 'center' }}>{d}</th>
))}
</tr>
</thead>
<tbody>
{group.classrooms.map(c => {
const sum = data.summary[c.id] || { rentedDays: 0, totalDays: data.days, occupancyRate: 0 };
return (
<tr key={c.id}>
<td style={{ position: 'sticky', left: 0, background: '#fff', zIndex: 1, padding: '6px 8px', border: '1px solid #f0f0f0', fontWeight: 500 }}>{c.name}</td>
<td style={{ padding: '6px', border: '1px solid #f0f0f0', textAlign: 'center' }}>{c.roomType}</td>
<td style={{ padding: '6px', border: '1px solid #f0f0f0', textAlign: 'center', color: sum.occupancyRate > 0.7 ? '#cf1322' : sum.occupancyRate > 0.4 ? '#fa8c16' : '#3f8600' }}>
{Math.round(sum.occupancyRate * 100)}%
</td>
{Array.from({ length: data.days }, (_, i) => i + 1).map(d => {
const cell = data.matrix[c.id]?.[d];
return (
<td
key={d}
onClick={() => cell && showDetail(cell.rentalId)}
style={{
padding: 0,
border: '1px solid #f0f0f0',
background: cell?.color || '#fff',
height: 26,
cursor: cell ? 'pointer' : 'default',
textAlign: 'center',
}}
>
{cell && (
<Tooltip title={`${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`}>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{cell.hasContract ? '📄' : ''}
</span>
</Tooltip>
)}
</td>
);
})}
</tr>
);
})}
</tbody>
</table>
</Card>
))}
</div>
)}
</Spin>
<Modal
title="租赁详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={500}
>
{detailModal && (
<div style={{ lineHeight: 2 }}>
<div><strong></strong>{detailModal.classroom?.building} · {detailModal.classroom?.name}{detailModal.classroom?.roomType}</div>
<div><strong></strong>
<Tag color={detailModal.tenant?.color} style={{ background: detailModal.tenant?.color, color: '#fff', borderColor: detailModal.tenant?.color }}>
{detailModal.tenant?.name}
</Tag>
</div>
<div><strong></strong>{detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''}</div>
<div><strong></strong>{detailModal.startDate} ~ {detailModal.endDate}{dayjs(detailModal.endDate).diff(dayjs(detailModal.startDate), 'day') + 1}</div>
{detailModal.dailyRate && <div><strong></strong>¥{detailModal.dailyRate}</div>}
{detailModal.totalAmount && <div><strong></strong>¥{detailModal.totalAmount}</div>}
{detailModal.notes && <div><strong></strong>{detailModal.notes}</div>}
<div style={{ marginTop: 12 }}>
<strong></strong>
{detailModal.contractPath ? (
<Button
type="link"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(detailModal.id, detailModal.contractOriginalName)}
>
{detailModal.contractOriginalName || '下载'}
</Button>
) : '未上传'}
</div>
</div>
)}
</Modal>
</div>
);
};
export default ClassroomSchedulePage;