feat(task1): restructure directories for turborepo monorepo

- Move backend/ to apps/server/ via git mv
- Move frontend/ to apps/admin/ via git mv
- Create packages/typescript-config/ with base, nestjs, and react-vite presets
This commit is contained in:
2026-07-02 15:05:12 +08:00
parent 4704adcba1
commit 46a817503e
137 changed files with 52 additions and 0 deletions

24
apps/admin/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

12
apps/admin/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

73
apps/admin/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
apps/admin/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>恭学教育基地管理系统</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

17
apps/admin/nginx.conf Normal file
View File

@@ -0,0 +1,17 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
location /api/ {
proxy_pass http://backend:3000/api/;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}

4340
apps/admin/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

38
apps/admin/package.json Normal file
View File

@@ -0,0 +1,38 @@
{
"name": "frontend",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/icons": "^6.1.1",
"antd": "^6.3.6",
"axios": "^1.15.1",
"dayjs": "^1.11.20",
"echarts": "^6.0.0",
"echarts-for-react": "^3.0.6",
"react": "^19.2.5",
"react-dom": "^19.2.5",
"react-router-dom": "^7.14.1",
"tslib": "^2.8.1"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.1.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.5.0",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.2",
"vite": "^8.0.9"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

184
apps/admin/src/App.css Normal file
View File

@@ -0,0 +1,184 @@
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}

63
apps/admin/src/App.tsx Normal file
View File

@@ -0,0 +1,63 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { ConfigProvider, App as AntdApp } from 'antd';
import zhCN from 'antd/es/locale/zh_CN';
import MainLayout from './layouts/MainLayout';
import LoginPage from './pages/Login';
import DashboardPage from './pages/Dashboard';
import StudentsPage from './pages/Students';
import RoomsPage from './pages/Rooms';
import OccupanciesPage from './pages/Occupancies';
import ExpensesPage from './pages/Expenses';
import BillsPage from './pages/Bills';
import RoomVisualPage from './pages/RoomVisual';
import OperationLogsPage from './pages/OperationLogs';
import UsersPage from './pages/Users';
import DepositsPage from './pages/Deposits';
import ClassroomsPage from './pages/Classrooms';
import TenantsPage from './pages/Tenants';
import ClassroomRentalsPage from './pages/ClassroomRentals';
import ClassroomSchedulePage from './pages/ClassroomSchedule';
import RolesPage from './pages/Roles';
import PermissionsPage from './pages/Permissions';
import PermissionRoute from './components/PermissionRoute';
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const token = localStorage.getItem('token');
return token ? <>{children}</> : <Navigate to="/login" />;
};
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" } }}>
<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>
</AntdApp>
</ConfigProvider>
);
};
export default App;

View File

@@ -0,0 +1,33 @@
import axios from 'axios';
const api = axios.create({
baseURL: '/api',
timeout: 10000,
});
api.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
api.interceptors.response.use(
(res) => res.data,
(err) => {
if (err.response?.status === 401) {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
window.location.href = '/login';
}
if (err.response?.status === 403) {
const msg = err.response?.data?.message || '权限不足';
console.warn('[403]', msg);
}
return Promise.reject(err.response?.data || err);
},
);
export default api;

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,17 @@
import React from 'react';
import { Button } from 'antd';
import type { ButtonProps } from 'antd';
import { usePermission } from '../hooks/usePermission';
interface PermissionButtonProps extends ButtonProps {
permission: string;
children: React.ReactNode;
}
const PermissionButton: React.FC<PermissionButtonProps> = ({ permission, children, ...btnProps }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) return null;
return <Button {...btnProps}>{children}</Button>;
};
export default PermissionButton;

View File

@@ -0,0 +1,24 @@
import React from 'react';
import { Result } from 'antd';
import { usePermission } from '../hooks/usePermission';
interface PermissionRouteProps {
permission: string;
children: React.ReactNode;
}
const PermissionRoute: React.FC<PermissionRouteProps> = ({ permission, children }) => {
const { hasPermission } = usePermission();
if (!hasPermission(permission)) {
return (
<Result
status="403"
title="无权访问"
subTitle="您没有访问此页面的权限"
/>
);
}
return <>{children}</>;
};
export default PermissionRoute;

View File

@@ -0,0 +1,21 @@
import { useMemo } from 'react';
export function usePermission() {
const permissions: string[] = useMemo(() => {
try {
return JSON.parse(localStorage.getItem('permissions') || '[]');
} catch {
return [];
}
}, []);
const hasPermission = (code: string): boolean => permissions.includes(code);
const hasAnyPermission = (...codes: string[]): boolean =>
codes.some(c => permissions.includes(c));
const hasAllPermissions = (...codes: string[]): boolean =>
codes.every(c => permissions.includes(c));
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
}

47
apps/admin/src/index.css Normal file
View File

@@ -0,0 +1,47 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
#root {
min-height: 100vh;
}
/* 移动端自适应 */
@media (max-width: 767px) {
.ant-table {
font-size: 13px;
}
.ant-table-cell {
padding: 8px 6px !important;
}
.ant-descriptions-item-label,
.ant-descriptions-item-content {
font-size: 13px;
}
.ant-modal {
max-width: calc(100vw - 24px) !important;
margin: 12px auto !important;
}
.ant-modal .ant-modal-body {
max-height: 60vh;
overflow-y: auto;
}
h2 {
font-size: 18px !important;
}
.ant-card {
margin-bottom: 8px;
}
.ant-space-item .ant-btn {
padding: 2px 6px;
font-size: 12px;
}
}

View File

