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,302 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Select, Space, message, Tag, Popconfirm, Badge, Upload } from 'antd';
import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, SearchOutlined, ExportOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可入住', color: 'green' },
full: { text: '已满', color: 'red' },
maintenance: { text: '维修中', color: 'orange' },
archived: { text: '已归档', color: '#999' },
};
const RoomsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [detailModal, setDetailModal] = useState<any>(null);
const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0);
const [searchText, setSearchText] = useState('');
const [filterBuilding, setFilterBuilding] = useState<string | undefined>(undefined);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [form] = Form.useForm();
const handleBatchDelete = async () => {
try {
const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量归档失败'); }
};
const fetchData = async () => {
setLoading(true);
try {
const params: any = { includeArchived: 'true' };
const res: any = await api.get('/rooms/overview', { params });
const archived = res.filter((r: any) => r.status === 'archived');
setArchivedCount(archived.length);
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
setData(filtered);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [showArchived]);
// 获取楼栋列表用于筛选
const buildings = useMemo(() => {
const set = new Set(data.map((r: any) => r.building).filter(Boolean));
return [...set].sort();
}, [data]);
// 前端搜索和楼栋筛选
const filteredData = useMemo(() => {
let result = data;
if (searchText) {
const keyword = searchText.toLowerCase();
result = result.filter((r: any) => r.roomNumber?.toLowerCase().includes(keyword));
}
if (filterBuilding) {
result = result.filter((r: any) => r.building === filterBuilding);
}
return result;
}, [data, searchText, filterBuilding]);
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rooms/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/rooms', values);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const showDetail = async (id: number) => {
try {
const res = await api.get(`/rooms/${id}`);
setDetailModal(res);
} catch (e) { console.error(e); }
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/rooms/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const handleRestore = async (id: number) => {
try {
await api.put(`/rooms/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/rooms/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('下载失败'));
};
const handleExport = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showArchived ? '?includeArchived=true' : '';
fetch(`${baseURL}/rooms/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 = '宿舍列表.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
};
const columns = [
{ title: '房间号', dataIndex: 'roomNumber', sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber) },
{ title: '楼栋', dataIndex: 'building' },
{ title: '楼层', dataIndex: 'floor' },
{ title: '类型', dataIndex: 'roomType', render: (v: any) => v || '-' },
{ title: '额定人数', dataIndex: 'capacity' },
{
title: '当前入住',
render: (_: any, r: any) => r.status === 'archived' ? <Tag color="#999">-</Tag> : <Badge count={r.currentCount} showZero overflowCount={99} style={{ backgroundColor: r.currentCount >= r.capacity ? '#ff4d4f' : '#52c41a' }} />,
},
{ title: '性别', dataIndex: 'gender', width: 60, render: (v: any) => v ? <Tag color={v === '男' ? 'blue' : 'pink'}>{v}</Tag> : '-' },
{
title: '状态', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作', width: 220,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<PermissionButton permission="room:edit">
<Popconfirm title="确定恢复此宿舍?恢复后将重新出现在宿舍总览中。" onConfirm={() => handleRestore(record.id)} okText="恢复" cancelText="取消">
<Button size="small" icon={<UndoOutlined />} type="link"></Button>
</Popconfirm>
</PermissionButton>
) : (
<>
<PermissionButton permission="room:view" size="small" type="link" onClick={() => showDetail(record.id)}></PermissionButton>
<PermissionButton permission="room:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}></PermissionButton>
<PermissionButton permission="room:delete">
<Popconfirm title="归档后不会删除数据,可随时恢复。有在住人员将无法归档。" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<Button size="small" icon={<InboxOutlined />}></Button>
</Popconfirm>
</PermissionButton>
</>
)}
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<h3 style={{ margin: 0 }}>宿</h3>
<Input.Search
placeholder="搜索房间号"
onSearch={setSearchText}
allowClear
style={{ width: 160 }}
prefix={<SearchOutlined />}
/>
<Select
placeholder="筛选楼栋"
allowClear
style={{ width: 120 }}
onChange={(v) => setFilterBuilding(v)}
options={buildings.map((b) => ({ value: b, label: b }))}
/>
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? '隐藏已归档' : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button>
</Space>
<Space wrap>
<PermissionButton permission="room:delete">
<Popconfirm title={`确定批量归档选中的 ${selectedRowKeys.length} 间宿舍?(有在住人员的会跳过)`} onConfirm={handleBatchDelete} okText="归档" cancelText="取消" disabled={selectedRowKeys.length === 0}>
<Button danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}></Button>
</Popconfirm>
</PermissionButton>
<PermissionButton permission="room:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
宿
</PermissionButton>
<PermissionButton permission="room:create">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/rooms/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</PermissionButton>
<PermissionButton permission="room:view" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}></PermissionButton>
<PermissionButton permission="room:view" icon={<ExportOutlined />} onClick={handleExport}></PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
rowClassName={(record) => record.status === 'archived' ? 'archived-row' : ''}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
}}
/>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
<Modal title={editing ? '编辑宿舍' : '添加宿舍'} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditing(null); }} okText="保存">
<Form form={form} layout="vertical">
<Form.Item name="roomNumber" label="房间号" rules={[{ required: true }]}><Input placeholder="如4-102自动解析楼栋楼层" /></Form.Item>
<Form.Item name="building" label="楼栋"><Input placeholder="如4号楼留空自动解析" /></Form.Item>
<Form.Item name="floor" label="楼层"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="capacity" label="额定人数" rules={[{ required: true }]}><InputNumber min={1} max={20} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="roomType" label="宿舍类型">
<Select allowClear options={[
{ value: '四人间', label: '四人间' },
{ value: '单人间', label: '单人间' },
{ value: '家庭房', label: '家庭房' },
{ value: '爆改房', label: '爆改房' },
]} placeholder="留空自动解析" />
</Form.Item>
{editing && (
<Form.Item name="status" label="状态">
<Select options={[
{ value: 'available', label: '可入住' },
{ value: 'full', label: '已满' },
{ value: 'maintenance', label: '维修中' },
]} />
</Form.Item>
)}
</Form>
</Modal>
<Modal title={`宿舍 ${detailModal?.roomNumber} 当前住户`} open={!!detailModal} onCancel={() => setDetailModal(null)} footer={null} width={600}>
{detailModal?.currentOccupants?.length > 0 ? (
<Table
dataSource={detailModal.currentOccupants}
rowKey="id"
pagination={false}
columns={[
{ title: '学生', render: (_: any, r: any) => r.student?.name },
{ title: '入住日期', dataIndex: 'checkInDate' },
{ title: '计费起始', dataIndex: 'billingStartDate' },
]}
/>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#999' }}></div>
)}
</Modal>
</div>
);
};
export default RoomsPage;