chore: commit oxfmt formatting changes and verify artifacts
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}</>;
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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="如:房间损坏赔偿" />
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user