@@ -0,0 +1,166 @@
import React, { useState, useEffect } from 'react';
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
import { Layout, Menu, Button, Avatar, Dropdown, Drawer } from 'antd';
import {
DashboardOutlined,
TeamOutlined,
HomeOutlined,
SwapOutlined,
DollarOutlined,
FileTextOutlined,
LogoutOutlined,
UserOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
AppstoreOutlined,
AuditOutlined,
SettingOutlined,
WalletOutlined,
ReadOutlined,
TagsOutlined,
FileProtectOutlined,
CalendarOutlined,
SafetyOutlined,
KeyOutlined,
} from '@ant-design/icons';
import { usePermission } from '../hooks/usePermission';
const { Header, Sider, Content } = Layout;
interface MenuItemType {
key: string;
icon: React.ReactNode;
label: string;
permission?: string;
children?: MenuItemType[];
}
const allMenuItems: MenuItemType[] = [
{ 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' },
{ key: '/occupancies', icon: <SwapOutlined />, label: '入住管理', permission: 'occupancy:view' },
{ key: '/expenses', icon: <DollarOutlined />, label: '费用录入', permission: 'expense:view' },
{ key: '/deposits', icon: <WalletOutlined />, label: '押金管理', permission: 'deposit:view' },
{ key: '/bills', icon: <FileTextOutlined />, label: '账单管理', permission: 'bill:view' },
{
key: 'classroom-group',
icon: <ReadOutlined />,
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: '/tenants', icon: <TagsOutlined />, label: '租赁方', permission: 'tenant:view' },
],
},
{ key: '/operation-logs', icon: <AuditOutlined />, label: '操作日志', permission: 'log:view' },
{ key: '/roles', icon: <SafetyOutlined />, label: '角色管理', permission: 'role:view' },
{ key: '/permissions', icon: <KeyOutlined />, label: '权限一览', permission: 'role:view' },
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
];
const MainLayout: React.FC = () => {
const [collapsed, setCollapsed] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [drawerOpen, setDrawerOpen] = useState(false);
const navigate = useNavigate();
const location = useLocation();
const user = JSON.parse(localStorage.getItem('user') || '{}');
const { hasPermission } = usePermission();
useEffect(() => {
const handleResize = () => setIsMobile(window.innerWidth < 768);
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, []);
// 按 permission 过滤菜单
const filterByPermission = (items: MenuItemType[]): MenuItemType[] => {
return items
.map(item => {
if (item.children) {
const kids = filterByPermission(item.children);
if (kids.length === 0) return null;
return { ...item, children: kids };
}
if (!item.permission) return item;
return hasPermission(item.permission) ? item : null;
})
.filter(Boolean) as MenuItemType[];
};
const menuItems = filterByPermission(allMenuItems);
const handleLogout = () => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
navigate('/login');
};
const handleMenuClick = (key: string) => {
navigate(key);
if (isMobile) setDrawerOpen(false);
};
const transformToMenuItems = (items: MenuItemType[]): any[] => {
return items.map(item => ({
key: item.key,
icon: item.icon,
label: item.label,
children: item.children ? transformToMenuItems(item.children) : undefined,
}));
};
const menuContent = (
<Menu
theme="light"
mode="inline"
selectedKeys={[location.pathname]}
items={transformToMenuItems(menuItems)}
onClick={({ key }) => handleMenuClick(key)}
style={{ border: 'none' }}
/>
);
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' }}>
{collapsed ? '恭' : '恭学教育基地'}
</div>
{menuContent}
</Sider>
)}
{isMobile && (
<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' }}>
<Button
type="text"
icon={isMobile ? <MenuUnfoldOutlined /> : (collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />)}
onClick={() => isMobile ? setDrawerOpen(true) : setCollapsed(!collapsed)}
/>
<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' }}>
<Outlet />
</Content>
</Layout>
</Layout>
);
};
export default MainLayout;

31
apps/admin/src/main.tsx Normal file
View File

@@ -0,0 +1,31 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import App from './App';
import './index.css';
import dayjs from 'dayjs';
import 'dayjs/locale/zh-cn';
import customParseFormat from 'dayjs/plugin/customParseFormat';
import advancedFormat from 'dayjs/plugin/advancedFormat';
import weekday from 'dayjs/plugin/weekday';
import localeData from 'dayjs/plugin/localeData';
import weekOfYear from 'dayjs/plugin/weekOfYear';
import weekYear from 'dayjs/plugin/weekYear';
import updateLocale from 'dayjs/plugin/updateLocale';
// 扩展 antd DatePicker/RangePicker 面板所需的 dayjs 插件,否则中文 locale 无法生效
dayjs.extend(customParseFormat);
dayjs.extend(advancedFormat);
dayjs.extend(weekday);
dayjs.extend(localeData);
dayjs.extend(weekOfYear);
dayjs.extend(weekYear);
dayjs.extend(updateLocale);
// 必须在所有插件加载后设置 locale
dayjs.locale('zh-cn');
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);

View File

