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,375 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Select, DatePicker, Input, InputNumber, Space, message, Tag, Popconfirm, Upload, Switch, Alert, Tooltip } from 'antd';
import { PlusOutlined, SwapOutlined, LogoutOutlined, DeleteOutlined, UploadOutlined, DownloadOutlined, ExportOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const OccupanciesPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [checkInModal, setCheckInModal] = useState(false);
const [checkOutModal, setCheckOutModal] = useState<any>(null);
const [transferModal, setTransferModal] = useState<any>(null);
const [showActive, setShowActive] = useState(true);
const [autoDeposit, setAutoDeposit] = useState(true);
const [depositAmount, setDepositAmount] = useState(500);
const [searchText, setSearchText] = useState('');
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
const [checkInForm] = Form.useForm();
const [checkOutForm] = Form.useForm();
const [transferForm] = Form.useForm();
const [batchCheckOutForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const [occ, stu, rm]: any[] = await Promise.all([
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined } }),
api.get('/students'),
api.get('/rooms/overview'),
]);
setData(occ);
setStudents(stu);
setRooms(rm);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); setSelectedRowKeys([]); }, [showActive]);
const filteredData = useMemo(() => {
if (!searchText) return data;
const keyword = searchText.toLowerCase();
return data.filter((r: any) =>
r.student?.name?.toLowerCase().includes(keyword) ||
r.room?.roomNumber?.toLowerCase().includes(keyword)
);
}, [data, searchText]);
const handleCheckIn = async () => {
const values = await checkInForm.validateFields();
try {
await api.post('/occupancies/check-in', {
studentId: values.studentId,
roomId: values.roomId,
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('入住登记成功');
setCheckInModal(false);
checkInForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleCheckOut = async () => {
const values = await checkOutForm.validateFields();
try {
await api.put(`/occupancies/${checkOutModal.id}/check-out`, {
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'),
checkOutReason: values.checkOutReason,
});
message.success('退宿成功');
setCheckOutModal(null);
checkOutForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleTransfer = async () => {
const values = await transferForm.validateFields();
try {
await api.put(`/occupancies/${transferModal.id}/transfer`, {
newRoomId: values.newRoomId,
transferDate: values.transferDate.format('YYYY-MM-DD'),
oldBillingEndDate: values.oldBillingEndDate?.format('YYYY-MM-DD'),
newBillingStartDate: values.newBillingStartDate?.format('YYYY-MM-DD'),
reason: values.reason,
});
message.success('换房成功');
setTransferModal(null);
transferForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleBatchCheckOut = async () => {
const values = await batchCheckOutForm.validateFields();
try {
const res: any = await api.post('/occupancies/batch-check-out', {
ids: selectedRowKeys,
checkOutDate: values.checkOutDate.format('YYYY-MM-DD'),
billingEndDate: values.billingEndDate?.format('YYYY-MM-DD'),
checkOutReason: values.checkOutReason,
});
message.success(res.message || `已成功退宿 ${res.success}`);
setBatchCheckOutModal(false);
batchCheckOutForm.resetFields();
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量退宿失败'); }
};
const handleBatchDelete = async () => {
try {
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已删除 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量删除失败'); }
};
const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '宿舍', render: (_: any, r: any) => r.room?.roomNumber || '-' },
{ title: '入住日期', dataIndex: 'checkInDate' },
{ title: '计费起始', dataIndex: 'billingStartDate' },
{ title: '退宿日期', dataIndex: 'checkOutDate', render: (v: any) => v || <Tag color="green"></Tag> },
{ title: '计费截止', dataIndex: 'billingEndDate', render: (v: any) => v || '-' },
{ title: '退宿原因', dataIndex: 'checkOutReason', render: (v: any) => v || '-' },
{
title: '操作', width: 200,
render: (_: any, record: any) => !record.checkOutDate ? (
<Space>
<PermissionButton permission="occupancy:checkout" size="small" icon={<LogoutOutlined />} onClick={() => { setCheckOutModal(record); checkOutForm.setFieldsValue({ checkOutDate: dayjs() }); }}>退宿</PermissionButton>
<PermissionButton permission="occupancy:transfer" size="small" icon={<SwapOutlined />} onClick={() => { setTransferModal(record); transferForm.setFieldsValue({ transferDate: dayjs() }); }}></PermissionButton>
</Space>
) : (
<Space>
<Tag>退宿</Tag>
<PermissionButton permission="occupancy:delete">
<Popconfirm title="确定删除此记录?" onConfirm={async () => { try { await api.delete(`/occupancies/${record.id}`); message.success('删除成功'); fetchData(); } catch (e: any) { message.error(e?.message || '删除失败'); } }}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
const rowSelection = {
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量删除
getCheckboxProps: (record: any) => showActive ? { disabled: !!record.checkOutDate } : {},
};
return (
<div>
<Alert
message="一站式导入"
description="导入入住名单时会自动创建学生和宿舍,无需单独在「学生管理」或「宿舍管理」中手动添加。后续仅需在此页面处理换房/退宿等日常操作即可。"
type="info"
showIcon
closable
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Button type={showActive ? 'primary' : 'default'} onClick={() => setShowActive(true)}></Button>
<Button type={!showActive ? 'primary' : 'default'} onClick={() => setShowActive(false)}></Button>
<Input.Search placeholder="搜索学生姓名或房间号" onSearch={setSearchText} allowClear style={{ width: 200 }} />
</Space>
<Space wrap>
<PermissionButton permission="occupancy:checkin" type="primary" icon={<PlusOutlined />} onClick={() => { checkInForm.resetFields(); checkInForm.setFieldsValue({ checkInDate: dayjs() }); setCheckInModal(true); }}>
</PermissionButton>
<PermissionButton permission="occupancy:checkin">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file);
const params = new URLSearchParams();
if (autoDeposit) {
params.set('autoDeposit', 'true');
params.set('depositAmount', String(depositAmount));
}
try {
const res: any = await api.post(`/occupancies/import?${params.toString()}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
if (res.errors?.length > 0) {
Modal.warning({ title: res.message, content: res.errors.join('\n'), width: 500 });
} else {
message.success(res.message);
}
onSuccess?.(res);
fetchData();
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
}}
>
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}></Button>
</Tooltip>
</Upload>
</PermissionButton>
<PermissionButton permission="occupancy:view" icon={<DownloadOutlined />} onClick={() => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/occupancies/template`, { 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 = '入住名单导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
}}></PermissionButton>
<PermissionButton permission="occupancy:view" icon={<ExportOutlined />} onClick={() => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showActive ? '?active=true' : '';
fetch(`${baseURL}/occupancies/export${params}`, { 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 = showActive ? '在住记录.xlsx' : '全部入住记录.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
}}></PermissionButton>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6, fontSize: 13 }}>
<Switch size="small" checked={autoDeposit} onChange={setAutoDeposit} />
{autoDeposit && <InputNumber size="small" min={0} value={depositAmount} onChange={(v) => setDepositAmount(v || 500)} style={{ width: 80 }} addonAfter="元" />}
</span>
</Space>
</div>
{selectedRowKeys.length > 0 && (
<Alert
message={
<span>
<strong>{selectedRowKeys.length}</strong>
{showActive ? (
<PermissionButton permission="occupancy:checkout" type="primary" size="small" icon={<LogoutOutlined />} onClick={() => { batchCheckOutForm.resetFields(); batchCheckOutForm.setFieldsValue({ checkOutDate: dayjs() }); setBatchCheckOutModal(true); }} style={{ marginLeft: 12 }}>退宿</PermissionButton>
) : (
<PermissionButton permission="occupancy:delete">
<Popconfirm title={`确定删除选中的 ${selectedRowKeys.length} 条入住记录?在住记录会自动跳过`} onConfirm={handleBatchDelete} okText="删除" cancelText="取消">
<Button danger size="small" icon={<DeleteOutlined />} style={{ marginLeft: 12 }}></Button>
</Popconfirm>
</PermissionButton>
)}
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}></Button>
</span>
}
type="info"
style={{ marginBottom: 12 }}
/>
)}
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={rowSelection}
/>
{/* 入住登记弹窗 */}
<Modal title="入住登记" open={checkInModal} onOk={handleCheckIn} onCancel={() => setCheckInModal(false)} okText="确认入住" width={500}>
<Form form={checkInForm} layout="vertical">
<Form.Item name="studentId" label="选择学生" rules={[{ required: true, message: '请选择学生' }]}>
<Select showSearch optionFilterProp="label" placeholder="搜索并选择学生"
options={students.filter((s: any) => s.status === 'active').map((s: any) => ({ value: s.id, label: `${s.name} (${s.idNumber || s.phone || ''})` }))} />
</Form.Item>
<Form.Item name="roomId" label="选择宿舍" rules={[{ required: true, message: '请选择宿舍' }]}>
<Select showSearch optionFilterProp="label" placeholder="搜索并选择宿舍"
options={rooms.map((r: any) => ({ value: r.id, label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, disabled: r.currentCount >= r.capacity }))} />
</Form.Item>
<Form.Item name="checkInDate" label="入住日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择入住日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="billingStartDate" label="计费起始日" extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)">
<DatePicker style={{ width: '100%' }} placeholder="选择计费起始日" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
{/* 退宿弹窗 */}
<Modal title={`退宿 - ${checkOutModal?.student?.name}`} open={!!checkOutModal} onOk={handleCheckOut} onCancel={() => setCheckOutModal(null)} okText="确认退宿">
<Form form={checkOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同">
<DatePicker style={{ width: '100%' }} placeholder="选择计费截止日" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="checkOutReason" label="退宿原因">
<Select allowClear options={[
{ value: '换房', label: '换房' },
{ value: '退训', label: '退训' },
{ value: '结业', label: '结业' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]} />
</Form.Item>
</Form>
</Modal>
{/* 批量退宿弹窗 */}
<Modal title={`批量退宿(${selectedRowKeys.length} 人)`} open={batchCheckOutModal} onOk={handleBatchCheckOut} onCancel={() => setBatchCheckOutModal(false)} okText="确认批量退宿" width={500}>
<Form form={batchCheckOutForm} layout="vertical">
<Form.Item name="checkOutDate" label="退宿日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择退宿日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="billingEndDate" label="计费截止日" extra="默认与退宿日期相同">
<DatePicker style={{ width: '100%' }} placeholder="选择计费截止日" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="checkOutReason" label="退宿原因">
<Select allowClear options={[
{ value: '结业', label: '结业' },
{ value: '退训', label: '退训' },
{ value: '毕业', label: '毕业' },
{ value: '其他', label: '其他' },
]} />
</Form.Item>
</Form>
<div style={{ marginTop: 12, padding: '8px 12px', background: '#f5f5f5', borderRadius: 6, maxHeight: 150, overflow: 'auto' }}>
<div style={{ fontSize: 12, color: '#666', marginBottom: 4 }}>退宿</div>
{data.filter((r: any) => selectedRowKeys.includes(r.id)).map((r: any) => (
<Tag key={r.id} style={{ marginBottom: 4 }}>{r.student?.name} ({r.room?.roomNumber})</Tag>
))}
</div>
</Modal>
{/* 换房弹窗 */}
<Modal title={`换房 - ${transferModal?.student?.name}`} open={!!transferModal} onOk={handleTransfer} onCancel={() => setTransferModal(null)} okText="确认换房" width={500}>
<Form form={transferForm} layout="vertical">
<Form.Item name="newRoomId" label="目标宿舍" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" placeholder="选择目标宿舍"
options={rooms.filter((r: any) => r.id !== transferModal?.roomId).map((r: any) => ({ value: r.id, label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`, disabled: r.currentCount >= r.capacity }))} />
</Form.Item>
<Form.Item name="transferDate" label="换房日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择换房日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="oldBillingEndDate" label="旧房计费截止日" extra="默认为换房当天">
<DatePicker style={{ width: '100%' }} placeholder="选择旧房计费截止日" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="newBillingStartDate" label="新房计费起始日" extra="默认为换房次日">
<DatePicker style={{ width: '100%' }} placeholder="选择新房计费起始日" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="reason" label="换房原因">
<Input />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default OccupanciesPage;