chore: commit oxfmt formatting changes and verify artifacts

This commit is contained in:
2026-07-02 15:55:09 +08:00
parent 34726cc46d
commit 4f4cee157a
77 changed files with 5576 additions and 1400 deletions

View File

@@ -40,15 +40,15 @@ export default defineConfig([
// other options...
},
},
])
]);
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
import reactX from 'eslint-plugin-react-x';
import reactDom from 'eslint-plugin-react-dom';
export default defineConfig([
globalIgnores(['dist']),
@@ -69,5 +69,5 @@ export default defineConfig([
// other options...
},
},
])
]);
```

View File

@@ -42,8 +42,7 @@
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) scale(1.4);
}
.vite {
@@ -51,8 +50,7 @@
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) scale(0.8);
}
}

View File

@@ -29,32 +29,162 @@ const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =>
const App: React.FC = () => {
return (
<ConfigProvider locale={zhCN} theme={{ token: { colorPrimary: '#007AFF', borderRadius: 10, colorBgContainer: '#fff', fontFamily: "-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif" } }}>
<ConfigProvider
locale={zhCN}
theme={{
token: {
colorPrimary: '#007AFF',
borderRadius: 10,
colorBgContainer: '#fff',
fontFamily:
"-apple-system, BlinkMacSystemFont, 'SF Pro Display', 'SF Pro Text', 'Helvetica Neue', Arial, sans-serif",
},
}}
>
<AntdApp>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route path="/" element={<PrivateRoute><MainLayout /></PrivateRoute>}>
<Route index element={<Navigate to="/dashboard" />} />
<Route path="dashboard" element={<PermissionRoute permission="dashboard:view"><DashboardPage /></PermissionRoute>} />
<Route path="room-visual" element={<PermissionRoute permission="room:view"><RoomVisualPage /></PermissionRoute>} />
<Route path="students" element={<PermissionRoute permission="student:view"><StudentsPage /></PermissionRoute>} />
<Route path="rooms" element={<PermissionRoute permission="room:view"><RoomsPage /></PermissionRoute>} />
<Route path="occupancies" element={<PermissionRoute permission="occupancy:view"><OccupanciesPage /></PermissionRoute>} />
<Route path="expenses" element={<PermissionRoute permission="expense:view"><ExpensesPage /></PermissionRoute>} />
<Route path="deposits" element={<PermissionRoute permission="deposit:view"><DepositsPage /></PermissionRoute>} />
<Route path="bills" element={<PermissionRoute permission="bill:view"><BillsPage /></PermissionRoute>} />
<Route path="operation-logs" element={<PermissionRoute permission="log:view"><OperationLogsPage /></PermissionRoute>} />
<Route path="roles" element={<PermissionRoute permission="role:view"><RolesPage /></PermissionRoute>} />
<Route path="permissions" element={<PermissionRoute permission="role:view"><PermissionsPage /></PermissionRoute>} />
<Route path="users" element={<PermissionRoute permission="user:view"><UsersPage /></PermissionRoute>} />
<Route path="classrooms" element={<PermissionRoute permission="classroom:view"><ClassroomsPage /></PermissionRoute>} />
<Route path="tenants" element={<PermissionRoute permission="tenant:view"><TenantsPage /></PermissionRoute>} />
<Route path="classroom-rentals" element={<PermissionRoute permission="rental:view"><ClassroomRentalsPage /></PermissionRoute>} />
<Route path="classroom-schedule" element={<PermissionRoute permission="classroom:view"><ClassroomSchedulePage /></PermissionRoute>} />
</Route>
</Routes>
</BrowserRouter>
<BrowserRouter>
<Routes>
<Route path="/login" element={<LoginPage />} />
<Route
path="/"
element={
<PrivateRoute>
<MainLayout />
</PrivateRoute>
}
>
<Route index element={<Navigate to="/dashboard" />} />
<Route
path="dashboard"
element={
<PermissionRoute permission="dashboard:view">
<DashboardPage />
</PermissionRoute>
}
/>
<Route
path="room-visual"
element={
<PermissionRoute permission="room:view">
<RoomVisualPage />
</PermissionRoute>
}
/>
<Route
path="students"
element={
<PermissionRoute permission="student:view">
<StudentsPage />
</PermissionRoute>
}
/>
<Route
path="rooms"
element={
<PermissionRoute permission="room:view">
<RoomsPage />
</PermissionRoute>
}
/>
<Route
path="occupancies"
element={
<PermissionRoute permission="occupancy:view">
<OccupanciesPage />
</PermissionRoute>
}
/>
<Route
path="expenses"
element={
<PermissionRoute permission="expense:view">
<ExpensesPage />
</PermissionRoute>
}
/>
<Route
path="deposits"
element={
<PermissionRoute permission="deposit:view">
<DepositsPage />
</PermissionRoute>
}
/>
<Route
path="bills"
element={
<PermissionRoute permission="bill:view">
<BillsPage />
</PermissionRoute>
}
/>
<Route
path="operation-logs"
element={
<PermissionRoute permission="log:view">
<OperationLogsPage />
</PermissionRoute>
}
/>
<Route
path="roles"
element={
<PermissionRoute permission="role:view">
<RolesPage />
</PermissionRoute>
}
/>
<Route
path="permissions"
element={
<PermissionRoute permission="role:view">
<PermissionsPage />
</PermissionRoute>
}
/>
<Route
path="users"
element={
<PermissionRoute permission="user:view">
<UsersPage />
</PermissionRoute>
}
/>
<Route
path="classrooms"
element={
<PermissionRoute permission="classroom:view">
<ClassroomsPage />
</PermissionRoute>
}
/>
<Route
path="tenants"
element={
<PermissionRoute permission="tenant:view">
<TenantsPage />
</PermissionRoute>
}
/>
<Route
path="classroom-rentals"
element={
<PermissionRoute permission="rental:view">
<ClassroomRentalsPage />
</PermissionRoute>
}
/>
<Route
path="classroom-schedule"
element={
<PermissionRoute permission="classroom:view">
<ClassroomSchedulePage />
</PermissionRoute>
}
/>
</Route>
</Routes>
</BrowserRouter>
</AntdApp>
</ConfigProvider>
);

View File

@@ -8,7 +8,11 @@ interface PermissionButtonProps extends ButtonProps {
children: React.ReactNode;
}
const PermissionButton: React.FC<PermissionButtonProps> = ({ permission, children, ...btnProps }) => {
const PermissionButton: React.FC<PermissionButtonProps> = ({
permission,
children,
...btnProps
}) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) return null;
return <Button {...btnProps}>{children}</Button>;

View File

@@ -10,13 +10,7 @@ interface PermissionRouteProps {
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) {
return (
<Result
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
/>
);
return <Result status="403" title="无权访问" subTitle="您没有访问此页面的权限" />;
}
return <>{children}</>;
};

View File

@@ -12,10 +12,10 @@ export function usePermission() {
const hasPermission = (code: string): boolean => permissions.includes(code);
const hasAnyPermission = (...codes: string[]): boolean =>
codes.some(c => permissions.includes(c));
codes.some((c) => permissions.includes(c));
const hasAllPermissions = (...codes: string[]): boolean =>
codes.every(c => permissions.includes(c));
codes.every((c) => permissions.includes(c));
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
}

View File

@@ -5,7 +5,8 @@
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}

View File

@@ -36,7 +36,12 @@ interface MenuItemType {
}
const allMenuItems: MenuItemType[] = [
{ key: '/dashboard', icon: <DashboardOutlined />, label: '数据面板', permission: 'dashboard:view' },
{
key: '/dashboard',
icon: <DashboardOutlined />,
label: '数据面板',
permission: 'dashboard:view',
},
{ key: '/room-visual', icon: <AppstoreOutlined />, label: '宿舍总览', permission: 'room:view' },
{ key: '/students', icon: <TeamOutlined />, label: '学生管理', permission: 'student:view' },
{ key: '/rooms', icon: <HomeOutlined />, label: '宿舍管理', permission: 'room:view' },
@@ -50,9 +55,24 @@ const allMenuItems: MenuItemType[] = [
label: '教室管理',
permission: 'classroom:view',
children: [
{ key: '/classroom-schedule', icon: <CalendarOutlined />, label: '排期总览', permission: 'classroom:view' },
{ key: '/classrooms', icon: <ReadOutlined />, label: '教室列表', permission: 'classroom:view' },
{ key: '/classroom-rentals', icon: <FileProtectOutlined />, label: '租赁订单', permission: 'rental:view' },
{
key: '/classroom-schedule',
icon: <CalendarOutlined />,
label: '排期总览',
permission: 'classroom:view',
},
{
key: '/classrooms',
icon: <ReadOutlined />,
label: '教室列表',
permission: 'classroom:view',
},
{
key: '/classroom-rentals',
icon: <FileProtectOutlined />,
label: '租赁订单',
permission: 'rental:view',
},
{ key: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
@@ -80,7 +100,7 @@ const MainLayout: React.FC = () => {
// 按 permission 过滤菜单
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
return items
.map(item => {
.map((item) => {
if (item.children) {
const kids = filterByPermission(item.children);
if (kids.length === 0) return null;
@@ -107,7 +127,7 @@ const MainLayout: React.FC = () => {
};
const transformToMenuItems = (items: MenuItemType[]): any[] => {
return items.map(item => ({
return items.map((item) => ({
key: item.key,
icon: item.icon,
label: item.label,
@@ -129,33 +149,94 @@ const MainLayout: React.FC = () => {
return (
<Layout style={{ minHeight: '100vh' }}>
{!isMobile && (
<Sider trigger={null} collapsible collapsed={collapsed} theme="light" style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}>
<div style={{ height: 64, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#1d1d1f', fontSize: collapsed ? 16 : 17, fontWeight: 600, borderBottom: '1px solid #e5e5e7' }}>
<Sider
trigger={null}
collapsible
collapsed={collapsed}
theme="light"
style={{ background: '#fff', borderRight: '1px solid #e5e5e7' }}
>
<div
style={{
height: 64,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#1d1d1f',
fontSize: collapsed ? 16 : 17,
fontWeight: 600,
borderBottom: '1px solid #e5e5e7',
}}
>
{collapsed ? '恭' : '恭学教育基地'}
</div>
{menuContent}
</Sider>
)}
{isMobile && (
<Drawer placement="left" open={drawerOpen} onClose={() => setDrawerOpen(false)} width={240} styles={{ body: { padding: 0 } }} title="恭学教育基地">
<Drawer
placement="left"
open={drawerOpen}
onClose={() => setDrawerOpen(false)}
width={240}
styles={{ body: { padding: 0 } }}
title="恭学教育基地"
>
{menuContent}
</Drawer>
)}
<Layout style={{ background: '#f5f5f7' }}>
<Header style={{ padding: '0 16px', background: '#fff', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderBottom: '1px solid #e5e5e7', boxShadow: 'none' }}>
<Header
style={{
padding: '0 16px',
background: '#fff',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
borderBottom: '1px solid #e5e5e7',
boxShadow: 'none',
}}
>
<Button
type="text"
icon={isMobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
onClick={() => isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed)}
icon={
isMobile ? (
<MenuUnfoldOutlined />
) : collapsed ? (
<MenuUnfoldOutlined />
) : (
<MenuFoldOutlined />
)
}
onClick={() => (isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed))}
/>
<Dropdown menu={{ items: [{ key: 'logout', icon: <LogoutOutlined />, label: '退出登录', onClick: handleLogout }] }}>
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
<div style={{ cursor: 'pointer', display: 'flex', alignItems: 'center', gap: 8 }}>
<Avatar icon={<UserOutlined />} />
<span>{user.name || user.username || '用户'}</span>
</div>
</Dropdown>
</Header>
<Content style={{ margin: isMobile ? 12 : 24, padding: isMobile ? 12 : 24, background: '#fff', borderRadius: 12, overflow: 'auto' }}>
<Content
style={{
margin: isMobile ? 12 : 24,
padding: isMobile ? 12 : 24,
background: '#fff',
borderRadius: 12,
overflow: 'auto',
}}
>
<Outlet />
</Content>
</Layout>

View File

@@ -1,6 +1,25 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, DatePicker, Space, message, Tag, Descriptions, Popconfirm, Input, Select, Tooltip } from 'antd';
import { FileTextOutlined, DeleteOutlined, DownloadOutlined, FilePdfOutlined } from '@ant-design/icons';
import {
Table,
Button,
Modal,
Form,
DatePicker,
Space,
message,
Tag,
Descriptions,
Popconfirm,
Input,
Select,
Tooltip,
} from 'antd';
import {
FileTextOutlined,
DeleteOutlined,
DownloadOutlined,
FilePdfOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -13,7 +32,14 @@ const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已支付', color: 'green' },
};
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', other: '其他' };
const typeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
other: '其他',
};
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
@@ -30,11 +56,15 @@ const BillsPage: React.FC = () => {
try {
const res: any = await api.get('/bills');
setBills(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
@@ -60,14 +90,18 @@ const BillsPage: React.FC = () => {
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '生成失败'); }
} catch (e: any) {
message.error(e?.message || '生成失败');
}
};
const showDetail = async (id: number) => {
try {
const res = await api.get(`/bills/${id}`);
setDetailModal(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
};
const updateStatus = async (id: number, status: string) => {
@@ -78,7 +112,9 @@ const BillsPage: React.FC = () => {
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
}
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const batchUpdateStatus = async (status: string) => {
@@ -88,7 +124,9 @@ const BillsPage: React.FC = () => {
message.success(`已批量更新 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
@@ -96,7 +134,9 @@ const BillsPage: React.FC = () => {
await api.delete(`/bills/${id}`);
message.success('账单已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const batchDelete = async () => {
@@ -106,18 +146,22 @@ const BillsPage: React.FC = () => {
message.success(`已删除 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleExportExcel = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const url = `${baseURL}/bills/export/excel`;
const a = document.createElement('a');
// 使用 fetch 来携带 token
fetch(url, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
.then((res) => res.blob())
.then((blob) => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
@@ -129,11 +173,15 @@ const BillsPage: React.FC = () => {
};
const handleExportPdf = (billId: number) => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/bills/export/pdf/${billId}`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
fetch(`${baseURL}/bills/export/pdf/${billId}`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -147,15 +195,30 @@ const BillsPage: React.FC = () => {
const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{ title: '分摊费用', dataIndex: 'sharedAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '个人费用', dataIndex: 'personalAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '总计', dataIndex: 'totalAmount', render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong> },
{
title: '分摊费用',
dataIndex: 'sharedAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '个人费用',
dataIndex: 'personalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '总计',
dataIndex: 'totalAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
{
title: '可用押金',
dataIndex: 'availableDeposit',
render: (v: number) => v > 0
? <span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
: <span style={{ color: '#999' }}>-</span>,
render: (v: number) =>
v > 0 ? (
<span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
) : (
<span style={{ color: '#999' }}>-</span>
),
},
{
title: '抵扣后应付',
@@ -173,21 +236,65 @@ const BillsPage: React.FC = () => {
},
},
{
title: '状态', dataIndex: 'status',
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text}</Tag>,
},
{ title: '生成时间', dataIndex: 'generatedAt', render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作', width: 320,
title: '生成时间',
dataIndex: 'generatedAt',
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
width: 320,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="bill:view" size="small" type="link" onClick={() => showDetail(record.id)}></PermissionButton>
{record.status === 'draft' && <PermissionButton permission="bill:confirm" size="small" onClick={() => updateStatus(record.id, 'confirmed')}></PermissionButton>}
{record.status === 'confirmed' && <PermissionButton permission="bill:confirm" size="small" type="primary" onClick={() => updateStatus(record.id, 'paid')}></PermissionButton>}
<PermissionButton permission="bill:export-pdf" size="small" icon={<FilePdfOutlined />} onClick={() => handleExportPdf(record.id)}>PDF</PermissionButton>
<PermissionButton
permission="bill:view"
size="small"
type="link"
onClick={() => showDetail(record.id)}
>
</PermissionButton>
{record.status === 'draft' && (
<PermissionButton
permission="bill:confirm"
size="small"
onClick={() => updateStatus(record.id, 'confirmed')}
>
</PermissionButton>
)}
{record.status === 'confirmed' && (
<PermissionButton
permission="bill:confirm"
size="small"
type="primary"
onClick={() => updateStatus(record.id, 'paid')}
>
</PermissionButton>
)}
<PermissionButton
permission="bill:export-pdf"
size="small"
icon={<FilePdfOutlined />}
onClick={() => handleExportPdf(record.id)}
>
PDF
</PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm title="确定删除此账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}></Button>
<Popconfirm
title="确定删除此账单?"
onConfirm={() => handleDelete(record.id)}
okText="删除"
cancelText="取消"
>
<Button size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
@@ -197,40 +304,85 @@ const BillsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名或账单周期"
allowClear
style={{ width: 220 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={v => setFilterStatus(v)}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'draft', label: '草稿' },
{ value: 'confirmed', label: '已确认' },
{ value: 'paid', label: '已支付' },
]}
/>
<PermissionButton permission="bill:confirm" onClick={() => batchUpdateStatus('confirmed')} disabled={selectedRows.length === 0}></PermissionButton>
<PermissionButton permission="bill:confirm" type="primary" onClick={() => batchUpdateStatus('paid')} disabled={selectedRows.length === 0}></PermissionButton>
<PermissionButton
permission="bill:confirm"
onClick={() => batchUpdateStatus('confirmed')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton
permission="bill:confirm"
type="primary"
onClick={() => batchUpdateStatus('paid')}
disabled={selectedRows.length === 0}
>
</PermissionButton>
<PermissionButton permission="bill:delete">
<Popconfirm title={`确定删除选中的 ${selectedRows.length} 条账单?`} onConfirm={batchDelete} okText="删除" cancelText="取消" disabled={selectedRows.length === 0}>
<Button danger disabled={selectedRows.length === 0} icon={<DeleteOutlined />}></Button>
<Popconfirm
title={`确定删除选中的 ${selectedRows.length} 条账单?`}
onConfirm={batchDelete}
okText="删除"
cancelText="取消"
disabled={selectedRows.length === 0}
>
<Button danger disabled={selectedRows.length === 0} icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
<Space>
<PermissionButton permission="bill:generate" type="primary" icon={<FileTextOutlined />} onClick={() => { generateForm.resetFields(); setGenerateModal(true); }}>
<PermissionButton
permission="bill:generate"
type="primary"
icon={<FileTextOutlined />}
onClick={() => {
generateForm.resetFields();
setGenerateModal(true);
}}
>
</PermissionButton>
<PermissionButton permission="bill:export-excel" icon={<DownloadOutlined />} onClick={handleExportExcel}>Excel</PermissionButton>
<PermissionButton
permission="bill:export-excel"
icon={<DownloadOutlined />}
onClick={handleExportExcel}
>
Excel
</PermissionButton>
</Space>
</div>
<Table
@@ -245,10 +397,25 @@ const BillsPage: React.FC = () => {
}}
/>
<Modal title="生成账单" open={generateModal} onOk={handleGenerate} onCancel={() => setGenerateModal(false)} okText="生成">
<Modal
title="生成账单"
open={generateModal}
onOk={handleGenerate}
onCancel={() => setGenerateModal(false)}
okText="生成"
>
<Form form={generateForm} layout="vertical">
<Form.Item name="period" label="账单周期" rules={[{ required: true, message: '请选择账单周期' }]} extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用">
<RangePicker style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" />
<Form.Item
name="period"
label="账单周期"
rules={[{ required: true, message: '请选择账单周期' }]}
extra="选择费用对应的时间段,系统将自动计算每个学生的分摊费用"
>
<RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
</Form>
</Modal>
@@ -264,20 +431,62 @@ const BillsPage: React.FC = () => {
<>
<Descriptions bordered size="small" column={2} style={{ marginBottom: 16 }}>
<Descriptions.Item label="学生">{detailModal.student?.name}</Descriptions.Item>
<Descriptions.Item label="状态"><Tag color={statusMap[detailModal.status]?.color}>{statusMap[detailModal.status]?.text}</Tag></Descriptions.Item>
<Descriptions.Item label="账单周期">{detailModal.periodStart} ~ {detailModal.periodEnd}</Descriptions.Item>
<Descriptions.Item label="生成时间">{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}</Descriptions.Item>
<Descriptions.Item label="分摊费用">¥{Number(detailModal.sharedAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="个人费用">¥{Number(detailModal.personalAmount).toFixed(2)}</Descriptions.Item>
<Descriptions.Item label="合计" span={2}><strong style={{ fontSize: 18, color: '#007AFF' }}>¥{Number(detailModal.totalAmount).toFixed(2)}</strong></Descriptions.Item>
<Descriptions.Item label="状态">
<Tag color={statusMap[detailModal.status]?.color}>
{statusMap[detailModal.status]?.text}
</Tag>
</Descriptions.Item>
<Descriptions.Item label="账单周期">
{detailModal.periodStart} ~ {detailModal.periodEnd}
</Descriptions.Item>
<Descriptions.Item label="生成时间">
{dayjs(detailModal.generatedAt).format('YYYY-MM-DD HH:mm')}
</Descriptions.Item>
<Descriptions.Item label="分摊费用">
¥{Number(detailModal.sharedAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="个人费用">
¥{Number(detailModal.personalAmount).toFixed(2)}
</Descriptions.Item>
<Descriptions.Item label="合计" span={2}>
<strong style={{ fontSize: 18, color: '#007AFF' }}>
¥{Number(detailModal.totalAmount).toFixed(2)}
</strong>
</Descriptions.Item>
</Descriptions>
{Number(detailModal.availableDeposit || 0) > 0 && (
<div style={{ marginBottom: 16, padding: 12, background: '#f6ffed', border: '1px solid #b7eb8f', borderRadius: 8 }}>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}></div>
<div
style={{
marginBottom: 16,
padding: 12,
background: '#f6ffed',
border: '1px solid #b7eb8f',
borderRadius: 8,
}}
>
<div style={{ fontSize: 13, color: '#666', marginBottom: 6 }}>
</div>
<Space size={24} wrap>
<span><strong style={{ color: '#52c41a' }}>¥{Number(detailModal.availableDeposit).toFixed(2)}</strong></span>
<span><strong style={{ color: '#fa8c16' }}>-¥{Number(detailModal.depositApplied || 0).toFixed(2)}</strong></span>
<span><strong style={{ color: '#fa541c', fontSize: 16 }}>¥{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}</strong></span>
<span>
<strong style={{ color: '#52c41a' }}>
¥{Number(detailModal.availableDeposit).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa8c16' }}>
-¥{Number(detailModal.depositApplied || 0).toFixed(2)}
</strong>
</span>
<span>
<strong style={{ color: '#fa541c', fontSize: 16 }}>
¥
{Number(detailModal.amountAfterDeposit ?? detailModal.totalAmount).toFixed(2)}
</strong>
</span>
</Space>
</div>
)}
@@ -290,10 +499,26 @@ const BillsPage: React.FC = () => {
columns={[
{ title: '类型', dataIndex: 'expenseType', render: (v: string) => typeMap[v] || v },
{ title: '说明', dataIndex: 'description' },
{ title: '计费天数', dataIndex: 'days', render: (v: number) => v > 0 ? `${v}` : '-' },
{ title: '宿舍总人天', dataIndex: 'totalRoomDays', render: (v: number) => v > 0 ? `${v}` : '-' },
{ title: '宿舍总费用', dataIndex: 'roomTotalAmount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '应分摊', dataIndex: 'studentAmount', render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong> },
{
title: '计费天数',
dataIndex: 'days',
render: (v: number) => (v > 0 ? `${v}` : '-'),
},
{
title: '宿舍总人天',
dataIndex: 'totalRoomDays',
render: (v: number) => (v > 0 ? `${v}` : '-'),
},
{
title: '宿舍总费用',
dataIndex: 'roomTotalAmount',
render: (v: number) => `¥${Number(v).toFixed(2)}`,
},
{
title: '应分摊',
dataIndex: 'studentAmount',
render: (v: number) => <strong>¥{Number(v).toFixed(2)}</strong>,
},
]}
/>
</>

View File

@@ -1,5 +1,20 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, message, Tag, Popconfirm, Upload, Tooltip } from 'antd';
import {
Table,
Button,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
message,
Tag,
Popconfirm,
Upload,
Tooltip,
} from 'antd';
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
@@ -33,23 +48,28 @@ const ClassroomRentalsPage: React.FC = () => {
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/tenants'),
]);
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
setClassrooms(cr);
setTenants(tn);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
};
useEffect(() => { fetchMeta(); }, []);
useEffect(() => { fetchData(); }, [filterMonth]);
useEffect(() => {
fetchMeta();
}, []);
useEffect(() => {
fetchData();
}, [filterMonth]);
const handleSave = async () => {
const values = await form.validateFields();
@@ -76,7 +96,9 @@ const ClassroomRentalsPage: React.FC = () => {
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`).join('、');
const list = e.conflicts
.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`)
.join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
@@ -89,18 +111,24 @@ const ClassroomRentalsPage: React.FC = () => {
await api.delete(`/classroom-rentals/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const handleDownloadContract = (id: number, filename?: string) => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
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 => {
fetch(`${baseURL}/classroom-rentals/${id}/contract`, {
headers: { Authorization: `Bearer ${token}` },
})
.then((res) => {
if (!res.ok) throw new Error('下载失败');
return res.blob();
})
.then(blob => {
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -116,7 +144,9 @@ const ClassroomRentalsPage: React.FC = () => {
await api.delete(`/classroom-rentals/${id}/contract`);
message.success('合同已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
} catch (e: any) {
message.error(e?.message || '删除失败');
}
};
const openEdit = (record: any) => {
@@ -134,66 +164,107 @@ const ClassroomRentalsPage: React.FC = () => {
const columns = [
{
title: '教室', dataIndex: 'classroom',
render: (c: any) => c ? <span>{c.building ? `${c.building} · ` : ''}{c.name}</span> : '-',
title: '教室',
dataIndex: 'classroom',
render: (c: any) =>
c ? (
<span>
{c.building ? `${c.building} · ` : ''}
{c.name}
</span>
) : (
'-'
),
},
{
title: '租赁方', dataIndex: 'tenant',
render: (t: any) => t ? <Tag color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>{t.name}</Tag> : '-',
title: '租赁方',
dataIndex: 'tenant',
render: (t: any) =>
t ? (
<Tag color={t.color} style={{ background: t.color, color: '#fff', borderColor: t.color }}>
{t.name}
</Tag>
) : (
'-'
),
},
{ title: '开始日期', dataIndex: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{
title: '时长', render: (_: any, r: any) => {
title: '时长',
render: (_: any, r: any) => {
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
return `${d}`;
},
},
{ title: '日租金', dataIndex: 'dailyRate', render: (v: any) => v ? `¥${v}` : '-' },
{ title: '总额', dataIndex: 'totalAmount', render: (v: any) => v ? `¥${v}` : '-' },
{ title: '日租金', dataIndex: 'dailyRate', render: (v: any) => (v ? `¥${v}` : '-') },
{ title: '总额', dataIndex: 'totalAmount', render: (v: any) => (v ? `¥${v}` : '-') },
{
title: '合同', dataIndex: 'contractPath',
render: (v: string, r: any) => v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button size="small" icon={<FileTextOutlined />} onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}></Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
) : (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) { message.error(e?.message || '上传失败'); onError?.(e); }
}}
>
<Button size="small" icon={<UploadOutlined />}>PDF</Button>
</Upload>
),
title: '合同',
dataIndex: 'contractPath',
render: (v: string, r: any) =>
v ? (
<Space>
<Tooltip title={r.contractOriginalName}>
<Button
size="small"
icon={<FileTextOutlined />}
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
>
</Button>
</Tooltip>
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
) : (
<Upload
accept="application/pdf"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
if (file.size > 10 * 1024 * 1024) {
message.error('文件不能超过 10MB');
onError?.(new Error('size'));
return;
}
const formData = new FormData();
formData.append('file', file);
try {
await api.post(`/classroom-rentals/${r.id}/contract`, formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success('合同已上传');
onSuccess?.({});
fetchData();
} catch (e: any) {
message.error(e?.message || '上传失败');
onError?.(e);
}
}}
>
<Button size="small" icon={<UploadOutlined />}>
PDF
</Button>
</Upload>
),
},
{
title: '操作', width: 150,
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}></PermissionButton>
<PermissionButton permission="rental:edit" size="small" onClick={() => openEdit(record)}>
</PermissionButton>
<PermissionButton permission="rental:delete">
<Popconfirm title="确定删除该租赁订单?合同文件将一并删除。" onConfirm={() => handleDelete(record.id)}>
<Button size="small" danger></Button>
<Popconfirm
title="确定删除该租赁订单?合同文件将一并删除。"
onConfirm={() => handleDelete(record.id)}
>
<Button size="small" danger>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
@@ -203,31 +274,77 @@ const ClassroomRentalsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索教室/租赁方"
allowClear
style={{ width: 180 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<DatePicker
picker="month"
placeholder="按月筛选"
value={filterMonth}
onChange={setFilterMonth}
allowClear
format="YYYY-MM"
/>
<DatePicker picker="month" placeholder="按月筛选" value={filterMonth} onChange={setFilterMonth} allowClear format="YYYY-MM" />
</Space>
<PermissionButton permission="rental:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
<PermissionButton
permission="rental:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
</div>
<Table columns={columns} dataSource={filteredData} rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} scroll={{ x: 1200 }} />
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
scroll={{ x: 1200 }}
/>
<Modal title={editing ? '编辑租赁' : '新增租赁'} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditing(null); }} okText="保存" width={600}>
<Modal
title={editing ? '编辑租赁' : '新增租赁'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="classroomId" label="教室" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择教室"
options={classrooms.map(c => ({ value: c.id, label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}` }))}
options={classrooms.map((c) => ({
value: c.id,
label: `${c.building ? c.building + ' · ' : ''}${c.name}${c.roomType}`,
}))}
/>
</Form.Item>
<Form.Item name="tenantId" label="租赁方" rules={[{ required: true }]}>
@@ -235,11 +352,15 @@ const ClassroomRentalsPage: React.FC = () => {
showSearch
optionFilterProp="label"
placeholder="选择租赁方"
options={tenants.map(t => ({ value: t.id, label: t.name }))}
options={tenants.map((t) => ({ value: t.id, label: t.name }))}
/>
</Form.Item>
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>
<DatePicker.RangePicker style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" />
<DatePicker.RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
@@ -247,7 +368,9 @@ const ClassroomRentalsPage: React.FC = () => {
<Form.Item name="totalAmount" label="合同总额(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>

View File

@@ -1,5 +1,18 @@
import React, { useEffect, useState, useMemo } from 'react';
import { DatePicker, Card, Row, Col, Statistic, Tag, Space, Button, Modal, Spin, Empty, Tooltip } from 'antd';
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';
@@ -11,7 +24,10 @@ interface ScheduleData {
classrooms: any[];
tenants: any[];
matrix: Record<number, Record<number, any>>;
summary: Record<number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }>;
summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
>;
}
const ClassroomSchedulePage: React.FC = () => {
@@ -27,11 +43,15 @@ const ClassroomSchedulePage: React.FC = () => {
params: { year: month.year(), month: month.month() + 1 },
});
setData(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [month]);
useEffect(() => {
fetchData();
}, [month]);
// 按楼栋+楼层分组教室
const groups = useMemo(() => {
@@ -64,15 +84,21 @@ const ClassroomSchedulePage: React.FC = () => {
try {
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
setDetailModal(res);
} catch (e) { console.error(e); }
} 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 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 => {
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;
@@ -84,33 +110,80 @@ const ClassroomSchedulePage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 8 }}>
<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月" />
<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>
<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>
<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>
{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>
@@ -121,7 +194,7 @@ const ClassroomSchedulePage: React.FC = () => {
<Empty description="暂无教室数据" />
) : (
<div style={{ overflowX: 'auto' }}>
{groups.map(group => (
{groups.map((group) => (
<Card
key={group.name}
size="small"
@@ -132,25 +205,88 @@ const ClassroomSchedulePage: React.FC = () => {
<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>
<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 };
{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' }}>
<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 => {
{Array.from({ length: data.days }, (_, i) => i + 1).map((d) => {
const cell = data.matrix[c.id]?.[d];
return (
<td
@@ -166,7 +302,9 @@ const ClassroomSchedulePage: React.FC = () => {
}}
>
{cell && (
<Tooltip title={`${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`}>
<Tooltip
title={`${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`}
>
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
{cell.hasContract ? '📄' : ''}
</span>
@@ -195,28 +333,64 @@ const ClassroomSchedulePage: React.FC = () => {
>
{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 }}>
<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>
<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)}
onClick={() =>
handleDownloadContract(detailModal.id, detailModal.contractOriginalName)
}
>
{detailModal.contractOriginalName || '下载'}
</Button>
) : '未上传'}
) : (
'未上传'
)}
</div>
</div>
)}

View File

@@ -1,6 +1,25 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Input, InputNumber, Select, Space, message, Tag, Popconfirm, Upload } from 'antd';
import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined } from '@ant-design/icons';
import {
Table,
Button,
Modal,
Form,
Input,
InputNumber,
Select,
Space,
message,
Tag,
Popconfirm,
Upload,
} from 'antd';
import {
PlusOutlined,
UploadOutlined,
DownloadOutlined,
UndoOutlined,
InboxOutlined,
} from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -27,7 +46,9 @@ const ClassroomsPage: React.FC = () => {
const filteredData = useMemo(() => {
if (!searchText) return data;
const s = searchText.toLowerCase();
return data.filter((d: any) => d.name?.toLowerCase().includes(s) || d.building?.toLowerCase().includes(s));
return data.filter(
(d: any) => d.name?.toLowerCase().includes(s) || d.building?.toLowerCase().includes(s),
);
}, [data, searchText]);
const fetchData = async () => {
@@ -35,11 +56,15 @@ const ClassroomsPage: React.FC = () => {
try {
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
setData(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [showArchived]);
useEffect(() => {
fetchData();
}, [showArchived]);
const handleSave = async () => {
const values = await form.validateFields();
@@ -55,7 +80,9 @@ const ClassroomsPage: React.FC = () => {
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleArchive = async (id: number) => {
@@ -63,7 +90,9 @@ const ClassroomsPage: React.FC = () => {
await api.delete(`/classrooms/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleRestore = async (id: number) => {
@@ -71,15 +100,19 @@ const ClassroomsPage: React.FC = () => {
await api.put(`/classrooms/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
} 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 baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -91,33 +124,62 @@ const ClassroomsPage: React.FC = () => {
};
const columns = [
{ title: '教室名', dataIndex: 'name', sorter: (a: any, b: any) => a.name.localeCompare(b.name) },
{
title: '教室名',
dataIndex: 'name',
sorter: (a: any, b: any) => a.name.localeCompare(b.name),
},
{ title: '楼栋', dataIndex: 'building' },
{ title: '楼层', dataIndex: 'floor' },
{ title: '类型', dataIndex: 'roomType', render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag> },
{
title: '类型',
dataIndex: 'roomType',
render: (v: string) => <Tag color={typeColor[v] || 'default'}>{v || '-'}</Tag>,
},
{ title: '容量', dataIndex: 'capacity' },
{ title: '课程类型', dataIndex: 'courseType', render: (v: string) => v || '-' },
{ title: '负责人', dataIndex: 'supervisor', render: (v: string) => v || '-' },
{
title: '状态', dataIndex: 'status',
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作', width: 180,
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<PermissionButton permission="classroom:edit">
<Popconfirm title="确定恢复此教室?" onConfirm={() => handleRestore(record.id)}>
<Button size="small" icon={<UndoOutlined />} type="link"></Button>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
</PermissionButton>
) : (
<>
<PermissionButton permission="classroom:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}></PermissionButton>
<PermissionButton
permission="classroom:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<PermissionButton permission="classroom:delete">
<Popconfirm title="归档后数据保留,可随时恢复。存在进行中的租赁将无法归档。" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<Button size="small" icon={<InboxOutlined />}></Button>
<Popconfirm
title="归档后数据保留,可随时恢复。存在进行中的租赁将无法归档。"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</>
@@ -129,21 +191,43 @@ const ClassroomsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索教室名/楼栋"
allowClear
style={{ width: 180 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(!showArchived)}>
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? '隐藏已归档' : '显示已归档'}
</Button>
</Space>
<Space wrap>
<PermissionButton permission="classroom:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
<PermissionButton
permission="classroom:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
<PermissionButton permission="classroom:create">
@@ -154,37 +238,80 @@ const ClassroomsPage: React.FC = () => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/classrooms/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
const res: any = await api.post('/classrooms/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
}
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</PermissionButton>
<PermissionButton permission="classroom:view" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}></PermissionButton>
<PermissionButton
permission="classroom:view"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
</PermissionButton>
</Space>
</div>
<Table columns={columns} dataSource={filteredData} rowKey="id" loading={loading} pagination={{ pageSize: 20, showTotal: (total) => `${total}` }} />
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/>
<Modal title={editing ? '编辑教室' : '添加教室'} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditing(null); }} okText="保存">
<Modal
title={editing ? '编辑教室' : '添加教室'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="教室名" rules={[{ required: true }]}><Input placeholder="如A201 / B301" /></Form.Item>
<Form.Item name="building" label="楼栋"><Input placeholder="如A / B" /></Form.Item>
<Form.Item name="floor" label="楼层"><InputNumber min={1} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="roomType" label="类型" tooltip="大/次大/小 对应可容纳规模">
<Select options={[
{ value: '大', label: '大' },
{ value: '次大', label: '次大' },
{ value: '小', label: '' },
]} placeholder="选择类型" />
<Form.Item name="name" label="教室名" rules={[{ required: true }]}>
<Input placeholder="如A201 / B301" />
</Form.Item>
<Form.Item name="building" label="楼栋">
<Input placeholder="如A座 / B座" />
</Form.Item>
<Form.Item name="floor" label="楼层">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="roomType" label="类型" tooltip="大/次大/小 对应可容纳规模">
<Select
options={[
{ value: '大', label: '大' },
{ value: '次大', label: '次大' },
{ value: '小', label: '小' },
]}
placeholder="选择类型"
/>
</Form.Item>
<Form.Item name="capacity" label="容量">
<InputNumber min={1} max={500} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="courseType" label="课程类型" tooltip="如:尊享培优班 / 专业课集训班">
<Input />
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">
<Input />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="capacity" label="容量"><InputNumber min={1} max={500} style={{ width: '100%' }} /></Form.Item>
<Form.Item name="courseType" label="课程类型" tooltip="如:尊享培优班 / 专业课集训班"><Input /></Form.Item>
<Form.Item name="supervisor" label="负责人/班主任"><Input /></Form.Item>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
</div>

View File

@@ -7,7 +7,16 @@ import api from '../../api';
const { RangePicker } = DatePicker;
const COLORS = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#5AC8FA', '#AF52DE', '#FF2D55', '#FFCC00'];
const COLORS = [
'#007AFF',
'#34C759',
'#FF9500',
'#FF3B30',
'#5AC8FA',
'#AF52DE',
'#FF2D55',
'#FFCC00',
];
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<any>(null);
@@ -26,21 +35,37 @@ const DashboardPage: React.FC = () => {
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] } }),
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); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [period]);
useEffect(() => {
fetchData();
}, [period]);
const expenseTypeMap: Record<string, string> = {
water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他',
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
// 甘特图配置
@@ -73,22 +98,24 @@ const DashboardPage: React.FC = () => {
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 },
};
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,
},
encode: { x: [1, 2], y: 0 },
data,
}],
],
};
};
@@ -96,14 +123,16 @@ const DashboardPage: React.FC = () => {
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),
})),
}],
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: expenseStats.map((e) => ({
name: expenseTypeMap[e.type] || e.type,
value: Number(e.total),
})),
},
],
};
// 宿舍费用排行
@@ -111,15 +140,33 @@ const DashboardPage: React.FC = () => {
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] } }],
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' }} />;
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' }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}></h2>
<RangePicker
value={[dayjs(period[0]), dayjs(period[1])]}
@@ -131,22 +178,47 @@ const DashboardPage: React.FC = () => {
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card><Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} /></Card>
<Card>
<Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} />
</Card>
</Col>
<Col span={6}>
<Card><Statistic title="在读学生" value={stats?.totalStudents || 0} prefix={<TeamOutlined />} /></Card>
<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>
<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>
<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) }} />
<ReactECharts
option={ganttOption()}
style={{ height: Math.max(300, ganttData.length * 40) }}
/>
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}