@@ -0,0 +1,306 @@
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 dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
const statusMap: Record<string, { text: string; color: string }> = {
draft: { text: '草稿', color: 'default' },
confirmed: { text: '已确认', color: 'blue' },
paid: { text: '已支付', color: 'green' },
};
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', other: '其他' };
const BillsPage: React.FC = () => {
const [bills, setBills] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [generateModal, setGenerateModal] = useState(false);
const [detailModal, setDetailModal] = useState<any>(null);
const [selectedRows, setSelectedRows] = useState<number[]>([]);
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const [generateForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/bills');
setBills(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const filteredBills = useMemo(() => {
return bills.filter((b: any) => {
if (searchText) {
const s = searchText.toLowerCase();
const matchName = b.student?.name?.toLowerCase().includes(s);
const matchPeriod = `${b.periodStart} ~ ${b.periodEnd}`.includes(s);
if (!matchName && !matchPeriod) return false;
}
if (filterStatus && b.status !== filterStatus) return false;
return true;
});
}, [bills, searchText, filterStatus]);
const handleGenerate = async () => {
const values = await generateForm.validateFields();
try {
const res: any = await api.post('/bills/generate', {
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
});
message.success(res.message || '生成成功');
setGenerateModal(false);
generateForm.resetFields();
fetchData();
} 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); }
};
const updateStatus = async (id: number, status: string) => {
try {
await api.put(`/bills/${id}/status`, { status });
message.success('状态更新成功');
fetchData();
if (detailModal?.id === id) {
setDetailModal({ ...detailModal, status });
}
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const batchUpdateStatus = async (status: string) => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.put('/bills/batch/status', { ids: selectedRows, status });
message.success(`已批量更新 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/bills/${id}`);
message.success('账单已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
};
const batchDelete = async () => {
if (selectedRows.length === 0) return message.warning('请先选择账单');
try {
await api.post('/bills/batch/delete', { ids: selectedRows });
message.success(`已删除 ${selectedRows.length} 条账单`);
setSelectedRows([]);
fetchData();
} 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 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 => {
const blobUrl = URL.createObjectURL(blob);
a.href = blobUrl;
a.download = `账单导出_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`;
a.click();
URL.revokeObjectURL(blobUrl);
message.success('Excel 导出成功');
})
.catch(() => message.error('导出失败'));
};
const handleExportPdf = (billId: number) => {
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 => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `账单_${billId}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
};
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: 'availableDeposit',
render: (v: number) => v > 0
? <span style={{ color: '#52c41a' }}>¥{Number(v).toFixed(2)}</span>
: <span style={{ color: '#999' }}>-</span>,
},
{
title: '抵扣后应付',
dataIndex: 'amountAfterDeposit',
render: (v: number, r: any) => {
const has = Number(r.availableDeposit || 0) > 0;
if (!has) return <span style={{ color: '#999' }}>-</span>;
const after = Number(v ?? r.totalAmount).toFixed(2);
const applied = Number(r.depositApplied || 0).toFixed(2);
return (
<Tooltip title={`已抵扣押金 ¥${applied}`}>
<strong style={{ color: '#fa541c' }}>¥{after}</strong>
</Tooltip>
);
},
},
{
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,
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:delete">
<Popconfirm title="确定删除此账单?" onConfirm={() => handleDelete(record.id)} okText="删除" cancelText="取消">
<Button size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<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(''); }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
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:delete">
<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>
<PermissionButton permission="bill:export-excel" icon={<DownloadOutlined />} onClick={handleExportExcel}>Excel</PermissionButton>
</Space>
</div>
<Table
columns={columns}
dataSource={filteredBills}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowSelection={{
selectedRowKeys: selectedRows,
onChange: (keys) => setSelectedRows(keys as number[]),
}}
/>
<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>
</Form>
</Modal>
<Modal
title="账单详情"
open={!!detailModal}
onCancel={() => setDetailModal(null)}
footer={null}
width={800}
>
{detailModal && (
<>
<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>
{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>
<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>
</Space>
</div>
)}
<h4></h4>
<Table
dataSource={detailModal.items || []}
rowKey="id"
pagination={false}
size="small"
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> },
]}
/>
</>
)}
</Modal>
</div>
);
};
export default BillsPage;

View File

@@ -0,0 +1,257 @@
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 { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
import dayjs, { Dayjs } from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const ClassroomRentalsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [classrooms, setClassrooms] = useState<any[]>([]);
const [tenants, setTenants] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
const [searchText, setSearchText] = useState('');
const filteredData = useMemo(() => {
if (!searchText) return data;
const s = searchText.toLowerCase();
return data.filter((r: any) => {
const matchClassroom = r.classroom?.name?.toLowerCase().includes(s);
const matchTenant = r.tenant?.name?.toLowerCase().includes(s);
return matchClassroom || matchTenant;
});
}, [data, searchText]);
const fetchData = async () => {
setLoading(true);
try {
const params: any = {};
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
const res: any = await api.get('/classroom-rentals', { params });
setData(res);
} catch (e) { console.error(e); }
setLoading(false);
};
const fetchMeta = async () => {
try {
const [cr, tn]: any = await Promise.all([
api.get('/classrooms'),
api.get('/tenants'),
]);
setClassrooms(cr);
setTenants(tn);
} catch (e) { console.error(e); }
};
useEffect(() => { fetchMeta(); }, []);
useEffect(() => { fetchData(); }, [filterMonth]);
const handleSave = async () => {
const values = await form.validateFields();
const payload = {
classroomId: values.classroomId,
tenantId: values.tenantId,
startDate: values.dateRange[0].format('YYYY-MM-DD'),
endDate: values.dateRange[1].format('YYYY-MM-DD'),
dailyRate: values.dailyRate,
totalAmount: values.totalAmount,
notes: values.notes,
};
try {
if (editing) {
await api.put(`/classroom-rentals/${editing.id}`, payload);
message.success('更新成功');
} else {
await api.post('/classroom-rentals', payload);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) {
if (e?.conflicts?.length) {
const list = e.conflicts.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`).join('、');
message.error(`时间段冲突:${list}`);
} else {
message.error(e?.message || '操作失败');
}
}
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}`);
message.success('已删除');
fetchData();
} 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 token = localStorage.getItem('token');
fetch(`${baseURL}/classroom-rentals/${id}/contract`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => {
if (!res.ok) throw new Error('下载失败');
return res.blob();
})
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = filename || `contract-${id}.pdf`;
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败(可能文件已丢失)'));
};
const handleDeleteContract = async (id: number) => {
try {
await api.delete(`/classroom-rentals/${id}/contract`);
message.success('合同已删除');
fetchData();
} catch (e: any) { message.error(e?.message || '删除失败'); }
};
const openEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
classroomId: record.classroomId,
tenantId: record.tenantId,
dateRange: [dayjs(record.startDate), dayjs(record.endDate)],
dailyRate: record.dailyRate ? Number(record.dailyRate) : undefined,
totalAmount: record.totalAmount ? Number(record.totalAmount) : undefined,
notes: record.notes,
});
setModalOpen(true);
};
const columns = [
{
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: 'startDate' },
{ title: '结束日期', dataIndex: 'endDate' },
{
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: '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,
render: (_: any, record: any) => (
<Space>
<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>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<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(''); }}
/>
<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>
</div>
<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}>
<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}` }))}
/>
</Form.Item>
<Form.Item name="tenantId" label="租赁方" rules={[{ required: true }]}>
<Select
showSearch
optionFilterProp="label"
placeholder="选择租赁方"
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" />
</Form.Item>
<Form.Item name="dailyRate" label="日租金(可选)">
<InputNumber min={0} precision={2} style={{ width: '100%' }} prefix="¥" />
</Form.Item>
<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>
</Modal>
</div>
);
};
export default ClassroomRentalsPage;

View File

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

View File

@@ -0,0 +1,194 @@
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 api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
available: { text: '可用', color: 'green' },
archived: { text: '已归档', color: '#999' },
};
const typeColor: Record<string, string> = {
: 'volcano',
: 'geekblue',
: 'cyan',
};
const ClassroomsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [showArchived, setShowArchived] = useState(false);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState('');
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));
}, [data, searchText]);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
setData(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [showArchived]);
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/classrooms/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/classrooms', values);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/classrooms/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const handleRestore = async (id: number) => {
try {
await api.put(`/classrooms/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/classrooms/template`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '教室导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
};
const columns = [
{ 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: 'capacity' },
{ title: '课程类型', dataIndex: 'courseType', render: (v: string) => v || '-' },
{ title: '负责人', dataIndex: 'supervisor', render: (v: string) => v || '-' },
{
title: '状态', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
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>
</Popconfirm>
</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>
</PermissionButton>
</>
)}
</Space>
),
},
];
return (
<div>
<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(''); }}
/>
<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>
<PermissionButton permission="classroom: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('/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); }
}}
>
<Button icon={<UploadOutlined />}>Excel</Button>
</Upload>
</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}` }} />
<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>
<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>
);
};
export default ClassroomsPage;

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Statistic, DatePicker, Spin } from 'antd';
import { TeamOutlined, HomeOutlined, DollarOutlined, CheckCircleOutlined } from '@ant-design/icons';
import ReactECharts from 'echarts-for-react';
import dayjs from 'dayjs';
import api from '../../api';
const { RangePicker } = DatePicker;
const COLORS = ['#007AFF', '#34C759', '#FF9500', '#FF3B30', '#5AC8FA', '#AF52DE', '#FF2D55', '#FFCC00'];
const DashboardPage: React.FC = () => {
const [stats, setStats] = useState<any>(null);
const [ganttData, setGanttData] = useState<any[]>([]);
const [expenseStats, setExpenseStats] = useState<any[]>([]);
const [roomRanking, setRoomRanking] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [period, setPeriod] = useState<[string, string]>([
dayjs().startOf('month').format('YYYY-MM-DD'),
dayjs().endOf('month').format('YYYY-MM-DD'),
]);
const fetchData = async () => {
setLoading(true);
try {
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] } }),
]);
setStats(s);
setGanttData(g as any);
setExpenseStats(e as any);
setRoomRanking(r as any);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [period]);
const expenseTypeMap: Record<string, string> = {
water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他',
};
// 甘特图配置
const ganttOption = () => {
if (!ganttData.length) return {};
const rooms = ganttData.map((d) => d.roomNumber);
const pStart = new Date(period[0]).getTime();
const pEnd = new Date(period[1]).getTime();
const data: any[] = [];
ganttData.forEach((room, roomIdx) => {
room.occupancies.forEach((occ: any, i: number) => {
const start = Math.max(new Date(occ.checkInDate).getTime(), pStart);
const end = occ.checkOutDate ? Math.min(new Date(occ.checkOutDate).getTime(), pEnd) : pEnd;
data.push({
name: occ.studentName,
value: [roomIdx, start, end, occ.studentName],
itemStyle: { color: COLORS[(occ.studentId || i) % COLORS.length] },
});
});
});
return {
tooltip: {
formatter: (p: any) => {
const v = p.value;
return `${p.name}<br/>宿舍: ${rooms[v[0]]}<br/>入住: ${dayjs(v[1]).format('MM-DD')} ~ ${dayjs(v[2]).format('MM-DD')}`;
},
},
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 },
};
},
encode: { x: [1, 2], y: 0 },
data,
}],
};
};
// 费用饼图
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),
})),
}],
};
// 宿舍费用排行
const barOption = {
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] } }],
};
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' }}>
<h2 style={{ margin: 0 }}></h2>
<RangePicker
value={[dayjs(period[0]), dayjs(period[1])]}
onChange={(dates) => {
if (dates) setPeriod([dates[0]!.format('YYYY-MM-DD'), dates[1]!.format('YYYY-MM-DD')]);
}}
/>
</div>
<Row gutter={16} style={{ marginBottom: 24 }}>
<Col span={6}>
<Card><Statistic title="宿舍总数" value={stats?.totalRooms || 0} prefix={<HomeOutlined />} /></Card>
</Col>
<Col span={6}>
<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>
</Col>
<Col span={6}>
<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) }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
<Row gutter={16}>
<Col span={12}>
<Card title="费用类型分布">
{expenseStats.length > 0 ? (
<ReactECharts option={pieOption} style={{ height: 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
<Col span={12}>
<Card title="宿舍费用排行 TOP 20">
{roomRanking.length > 0 ? (
<ReactECharts option={barOption} style={{ height: 300 }} />
) : (
<div style={{ textAlign: 'center', padding: 40, color: '#999' }}></div>
)}
</Card>
</Col>
</Row>
</div>
);
};
export default DashboardPage;

View File

@@ -0,0 +1,191 @@
import React, { useEffect, useState, useMemo } from 'react';
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';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
paid: { text: '已缴', color: 'green' },
refunded: { text: '已全退', color: 'blue' },
partial_refund: { text: '部分退还', color: 'orange' },
deducted: { text: '已全扣', color: 'red' },
};
const DepositsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [createModal, setCreateModal] = useState(false);
const [refundModal, setRefundModal] = useState<any>(null);
const [createForm] = Form.useForm();
const [refundForm] = Form.useForm();
const [searchText, setSearchText] = useState('');
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
const fetchData = async () => {
setLoading(true);
try {
const [d, s]: any[] = await Promise.all([
api.get('/deposits'),
api.get('/students'),
]);
setData(d);
setStudents(s);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const filteredData = useMemo(() => {
return data.filter((d: any) => {
if (searchText) {
const s = searchText.toLowerCase();
if (!d.student?.name?.toLowerCase().includes(s)) return false;
}
if (filterStatus && d.status !== filterStatus) return false;
return true;
});
}, [data, searchText, filterStatus]);
const handleCreate = async () => {
const values = await createForm.validateFields();
try {
await api.post('/deposits', {
studentId: values.studentId,
amount: values.amount,
paidDate: values.paidDate.format('YYYY-MM-DD'),
notes: values.notes,
});
message.success('押金记录已创建');
setCreateModal(false);
createForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleRefund = async () => {
const values = await refundForm.validateFields();
try {
await api.put(`/deposits/${refundModal.id}/refund`, {
refundDate: values.refundDate.format('YYYY-MM-DD'),
deductionAmount: values.deductionAmount || 0,
deductionReason: values.deductionReason,
notes: values.notes,
});
message.success('退还操作完成');
setRefundModal(null);
refundForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const columns = [
{ title: '学生', render: (_: any, r: any) => r.student?.name || '-' },
{ title: '押金金额', dataIndex: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '缴纳日期', dataIndex: 'paidDate' },
{
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: 'deductionReason', render: (v: any) => v || '-' },
{ title: '退还日期', dataIndex: 'refundDate', render: (v: any) => v || '-' },
{ title: '备注', dataIndex: 'notes', render: (v: any) => v || '-' },
{
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:delete">
<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>
</Space>
),
},
];
return (
<div>
<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(''); }}
/>
<Select
placeholder="状态筛选"
allowClear
style={{ width: 120 }}
value={filterStatus}
onChange={v => setFilterStatus(v)}
options={[
{ value: 'paid', label: '已缴' },
{ value: 'refunded', label: '已全退' },
{ value: 'partial_refund', label: '部分退还' },
{ value: 'deducted', label: '已全扣' },
]}
/>
</Space>
<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}` }} />
<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>
<Form.Item name="amount" label="押金金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="paidDate" label="缴纳日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择缴纳日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<Modal title={`退还押金 - ${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>
</div>
<Form.Item name="refundDate" label="退还日期" rules={[{ required: true }]}>
<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%' }} />
</Form.Item>
<Form.Item name="deductionReason" label="扣除原因">
<Input placeholder="如:房间损坏赔偿" />
</Form.Item>
<Form.Item name="notes" label="备注">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default DepositsPage;

View File

@@ -0,0 +1,435 @@
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 dayjs from 'dayjs';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const { RangePicker } = DatePicker;
const expenseTypeOptions = [
{ value: 'water', label: '水费' },
{ value: 'electricity', label: '电费' },
{ value: 'cleaning', label: '保洁费' },
{ value: 'damage', label: '损坏赔偿' },
{ value: 'other', label: '其他' },
];
const personalExpenseTypeOptions = [
{ value: 'damage', label: '物品损坏' },
{ value: 'cleaning', label: '保洁费' },
{ value: 'penalty', label: '罚款' },
{ value: 'key', label: '钥匙费' },
{ value: 'remote', label: '空调遥控器' },
{ value: 'deposit_deduction', label: '押金扣除' },
{ value: 'other', label: '其他' },
];
const typeMap: Record<string, string> = { water: '水费', electricity: '电费', cleaning: '保洁费', damage: '损坏赔偿', penalty: '罚款', key: '钥匙费', remote: '空调遥控器', deposit_deduction: '押金扣除', other: '其他' };
const ExpensesPage: React.FC = () => {
const [roomExpenses, setRoomExpenses] = useState<any[]>([]);
const [personalExpenses, setPersonalExpenses] = useState<any[]>([]);
const [rooms, setRooms] = useState<any[]>([]);
const [students, setStudents] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [roomModal, setRoomModal] = useState(false);
const [personalModal, setPersonalModal] = useState(false);
const [editingRoom, setEditingRoom] = useState<any>(null);
const [editingPersonal, setEditingPersonal] = useState<any>(null);
const [roomForm] = Form.useForm();
const [personalForm] = Form.useForm();
const [roomSearch, setRoomSearch] = useState('');
const [roomTypeFilter, setRoomTypeFilter] = useState<string | undefined>(undefined);
const [personalSearch, setPersonalSearch] = useState('');
const [personalTypeFilter, setPersonalTypeFilter] = useState<string | undefined>(undefined);
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
const handleBatchDeleteRoom = async () => {
try {
const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys });
message.success(res?.message || `已删除 ${selectedRoomKeys.length}`);
setSelectedRoomKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量删除失败'); }
};
const handleBatchDeletePersonal = async () => {
try {
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 || '批量删除失败'); }
};
const fetchData = async () => {
setLoading(true);
try {
const [re, pe, rm, st]: any[] = await Promise.all([
api.get('/expenses/room'),
api.get('/expenses/personal'),
api.get('/rooms'),
api.get('/students'),
]);
setRoomExpenses(re);
setPersonalExpenses(pe);
setRooms(rm);
setStudents(st);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const filteredRoomExpenses = useMemo(() => {
return roomExpenses.filter((r: any) => {
if (roomSearch) {
const s = roomSearch.toLowerCase();
if (!r.room?.roomNumber?.toLowerCase().includes(s)) return false;
}
if (roomTypeFilter && r.expenseType !== roomTypeFilter) return false;
return true;
});
}, [roomExpenses, roomSearch, roomTypeFilter]);
const filteredPersonalExpenses = useMemo(() => {
return personalExpenses.filter((p: any) => {
if (personalSearch) {
const s = personalSearch.toLowerCase();
if (!p.student?.name?.toLowerCase().includes(s)) return false;
}
if (personalTypeFilter && p.expenseType !== personalTypeFilter) return false;
return true;
});
}, [personalExpenses, personalSearch, personalTypeFilter]);
const handleRoomExpense = async () => {
const values = await roomForm.validateFields();
const payload = {
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
periodStart: values.period[0].format('YYYY-MM-DD'),
periodEnd: values.period[1].format('YYYY-MM-DD'),
description: values.description,
};
try {
if (editingRoom) {
await api.put(`/expenses/room/${editingRoom.id}`, payload);
message.success('更新成功');
} else {
await api.post('/expenses/room', payload);
message.success('录入成功');
}
setRoomModal(false);
setEditingRoom(null);
roomForm.resetFields();
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handlePersonalExpense = async () => {
const values = await personalForm.validateFields();
const payload = {
studentId: values.studentId,
roomId: values.roomId,
expenseType: values.expenseType,
amount: values.amount,
expenseDate: values.expenseDate.format('YYYY-MM-DD'),
description: values.description,
};
try {
if (editingPersonal) {
await api.put(`/expenses/personal/${editingPersonal.id}`, payload);
message.success('更新成功');
} else {
await api.post('/expenses/personal', payload);
message.success('录入成功');
}
setPersonalModal(false);
setEditingPersonal(null);
personalForm.resetFields();
fetchData();
} 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: '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,
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:delete">
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/room/${record.id}`); message.success('删除成功'); fetchData(); }}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
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: 'amount', render: (v: number) => `¥${Number(v).toFixed(2)}` },
{ title: '日期', dataIndex: 'expenseDate' },
{ title: '说明', dataIndex: 'description' },
{
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:delete">
<Popconfirm title="确定删除?" onConfirm={async () => { await api.delete(`/expenses/personal/${record.id}`); message.success('删除成功'); fetchData(); }}>
<Button size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
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); }
}}
>
<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 {
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 ? '保存' : '确认录入'}>
<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 || ''})` }))} />
</Form.Item>
<Form.Item name="expenseType" label="费用类型" rules={[{ required: true }]}>
<Select options={expenseTypeOptions} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<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" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
<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 }))} />
</Form.Item>
<Form.Item name="roomId" label="关联宿舍">
<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} />
</Form.Item>
<Form.Item name="amount" label="金额(元)" rules={[{ required: true }]}>
<InputNumber min={0} precision={2} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="expenseDate" label="费用日期" rules={[{ required: true }]}>
<DatePicker style={{ width: '100%' }} placeholder="选择日期" format="YYYY-MM-DD" />
</Form.Item>
<Form.Item name="description" label="说明">
<Input.TextArea rows={2} />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default ExpensesPage;

View File

@@ -0,0 +1,54 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { Form, Input, Button, Card, message, Typography } from 'antd';
import { UserOutlined, LockOutlined } from '@ant-design/icons';
import api from '../../api';
const { Title } = Typography;
const LoginPage: React.FC = () => {
const [loading, setLoading] = useState(false);
const navigate = useNavigate();
const onFinish = async (values: any) => {
setLoading(true);
try {
const res: any = await api.post('/auth/login', values);
localStorage.setItem('token', res.access_token);
localStorage.setItem('user', JSON.stringify(res.user));
localStorage.setItem('permissions', JSON.stringify(res.user.permissions || []));
message.success('登录成功');
navigate('/dashboard');
} catch (err: any) {
message.error(err?.message || '登录失败');
} finally {
setLoading(false);
}
};
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={{ textAlign: 'center', marginBottom: 32 }}>
<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">
<Form.Item name="username" rules={[{ required: true, message: '请输入用户名' }]}>
<Input prefix={<UserOutlined />} placeholder="用户名" />
</Form.Item>
<Form.Item name="password" rules={[{ required: true, message: '请输入密码' }]}>
<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>
</Form.Item>
</Form>
</Card>
</div>
);
};
export default LoginPage;

View File

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

View File

@@ -0,0 +1,97 @@
import React, { useEffect, useState } from 'react';
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
import dayjs from 'dayjs';
import api from '../../api';
const { RangePicker } = DatePicker;
const moduleColorMap: Record<string, string> = {
'学生': 'blue', '宿舍': 'green', '入住': 'cyan', '费用': 'orange', '账单': 'red', '账号': 'purple', '认证': 'magenta',
};
const statusMap: Record<string, { text: string; color: string }> = {
success: { text: '成功', color: 'green' },
fail: { text: '失败', color: 'red' },
};
const OperationLogsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(false);
const [page, setPage] = useState(1);
const [filterModule, setFilterModule] = useState<string | undefined>();
const [dateRange, setDateRange] = useState<[string, string] | null>(null);
const fetchData = async () => {
setLoading(true);
try {
const params: any = { page, pageSize: 20 };
if (filterModule) params.module = filterModule;
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); }
setLoading(false);
};
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: 'username', width: 100 },
{ 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: '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>;
}},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<h2 style={{ margin: 0 }}></h2>
<Space wrap>
<Select
allowClear
placeholder="筛选模块"
style={{ width: 140 }}
value={filterModule}
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')]);
else setDateRange(null);
setPage(1);
}}
/>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
scroll={{ x: 1000 }}
pagination={{ current: page, total, pageSize: 20, onChange: setPage, showTotal: (t) => `${t}` }}
/>
</div>
);
};
export default OperationLogsPage;

View File

@@ -0,0 +1,77 @@
import React, { useEffect, useState } from 'react';
import { Card, Tag, Input, Space, Spin } from 'antd';
import api from '../../api';
interface PermissionItem {
id: number;
code: string;
name: string;
group: string;
description: string;
}
const PermissionsPage: React.FC = () => {
const [permTree, setPermTree] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
const [loading, setLoading] = useState(false);
const [search, setSearch] = useState('');
const groupNames: Record<string, string> = {
dashboard: '数据面板', student: '学生管理', room: '宿舍管理', occupancy: '入住管理',
expense: '费用管理', bill: '账单管理', deposit: '押金管理',
classroom: '教室管理', tenant: '租赁方', rental: '租赁订单',
log: '操作日志', user: '用户管理', role: '角色管理',
};
useEffect(() => {
setLoading(true);
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;
if (loading) return <Spin style={{ display: 'block', margin: '40px auto' }} />;
return (
<div>
<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('')}
/>
</div>
<Space direction="vertical" style={{ width: '100%' }} size={16}>
{filteredTree.map(group => (
<Card
key={group.group}
title={<span style={{ fontWeight: 600 }}>{groupNames[group.group] || group.group} ({group.permissions.length})</span>}
size="small"
>
<Space wrap>
{group.permissions.map(p => (
<Tag key={p.id} color="blue" style={{ marginBottom: 8 }}>
{p.name} <Tag color="geekblue" style={{ marginLeft: 4 }}>{p.code}</Tag>
</Tag>
))}
</Space>
</Card>
))}
</Space>
</div>
);
};
export default PermissionsPage;

View File

@@ -0,0 +1,206 @@
import React, { useEffect, useState } from 'react';
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';
interface PermissionItem {
id: number;
code: string;
name: string;
group: string;
}
interface RoleItem {
id: number;
name: string;
description: string;
isSystem: boolean;
status: number;
permissions: PermissionItem[];
}
const RolesPage: React.FC = () => {
const [data, setData] = useState<RoleItem[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<RoleItem | null>(null);
const [allPerms, setAllPerms] = useState<{ group: string; permissions: PermissionItem[] }[]>([]);
const [form] = Form.useForm();
const [selectedPermIds, setSelectedPermIds] = useState<number[]>([]);
const fetchData = async () => {
setLoading(true);
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[] }[]>,
]);
setData(roles);
setAllPerms(permTree);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleAdd = () => {
setEditing(null);
form.resetFields();
setSelectedPermIds([]);
setModalOpen(true);
};
const handleEdit = (record: RoleItem) => {
setEditing(record);
form.setFieldsValue({ name: record.name, description: record.description });
setSelectedPermIds(record.permissions.map(p => p.id));
setModalOpen(true);
};
const handleSubmit = async () => {
const values = await form.validateFields();
try {
if (editing) {
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 });
message.success('角色创建成功');
}
setModalOpen(false);
fetchData();
} catch (e: any) { message.error(e.message || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/rbac/roles/${id}`);
message.success('角色已删除');
fetchData();
} 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: '角色管理',
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ 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: 'isSystem', width: 70,
render: (v: boolean) => v ? <Tag color="orange"></Tag> : null,
},
{
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>
{!record.isSystem && (
<PermissionButton permission="role:delete">
<Popconfirm title="确认删除该角色?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</PermissionButton>
)}
</Space>
),
},
];
const handleGroupCheckAll = (group: string, checked: boolean) => {
const groupPermIds = allPerms.find(g => g.group === group)?.permissions.map(p => p.id) || [];
if (checked) {
setSelectedPermIds(prev => [...new Set([...prev, ...groupPermIds])]);
} else {
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 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;
return checkedCount > 0 && checkedCount < groupPermIds.length;
};
return (
<div>
<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>
</div>
<Table columns={columns} dataSource={data} rowKey="id" loading={loading} scroll={{ x: 800 }} pagination={false} />
<Modal
title={editing ? '编辑角色' : '新增角色'}
open={modalOpen}
onOk={handleSubmit}
onCancel={() => setModalOpen(false)}
width={700}
destroyOnClose
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="角色名称" rules={[{ required: true, message: '请输入角色名称' }]}>
<Input disabled={editing?.isSystem} />
</Form.Item>
<Form.Item name="description" label="角色描述">
<Input.TextArea rows={2} />
</Form.Item>
<Form.Item label="权限分配">
<div style={{ maxHeight: 400, overflow: 'auto' }}>
{allPerms.map(group => (
<Card
key={group.group}
size="small"
title={
<Checkbox
checked={isGroupAllChecked(group.group)}
indeterminate={isGroupIndeterminate(group.group)}
onChange={e => handleGroupCheckAll(group.group, e.target.checked)}
>
{groupNames[group.group] || group.group}
</Checkbox>
}
style={{ marginBottom: 8 }}
>
<Checkbox.Group
value={selectedPermIds}
onChange={vals => setSelectedPermIds(vals as number[])}
>
<Space wrap>
{group.permissions.map(p => (
<Checkbox key={p.id} value={p.id}>{p.name}</Checkbox>
))}
</Space>
</Checkbox.Group>
</Card>
))}
</div>
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default RolesPage;

View File

@@ -0,0 +1,179 @@
import React, { useEffect, useState } from 'react';
import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip } from 'antd';
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined } from '@ant-design/icons';
import api from '../../api';
const RoomVisualPage: React.FC = () => {
const [data, setData] = useState<any>(null);
const [loading, setLoading] = useState(true);
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
const [detailRoom, setDetailRoom] = useState<any>(null);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/rooms/visual');
setData(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
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 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 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' };
return { background: '#e6f4ff', borderColor: '#91caff' };
};
const getStatusLabel = (room: any) => {
if (room.status === 'maintenance') return <Tag color="default"></Tag>;
if (room.currentCount === 0) return <Tag color="success"></Tag>;
if (room.currentCount >= room.capacity) return <Tag color="error"></Tag>;
return <Tag color="processing"></Tag>;
};
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', alignItems: 'center', flexWrap: 'wrap', gap: 12 }}>
<h2 style={{ margin: 0 }}>宿</h2>
<Select
value={selectedBuilding}
onChange={setSelectedBuilding}
style={{ width: 160 }}
options={[
{ value: 'all', label: '全部楼栋' },
...data.buildings.map((b: string) => ({ value: b, label: b })),
]}
/>
</div>
{/* 统计栏 */}
<Row gutter={[12, 12]} style={{ marginBottom: 20 }}>
<Col xs={12} sm={6}>
<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>
</Col>
<Col xs={12} sm={6}>
<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>
</Col>
</Row>
{/* 房态网格 */}
<Row gutter={[12, 12]}>
{rooms.map((room: any) => (
<Col xs={12} sm={8} md={6} lg={4} key={room.id}>
<Card
size="small"
hoverable
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>
{getStatusLabel(room)}
</div>
<div style={{ color: '#86868b', fontSize: 12, marginBottom: 6 }}>
{room.building && <span>{room.building} </span>}
{room.floor && <span>{room.floor}F</span>}
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 8 }}>
<Badge
count={`${room.currentCount}/${room.capacity}`}
showZero
style={{
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>
</div>
)}
{room.occupants.length > 0 && (
<div style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
{room.occupants.slice(0, 4).map((o: any) => (
<Tooltip key={o.studentId} title={`入住 ${o.days} 天 (${o.checkInDate} 起)`}>
<Tag style={{ margin: '0 4px 4px 0', fontSize: 11 }} icon={<UserOutlined />}>
{o.studentName}
</Tag>
</Tooltip>
))}
{room.occupants.length > 4 && <Tag>+{room.occupants.length - 4}</Tag>}
</div>
)}
</Card>
</Col>
))}
</Row>
{/* 详情弹窗 */}
<Modal
title={`宿舍 ${detailRoom?.roomNumber} 详情`}
open={!!detailRoom}
onCancel={() => setDetailRoom(null)}
footer={null}
width={500}
>
{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>
</Row>
<div style={{ marginBottom: 8, fontWeight: 500 }}>
{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
</div>
<div style={{ marginBottom: 16 }}>{getStatusLabel(detailRoom)}</div>
{detailRoom.occupants.length > 0 ? (
<div>
<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>
<UserOutlined style={{ marginRight: 6 }} />
<strong>{o.studentName}</strong>
{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>}
</div>
</Card>
))}
</div>
) : (
<div style={{ textAlign: 'center', padding: 24, color: '#86868b' }}></div>
)}
</div>
)}
</Modal>
</div>
);
};
export default RoomVisualPage;