View File

@@ -1,5 +1,18 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, message, Tag, Popconfirm } from 'antd';
import {
Table,
Button,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
message,
Tag,
Popconfirm,
} from 'antd';
import { PlusOutlined, DeleteOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
@@ -26,17 +39,18 @@ const DepositsPage: React.FC = () => {
const fetchData = async () => {
setLoading(true);
try {
const [d, s]: any[] = await Promise.all([
api.get('/deposits'),
api.get('/students'),
]);
const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]);
setData(d);
setStudents(s);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const filteredData = useMemo(() => {
return data.filter((d: any) => {
@@ -62,7 +76,9 @@ const DepositsPage: React.FC = () => {
setCreateModal(false);
createForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleRefund = async () => {
@@ -78,7 +94,9 @@ const DepositsPage: React.FC = () => {
setRefundModal(null);
refundForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const columns = [
@@ -86,28 +104,54 @@ const DepositsPage: React.FC = () => {
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate' },
{
title: '状态', dataIndex: 'status',
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{ title: '退还金额', dataIndex: 'refundAmount', render: (v: any) => v != null ? `¥${Number(v).toFixed(2)}` : '-' },
{ title: '扣除金额', dataIndex: 'deductionAmount', render: (v: any) => v > 0 ? `¥${Number(v).toFixed(2)}` : '-' },
{
title: '退还金额',
dataIndex: 'refundAmount',
render: (v: any) => (v != null ? `¥${Number(v).toFixed(2)}` : '-'),
},
{
title: '扣除金额',
dataIndex: 'deductionAmount',
render: (v: any) => (v > 0 ? `¥${Number(v).toFixed(2)}` : '-'),
},
{ title: '扣除原因', dataIndex: 'deductionReason', render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', render: (v: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' },
{
title: '操作', width: 160,
title: '操作',
width: 160,
render: (_: any, record: any) => (
<Space>
{record.status === 'paid' && (
<PermissionButton permission="deposit:edit" size="small" type="primary" onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
}}>退</PermissionButton>
<PermissionButton
permission="deposit:edit"
size="small"
type="primary"
onClick={() => {
setRefundModal(record);
refundForm.setFieldsValue({ refundDate: dayjs(), deductionAmount: 0 });
}}
>
退
</PermissionButton>
)}
<PermissionButton permission="deposit:delete">
<Popconfirm title="确定删除?" onConfirm={async () => {
try { await api.delete(`/deposits/${record.id}`); message.success('删除成功'); fetchData(); } catch (e: any) { message.error(e?.message || '删除失败'); }
}}>
<Popconfirm
title="确定删除?"
onConfirm={async () => {
try {
await api.delete(`/deposits/${record.id}`);
message.success('删除成功');
fetchData();
} catch (e: any) {
message.error(e?.message || '删除失败');
}
}}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
@@ -118,21 +162,31 @@ const DepositsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 180 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={v => setFilterStatus(v)}
onChange={(v) => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
@@ -141,17 +195,51 @@ const DepositsPage: React.FC = () => {
]}
/>
</Space>
<PermissionButton permission="deposit:create" type="primary" icon={<PlusOutlined />} onClick={() => { createForm.resetFields(); createForm.setFieldsValue({ amount: 500, paidDate: dayjs() }); setCreateModal(true); }}>
<PermissionButton
permission="deposit:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
createForm.resetFields();
createForm.setFieldsValue({ amount: 500, paidDate: dayjs() });
setCreateModal(true);
}}
>
</PermissionButton>
</div>
<Table columns={columns} dataSource={filteredData} rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }} />
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
/>
<Modal title="收取押金" open={createModal} onOk={handleCreate} onCancel={() => setCreateModal(false)} okText="确认">
<Modal
title="收取押金"
open={createModal}
onOk={handleCreate}
onCancel={() => setCreateModal(false)}
okText="确认"
>
<Form form={createForm} 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
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="amount" label="押金金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
@@ -165,7 +253,13 @@ const DepositsPage: React.FC = () => {
</Form>
</Modal>
<Modal title={`退还押金 - ${refundModal?.student?.name}`} open={!!refundModal} onOk={handleRefund} onCancel={() => setRefundModal(null)} okText="确认退还">
<Modal
title={`退还押金 - ${refundModal?.student?.name}`}
open={!!refundModal}
onOk={handleRefund}
onCancel={() => setRefundModal(null)}
okText="确认退还"
>
<Form form={refundForm} layout="vertical">
<div style={{ marginBottom: 16, padding: 12, background: '#f5f5f5', borderRadius: 8 }}>
: <strong>¥{Number(refundModal?.amount || 0).toFixed(2)}</strong>
@@ -174,7 +268,12 @@ const DepositsPage: React.FC = () => {
<DatePicker style={{ width: '100%' }} placeholder="选择退还日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="deductionAmount" label="扣除金额(元)" extra="如无扣除填0">
<InputNumber min={0} max={Number(refundModal?.amount || 500)} precision={2} style={{ width: '100%' }} />
<InputNumber
min={0}
max={Number(refundModal?.amount || 500)}
precision={2}
style={{ width: '100%' }}
/>
</Form.Item>
<Form.Item name="deductionReason" label="扣除原因">
<Input placeholder="如:房间损坏赔偿" />

View File

@@ -1,6 +1,28 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Select, DatePicker, InputNumber, Input, Space, message, Tag, Tabs, Popconfirm, Upload } from 'antd';
import { PlusOutlined, DeleteOutlined, EditOutlined, UploadOutlined, DownloadOutlined, ExportOutlined } from '@ant-design/icons';
import {
Table,
Button,
Modal,
Form,
Select,
DatePicker,
InputNumber,
Input,
Space,
message,
Tag,
Tabs,
Popconfirm,
Upload,
} from 'antd';
import {
PlusOutlined,
DeleteOutlined,
EditOutlined,
UploadOutlined,
DownloadOutlined,
ExportOutlined,
} from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -25,7 +47,17 @@ const personalExpenseTypeOptions = [
{ value: 'other', label: '其他' },
];
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他' };
const typeMap: Record<string, string> = {
water: '水费',
electricity: '电费',
cleaning: '保洁费',
damage: '损坏赔偿',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
const ExpensesPage: React.FC = () => {
const [roomExpenses, setRoomExpenses] = useState<any[]>([]);
@@ -52,16 +84,22 @@ const ExpensesPage: React.FC = () => {
message.success(res?.message || `已删除 ${selectedRoomKeys.length}`);
setSelectedRoomKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量删除失败'); }
} catch (e: any) {
message.error(e?.message || '批量删除失败');
}
};
const handleBatchDeletePersonal = async () => {
try {
const res: any = await api.post('/expenses/personal/batch-delete', { ids: selectedPersonalKeys });
const res: any = await api.post('/expenses/personal/batch-delete', {
ids: selectedPersonalKeys,
});
message.success(res?.message || `已删除 ${selectedPersonalKeys.length}`);
setSelectedPersonalKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量删除失败'); }
} catch (e: any) {
message.error(e?.message || '批量删除失败');
}
};
const fetchData = async () => {
@@ -77,11 +115,15 @@ const ExpensesPage: React.FC = () => {
setPersonalExpenses(pe);
setRooms(rm);
setStudents(st);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const filteredRoomExpenses = useMemo(() => {
return roomExpenses.filter((r: any) => {
@@ -127,7 +169,9 @@ const ExpensesPage: React.FC = () => {
setEditingRoom(null);
roomForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handlePersonalExpense = async () => {
@@ -152,33 +196,58 @@ const ExpensesPage: React.FC = () => {
setEditingPersonal(null);
personalForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const roomColumns = [
{ title: '宿舍', render: (_: any, r: any) => r.room?.roomNumber || '-' },
{ title: '费用类型', dataIndex: 'expenseType', render: (v: string) => <Tag>{typeMap[v] || v}</Tag> },
{
title: '费用类型',
dataIndex: 'expenseType',
render: (v: string) => <Tag>{typeMap[v] || v}</Tag>,
},
{ title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '账单周期', render: (_: any, r: any) => `${r.periodStart} ~ ${r.periodEnd}` },
{ title: '说明', dataIndex: 'description' },
{ title: '录入时间', dataIndex: 'createdAt', render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm') },
{
title: '操作', width: 120,
title: '录入时间',
dataIndex: 'createdAt',
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm'),
},
{
title: '操作',
width: 120,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="expense:edit" size="small" icon={<EditOutlined />} onClick={() => {
setEditingRoom(record);
roomForm.setFieldsValue({
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
description: record.description,
});
setRoomModal(true);
}}>{''}</PermissionButton>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingRoom(record);
roomForm.setFieldsValue({
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
period: [dayjs(record.periodStart), dayjs(record.periodEnd)],
description: record.description,
});
setRoomModal(true);
}}
>
{''}
</PermissionButton>
<PermissionButton permission="expense:delete">
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/room/${record.id}`); message.success('删除成功'); fetchData(); }}>
<Popconfirm
title="确定删除?"
onConfirm={async () => {
await api.delete(`/expenses/room/${record.id}`);
message.success('删除成功');
fetchData();
}}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
@@ -189,28 +258,47 @@ const ExpensesPage: React.FC = () => {
const personalColumns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '费用类型', dataIndex: 'expenseType', render: (v: string) => <Tag color="orange">{typeMap[v] || v}</Tag> },
{
title: '费用类型',
dataIndex: 'expenseType',
render: (v: string) => <Tag color="orange">{typeMap[v] || v}</Tag>,
},
{ title: '金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '日期', dataIndex: 'expenseDate' },
{ title: '说明', dataIndex: 'description' },
{
title: '操作', width: 120,
title: '操作',
width: 120,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="expense:edit" size="small" icon={<EditOutlined />} onClick={() => {
setEditingPersonal(record);
personalForm.setFieldsValue({
studentId: record.studentId,
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
expenseDate: dayjs(record.expenseDate),
description: record.description,
});
setPersonalModal(true);
}}>{''}</PermissionButton>
<PermissionButton
permission="expense:edit"
size="small"
icon={<EditOutlined />}
onClick={() => {
setEditingPersonal(record);
personalForm.setFieldsValue({
studentId: record.studentId,
roomId: record.roomId,
expenseType: record.expenseType,
amount: Number(record.amount),
expenseDate: dayjs(record.expenseDate),
description: record.description,
});
setPersonalModal(true);
}}
>
{''}
</PermissionButton>
<PermissionButton permission="expense:delete">
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/personal/${record.id}`); message.success('删除成功'); fetchData(); }}>
<Popconfirm
title="确定删除?"
onConfirm={async () => {
await api.delete(`/expenses/personal/${record.id}`);
message.success('删除成功');
fetchData();
}}
>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
@@ -221,175 +309,318 @@ const ExpensesPage: React.FC = () => {
return (
<div>
<Tabs items={[
{
key: 'room',
label: '宿舍费用',
children: (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input.Search
placeholder="搜索宿舍号"
allowClear
style={{ width: 160 }}
onSearch={v => setRoomSearch(v)}
onChange={e => { if (!e.target.value) setRoomSearch(''); }}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={roomTypeFilter}
onChange={v => setRoomTypeFilter(v)}
options={expenseTypeOptions}
/>
<PermissionButton permission="expense: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('/expenses/utility/import', 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); }
<Tabs
items={[
{
key: 'room',
label: '宿舍费用',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索宿舍号"
allowClear
style={{ width: 160 }}
onSearch={(v) => setRoomSearch(v)}
onChange={(e) => {
if (!e.target.value) setRoomSearch('');
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</PermissionButton>
<PermissionButton permission="expense: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}/expenses/utility/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>
</Space>
<Space>
<PermissionButton permission="expense:delete">
<Popconfirm title={`确定删除选中的 ${selectedRoomKeys.length} 条费用?`} onConfirm={handleBatchDeleteRoom} okText="删除" cancelText="取消" disabled={selectedRoomKeys.length === 0}>
<Button danger icon={<DeleteOutlined />} disabled={selectedRoomKeys.length === 0}></Button>
</Popconfirm>
</PermissionButton>
<PermissionButton permission="expense:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingRoom(null); roomForm.resetFields(); setRoomModal(true); }}>宿</PermissionButton>
</Space>
</div>
<Table columns={roomColumns} dataSource={filteredRoomExpenses} rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{ selectedRowKeys: selectedRoomKeys, onChange: (keys) => setSelectedRoomKeys(keys as number[]) }}
/>
</>
),
},
{
key: 'personal',
label: '个人附加费',
children: (
<>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 160 }}
onSearch={v => setPersonalSearch(v)}
onChange={e => { if (!e.target.value) setPersonalSearch(''); }}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={personalTypeFilter}
onChange={v => setPersonalTypeFilter(v)}
options={personalExpenseTypeOptions}
/>
<PermissionButton permission="expense:create">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
try {
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={roomTypeFilter}
onChange={(v) => setRoomTypeFilter(v)}
options={expenseTypeOptions}
/>
<PermissionButton permission="expense:create">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
const formData = new FormData();
formData.append('file', file);
const res: any = await api.post('/expenses/personal/import', formData);
message.success(res.message || '导入完成');
if (res.errors?.length) res.errors.forEach((e: string) => message.warning(e));
fetchData();
onSuccess?.(res);
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
try {
const res: any = await api.post('/expenses/utility/import', 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);
}
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</PermissionButton>
<PermissionButton
permission="expense: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}/expenses/utility/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('下载失败'));
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</PermissionButton>
<PermissionButton permission="expense: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}/expenses/personal/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="expense: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');
fetch(`${baseURL}/expenses/personal/export`, { 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>
</Space>
<Space>
<PermissionButton permission="expense:delete">
<Popconfirm title={`确定删除选中的 ${selectedPersonalKeys.length} 条个人费用?`} onConfirm={handleBatchDeletePersonal} okText="删除" cancelText="取消" disabled={selectedPersonalKeys.length === 0}>
<Button danger icon={<DeleteOutlined />} disabled={selectedPersonalKeys.length === 0}></Button>
</Popconfirm>
</PermissionButton>
<PermissionButton permission="expense:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingPersonal(null); personalForm.resetFields(); setPersonalModal(true); }}></PermissionButton>
</Space>
</div>
<Table columns={personalColumns} dataSource={filteredPersonalExpenses} rowKey="id" loading={loading} pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{ selectedRowKeys: selectedPersonalKeys, onChange: (keys) => setSelectedPersonalKeys(keys as number[]) }}
/>
</>
),
},
]} />
</PermissionButton>
</Space>
<Space>
<PermissionButton permission="expense:delete">
<Popconfirm
title={`确定删除选中的 ${selectedRoomKeys.length} 条费用?`}
onConfirm={handleBatchDeleteRoom}
okText="删除"
cancelText="取消"
disabled={selectedRoomKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedRoomKeys.length === 0}
>
</Button>
</Popconfirm>
</PermissionButton>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingRoom(null);
roomForm.resetFields();
setRoomModal(true);
}}
>
宿
</PermissionButton>
</Space>
</div>
<Table
columns={roomColumns}
dataSource={filteredRoomExpenses}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{
selectedRowKeys: selectedRoomKeys,
onChange: (keys) => setSelectedRoomKeys(keys as number[]),
}}
/>
</>
),
},
{
key: 'personal',
label: '个人附加费',
children: (
<>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<Input.Search
placeholder="搜索学生姓名"
allowClear
style={{ width: 160 }}
onSearch={(v) => setPersonalSearch(v)}
onChange={(e) => {
if (!e.target.value) setPersonalSearch('');
}}
/>
<Select
placeholder="费用类型"
allowClear
style={{ width: 120 }}
value={personalTypeFilter}
onChange={(v) => setPersonalTypeFilter(v)}
options={personalExpenseTypeOptions}
/>
<PermissionButton permission="expense:create">
<Upload
accept=".xlsx,.xls"
showUploadList={false}
customRequest={async ({ file, onSuccess, onError }: any) => {
try {
const formData = new FormData();
formData.append('file', file);
const res: any = await api.post('/expenses/personal/import', formData);
message.success(res.message || '导入完成');
if (res.errors?.length)
res.errors.forEach((e: string) => message.warning(e));
fetchData();
onSuccess?.(res);
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
}
}}
>
<Button icon={<UploadOutlined />}></Button>
</Upload>
</PermissionButton>
<PermissionButton
permission="expense: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}/expenses/personal/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="expense: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');
fetch(`${baseURL}/expenses/personal/export`, {
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>
</Space>
<Space>
<PermissionButton permission="expense:delete">
<Popconfirm
title={`确定删除选中的 ${selectedPersonalKeys.length} 条个人费用?`}
onConfirm={handleBatchDeletePersonal}
okText="删除"
cancelText="取消"
disabled={selectedPersonalKeys.length === 0}
>
<Button
danger
icon={<DeleteOutlined />}
disabled={selectedPersonalKeys.length === 0}
>
</Button>
</Popconfirm>
</PermissionButton>
<PermissionButton
permission="expense:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditingPersonal(null);
personalForm.resetFields();
setPersonalModal(true);
}}
>
</PermissionButton>
</Space>
</div>
<Table
columns={personalColumns}
dataSource={filteredPersonalExpenses}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{
selectedRowKeys: selectedPersonalKeys,
onChange: (keys) => setSelectedPersonalKeys(keys as number[]),
}}
/>
</>
),
},
]}
/>
<Modal title={editingRoom ? '编辑宿舍费用' : '录入宿舍费用'} open={roomModal} onOk={handleRoomExpense} onCancel={() => { setRoomModal(false); setEditingRoom(null); }} okText={editingRoom ? '保存' : '确认录入'}>
<Modal
title={editingRoom ? '编辑宿舍费用' : '录入宿舍费用'}
open={roomModal}
onOk={handleRoomExpense}
onCancel={() => {
setRoomModal(false);
setEditingRoom(null);
}}
okText={editingRoom ? '保存' : '确认录入'}
>
<Form form={roomForm} layout="vertical">
<Form.Item name="roomId" label="宿舍" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={rooms.map((r: any) => ({ value: r.id, label: `${r.roomNumber} (${r.building || ''})` }))} />
<Select
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({
value: r.id,
label: `${r.roomNumber} (${r.building || ''})`,
}))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={expenseTypeOptions} />
@@ -398,7 +629,11 @@ const ExpensesPage: React.FC = () => {
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="period" label="账单周期" rules={[{ required: true }]}>
<RangePicker style={{ width: '100%' }} placeholder={['开始日期', '结束日期']} format="YYYY-MM-DD" />
<RangePicker
style={{ width: '100%' }}
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
@@ -406,13 +641,31 @@ const ExpensesPage: React.FC = () => {
</Form>
</Modal>
<Modal title={editingPersonal ? '编辑个人费用' : '录入个人附加费'} open={personalModal} onOk={handlePersonalExpense} onCancel={() => { setPersonalModal(false); setEditingPersonal(null); }} okText={editingPersonal ? '保存' : '确认录入'}>
<Modal
title={editingPersonal ? '编辑个人费用' : '录入个人附加费'}
open={personalModal}
onOk={handlePersonalExpense}
onCancel={() => {
setPersonalModal(false);
setEditingPersonal(null);
}}
okText={editingPersonal ? '保存' : '确认录入'}
>
<Form form={personalForm} layout="vertical">
<Form.Item name="studentId" label="学生" rules={[{ required: true }]}>
<Select showSearch optionFilterProp="label" options={students.map((s: any) => ({ value: s.id, label: s.name }))} />
<Select
showSearch
optionFilterProp="label"
options={students.map((s: any) => ({ value: s.id, label: s.name }))}
/>
</Form.Item>
<Form.Item name="roomId" label="关联宿舍">
<Select allowClear showSearch optionFilterProp="label" options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))} />
<Select
allowClear
showSearch
optionFilterProp="label"
options={rooms.map((r: any) => ({ value: r.id, label: r.roomNumber }))}
/>
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={personalExpenseTypeOptions} />

View File

@@ -27,10 +27,27 @@ const LoginPage: React.FC = () => {
};
return (
<div style={{ minHeight: '100vh', display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f5f5f7' }}>
<Card style={{ width: 400, borderRadius: 16, boxShadow: '0 4px 24px rgba(0,0,0,0.08)', border: 'none' }}>
<div
style={{
minHeight: '100vh',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: '#f5f5f7',
}}
>
<Card
style={{
width: 400,
borderRadius: 16,
boxShadow: '0 4px 24px rgba(0,0,0,0.08)',
border: 'none',
}}
>
<div style={{ textAlign: 'center', marginBottom: 32 }}>
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}></Title>
<Title level={3} style={{ margin: 0, fontWeight: 600, color: '#1d1d1f' }}>
</Title>
<p style={{ color: '#86868b', marginTop: 8 }}></p>
</div>
<Form name="login" onFinish={onFinish} size="large">
@@ -41,7 +58,13 @@ const LoginPage: React.FC = () => {
<Input.Password prefix={<LockOutlined />} placeholder="密码" />
</Form.Item>
<Form.Item>
<Button type="primary" htmlType="submit" loading={loading} block style={{ height: 44, borderRadius: 10, fontWeight: 500 }}>
<Button
type="primary"
htmlType="submit"
loading={loading}
block
style={{ height: 44, borderRadius: 10, fontWeight: 500 }}
>
</Button>
</Form.Item>

View File

@@ -1,6 +1,31 @@
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 {
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';
@@ -35,18 +60,24 @@ const OccupanciesPage: React.FC = () => {
setData(occ);
setStudents(stu);
setRooms(rm);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); setSelectedRowKeys([]); }, [showActive]);
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)
return data.filter(
(r: any) =>
r.student?.name?.toLowerCase().includes(keyword) ||
r.room?.roomNumber?.toLowerCase().includes(keyword),
);
}, [data, searchText]);
@@ -64,7 +95,9 @@ const OccupanciesPage: React.FC = () => {
setCheckInModal(false);
checkInForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleCheckOut = async () => {
@@ -79,7 +112,9 @@ const OccupanciesPage: React.FC = () => {
setCheckOutModal(null);
checkOutForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleTransfer = async () => {
@@ -96,7 +131,9 @@ const OccupanciesPage: React.FC = () => {
setTransferModal(null);
transferForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleBatchCheckOut = async () => {
@@ -113,7 +150,9 @@ const OccupanciesPage: React.FC = () => {
batchCheckOutForm.resetFields();
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量退宿失败'); }
} catch (e: any) {
message.error(e?.message || '批量退宿失败');
}
};
const handleBatchDelete = async () => {
@@ -122,7 +161,9 @@ const OccupanciesPage: React.FC = () => {
message.success(res?.message || `已删除 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量删除失败'); }
} catch (e: any) {
message.error(e?.message || '批量删除失败');
}
};
const columns = [
@@ -130,26 +171,63 @@ const OccupanciesPage: React.FC = () => {
{ 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: '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>
),
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>
),
},
];
@@ -157,7 +235,7 @@ const OccupanciesPage: React.FC = () => {
selectedRowKeys,
onChange: (keys: any[]) => setSelectedRowKeys(keys),
// 「在住记录」Tab禁用已退宿防止误选用于批量退宿「全部记录」Tab均可选用于批量删除
getCheckboxProps: (record: any) => showActive ? { disabled: !!record.checkOutDate } : {},
getCheckboxProps: (record: any) => (showActive ? { disabled: !!record.checkOutDate } : {}),
};
return (
@@ -170,14 +248,40 @@ const OccupanciesPage: React.FC = () => {
closable
style={{ marginBottom: 16 }}
/>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<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 }} />
<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
permission="occupancy:checkin"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
checkInForm.resetFields();
checkInForm.setFieldsValue({ checkInDate: dayjs() });
setCheckInModal(true);
}}
>
</PermissionButton>
<PermissionButton permission="occupancy:checkin">
@@ -193,57 +297,99 @@ const OccupanciesPage: React.FC = () => {
params.set('depositAmount', String(depositAmount));
}
try {
const res: any = await api.post(`/occupancies/import?${params.toString()}`, formData, { headers: { 'Content-Type': 'multipart/form-data' } });
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 });
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); }
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
}
}}
>
<Tooltip title="导入时自动创建学生、宿舍和入住记录">
<Button type="primary" ghost icon={<UploadOutlined />}></Button>
<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);
<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}` },
})
.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);
.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}` },
})
.catch(() => message.error('导出失败'));
}}></PermissionButton>
.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="元" />}
{autoDeposit && (
<InputNumber
size="small"
min={0}
value={depositAmount}
onChange={(v) => setDepositAmount(v || 500)}
style={{ width: 80 }}
addonAfter="元"
/>
)}
</span>
</Space>
</div>
@@ -253,15 +399,42 @@ const OccupanciesPage: React.FC = () => {
<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: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
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>
<Button size="small" onClick={() => setSelectedRowKeys([])} style={{ marginLeft: 8 }}>
</Button>
</span>
}
type="info"
@@ -278,21 +451,61 @@ const OccupanciesPage: React.FC = () => {
/>
{/* 入住登记弹窗 */}
<Modal title="入住登记" open={checkInModal} onOk={handleCheckIn} onCancel={() => setCheckInModal(false)} okText="确认入住" width={500}>
<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
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
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
name="billingStartDate"
label="计费起始日"
extra="默认与入住日期相同,可调整(如学生要求从次日开始计费)"
>
<DatePicker
style={{ width: '100%' }}
placeholder="选择计费起始日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
@@ -301,67 +514,132 @@ const OccupanciesPage: React.FC = () => {
</Modal>
{/* 退宿弹窗 */}
<Modal title={`退宿 - ${checkOutModal?.student?.name}`} open={!!checkOutModal} onOk={handleCheckOut} onCancel={() => setCheckOutModal(null)} okText="确认退宿">
<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" />
<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: '其他' },
]} />
<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}>
<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" />
<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: '其他' },
]} />
<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={{
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>
))}
{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}>
<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 }))} />
<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" />
<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" />
<DatePicker
style={{ width: '100%' }}
placeholder="选择新房计费起始日"
format="YYYY-MM-DD"
/>
</Form.Item>
<Form.Item name="reason" label="换房原因">
<Input />

View File

@@ -6,7 +6,13 @@ import api from '../../api';
const { RangePicker } = DatePicker;
const moduleColorMap: Record<string, string> = {
'学生': 'blue', '宿舍': 'green', '入住': 'cyan', '费用': 'orange', '账单': 'red', '账号': 'purple', '认证': 'magenta',
: 'blue',
宿: 'green',
: 'cyan',
: 'orange',
: 'red',
: 'purple',
: 'magenta',
};
const statusMap: Record<string, { text: string; color: string }> = {
@@ -27,40 +33,93 @@ const OperationLogsPage: React.FC = () => {
try {
const params: any = { page, pageSize: 20 };
if (filterModule) params.module = filterModule;
if (dateRange) { params.startDate = dateRange[0]; params.endDate = dateRange[1]; }
if (dateRange) {
params.startDate = dateRange[0];
params.endDate = dateRange[1];
}
const res: any = await api.get('/operation-logs', { params });
setData(res.data);
setTotal(res.total);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [page, filterModule, dateRange]);
useEffect(() => {
fetchData();
}, [page, filterModule, dateRange]);
const columns = [
{ title: '时间', dataIndex: 'createdAt', width: 170, render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss') },
{
title: '时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{ title: '操作人', dataIndex: 'username', width: 100 },
{ title: '模块', dataIndex: 'module', width: 80, render: (v: string) => <Tag color={moduleColorMap[v] || 'default'}>{v}</Tag> },
{
title: '模块',
dataIndex: 'module',
width: 80,
render: (v: string) => <Tag color={moduleColorMap[v] || 'default'}>{v}</Tag>,
},
{ title: '操作', dataIndex: 'action', width: 150 },
{ title: '状态', dataIndex: 'status', width: 70, render: (v: string) => {
const s = statusMap[v] || statusMap['success'];
return <Tag color={s.color}>{s.text}</Tag>;
}},
{ title: '详情', dataIndex: 'detail', ellipsis: true, render: (v: string) => v ? <Tooltip title={v}><span>{v}</span></Tooltip> : '-' },
{
title: '状态',
dataIndex: 'status',
width: 70,
render: (v: string) => {
const s = statusMap[v] || statusMap['success'];
return <Tag color={s.color}>{s.text}</Tag>;
},
},
{
title: '详情',
dataIndex: 'detail',
ellipsis: true,
render: (v: string) =>
v ? (
<Tooltip title={v}>
<span>{v}</span>
</Tooltip>
) : (
'-'
),
},
{ title: 'IP地址', dataIndex: 'ipAddress', width: 130, render: (v: string) => v || '-' },
{ title: '终端', dataIndex: 'userAgent', width: 100, ellipsis: true, render: (v: string) => {
if (!v) return '-';
if (v.includes('Mobile')) return <Tag color="blue"></Tag>;
if (v.includes('Windows')) return <Tag>Windows</Tag>;
if (v.includes('Mac')) return <Tag>Mac</Tag>;
if (v.includes('Linux')) return <Tag>Linux</Tag>;
return <Tooltip title={v}><Tag></Tag></Tooltip>;
}},
{
title: '终端',
dataIndex: 'userAgent',
width: 100,
ellipsis: true,
render: (v: string) => {
if (!v) return '-';
if (v.includes('Mobile')) return <Tag color="blue"></Tag>;
if (v.includes('Windows')) return <Tag>Windows</Tag>;
if (v.includes('Mac')) return <Tag>Mac</Tag>;
if (v.includes('Linux')) return <Tag>Linux</Tag>;
return (
<Tooltip title={v}>
<Tag></Tag>
</Tooltip>
);
},
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Select
@@ -68,14 +127,21 @@ const OperationLogsPage: React.FC = () => {
placeholder="筛选模块"
style={{ width: 140 }}
value={filterModule}
onChange={(v) => { setFilterModule(v); setPage(1); }}
options={['认证', '学生', '宿舍', '入住', '费用', '账单', '账号'].map((m) => ({ value: m, label: m }))}
onChange={(v) => {
setFilterModule(v);
setPage(1);
}}
options={['认证', '学生', '宿舍', '入住', '费用', '账单', '账号'].map((m) => ({
value: m,
label: m,
}))}
/>
<RangePicker
placeholder={['开始日期', '结束日期']}
format="YYYY-MM-DD"
onChange={(dates) => {
if (dates) setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
if (dates)
setDateRange([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
else setDateRange(null);
setPage(1);
}}
@@ -88,7 +154,13 @@ const OperationLogsPage: React.FC = () => {
rowKey="id"
loading={loading}
scroll={{ x: 1000 }}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showTotal: (t) => `${t}` }}
pagination={{
current: page,
total,
pageSize: 20,
onChange: setPage,
showTotal: (t) => `${t}`,
}}
/>
</div>
);

View File

@@ -16,54 +16,80 @@ const PermissionsPage: React.FC = () => {
const [search, setSearch] = useState('');
const groupNames: Record<string, string> = {
dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理',
expense: '费用管理', bill: '账单管理', deposit: '押金管理',
classroom: '教室管理', tenant: '租赁方', rental: '租赁订单',
log: '操作日志', user: '用户管理', role: '角色管理',
dashboard: '数据面板',
student: '学生管理',
room: '宿舍管理',
occupancy: '入住管理',
expense: '费用管理',
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',
role: '角色管理',
};
useEffect(() => {
setLoading(true);
api.get('/rbac/permissions/tree')
api
.get('/rbac/permissions/tree')
.then((res: any) => setPermTree(res))
.catch(console.error)
.finally(() => setLoading(false));
}, []);
const filteredTree = search
? permTree.map(g => ({
...g,
permissions: g.permissions.filter(p =>
p.name.includes(search) || p.code.includes(search)
),
})).filter(g => g.permissions.length > 0)
? permTree
.map((g) => ({
...g,
permissions: g.permissions.filter(
(p) => p.name.includes(search) || p.code.includes(search),
),
}))
.filter((g) => g.permissions.length > 0)
: permTree;
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}></h2>
<Input.Search
placeholder="搜索权限名称或编码"
allowClear
style={{ width: 280 }}
onSearch={setSearch}
onChange={e => !e.target.value && setSearch('')}
onChange={(e) => !e.target.value && setSearch('')}
/>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
{filteredTree.map(group => (
{filteredTree.map((group) => (
<Card
key={group.group}
title={<span style={{ fontWeight: 600 }}>{groupNames[group.group] || group.group} ({group.permissions.length})</span>}
title={
<span style={{ fontWeight: 600 }}>
{groupNames[group.group] || group.group} ({group.permissions.length})
</span>
}
size="small"
>
<Space wrap>
{group.permissions.map(p => (
{group.permissions.map((p) => (
<Tag key={p.id} color="blue" style={{ marginBottom: 8 }}>
{p.name} <Tag color="geekblue" style={{ marginLeft: 4 }}>{p.code}</Tag>
{p.name}{' '}
<Tag color="geekblue" style={{ marginLeft: 4 }}>
{p.code}
</Tag>
</Tag>
))}
</Space>

View File

@@ -1,5 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Space, Tag, Popconfirm, message, Card, Checkbox } from 'antd';
import {
Table,
Button,
Modal,
Form,
Input,
Space,
Tag,
Popconfirm,
message,
Card,
Checkbox,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -34,15 +46,21 @@ const RolesPage: React.FC = () => {
try {
const [roles, permTree] = await Promise.all([
api.get('/rbac/roles') as Promise<RoleItem[]>,
api.get('/rbac/permissions/tree') as Promise<{ group: string; permissions: PermissionItem[] }[]>,
api.get('/rbac/permissions/tree') as Promise<
{ group: string; permissions: PermissionItem[] }[]
>,
]);
setData(roles);
setAllPerms(permTree);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const handleAdd = () => {
setEditing(null);
@@ -54,7 +72,7 @@ const RolesPage: React.FC = () => {
const handleEdit = (record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map(p => p.id));
setSelectedPermIds(record.permissions.map((p) => p.id));
setModalOpen(true);
};
@@ -62,15 +80,25 @@ const RolesPage: React.FC = () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rbac/roles/${editing.id}`, { name: values.name, description: values.description, permissionIds: selectedPermIds });
await api.put(`/rbac/roles/${editing.id}`, {
name: values.name,
description: values.description,
permissionIds: selectedPermIds,
});
message.success('角色更新成功');
} else {
await api.post('/rbac/roles', { name: values.name, description: values.description, permissionIds: selectedPermIds });
await api.post('/rbac/roles', {
name: values.name,
description: values.description,
permissionIds: selectedPermIds,
});
message.success('角色创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) { message.error(e.message || '操作失败'); }
} catch (e: any) {
message.error(e.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
@@ -78,14 +106,25 @@ const RolesPage: React.FC = () => {
await api.delete(`/rbac/roles/${id}`);
message.success('角色已删除');
fetchData();
} catch (e: any) { message.error(e.message || '删除失败'); }
} catch (e: any) {
message.error(e.message || '删除失败');
}
};
const groupNames: Record<string, string> = {
dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理',
expense: '费用管理', bill: '账单管理', deposit: '押金管理',
classroom: '教室管理', tenant: '租赁方', rental: '租赁订单',
log: '操作日志', user: '用户管理', role: '角色管理',
dashboard: '数据面板',
student: '学生管理',
room: '宿舍管理',
occupancy: '入住管理',
expense: '费用管理',
bill: '账单管理',
deposit: '押金管理',
classroom: '教室管理',
tenant: '租赁方',
rental: '租赁订单',
log: '操作日志',
user: '用户管理',
role: '角色管理',
};
const columns = [
@@ -93,26 +132,44 @@ const RolesPage: React.FC = () => {
{ title: '名称', dataIndex: 'name', width: 120 },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '权限标签', dataIndex: 'permissions', width: 150, ellipsis: true,
render: (perms: PermissionItem[]) => perms?.length > 0
? <Tag color="blue">{perms.length} </Tag>
: <Tag color="default"></Tag>,
title: '权限标签',
dataIndex: 'permissions',
width: 150,
ellipsis: true,
render: (perms: PermissionItem[]) =>
perms?.length > 0 ? (
<Tag color="blue">{perms.length} </Tag>
) : (
<Tag color="default"></Tag>
),
},
{
title: '系统', dataIndex: 'isSystem', width: 70,
render: (v: boolean) => v ? <Tag color="orange"></Tag> : null,
title: '系统',
dataIndex: 'isSystem',
width: 70,
render: (v: boolean) => (v ? <Tag color="orange"></Tag> : null),
},
{
title: '操作', width: 160, fixed: 'right' as const,
title: '操作',
width: 160,
fixed: 'right' as const,
render: (_: any, record: RoleItem) => (
<Space>
<PermissionButton permission="role:edit" type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}>
<PermissionButton
permission="role:edit"
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</PermissionButton>
{!record.isSystem && (
<PermissionButton permission="role:delete">
<Popconfirm title="确认删除该角色?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
)}
@@ -122,34 +179,56 @@ const RolesPage: React.FC = () => {
];
const handleGroupCheckAll = (group: string, checked: boolean) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
if (checked) {
setSelectedPermIds(prev => [...new Set([...prev, ...groupPermIds])]);
setSelectedPermIds((prev) => [...new Set([...prev, ...groupPermIds])]);
} else {
setSelectedPermIds(prev => prev.filter(id => !groupPermIds.includes(id)));
setSelectedPermIds((prev) => prev.filter((id) => !groupPermIds.includes(id)));
}
};
const isGroupAllChecked = (group: string) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
return groupPermIds.length > 0 && groupPermIds.every(id => selectedPermIds.includes(id));
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
return groupPermIds.length > 0 && groupPermIds.every((id) => selectedPermIds.includes(id));
};
const isGroupIndeterminate = (group: string) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
const checkedCount = groupPermIds.filter(id => selectedPermIds.includes(id)).length;
const groupPermIds =
allPerms.find((g) => g.group === group)?.permissions.map((p) => p.id) || [];
const checkedCount = groupPermIds.filter((id) => selectedPermIds.includes(id)).length;
return checkedCount > 0 && checkedCount < groupPermIds.length;
};
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}></h2>
<PermissionButton permission="role:create" type="primary" icon={<PlusOutlined />} onClick={handleAdd}>
<PermissionButton
permission="role:create"
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
</PermissionButton>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 800 }} pagination={false} />
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 800 }}
pagination={false}
/>
<Modal
title={editing ? '编辑角色' : '新增角色'}
@@ -160,7 +239,11 @@ const RolesPage: React.FC = () => {
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Form.Item
name="name"
label="角色名称"
rules={[{ required: true, message: '请输入角色名称' }]}
>
<Input disabled={editing?.isSystem} />
</Form.Item>
<Form.Item name="description" label="角色描述">
@@ -168,7 +251,7 @@ const RolesPage: React.FC = () => {
</Form.Item>
<Form.Item label="权限分配">
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{allPerms.map(group => (
{allPerms.map((group) => (
<Card
key={group.group}
size="small"
@@ -176,7 +259,7 @@ const RolesPage: React.FC = () => {
<Checkbox
checked={isGroupAllChecked(group.group)}
indeterminate={isGroupIndeterminate(group.group)}
onChange={e => handleGroupCheckAll(group.group, e.target.checked)}
onChange={(e) => handleGroupCheckAll(group.group, e.target.checked)}
>
{groupNames[group.group] || group.group}
</Checkbox>
@@ -185,11 +268,13 @@ const RolesPage: React.FC = () => {
>
<Checkbox.Group
value={selectedPermIds}
onChange={vals => setSelectedPermIds(vals as number[])}
onChange={(vals) => setSelectedPermIds(vals as number[])}
>
<Space wrap>
{group.permissions.map(p => (
<Checkbox key={p.id} value={p.id}>{p.name}</Checkbox>
{group.permissions.map((p) => (
<Checkbox key={p.id} value={p.id}>
{p.name}
</Checkbox>
))}
</Space>
</Checkbox.Group>

View File

@@ -14,27 +14,40 @@ const RoomVisualPage: React.FC = () => {
try {
const res: any = await api.get('/rooms/visual');
setData(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
if (loading || !data) return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
if (loading || !data)
return <Spin size="large" style={{ display: 'block', margin: '100px auto' }} />;
const rooms = selectedBuilding === 'all'
? data.rooms
: data.rooms.filter((r: any) => r.building === selectedBuilding);
const rooms =
selectedBuilding === 'all'
? data.rooms
: data.rooms.filter((r: any) => r.building === selectedBuilding);
const totalRooms = rooms.length;
const emptyRooms = rooms.filter((r: any) => r.currentCount === 0 && r.status !== 'maintenance').length;
const availableBeds = rooms.reduce((sum: number, r: any) => r.status !== 'maintenance' ? sum + (r.capacity - r.currentCount) : sum, 0);
const emptyRooms = rooms.filter(
(r: any) => r.currentCount === 0 && r.status !== 'maintenance',
).length;
const availableBeds = rooms.reduce(
(sum: number, r: any) =>
r.status !== 'maintenance' ? sum + (r.capacity - r.currentCount) : sum,
0,
);
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
const getCardStyle = (room: any): React.CSSProperties => {
if (room.status === 'maintenance') return { background: '#f5f5f5', borderColor: '#d9d9d9' };
if (room.currentCount === 0) return { background: '#f6ffed', borderColor: '#b7eb8f' };
if (room.currentCount >= room.capacity) return { background: '#fff2f0', borderColor: '#ffccc7' };
if (room.currentCount >= room.capacity)
return { background: '#fff2f0', borderColor: '#ffccc7' };
return { background: '#e6f4ff', borderColor: '#91caff' };
};
@@ -47,7 +60,16 @@ const RoomVisualPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
flexWrap: 'wrap',
gap: 12,
}}
>
<h2 style={{ margin: 0 }}>宿</h2>
<Select
value={selectedBuilding}
@@ -63,16 +85,24 @@ const RoomVisualPage: React.FC = () => {
{/* 统计栏 */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="宿舍总数" value={totalRooms} prefix={<HomeOutlined />} /></Card>
<Card size="small">
<Statistic title="宿舍总数" value={totalRooms} prefix={<HomeOutlined />} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="空闲房间" value={emptyRooms} valueStyle={{ color: '#34C759' }} /></Card>
<Card size="small">
<Statistic title="空闲房间" value={emptyRooms} valueStyle={{ color: '#34C759' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="可安排床位" value={availableBeds} valueStyle={{ color: '#007AFF' }} /></Card>
<Card size="small">
<Statistic title="可安排床位" value={availableBeds} valueStyle={{ color: '#007AFF' }} />
</Card>
</Col>
<Col xs={12} sm={6}>
<Card size="small"><Statistic title="满员房间" value={fullRooms} valueStyle={{ color: '#FF3B30' }} /></Card>
<Card size="small">
<Statistic title="满员房间" value={fullRooms} valueStyle={{ color: '#FF3B30' }} />
</Card>
</Col>
</Row>
@@ -83,11 +113,26 @@ const RoomVisualPage: React.FC = () => {
<Card
size="small"
hoverable
style={{ ...getCardStyle(room), borderRadius: 12, borderWidth: 2, cursor: 'pointer', height: '100%' }}
style={{
...getCardStyle(room),
borderRadius: 12,
borderWidth: 2,
cursor: 'pointer',
height: '100%',
}}
onClick={() => setDetailRoom(room)}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 8 }}>
<span style={{ fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>{room.roomNumber}</span>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 8,
}}
>
<span style={{ fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
{room.roomNumber}
</span>
{getStatusLabel(room)}
</div>
<div style={{ color: '#86868b', fontSize: 12, marginBottom: 6 }}>
@@ -99,14 +144,21 @@ const RoomVisualPage: React.FC = () => {
count={`${room.currentCount}/${room.capacity}`}
showZero
style={{
backgroundColor: room.currentCount >= room.capacity ? '#FF3B30' : room.currentCount > 0 ? '#007AFF' : '#34C759',
backgroundColor:
room.currentCount >= room.capacity
? '#FF3B30'
: room.currentCount > 0
? '#007AFF'
: '#34C759',
fontSize: 11,
}}
/>
</div>
{room.orgLabel && (
<div style={{ marginBottom: 6 }}>
<Tag color="purple" style={{ fontSize: 11 }} icon={<BankOutlined />}>{room.orgLabel}</Tag>
<Tag color="purple" style={{ fontSize: 11 }} icon={<BankOutlined />}>
{room.orgLabel}
</Tag>
</div>
)}
{room.occupants.length > 0 && (
@@ -137,9 +189,18 @@ const RoomVisualPage: React.FC = () => {
{detailRoom && (
<div>
<Row gutter={16} style={{ marginBottom: 16 }}>
<Col span={8}><Statistic title="额定人数" value={detailRoom.capacity} /></Col>
<Col span={8}><Statistic title="当前入住" value={detailRoom.currentCount} /></Col>
<Col span={8}><Statistic title="剩余床位" value={Math.max(0, detailRoom.capacity - detailRoom.currentCount)} /></Col>
<Col span={8}>
<Statistic title="额定人数" value={detailRoom.capacity} />
</Col>
<Col span={8}>
<Statistic title="当前入住" value={detailRoom.currentCount} />
</Col>
<Col span={8}>
<Statistic
title="剩余床位"
value={Math.max(0, detailRoom.capacity - detailRoom.currentCount)}
/>
</Col>
</Row>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
@@ -150,24 +211,38 @@ const RoomVisualPage: React.FC = () => {
<h4 style={{ marginBottom: 8 }}></h4>
{detailRoom.occupants.map((o: any) => (
<Card key={o.studentId} size="small" style={{ marginBottom: 8, borderRadius: 8 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div>
<UserOutlined style={{ marginRight: 6 }} />
<strong>{o.studentName}</strong>
{o.organization && <Tag color="purple" style={{ marginLeft: 6, fontSize: 11 }}>{o.organization}</Tag>}
{o.organization && (
<Tag color="purple" style={{ marginLeft: 6, fontSize: 11 }}>
{o.organization}
</Tag>
)}
</div>
<Tag color="blue">{o.days} </Tag>
</div>
<div style={{ color: '#86868b', fontSize: 12, marginTop: 4 }}>
<CalendarOutlined style={{ marginRight: 4 }} />
{o.checkInDate} | {o.billingStartDate}
{o.supervisor && <span style={{ marginLeft: 8 }}>{o.supervisor}</span>}
{o.supervisor && (
<span style={{ marginLeft: 8 }}>{o.supervisor}</span>
)}
</div>
</Card>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}></div>
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}>
</div>
)}
</div>
)}

View File

@@ -1,6 +1,29 @@
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 {
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';
@@ -30,7 +53,9 @@ const RoomsPage: React.FC = () => {
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量归档失败'); }
} catch (e: any) {
message.error(e?.message || '批量归档失败');
}
};
const fetchData = async () => {
@@ -42,11 +67,15 @@ const RoomsPage: React.FC = () => {
setArchivedCount(archived.length);
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
setData(filtered);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [showArchived]);
useEffect(() => {
fetchData();
}, [showArchived]);
// 获取楼栋列表用于筛选
const buildings = useMemo(() => {
@@ -81,14 +110,18 @@ const RoomsPage: React.FC = () => {
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} 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); }
} catch (e) {
console.error(e);
}
};
const handleArchive = async (id: number) => {
@@ -96,7 +129,9 @@ const RoomsPage: React.FC = () => {
await api.delete(`/rooms/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleRestore = async (id: number) => {
@@ -104,15 +139,19 @@ const RoomsPage: React.FC = () => {
await api.put(`/rooms/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
} 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 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 => {
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -124,12 +163,14 @@ const RoomsPage: React.FC = () => {
};
const handleExport = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
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 => {
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -141,37 +182,89 @@ const RoomsPage: React.FC = () => {
};
const columns = [
{ title: '房间号', dataIndex: 'roomNumber', sorter: (a: any, b: any) => a.roomNumber.localeCompare(b.roomNumber) },
{
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' }} />,
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',
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,
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
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: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
title="归档后不会删除数据,可随时恢复。有在住人员将无法归档。"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</>
@@ -183,7 +276,15 @@ const RoomsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Space wrap>
<h3 style={{ margin: 0 }}>宿</h3>
<Input.Search
@@ -204,16 +305,35 @@ const RoomsPage: React.FC = () => {
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived ? '隐藏已归档' : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
{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
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
permission="room:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
宿
</PermissionButton>
<PermissionButton permission="room:create">
@@ -224,18 +344,31 @@ const RoomsPage: React.FC = () => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/rooms/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
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); }
} 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>
<PermissionButton
permission="room:view"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
</PermissionButton>
<PermissionButton permission="room:view" icon={<ExportOutlined />} onClick={handleExport}>
</PermissionButton>
</Space>
</div>
<Table
@@ -244,7 +377,7 @@ const RoomsPage: React.FC = () => {
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
rowClassName={(record) => record.status === 'archived' ? 'archived-row' : ''}
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
@@ -253,33 +386,62 @@ const RoomsPage: React.FC = () => {
/>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
<Modal title={editing ? '编辑宿舍' : '添加宿舍'} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditing(null); }} okText="保存">
<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="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="留空自动解析" />
<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: '维修中' },
]} />
<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}>
<Modal
title={`宿舍 ${detailModal?.roomNumber} 当前住户`}
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={600}
>
{detailModal?.currentOccupants?.length > 0 ? (
<Table
dataSource={detailModal.currentOccupants}

View File

@@ -1,6 +1,26 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Select, Space, message, Tag, Popconfirm, Upload } from 'antd';
import { PlusOutlined, UploadOutlined, DownloadOutlined, UndoOutlined, InboxOutlined, ExportOutlined, DeleteOutlined } from '@ant-design/icons';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Space,
message,
Tag,
Popconfirm,
Upload,
} from 'antd';
import {
PlusOutlined,
UploadOutlined,
DownloadOutlined,
UndoOutlined,
InboxOutlined,
ExportOutlined,
DeleteOutlined,
} from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
@@ -28,7 +48,9 @@ const StudentsPage: React.FC = () => {
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量归档失败'); }
} catch (e: any) {
message.error(e?.message || '批量归档失败');
}
};
const fetchData = async () => {
@@ -40,11 +62,15 @@ const StudentsPage: React.FC = () => {
setArchivedCount(archived.length);
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
setData(filtered);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, [searchName, showArchived]);
useEffect(() => {
fetchData();
}, [searchName, showArchived]);
const handleSave = async () => {
const values = await form.validateFields();
@@ -60,7 +86,9 @@ const StudentsPage: React.FC = () => {
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleArchive = async (id: number) => {
@@ -68,7 +96,9 @@ const StudentsPage: React.FC = () => {
await api.delete(`/students/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const handleRestore = async (id: number) => {
@@ -76,15 +106,19 @@ const StudentsPage: React.FC = () => {
await api.put(`/students/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
} 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 baseURL = import.meta.env.PROD
? '/api'
: `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -96,12 +130,14 @@ const StudentsPage: React.FC = () => {
};
const handleExport = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
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}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
.then((res) => res.blob())
.then((blob) => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
@@ -121,28 +157,58 @@ const StudentsPage: React.FC = () => {
{ title: '民族', dataIndex: 'ethnicity', width: 80 },
{ title: '紧急联系人', dataIndex: 'emergencyContact' },
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone' },
{ title: '所属机构', dataIndex: 'organization', render: (v: string) => v ? <Tag color="purple">{v}</Tag> : '-' },
{
title: '所属机构',
dataIndex: 'organization',
render: (v: string) => (v ? <Tag color="purple">{v}</Tag> : '-'),
},
{ title: '负责人', dataIndex: 'supervisor' },
{
title: '状态', dataIndex: 'status',
title: '状态',
dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
title: '操作', width: 180,
title: '操作',
width: 180,
render: (_: any, record: any) => (
<Space>
{record.status === 'archived' ? (
<PermissionButton permission="student:edit">
<Popconfirm title="确定恢复此学生?恢复后将重新出现在学生列表中。" onConfirm={() => handleRestore(record.id)} okText="恢复" cancelText="取消">
<Button size="small" icon={<UndoOutlined />} type="link"></Button>
<Popconfirm
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
onConfirm={() => handleRestore(record.id)}
okText="恢复"
cancelText="取消"
>
<Button size="small" icon={<UndoOutlined />} type="link">
</Button>
</Popconfirm>
</PermissionButton>
) : (
<>
<PermissionButton permission="student:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}></PermissionButton>
<PermissionButton
permission="student:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<PermissionButton permission="student:delete">
<Popconfirm title="归档后不会删除数据,可随时恢复。确定归档?" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<Button size="small" icon={<InboxOutlined />}></Button>
<Popconfirm
title="归档后不会删除数据,可随时恢复。确定归档?"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</>
@@ -156,18 +222,45 @@ const StudentsPage: React.FC = () => {
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between' }}>
<Space>
<Input.Search placeholder="搜索学生姓名" onSearch={setSearchName} allowClear style={{ width: 250 }} />
<Button type={showArchived ? 'primary' : 'default'} onClick={() => setShowArchived(!showArchived)}>
{showArchived ? '隐藏已归档' : `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
<Input.Search
placeholder="搜索学生姓名"
onSearch={setSearchName}
allowClear
style={{ width: 250 }}
/>
<Button
type={showArchived ? 'primary' : 'default'}
onClick={() => setShowArchived(!showArchived)}
>
{showArchived
? '隐藏已归档'
: `显示已归档${archivedCount > 0 ? ` (${archivedCount})` : ''}`}
</Button>
</Space>
<Space>
<PermissionButton permission="student:delete">
<Popconfirm title={`确定批量归档选中的 ${selectedRowKeys.length} 名学生?(数据保留,可恢复)`} onConfirm={handleBatchDelete} okText="归档" cancelText="取消" disabled={selectedRowKeys.length === 0}>
<Button danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}></Button>
<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="student:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
<PermissionButton
permission="student:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
<PermissionButton permission="student:import">
@@ -178,18 +271,35 @@ const StudentsPage: React.FC = () => {
const formData = new FormData();
formData.append('file', file);
try {
const res: any = await api.post('/students/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
const res: any = await api.post('/students/import', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
});
message.success(res.message);
onSuccess?.(res);
fetchData();
} catch (e: any) { message.error(e?.message || '导入失败'); onError?.(e); }
} catch (e: any) {
message.error(e?.message || '导入失败');
onError?.(e);
}
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</PermissionButton>
<PermissionButton permission="student:view" icon={<DownloadOutlined />} onClick={handleDownloadTemplate}></PermissionButton>
<PermissionButton permission="student:export" icon={<ExportOutlined />} onClick={handleExport}></PermissionButton>
<PermissionButton
permission="student:view"
icon={<DownloadOutlined />}
onClick={handleDownloadTemplate}
>
</PermissionButton>
<PermissionButton
permission="student:export"
icon={<ExportOutlined />}
onClick={handleExport}
>
</PermissionButton>
</Space>
</div>
<Table
@@ -198,7 +308,7 @@ const StudentsPage: React.FC = () => {
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowClassName={(record: any) => record.status === 'archived' ? 'archived-row' : ''}
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
@@ -210,7 +320,10 @@ const StudentsPage: React.FC = () => {
title={editing ? '编辑学生' : '添加学生'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditing(null); }}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
>
<Form form={form} layout="vertical">
@@ -218,7 +331,13 @@ const StudentsPage: React.FC = () => {
<Input />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select allowClear options={[{ value: '男', label: '男' }, { value: '女', label: '女' }]} />
<Select
allowClear
options={[
{ value: '男', label: '男' },
{ value: '女', label: '女' },
]}
/>
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
@@ -235,7 +354,11 @@ const StudentsPage: React.FC = () => {
<Form.Item name="emergencyPhone" label="紧急联系人电话">
<Input />
</Form.Item>
<Form.Item name="organization" label="所属机构" tooltip="外部合作公司/机构名称,留空表示本机构">
<Form.Item
name="organization"
label="所属机构"
tooltip="外部合作公司/机构名称,留空表示本机构"
>
<Input placeholder="如XXX教育科技公司" />
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">
@@ -243,11 +366,13 @@ const StudentsPage: React.FC = () => {
</Form.Item>
{editing && (
<Form.Item name="status" label="状态">
<Select options={[
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
]} />
<Select
options={[
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
]}
/>
</Form.Item>
)}
</Form>

View File

@@ -5,8 +5,16 @@ import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const PRESET_COLORS = [
'#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9',
'#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c',
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
const TenantsPage: React.FC = () => {
@@ -20,7 +28,9 @@ const TenantsPage: React.FC = () => {
const filteredData = useMemo(() => {
if (!searchText) return data;
const s = searchText.toLowerCase();
return data.filter((d: any) => d.name?.toLowerCase().includes(s) || d.contact?.toLowerCase().includes(s));
return data.filter(
(d: any) => d.name?.toLowerCase().includes(s) || d.contact?.toLowerCase().includes(s),
);
}, [data, searchText]);
const fetchData = async () => {
@@ -28,11 +38,15 @@ const TenantsPage: React.FC = () => {
try {
const res: any = await api.get('/tenants');
setData(res);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const handleSave = async () => {
const values = await form.validateFields();
@@ -48,7 +62,9 @@ const TenantsPage: React.FC = () => {
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
} catch (e: any) {
message.error(e?.message || '操作失败');
}
};
const handleArchive = async (id: number) => {
@@ -56,30 +72,74 @@ const TenantsPage: React.FC = () => {
await api.delete(`/tenants/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
} catch (e: any) {
message.error(e?.message || '归档失败');
}
};
const columns = [
{
title: '租赁方名称', dataIndex: 'name',
title: '租赁方名称',
dataIndex: 'name',
render: (v: string, r: any) => (
<Space>
<Tag color={r.color || 'default'} style={{ borderColor: r.color, color: '#fff', background: r.color }}>{v}</Tag>
<Tag
color={r.color || 'default'}
style={{ borderColor: r.color, color: '#fff', background: r.color }}
>
{v}
</Tag>
</Space>
),
},
{ title: '联系人', dataIndex: 'contact', render: (v: string) => v || '-' },
{ title: '电话', dataIndex: 'phone', render: (v: string) => v || '-' },
{ title: '颜色', dataIndex: 'color', render: (v: string) => v ? <span style={{ display: 'inline-block', width: 20, height: 20, background: v, borderRadius: 4, verticalAlign: 'middle' }} /> : '-' },
{
title: '颜色',
dataIndex: 'color',
render: (v: string) =>
v ? (
<span
style={{
display: 'inline-block',
width: 20,
height: 20,
background: v,
borderRadius: 4,
verticalAlign: 'middle',
}}
/>
) : (
'-'
),
},
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (v: string) => v || '-' },
{
title: '操作', width: 150,
title: '操作',
width: 150,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="tenant:edit" size="small" onClick={() => { setEditing(record); form.setFieldsValue(record); setModalOpen(true); }}></PermissionButton>
<PermissionButton
permission="tenant:edit"
size="small"
onClick={() => {
setEditing(record);
form.setFieldsValue(record);
setModalOpen(true);
}}
>
</PermissionButton>
<PermissionButton permission="tenant:delete">
<Popconfirm title="归档后仍可查看历史租赁" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<Button size="small" icon={<InboxOutlined />}></Button>
<Popconfirm
title="归档后仍可查看历史租赁"
onConfirm={() => handleArchive(record.id)}
okText="归档"
cancelText="取消"
>
<Button size="small" icon={<InboxOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
</Space>
@@ -89,39 +149,92 @@ const TenantsPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
flexWrap: 'wrap',
gap: 8,
}}
>
<Input.Search
placeholder="搜索名称或联系人"
allowClear
style={{ width: 200 }}
onSearch={v => setSearchText(v)}
onChange={e => { if (!e.target.value) setSearchText(''); }}
onSearch={(v) => setSearchText(v)}
onChange={(e) => {
if (!e.target.value) setSearchText('');
}}
/>
<PermissionButton permission="tenant:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
<PermissionButton
permission="tenant:create"
type="primary"
icon={<PlusOutlined />}
onClick={() => {
setEditing(null);
form.resetFields();
setModalOpen(true);
}}
>
</PermissionButton>
</div>
<Table columns={columns} dataSource={filteredData} rowKey="id" loading={loading} pagination={{ pageSize: 20, showTotal: (total) => `${total}` }} />
<Table
columns={columns}
dataSource={filteredData}
rowKey="id"
loading={loading}
pagination={{ pageSize: 20, showTotal: (total) => `${total}` }}
/>
<Modal title={editing ? '编辑租赁方' : '添加租赁方'} open={modalOpen} onOk={handleSave} onCancel={() => { setModalOpen(false); setEditing(null); }} okText="保存">
<Modal
title={editing ? '编辑租赁方' : '添加租赁方'}
open={modalOpen}
onOk={handleSave}
onCancel={() => {
setModalOpen(false);
setEditing(null);
}}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}><Input placeholder="如:犀牛华安 / 艺考 / 博才" /></Form.Item>
<Form.Item name="contact" label="联系人"><Input /></Form.Item>
<Form.Item name="phone" label="电话"><Input /></Form.Item>
<Form.Item name="color" label="标签颜色" tooltip="可视化排期时用的颜色,留空则自动分配">
<Input placeholder="#40a9ff" addonAfter={
<Space size={4}>
{PRESET_COLORS.map(c => (
<span
key={c}
onClick={() => form.setFieldValue('color', c)}
style={{ display: 'inline-block', width: 16, height: 16, background: c, borderRadius: 3, cursor: 'pointer', border: '1px solid #d9d9d9' }}
/>
))}
</Space>
} />
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="如:犀牛华安 / 艺考 / 博才" />
</Form.Item>
<Form.Item name="contact" label="联系人">
<Input />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="color" label="标签颜色" tooltip="可视化排期时用的颜色,留空则自动分配">
<Input
placeholder="#40a9ff"
addonAfter={
<Space size={4}>
{PRESET_COLORS.map((c) => (
<span
key={c}
onClick={() => form.setFieldValue('color', c)}
style={{
display: 'inline-block',
width: 16,
height: 16,
background: c,
borderRadius: 3,
cursor: 'pointer',
border: '1px solid #d9d9d9',
}}
/>
))}
</Space>
}
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
</div>

View File

@@ -1,5 +1,17 @@
import React, { useEffect, useState } from 'react';
import { Table, Button, Modal, Form, Input, Select, Switch, Space, Tag, Popconfirm, message } from 'antd';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
Switch,
Space,
Tag,
Popconfirm,
message,
} from 'antd';
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined } from '@ant-design/icons';
import dayjs from 'dayjs';
import api from '../../api';
@@ -25,11 +37,15 @@ const UsersPage: React.FC = () => {
]);
setData(users);
setRoles(rolesRes);
} catch (e) { console.error(e); }
} catch (e) {
console.error(e);
}
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
useEffect(() => {
fetchData();
}, []);
const handleAdd = () => {
setEditing(null);
@@ -52,15 +68,27 @@ const UsersPage: React.FC = () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/rbac/users/${editing.id}`, { username: values.username, name: values.name, isActive: values.isActive, roleIds: values.roleIds || [] });
await api.put(`/rbac/users/${editing.id}`, {
username: values.username,
name: values.name,
isActive: values.isActive,
roleIds: values.roleIds || [],
});
message.success('更新成功');
} else {
await api.post('/rbac/users', { username: values.username, password: values.password, name: values.name, roleIds: values.roleIds || [] });
await api.post('/rbac/users', {
username: values.username,
password: values.password,
name: values.name,
roleIds: values.roleIds || [],
});
message.success('创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) { message.error(e.message || '操作失败'); }
} catch (e: any) {
message.error(e.message || '操作失败');
}
};
const handleDelete = async (id: number) => {
@@ -68,7 +96,9 @@ const UsersPage: React.FC = () => {
await api.delete(`/rbac/users/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) { message.error(e.message || '删除失败'); }
} catch (e: any) {
message.error(e.message || '删除失败');
}
};
const handleResetPwd = (record: any) => {
@@ -83,7 +113,9 @@ const UsersPage: React.FC = () => {
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
message.success('密码已重置');
setPwdModalOpen(false);
} catch (e: any) { message.error(e.message || '操作失败'); }
} catch (e: any) {
message.error(e.message || '操作失败');
}
};
const columns = [
@@ -91,33 +123,68 @@ const UsersPage: React.FC = () => {
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '姓名', dataIndex: 'name', width: 120 },
{
title: '角色', dataIndex: 'roles', width: 200,
render: (v: any[]) => v && v.length > 0
? v.map((r: any) => <Tag key={r.id} color="blue">{r.name}</Tag>)
: <Tag color="default"></Tag>,
title: '角色',
dataIndex: 'roles',
width: 200,
render: (v: any[]) =>
v && v.length > 0 ? (
v.map((r: any) => (
<Tag key={r.id} color="blue">
{r.name}
</Tag>
))
) : (
<Tag color="default"></Tag>
),
},
{
title: '状态', dataIndex: 'isActive', width: 80,
title: '状态',
dataIndex: 'isActive',
width: 80,
render: (v: boolean) => <Tag color={v ? 'green' : 'default'}>{v ? '启用' : '禁用'}</Tag>,
},
{
title: '最后登录', dataIndex: 'lastLoginAt', width: 170,
render: (v: string) => v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-',
title: '最后登录',
dataIndex: 'lastLoginAt',
width: 170,
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
},
{
title: '创建时间', dataIndex: 'createdAt', width: 170,
title: '创建时间',
dataIndex: 'createdAt',
width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
title: '操作', width: 220, fixed: 'right' as const,
title: '操作',
width: 220,
fixed: 'right' as const,
render: (_: any, record: any) => (
<Space>
<PermissionButton permission="user:edit" type="link" size="small" icon={<EditOutlined />} onClick={() => handleEdit(record)}></PermissionButton>
<PermissionButton permission="user:reset-password" type="link" size="small" icon={<KeyOutlined />} onClick={() => handleResetPwd(record)}></PermissionButton>
<PermissionButton
permission="user:edit"
type="link"
size="small"
icon={<EditOutlined />}
onClick={() => handleEdit(record)}
>
</PermissionButton>
<PermissionButton
permission="user:reset-password"
type="link"
size="small"
icon={<KeyOutlined />}
onClick={() => handleResetPwd(record)}
>
</PermissionButton>
{record.username !== 'admin' && (
<PermissionButton permission="user:delete">
<Popconfirm title="确认删除该用户?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
<Button type="link" size="small" danger icon={<DeleteOutlined />}>
</Button>
</Popconfirm>
</PermissionButton>
)}
@@ -128,19 +195,54 @@ const UsersPage: React.FC = () => {
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<div
style={{
marginBottom: 16,
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<h2 style={{ margin: 0 }}></h2>
<PermissionButton permission="user:create" type="primary" icon={<PlusOutlined />} onClick={handleAdd}></PermissionButton>
<PermissionButton
permission="user:create"
type="primary"
icon={<PlusOutlined />}
onClick={handleAdd}
>
</PermissionButton>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 1000 }} pagination={false} />
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1000 }}
pagination={false}
/>
<Modal title={editing ? '编辑账号' : '新增账号'} open={modalOpen} onOk={handleSubmit} onCancel={() => setModalOpen(false)} destroyOnClose>
<Modal
title={editing ? '编辑账号' : '新增账号'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="username" label="用户名" rules={[{ required: true, message: '请输入用户名' }]}>
<Form.Item
name="username"
label="用户名"
rules={[{ required: true, message: '请输入用户名' }]}
>
<Input />
</Form.Item>
{!editing && (
<Form.Item name="password" label="密码" rules={[{ required: true, min: 4, message: '密码至少4位' }]}>
<Form.Item
name="password"
label="密码"
rules={[{ required: true, min: 4, message: '密码至少4位' }]}
>
<Input.Password />
</Form.Item>
)}
@@ -152,19 +254,38 @@ const UsersPage: React.FC = () => {
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
)}
<Form.Item name="roleIds" label="角色分配" rules={[{ required: !editing, message: '请至少选择一个角色' }]}>
<Form.Item
name="roleIds"
label="角色分配"
rules={[{ required: !editing, message: '请至少选择一个角色' }]}
>
<Select
mode="multiple"
placeholder="选择角色"
options={roles.filter((r: any) => r.status !== 0).map((r: any) => ({ value: r.id, label: `${r.name}${r.isSystem ? ' (系统)' : ''}` }))}
options={roles
.filter((r: any) => r.status !== 0)
.map((r: any) => ({
value: r.id,
label: `${r.name}${r.isSystem ? ' (系统)' : ''}`,
}))}
/>
</Form.Item>
</Form>
</Modal>
<Modal title={`重置密码 - ${resetTarget?.username}`} open={pwdModalOpen} onOk={handlePwdSubmit} onCancel={() => setPwdModalOpen(false)} destroyOnClose>
<Modal
title={`重置密码 - ${resetTarget?.username}`}
open={pwdModalOpen}
onOk={handlePwdSubmit}
onCancel={() => setPwdModalOpen(false)}
destroyOnClose
>
<Form form={pwdForm} layout="vertical">
<Form.Item name="password" label="新密码" rules={[{ required: true, min: 4, message: '密码至少4位' }]}>
<Form.Item
name="password"
label="新密码"
rules={[{ required: true, min: 4, message: '密码至少4位' }]}
>
<Input.Password />
</Form.Item>
</Form>

View File

@@ -1,7 +1,4 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}

View File

@@ -1,5 +1,5 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
// https://vite.dev/config/
export default defineConfig({
@@ -27,4 +27,4 @@ export default defineConfig({
'antd/es/locale/zh_CN',
],
},
})
});

View File

@@ -4,9 +4,21 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ThrottlerModule, ThrottlerGuard } from '@nestjs/throttler';
import {
Student, Room, Occupancy, RoomExpense, PersonalExpense,
Bill, BillItem, User, OperationLog, Deposit, Classroom,
Tenant, ClassroomRental, Permission, Role,
Student,
Room,
Occupancy,
RoomExpense,
PersonalExpense,
Bill,
BillItem,
User,
OperationLog,
Deposit,
Classroom,
Tenant,
ClassroomRental,
Permission,
Role,
} from './entities';
import { AuthModule } from './auth/auth.module';
import { RbacModule } from './rbac/rbac.module';
@@ -26,19 +38,33 @@ import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.mo
@Module({
imports: [
ConfigModule.forRoot({ isGlobal: true }),
ThrottlerModule.forRoot([{
ttl: 60000, // 60秒窗口
limit: 100, // 普通接口每分钟100次
}]),
ThrottlerModule.forRoot([
{
ttl: 60000, // 60秒窗口
limit: 100, // 普通接口每分钟100次
},
]),
TypeOrmModule.forRootAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (config: ConfigService): any => {
const dbType = config.get('DB_TYPE', 'sqlite');
const allEntities = [
Student, Room, Occupancy, RoomExpense, PersonalExpense,
Bill, BillItem, User, OperationLog, Deposit, Classroom,
Tenant, ClassroomRental, Permission, Role,
Student,
Room,
Occupancy,
RoomExpense,
PersonalExpense,
Bill,
BillItem,
User,
OperationLog,
Deposit,
Classroom,
Tenant,
ClassroomRental,
Permission,
Role,
];
if (dbType === 'mysql') {
return {

View File

@@ -9,7 +9,10 @@ import { Public } from './decorators/public.decorator';
@Controller('auth')
export class AuthController {
constructor(private authService: AuthService, private logService: OperationLogsService) {}
constructor(
private authService: AuthService,
private logService: OperationLogsService,
) {}
@Public()
@Post('login')
@@ -19,17 +22,24 @@ export class AuthController {
try {
const result = await this.authService.login(dto, ipAddress);
await this.logService.log({
userId: result.user.id, username: result.user.username,
module: '认证', action: '登录成功',
ipAddress, userAgent, status: 'success',
userId: result.user.id,
username: result.user.username,
module: '认证',
action: '登录成功',
ipAddress,
userAgent,
status: 'success',
});
return result;
} catch (e: any) {
await this.logService.log({
username: dto.username,
module: '认证', action: '登录失败',
module: '认证',
action: '登录失败',
detail: e.message || '密码错误',
ipAddress, userAgent, status: 'fail',
ipAddress,
userAgent,
status: 'fail',
});
throw e;
}

View File

@@ -62,7 +62,7 @@ export class AuthService {
const payload = { sub: user.id, username: user.username, permissions };
// 获取角色名称列表
const roleNames = user.roles ? user.roles.filter(r => r.status === 1).map(r => r.name) : [];
const roleNames = user.roles ? user.roles.filter((r) => r.status === 1).map((r) => r.name) : [];
return {
access_token: this.jwtService.sign(payload),

View File

@@ -16,10 +16,10 @@ export class PermissionGuard implements CanActivate {
if (isPublic) return true;
// 2. 获取所需权限getAllAndMerge 合并 handler+class 层的所有 metadata
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(
PERMISSION_KEY,
[context.getHandler(), context.getClass()],
);
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
context.getHandler(),
context.getClass(),
]);
// 无装饰器 = 默认拒绝
if (!requiredPermissions || requiredPermissions.length === 0) return false;
@@ -29,6 +29,6 @@ export class PermissionGuard implements CanActivate {
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
return requiredPermissions.some(p => user.permissions.includes(p));
return requiredPermissions.some((p) => user.permissions.includes(p));
}
}

View File

@@ -19,8 +19,12 @@ export class BillsExportService {
/**
* 导出账单列表为 Excel
*/
async exportExcel(query: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string }, res: Response) {
const qb = this.billRepo.createQueryBuilder('b')
async exportExcel(
query: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string },
res: Response,
) {
const qb = this.billRepo
.createQueryBuilder('b')
.leftJoinAndSelect('b.student', 'student')
.leftJoinAndSelect('b.items', 'items')
.orderBy('b.generatedAt', 'DESC');
@@ -34,7 +38,8 @@ export class BillsExportService {
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
const depMap = new Map<number, number>();
if (studentIds.length > 0) {
const deposits = await this.depositRepo.createQueryBuilder('d')
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
@@ -65,7 +70,11 @@ export class BillsExportService {
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
const statusMap: Record<string, string> = {
draft: '草稿',
confirmed: '已确认',
paid: '已结清',
};
for (const bill of bills) {
const total = Number(bill.totalAmount || 0);
const dep = Number((depMap.get(bill.studentId) || 0).toFixed(2));
@@ -116,7 +125,10 @@ export class BillsExportService {
}
}
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', `attachment; filename=bills_${Date.now()}.xlsx`);
await workbook.xlsx.write(res);
res.end();
@@ -126,11 +138,18 @@ export class BillsExportService {
* 导出单个学生的 PDF 账单
*/
async exportStudentPdf(billId: number, res: Response) {
const bill = await this.billRepo.findOne({ where: { id: billId }, relations: ['student', 'items'] });
if (!bill) { res.status(404).json({ message: '账单不存在' }); return; }
const bill = await this.billRepo.findOne({
where: { id: billId },
relations: ['student', 'items'],
});
if (!bill) {
res.status(404).json({ message: '账单不存在' });
return;
}
// 查询该学生的可用押金(已缴未退)
const deposits = await this.depositRepo.createQueryBuilder('d')
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId = :sid', { sid: bill.studentId })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
@@ -146,12 +165,12 @@ export class BillsExportService {
// 注册中文字体(优先使用系统字体,兼容 macOS 和 Linux
const fontPaths = [
'/System/Library/Fonts/PingFang.ttc', // macOS
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', // Linux Noto
'/System/Library/Fonts/PingFang.ttc', // macOS
'/usr/share/fonts/opentype/noto/NotoSansCJK-Regular.ttc', // Linux Noto
'/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/google-noto-cjk/NotoSansCJK-Regular.ttc',
'/usr/share/fonts/noto-cjk/NotoSansSC-Regular.otf',
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', // Linux WenQuanYi
'/usr/share/fonts/wqy-microhei/wqy-microhei.ttc', // Linux WenQuanYi
'/usr/share/fonts/truetype/wqy/wqy-microhei.ttc',
];
let fontRegistered = false;
@@ -171,12 +190,19 @@ export class BillsExportService {
doc.font('Helvetica');
}
const statusMap: Record<string, string> = { draft: '草稿', confirmed: '已确认', paid: '已结清' };
const statusMap: Record<string, string> = {
draft: '草稿',
confirmed: '已确认',
paid: '已结清',
};
// 标题
doc.fontSize(20).text('恭学教育基地水电费账单', { align: 'center' });
doc.moveDown(0.5);
doc.fontSize(10).fillColor('#666').text(`生成时间: ${new Date().toLocaleString('zh-CN')}`, { align: 'center' });
doc
.fontSize(10)
.fillColor('#666')
.text(`生成时间: ${new Date().toLocaleString('zh-CN')}`, { align: 'center' });
doc.moveDown(1);
// 基本信息
@@ -192,12 +218,24 @@ export class BillsExportService {
doc.fontSize(12);
doc.text(`分摊费用: ¥${Number(bill.sharedAmount).toFixed(2)}`);
doc.text(`个人费用: ¥${Number(bill.personalAmount).toFixed(2)}`);
doc.fontSize(14).fillColor('#007AFF').text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc
.fontSize(14)
.fillColor('#007AFF')
.text(`应付总额: ¥${totalAmount.toFixed(2)}`);
doc.moveDown(0.3);
if (availableDeposit > 0) {
doc.fontSize(11).fillColor('#52C41A').text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
doc.fontSize(11).fillColor('#FA8C16').text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
doc.fontSize(14).fillColor('#FF3B30').text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
doc
.fontSize(11)
.fillColor('#52C41A')
.text(`可用押金: ¥${availableDeposit.toFixed(2)}`);
doc
.fontSize(11)
.fillColor('#FA8C16')
.text(`押金抵扣: -¥${depositApplied.toFixed(2)}`);
doc
.fontSize(14)
.fillColor('#FF3B30')
.text(`抵扣后应付: ¥${amountAfterDeposit.toFixed(2)}`);
}
doc.moveDown(1);
@@ -226,16 +264,23 @@ export class BillsExportService {
const y = doc.y;
x = 50;
doc.fontSize(9).fillColor('#000');
doc.text(item.expenseType || '', x, y, { width: colWidths[0] }); x += colWidths[0];
doc.text(item.description || '', x, y, { width: colWidths[1] }); x += colWidths[1];
doc.text(String(item.days || 0), x, y, { width: colWidths[2] }); x += colWidths[2];
doc.text(String(item.totalRoomDays || 0), x, y, { width: colWidths[3] }); x += colWidths[3];
doc.text(item.expenseType || '', x, y, { width: colWidths[0] });
x += colWidths[0];
doc.text(item.description || '', x, y, { width: colWidths[1] });
x += colWidths[1];
doc.text(String(item.days || 0), x, y, { width: colWidths[2] });
x += colWidths[2];
doc.text(String(item.totalRoomDays || 0), x, y, { width: colWidths[3] });
x += colWidths[3];
doc.text(Number(item.studentAmount).toFixed(2), x, y, { width: colWidths[4] });
doc.moveDown(0.8);
}
doc.moveDown(2);
doc.fontSize(8).fillColor('#999').text('本账单由恭学教育基地管理系统自动生成', { align: 'center' });
doc
.fontSize(8)
.fillColor('#999')
.text('本账单由恭学教育基地管理系统自动生成', { align: 'center' });
doc.end();
}

View File

@@ -1,4 +1,17 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, Request, Res, Req } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
UseGuards,
Request,
Res,
Req,
} from '@nestjs/common';
import { BillsService } from './bills.service';
import { BillsExportService } from './bills-export.service';
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
@@ -11,14 +24,26 @@ import type { Response } from 'express';
@UseGuards(JwtAuthGuard)
@Controller('bills')
export class BillsController {
constructor(private service: BillsService, private exportService: BillsExportService, private logService: OperationLogsService) {}
constructor(
private service: BillsService,
private exportService: BillsExportService,
private logService: OperationLogsService,
) {}
@Post('generate')
@RequirePermission('bill:generate')
async generateBills(@Body() dto: GenerateBillsDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.generateBills(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '生成账单', detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单',
action: '生成账单',
detail: `周期 ${dto.periodStart}~${dto.periodEnd}, 生成 ${result.count}`,
ipAddress,
userAgent,
});
return result;
}
@@ -31,7 +56,8 @@ export class BillsController {
@Query('status') status?: string,
) {
return this.service.findAll({
periodStart, periodEnd,
periodStart,
periodEnd,
studentId: studentId ? +studentId : undefined,
status,
});
@@ -45,10 +71,23 @@ export class BillsController {
@Put(':id/status')
@RequirePermission('bill:confirm')
async updateStatus(@Param('id') id: string, @Body() dto: UpdateBillStatusDto, @Request() req: any) {
async updateStatus(
@Param('id') id: string,
@Body() dto: UpdateBillStatusDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateStatus(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: `状态变更为${dto.status}`, targetId: +id, targetType: 'bill', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单',
action: `状态变更为${dto.status}`,
targetId: +id,
targetType: 'bill',
ipAddress,
userAgent,
});
return result;
}
@@ -57,7 +96,15 @@ export class BillsController {
async batchUpdateStatus(@Body() body: { ids: number[]; status: string }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchUpdateStatus(body.ids, body.status);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: `批量状态变更为${body.status}`, detail: `IDs: ${body.ids.join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单',
action: `批量状态变更为${body.status}`,
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@@ -66,7 +113,16 @@ export class BillsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '删除账单', targetId: +id, targetType: 'bill', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单',
action: '删除账单',
targetId: +id,
targetType: 'bill',
ipAddress,
userAgent,
});
return result;
}
@@ -75,7 +131,15 @@ export class BillsController {
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账单', action: '批量删除账单', detail: `IDs: ${body.ids.join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账单',
action: '批量删除账单',
detail: `IDs: ${body.ids.join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@@ -90,19 +154,40 @@ export class BillsController {
@Req() req?: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({ userId: req?.user?.id, username: req?.user?.username, module: '账单', action: '导出Excel', detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`, ipAddress, userAgent });
return this.exportService.exportExcel({
periodStart, periodEnd,
studentId: studentId ? +studentId : undefined,
status,
}, res!);
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单',
action: '导出Excel',
detail: `筛选: 周期${periodStart || '全部'}~${periodEnd || '全部'}, 状态${status || '全部'}`,
ipAddress,
userAgent,
});
return this.exportService.exportExcel(
{
periodStart,
periodEnd,
studentId: studentId ? +studentId : undefined,
status,
},
res!,
);
}
@Get('export/pdf/:id')
@RequirePermission('bill:export-pdf')
async exportPdf(@Param('id') id: string, @Res() res: Response, @Req() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
await this.logService.log({ userId: req?.user?.id, username: req?.user?.username, module: '账单', action: '导出PDF', targetId: +id, targetType: 'bill', ipAddress, userAgent });
await this.logService.log({
userId: req?.user?.id,
username: req?.user?.username,
module: '账单',
action: '导出PDF',
targetId: +id,
targetType: 'bill',
ipAddress,
userAgent,
});
return this.exportService.exportStudentPdf(+id, res);
}
}

View File

@@ -12,7 +12,17 @@ import { BillsExportService } from './bills-export.service';
import { BillsController } from './bills.controller';
@Module({
imports: [TypeOrmModule.forFeature([Bill, BillItem, RoomExpense, PersonalExpense, Occupancy, Room, Deposit])],
imports: [
TypeOrmModule.forFeature([
Bill,
BillItem,
RoomExpense,
PersonalExpense,
Occupancy,
Room,
Deposit,
]),
],
controllers: [BillsController],
providers: [BillsService, BillsExportService],
exports: [BillsService],

View File

@@ -37,13 +37,25 @@ export class BillsService {
});
if (existingDrafts.length > 0) {
const draftIds = existingDrafts.map((b) => b.id);
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids: draftIds }).execute();
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids: draftIds }).execute();
await this.itemRepo
.createQueryBuilder()
.delete()
.where('billId IN (:...ids)', { ids: draftIds })
.execute();
await this.billRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: draftIds })
.execute();
}
// 获取所有有费用的宿舍
const roomExpenses = await this.roomExpRepo.createQueryBuilder('e')
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', { periodStart, periodEnd })
const roomExpenses = await this.roomExpRepo
.createQueryBuilder('e')
.where('e.periodStart = :periodStart AND e.periodEnd = :periodEnd', {
periodStart,
periodEnd,
})
.getMany();
// 按宿舍分组费用
@@ -58,7 +70,8 @@ export class BillsService {
for (const [roomId, expenses] of roomExpMap) {
// 获取该宿舍在此周期内的所有入住记录
const occupancies = await this.occRepo.createQueryBuilder('o')
const occupancies = await this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
@@ -72,11 +85,16 @@ export class BillsService {
let totalDays = 0;
for (const occ of occupancies) {
const start = new Date(Math.max(new Date(occ.billingStartDate).getTime(), pStart.getTime()));
const start = new Date(
Math.max(new Date(occ.billingStartDate).getTime(), pStart.getTime()),
);
const end = occ.billingEndDate
? new Date(Math.min(new Date(occ.billingEndDate).getTime(), pEnd.getTime()))
: pEnd;
const days = Math.max(0, Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1);
const days = Math.max(
0,
Math.ceil((end.getTime() - start.getTime()) / (1000 * 60 * 60 * 24)) + 1,
);
studentDays.push({ studentId: occ.studentId, days });
totalDays += days;
}
@@ -107,8 +125,12 @@ export class BillsService {
}
// 获取个人附加费
const personalExps = await this.personalExpRepo.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', { periodStart, periodEnd })
const personalExps = await this.personalExpRepo
.createQueryBuilder('pe')
.where('pe.expenseDate >= :periodStart AND pe.expenseDate <= :periodEnd', {
periodStart,
periodEnd,
})
.getMany();
const personalMap = new Map<number, number>();
@@ -162,8 +184,14 @@ export class BillsService {
return { message: `成功生成 ${bills.length} 条账单`, count: bills.length, bills };
}
async findAll(query?: { periodStart?: string; periodEnd?: string; studentId?: number; status?: string }) {
const qb = this.billRepo.createQueryBuilder('b')
async findAll(query?: {
periodStart?: string;
periodEnd?: string;
studentId?: number;
status?: string;
}) {
const qb = this.billRepo
.createQueryBuilder('b')
.leftJoinAndSelect('b.student', 'student')
.orderBy('b.generatedAt', 'DESC');
if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
@@ -191,7 +219,8 @@ export class BillsService {
if (!bills || bills.length === 0) return bills;
const studentIds = Array.from(new Set(bills.map((b) => b.studentId)));
if (studentIds.length === 0) return bills;
const deposits = await this.depositRepo.createQueryBuilder('d')
const deposits = await this.depositRepo
.createQueryBuilder('d')
.where('d.studentId IN (:...ids)', { ids: studentIds })
.andWhere('d.status = :status', { status: 'paid' })
.getMany();
@@ -220,7 +249,12 @@ export class BillsService {
}
async batchUpdateStatus(ids: number[], status: string) {
await this.billRepo.createQueryBuilder().update().set({ status }).where('id IN (:...ids)', { ids }).execute();
await this.billRepo
.createQueryBuilder()
.update()
.set({ status })
.where('id IN (:...ids)', { ids })
.execute();
return { message: `成功更新 ${ids.length} 条账单状态` };
}
@@ -233,7 +267,11 @@ export class BillsService {
}
async batchRemove(ids: number[]) {
await this.itemRepo.createQueryBuilder().delete().where('billId IN (:...ids)', { ids }).execute();
await this.itemRepo
.createQueryBuilder()
.delete()
.where('billId IN (:...ids)', { ids })
.execute();
await this.billRepo.createQueryBuilder().delete().where('id IN (:...ids)', { ids }).execute();
return { message: `成功删除 ${ids.length} 条账单` };
}

View File

@@ -1,4 +1,19 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile, BadRequestException } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
BadRequestException,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import * as fs from 'fs';
@@ -12,7 +27,10 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('classroom-rentals')
export class ClassroomRentalsController {
constructor(private service: ClassroomRentalsService, private logService: OperationLogsService) {}
constructor(
private service: ClassroomRentalsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('rental:view')
@@ -52,10 +70,15 @@ export class ClassroomRentalsController {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({
userId: req.user?.id, username: req.user?.username,
module: '教室租赁', action: '新增租赁', targetId: result.id, targetType: 'classroom-rental',
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '新增租赁',
targetId: result.id,
targetType: 'classroom-rental',
detail: `教室${dto.classroomId} 租赁方${dto.tenantId} ${dto.startDate}~${dto.endDate}`,
ipAddress, userAgent,
ipAddress,
userAgent,
});
return result;
}
@@ -66,9 +89,15 @@ export class ClassroomRentalsController {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({
userId: req.user?.id, username: req.user?.username,
module: '教室租赁', action: '编辑租赁', targetId: +id, targetType: 'classroom-rental',
detail: JSON.stringify(dto), ipAddress, userAgent,
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '编辑租赁',
targetId: +id,
targetType: 'classroom-rental',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -79,9 +108,14 @@ export class ClassroomRentalsController {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({
userId: req.user?.id, username: req.user?.username,
module: '教室租赁', action: '删除租赁', targetId: +id, targetType: 'classroom-rental',
ipAddress, userAgent,
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '删除租赁',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
});
return result;
}
@@ -89,23 +123,35 @@ export class ClassroomRentalsController {
// 合同上传multer 限制 10MB + 仅 PDF
@Post(':id/contract')
@RequirePermission('rental:edit')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype !== 'application/pdf') {
return cb(new BadRequestException('仅支持 PDF 文件'), false);
}
cb(null, true);
},
}))
async uploadContract(@Param('id') id: string, @UploadedFile() file: Express.Multer.File, @Request() req: any) {
@UseInterceptors(
FileInterceptor('file', {
limits: { fileSize: 10 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (file.mimetype !== 'application/pdf') {
return cb(new BadRequestException('仅支持 PDF 文件'), false);
}
cb(null, true);
},
}),
)
async uploadContract(
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
@Request() req: any,
) {
if (!file) throw new BadRequestException('请上传合同文件');
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.attachContract(+id, file);
await this.logService.log({
userId: req.user?.id, username: req.user?.username,
module: '教室租赁', action: '上传合同', targetId: +id, targetType: 'classroom-rental',
detail: file.originalname, ipAddress, userAgent,
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '上传合同',
targetId: +id,
targetType: 'classroom-rental',
detail: file.originalname,
ipAddress,
userAgent,
});
return result;
}
@@ -115,7 +161,10 @@ export class ClassroomRentalsController {
async downloadContract(@Param('id') id: string, @Res() res: Response) {
const { fullPath, originalName } = await this.service.getContractPath(+id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
res.setHeader(
'Content-Disposition',
`attachment; filename="${encodeURIComponent(originalName)}"`,
);
const stream = fs.createReadStream(fullPath);
stream.pipe(res);
}
@@ -126,9 +175,14 @@ export class ClassroomRentalsController {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.removeContract(+id);
await this.logService.log({
userId: req.user?.id, username: req.user?.username,
module: '教室租赁', action: '删除合同', targetId: +id, targetType: 'classroom-rental',
ipAddress, userAgent,
userId: req.user?.id,
username: req.user?.username,
module: '教室租赁',
action: '删除合同',
targetId: +id,
targetType: 'classroom-rental',
ipAddress,
userAgent,
});
return result;
}

View File

@@ -1,4 +1,9 @@
import { Injectable, NotFoundException, BadRequestException, ConflictException } from '@nestjs/common';
import {
Injectable,
NotFoundException,
BadRequestException,
ConflictException,
} from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, Not } from 'typeorm';
import { ClassroomRental } from '../entities/classroom-rental.entity';
@@ -10,8 +15,16 @@ import * as fs from 'fs';
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
const COLOR_PALETTE = [
'#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9',
'#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c',
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()
@@ -33,8 +46,14 @@ export class ClassroomRentalsService {
}
}
async findAll(query?: { classroomId?: number; tenantId?: number; month?: string; includeEnded?: boolean }) {
const qb = this.repo.createQueryBuilder('r')
async findAll(query?: {
classroomId?: number;
tenantId?: number;
month?: string;
includeEnded?: boolean;
}) {
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.classroom', 'classroom')
.leftJoinAndSelect('r.tenant', 'tenant')
.orderBy('r.startDate', 'DESC');
@@ -63,7 +82,8 @@ export class ClassroomRentalsService {
* 重叠判定start1 <= end2 AND start2 <= end1
*/
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
const qb = this.repo.createQueryBuilder('r')
const qb = this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.where('r.classroomId = :cid', { cid: classroomId })
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
@@ -84,7 +104,12 @@ export class ClassroomRentalsService {
if (conflicts.length > 0) {
throw new ConflictException({
message: '该教室在此时间段已有租赁',
conflicts: conflicts.map(c => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, tenantName: c.tenant?.name })),
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
})),
});
}
return this.repo.save(this.repo.create({ ...dto, createdBy: userId, status: 'active' }));
@@ -102,7 +127,12 @@ export class ClassroomRentalsService {
if (conflicts.length > 0) {
throw new ConflictException({
message: '修改后时间段与已有租赁冲突',
conflicts: conflicts.map(c => ({ id: c.id, startDate: c.startDate, endDate: c.endDate, tenantName: c.tenant?.name })),
conflicts: conflicts.map((c) => ({
id: c.id,
startDate: c.startDate,
endDate: c.endDate,
tenantName: c.tenant?.name,
})),
});
}
}
@@ -116,7 +146,11 @@ export class ClassroomRentalsService {
if (rental.contractPath) {
const full = path.join(this.uploadDir, rental.contractPath);
if (fs.existsSync(full)) {
try { fs.unlinkSync(full); } catch { /* ignore */ }
try {
fs.unlinkSync(full);
} catch {
/* ignore */
}
}
}
await this.repo.delete(id);
@@ -133,7 +167,9 @@ export class ClassroomRentalsService {
const ext = path.extname(file.originalname).toLowerCase();
if (ext !== '.pdf') throw new BadRequestException('文件扩展名必须为 .pdf');
// UUID 文件名
const uuid = (globalThis as any).crypto?.randomUUID?.() || require('crypto').randomBytes(16).toString('hex');
const uuid =
(globalThis as any).crypto?.randomUUID?.() ||
require('crypto').randomBytes(16).toString('hex');
const filename = `${uuid}.pdf`;
const fullPath = path.join(this.uploadDir, filename);
// 路径遍历防护
@@ -142,7 +178,11 @@ export class ClassroomRentalsService {
if (rental.contractPath) {
const oldPath = path.join(this.uploadDir, rental.contractPath);
if (fs.existsSync(oldPath)) {
try { fs.unlinkSync(oldPath); } catch { /* ignore */ }
try {
fs.unlinkSync(oldPath);
} catch {
/* ignore */
}
}
}
fs.writeFileSync(fullPath, file.buffer);
@@ -158,7 +198,11 @@ export class ClassroomRentalsService {
if (!rental.contractPath) throw new BadRequestException('该租赁未上传合同');
const fullPath = path.join(this.uploadDir, rental.contractPath);
if (fs.existsSync(fullPath)) {
try { fs.unlinkSync(fullPath); } catch { /* ignore */ }
try {
fs.unlinkSync(fullPath);
} catch {
/* ignore */
}
}
await this.repo.update(id, { contractPath: null as any, contractOriginalName: null as any });
return { message: '合同已删除' };
@@ -188,7 +232,8 @@ export class ClassroomRentalsService {
where: { status: Not('archived') },
order: { building: 'ASC', name: 'ASC' },
});
const rentals = await this.repo.createQueryBuilder('r')
const rentals = await this.repo
.createQueryBuilder('r')
.leftJoinAndSelect('r.tenant', 'tenant')
.leftJoinAndSelect('r.classroom', 'classroom')
.where('r.status != :cancelled', { cancelled: 'cancelled' })
@@ -197,7 +242,10 @@ export class ClassroomRentalsService {
const tenantMap = new Map<number, any>();
const matrix: Record<number, Record<number, any>> = {};
const summary: Record<number, { totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }> = {};
const summary: Record<
number,
{ totalDays: number; rentedDays: number; idleDays: number; occupancyRate: number }
> = {};
for (const cls of classrooms) {
matrix[cls.id] = {};
@@ -225,7 +273,8 @@ export class ClassroomRentalsService {
rentalId: rental.id,
tenantId: rental.tenantId,
tenantName: rental.tenant?.name || '未知',
color: rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
color:
rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
hasContract: !!rental.contractPath,
};
}
@@ -243,7 +292,15 @@ export class ClassroomRentalsService {
year,
month,
days: lastDay,
classrooms: classrooms.map(c => ({ id: c.id, name: c.name, building: c.building, floor: c.floor, roomType: c.roomType, capacity: c.capacity, supervisor: c.supervisor })),
classrooms: classrooms.map((c) => ({
id: c.id,
name: c.name,
building: c.building,
floor: c.floor,
roomType: c.roomType,
capacity: c.capacity,
supervisor: c.supervisor,
})),
tenants: Array.from(tenantMap.values()),
matrix,
summary,

View File

@@ -1,4 +1,12 @@
import { IsOptional, IsString, IsNotEmpty, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
import {
IsOptional,
IsString,
IsNotEmpty,
IsInt,
IsNumber,
IsEnum,
IsDateString,
} from 'class-validator';
export class CreateRentalDto {
@IsInt()

View File

@@ -1,4 +1,18 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { ClassroomsService } from './classrooms.service';
@@ -12,11 +26,18 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('classrooms')
export class ClassroomsController {
constructor(private service: ClassroomsService, private logService: OperationLogsService) {}
constructor(
private service: ClassroomsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('classroom:view')
findAll(@Query('building') building?: string, @Query('roomType') roomType?: string, @Query('includeArchived') includeArchived?: string) {
findAll(
@Query('building') building?: string,
@Query('roomType') roomType?: string,
@Query('includeArchived') includeArchived?: string,
) {
return this.service.findAll({
building,
roomType,
@@ -40,9 +61,33 @@ export class ClassroomsController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ name: 'A201', building: 'A座', floor: 2, roomType: '大', capacity: 60, courseType: '尊享培优班', supervisor: '张老师' });
ws.addRow({ name: 'B301', building: 'B座', floor: 3, roomType: '次大', capacity: 40, courseType: '专业课集训班', supervisor: '李老师' });
ws.addRow({ name: 'B405', building: 'B座', floor: 4, roomType: '小', capacity: 20, courseType: '', supervisor: '' });
ws.addRow({
name: 'A201',
building: 'A座',
floor: 2,
roomType: '大',
capacity: 60,
courseType: '尊享培优班',
supervisor: '张老师',
});
ws.addRow({
name: 'B301',
building: 'B座',
floor: 3,
roomType: '次大',
capacity: 40,
courseType: '专业课集训班',
supervisor: '李老师',
});
ws.addRow({
name: 'B405',
building: 'B座',
floor: 4,
roomType: '小',
capacity: 20,
courseType: '',
supervisor: '',
});
// 说明sheet
const ws2 = workbook.addWorksheet('使用说明');
@@ -56,7 +101,10 @@ export class ClassroomsController {
'5. 负责人为班主任/对接人',
].forEach((note) => ws2.addRow({ note }));
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=classroom_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -73,7 +121,17 @@ export class ClassroomsController {
async create(@Body() dto: CreateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '新增教室', targetId: result.id, targetType: 'classroom', detail: dto.name, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '新增教室',
targetId: result.id,
targetType: 'classroom',
detail: dto.name,
ipAddress,
userAgent,
});
return result;
}
@@ -82,7 +140,17 @@ export class ClassroomsController {
async update(@Param('id') id: string, @Body() dto: UpdateClassroomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '编辑教室', targetId: +id, targetType: 'classroom', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '编辑教室',
targetId: +id,
targetType: 'classroom',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -91,7 +159,16 @@ export class ClassroomsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '归档教室', targetId: +id, targetType: 'classroom', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '归档教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
});
return result;
}
@@ -100,7 +177,16 @@ export class ClassroomsController {
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '恢复教室', targetId: +id, targetType: 'classroom', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '恢复教室',
targetId: +id,
targetType: 'classroom',
ipAddress,
userAgent,
});
return result;
}
@@ -126,7 +212,15 @@ export class ClassroomsController {
});
});
const result = await this.service.batchImport(rows);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '教室', action: '批量导入', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '教室',
action: '批量导入',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -54,25 +54,47 @@ export class ClassroomsService {
return { message: '已恢复' };
}
async batchImport(rows: { name: string; building?: string; floor?: number; capacity?: number; roomType?: string; courseType?: string; supervisor?: string }[]) {
async batchImport(
rows: {
name: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
courseType?: string;
supervisor?: string;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) { skipped++; continue; }
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const name = row.name.trim();
const exists = await this.repo.findOne({ where: { name } });
if (exists) { skipped++; continue; }
await this.repo.save(this.repo.create({
name,
building: row.building?.trim() || undefined,
floor: row.floor || undefined,
capacity: row.capacity || 30,
roomType: row.roomType?.trim() || '大',
courseType: row.courseType?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}));
if (exists) {
skipped++;
continue;
}
await this.repo.save(
this.repo.create({
name,
building: row.building?.trim() || undefined,
floor: row.floor || undefined,
capacity: row.capacity || 30,
roomType: row.roomType?.trim() || '大',
courseType: row.courseType?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}),
);
imported++;
}
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
return {
message: `成功导入 ${imported} 间教室,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
}

View File

@@ -2,7 +2,11 @@
* 从请求对象中提取客户端 IP 和 UserAgent
*/
export function extractRequestInfo(req: any): { ipAddress: string; userAgent: string } {
const forwarded = req.headers?.['x-forwarded-for'] || req.headers?.['x-real-ip'] || req.connection?.remoteAddress || '';
const forwarded =
req.headers?.['x-forwarded-for'] ||
req.headers?.['x-real-ip'] ||
req.connection?.remoteAddress ||
'';
const ipAddress = String(forwarded).split(',')[0].trim() || 'unknown';
const userAgent = (req.headers?.['user-agent'] || '').substring(0, 500);
return { ipAddress, userAgent };

View File

@@ -21,26 +21,36 @@ export class DashboardService {
const totalRooms = await this.roomRepo.count({ where: { status: Not('archived') } });
const totalStudents = await this.studentRepo.count({ where: { status: 'active' } });
const occupiedBeds = await this.occRepo.count({ where: { checkOutDate: IsNull() } });
const totalCapacity = await this.roomRepo.createQueryBuilder('r')
const totalCapacity = await this.roomRepo
.createQueryBuilder('r')
.select('SUM(r.capacity)', 'total')
.where('r.status != :archived', { archived: 'archived' })
.getRawOne();
const cap = totalCapacity?.total || 0;
const occupancyRate = cap > 0 ? ((occupiedBeds / cap) * 100).toFixed(1) : 0;
const billStats = await this.billRepo.createQueryBuilder('b')
const billStats = await this.billRepo
.createQueryBuilder('b')
.select('b.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(b.totalAmount)', 'total')
.groupBy('b.status')
.getRawMany();
return { totalRooms, totalStudents, occupiedBeds, totalCapacity: cap, occupancyRate, billStats };
return {
totalRooms,
totalStudents,
occupiedBeds,
totalCapacity: cap,
occupancyRate,
billStats,
};
}
// 甘特图数据:每个宿舍的入住时间线
async getGanttData(query?: { periodStart?: string; periodEnd?: string; building?: string }) {
const qb = this.occRepo.createQueryBuilder('o')
const qb = this.occRepo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.where('room.status != :archived', { archived: 'archived' })
@@ -82,7 +92,8 @@ export class DashboardService {
// 费用统计
async getExpenseStats(periodStart?: string, periodEnd?: string) {
const qb = this.expRepo.createQueryBuilder('e')
const qb = this.expRepo
.createQueryBuilder('e')
.select('e.expenseType', 'type')
.addSelect('SUM(e.amount)', 'total')
.groupBy('e.expenseType');
@@ -93,7 +104,8 @@ export class DashboardService {
// 各宿舍费用排行
async getRoomExpenseRanking(periodStart?: string, periodEnd?: string) {
const qb = this.expRepo.createQueryBuilder('e')
const qb = this.expRepo
.createQueryBuilder('e')
.leftJoin('e.room', 'room')
.select('room.roomNumber', 'roomNumber')
.addSelect('SUM(e.amount)', 'total')

View File

@@ -1,4 +1,15 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { DepositsService } from './deposits.service';
import { CreateDepositDto, RefundDepositDto } from './dto/deposit.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -9,7 +20,10 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('deposits')
export class DepositsController {
constructor(private service: DepositsService, private logService: OperationLogsService) {}
constructor(
private service: DepositsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('deposit:view')
@@ -31,7 +45,17 @@ export class DepositsController {
async create(@Body() dto: CreateDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金', action: '收取押金', targetId: result.id, targetType: 'deposit', detail: `学生${dto.studentId} ¥${dto.amount}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金',
action: '收取押金',
targetId: result.id,
targetType: 'deposit',
detail: `学生${dto.studentId} ¥${dto.amount}`,
ipAddress,
userAgent,
});
return result;
}
@@ -40,7 +64,17 @@ export class DepositsController {
async refund(@Param('id') id: string, @Body() dto: RefundDepositDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.refund(+id, dto, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金', action: '退还押金', targetId: +id, targetType: 'deposit', detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金',
action: '退还押金',
targetId: +id,
targetType: 'deposit',
detail: `退还¥${result.refundAmount}, 扣除¥${result.deductionAmount}`,
ipAddress,
userAgent,
});
return result;
}
@@ -49,7 +83,16 @@ export class DepositsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '押金', action: '删除押金记录', targetId: +id, targetType: 'deposit', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '押金',
action: '删除押金记录',
targetId: +id,
targetType: 'deposit',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -9,7 +9,8 @@ export class DepositsService {
constructor(@InjectRepository(Deposit) private repo: Repository<Deposit>) {}
async findAll(query?: { studentId?: number; status?: string }) {
const qb = this.repo.createQueryBuilder('d')
const qb = this.repo
.createQueryBuilder('d')
.leftJoinAndSelect('d.student', 'student')
.orderBy('d.createdAt', 'DESC');
if (query?.studentId) qb.andWhere('d.studentId = :studentId', { studentId: query.studentId });
@@ -18,14 +19,16 @@ export class DepositsService {
}
async create(dto: CreateDepositDto, userId?: number) {
return this.repo.save(this.repo.create({
studentId: dto.studentId,
amount: dto.amount,
paidDate: dto.paidDate,
notes: dto.notes,
status: 'paid',
recordedBy: userId,
}));
return this.repo.save(
this.repo.create({
studentId: dto.studentId,
amount: dto.amount,
paidDate: dto.paidDate,
notes: dto.notes,
status: 'paid',
recordedBy: userId,
}),
);
}
async refund(id: number, dto: RefundDepositDto, userId?: number) {
@@ -41,7 +44,8 @@ export class DepositsService {
deposit.deductionAmount = deduction;
deposit.deductionReason = dto.deductionReason || '';
deposit.refundAmount = refundAmount;
deposit.status = deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
deposit.status =
deduction > 0 ? (refundAmount > 0 ? 'partial_refund' : 'deducted') : 'refunded';
if (dto.notes) deposit.notes = dto.notes;
return this.repo.save(deposit);
@@ -55,7 +59,8 @@ export class DepositsService {
}
async getStats() {
const result = await this.repo.createQueryBuilder('d')
const result = await this.repo
.createQueryBuilder('d')
.select('d.status', 'status')
.addSelect('COUNT(*)', 'count')
.addSelect('SUM(d.amount)', 'totalAmount')

View File

@@ -1,4 +1,12 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn, OneToMany } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
OneToMany,
} from 'typeorm';
import { Student } from './student.entity';
import { BillItem } from './bill-item.entity';

View File

@@ -1,4 +1,13 @@
import { Entity, PrimaryGeneratedColumn, Column, ManyToOne, JoinColumn, CreateDateColumn, UpdateDateColumn, Index } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
ManyToOne,
JoinColumn,
CreateDateColumn,
UpdateDateColumn,
Index,
} from 'typeorm';
import { Classroom } from './classroom.entity';
import { Tenant } from './tenant.entity';

View File

@@ -1,4 +1,11 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('deposits')

View File

@@ -1,4 +1,11 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
import { Room } from './room.entity';

View File

@@ -1,4 +1,11 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Student } from './student.entity';
@Entity('personal_expenses')

View File

@@ -1,4 +1,12 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Permission } from './permission.entity';
import { User } from './user.entity';

View File

@@ -1,4 +1,11 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, ManyToOne, JoinColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
ManyToOne,
JoinColumn,
} from 'typeorm';
import { Room } from './room.entity';
@Entity('room_expenses')

View File

@@ -1,4 +1,11 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, OneToMany } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
OneToMany,
} from 'typeorm';
import { Occupancy } from './occupancy.entity';
import { PersonalExpense } from './personal-expense.entity';
import { Bill } from './bill.entity';

View File

@@ -1,4 +1,10 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
} from 'typeorm';
@Entity('tenants')
export class Tenant {

View File

@@ -1,4 +1,12 @@
import { Entity, PrimaryGeneratedColumn, Column, CreateDateColumn, UpdateDateColumn, ManyToMany, JoinTable } from 'typeorm';
import {
Entity,
PrimaryGeneratedColumn,
Column,
CreateDateColumn,
UpdateDateColumn,
ManyToMany,
JoinTable,
} from 'typeorm';
import { Role } from './role.entity';
@Entity('users')

View File

@@ -1,8 +1,26 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { ExpensesService } from './expenses.service';
import { CreateRoomExpenseDto, CreatePersonalExpenseDto, BatchRoomExpenseDto } from './dto/expense.dto';
import {
CreateRoomExpenseDto,
CreatePersonalExpenseDto,
BatchRoomExpenseDto,
} from './dto/expense.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
import { extractRequestInfo } from '../common/request-utils';
@@ -15,13 +33,13 @@ function readCell(cell: ExcelJS.Cell): any {
if (v == null) return '';
if (typeof v === 'object') {
// 公式单元格:{ formula, result }
if ('result' in v) v = (v as any).result;
if ('result' in v) v = v.result;
// 富文本:{ richText: [...] }
else if ('richText' in v && Array.isArray((v as any).richText)) {
return (v as any).richText.map((r: any) => r.text || '').join('');
else if ('richText' in v && Array.isArray(v.richText)) {
return v.richText.map((r: any) => r.text || '').join('');
}
// 超链接:{ text, hyperlink }
else if ('text' in v) v = (v as any).text;
else if ('text' in v) v = v.text;
// 错误值:{ error: '#DIV/0!' }
else if ('error' in v) return '';
}
@@ -49,14 +67,27 @@ function readCellStr(cell: ExcelJS.Cell): string {
@UseGuards(JwtAuthGuard)
@Controller('expenses')
export class ExpensesController {
constructor(private service: ExpensesService, private logService: OperationLogsService) {}
constructor(
private service: ExpensesService,
private logService: OperationLogsService,
) {}
@Post('room')
@RequirePermission('expense:create')
async createRoomExpense(@Body() dto: CreateRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createRoomExpense(dto, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '录入宿舍费用', targetId: result.id, targetType: 'room_expense', detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '录入宿舍费用',
targetId: result.id,
targetType: 'room_expense',
detail: `房间${dto.roomId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
});
return result;
}
@@ -65,7 +96,15 @@ export class ExpensesController {
async batchCreateRoomExpenses(@Body() dto: BatchRoomExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCreateRoomExpenses(dto, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '批量录入费用', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '批量录入费用',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -78,7 +117,8 @@ export class ExpensesController {
) {
return this.service.findRoomExpenses({
roomId: roomId ? +roomId : undefined,
periodStart, periodEnd,
periodStart,
periodEnd,
});
}
@@ -87,7 +127,16 @@ export class ExpensesController {
async deleteRoomExpense(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deleteRoomExpense(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '删除宿舍费用', targetId: +id, targetType: 'room_expense', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '删除宿舍费用',
targetId: +id,
targetType: 'room_expense',
ipAddress,
userAgent,
});
return result;
}
@@ -96,16 +145,38 @@ export class ExpensesController {
async batchDeleteRoomExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeleteRoomExpenses(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '批量删除宿舍费用', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '批量删除宿舍费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@Put('room/:id')
@RequirePermission('expense:edit')
async updateRoomExpense(@Param('id') id: string, @Body() dto: CreateRoomExpenseDto, @Request() req: any) {
async updateRoomExpense(
@Param('id') id: string,
@Body() dto: CreateRoomExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updateRoomExpense(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '编辑宿舍费用', targetId: +id, targetType: 'room_expense', detail: `¥${dto.amount} ${dto.expenseType}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '编辑宿舍费用',
targetId: +id,
targetType: 'room_expense',
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
});
return result;
}
@@ -114,7 +185,15 @@ export class ExpensesController {
async createPersonalExpense(@Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.createPersonalExpense(dto, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '录入个人费用', detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '录入个人费用',
detail: `学生${dto.studentId} ¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
});
return result;
}
@@ -129,7 +208,15 @@ export class ExpensesController {
async deletePersonalExpense(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.deletePersonalExpense(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '删除个人费用', targetId: +id, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '删除个人费用',
targetId: +id,
ipAddress,
userAgent,
});
return result;
}
@@ -138,16 +225,37 @@ export class ExpensesController {
async batchDeletePersonalExpenses(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchDeletePersonalExpenses(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '批量删除个人费用', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '批量删除个人费用',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@Put('personal/:id')
@RequirePermission('expense:edit')
async updatePersonalExpense(@Param('id') id: string, @Body() dto: CreatePersonalExpenseDto, @Request() req: any) {
async updatePersonalExpense(
@Param('id') id: string,
@Body() dto: CreatePersonalExpenseDto,
@Request() req: any,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.updatePersonalExpense(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '编辑个人费用', targetId: +id, detail: `¥${dto.amount} ${dto.expenseType}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '编辑个人费用',
targetId: +id,
detail: `¥${dto.amount} ${dto.expenseType}`,
ipAddress,
userAgent,
});
return result;
}
@@ -168,8 +276,20 @@ export class ExpensesController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ seq: 1, period: '2026-01-21 - 2026-02-08', roomNumber: '4-102', electricity: 50, electricityFee: 25.5, water: 3, waterFee: 14.7, total: 40.2 });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ws.addRow({
seq: 1,
period: '2026-01-21 - 2026-02-08',
roomNumber: '4-102',
electricity: 50,
electricityFee: 25.5,
water: 3,
waterFee: 14.7,
total: 40.2,
});
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=utility_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -199,7 +319,15 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportUtilityExpenses(rows, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '导入水电费', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '导入水电费',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
@@ -217,12 +345,23 @@ export class ExpensesController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ studentName: '张三', expenseType: '钥匙费', amount: 30, expenseDate: '2026-01-15', description: '丢失宿舍钥匙' });
ws.addRow({
studentName: '张三',
expenseType: '钥匙费',
amount: 30,
expenseDate: '2026-01-15',
description: '丢失宿舍钥匙',
});
// 添加费用类型说明
const noteSheet = workbook.addWorksheet('费用类型说明');
noteSheet.columns = [{ header: '费用类型可用值', key: 'type', width: 25 }];
['物品损坏', '保洁费', '罚款', '钥匙费', '空调遥控器', '押金扣除', '其他'].forEach(t => noteSheet.addRow({ type: t }));
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
['物品损坏', '保洁费', '罚款', '钥匙费', '空调遥控器', '押金扣除', '其他'].forEach((t) =>
noteSheet.addRow({ type: t }),
);
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=personal_expense_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -250,7 +389,15 @@ export class ExpensesController {
});
});
const result = await this.service.batchImportPersonalExpenses(rows, req.user?.id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '费用', action: '导入个人附加费', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '费用',
action: '导入个人附加费',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
@@ -258,7 +405,15 @@ export class ExpensesController {
@RequirePermission('expense:view')
async exportPersonalExpenses(@Res() res: Response) {
const data = await this.service.findPersonalExpenses();
const typeMap: Record<string, string> = { damage: '物品损坏', cleaning: '保洁费', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他' };
const typeMap: Record<string, string> = {
damage: '物品损坏',
cleaning: '保洁费',
penalty: '罚款',
key: '钥匙费',
remote: '空调遥控器',
deposit_deduction: '押金扣除',
other: '其他',
};
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('个人附加费');
ws.columns = [
@@ -278,7 +433,10 @@ export class ExpensesController {
description: d.description || '',
});
});
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=personal_expenses_export.xlsx');
await workbook.xlsx.write(res);
res.end();

View File

@@ -9,7 +9,10 @@ import { ExpensesController } from './expenses.controller';
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
@Module({
imports: [TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]), OperationLogsModule],
imports: [
TypeOrmModule.forFeature([RoomExpense, PersonalExpense, Room, Student]),
OperationLogsModule,
],
controllers: [ExpensesController],
providers: [ExpensesService],
exports: [ExpensesService],

View File

@@ -5,7 +5,11 @@ import { RoomExpense } from '../entities/room-expense.entity';
import { PersonalExpense } from '../entities/personal-expense.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
import { CreateRoomExpenseDto, CreatePersonalExpenseDto, BatchRoomExpenseDto } from './dto/expense.dto';
import {
CreateRoomExpenseDto,
CreatePersonalExpenseDto,
BatchRoomExpenseDto,
} from './dto/expense.dto';
import { RoomsService } from '../rooms/rooms.service';
@Injectable()
@@ -38,7 +42,8 @@ export class ExpensesService {
}
async findRoomExpenses(query?: { roomId?: number; periodStart?: string; periodEnd?: string }) {
const qb = this.roomExpRepo.createQueryBuilder('e')
const qb = this.roomExpRepo
.createQueryBuilder('e')
.leftJoinAndSelect('e.room', 'room')
.orderBy('e.createdAt', 'DESC');
if (query?.roomId) qb.andWhere('e.roomId = :roomId', { roomId: query.roomId });
@@ -56,7 +61,8 @@ export class ExpensesService {
async batchDeleteRoomExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
const result = await this.roomExpRepo.createQueryBuilder()
const result = await this.roomExpRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids })
.execute();
@@ -78,7 +84,11 @@ export class ExpensesService {
async findPersonalExpenses(query?: { studentId?: number }) {
const where: any = {};
if (query?.studentId) where.studentId = query.studentId;
return this.personalExpRepo.find({ where, relations: ['student'], order: { createdAt: 'DESC' } });
return this.personalExpRepo.find({
where,
relations: ['student'],
order: { createdAt: 'DESC' },
});
}
async deletePersonalExpense(id: number) {
@@ -90,7 +100,8 @@ export class ExpensesService {
async batchDeletePersonalExpenses(ids: number[]) {
if (!ids || ids.length === 0) throw new BadRequestException('请选择要删除的记录');
const result = await this.personalExpRepo.createQueryBuilder()
const result = await this.personalExpRepo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids })
.execute();
@@ -109,15 +120,18 @@ export class ExpensesService {
* Excel格式: 序号|时间|房间号|房间电量|电费|冷水用量(吨)|水费|应缴金额
* 时间格式: "2026-01-21 - 2026-02-08"
*/
async batchImportUtilityExpenses(rows: {
periodStr: string;
roomNumber: string;
electricityAmount: number;
electricityFee: number;
waterAmount: number;
waterFee: number;
totalFee: number;
}[], userId?: number) {
async batchImportUtilityExpenses(
rows: {
periodStr: string;
roomNumber: string;
electricityAmount: number;
electricityFee: number;
waterAmount: number;
waterFee: number;
totalFee: number;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
@@ -126,20 +140,25 @@ export class ExpensesService {
const row = rows[i];
const rowNum = i + 2;
if (!row.roomNumber?.trim()) { skipped++; continue; }
if (!row.roomNumber?.trim()) {
skipped++;
continue;
}
try {
// 查找或创建宿舍
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}));
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 解析时间段 "2026-01-21 - 2026-02-08" 或 "2026-01-21~2026-02-08"
@@ -169,13 +188,16 @@ export class ExpensesService {
// 关键校验:电费 + 水费 都为 0 时,多半是 Excel 公式未正确计算或字段缺失,
// 必须给出明确错误,避免出现"提示成功但无数据"的迷之现象。
if ((row.electricityFee || 0) <= 0 && (row.waterFee || 0) <= 0) {
errors.push(`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`);
errors.push(
`${rowNum}行: ${row.roomNumber} 电费和水费均为 0可能 Excel 中是未生效的公式(请打开文件让公式重算后再保存导入),已跳过`,
);
skipped++;
continue;
}
// 幂等:先删除该房间在同一周期已有的水/电费用记录,避免重复导入产生脏数据
await this.roomExpRepo.createQueryBuilder()
await this.roomExpRepo
.createQueryBuilder()
.delete()
.where('roomId = :roomId', { roomId: room.id })
.andWhere('periodStart = :ps AND periodEnd = :pe', { ps: periodStart, pe: periodEnd })
@@ -185,34 +207,41 @@ export class ExpensesService {
let savedAny = false;
// 导入电费
if (row.electricityFee > 0) {
await this.roomExpRepo.save(this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
amount: row.electricityFee,
periodStart,
periodEnd,
description: `电量${row.electricityAmount}kWh`,
recordedBy: userId,
}));
await this.roomExpRepo.save(
this.roomExpRepo.create({
roomId: room.id,
expenseType: 'electricity',
amount: row.electricityFee,
periodStart,
periodEnd,
description: `电量${row.electricityAmount}kWh`,
recordedBy: userId,
}),
);
savedAny = true;
}
// 导入水费
if (row.waterFee > 0) {
await this.roomExpRepo.save(this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
amount: row.waterFee,
periodStart,
periodEnd,
description: `用水${row.waterAmount}`,
recordedBy: userId,
}));
await this.roomExpRepo.save(
this.roomExpRepo.create({
roomId: room.id,
expenseType: 'water',
amount: row.waterFee,
periodStart,
periodEnd,
description: `用水${row.waterAmount}`,
recordedBy: userId,
}),
);
savedAny = true;
}
if (savedAny) imported++;
else { skipped++; errors.push(`${rowNum}行: ${row.roomNumber} 无有效金额`); }
else {
skipped++;
errors.push(`${rowNum}行: ${row.roomNumber} 无有效金额`);
}
} catch (e: any) {
errors.push(`${rowNum}行: ${row.roomNumber} 导入失败 - ${e.message}`);
skipped++;
@@ -220,9 +249,10 @@ export class ExpensesService {
}
return {
message: imported > 0
? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `,跳过 ${skipped}` : ''}`
: `未导入任何记录${skipped > 0 ? ` ${skipped}被跳过` : ''}`,
message:
imported > 0
? `成功导入 ${imported} 间宿舍水电费${skipped > 0 ? `跳过 ${skipped}` : ''}`
: `未导入任何记录${skipped > 0 ? `,共 ${skipped} 条被跳过` : ''}`,
imported,
skipped,
errors: errors.length > 0 ? errors : undefined,
@@ -242,32 +272,42 @@ export class ExpensesService {
* 个人附加费Excel批量导入
* Excel格式: 学生姓名|费用类型|金额|费用日期|说明
*/
async batchImportPersonalExpenses(rows: {
studentName: string;
expenseType: string;
amount: number;
expenseDate: string;
description?: string;
}[], userId?: number) {
async batchImportPersonalExpenses(
rows: {
studentName: string;
expenseType: string;
amount: number;
expenseDate: string;
description?: string;
}[],
userId?: number,
) {
let imported = 0;
let skipped = 0;
const errors: string[] = [];
const typeMap: Record<string, string> = {
'物品损坏': 'damage', '损坏': 'damage',
'保洁费': 'cleaning', '保洁': 'cleaning',
'罚款': 'penalty',
'钥匙费': 'key', '钥匙': 'key',
'空调遥控器': 'remote', '遥控器': 'remote',
'押金扣除': 'deposit_deduction',
'其他': 'other',
: 'damage',
: 'damage',
: 'cleaning',
: 'cleaning',
: 'penalty',
: 'key',
: 'key',
: 'remote',
: 'remote',
: 'deposit_deduction',
: 'other',
};
for (let i = 0; i < rows.length; i++) {
const row = rows[i];
const rowNum = i + 2;
if (!row.studentName?.trim()) { skipped++; continue; }
if (!row.studentName?.trim()) {
skipped++;
continue;
}
try {
// 查找学生
@@ -283,7 +323,15 @@ export class ExpensesService {
if (typeMap[expenseType]) {
expenseType = typeMap[expenseType];
}
const validTypes = ['damage', 'cleaning', 'penalty', 'key', 'remote', 'deposit_deduction', 'other'];
const validTypes = [
'damage',
'cleaning',
'penalty',
'key',
'remote',
'deposit_deduction',
'other',
];
if (!validTypes.includes(expenseType)) {
errors.push(`${rowNum}行: 费用类型"${row.expenseType}"无效`);
skipped++;
@@ -304,14 +352,16 @@ export class ExpensesService {
}
}
await this.personalExpRepo.save(this.personalExpRepo.create({
studentId: student.id,
expenseType,
amount: row.amount,
expenseDate,
description: row.description || undefined,
recordedBy: userId,
}));
await this.personalExpRepo.save(
this.personalExpRepo.create({
studentId: student.id,
expenseType,
amount: row.amount,
expenseDate,
description: row.description || undefined,
recordedBy: userId,
}),
);
imported++;
} catch (e: any) {

View File

@@ -1,4 +1,18 @@
import { Controller, Get, Post, Put, Delete, Param, Body, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Param,
Body,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { OccupanciesService } from './occupancies.service';
@@ -12,7 +26,10 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('occupancies')
export class OccupanciesController {
constructor(private service: OccupanciesService, private logService: OperationLogsService) {}
constructor(
private service: OccupanciesService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('occupancy:view')
@@ -33,7 +50,15 @@ export class OccupanciesController {
async batchCheckOut(@Body() dto: BatchCheckOutDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchCheckOut(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量退宿', detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '批量退宿',
detail: `退宿 ${dto.ids.length} 人,日期 ${dto.checkOutDate}`,
ipAddress,
userAgent,
});
return result;
}
@@ -42,7 +67,17 @@ export class OccupanciesController {
async checkIn(@Body() dto: CheckInDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.checkIn(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '入住登记', targetId: result.id, targetType: 'occupancy', detail: `学生${dto.studentId} 入住房间${dto.roomId}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '入住登记',
targetId: result.id,
targetType: 'occupancy',
detail: `学生${dto.studentId} 入住房间${dto.roomId}`,
ipAddress,
userAgent,
});
return result;
}
@@ -51,7 +86,16 @@ export class OccupanciesController {
async checkOut(@Param('id') id: string, @Body() dto: CheckOutDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.checkOut(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '退宿', targetId: +id, targetType: 'occupancy', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '退宿',
targetId: +id,
targetType: 'occupancy',
ipAddress,
userAgent,
});
return result;
}
@@ -60,7 +104,17 @@ export class OccupanciesController {
async transferRoom(@Param('id') id: string, @Body() dto: TransferRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.transferRoom(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '换房', targetId: +id, targetType: 'occupancy', detail: `换到房间${dto.newRoomId}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '换房',
targetId: +id,
targetType: 'occupancy',
detail: `换到房间${dto.newRoomId}`,
ipAddress,
userAgent,
});
return result;
}
@@ -69,7 +123,16 @@ export class OccupanciesController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '删除入住记录', targetId: +id, targetType: 'occupancy', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '删除入住记录',
targetId: +id,
targetType: 'occupancy',
ipAddress,
userAgent,
});
return result;
}
@@ -78,7 +141,15 @@ export class OccupanciesController {
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量删除入住记录', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '批量删除入住记录',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@@ -122,7 +193,10 @@ export class OccupanciesController {
checkOutReason: r.checkOutReason || '',
});
}
res!.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res!.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res!.setHeader('Content-Disposition', 'attachment; filename=occupancies.xlsx');
await workbook.xlsx.write(res!);
res!.end();
@@ -151,8 +225,36 @@ export class OccupanciesController {
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
// 添加说明行
ws.addRow({ roomNumber: '4-102', bedNumber: 1, name: '张三', gender: '男', ethnicity: '汉族', phone: '13800138000', idNumber: '2024001', checkInDate: '2026-04-21', checkOutDate: '', emergencyContact: '张父', emergencyPhone: '13900000000', organization: '', supervisor: '' });
ws.addRow({ roomNumber: '4-102', bedNumber: 2, name: '李四', gender: '男', ethnicity: '汉族', phone: '13800138001', idNumber: '2024002', checkInDate: '2026-04-21', checkOutDate: '', emergencyContact: '', emergencyPhone: '', organization: 'XXX教育科技', supervisor: '王老师' });
ws.addRow({
roomNumber: '4-102',
bedNumber: 1,
name: '张三',
gender: '男',
ethnicity: '汉族',
phone: '13800138000',
idNumber: '2024001',
checkInDate: '2026-04-21',
checkOutDate: '',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
supervisor: '',
});
ws.addRow({
roomNumber: '4-102',
bedNumber: 2,
name: '李四',
gender: '男',
ethnicity: '汉族',
phone: '13800138001',
idNumber: '2024002',
checkInDate: '2026-04-21',
checkOutDate: '',
emergencyContact: '',
emergencyPhone: '',
organization: 'XXX教育科技',
supervisor: '王老师',
});
// 添加使用说明sheet
const helpWs = workbook.addWorksheet('使用说明');
helpWs.getColumn(1).width = 60;
@@ -166,7 +268,10 @@ export class OccupanciesController {
helpWs.addRow(['7. 性别约束:同一宿舍只能住同性别学生,首位入住者确定宿舍性别']);
helpWs.addRow(['8. 床位号仅做标识参考,不影响入住逻辑']);
helpWs.getRow(1).font = { bold: true, size: 14 };
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=checkin_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -175,7 +280,12 @@ export class OccupanciesController {
@Post('import')
@RequirePermission('occupancy:checkin')
@UseInterceptors(FileInterceptor('file'))
async importCheckIn(@UploadedFile() file: Express.Multer.File, @Request() req: any, @Query('autoDeposit') autoDeposit?: string, @Query('depositAmount') depositAmount?: string) {
async importCheckIn(
@UploadedFile() file: Express.Multer.File,
@Request() req: any,
@Query('autoDeposit') autoDeposit?: string,
@Query('depositAmount') depositAmount?: string,
) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
@@ -225,7 +335,15 @@ export class OccupanciesController {
autoDeposit: autoDeposit === 'true',
depositAmount: depositAmount ? +depositAmount : undefined,
});
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '入住', action: '批量导入入住', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '入住',
action: '批量导入入住',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -1,6 +1,14 @@
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository, DataSource, IsNull, Between, LessThanOrEqual, MoreThanOrEqual, In } from 'typeorm';
import {
Repository,
DataSource,
IsNull,
Between,
LessThanOrEqual,
MoreThanOrEqual,
In,
} from 'typeorm';
import { Occupancy } from '../entities/occupancy.entity';
import { Room } from '../entities/room.entity';
import { Student } from '../entities/student.entity';
@@ -19,7 +27,8 @@ export class OccupanciesService {
) {}
async findAll(query?: { roomId?: number; studentId?: number; active?: boolean }) {
const qb = this.repo.createQueryBuilder('o')
const qb = this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.leftJoinAndSelect('o.room', 'room')
.orderBy('o.checkInDate', 'DESC');
@@ -31,7 +40,9 @@ export class OccupanciesService {
async checkIn(dto: CheckInDto) {
// 检查学生是否已有活跃入住
const existing = await this.repo.findOne({ where: { studentId: dto.studentId, checkOutDate: IsNull() } });
const existing = await this.repo.findOne({
where: { studentId: dto.studentId, checkOutDate: IsNull() },
});
if (existing) throw new BadRequestException('该学生已有在住记录,请先办理退宿');
// 检查宿舍容量
@@ -44,7 +55,9 @@ export class OccupanciesService {
const student = await this.studentRepo.findOne({ where: { id: dto.studentId } });
if (!student) throw new NotFoundException('学生不存在');
if (student.gender && room.gender && student.gender !== room.gender) {
throw new BadRequestException(`该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`);
throw new BadRequestException(
`该宿舍当前为${room.gender}生寝室,${student.gender}生无法入住`,
);
}
const occ = this.repo.create({
@@ -82,7 +95,9 @@ export class OccupanciesService {
await this.roomRepo.update(occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置房间性别
const remaining = await this.repo.count({ where: { roomId: occ.roomId, checkOutDate: IsNull() } });
const remaining = await this.repo.count({
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await this.roomRepo.update(occ.roomId, { gender: null as any });
}
@@ -106,7 +121,9 @@ export class OccupanciesService {
await runner.manager.save(oldOcc);
await runner.manager.update(Room, oldOcc.roomId, { status: 'available' });
// 旧房如果已无在住人员,重置性别
const oldRemaining = await runner.manager.count(Occupancy, { where: { roomId: oldOcc.roomId, checkOutDate: IsNull() } });
const oldRemaining = await runner.manager.count(Occupancy, {
where: { roomId: oldOcc.roomId, checkOutDate: IsNull() },
});
if (oldRemaining === 0) {
await runner.manager.update(Room, oldOcc.roomId, { gender: null as any });
}
@@ -114,7 +131,9 @@ export class OccupanciesService {
// 检查新房容量
const newRoom = await runner.manager.findOne(Room, { where: { id: dto.newRoomId } });
if (!newRoom) throw new NotFoundException('目标宿舍不存在');
const count = await runner.manager.count(Occupancy, { where: { roomId: dto.newRoomId, checkOutDate: IsNull() } });
const count = await runner.manager.count(Occupancy, {
where: { roomId: dto.newRoomId, checkOutDate: IsNull() },
});
if (count >= newRoom.capacity) throw new BadRequestException('目标宿舍已满');
// 换房性别约束检查
@@ -160,7 +179,8 @@ export class OccupanciesService {
// 获取某宿舍在指定时间段内的入住记录(用于计费)
async getRoomOccupanciesInPeriod(roomId: number, periodStart: string, periodEnd: string) {
return this.repo.createQueryBuilder('o')
return this.repo
.createQueryBuilder('o')
.leftJoinAndSelect('o.student', 'student')
.where('o.roomId = :roomId', { roomId })
.andWhere('o.billingStartDate <= :periodEnd', { periodEnd })
@@ -190,19 +210,26 @@ export class OccupanciesService {
}
let deleted = 0;
if (deletableIds.length > 0) {
const result = await this.repo.createQueryBuilder()
const result = await this.repo
.createQueryBuilder()
.delete()
.where('id IN (:...ids)', { ids: deletableIds })
.execute();
deleted = result.affected || 0;
}
const message = skipped.length > 0
? `成功删除 ${deleted} 条;${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量删除成功,共 ${deleted}`;
const message =
skipped.length > 0
? `成功删除 ${deleted}${skipped.length} 条在住记录被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}),请先办理退宿`
: `批量删除成功,共 ${deleted}`;
return { message, deleted, skipped: skipped.length };
}
async batchCheckOut(dto: { ids: number[]; checkOutDate: string; billingEndDate?: string; checkOutReason?: string }) {
async batchCheckOut(dto: {
ids: number[];
checkOutDate: string;
billingEndDate?: string;
checkOutReason?: string;
}) {
if (!dto.ids || dto.ids.length === 0) {
throw new BadRequestException('请选择要退宿的记录');
}
@@ -213,9 +240,18 @@ export class OccupanciesService {
const errors: string[] = [];
try {
for (const id of dto.ids) {
const occ = await runner.manager.findOne(Occupancy, { where: { id }, relations: ['student'] });
if (!occ) { errors.push(`记录${id}不存在`); continue; }
if (occ.checkOutDate) { errors.push(`${occ.student?.name || id}已退宿`); continue; }
const occ = await runner.manager.findOne(Occupancy, {
where: { id },
relations: ['student'],
});
if (!occ) {
errors.push(`记录${id}不存在`);
continue;
}
if (occ.checkOutDate) {
errors.push(`${occ.student?.name || id}已退宿`);
continue;
}
occ.checkOutDate = dto.checkOutDate;
occ.billingEndDate = dto.billingEndDate || dto.checkOutDate;
occ.checkOutReason = dto.checkOutReason || '';
@@ -223,7 +259,9 @@ export class OccupanciesService {
// 更新房间状态
await runner.manager.update(Room, occ.roomId, { status: 'available' });
// 如果房间已无在住人员,重置性别
const remaining = await runner.manager.count(Occupancy, { where: { roomId: occ.roomId, checkOutDate: IsNull() } });
const remaining = await runner.manager.count(Occupancy, {
where: { roomId: occ.roomId, checkOutDate: IsNull() },
});
if (remaining === 0) {
await runner.manager.update(Room, occ.roomId, { gender: null as any });
}
@@ -236,7 +274,12 @@ export class OccupanciesService {
} finally {
await runner.release();
}
return { success, failed: errors.length, message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`, errors: errors.length > 0 ? errors : undefined };
return {
success,
failed: errors.length,
message: `已成功退宿 ${success}${errors.length > 0 ? `${errors.length} 条失败` : ''}`,
errors: errors.length > 0 ? errors : undefined,
};
}
/**
@@ -244,13 +287,24 @@ export class OccupanciesService {
* 每行数据:姓名、电话、学号、房间号、楼栋、入住日期
* 自动创建不存在的学生和宿舍,并登记入住
*/
async batchImportCheckIn(rows: {
name: string; phone?: string; idNumber?: string;
gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string;
organization?: string; supervisor?: string;
roomNumber: string; building?: string;
checkInDate: string; checkOutDate?: string;
}[], options?: { autoDeposit?: boolean; depositAmount?: number }) {
async batchImportCheckIn(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
roomNumber: string;
building?: string;
checkInDate: string;
checkOutDate?: string;
}[],
options?: { autoDeposit?: boolean; depositAmount?: number },
) {
let imported = 0;
let skipped = 0;
let depositsCreated = 0;
@@ -269,17 +323,19 @@ export class OccupanciesService {
// 1. 查找或创建学生
let student = await this.studentRepo.findOne({ where: { name: row.name.trim() } });
if (!student) {
student = await this.studentRepo.save(this.studentRepo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organization: row.organization?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}));
student = await this.studentRepo.save(
this.studentRepo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender?.trim() || undefined,
ethnicity: row.ethnicity?.trim() || undefined,
emergencyContact: row.emergencyContact?.trim() || undefined,
emergencyPhone: row.emergencyPhone?.trim() || undefined,
organization: row.organization?.trim() || undefined,
supervisor: row.supervisor?.trim() || undefined,
}),
);
} else {
// 更新已有学生的缺失信息
const updates: any = {};
@@ -287,10 +343,14 @@ export class OccupanciesService {
if (!student.idNumber && row.idNumber?.trim()) updates.idNumber = row.idNumber.trim();
if (!student.gender && row.gender?.trim()) updates.gender = row.gender.trim();
if (!student.ethnicity && row.ethnicity?.trim()) updates.ethnicity = row.ethnicity.trim();
if (!student.emergencyContact && row.emergencyContact?.trim()) updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim()) updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organization && row.organization?.trim()) updates.organization = row.organization.trim();
if (!student.supervisor && row.supervisor?.trim()) updates.supervisor = row.supervisor.trim();
if (!student.emergencyContact && row.emergencyContact?.trim())
updates.emergencyContact = row.emergencyContact.trim();
if (!student.emergencyPhone && row.emergencyPhone?.trim())
updates.emergencyPhone = row.emergencyPhone.trim();
if (!student.organization && row.organization?.trim())
updates.organization = row.organization.trim();
if (!student.supervisor && row.supervisor?.trim())
updates.supervisor = row.supervisor.trim();
if (Object.keys(updates).length > 0) {
await this.studentRepo.update(student.id, updates);
Object.assign(student, updates);
@@ -301,19 +361,26 @@ export class OccupanciesService {
let room = await this.roomRepo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (!room) {
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
room = await this.roomRepo.save(this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}));
room = await this.roomRepo.save(
this.roomRepo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: parsed.floor || undefined,
capacity: parsed.capacity || 4,
roomType: parsed.roomType || undefined,
}),
);
}
// 3. 检查是否已有活跃入住
const existing = await this.repo.findOne({ where: { studentId: student.id, checkOutDate: IsNull() }, relations: ['room'] });
const existing = await this.repo.findOne({
where: { studentId: student.id, checkOutDate: IsNull() },
relations: ['room'],
});
if (existing) {
errors.push(`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`);
errors.push(
`${rowNum}行: ${row.name} 已在住(${existing.room?.roomNumber || '房间' + existing.roomId}),跳过`,
);
skipped++;
continue;
}
@@ -321,14 +388,18 @@ export class OccupanciesService {
// 4. 检查宿舍容量
const count = await this.repo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
if (count >= room.capacity) {
errors.push(`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`);
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber} 已满(${count}/${room.capacity}),跳过 ${row.name}`,
);
skipped++;
continue;
}
// 5. 房间级别性别约束
if (student.gender && room.gender && student.gender !== room.gender) {
errors.push(`${rowNum}行: 宿舍 ${row.roomNumber}${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`);
errors.push(
`${rowNum}行: 宿舍 ${row.roomNumber}${room.gender}生寝室,${row.name}(${student.gender})无法入住,跳过`,
);
skipped++;
continue;
}
@@ -361,15 +432,19 @@ export class OccupanciesService {
// 9. 自动收取押金(仅对新入住且非历史记录的学生)
if (options?.autoDeposit && !row.checkOutDate?.trim()) {
const existingDeposit = await this.depositRepo.findOne({ where: { studentId: student.id, status: 'paid' } });
const existingDeposit = await this.depositRepo.findOne({
where: { studentId: student.id, status: 'paid' },
});
if (!existingDeposit) {
await this.depositRepo.save(this.depositRepo.create({
studentId: student.id,
amount: options.depositAmount || 500,
paidDate: checkInDate,
status: 'paid',
notes: '入住导入自动收取',
}));
await this.depositRepo.save(
this.depositRepo.create({
studentId: student.id,
amount: options.depositAmount || 500,
paidDate: checkInDate,
status: 'paid',
notes: '入住导入自动收取',
}),
);
depositsCreated++;
}
}

View File

@@ -5,9 +5,7 @@ import { OperationLog } from '../entities/operation-log.entity';
@Injectable()
export class OperationLogsService {
constructor(
@InjectRepository(OperationLog) private repo: Repository<OperationLog>,
) {}
constructor(@InjectRepository(OperationLog) private repo: Repository<OperationLog>) {}
async log(params: {
userId?: number;
@@ -33,16 +31,20 @@ export class OperationLogsService {
page?: number;
pageSize?: number;
}) {
const qb = this.repo.createQueryBuilder('log')
.orderBy('log.createdAt', 'DESC');
const qb = this.repo.createQueryBuilder('log').orderBy('log.createdAt', 'DESC');
if (query?.module) qb.andWhere('log.module = :module', { module: query.module });
if (query?.userId) qb.andWhere('log.userId = :userId', { userId: query.userId });
if (query?.startDate) qb.andWhere('log.createdAt >= :startDate', { startDate: query.startDate });
if (query?.endDate) qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' });
if (query?.startDate)
qb.andWhere('log.createdAt >= :startDate', { startDate: query.startDate });
if (query?.endDate)
qb.andWhere('log.createdAt <= :endDate', { endDate: query.endDate + ' 23:59:59' });
const page = query?.page || 1;
const pageSize = query?.pageSize || 50;
const [data, total] = await qb.skip((page - 1) * pageSize).take(pageSize).getManyAndCount();
const [data, total] = await qb
.skip((page - 1) * pageSize)
.take(pageSize)
.getManyAndCount();
return { data, total, page, pageSize };
}
}

View File

@@ -1,8 +1,23 @@
import {
Controller, Get, Post, Put, Delete, Body, Param, UseGuards, Request, BadRequestException,
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
UseGuards,
Request,
BadRequestException,
} from '@nestjs/common';
import { RbacService } from './rbac.service';
import { CreateRoleDto, UpdateRoleDto, CreateUserDto, UpdateUserDto, ResetPasswordDto } from './dto/rbac.dto';
import {
CreateRoleDto,
UpdateRoleDto,
CreateUserDto,
UpdateUserDto,
ResetPasswordDto,
} from './dto/rbac.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { RequirePermission } from '../auth/decorators/permission.decorator';
import { OperationLogsService } from '../operation-logs/operation-logs.service';
@@ -35,7 +50,15 @@ export class RbacController {
async createRole(@Body() dto: CreateRoleDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.rbacService.createRole(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '创建角色', detail: `角色: ${dto.name}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: 'RBAC',
action: '创建角色',
detail: `角色: ${dto.name}`,
ipAddress,
userAgent,
});
return result;
}
@@ -45,7 +68,17 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.updateRole(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '编辑角色', targetId: +id, targetType: 'role', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: 'RBAC',
action: '编辑角色',
targetId: +id,
targetType: 'role',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
@@ -58,7 +91,16 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.deleteRole(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: 'RBAC', action: '删除角色', targetId: +id, targetType: 'role', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: 'RBAC',
action: '删除角色',
targetId: +id,
targetType: 'role',
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
@@ -93,7 +135,15 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.createUser(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '创建账号', detail: `用户名: ${dto.username}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账号',
action: '创建账号',
detail: `用户名: ${dto.username}`,
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
@@ -106,7 +156,17 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.updateUser(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '更新账号', targetId: +id, targetType: 'user', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账号',
action: '更新账号',
targetId: +id,
targetType: 'user',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
@@ -119,7 +179,16 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.resetPassword(+id, dto.password);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '重置密码', targetId: +id, targetType: 'user', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账号',
action: '重置密码',
targetId: +id,
targetType: 'user',
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);
@@ -132,7 +201,16 @@ export class RbacController {
const { ipAddress, userAgent } = extractRequestInfo(req);
try {
const result = await this.rbacService.deleteUser(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '账号', action: '删除账号', targetId: +id, targetType: 'user', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '账号',
action: '删除账号',
targetId: +id,
targetType: 'user',
ipAddress,
userAgent,
});
return result;
} catch (e: any) {
throw new BadRequestException(e.message);

View File

@@ -6,10 +6,7 @@ import { RbacController } from './rbac.controller';
import { AuthModule } from '../auth/auth.module';
@Module({
imports: [
TypeOrmModule.forFeature([Permission, Role, User]),
forwardRef(() => AuthModule),
],
imports: [TypeOrmModule.forFeature([Permission, Role, User]), forwardRef(() => AuthModule)],
controllers: [RbacController],
providers: [RbacService],
exports: [RbacService],

View File

@@ -79,7 +79,16 @@ const PRESET_ROLES: Array<{
code: 'dormitory_supervisor',
description: '管理宿舍相关业务',
isSystem: true,
permissionGroups: ['student', 'room', 'occupancy', 'expense', 'bill', 'deposit', 'log', 'dashboard'],
permissionGroups: [
'student',
'room',
'occupancy',
'expense',
'bill',
'deposit',
'log',
'dashboard',
],
},
{
name: '老师',
@@ -216,9 +225,7 @@ export class RbacService {
if (dto.description !== undefined) role.description = dto.description;
if (dto.permissionIds !== undefined) {
role.permissions =
dto.permissionIds.length > 0
? await this.permRepo.findByIds(dto.permissionIds)
: [];
dto.permissionIds.length > 0 ? await this.permRepo.findByIds(dto.permissionIds) : [];
}
return this.roleRepo.save(role);
}
@@ -267,7 +274,7 @@ export class RbacService {
relations: ['roles'],
order: { createdAt: 'DESC' },
});
return users.map(u => ({
return users.map((u) => ({
id: u.id,
username: u.username,
name: u.name,
@@ -275,7 +282,7 @@ export class RbacService {
lastLoginAt: u.lastLoginAt,
createdAt: u.createdAt,
updatedAt: u.updatedAt,
roles: u.roles?.map(r => ({ id: r.id, name: r.name })) || [],
roles: u.roles?.map((r) => ({ id: r.id, name: r.name })) || [],
}));
}
@@ -283,7 +290,11 @@ export class RbacService {
const exists = await this.userRepo.findOne({ where: { username: dto.username } });
if (exists) throw new Error('用户名已存在');
const hash = await bcrypt.hash(dto.password, 10);
const user = this.userRepo.create({ username: dto.username, passwordHash: hash, name: dto.name });
const user = this.userRepo.create({
username: dto.username,
passwordHash: hash,
name: dto.name,
});
if (dto.roleIds && dto.roleIds.length > 0) {
user.roles = await this.roleRepo.findByIds(dto.roleIds);
}
@@ -291,7 +302,10 @@ export class RbacService {
return { message: '用户创建成功' };
}
async updateUser(id: number, dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] }) {
async updateUser(
id: number,
dto: { username?: string; name?: string; isActive?: boolean; roleIds?: number[] },
) {
const user = await this.userRepo.findOne({ where: { id }, relations: ['roles'] });
if (!user) throw new Error('用户不存在');
if (dto.username !== undefined && dto.username !== user.username) {
@@ -302,9 +316,7 @@ export class RbacService {
if (dto.name !== undefined) user.name = dto.name;
if (dto.isActive !== undefined) user.isActive = dto.isActive;
if (dto.roleIds !== undefined) {
user.roles = dto.roleIds.length > 0
? await this.roleRepo.findByIds(dto.roleIds)
: [];
user.roles = dto.roleIds.length > 0 ? await this.roleRepo.findByIds(dto.roleIds) : [];
}
await this.userRepo.save(user);
return { message: '更新成功' };

View File

@@ -1,4 +1,18 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { RoomsService } from './rooms.service';
@@ -12,11 +26,17 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('rooms')
export class RoomsController {
constructor(private service: RoomsService, private logService: OperationLogsService) {}
constructor(
private service: RoomsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('room:view')
findAll(@Query('building') building?: string, @Query('includeArchived') includeArchived?: string) {
findAll(
@Query('building') building?: string,
@Query('includeArchived') includeArchived?: string,
) {
return this.service.findAll({ building, includeArchived: includeArchived === 'true' });
}
@@ -46,9 +66,24 @@ export class RoomsController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ roomNumber: '4-102', building: '4号楼', floor: 1, capacity: 4, roomType: '四人间' });
ws.addRow({ roomNumber: '2-201', building: '2号楼', floor: 2, capacity: 1, roomType: '单人间' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ws.addRow({
roomNumber: '4-102',
building: '4号楼',
floor: 1,
capacity: 4,
roomType: '四人间',
});
ws.addRow({
roomNumber: '2-201',
building: '2号楼',
floor: 2,
capacity: 1,
roomType: '单人间',
});
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=room_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -57,7 +92,9 @@ export class RoomsController {
@Get('export')
@RequirePermission('room:view')
async exportExcel(@Query('includeArchived') includeArchived?: string, @Res() res?: Response) {
const rooms = await this.service.getRoomOverview({ includeArchived: includeArchived === 'true' });
const rooms = await this.service.getRoomOverview({
includeArchived: includeArchived === 'true',
});
const workbook = new ExcelJS.Workbook();
const ws = workbook.addWorksheet('宿舍列表');
ws.columns = [
@@ -72,11 +109,28 @@ export class RoomsController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { available: '可入住', full: '已满', maintenance: '维修中', archived: '已归档' };
const statusMap: Record<string, string> = {
available: '可入住',
full: '已满',
maintenance: '维修中',
archived: '已归档',
};
for (const r of rooms) {
ws.addRow({ roomNumber: r.roomNumber, building: r.building || '', floor: r.floor || '', roomType: r.roomType || '', capacity: r.capacity, currentCount: r.currentCount, gender: r.gender || '', status: statusMap[r.status] || r.status });
ws.addRow({
roomNumber: r.roomNumber,
building: r.building || '',
floor: r.floor || '',
roomType: r.roomType || '',
capacity: r.capacity,
currentCount: r.currentCount,
gender: r.gender || '',
status: statusMap[r.status] || r.status,
});
}
res!.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res!.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res!.setHeader('Content-Disposition', 'attachment; filename=rooms.xlsx');
await workbook.xlsx.write(res!);
res!.end();
@@ -93,7 +147,15 @@ export class RoomsController {
async create(@Body() dto: CreateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '添加宿舍', detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '添加宿舍',
detail: `房间号: ${dto.roomNumber}, 楼栋: ${dto.building || '无'}, 额定: ${dto.capacity}`,
ipAddress,
userAgent,
});
return result;
}
@@ -102,7 +164,17 @@ export class RoomsController {
async update(@Param('id') id: string, @Body() dto: UpdateRoomDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '编辑宿舍', targetId: +id, targetType: 'room', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '编辑宿舍',
targetId: +id,
targetType: 'room',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -111,7 +183,16 @@ export class RoomsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '归档宿舍', targetId: +id, targetType: 'room', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '归档宿舍',
targetId: +id,
targetType: 'room',
ipAddress,
userAgent,
});
return result;
}
@@ -120,7 +201,15 @@ export class RoomsController {
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '批量归档宿舍', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '批量归档宿舍',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@@ -129,7 +218,16 @@ export class RoomsController {
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '恢复宿舍', targetId: +id, targetType: 'room', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '恢复宿舍',
targetId: +id,
targetType: 'room',
ipAddress,
userAgent,
});
return result;
}
@@ -141,7 +239,13 @@ export class RoomsController {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
const ws = workbook.worksheets[0];
const rows: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string }[] = [];
const rows: {
roomNumber: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
@@ -153,7 +257,15 @@ export class RoomsController {
});
});
const result = await this.service.batchImport(rows);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '宿舍', action: '批量导入', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '宿舍',
action: '批量导入',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -21,7 +21,12 @@ export class RoomsService {
* "3-301" → building:"3号楼", floor:3, roomType:"四人间"
* "8-102" → building:"8号楼", floor:1, roomType:"爆改房"
*/
static parseRoomNumber(roomNumber: string): { building?: string; floor?: number; roomType?: string; capacity?: number } {
static parseRoomNumber(roomNumber: string): {
building?: string;
floor?: number;
roomType?: string;
capacity?: number;
} {
const cleaned = roomNumber.replace(/[(].*?[)]/g, '').trim();
// 家庭房: X-Y-ZZZ 格式
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
@@ -36,12 +41,18 @@ export class RoomsService {
if (stdMatch) {
const bldgNum = stdMatch[1];
const roomPart = stdMatch[2];
const floor = roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
const floor =
roomPart.length >= 3 ? parseInt(roomPart.charAt(0), 10) : parseInt(roomPart.charAt(0), 10);
const building = `${bldgNum}号楼`;
let roomType = '四人间';
let capacity = 4;
if (bldgNum === '2') { roomType = '单人间'; capacity = 1; }
else if (bldgNum === '8') { roomType = '爆改房'; capacity = 2; }
if (bldgNum === '2') {
roomType = '单人间';
capacity = 1;
} else if (bldgNum === '8') {
roomType = '爆改房';
capacity = 2;
}
return { building, floor, roomType, capacity };
}
return {};
@@ -76,7 +87,9 @@ export class RoomsService {
const rooms = await this.repo.find({ where, order: { building: 'ASC', roomNumber: 'ASC' } });
const result: any[] = [];
for (const room of rooms) {
const count = await this.occRepo.count({ where: { roomId: room.id, checkOutDate: IsNull() } });
const count = await this.occRepo.count({
where: { roomId: room.id, checkOutDate: IsNull() },
});
result.push({ ...room, currentCount: count });
}
return result;
@@ -113,7 +126,9 @@ export class RoomsService {
skipped.push(`${r.roomNumber}(已归档)`);
continue;
}
const activeCount = await this.occRepo.count({ where: { roomId: r.id, checkOutDate: IsNull() } });
const activeCount = await this.occRepo.count({
where: { roomId: r.id, checkOutDate: IsNull() },
});
if (activeCount > 0) {
skipped.push(`${r.roomNumber}(有在住人员)`);
continue;
@@ -122,16 +137,18 @@ export class RoomsService {
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo.createQueryBuilder()
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message = skipped.length > 0
? `成功归档 ${affected} 间;${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected}宿舍(数据已保留,可随时恢复`;
const message =
skipped.length > 0
? `成功归档 ${affected}${skipped.length} 间被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 间宿舍(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
@@ -143,7 +160,10 @@ export class RoomsService {
}
async getRoomVisual() {
const rooms = await this.repo.find({ where: { status: Not('archived') }, order: { building: 'ASC', roomNumber: 'ASC' } });
const rooms = await this.repo.find({
where: { status: Not('archived') },
order: { building: 'ASC', roomNumber: 'ASC' },
});
const occupancies = await this.occRepo.find({
where: { checkOutDate: IsNull() },
relations: ['student'],
@@ -156,7 +176,10 @@ export class RoomsService {
if (!occMap.has(occ.roomId)) occMap.set(occ.roomId, []);
const now = new Date();
const checkIn = new Date(occ.checkInDate);
const days = Math.max(1, Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)));
const days = Math.max(
1,
Math.ceil((now.getTime() - checkIn.getTime()) / (1000 * 60 * 60 * 24)),
);
occMap.get(occ.roomId)!.push({
studentId: occ.studentId,
studentName: occ.student?.name || '未知',
@@ -201,24 +224,44 @@ export class RoomsService {
};
}
async batchImport(rows: { roomNumber: string; building?: string; floor?: number; capacity?: number; roomType?: string }[]) {
async batchImport(
rows: {
roomNumber: string;
building?: string;
floor?: number;
capacity?: number;
roomType?: string;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.roomNumber || !row.roomNumber.trim()) { skipped++; continue; }
if (!row.roomNumber || !row.roomNumber.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { roomNumber: row.roomNumber.trim() } });
if (exists) { skipped++; continue; }
if (exists) {
skipped++;
continue;
}
// 智能解析房间号
const parsed = RoomsService.parseRoomNumber(row.roomNumber.trim());
await this.repo.save(this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor || parsed.floor || undefined,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
}));
await this.repo.save(
this.repo.create({
roomNumber: row.roomNumber.trim(),
building: row.building?.trim() || parsed.building || undefined,
floor: row.floor || parsed.floor || undefined,
capacity: row.capacity || parsed.capacity || 4,
roomType: row.roomType || parsed.roomType || undefined,
}),
);
imported++;
}
return { message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
return {
message: `成功导入 ${imported} 间宿舍,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
}

View File

@@ -1,4 +1,18 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request, Res, UseInterceptors, UploadedFile } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
Res,
UseInterceptors,
UploadedFile,
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import type { Response } from 'express';
import { StudentsService } from './students.service';
@@ -12,11 +26,18 @@ import * as ExcelJS from 'exceljs';
@UseGuards(JwtAuthGuard)
@Controller('students')
export class StudentsController {
constructor(private service: StudentsService, private logService: OperationLogsService) {}
constructor(
private service: StudentsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('student:view')
findAll(@Query('name') name?: string, @Query('status') status?: string, @Query('includeArchived') includeArchived?: string) {
findAll(
@Query('name') name?: string,
@Query('status') status?: string,
@Query('includeArchived') includeArchived?: string,
) {
return this.service.findAll({ name, status, includeArchived: includeArchived === 'true' });
}
@@ -40,11 +61,30 @@ export class StudentsController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
const statusMap: Record<string, string> = { active: '在读', graduated: '已毕业', withdrawn: '已退训', archived: '已归档' };
const statusMap: Record<string, string> = {
active: '在读',
graduated: '已毕业',
withdrawn: '已退训',
archived: '已归档',
};
for (const s of students) {
ws.addRow({ name: s.name, gender: s.gender || '', phone: s.phone || '', idNumber: s.idNumber || '', ethnicity: s.ethnicity || '', emergencyContact: s.emergencyContact || '', emergencyPhone: s.emergencyPhone || '', organization: s.organization || '', supervisor: s.supervisor || '', status: statusMap[s.status] || s.status });
ws.addRow({
name: s.name,
gender: s.gender || '',
phone: s.phone || '',
idNumber: s.idNumber || '',
ethnicity: s.ethnicity || '',
emergencyContact: s.emergencyContact || '',
emergencyPhone: s.emergencyPhone || '',
organization: s.organization || '',
supervisor: s.supervisor || '',
status: statusMap[s.status] || s.status,
});
}
res!.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
res!.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res!.setHeader('Content-Disposition', 'attachment; filename=students.xlsx');
await workbook.xlsx.write(res!);
res!.end();
@@ -68,8 +108,21 @@ export class StudentsController {
];
ws.getRow(1).font = { bold: true };
ws.getRow(1).fill = { type: 'pattern', pattern: 'solid', fgColor: { argb: 'FFE0E0E0' } };
ws.addRow({ name: '张三', phone: '13800138000', idNumber: '2024001', gender: '男', ethnicity: '汉族', emergencyContact: '张父', emergencyPhone: '13900000000', organization: '', supervisor: '' });
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
ws.addRow({
name: '张三',
phone: '13800138000',
idNumber: '2024001',
gender: '男',
ethnicity: '汉族',
emergencyContact: '张父',
emergencyPhone: '13900000000',
organization: '',
supervisor: '',
});
res.setHeader(
'Content-Type',
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
);
res.setHeader('Content-Disposition', 'attachment; filename=student_template.xlsx');
await workbook.xlsx.write(res);
res.end();
@@ -86,7 +139,17 @@ export class StudentsController {
async create(@Body() dto: CreateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '添加学生', targetId: result.id, targetType: 'student', detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '添加学生',
targetId: result.id,
targetType: 'student',
detail: `姓名: ${dto.name}, 电话: ${dto.phone || '无'}, 学号: ${dto.idNumber || '无'}`,
ipAddress,
userAgent,
});
return result;
}
@@ -95,7 +158,17 @@ export class StudentsController {
async update(@Param('id') id: string, @Body() dto: UpdateStudentDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '编辑学生', targetId: +id, targetType: 'student', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '编辑学生',
targetId: +id,
targetType: 'student',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -104,7 +177,16 @@ export class StudentsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '归档学生', targetId: +id, targetType: 'student', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '归档学生',
targetId: +id,
targetType: 'student',
ipAddress,
userAgent,
});
return result;
}
@@ -113,7 +195,15 @@ export class StudentsController {
async batchRemove(@Body() body: { ids: number[] }, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.batchRemove(body.ids || []);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '批量归档学生', detail: `IDs: ${(body.ids || []).join(',')}`, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '批量归档学生',
detail: `IDs: ${(body.ids || []).join(',')}`,
ipAddress,
userAgent,
});
return result;
}
@@ -122,7 +212,16 @@ export class StudentsController {
async restore(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.restore(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '恢复学生', targetId: +id, targetType: 'student', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '恢复学生',
targetId: +id,
targetType: 'student',
ipAddress,
userAgent,
});
return result;
}
@@ -134,7 +233,17 @@ export class StudentsController {
const workbook = new ExcelJS.Workbook();
await workbook.xlsx.load(file.buffer as any);
const ws = workbook.worksheets[0];
const rows: { name: string; phone?: string; idNumber?: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organization?: string; supervisor?: string }[] = [];
const rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
}[] = [];
ws.eachRow((row, idx) => {
if (idx === 1) return;
rows.push({
@@ -150,7 +259,15 @@ export class StudentsController {
});
});
const result = await this.service.batchImport(rows);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '学生', action: '批量导入', detail: result.message, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '学生',
action: '批量导入',
detail: result.message,
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -20,7 +20,10 @@ export class StudentsService {
}
async findOne(id: number) {
const student = await this.repo.findOne({ where: { id }, relations: ['occupancies', 'occupancies.room'] });
const student = await this.repo.findOne({
where: { id },
relations: ['occupancies', 'occupancies.room'],
});
if (!student) throw new NotFoundException('学生不存在');
return student;
}
@@ -56,16 +59,18 @@ export class StudentsService {
}
let affected = 0;
if (targetIds.length > 0) {
const result = await this.repo.createQueryBuilder()
const result = await this.repo
.createQueryBuilder()
.update()
.set({ status: 'archived' })
.where('id IN (:...ids)', { ids: targetIds })
.execute();
affected = result.affected || 0;
}
const message = skipped.length > 0
? `成功归档 ${affected} 人;${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected}(数据已保留,可随时恢复`;
const message =
skipped.length > 0
? `成功归档 ${affected}${skipped.length} 人已是归档状态被跳过(${skipped.slice(0, 5).join('、')}${skipped.length > 5 ? '…' : ''}`
: `已批量归档 ${affected} 人(数据已保留,可随时恢复)`;
return { message, archived: affected, skipped: skipped.length };
}
@@ -78,26 +83,50 @@ export class StudentsService {
return { message: '已恢复' };
}
async batchImport(rows: { name: string; phone?: string; idNumber?: string; gender?: string; ethnicity?: string; emergencyContact?: string; emergencyPhone?: string; organization?: string; supervisor?: string }[]) {
async batchImport(
rows: {
name: string;
phone?: string;
idNumber?: string;
gender?: string;
ethnicity?: string;
emergencyContact?: string;
emergencyPhone?: string;
organization?: string;
supervisor?: string;
}[],
) {
let imported = 0;
let skipped = 0;
for (const row of rows) {
if (!row.name || !row.name.trim()) { skipped++; continue; }
if (!row.name || !row.name.trim()) {
skipped++;
continue;
}
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
if (exists) { skipped++; continue; }
await this.repo.save(this.repo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
}));
if (exists) {
skipped++;
continue;
}
await this.repo.save(
this.repo.create({
name: row.name.trim(),
phone: row.phone?.trim() || undefined,
idNumber: row.idNumber?.trim() || undefined,
gender: row.gender || undefined,
ethnicity: row.ethnicity || undefined,
emergencyContact: row.emergencyContact || undefined,
emergencyPhone: row.emergencyPhone || undefined,
organization: row.organization || undefined,
supervisor: row.supervisor || undefined,
}),
);
imported++;
}
return { message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`, imported, skipped };
return {
message: `成功导入 ${imported} 名学生,跳过 ${skipped} 条(重复或空行)`,
imported,
skipped,
};
}
}

View File

@@ -1,4 +1,15 @@
import { Controller, Get, Post, Put, Delete, Body, Param, Query, UseGuards, Request } from '@nestjs/common';
import {
Controller,
Get,
Post,
Put,
Delete,
Body,
Param,
Query,
UseGuards,
Request,
} from '@nestjs/common';
import { TenantsService } from './tenants.service';
import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
@@ -9,7 +20,10 @@ import { RequirePermission } from '../auth/decorators/permission.decorator';
@UseGuards(JwtAuthGuard)
@Controller('tenants')
export class TenantsController {
constructor(private service: TenantsService, private logService: OperationLogsService) {}
constructor(
private service: TenantsService,
private logService: OperationLogsService,
) {}
@Get()
@RequirePermission('tenant:view')
@@ -28,7 +42,17 @@ export class TenantsController {
async create(@Body() dto: CreateTenantDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.create(dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '租赁方', action: '新增租赁方', targetId: result.id, targetType: 'tenant', detail: dto.name, ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '新增租赁方',
targetId: result.id,
targetType: 'tenant',
detail: dto.name,
ipAddress,
userAgent,
});
return result;
}
@@ -37,7 +61,17 @@ export class TenantsController {
async update(@Param('id') id: string, @Body() dto: UpdateTenantDto, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.update(+id, dto);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '租赁方', action: '编辑租赁方', targetId: +id, targetType: 'tenant', detail: JSON.stringify(dto), ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '编辑租赁方',
targetId: +id,
targetType: 'tenant',
detail: JSON.stringify(dto),
ipAddress,
userAgent,
});
return result;
}
@@ -46,7 +80,16 @@ export class TenantsController {
async remove(@Param('id') id: string, @Request() req: any) {
const { ipAddress, userAgent } = extractRequestInfo(req);
const result = await this.service.remove(+id);
await this.logService.log({ userId: req.user?.id, username: req.user?.username, module: '租赁方', action: '归档租赁方', targetId: +id, targetType: 'tenant', ipAddress, userAgent });
await this.logService.log({
userId: req.user?.id,
username: req.user?.username,
module: '租赁方',
action: '归档租赁方',
targetId: +id,
targetType: 'tenant',
ipAddress,
userAgent,
});
return result;
}
}

View File

@@ -6,8 +6,16 @@ import { CreateTenantDto, UpdateTenantDto } from './dto/tenant.dto';
// 预设色板(避开红绿盲敏感色,保证差异度)
const COLOR_PALETTE = [
'#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9',
'#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c',
'#ff7875',
'#ffa940',
'#ffc53d',
'#73d13d',
'#36cfc9',
'#40a9ff',
'#597ef7',
'#9254de',
'#f759ab',
'#8c8c8c',
];
@Injectable()

View File

@@ -17,10 +17,7 @@ describe('AppController (e2e)', () => {
});
it('/ (GET)', () => {
return request(app.getHttpServer())
.get('/')
.expect(200)
.expect('Hello World!');
return request(app.getHttpServer()).get('/').expect(200).expect('Hello World!');
});
afterEach(async () => {

View File

@@ -0,0 +1,116 @@
---
comet_change: migrate-to-turborepo
role: technical-design
canonical_spec: openspec
---
# Migrate to Turborepo Monorepo — Technical Design
## Architecture Overview
```
gongxue-base/
├── apps/
│ ├── server/ # ← git mv backend → apps/server (NestJS API)
│ ├── admin/ # ← git mv frontend → apps/admin (React + Vite)
│ └── student/ # 未来:学生端
├── packages/
│ └── typescript-config/ # 共享 TS 配置base / nestjs / react-vite
├── package.json # npm workspaces + turbo 根脚本
├── turbo.json # Turborepo 流水线
├── .oxfmtrc.json # oxfmt 全局配置
├── oxlint.config.ts # oxlint 全局配置admin 前端)
└── docker-compose.yml # 调整 build.context 路径
```
## Key Decisions
### 1. File Migration: `git mv`
`node_modules/` 从未被 Git 追踪,因此 `git mv backend apps/server``git mv frontend apps/admin` 可以干净执行,完整保留文件历史。
### 2. Package Manager: npm workspaces
用户指定沿用 npm。根 `package.json` 声明 `workspaces: ["apps/*", "packages/*"]`,所有根脚本委托 `turbo run` 执行。
### 3. Build Orchestration: Turborepo
`turbo.json` 定义六条流水线:
| Task | 配置 |
|------|------|
| `build` | `dependsOn: ["^build"]`, outputs: `dist/**` |
| `dev` | `cache: false`, `persistent: true` |
| `lint` | 无特殊配置,各 workspace 自行定义工具 |
| `test` | 无特殊配置 |
| `format` | `cache: false` (oxfmt) |
| `typecheck` | `dependsOn: ["^build"]` |
### 4. Toolchain: oxfmt + oxlint (mixed)
- **oxfmt**:根目录 `.oxfmtrc.json`,映射 Prettier 配置 `{ singleQuote: true, trailingComma: "all" }`
- **oxlint**:仅用于 `apps/admin`,覆盖 TypeScript + React 规则。`exhaustive-deps``react-refresh` 由 TypeScript compiler + code review 兜底
- **ESLint**`apps/server` 保留,移除 Prettier 集成。保障 NestJS 装饰器类型检查
所有 workspace 统一使用 `lint` 脚本名Turborepo 在 `turbo run lint` 时并行调度。
### 5. Shared TypeScript Config
`@gongxue/typescript-config` 包提供三个预设:
| 预设 | 继承 | 用途 |
|------|------|------|
| `base.json` | — | 通用选项ES2023、strictNullChecks、skipLibCheck |
| `nestjs.json` | base | NestJSnodenext module、experimentalDecorators、declaration |
| `react-vite.json` | base | Vite + Reactbundler resolution、jsx: react-jsx、noEmit |
TypeScript 版本统一为 `~6.0.2`(从 server 5.7→6.0 和 admin 6.0 对齐)。
### 6. Docker Compose
容器名 `dorm_billing_backend` / `dorm_billing_frontend` 保持不变,仅调整 `build.context`
- `backend: build: ./apps/server`
- `frontend: build: ./apps/admin`
## Data Flow
```
npm run dev (根)
turbo run dev
├──▶ apps/server (NestJS, :3003) ← ESLint, @gongxue/typescript-config/nestjs
└──▶ apps/admin (Vite, :3002) ← oxlint, @gongxue/typescript-config/react-vite
▼ /api proxy → localhost:3003
```
## Testing Strategy
### Core Verification Chain
1. `npm install` — 所有 workspace 依赖正确安装hoisting 无冲突
2. `npm run build` — server + admin 构建通过
3. `npm run lint` — server ESLint + admin oxlint 通过
4. `npm run format -- --check` — oxfmt 格式化检查通过
5. `npm run test --workspace=apps/server` — NestJS Jest 测试通过
### Runtime Verification
- `npm run dev` → server :3003 + admin :3002 并行启动
- admin Vite 代理 `/api``localhost:3003` 正常工作
### Docker Verification
- `docker compose build` — 所有镜像构建成功
- `docker compose up` — 所有服务启动并响应
## Risks & Mitigations
| Risk | Impact | Mitigation |
|------|--------|------------|
| `git mv` 后路径引用失效 | docker-compose、tsconfig extends、scripts | 逐文件验证,每步 commit |
| oxlint 规则覆盖不全 | `exhaustive-deps``react-refresh` 缺失 | TS compiler + code review 兜底,明确文档化 |
| TS 6.x + NestJS 生态兼容 | ts-jest、ts-node 可能报错 | 迁移后立即验证 build + test |
| npm hoisting 行为变化 | 子项目可能拿到不兼容版本 | 重新 install 后逐 workspace 验证 |

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-07-02

View File

@@ -0,0 +1,112 @@
## Context
当前项目为双应用平面结构:`backend/`NestJS + TypeORM`frontend/`React 19 + Vite通过 `docker-compose.yml` 编排部署。根 `package.json` 仅包含两个 `cd` 子目录的脚本,无统一构建编排能力。
随着业务扩展规划(学生端 `apps/student/` 等新应用),当前结构面临以下问题:
- 无共享配置机制,前后端各自维护 ESLint/TypeScript 配置
- 无构建缓存和并行能力CI 时间随应用增加线性增长
- 新增应用无标准目录约定,结构逐渐混乱
## Goals / Non-Goals
**Goals:**
- 建立标准 Turborepo monorepo 目录结构apps/ + packages/
- 引入 Turborepo 构建流水线与缓存,支持并行构建
- npm workspaces 统一依赖管理
- 抽取共享 TypeScript 配置到独立 package
- 代码格式化迁移到 oxfmt统一
- 前端 Linting 迁移到 oxlint更快后端保留 ESLint保障 NestJS 覆盖)
- Docker Compose 构建上下文适配新目录结构
**Non-Goals:**
- 不拆分后端微服务
- 不引入 pnpm/yarn 包管理器
- 不重构业务代码逻辑src/ 零变更)
- 不修改 Docker Compose 服务编排逻辑
- 暂不抽取 shared-types 包
## Decisions
### 1. 选择 Turborepo 而非 Nx/Lerna
| 维度 | Turborepo | Nx | Lerna |
|------|-----------|-----|-------|
| 构建缓存 | ✅ 内置(内容哈希) | ✅ 内置 | ⚠️ 需插件 |
| 并行执行 | ✅ 自动拓扑排序 | ✅ 自动 | ⚠️ 手动配置 |
| 配置复杂度 | 低(单一 turbo.json | 中nx.json + project.json | 低 |
| 生态 | Vercel 维护,与 Vite 天然契合 | 功能最强但重 | 社区为主 |
**选择 Turborepo**:项目前端使用 Vite同属 Vercel 生态),配置简洁,满足当前规模需求且不过度设计。
### 2. 选择 npm workspaces用户指定
用户要求沿用 npm。npm workspaces 自 v8+ 已成熟,支持 `--workspace` 标志,与 Turborepo 完全兼容。
### 3. 目录结构选择apps/ + packages/
采用 Turborepo 官方推荐结构:
```
gongxue-base/
├── apps/
│ ├── server/ # ← 当前 backend/ 移入API 服务)
│ ├── admin/ # ← 当前 frontend/ 移入(管理后台)
│ └── student/ # 未来:学生端
├── packages/
│ └── typescript-config/ # 共享 TS 配置
├── package.json # workspaces 声明 + 根脚本
├── turbo.json # Turborepo 流水线
├── .oxfmtrc.json # oxfmt 全局配置
├── oxlint.config.ts # oxlint 全局配置
└── docker-compose.yml # 调整构建 context
```
### 4. oxlint / oxfmt 工具链策略
**oxfmt**:统一替换 Prettier。oxfmt 与 Prettier 格式输出高度兼容,配置项映射简单。`.oxfmtrc.json` 放在根目录。
**oxlint**:采用混合策略:
- **前端apps/admin**:全面切换 oxlint。现有规则映射
- `typescript-eslint` → oxlint 内置 TypeScript 规则 ✅
- `react-hooks/rules-of-hooks` → oxlint 内置 ✅
- `react-hooks/exhaustive-deps` → ⚠️ oxlint 不支持,在 oxlint.config.ts 中禁用对应检查,接受 IDE 级补充
- `react-refresh` → ❌ 无等效规则,损失较小(仅 HMR 时的 export 检查)
- **后端apps/server**:保留 ESLint。NestJS 特有的装饰器类型检查和依赖注入规则(`@typescript-eslint/no-unsafe-*` 系列)在 oxlint 中无等效覆盖,贸然切换风险较高。
### 5. 共享 TypeScript 配置设计
`packages/typescript-config/` 提供三个预设:
- `base.json` — 通用 compilerOptions`strictNullChecks``skipLibCheck``forceConsistentCasingInFileNames``esModuleInterop` 等)
- `nestjs.json` — 继承 base + NestJS 特有(`experimentalDecorators``emitDecoratorMetadata``declaration`
- `react-vite.json` — 继承 base + 前端特有(`jsx: react-jsx``moduleResolution: bundler`
### 6. Turbo 流水线设计
```json
{
"tasks": {
"build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]
},
"dev": {
"cache": false,
"persistent": true
},
"lint": { "dependsOn": ["^build"] },
"test": { "dependsOn": ["build"] },
"format": { "cache": false },
"typecheck": { "dependsOn": ["^build"] }
}
}
```
## Risks / Trade-offs
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 目录迁移导致所有路径引用失效 | import 路径、Dockerfile、CI 脚本全部需更新 | tasks 中拆分独立的"目录迁移"步骤,逐项验证 |
| oxlint 覆盖不足导致部分规则缺失 | `exhaustive-deps``react-refresh` 规则丢失 | 明确记录缺失规则,依赖 TypeScript compiler 和 code review 兜底 |
| npm workspaces hoisting 行为变化 | 子项目可能访问到 hoisted 的不兼容版本 | Turborepo 严格模式下按 workspace 隔离;迁移后全量测试 |
| Docker 构建 context 路径变更 | `docker-compose.yml``build: ./backend` 需改为 `build: ./apps/server` | 迁移后 `docker compose build` 验证 |
| 根 node_modules 现有依赖冲突 | `@fission-ai/openspec` 与子项目依赖可能 hoisting 冲突 | openspec 保留在根,子项目依赖在各自 workspace 中声明 |

View File

@@ -0,0 +1,31 @@
## Why
当前项目采用平面目录结构(`backend/` + `frontend/`),根 `package.json` 仅用 `cd` 脚本串联两个子项目。规划中将新增学生端(`student`)等应用,现有结构无法支持统一构建编排、共享配置和依赖管理,项目工程化能力成为扩张瓶颈。需要建立标准 monorepo 体系,为多应用并行开发奠定基础。
## What Changes
- **BREAKING**:目录重组 — `backend/``apps/server/`API 服务),`frontend/``apps/admin/`(管理后台),预留 `apps/student/`(学生端)
- 引入 Turborepo 构建编排,定义 `turbo.json` 流水线
-`package.json` 改造为 npm workspaces 声明
- 抽取共享 TypeScript 配置到 `packages/typescript-config/`
- 代码格式化从 Prettier 迁移到 oxfmt
- 前端 Linting 从 ESLint 迁移到 oxlint后端保留 ESLint保障 NestJS 规则覆盖)
- Docker Compose 构建上下文路径调整
- 新增统一的根级脚本:`dev``build``lint``format`
## Capabilities
### New Capabilities
- `monorepo-structure`: npm workspaces 驱动的 monorepo 目录结构apps/ + packages/
- `turborepo-pipeline`: Turborepo 构建流水线与缓存
- `oxlint-oxfmt-toolchain`: 基于 oxlint + oxfmt 的代码质量工具链(前端 oxlint后端保留 ESLint
### Modified Capabilities
<!-- 本次无现有 spec 需要修改 -->
## Impact
- **目录结构**:所有顶层 `backend/``frontend/` 路径引用需更新
- **配置文件**:根 `package.json``tsconfig`(新增共享包)、`.gitignore`、Dockerfiles、`docker-compose.yml`
- **开发流程**开发、构建、lint、format 命令从子目录变更到根目录执行
- **无业务代码变更**`src/` 下所有业务逻辑保持不变

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: Monorepo directory structure
The project SHALL adopt the Turborepo-recommended directory structure with `apps/` for applications and `packages/` for shared libraries.
#### Scenario: Directory layout exists
- **WHEN** a developer clones the repository
- **THEN** the root directory contains `apps/server/`, `apps/admin/`, and `packages/typescript-config/` as npm workspace packages
#### Scenario: Legacy paths removed
- **WHEN** the migration is complete
- **THEN** top-level `backend/` (moved to `apps/server/`) and `frontend/` (moved to `apps/admin/`) directories no longer exist
### Requirement: npm workspaces configuration
The root `package.json` SHALL declare `workspaces` field listing all app and package directories, enabling unified dependency management via npm.
#### Scenario: Install from root
- **WHEN** `npm install` is run at the project root
- **THEN** dependencies for all workspaces are installed and hoisted to root `node_modules/`
#### Scenario: Workspace-scoped scripts
- **WHEN** `npm run test --workspace=apps/server` is executed
- **THEN** only the backend test suite runs
### Requirement: Shared TypeScript configuration
The project SHALL provide shared TypeScript configuration presets via `packages/typescript-config/`, including `base.json`, `nestjs.json`, and `react-vite.json`.
#### Scenario: Server inherits NestJS preset
- **WHEN** `apps/server/tsconfig.json` is read
- **THEN** it extends `@gongxue/typescript-config/nestjs.json`
#### Scenario: Admin inherits React preset
- **WHEN** `apps/admin/tsconfig.json` is read
- **THEN** it extends `@gongxue/typescript-config/react-vite.json`
### Requirement: Docker Compose path compatibility
The `docker-compose.yml` SHALL reference build contexts using the new `apps/` paths, and all services MUST build and start successfully.
#### Scenario: Docker compose build succeeds
- **WHEN** `docker compose build` is executed
- **THEN** server and admin images build without errors
#### Scenario: Docker compose up succeeds
- **WHEN** `docker compose up` is executed
- **THEN** all services (MySQL, server, admin) start and respond to requests

View File

@@ -0,0 +1,45 @@
## ADDED Requirements
### Requirement: oxfmt replaces Prettier
The project SHALL use oxfmt for all code formatting, with a root `.oxfmtrc.json` configuration that replicates the existing Prettier conventions.
#### Scenario: Format check passes
- **WHEN** `npm run format` is executed at root
- **THEN** all TypeScript/JavaScript/JSON source files are formatted according to `.oxfmtrc.json` rules
#### Scenario: CI format gate
- **WHEN** `npm run format -- --check` is executed in CI
- **THEN** it exits with non-zero code if any file is not formatted correctly
### Requirement: Frontend oxlint replaces ESLint
The admin application (`apps/admin/`) SHALL use oxlint for linting, with a configuration that covers TypeScript and React rules equivalent to the existing ESLint setup.
#### Scenario: Admin lint passes
- **WHEN** `npm run lint` is executed at root
- **THEN** admin source files are linted with oxlint and pass without errors
#### Scenario: Rules-of-hooks violations detected
- **WHEN** a React hook is called conditionally in admin source
- **THEN** oxlint reports a rules-of-hooks violation
### Requirement: Backend retains ESLint
The server application (`apps/server/`) SHALL retain its existing ESLint configuration due to NestJS-specific rules that oxlint does not support.
#### Scenario: Server lint passes
- **WHEN** `npm run lint` is executed at root
- **THEN** server source files are linted with ESLint and pass without errors
#### Scenario: NestJS decorator checks work
- **WHEN** ESLint runs on server source
- **THEN** `@typescript-eslint/no-unsafe-*` rules and NestJS-specific patterns are enforced
### Requirement: Pre-existing Prettier/ESLint cleanup
All Prettier configuration files (`.prettierrc`, `eslint-plugin-prettier` references) SHALL be removed, and ESLint configurations SHALL be updated to remove Prettier integration.
#### Scenario: No Prettier remnants
- **WHEN** the migration is complete
- **THEN** `grep -r "prettier"` across config files returns no results (excluding oxfmt config which is separate)
#### Scenario: No Prettier dependencies
- **WHEN** `npm ls prettier eslint-plugin-prettier eslint-config-prettier` is run
- **THEN** no Prettier-related packages are installed in any workspace

View File

@@ -0,0 +1,38 @@
## ADDED Requirements
### Requirement: Turbo pipeline configuration
The project SHALL define a `turbo.json` at the repository root that configures build, dev, lint, test, format, and typecheck pipelines with appropriate caching and dependency ordering.
#### Scenario: Build pipeline with caching
- **WHEN** `turbo run build` is executed twice without source changes
- **THEN** the second run uses cached outputs and completes with "FULL TURBO" status
#### Scenario: Topological build ordering
- **WHEN** `turbo run build` is executed
- **THEN** packages (shared configs, types) build before apps that depend on them
#### Scenario: Parallel execution
- **WHEN** `turbo run lint` is executed
- **THEN** server and admin linting run in parallel where dependency graph allows
### Requirement: Unified root scripts
The root `package.json` SHALL provide top-level scripts (`dev`, `build`, `lint`, `format`, `test`, `typecheck`) that delegate to Turborepo or workspace-level commands.
#### Scenario: Dev mode starts all apps
- **WHEN** `npm run dev` is executed at root
- **THEN** both server (NestJS on port 3003) and admin (Vite on port 3002) start in dev mode
#### Scenario: Build produces all outputs
- **WHEN** `npm run build` is executed at root
- **THEN** server `dist/` and admin `dist/` are produced
### Requirement: Independent workspace scripts
Each workspace SHALL retain the ability to run its own scripts independently (e.g., `npm run test` inside `apps/server/`).
#### Scenario: Server tests run independently
- **WHEN** `npm run test` is executed inside `apps/server/`
- **THEN** the NestJS Jest test suite runs and reports results
#### Scenario: Admin dev runs independently
- **WHEN** `npm run dev` is executed inside `apps/admin/`
- **THEN** the Vite dev server starts on port 3002 with API proxy configured