View File

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

View File

@@ -0,0 +1,259 @@
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 api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const statusMap: Record<string, { text: string; color: string }> = {
active: { text: '在读', color: 'green' },
graduated: { text: '已毕业', color: 'blue' },
withdrawn: { text: '已退训', color: 'red' },
archived: { text: '已归档', color: '#999' },
};
const StudentsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [searchName, setSearchName] = useState('');
const [showArchived, setShowArchived] = useState(false);
const [archivedCount, setArchivedCount] = useState(0);
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
const [form] = Form.useForm();
const handleBatchDelete = async () => {
try {
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
message.success(res?.message || `已批量归档 ${selectedRowKeys.length}`);
setSelectedRowKeys([]);
fetchData();
} catch (e: any) { message.error(e?.message || '批量归档失败'); }
};
const fetchData = async () => {
setLoading(true);
try {
const params: any = { name: searchName || undefined, includeArchived: 'true' };
const res: any = await api.get('/students', { params });
const archived = res.filter((r: any) => r.status === 'archived');
setArchivedCount(archived.length);
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
setData(filtered);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, [searchName, showArchived]);
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/students/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/students', values);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/students/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const handleRestore = async (id: number) => {
try {
await api.put(`/students/${id}/restore`);
message.success('已恢复');
fetchData();
} catch (e: any) { message.error(e?.message || '恢复失败'); }
};
const handleDownloadTemplate = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
fetch(`${baseURL}/students/template`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '学生导入模板.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('下载失败'));
};
const handleExport = () => {
const baseURL = import.meta.env.PROD ? '/api' : `http://localhost:${import.meta.env.VITE_API_PORT || 3002}/api`;
const token = localStorage.getItem('token');
const params = showArchived ? '?includeArchived=true' : '';
fetch(`${baseURL}/students/export${params}`, { headers: { Authorization: `Bearer ${token}` } })
.then(res => res.blob())
.then(blob => {
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = '学生名单.xlsx';
a.click();
URL.revokeObjectURL(url);
})
.catch(() => message.error('导出失败'));
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ title: '姓名', dataIndex: 'name' },
{ title: '性别', dataIndex: 'gender', width: 60 },
{ title: '电话', dataIndex: 'phone' },
{ title: '学号/身份证', dataIndex: 'idNumber' },
{ 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: 'supervisor' },
{
title: '状态', dataIndex: 'status',
render: (s: string) => <Tag color={statusMap[s]?.color}>{statusMap[s]?.text || s}</Tag>,
},
{
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>
</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>
</PermissionButton>
</>
)}
</Space>
),
},
];
return (
<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})` : ''}`}
</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>
</PermissionButton>
<PermissionButton permission="student:create" type="primary" icon={<PlusOutlined />} onClick={() => { setEditing(null); form.resetFields(); setModalOpen(true); }}>
</PermissionButton>
<PermissionButton permission="student:import">
<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('/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); }
}}
>
<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>
</Space>
</div>
<Table
columns={columns}
dataSource={data}
rowKey="id"
loading={loading}
pagination={{ pageSize: 15, showTotal: (total) => `${total}` }}
rowClassName={(record: any) => record.status === 'archived' ? 'archived-row' : ''}
rowSelection={{
selectedRowKeys,
onChange: (keys) => setSelectedRowKeys(keys as number[]),
getCheckboxProps: (record: any) => ({ disabled: record.status === 'archived' }),
}}
/>
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
<Modal
title={editing ? '编辑学生' : '添加学生'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditing(null); }}
okText="保存"
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="姓名" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="gender" label="性别">
<Select allowClear options={[{ value: '男', label: '男' }, { value: '女', label: '女' }]} />
</Form.Item>
<Form.Item name="phone" label="电话">
<Input />
</Form.Item>
<Form.Item name="idNumber" label="学号/身份证">
<Input />
</Form.Item>
<Form.Item name="ethnicity" label="民族">
<Input placeholder="如:汉族" />
</Form.Item>
<Form.Item name="emergencyContact" label="紧急联系人">
<Input />
</Form.Item>
<Form.Item name="emergencyPhone" label="紧急联系人电话">
<Input />
</Form.Item>
<Form.Item name="organization" label="所属机构" tooltip="外部合作公司/机构名称,留空表示本机构">
<Input placeholder="如XXX教育科技公司" />
</Form.Item>
<Form.Item name="supervisor" label="负责人/班主任">
<Input />
</Form.Item>
{editing && (
<Form.Item name="status" label="状态">
<Select options={[
{ value: 'active', label: '在读' },
{ value: 'graduated', label: '已毕业' },
{ value: 'withdrawn', label: '已退训' },
]} />
</Form.Item>
)}
</Form>
</Modal>
</div>
);
};
export default StudentsPage;

View File

@@ -0,0 +1,131 @@
import React, { useEffect, useState, useMemo } from 'react';
import { Table, Button, Modal, Form, Input, Space, message, Tag, Popconfirm } from 'antd';
import { PlusOutlined, InboxOutlined } from '@ant-design/icons';
import api from '../../api';
import PermissionButton from '../../components/PermissionButton';
const PRESET_COLORS = [
'#ff7875', '#ffa940', '#ffc53d', '#73d13d', '#36cfc9',
'#40a9ff', '#597ef7', '#9254de', '#f759ab', '#8c8c8c',
];
const TenantsPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form] = Form.useForm();
const [searchText, setSearchText] = useState('');
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));
}, [data, searchText]);
const fetchData = async () => {
setLoading(true);
try {
const res: any = await api.get('/tenants');
setData(res);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleSave = async () => {
const values = await form.validateFields();
try {
if (editing) {
await api.put(`/tenants/${editing.id}`, values);
message.success('更新成功');
} else {
await api.post('/tenants', values);
message.success('创建成功');
}
setModalOpen(false);
form.resetFields();
setEditing(null);
fetchData();
} catch (e: any) { message.error(e?.message || '操作失败'); }
};
const handleArchive = async (id: number) => {
try {
await api.delete(`/tenants/${id}`);
message.success('已归档');
fetchData();
} catch (e: any) { message.error(e?.message || '归档失败'); }
};
const columns = [
{
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>
</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: 'notes', ellipsis: true, render: (v: string) => v || '-' },
{
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:delete">
<Popconfirm title="归档后仍可查看历史租赁" onConfirm={() => handleArchive(record.id)} okText="归档" cancelText="取消">
<Button size="small" icon={<InboxOutlined />}></Button>
</Popconfirm>
</PermissionButton>
</Space>
),
},
];
return (
<div>
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
<Input.Search
placeholder="搜索名称或联系人"
allowClear
style={{ width: 200 }}
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>
</div>
<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="保存">
<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>
<Form.Item name="notes" label="备注"><Input.TextArea rows={2} /></Form.Item>
</Form>
</Modal>
</div>
);
};
export default TenantsPage;

View File

@@ -0,0 +1,176 @@
import React, { useEffect, useState } from 'react';
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';
import PermissionButton from '../../components/PermissionButton';
const UsersPage: React.FC = () => {
const [data, setData] = useState<any[]>([]);
const [roles, setRoles] = useState<any[]>([]);
const [loading, setLoading] = useState(false);
const [modalOpen, setModalOpen] = useState(false);
const [pwdModalOpen, setPwdModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [resetTarget, setResetTarget] = useState<any>(null);
const [form] = Form.useForm();
const [pwdForm] = Form.useForm();
const fetchData = async () => {
setLoading(true);
try {
const [users, rolesRes] = await Promise.all([
api.get('/rbac/users') as Promise<any[]>,
api.get('/rbac/roles') as Promise<any[]>,
]);
setData(users);
setRoles(rolesRes);
} catch (e) { console.error(e); }
setLoading(false);
};
useEffect(() => { fetchData(); }, []);
const handleAdd = () => {
setEditing(null);
form.resetFields();
setModalOpen(true);
};
const handleEdit = (record: any) => {
setEditing(record);
form.setFieldsValue({
username: record.username,
name: record.name,
isActive: record.isActive,
roleIds: record.roles?.map((r: any) => r.id) || [],
});
setModalOpen(true);
};
const handleSubmit = async () => {
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 || [] });
message.success('更新成功');
} else {
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 || '操作失败'); }
};
const handleDelete = async (id: number) => {
try {
await api.delete(`/rbac/users/${id}`);
message.success('已删除');
fetchData();
} catch (e: any) { message.error(e.message || '删除失败'); }
};
const handleResetPwd = (record: any) => {
setResetTarget(record);
pwdForm.resetFields();
setPwdModalOpen(true);
};
const handlePwdSubmit = async () => {
const values = await pwdForm.validateFields();
try {
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
message.success('密码已重置');
setPwdModalOpen(false);
} catch (e: any) { message.error(e.message || '操作失败'); }
};
const columns = [
{ title: 'ID', dataIndex: 'id', width: 60 },
{ 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: '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: 'createdAt', width: 170,
render: (v: string) => dayjs(v).format('YYYY-MM-DD HH:mm:ss'),
},
{
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>
{record.username !== 'admin' && (
<PermissionButton permission="user:delete">
<Popconfirm title="确认删除该用户?" onConfirm={() => handleDelete(record.id)}>
<Button type="link" size="small" danger icon={<DeleteOutlined />}></Button>
</Popconfirm>
</PermissionButton>
)}
</Space>
),
},
];
return (
<div>
<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>
</div>
<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>
<Form form={form} layout="vertical">
<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位' }]}>
<Input.Password />
</Form.Item>
)}
<Form.Item name="name" label="姓名" rules={[{ required: true, message: '请输入姓名' }]}>
<Input />
</Form.Item>
{editing && (
<Form.Item name="isActive" label="状态" valuePropName="checked">
<Switch checkedChildren="启用" unCheckedChildren="禁用" />
</Form.Item>
)}
<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 ? ' (系统)' : ''}` }))}
/>
</Form.Item>
</Form>
</Modal>
<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位' }]}>
<Input.Password />
</Form.Item>
</Form>
</Modal>
</div>
);
};
export default UsersPage;

View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
apps/admin/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

30
apps/admin/vite.config.ts Normal file
View File

@@ -0,0 +1,30 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
// https://vite.dev/config/
export default defineConfig({
plugins: [react()],
server: {
port: 3002,
proxy: {
'/api': {
target: 'http://localhost:3003',
changeOrigin: true,
},
},
},
optimizeDeps: {
include: [
'dayjs',
'dayjs/locale/zh-cn',
'dayjs/plugin/customParseFormat',
'dayjs/plugin/advancedFormat',
'dayjs/plugin/weekday',
'dayjs/plugin/localeData',
'dayjs/plugin/weekOfYear',
'dayjs/plugin/weekYear',
'dayjs/plugin/updateLocale',
'antd/es/locale/zh_CN',
],
},
})