Compare commits
119 Commits
42d3f0e27f
...
8f0991a51f
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f0991a51f | |||
| 8ed1682b90 | |||
| 247879f276 | |||
| 44105c1689 | |||
| b093d69f6a | |||
| 50b3608db4 | |||
| e26a8e816e | |||
| 1ca4a4d185 | |||
| 55881863c1 | |||
| bc6b8b0095 | |||
| f6a84fd6b3 | |||
| fb54a6c466 | |||
| 3ccb878092 | |||
| 3992f6c33f | |||
| 18060d44a8 | |||
| 31bcd15189 | |||
| b261ec5930 | |||
| 1f9308c1b7 | |||
| e7e335cd95 | |||
| 97c2ceccde | |||
| 8f28c172c4 | |||
| bc2ff2b270 | |||
| c59fd6ce92 | |||
| 6029d8e2fd | |||
| b0f7883f33 | |||
| 22bc1876d8 | |||
| 40fef906cd | |||
| 9b7a8e37b4 | |||
| 1f63a53222 | |||
| 91035e9f2c | |||
| 7b8691866e | |||
| 570ef7b7e9 | |||
| e87e5e2096 | |||
| 73c1ea7a76 | |||
| 86f126671c | |||
| ef1b46b9f4 | |||
| 1fa336331c | |||
| 6762a7f661 | |||
| c1d065a5e6 | |||
| 4592502876 | |||
| 6b232eb4ed | |||
| c56f297f95 | |||
| b81a06e77c | |||
| 5145c3e4b4 | |||
| d1d5b4681b | |||
| c928ee0f65 | |||
| 4f3b5112b0 | |||
| 0509175a57 | |||
| a6891b32d7 | |||
| 3ee1ebaa1e | |||
| 7e4f06058c | |||
| 64c8964ea4 | |||
| 2da2e68318 | |||
| d2d0ca31ad | |||
| 867e1aab1f | |||
| 8bfb8bf7ae | |||
| 4d4db7626c | |||
| 3240c7cfdd | |||
| 7f355952fd | |||
| 897ffbe018 | |||
| 9b36284f1a | |||
| b624c52b30 | |||
| f7df3bdcf1 | |||
| 3ea7f9a2b7 | |||
| a568d9a871 | |||
| 7666baceff | |||
| db3e4aae84 | |||
| eeacab6130 | |||
| 7f42c12392 | |||
| 0f3fec6b44 | |||
| 00de6cbd3a | |||
| 5b92521426 | |||
| 166066f49f | |||
| e90f73cb60 | |||
| 74e6171e17 | |||
| 0d82c96ec6 | |||
| ba94a0e091 | |||
| f959d2adf2 | |||
| 5700fa556b | |||
| e572f92017 | |||
| 454a0d24c6 | |||
| 54d8d0545e | |||
| 3bed3409fe | |||
| 79d6e97bd8 | |||
| 12fa2e974f | |||
| 87a3f0c554 | |||
| 9b7dee17f3 | |||
| a6fbb71251 | |||
| 513b6c11e4 | |||
| ad6c303369 | |||
| 36a1aca6f0 | |||
| 543fddbc1e | |||
| 115ee5206e | |||
| fb8c897b4f | |||
| 0cfdab95ee | |||
| 33e6cb445c | |||
| a42039d1df | |||
| ff6cb03419 | |||
| 8ba3327074 | |||
| 3790b2dabb | |||
| cd7f9c90b3 | |||
| 3835110204 | |||
| 2b60a10927 | |||
| 5abc27c1f0 | |||
| 3578ba92ed | |||
| 423472daf2 | |||
| 40aad81087 | |||
| 129b011958 | |||
| 5ddd6d305a | |||
| 731f74e51a | |||
| e8dec3b171 | |||
| 7df7695073 | |||
| 903f090293 | |||
| e20e22fc65 | |||
| 17ef1adcaa | |||
| 14eec2852a | |||
| 6e0ad8759f | |||
| 6850777509 | |||
| 9e828756ea |
@@ -1,19 +0,0 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"mcp__codegraph__*"
|
||||
]
|
||||
},
|
||||
"hooks": {
|
||||
"UserPromptSubmit": [
|
||||
{
|
||||
"hooks": [
|
||||
{
|
||||
"type": "command",
|
||||
"command": "codegraph prompt-hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -50,3 +50,7 @@ apps/server/dist/
|
||||
apps/admin/node_modules/
|
||||
apps/admin/dist/
|
||||
.codegraph/
|
||||
.worktrees/
|
||||
.claude/
|
||||
.omp/
|
||||
.superpowers/
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
# 恭学教育学生管理系统 — 项目约束
|
||||
|
||||
完整 PRD 文档:`../../Downloads/PRD-恭学教育学生管理系统.md`(相对项目根目录)
|
||||
|
||||
## 技术栈
|
||||
|
||||
| 层级 | 技术 |
|
||||
|------|------|
|
||||
| 架构 | Monorepo (Turborepo) |
|
||||
| 前端 | React 19 + Vite + Ant Design 6 + ECharts |
|
||||
| 后端 | NestJS 11 + TypeORM 0.3 + JWT + Passport |
|
||||
| 数据库 | 开发 SQLite / 生产 MySQL 8 |
|
||||
| 部署 | Docker Compose (MySQL + NestJS + Nginx) |
|
||||
|
||||
## 核心用户角色
|
||||
|
||||
- 超级管理员:系统配置、账号管理、全部数据
|
||||
- 教职工(宿管+财务):宿舍管理、费用录入、账单生成、指定部门
|
||||
- 班主任:本班学生信息、档案、费用、考勤
|
||||
- 学生:仅本人数据
|
||||
|
||||
## 业务域
|
||||
|
||||
- 学员管理域:学生管理、学生档案、考勤管理
|
||||
- 教务管理域:班级管理(新)、排课管理(新)、教师管理、教室管理
|
||||
- 住宿管理域:宿舍管理、入住管理
|
||||
- 财务运营域:费用管理、账单管理、押金管理、租赁方管理、数据面板
|
||||
|
||||
## 横切关注点
|
||||
|
||||
- RBAC 权限控制
|
||||
- 操作日志(所有关键写操作)
|
||||
- 钉钉/企业微信集成
|
||||
- 报表导出、批量导入
|
||||
|
||||
## 上线优先级
|
||||
|
||||
**P0(阻塞上线)**:班级管理、排课管理、宿舍管理增强、账单管理(长租)、操作日志
|
||||
**P1(上线前完成)**:考勤管理前端、数据面板、RBAC 扩展、教室管理增强、organization→tenant_id
|
||||
**P2(可迭代)**:报表导出、教师档案增强、多班型对比、押金分期、第三方集成
|
||||
|
||||
## 编码规范
|
||||
|
||||
- 所有涉及大量数据的列表/表单页面必须有详细的筛选方案
|
||||
- 敏感信息(手机号、身份证)需脱敏展示,查看时二次确认+记录日志
|
||||
- 遵循现有 NestJS 模块结构:每个业务模块独立目录,含 entity/dto/service/controller
|
||||
- 前端页面放在 `apps/admin/src/pages/` 下,每个模块独立目录
|
||||
@@ -31,6 +31,7 @@
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"@vitejs/plugin-react": "^6.0.1",
|
||||
"@vitest/browser": "^4.1.10",
|
||||
"@vitest/browser-playwright": "^4.1.10",
|
||||
"@vitest/coverage-v8": "^4.1.10",
|
||||
"playwright": "^1.61.1",
|
||||
"typescript": "~6.0.2",
|
||||
|
||||
@@ -1,35 +1,37 @@
|
||||
import React from 'react';
|
||||
import React, { Suspense, lazy } from 'react';
|
||||
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||
import { ConfigProvider, App as AntdApp } from 'antd';
|
||||
import { ConfigProvider, App as AntdApp, Spin } 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 ClassroomsPage from './pages/Classrooms';
|
||||
import DepositsPage from './pages/Deposits';
|
||||
import TeachersPage from './pages/Teachers';
|
||||
import StudentProfilePage from './pages/StudentProfile';
|
||||
import ClassesPage from './pages/Classes';
|
||||
import ClassDetailPage from './pages/Classes/detail';
|
||||
import TenantsPage from './pages/Tenants';
|
||||
import ClassroomRentalsPage from './pages/ClassroomRentals';
|
||||
import ClassroomSchedulePage from './pages/ClassroomSchedule';
|
||||
import SchedulesPage from './pages/Schedules';
|
||||
import RolesPage from './pages/Roles';
|
||||
import PermissionsPage from './pages/Permissions';
|
||||
import AttendancePage from './pages/Attendance';
|
||||
import TeacherWorkspacePage from './pages/TeacherWorkspace';
|
||||
import NotificationsPage from './pages/Notifications';
|
||||
import DepartmentsPage from './pages/Departments';
|
||||
import PermissionRoute from './components/PermissionRoute';
|
||||
import AppMessageBridge from './ui/AppMessageBridge';
|
||||
|
||||
const LoginPage = lazy(() => import('./pages/Login'));
|
||||
const DashboardPage = lazy(() => import('./pages/Dashboard'));
|
||||
const StudentsPage = lazy(() => import('./pages/Students'));
|
||||
const RoomsPage = lazy(() => import('./pages/Rooms'));
|
||||
const OccupanciesPage = lazy(() => import('./pages/Occupancies'));
|
||||
const ExpensesPage = lazy(() => import('./pages/Expenses'));
|
||||
const BillsPage = lazy(() => import('./pages/Bills'));
|
||||
const RoomVisualPage = lazy(() => import('./pages/RoomVisual'));
|
||||
const OperationLogsPage = lazy(() => import('./pages/OperationLogs'));
|
||||
const UsersPage = lazy(() => import('./pages/Users'));
|
||||
const ClassroomsPage = lazy(() => import('./pages/Classrooms'));
|
||||
const DepositsPage = lazy(() => import('./pages/Deposits'));
|
||||
const TeachersPage = lazy(() => import('./pages/Teachers'));
|
||||
const StudentProfilePage = lazy(() => import('./pages/StudentProfile'));
|
||||
const ClassesPage = lazy(() => import('./pages/Classes'));
|
||||
const ClassDetailPage = lazy(() => import('./pages/Classes/detail'));
|
||||
const OrganizationsPage = lazy(() => import('./pages/Organizations'))
|
||||
const ClassroomRentalsPage = lazy(() => import('./pages/ClassroomRentals'));
|
||||
const ClassroomSchedulePage = lazy(() => import('./pages/ClassroomSchedule'));
|
||||
const SchedulesPage = lazy(() => import('./pages/Schedules'));
|
||||
const RolesPage = lazy(() => import('./pages/Roles'));
|
||||
const PermissionsPage = lazy(() => import('./pages/Permissions'));
|
||||
const AttendancePage = lazy(() => import('./pages/Attendance'));
|
||||
const TeacherWorkspacePage = lazy(() => import('./pages/TeacherWorkspace'));
|
||||
const NotificationsPage = lazy(() => import('./pages/Notifications'));
|
||||
const IntegrationConfigPage = lazy(() => import('./pages/IntegrationConfig'));
|
||||
|
||||
const PrivateRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
const token = localStorage.getItem('token');
|
||||
@@ -51,7 +53,9 @@ const App: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<AntdApp>
|
||||
<AppMessageBridge />
|
||||
<BrowserRouter>
|
||||
<Suspense fallback={<div style={{ minHeight: '40vh', display: 'grid', placeItems: 'center' }}><Spin size="large" /></div>}>
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route
|
||||
@@ -201,10 +205,10 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
<Route
|
||||
path="tenants"
|
||||
path="organizations"
|
||||
element={
|
||||
<PermissionRoute permission="tenant:view">
|
||||
<TenantsPage />
|
||||
<PermissionRoute permission="organization:view">
|
||||
<OrganizationsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
@@ -252,18 +256,27 @@ const App: React.FC = () => {
|
||||
}
|
||||
/>
|
||||
|
||||
<Route path="notifications" element={<NotificationsPage />} />
|
||||
<Route
|
||||
path="notifications"
|
||||
element={
|
||||
<PermissionRoute permission="notification:view">
|
||||
<NotificationsPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
|
||||
|
||||
<Route
|
||||
path="departments"
|
||||
path="integration-config"
|
||||
element={
|
||||
<PermissionRoute permission="department:view">
|
||||
<DepartmentsPage />
|
||||
<PermissionRoute permission="integration:read">
|
||||
<IntegrationConfigPage />
|
||||
</PermissionRoute>
|
||||
}
|
||||
/>
|
||||
</Route>
|
||||
</Routes>
|
||||
</Suspense>
|
||||
</BrowserRouter>
|
||||
</AntdApp>
|
||||
</ConfigProvider>
|
||||
|
||||
@@ -10,10 +10,6 @@ instance.interceptors.request.use((config) => {
|
||||
if (token) {
|
||||
config.headers.Authorization = `Bearer ${token}`;
|
||||
}
|
||||
const campusId = localStorage.getItem('currentCampusId');
|
||||
if (campusId) {
|
||||
config.headers['X-Campus-Id'] = campusId;
|
||||
}
|
||||
return config;
|
||||
});
|
||||
|
||||
|
||||
15
apps/admin/src/auth/permission-store.ts
Normal file
15
apps/admin/src/auth/permission-store.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
export const PERMISSIONS_UPDATED_EVENT = 'permissions-updated';
|
||||
|
||||
export function readPermissions(): string[] {
|
||||
try {
|
||||
const value = JSON.parse(localStorage.getItem('permissions') || '[]');
|
||||
return Array.isArray(value) ? value.filter((item): item is string => typeof item === 'string') : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function writePermissions(permissions: string[]): void {
|
||||
localStorage.setItem('permissions', JSON.stringify([...new Set(permissions)]));
|
||||
window.dispatchEvent(new Event(PERMISSIONS_UPDATED_EVENT));
|
||||
}
|
||||
@@ -1,36 +0,0 @@
|
||||
import React from 'react';
|
||||
import { Select, Typography } from 'antd';
|
||||
import { EnvironmentOutlined } from '@ant-design/icons';
|
||||
import { useCampus } from '../hooks/useCampus';
|
||||
|
||||
const CampusSwitcher: React.FC = () => {
|
||||
const { campuses, currentId, switchCampus, loading } = useCampus();
|
||||
|
||||
if (campuses.length <= 1) {
|
||||
return (
|
||||
<Typography.Text style={{ color: '#fff', marginRight: 24 }}>
|
||||
<EnvironmentOutlined style={{ marginRight: 4 }} />
|
||||
{campuses[0]?.name || '主校区'}
|
||||
</Typography.Text>
|
||||
);
|
||||
}
|
||||
|
||||
const options = [
|
||||
...campuses.map((c) => ({ value: String(c.id), label: c.name })),
|
||||
{ value: '', label: '全部校区' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Select
|
||||
value={currentId || undefined}
|
||||
onChange={switchCampus}
|
||||
options={options}
|
||||
loading={loading}
|
||||
style={{ minWidth: 140, marginRight: 24 }}
|
||||
variant="borderless"
|
||||
popupMatchSelectWidth={false}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default CampusSwitcher;
|
||||
56
apps/admin/src/components/ECharts.tsx
Normal file
56
apps/admin/src/components/ECharts.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import React, { useEffect, useRef } from 'react';
|
||||
import * as echarts from 'echarts/core';
|
||||
export type EChartsOption = Record<string, unknown>;
|
||||
import { BarChart, CustomChart, LineChart, PieChart } from 'echarts/charts';
|
||||
import {
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
} from 'echarts/components';
|
||||
import { CanvasRenderer } from 'echarts/renderers';
|
||||
|
||||
echarts.use([
|
||||
BarChart,
|
||||
CustomChart,
|
||||
LineChart,
|
||||
PieChart,
|
||||
DataZoomComponent,
|
||||
GridComponent,
|
||||
LegendComponent,
|
||||
TooltipComponent,
|
||||
VisualMapComponent,
|
||||
CanvasRenderer,
|
||||
]);
|
||||
|
||||
interface EChartsProps {
|
||||
option: EChartsOption;
|
||||
style?: React.CSSProperties;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const ECharts: React.FC<EChartsProps> = ({ option, style, className }) => {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return;
|
||||
const chart = echarts.init(containerRef.current);
|
||||
chart.setOption(option);
|
||||
const observer = new ResizeObserver(() => chart.resize());
|
||||
observer.observe(containerRef.current);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
chart.dispose();
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const chart = containerRef.current ? echarts.getInstanceByDom(containerRef.current) : undefined;
|
||||
chart?.setOption(option, true);
|
||||
}, [option]);
|
||||
|
||||
return <div ref={containerRef} className={className} style={style} />;
|
||||
};
|
||||
|
||||
export default ECharts;
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
Upload,
|
||||
Tag,
|
||||
Space,
|
||||
message,
|
||||
Popconfirm,
|
||||
Empty,
|
||||
Row,
|
||||
@@ -36,6 +35,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { useViewSensitive } from '../../hooks/useViewSensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -53,7 +53,6 @@ interface ProfileData {
|
||||
targetMajor?: string;
|
||||
subjectDirection?: string;
|
||||
grade?: string;
|
||||
campusLocation?: string;
|
||||
profileDate?: string;
|
||||
notes?: string;
|
||||
}
|
||||
@@ -220,7 +219,6 @@ const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefr
|
||||
targetMajor: data?.targetMajor ?? undefined,
|
||||
subjectDirection: data?.subjectDirection ?? undefined,
|
||||
grade: data?.grade ?? undefined,
|
||||
campusLocation: data?.campusLocation ?? undefined,
|
||||
profileDate: data?.profileDate ? dayjs(data.profileDate) : undefined,
|
||||
notes: data?.notes ?? undefined,
|
||||
}}
|
||||
@@ -238,9 +236,6 @@ const ProfileTab: React.FC<{ data: ProfileData | null; studentId: number; onRefr
|
||||
<Form.Item name="grade" label="年级">
|
||||
<Input placeholder="如:高三" />
|
||||
</Form.Item>
|
||||
<Form.Item name="campusLocation" label="校区">
|
||||
<Input placeholder="请输入校区" />
|
||||
</Form.Item>
|
||||
<Form.Item name="profileDate" label="建档日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
@@ -635,7 +630,7 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
||||
|
||||
const handleDelete = async (attachmentId: number) => {
|
||||
try {
|
||||
await api.delete(`/archive/${studentId}/attachments/${attachmentId}`);
|
||||
await api.delete(`/archive/attachments/${attachmentId}`);
|
||||
message.success('已删除');
|
||||
onRefresh();
|
||||
} catch (e: unknown) {
|
||||
@@ -659,9 +654,18 @@ const AttachmentsTab: React.FC<TabProps & { data: AttachmentRecord[] }> = ({ dat
|
||||
<Button
|
||||
size="small"
|
||||
icon={<EyeOutlined />}
|
||||
onClick={() => {
|
||||
const token = localStorage.getItem('token');
|
||||
window.open(`/api/archive/${studentId}/attachments/${record.id}?token=${token}`, '_blank');
|
||||
onClick={async () => {
|
||||
try {
|
||||
const blob = await api.get<Blob>(`/archive/${studentId}/attachments/${record.id}`, {
|
||||
responseType: 'blob',
|
||||
});
|
||||
const url = URL.createObjectURL(blob);
|
||||
window.open(url, '_blank');
|
||||
setTimeout(() => URL.revokeObjectURL(url), 60_000);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '查看失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
查看
|
||||
@@ -903,9 +907,6 @@ const StudentProfileContent: React.FC<StudentProfileContentProps> = ({
|
||||
{profile?.subjectDirection && (
|
||||
<Descriptions.Item label="选科方向">{profile.subjectDirection}</Descriptions.Item>
|
||||
)}
|
||||
{profile?.campusLocation && (
|
||||
<Descriptions.Item label="校区">{profile.campusLocation}</Descriptions.Item>
|
||||
)}
|
||||
</Descriptions>
|
||||
|
||||
<Tabs
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
interface Department {
|
||||
id: number;
|
||||
name: string;
|
||||
type: string;
|
||||
parentId: number | null;
|
||||
}
|
||||
|
||||
export function useCampus() {
|
||||
const [campuses, setCampuses] = useState<Department[]>([]);
|
||||
const [currentId, setCurrentId] = useState<string>(
|
||||
() => localStorage.getItem('currentCampusId') || ''
|
||||
);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
const fetchCampuses = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get('/departments') as unknown as Department[];
|
||||
const campusList = data.filter((d) => d.type === 'campus');
|
||||
setCampuses(campusList);
|
||||
if (!currentId && campusList.length > 0) {
|
||||
setCurrentId(String(campusList[0].id));
|
||||
localStorage.setItem('currentCampusId', String(campusList[0].id));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
finally { setLoading(false); }
|
||||
}, [currentId]);
|
||||
|
||||
useEffect(() => { fetchCampuses(); }, []);
|
||||
|
||||
const switchCampus = useCallback((id: string) => {
|
||||
setCurrentId(id);
|
||||
localStorage.setItem('currentCampusId', id);
|
||||
window.dispatchEvent(new CustomEvent('campus-changed', { detail: id }));
|
||||
}, []);
|
||||
|
||||
return { campuses, currentId, switchCampus, loading };
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
interface Notification {
|
||||
id: number;
|
||||
type: string;
|
||||
title: string;
|
||||
content: string;
|
||||
link: string | null;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export function useNotifications() {
|
||||
const [unreadCount, setUnreadCount] = useState(0);
|
||||
const [latestNotification, setLatestNotification] = useState<Notification | null>(null);
|
||||
|
||||
const fetchUnreadCount = useCallback(async () => {
|
||||
try {
|
||||
const data = await api.get('/notifications/unread-count') as unknown as { count: number };
|
||||
setUnreadCount(data.count);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUnreadCount();
|
||||
|
||||
const token = localStorage.getItem('token');
|
||||
if (!token) return;
|
||||
|
||||
const es = new EventSource(`/api/notifications/stream?token=${encodeURIComponent(token)}`);
|
||||
|
||||
es.onmessage = (event) => {
|
||||
try {
|
||||
const notification = JSON.parse(event.data) as Notification;
|
||||
setUnreadCount((c) => c + 1);
|
||||
setLatestNotification(notification);
|
||||
} catch {
|
||||
// ignore parse errors
|
||||
}
|
||||
};
|
||||
|
||||
const pollRef = { current: undefined as number | undefined };
|
||||
|
||||
es.onerror = () => {
|
||||
es.close();
|
||||
pollRef.current = setInterval(() => {
|
||||
fetchUnreadCount();
|
||||
}, 60_000);
|
||||
};
|
||||
|
||||
return () => {
|
||||
es.close();
|
||||
if (pollRef.current !== undefined) clearInterval(pollRef.current);
|
||||
};
|
||||
}, [fetchUnreadCount]);
|
||||
|
||||
const markAsRead = useCallback(async (id: number) => {
|
||||
try {
|
||||
await api.put(`/notifications/${id}/read`);
|
||||
setUnreadCount((c) => Math.max(0, c - 1));
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
const markAllAsRead = useCallback(async () => {
|
||||
try {
|
||||
await api.put('/notifications/read-all');
|
||||
setUnreadCount(0);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { unreadCount, latestNotification, markAsRead, markAllAsRead };
|
||||
}
|
||||
@@ -1,21 +1,31 @@
|
||||
import { useMemo } from 'react';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { PERMISSIONS_UPDATED_EVENT, readPermissions } from '../auth/permission-store';
|
||||
|
||||
export function usePermission() {
|
||||
const permissions: string[] = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem('permissions') || '[]');
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const [permissions, setPermissions] = useState<string[]>(readPermissions);
|
||||
|
||||
useEffect(() => {
|
||||
const refresh = () => setPermissions(readPermissions());
|
||||
window.addEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
|
||||
window.addEventListener('storage', refresh);
|
||||
return () => {
|
||||
window.removeEventListener(PERMISSIONS_UPDATED_EVENT, refresh);
|
||||
window.removeEventListener('storage', refresh);
|
||||
};
|
||||
}, []);
|
||||
|
||||
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));
|
||||
const hasPermission = useCallback(
|
||||
(code: string): boolean => permissions.includes(code),
|
||||
[permissions],
|
||||
);
|
||||
const hasAnyPermission = useCallback(
|
||||
(...codes: string[]): boolean => codes.some((code) => permissions.includes(code)),
|
||||
[permissions],
|
||||
);
|
||||
const hasAllPermissions = useCallback(
|
||||
(...codes: string[]): boolean => codes.every((code) => permissions.includes(code)),
|
||||
[permissions],
|
||||
);
|
||||
|
||||
return { permissions, hasPermission, hasAnyPermission, hasAllPermissions };
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useCallback } from 'react';
|
||||
import { Modal, message } from 'antd';
|
||||
import { Modal } from 'antd';
|
||||
import api from '../api';
|
||||
import { message } from '../ui/app-message';
|
||||
|
||||
/**
|
||||
* Shared hook for viewing sensitive student info (phone / ID number).
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useCallback, useMemo, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Outlet, useNavigate, useLocation } from 'react-router-dom';
|
||||
import { Layout, Menu, Button, Avatar, Dropdown, Drawer, Grid } from 'antd';
|
||||
import {
|
||||
@@ -25,10 +25,12 @@ import {
|
||||
CheckCircleOutlined,
|
||||
LaptopOutlined,
|
||||
BellOutlined,
|
||||
ApiOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { usePermission } from '../hooks/usePermission';
|
||||
import api from '../api';
|
||||
import { writePermissions } from '../auth/permission-store';
|
||||
import NotificationBell from '../components/NotificationBell';
|
||||
import CampusSwitcher from '../components/CampusSwitcher';
|
||||
|
||||
const { Header, Sider, Content } = Layout;
|
||||
|
||||
@@ -77,18 +79,18 @@ const allMenuItems: MenuItemType[] = [
|
||||
children: [
|
||||
{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理', permission: 'schedule:view' },
|
||||
{ key: '/teacher-workspace', icon: <LaptopOutlined />, label: '教师工作台', permission: 'class: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: '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: '/organizations', icon: <TagsOutlined />, label: '机构管理', permission: 'organization:view' },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -108,11 +110,11 @@ const allMenuItems: MenuItemType[] = [
|
||||
label: '系统管理',
|
||||
permission: 'log:view',
|
||||
children: [
|
||||
{ key: '/departments', icon: <HomeOutlined />, label: '校区/部门', permission: 'department:view' },
|
||||
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心', permission: 'notification: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: '/integration-config', icon: <ApiOutlined />, label: '钉钉集成配置', permission: 'integration:read' },
|
||||
{ key: '/users', icon: <SettingOutlined />, label: '账号管理', permission: 'user:view' },
|
||||
],
|
||||
},
|
||||
@@ -121,11 +123,28 @@ const allMenuItems: MenuItemType[] = [
|
||||
const MainLayout: React.FC = () => {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [openKeys, setOpenKeys] = useState<string[]>([]);
|
||||
const prevPathname = useRef('');
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const user = useMemo(() => JSON.parse(localStorage.getItem('user') || '{}'), []);
|
||||
const { hasPermission } = usePermission();
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<{ id: number; username: string; permissions: string[]; roles?: string[] }>('/auth/profile')
|
||||
.then((profile) => {
|
||||
if (cancelled) return;
|
||||
writePermissions(profile.permissions || []);
|
||||
const cachedUser = JSON.parse(localStorage.getItem('user') || '{}');
|
||||
localStorage.setItem('user', JSON.stringify({ ...cachedUser, ...profile }));
|
||||
})
|
||||
.catch(() => {
|
||||
// The API interceptor handles expired/invalid sessions.
|
||||
});
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
const screens = Grid.useBreakpoint();
|
||||
const isMobile = !screens.sm; // < 576px (仅 xs)
|
||||
const isTablet = (screens.sm || screens.md) && !screens.lg; // 576-991px
|
||||
@@ -183,7 +202,20 @@ const MainLayout: React.FC = () => {
|
||||
};
|
||||
|
||||
const selectedKeys = useMemo(() => findSelectedKeys(menuItems, location.pathname), [menuItems, location.pathname]);
|
||||
const openKeys = useMemo(() => findOpenKeys(menuItems, location.pathname), [menuItems, location.pathname]);
|
||||
// 路径变化时同步展开的菜单(不干扰用户手动展开/收起)
|
||||
useEffect(() => {
|
||||
if (location.pathname !== prevPathname.current) {
|
||||
prevPathname.current = location.pathname;
|
||||
setOpenKeys(findOpenKeys(menuItems, location.pathname));
|
||||
}
|
||||
}, [location.pathname, menuItems]);
|
||||
|
||||
const handleOpenChange = useCallback((keys: string[]) => {
|
||||
// 只保留最新打开的一个子菜单
|
||||
const latestKey = keys[keys.length - 1];
|
||||
setOpenKeys(latestKey ? [latestKey] : []);
|
||||
}, []);
|
||||
|
||||
|
||||
|
||||
const transformToMenuItems = (items: MenuItemType[]): any[] => {
|
||||
@@ -199,7 +231,8 @@ const MainLayout: React.FC = () => {
|
||||
theme="light"
|
||||
mode="inline"
|
||||
selectedKeys={selectedKeys}
|
||||
defaultOpenKeys={openKeys}
|
||||
openKeys={openKeys}
|
||||
onOpenChange={handleOpenChange}
|
||||
items={transformToMenuItems(menuItems)}
|
||||
onClick={({ key }) => handleMenuClick(key)}
|
||||
style={{ border: 'none' }}
|
||||
@@ -271,9 +304,8 @@ const MainLayout: React.FC = () => {
|
||||
}
|
||||
onClick={() => (isMobile || isTablet ? setDrawerOpen(true) : setCollapsed(!collapsed))}
|
||||
/>
|
||||
{isDesktop && <CampusSwitcher />}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 16 }}>
|
||||
<NotificationBell />
|
||||
{hasPermission('notification:view') && <NotificationBell />}
|
||||
<Dropdown
|
||||
menu={{
|
||||
items: [
|
||||
|
||||
@@ -1,7 +1,20 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Modal, Form, DatePicker, Select, Space, message,
|
||||
Tag, Card, Input, Tooltip, Row, Col, Tabs, Alert,
|
||||
Table,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
DatePicker,
|
||||
Select,
|
||||
Space,
|
||||
Tag,
|
||||
Card,
|
||||
Input,
|
||||
Tooltip,
|
||||
Row,
|
||||
Col,
|
||||
Tabs,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -13,6 +26,8 @@ import {
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { usePermission } from '../../hooks/usePermission';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -51,7 +66,6 @@ const SOURCE_OPTIONS = [
|
||||
{ value: 'dingtalk', label: '钉钉导入' },
|
||||
];
|
||||
|
||||
|
||||
const MATCH_STATUS_MAP: Record<string, { text: string; color: string }> = {
|
||||
unmatched: { text: '未处理', color: 'default' },
|
||||
pending: { text: '待匹配', color: 'orange' },
|
||||
@@ -98,16 +112,28 @@ interface BatchRecordInput {
|
||||
interface DingRecord {
|
||||
id: number;
|
||||
dingUserId: string;
|
||||
checkTime: string;
|
||||
rawStatus: string;
|
||||
attendanceDate: string;
|
||||
checkInTime?: string;
|
||||
checkOutTime?: string;
|
||||
timeResult: string;
|
||||
matchStatus: string;
|
||||
studentId?: number;
|
||||
}
|
||||
interface AlertItem { studentId: number; studentName: string; className: string; type: string; count: number; lastDate: string }
|
||||
interface AlertItem {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
className: string;
|
||||
type: string;
|
||||
count: number;
|
||||
lastDate: string;
|
||||
}
|
||||
|
||||
// ── Component ──
|
||||
|
||||
const AttendancePage: React.FC = () => {
|
||||
const { hasAnyPermission } = usePermission();
|
||||
const canManageAllAttendance = hasAnyPermission('class:edit', 'attendance:edit');
|
||||
|
||||
// ── State ──
|
||||
const [records, setRecords] = useState<AttendanceRecordItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -127,7 +153,11 @@ const AttendancePage: React.FC = () => {
|
||||
// View toggle
|
||||
const [calendarView, setCalendarView] = useState(false);
|
||||
const [calendarData, setCalendarData] = useState<
|
||||
{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]
|
||||
{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
}[]
|
||||
>([]);
|
||||
const [calendarLoading, setCalendarLoading] = useState(false);
|
||||
|
||||
@@ -143,8 +173,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [dingMatchStatus, setDingMatchStatus] = useState<string | undefined>(undefined);
|
||||
// DingTalk import
|
||||
const [importModalOpen, setImportModalOpen] = useState(false);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>(null);
|
||||
const [importAutoMatch, setImportAutoMatch] = useState(true);
|
||||
const [importClassId, setImportClassId] = useState<number | undefined>(undefined);
|
||||
const [importClassOptions, setImportClassOptions] = useState<ClassOption[]>([]);
|
||||
const [importDateRange, setImportDateRange] = useState<[Dayjs, Dayjs] | null>([dayjs(), dayjs()]);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [importProgressMsg, setImportProgressMsg] = useState('');
|
||||
|
||||
@@ -152,7 +183,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [matchModalOpen, setMatchModalOpen] = useState(false);
|
||||
const [matchRecordId, setMatchRecordId] = useState<number | null>(null);
|
||||
const [matchStudentSearch, setMatchStudentSearch] = useState('');
|
||||
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>([]);
|
||||
const [matchStudentResults, setMatchStudentResults] = useState<{ id: number; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [matchStudentLoading, setMatchStudentLoading] = useState(false);
|
||||
const [matchSubmitting, setMatchSubmitting] = useState(false);
|
||||
|
||||
@@ -165,7 +198,9 @@ const AttendancePage: React.FC = () => {
|
||||
const [batchRemark, setBatchRemark] = useState('');
|
||||
const [batchSubmitting, setBatchSubmitting] = useState(false);
|
||||
const [studentSearch, setStudentSearch] = useState('');
|
||||
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>([]);
|
||||
const [studentSearchResults, setStudentSearchResults] = useState<{ id: number; name: string }[]>(
|
||||
[],
|
||||
);
|
||||
const [studentSearchLoading, setStudentSearchLoading] = useState(false);
|
||||
|
||||
// Edit modal
|
||||
@@ -173,11 +208,14 @@ const AttendancePage: React.FC = () => {
|
||||
const [editRecord, setEditRecord] = useState<AttendanceRecordItem | null>(null);
|
||||
const [editForm] = Form.useForm();
|
||||
|
||||
|
||||
// ── Edit record ──
|
||||
const handleEdit = (record: AttendanceRecordItem) => {
|
||||
setEditRecord(record);
|
||||
editForm.setFieldsValue({ session: record.session, status: record.status, remark: record.remark });
|
||||
editForm.setFieldsValue({
|
||||
session: record.session,
|
||||
status: record.status,
|
||||
remark: record.remark,
|
||||
});
|
||||
setEditModalOpen(true);
|
||||
};
|
||||
|
||||
@@ -216,7 +254,10 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterSource) params.source = filterSource;
|
||||
|
||||
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>('/attendance-records', { params });
|
||||
const data = await api.get<{ list: AttendanceRecordItem[]; total: number }>(
|
||||
'/attendance-records',
|
||||
{ params },
|
||||
);
|
||||
setRecords(data.list);
|
||||
setTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -235,7 +276,13 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setCalendarLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }[]>('/attendance-records/calendar', {
|
||||
const res = await api.get<
|
||||
{
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
}[]
|
||||
>('/attendance-records/calendar', {
|
||||
params: { classId: filterClassId },
|
||||
});
|
||||
setCalendarData(res);
|
||||
@@ -257,7 +304,9 @@ const AttendancePage: React.FC = () => {
|
||||
if (filterDateRange?.[1]) params.dateTo = filterDateRange[1].format('YYYY-MM-DD');
|
||||
if (dingMatchStatus) params.matchStatus = dingMatchStatus;
|
||||
|
||||
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', { params });
|
||||
const data = await api.get<{ list: DingRecord[]; total: number }>('/ding-attendance-raw', {
|
||||
params,
|
||||
});
|
||||
setDingRecords(data.list);
|
||||
setDingTotal(data.total);
|
||||
} catch (e: unknown) {
|
||||
@@ -268,8 +317,42 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, [dingPage, dingPageSize, filterClassId, filterDateRange, dingMatchStatus]);
|
||||
|
||||
// ── Classes the current teacher may import from DingTalk ──
|
||||
const fetchImportClasses = useCallback(async () => {
|
||||
try {
|
||||
const options = await api.get<ClassOption[]>('/attendance-records/import/dingtalk/classes');
|
||||
setImportClassOptions(options);
|
||||
setImportClassId(
|
||||
(current) => current ?? (options.length === 1 ? options[0].classId : undefined),
|
||||
);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
setImportClassOptions([]);
|
||||
message.error(err?.message || '加载可拉取班级失败,请刷新页面后重试');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const openDingTalkImportModal = () => {
|
||||
const today = dayjs();
|
||||
setImportDateRange([today, today]);
|
||||
setImportProgressMsg('');
|
||||
setImportModalOpen(true);
|
||||
void fetchImportClasses();
|
||||
};
|
||||
|
||||
const closeDingTalkImportModal = () => {
|
||||
setImportModalOpen(false);
|
||||
setImportClassId(undefined);
|
||||
setImportDateRange([dayjs(), dayjs()]);
|
||||
setImportProgressMsg('');
|
||||
};
|
||||
|
||||
// ── DingTalk import handler ──
|
||||
const handleImportDingTalk = useCallback(async () => {
|
||||
if (!importClassId) {
|
||||
message.warning('请选择要拉取考勤的班级');
|
||||
return;
|
||||
}
|
||||
if (!importDateRange?.[0] || !importDateRange?.[1]) {
|
||||
message.warning('请选择导入日期范围');
|
||||
return;
|
||||
@@ -279,11 +362,16 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
try {
|
||||
const result = await api.post<{
|
||||
success: boolean; imported: number; skipped: number; matched: number; errors: string[]; duration: number;
|
||||
success: boolean;
|
||||
imported: number;
|
||||
skipped: number;
|
||||
matched: number;
|
||||
errors: string[];
|
||||
duration: number;
|
||||
}>('/attendance-records/import/dingtalk', {
|
||||
classId: importClassId,
|
||||
start: importDateRange[0].format('YYYY-MM-DD'),
|
||||
end: importDateRange[1].format('YYYY-MM-DD'),
|
||||
autoMatch: importAutoMatch,
|
||||
});
|
||||
|
||||
setImportProgressMsg('');
|
||||
@@ -305,7 +393,7 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, [importDateRange, importAutoMatch, fetchDingRecords]);
|
||||
}, [canManageAllAttendance, importClassId, importDateRange, fetchDingRecords]);
|
||||
|
||||
// ── Effects ──
|
||||
useEffect(() => {
|
||||
@@ -313,10 +401,15 @@ const AttendancePage: React.FC = () => {
|
||||
}, [fetchClasses]);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
api.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => { if (!cancelled) setAlerts(data); })
|
||||
api
|
||||
.get<AlertItem[]>('/attendance-records/alerts')
|
||||
.then((data) => {
|
||||
if (!cancelled) setAlerts(data);
|
||||
})
|
||||
.catch(() => {});
|
||||
return () => { cancelled = true; };
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -342,8 +435,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setStudentSearchLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ list?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : (data.list ?? []);
|
||||
setStudentSearchResults(list);
|
||||
} catch {
|
||||
setStudentSearchResults([]);
|
||||
@@ -397,7 +492,15 @@ const AttendancePage: React.FC = () => {
|
||||
} finally {
|
||||
setBatchSubmitting(false);
|
||||
}
|
||||
}, [batchStudents, batchDate, batchSession, batchStatus, batchRemark, filterClassId, fetchRecords]);
|
||||
}, [
|
||||
batchStudents,
|
||||
batchDate,
|
||||
batchSession,
|
||||
batchStatus,
|
||||
batchRemark,
|
||||
filterClassId,
|
||||
fetchRecords,
|
||||
]);
|
||||
|
||||
// ── Reset filters ──
|
||||
const handleReset = useCallback(() => {
|
||||
@@ -425,8 +528,10 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
setMatchStudentLoading(true);
|
||||
try {
|
||||
const data = await api.get<{ list?: { id: number; name: string }[] } | { id: number; name: string }[]>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : data.list ?? [];
|
||||
const data = await api.get<
|
||||
{ list?: { id: number; name: string }[] } | { id: number; name: string }[]
|
||||
>('/students', { params: { search: value, pageSize: 10 } });
|
||||
const list = Array.isArray(data) ? data : (data.list ?? []);
|
||||
setMatchStudentResults(list);
|
||||
} catch {
|
||||
setMatchStudentResults([]);
|
||||
@@ -435,22 +540,25 @@ const AttendancePage: React.FC = () => {
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleMatchSubmit = useCallback(async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
}, [matchRecordId, fetchDingRecords]);
|
||||
const handleMatchSubmit = useCallback(
|
||||
async (studentId: number) => {
|
||||
if (matchRecordId === null) return;
|
||||
setMatchSubmitting(true);
|
||||
try {
|
||||
await api.post(`/ding-attendance-raw/${matchRecordId}/match`, { studentId });
|
||||
message.success('匹配成功');
|
||||
setMatchModalOpen(false);
|
||||
setMatchRecordId(null);
|
||||
fetchDingRecords();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配失败');
|
||||
} finally {
|
||||
setMatchSubmitting(false);
|
||||
}
|
||||
},
|
||||
[matchRecordId, fetchDingRecords],
|
||||
);
|
||||
|
||||
// ── Report download ──
|
||||
const handleExportReport = useCallback(() => {
|
||||
@@ -491,15 +599,17 @@ const AttendancePage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '打卡时间',
|
||||
dataIndex: 'checkTime',
|
||||
key: 'checkTime',
|
||||
width: 160,
|
||||
render: (v: string) => (v ? dayjs(v).format('YYYY-MM-DD HH:mm:ss') : '-'),
|
||||
width: 180,
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
const value = record.checkInTime || record.checkOutTime;
|
||||
return value ? dayjs(value).format('YYYY-MM-DD HH:mm:ss') : record.attendanceDate || '-';
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '打卡状态',
|
||||
dataIndex: 'rawStatus',
|
||||
key: 'rawStatus',
|
||||
dataIndex: 'timeResult',
|
||||
key: 'timeResult',
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
@@ -519,9 +629,14 @@ const AttendancePage: React.FC = () => {
|
||||
render: (_: unknown, record: DingRecord) => {
|
||||
if (record.matchStatus === 'matched') return <span>-</span>;
|
||||
return (
|
||||
<Button size="small" type="link" onClick={() => openMatchModal(record.id)}>
|
||||
<PermissionButton
|
||||
permission="attendance:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => openMatchModal(record.id)}
|
||||
>
|
||||
匹配
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
@@ -596,7 +711,11 @@ const AttendancePage: React.FC = () => {
|
||||
width: 80,
|
||||
fixed: 'right' as const,
|
||||
render: (_: unknown, record: AttendanceRecordItem) => (
|
||||
<PermissionButton permission="attendance:edit" size="small" onClick={() => handleEdit(record)}>
|
||||
<PermissionButton
|
||||
permission="attendance:edit"
|
||||
size="small"
|
||||
onClick={() => handleEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
),
|
||||
@@ -616,46 +735,68 @@ const AttendancePage: React.FC = () => {
|
||||
return Array.from(dates).sort();
|
||||
}, [calendarData]);
|
||||
|
||||
const calendarColumns = useMemo(() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (_: unknown, record: { studentId: number; studentName: string; days: { date: string; session: string; status: string }[] }) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
const calendarColumns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '学生',
|
||||
dataIndex: 'studentName',
|
||||
key: 'studentName',
|
||||
width: 100,
|
||||
fixed: 'left' as const,
|
||||
},
|
||||
})),
|
||||
], [calendarDates]);
|
||||
...calendarDates.map((date) => ({
|
||||
title: (
|
||||
<div style={{ textAlign: 'center', fontSize: 12 }}>
|
||||
<div>{dayjs(date).format('MM/DD')}</div>
|
||||
<div style={{ color: '#999' }}>{dayjs(date).format('ddd')}</div>
|
||||
</div>
|
||||
),
|
||||
key: date,
|
||||
width: 80,
|
||||
render: (
|
||||
_: unknown,
|
||||
record: {
|
||||
studentId: number;
|
||||
studentName: string;
|
||||
days: { date: string; session: string; status: string }[];
|
||||
},
|
||||
) => {
|
||||
const dayRecord = record.days.find((d) => d.date === date);
|
||||
if (!dayRecord) return <span style={{ color: '#d9d9d9' }}>-</span>;
|
||||
const statusInfo = STATUS_MAP[dayRecord.status];
|
||||
return (
|
||||
<Tooltip
|
||||
title={`${SESSION_MAP[dayRecord.session] || dayRecord.session}: ${statusInfo?.text || dayRecord.status}`}
|
||||
>
|
||||
<Tag color={statusInfo?.color || 'default'} style={{ margin: 0, cursor: 'pointer' }}>
|
||||
{statusInfo?.text || dayRecord.status}
|
||||
</Tag>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
})),
|
||||
],
|
||||
[calendarDates],
|
||||
);
|
||||
|
||||
// ── Render ──
|
||||
return (
|
||||
<div>
|
||||
{alerts.length > 0 && (
|
||||
<Alert type="warning" showIcon closable
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
closable
|
||||
title={`考勤预警:${alerts.length} 名学生异常`}
|
||||
description={alerts.map(a => `${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`).join(';')}
|
||||
style={{ marginBottom: 16 }} />)}
|
||||
description={alerts
|
||||
.map(
|
||||
(a) =>
|
||||
`${a.studentName}(${a.className || '-'}):${a.type} ${a.count}次,最近${a.lastDate}`,
|
||||
)
|
||||
.join(';')}
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
)}
|
||||
<Tabs
|
||||
activeKey={activeTab}
|
||||
onChange={setActiveTab}
|
||||
@@ -752,12 +893,13 @@ const AttendancePage: React.FC = () => {
|
||||
>
|
||||
批量录入
|
||||
</PermissionButton>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:export"
|
||||
icon={<ExportOutlined />}
|
||||
onClick={handleExportReport}
|
||||
>
|
||||
导出考勤报表
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
<Button
|
||||
icon={calendarView ? <UnorderedListOutlined /> : <CalendarOutlined />}
|
||||
@@ -858,14 +1000,15 @@ const AttendancePage: React.FC = () => {
|
||||
/>
|
||||
</Col>
|
||||
<Col>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="attendance:create"
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={importing}
|
||||
onClick={() => setImportModalOpen(true)}
|
||||
onClick={openDingTalkImportModal}
|
||||
>
|
||||
从钉钉拉取考勤
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
</Col>
|
||||
</Row>
|
||||
{importing && (
|
||||
@@ -996,39 +1139,53 @@ const AttendancePage: React.FC = () => {
|
||||
|
||||
{/* ── DingTalk import modal ── */}
|
||||
<Modal
|
||||
title="从钉钉拉取考勤数据"
|
||||
title={canManageAllAttendance ? '从钉钉拉取考勤数据' : '同步今日考勤'}
|
||||
open={importModalOpen}
|
||||
onOk={handleImportDingTalk}
|
||||
onCancel={() => { setImportModalOpen(false); setImportDateRange(null); setImportProgressMsg(''); }}
|
||||
onCancel={closeDingTalkImportModal}
|
||||
confirmLoading={importing}
|
||||
okText="开始拉取"
|
||||
okText={canManageAllAttendance ? '开始拉取' : '同步今日'}
|
||||
cancelText="取消"
|
||||
>
|
||||
<Form layout="vertical">
|
||||
<Form.Item label="日期范围" required>
|
||||
<RangePicker
|
||||
value={importDateRange}
|
||||
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item label="自动匹配">
|
||||
<Form.Item label="班级" required>
|
||||
<Select
|
||||
value={importAutoMatch ? 'yes' : 'no'}
|
||||
onChange={(v) => setImportAutoMatch(v === 'yes')}
|
||||
options={[
|
||||
{ value: 'yes', label: '是 — 导入后按姓名自动匹配学生' },
|
||||
{ value: 'no', label: '否 — 仅导入原始数据,稍后手动匹配' },
|
||||
]}
|
||||
value={importClassId}
|
||||
onChange={setImportClassId}
|
||||
placeholder="选择自己任教的班级"
|
||||
options={importClassOptions.map((item) => ({
|
||||
value: item.classId,
|
||||
label: item.className,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
{canManageAllAttendance ? (
|
||||
<Form.Item label="日期范围" required>
|
||||
<RangePicker
|
||||
value={importDateRange}
|
||||
onChange={(dates) => setImportDateRange(dates as [Dayjs, Dayjs] | null)}
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
/>
|
||||
</Form.Item>
|
||||
) : (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message={`将同步今天(${dayjs().format('YYYY-MM-DD')})的钉钉考勤`}
|
||||
description="老师端只同步当天数据,不展示历史日期范围。历史数据请联系管理员处理。"
|
||||
/>
|
||||
)}
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
|
||||
{/* ── Edit modal ── */}
|
||||
<Modal title="编辑考勤" open={editModalOpen} onOk={handleEditSubmit} onCancel={() => setEditModalOpen(false)}>
|
||||
<Modal
|
||||
title="编辑考勤"
|
||||
open={editModalOpen}
|
||||
onOk={handleEditSubmit}
|
||||
onCancel={() => setEditModalOpen(false)}
|
||||
>
|
||||
<Form form={editForm} layout="vertical">
|
||||
<Form.Item name="session" label="时段" rules={[{ required: true }]}>
|
||||
<Select options={SESSION_OPTIONS.map((s) => ({ value: s.value, label: s.label }))} />
|
||||
@@ -1083,7 +1240,6 @@ const AttendancePage: React.FC = () => {
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
Form,
|
||||
DatePicker,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Popconfirm,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
Select,
|
||||
Tooltip,
|
||||
Spin,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
FileTextOutlined,
|
||||
@@ -24,6 +24,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -54,6 +55,7 @@ const BillsPage: React.FC = () => {
|
||||
const [generateForm] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [detailLoading, setDetailLoading] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -63,8 +65,8 @@ const BillsPage: React.FC = () => {
|
||||
if (filterExpenseType) params.expenseType = filterExpenseType;
|
||||
const res = await api.get('/bills', { params }) as unknown[];
|
||||
setBills(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [filterStatus, filterExpenseType]);
|
||||
@@ -110,8 +112,8 @@ const BillsPage: React.FC = () => {
|
||||
try {
|
||||
const res = await api.get(`/bills/${id}`);
|
||||
setDetailModal(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
} finally {
|
||||
setDetailLoading(false);
|
||||
}
|
||||
@@ -132,6 +134,8 @@ const BillsPage: React.FC = () => {
|
||||
|
||||
const batchUpdateStatus = async (status: string) => {
|
||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await api.put('/bills/batch/status', { ids: selectedRows, status });
|
||||
message.success(`已批量更新 ${selectedRows.length} 条账单`);
|
||||
@@ -139,6 +143,8 @@ const BillsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -154,6 +160,8 @@ const BillsPage: React.FC = () => {
|
||||
|
||||
const batchDelete = async () => {
|
||||
if (selectedRows.length === 0) return message.warning('请先选择账单');
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
await api.post('/bills/batch/delete', { ids: selectedRows });
|
||||
message.success(`已删除 ${selectedRows.length} 条账单`);
|
||||
@@ -161,6 +169,8 @@ const BillsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '操作失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -390,6 +400,7 @@ const BillsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRows,
|
||||
onChange: (keys) => setSelectedRows(keys as number[]),
|
||||
|
||||
@@ -2,13 +2,14 @@ import React, { useEffect, useState, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Card, Tabs, Descriptions, Table, Button, Space, Select, Modal, Tag,
|
||||
Popconfirm, message, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
|
||||
Popconfirm, Form, Input, DatePicker, InputNumber, Row, Col, Statistic,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { ArrowLeftOutlined, PlusOutlined, DownloadOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -62,7 +63,6 @@ interface ClassDetail {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
departmentId: number | null;
|
||||
classType: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
@@ -287,7 +287,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
|
||||
const openTeacherModal = async () => {
|
||||
try {
|
||||
const res = await api.get('/users') as UserItem[];
|
||||
const res = await api.get('/rbac/users') as UserItem[];
|
||||
setAllUsers(res || []);
|
||||
setTeacherUserId(undefined);
|
||||
setTeacherRole('subject_teacher');
|
||||
@@ -318,7 +318,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassStudent) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveStudent(r.studentId)}>
|
||||
<Button size="small" danger>移除</Button>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
@@ -340,7 +340,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
title: '操作',
|
||||
render: (_: unknown, r: ClassTeacher) => (
|
||||
<Popconfirm title="确认移除?" onConfirm={() => handleRemoveTeacher(r.userId)}>
|
||||
<Button size="small" danger>移除</Button>
|
||||
<PermissionButton permission="class:edit" size="small" danger>移除</PermissionButton>
|
||||
</Popconfirm>
|
||||
),
|
||||
},
|
||||
@@ -431,9 +431,9 @@ const ClassDetailPage: React.FC = () => {
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" onClick={handleSaveInfo}>
|
||||
<PermissionButton permission="class:edit" type="primary" onClick={handleSaveInfo}>
|
||||
保存
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<Button onClick={() => setEditingInfo(false)}>取消</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
@@ -444,10 +444,10 @@ const ClassDetailPage: React.FC = () => {
|
||||
{TYPE_MAP[detail.classType]}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{detail.startDate || '-'}
|
||||
{detail.startDate ? dayjs(detail.startDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{detail.endDate || '-'}
|
||||
{detail.endDate ? dayjs(detail.endDate).format('YYYY-MM-DD') : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="学员">
|
||||
{detail.studentCount}/{detail.maxStudents || '-'}
|
||||
@@ -488,14 +488,15 @@ const ClassDetailPage: React.FC = () => {
|
||||
label: `花名册 (${students.filter((s) => s.status === 'active').length})`,
|
||||
children: (
|
||||
<div>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={openStudentModal}
|
||||
style={{ marginBottom: 16, marginRight: 8 }}
|
||||
>
|
||||
添加学员
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="class:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -557,14 +558,15 @@ const ClassDetailPage: React.FC = () => {
|
||||
label: `教师 (${teachers.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<Button
|
||||
<PermissionButton
|
||||
permission="class:edit"
|
||||
icon={<PlusOutlined />}
|
||||
type="primary"
|
||||
onClick={openTeacherModal}
|
||||
style={{ marginBottom: 16 }}
|
||||
>
|
||||
添加教师
|
||||
</Button>
|
||||
</PermissionButton>
|
||||
<Table<ClassTeacher>
|
||||
columns={teacherColumns}
|
||||
dataSource={teachers}
|
||||
@@ -577,7 +579,7 @@ const ClassDetailPage: React.FC = () => {
|
||||
onOk={handleAddTeacher}
|
||||
onCancel={() => setTeacherModalOpen(false)}
|
||||
>
|
||||
<Space orientation="vertical" style={{ width: '100%' }}>
|
||||
<Space direction="vertical" style={{ width: '100%' }}>
|
||||
<Select
|
||||
style={{ width: '100%' }}
|
||||
placeholder="选择教师"
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table, Button, Input, Select, Space, Tag, Modal, Form, InputNumber,
|
||||
DatePicker, Popconfirm, message, Card,
|
||||
DatePicker, Popconfirm, Card, Switch, Empty,
|
||||
} from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import { PlusOutlined, SearchOutlined, TeamOutlined } from '@ant-design/icons';
|
||||
import { PlusOutlined, SearchOutlined, TeamOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -16,7 +17,6 @@ interface ClassItem {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
departmentId: number | null;
|
||||
classType: string;
|
||||
startDate: string | null;
|
||||
endDate: string | null;
|
||||
@@ -27,6 +27,7 @@ interface ClassItem {
|
||||
maxStudents: number;
|
||||
notes: string | null;
|
||||
studentCount: number;
|
||||
isArchived: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
@@ -42,11 +43,6 @@ interface ClassFormValues {
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ClassQueryParams {
|
||||
status?: string;
|
||||
classType?: string;
|
||||
}
|
||||
|
||||
// ---- Constants ----
|
||||
|
||||
const STATUS_MAP: Record<string, { color: string; text: string }> = {
|
||||
@@ -76,21 +72,35 @@ const ClassesPage: React.FC = () => {
|
||||
const [filterType, setFilterType] = useState<string>();
|
||||
const [form] = Form.useForm<ClassFormValues>();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
const handleArchive = async (id: number, archive: boolean) => {
|
||||
try {
|
||||
await api.put(`/classes/${id}/${archive ? 'archive' : 'restore'}`);
|
||||
message.success(archive ? '已归档' : '已恢复');
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: ClassQueryParams = {};
|
||||
const params: Record<string, string | boolean | undefined> = {};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterType) params.classType = filterType;
|
||||
const res = await api.get<ClassItem[]>('/classes', { params });
|
||||
params.isArchived = showArchived;
|
||||
const res = await api.get<ClassItem[]>('/classes', { params } as Record<string, unknown>);
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} finally {
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [filterStatus, filterType]);
|
||||
}, [filterStatus, filterType, showArchived]);
|
||||
|
||||
useEffect(() => { fetchData(); }, [fetchData]);
|
||||
|
||||
@@ -180,7 +190,7 @@ const ClassesPage: React.FC = () => {
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '操作', width: 200,
|
||||
title: '操作', width: 280,
|
||||
render: (_: unknown, r: ClassItem) => (
|
||||
<Space>
|
||||
<Button size="small" icon={<TeamOutlined />} onClick={() => navigate(`/classes/${r.id}`)}>
|
||||
@@ -189,6 +199,15 @@ const ClassesPage: React.FC = () => {
|
||||
<PermissionButton permission="class:edit" size="small" onClick={() => handleEdit(r)}>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{r.isArchived ? (
|
||||
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(r.id, false)}>
|
||||
<PermissionButton permission="class:edit" size="small">恢复</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(r.id, true)}>
|
||||
<PermissionButton permission="class:edit" size="small">归档</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
<Popconfirm title="确认删除?" onConfirm={() => handleDelete(r.id)}>
|
||||
<PermissionButton permission="class:delete" size="small" danger>
|
||||
删除
|
||||
@@ -228,12 +247,23 @@ const ClassesPage: React.FC = () => {
|
||||
<PermissionButton permission="class:create" type="primary" icon={<PlusOutlined />} onClick={handleCreate}>
|
||||
创建班级
|
||||
</PermissionButton>
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<InboxOutlined style={{ marginRight: 4 }} />
|
||||
归档
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 4 }}
|
||||
checked={showArchived}
|
||||
onChange={setShowArchived}
|
||||
/>
|
||||
</span>
|
||||
</Space>
|
||||
<Table<ClassItem>
|
||||
columns={columns}
|
||||
dataSource={filtered}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20 }}
|
||||
scroll={{ x: 1100 }}
|
||||
/>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -9,22 +9,29 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, UploadOutlined, DeleteOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface UnavailableDatesResponse {
|
||||
dates: string[];
|
||||
}
|
||||
export const unavailableDatesCacheKey = (classroomId: number, date: Dayjs) =>
|
||||
`${classroomId}:${date.format('YYYY-MM')}`;
|
||||
|
||||
const ClassroomRentalsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [classrooms, setClassrooms] = useState<any[]>([]);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
@@ -32,14 +39,19 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
const [filterMonth, setFilterMonth] = useState<Dayjs | null>(null);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [unavailableDates, setUnavailableDates] = useState<Set<string>>(new Set());
|
||||
const loadedUnavailableMonths = useRef<Set<string>>(new Set());
|
||||
const unavailableRequestVersion = useRef(0);
|
||||
const [unavailableDatesLoading, setUnavailableDatesLoading] = useState(false);
|
||||
const selectedClassroomId = Form.useWatch('classroomId', form);
|
||||
|
||||
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;
|
||||
const matchOrganization = r.lesseeOrganization?.name?.toLowerCase().includes(s);
|
||||
return matchClassroom || matchOrganization;
|
||||
});
|
||||
}, [data, searchText]);
|
||||
|
||||
@@ -50,19 +62,22 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
if (filterMonth) params.month = filterMonth.format('YYYY-MM');
|
||||
const res: any = await api.get('/classroom-rentals', { params });
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const fetchMeta = async () => {
|
||||
try {
|
||||
const [cr, tn]: any = await Promise.all([api.get('/classrooms'), api.get('/tenants')]);
|
||||
const [cr, tn]: any = await Promise.all([
|
||||
api.get('/classrooms'),
|
||||
api.get('/organizations', { params: { scope: 'all' } }),
|
||||
]);
|
||||
setClassrooms(cr);
|
||||
setTenants(tn);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
setOrganizations(tn);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载教室列表失败');
|
||||
}
|
||||
};
|
||||
|
||||
@@ -73,12 +88,89 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
fetchData();
|
||||
}, [filterMonth]);
|
||||
|
||||
const resetUnavailableDates = () => {
|
||||
unavailableRequestVersion.current += 1;
|
||||
loadedUnavailableMonths.current.clear();
|
||||
setUnavailableDates(new Set());
|
||||
};
|
||||
|
||||
const loadUnavailableDates = useCallback(
|
||||
async (classroomId: number, date: Dayjs, excludeId?: number) => {
|
||||
const key = unavailableDatesCacheKey(classroomId, date);
|
||||
if (loadedUnavailableMonths.current.has(key)) return;
|
||||
loadedUnavailableMonths.current.add(key);
|
||||
const requestVersion = unavailableRequestVersion.current;
|
||||
|
||||
setUnavailableDatesLoading(true);
|
||||
try {
|
||||
const response = await api.get<UnavailableDatesResponse>(
|
||||
'/classroom-rentals/unavailable-dates',
|
||||
{
|
||||
params: {
|
||||
classroomId,
|
||||
year: date.year(),
|
||||
month: date.month() + 1,
|
||||
excludeId,
|
||||
},
|
||||
},
|
||||
);
|
||||
if (requestVersion !== unavailableRequestVersion.current) return;
|
||||
setUnavailableDates((current) => {
|
||||
const next = new Set(current);
|
||||
response.dates.forEach((item) => next.add(item));
|
||||
return next;
|
||||
});
|
||||
} catch (e: any) {
|
||||
loadedUnavailableMonths.current.delete(key);
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
message.error(e?.message || '加载教室占用日期失败');
|
||||
}
|
||||
} finally {
|
||||
if (requestVersion === unavailableRequestVersion.current) {
|
||||
setUnavailableDatesLoading(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleClassroomChange = (classroomId: number) => {
|
||||
form.setFieldValue('dateRange', undefined);
|
||||
resetUnavailableDates();
|
||||
void loadUnavailableDates(classroomId, dayjs(), editing?.id);
|
||||
void loadUnavailableDates(classroomId, dayjs().add(1, 'month'), editing?.id);
|
||||
};
|
||||
|
||||
const handleCalendarChange = (date: Dayjs) => {
|
||||
const classroomId = form.getFieldValue('classroomId');
|
||||
if (classroomId) void loadUnavailableDates(classroomId, date, editing?.id);
|
||||
};
|
||||
|
||||
const isDateUnavailable = (date: Dayjs) => unavailableDates.has(date.format('YYYY-MM-DD'));
|
||||
|
||||
const rangeIncludesUnavailableDate = (range?: [Dayjs, Dayjs]) => {
|
||||
if (!range) return false;
|
||||
for (
|
||||
let date = range[0].startOf('day');
|
||||
!date.isAfter(range[1], 'day');
|
||||
date = date.add(1, 'day')
|
||||
) {
|
||||
if (isDateUnavailable(date)) return true;
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
if (rangeIncludesUnavailableDate(values.dateRange)) {
|
||||
message.error('所选日期范围包含已排课或已租赁日期,请重新选择');
|
||||
return;
|
||||
}
|
||||
setSaving(true);
|
||||
const payload = {
|
||||
classroomId: values.classroomId,
|
||||
tenantId: values.tenantId,
|
||||
lessorOrganizationId: values.lessorOrganizationId,
|
||||
lesseeOrganizationId: values.lesseeOrganizationId,
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
dailyRate: values.dailyRate,
|
||||
@@ -100,7 +192,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
} catch (e: any) {
|
||||
if (e?.conflicts?.length) {
|
||||
const list = e.conflicts
|
||||
.map((c: any) => `${c.tenantName}(${c.startDate}~${c.endDate})`)
|
||||
.map((c: any) => `${c.organizationName}(${c.startDate}~${c.endDate})`)
|
||||
.join('、');
|
||||
message.error(`时间段冲突:${list}`);
|
||||
} else {
|
||||
@@ -123,10 +215,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(
|
||||
`/classroom-rentals/${id}/contract`,
|
||||
filename || `contract-${id}.pdf`,
|
||||
);
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
} catch {
|
||||
message.error('下载失败(可能文件已丢失)');
|
||||
}
|
||||
@@ -144,124 +233,156 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
|
||||
const openEdit = (record: any) => {
|
||||
setEditing(record);
|
||||
resetUnavailableDates();
|
||||
form.setFieldsValue({
|
||||
classroomId: record.classroomId,
|
||||
tenantId: record.tenantId,
|
||||
lessorOrganizationId: record.lessorOrganizationId,
|
||||
lesseeOrganizationId: record.lesseeOrganizationId,
|
||||
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);
|
||||
void loadUnavailableDates(record.classroomId, dayjs(record.startDate), record.id);
|
||||
void loadUnavailableDates(
|
||||
record.classroomId,
|
||||
dayjs(record.startDate).add(1, 'month'),
|
||||
record.id,
|
||||
);
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '教室', width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '租赁方', width: 100,
|
||||
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', width: 110 },
|
||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
||||
{
|
||||
title: '时长', width: 80,
|
||||
render: (_: any, r: any) => {
|
||||
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
|
||||
return `${d}天`;
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: '教室',
|
||||
width: 120,
|
||||
dataIndex: 'classroom',
|
||||
render: (c: any) =>
|
||||
c ? (
|
||||
<span>
|
||||
{c.building ? `${c.building} · ` : ''}
|
||||
{c.name}
|
||||
</span>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
},
|
||||
{ title: '日租金', dataIndex: 'dailyRate', width: 100, render: (v: any) => (v ? `¥${v}` : '-') },
|
||||
{ title: '总额', dataIndex: 'totalAmount', width: 100, render: (v: any) => (v ? `¥${v}` : '-') },
|
||||
{
|
||||
title: '合同', width: 120,
|
||||
dataIndex: 'contractPath',
|
||||
render: (v: string, r: any) =>
|
||||
v ? (
|
||||
<Space>
|
||||
<Tooltip title={r.contractOriginalName}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<FileTextOutlined />}
|
||||
onClick={() => handleDownloadContract(r.id, r.contractOriginalName)}
|
||||
>
|
||||
下载
|
||||
{
|
||||
title: '承租机构',
|
||||
width: 100,
|
||||
dataIndex: 'lesseeOrganization',
|
||||
render: (t: any) =>
|
||||
t ? (
|
||||
<Tag
|
||||
color={t.color}
|
||||
style={{ background: t.color, color: '#fff', borderColor: t.color }}
|
||||
>
|
||||
{t.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{ title: '开始日期', dataIndex: 'startDate', width: 110 },
|
||||
{ title: '结束日期', dataIndex: 'endDate', width: 110 },
|
||||
{
|
||||
title: '时长',
|
||||
width: 80,
|
||||
render: (_: any, r: any) => {
|
||||
const d = dayjs(r.endDate).diff(dayjs(r.startDate), 'day') + 1;
|
||||
return `${d}天`;
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '日租金',
|
||||
dataIndex: 'dailyRate',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '总额',
|
||||
dataIndex: 'totalAmount',
|
||||
width: 100,
|
||||
render: (v: any) => (v ? `¥${v}` : '-'),
|
||||
},
|
||||
{
|
||||
title: '合同',
|
||||
width: 120,
|
||||
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 />} aria-label="删除合同文件" />
|
||||
</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>
|
||||
</Tooltip>
|
||||
<Popconfirm title="删除合同文件?" onConfirm={() => handleDeleteContract(r.id)}>
|
||||
<Button size="small" danger icon={<DeleteOutlined />} aria-label="删除合同文件" />
|
||||
</Upload>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 150,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="rental:edit"
|
||||
size="small"
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定删除该租赁订单?合同文件将一并删除。"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<PermissionButton permission="rental:delete" size="small" danger>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</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>
|
||||
<Popconfirm
|
||||
title="确定删除该租赁订单?合同文件将一并删除。"
|
||||
onConfirm={() => handleDelete(record.id)}
|
||||
>
|
||||
<PermissionButton permission="rental:delete" size="small" danger>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], []);
|
||||
},
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -276,7 +397,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索教室/租赁方"
|
||||
placeholder="搜索教室/承租机构"
|
||||
allowClear
|
||||
style={{ width: 180 }}
|
||||
onSearch={(v) => setSearchText(v)}
|
||||
@@ -300,6 +421,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
resetUnavailableDates();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -311,10 +433,10 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
scroll={{ x: 1200 }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑租赁' : '新增租赁'}
|
||||
open={modalOpen}
|
||||
@@ -322,6 +444,7 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditing(null);
|
||||
resetUnavailableDates();
|
||||
}}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
@@ -333,18 +456,42 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择教室"
|
||||
onChange={handleClassroomChange}
|
||||
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 }]}>
|
||||
<Form.Item name="lessorOrganizationId" label="出租机构" tooltip="默认由本机构出租">
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择租赁方"
|
||||
options={tenants.map((t) => ({ value: t.id, label: t.name }))}
|
||||
placeholder="默认本机构"
|
||||
allowClear
|
||||
options={organizations
|
||||
.filter((organization) => organization.isHost)
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: `${organization.name}(本机构)`,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="lesseeOrganizationId"
|
||||
label="承租机构"
|
||||
rules={[{ required: true, message: '请选择承租机构' }]}
|
||||
>
|
||||
<Select
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择外部承租机构"
|
||||
options={organizations
|
||||
.filter((organization) => !organization.isHost && organization.status === 'active')
|
||||
.map((organization) => ({
|
||||
value: organization.id,
|
||||
label: organization.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dateRange" label="租赁起止日期" rules={[{ required: true }]}>
|
||||
@@ -352,6 +499,9 @@ const ClassroomRentalsPage: React.FC = () => {
|
||||
style={{ width: '100%' }}
|
||||
placeholder={['开始日期', '结束日期']}
|
||||
format="YYYY-MM-DD"
|
||||
disabled={!selectedClassroomId}
|
||||
disabledDate={(date) => unavailableDatesLoading || isDateUnavailable(date)}
|
||||
onPanelChange={(dates) => dates.forEach((date) => date && handleCalendarChange(date))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="dailyRate" label="日租金(可选)">
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
import { unavailableDatesCacheKey } from './index';
|
||||
|
||||
describe('classroom rental unavailable dates cache', () => {
|
||||
it('scopes loaded month keys by classroom id', () => {
|
||||
const month = dayjs('2026-07-10');
|
||||
|
||||
expect(unavailableDatesCacheKey(1, month)).toBe('1:2026-07');
|
||||
expect(unavailableDatesCacheKey(1, month)).not.toBe(unavailableDatesCacheKey(2, month));
|
||||
});
|
||||
});
|
||||
@@ -17,13 +17,14 @@ import { CalendarOutlined, FileTextOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface ScheduleData {
|
||||
year: number;
|
||||
month: number;
|
||||
days: number;
|
||||
classrooms: any[];
|
||||
tenants: any[];
|
||||
organizations: any[];
|
||||
matrix: Record<number, Record<number, any>>;
|
||||
summary: Record<
|
||||
number,
|
||||
@@ -44,8 +45,9 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
params: { year: month.year(), month: month.month() + 1 },
|
||||
});
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [month]);
|
||||
@@ -85,17 +87,15 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
try {
|
||||
const res: any = await api.get(`/classroom-rentals/${rentalId}`);
|
||||
setDetailModal(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载详情失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDownloadContract = async (id: number, filename?: string) => {
|
||||
try {
|
||||
await downloadBlob(
|
||||
`/classroom-rentals/${id}/contract`,
|
||||
filename || `contract-${id}.pdf`,
|
||||
);
|
||||
await downloadBlob(`/classroom-rentals/${id}/contract`, filename || `contract-${id}.pdf`);
|
||||
} catch {
|
||||
// downloadBlob already shows an error via throw
|
||||
}
|
||||
@@ -172,7 +172,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
<Card size="small" style={{ marginBottom: 16 }} title="图例">
|
||||
<Space wrap>
|
||||
<Tag color="#52c41a">内部排课</Tag>
|
||||
{data.tenants.map((t) => (
|
||||
{data.organizations.map((t) => (
|
||||
<Tag
|
||||
key={t.id}
|
||||
color={t.color}
|
||||
@@ -181,7 +181,9 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{t.name} (租赁)
|
||||
</Tag>
|
||||
))}
|
||||
<Tag color="#d9d9d9" style={{ color: '#999' }}>空闲</Tag>
|
||||
<Tag color="#d9d9d9" style={{ color: '#999' }}>
|
||||
空闲
|
||||
</Tag>
|
||||
</Space>
|
||||
</Card>
|
||||
)}
|
||||
@@ -307,7 +309,7 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
title={
|
||||
isInternal
|
||||
? `${cell.className} · ${cell.subject}\n${cell.teacherName} · ${cell.startTime}-${cell.endTime}`
|
||||
: `${cell.tenantName}${cell.hasContract ? ' · 有合同' : ''}`
|
||||
: `${cell.organizationName}${cell.hasContract ? ' · 有合同' : ''}`
|
||||
}
|
||||
>
|
||||
<span style={{ color: '#fff', fontSize: 10, fontWeight: 600 }}>
|
||||
@@ -344,21 +346,22 @@ const ClassroomSchedulePage: React.FC = () => {
|
||||
{detailModal.classroom?.roomType})
|
||||
</div>
|
||||
<div>
|
||||
<strong>租赁方:</strong>
|
||||
<strong>承租机构:</strong>
|
||||
<Tag
|
||||
color={detailModal.tenant?.color}
|
||||
color={detailModal.lesseeOrganization?.color}
|
||||
style={{
|
||||
background: detailModal.tenant?.color,
|
||||
background: detailModal.lesseeOrganization?.color,
|
||||
color: '#fff',
|
||||
borderColor: detailModal.tenant?.color,
|
||||
borderColor: detailModal.lesseeOrganization?.color,
|
||||
}}
|
||||
>
|
||||
{detailModal.tenant?.name}
|
||||
{detailModal.lesseeOrganization?.name}
|
||||
</Tag>
|
||||
</div>
|
||||
<div>
|
||||
<strong>联系人:</strong>
|
||||
{detailModal.tenant?.contact || '-'} {detailModal.tenant?.phone || ''}
|
||||
{detailModal.lesseeOrganization?.contactName || '-'}{' '}
|
||||
{detailModal.lesseeOrganization?.phone || ''}
|
||||
</div>
|
||||
<div>
|
||||
<strong>起止日期:</strong>
|
||||
|
||||
@@ -8,11 +8,11 @@ import {
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
Tooltip,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可用', color: 'green' },
|
||||
@@ -69,8 +70,8 @@ const ClassroomsPage: React.FC = () => {
|
||||
try {
|
||||
const res: any = await api.get('/classrooms', { params: { includeArchived: showArchived } });
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -287,9 +288,9 @@ const ClassroomsPage: React.FC = () => {
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 条` }}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑教室' : '添加教室'}
|
||||
open={modalOpen}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,423 +0,0 @@
|
||||
import React, { useEffect, useState, useCallback } from 'react';
|
||||
import {
|
||||
Tree,
|
||||
Card,
|
||||
Button,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
Select,
|
||||
InputNumber,
|
||||
Table,
|
||||
Row,
|
||||
Col,
|
||||
Popconfirm,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Descriptions,
|
||||
Empty,
|
||||
Spin,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
|
||||
interface DepartmentItem {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number | null;
|
||||
type: string;
|
||||
sortOrder: number;
|
||||
status: string;
|
||||
children?: DepartmentItem[];
|
||||
}
|
||||
|
||||
interface UserInfo {
|
||||
id: number;
|
||||
username: string;
|
||||
name: string;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
interface DeptMember {
|
||||
id: number;
|
||||
userId: number;
|
||||
departmentId: number;
|
||||
isDefault: boolean;
|
||||
user: UserInfo;
|
||||
}
|
||||
|
||||
function departmentToTreeNode(dept: DepartmentItem): DataNode {
|
||||
return {
|
||||
key: String(dept.id),
|
||||
title: dept.name,
|
||||
children: dept.children?.map(departmentToTreeNode),
|
||||
};
|
||||
}
|
||||
|
||||
const TYPE_LABELS: Record<string, string> = {
|
||||
campus: '校区',
|
||||
department: '部门',
|
||||
};
|
||||
|
||||
const TYPE_COLORS: Record<string, string> = {
|
||||
campus: 'blue',
|
||||
department: 'green',
|
||||
};
|
||||
|
||||
const DepartmentsPage: React.FC = () => {
|
||||
const [treeData, setTreeData] = useState<DataNode[]>([]);
|
||||
const [flatDepts, setFlatDepts] = useState<DepartmentItem[]>([]);
|
||||
const [selectedId, setSelectedId] = useState<string | null>(null);
|
||||
const [selectedDept, setSelectedDept] = useState<DepartmentItem | null>(null);
|
||||
const [members, setMembers] = useState<DeptMember[]>([]);
|
||||
const [membersLoading, setMembersLoading] = useState(false);
|
||||
const [treeLoading, setTreeLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<DepartmentItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
const fetchTree = useCallback(async () => {
|
||||
setTreeLoading(true);
|
||||
try {
|
||||
const data = await api.get('/departments/tree') as unknown as DepartmentItem[];
|
||||
const flat = await api.get('/departments') as unknown as DepartmentItem[];
|
||||
setFlatDepts(flat);
|
||||
setTreeData(data.map(departmentToTreeNode));
|
||||
} catch {
|
||||
message.error('加载部门树失败');
|
||||
} finally {
|
||||
setTreeLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTree();
|
||||
}, [fetchTree]);
|
||||
|
||||
const fetchDetail = useCallback(async (id: number) => {
|
||||
try {
|
||||
const dept = await api.get(`/departments/${id}`) as unknown as DepartmentItem;
|
||||
setSelectedDept(dept);
|
||||
setMembersLoading(true);
|
||||
const users = await api.get(`/departments/${id}/users`) as unknown as DeptMember[];
|
||||
setMembers(users);
|
||||
} catch {
|
||||
message.error('加载部门详情失败');
|
||||
} finally {
|
||||
setMembersLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSelect = useCallback(
|
||||
(keys: React.Key[]) => {
|
||||
if (keys.length === 0) {
|
||||
setSelectedId(null);
|
||||
setSelectedDept(null);
|
||||
setMembers([]);
|
||||
return;
|
||||
}
|
||||
const id = String(keys[0]);
|
||||
setSelectedId(id);
|
||||
fetchDetail(Number(id));
|
||||
},
|
||||
[fetchDetail],
|
||||
);
|
||||
|
||||
const handleAdd = useCallback(() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
if (selectedId) {
|
||||
form.setFieldsValue({ parentId: Number(selectedId) });
|
||||
}
|
||||
setModalOpen(true);
|
||||
}, [selectedId, form]);
|
||||
|
||||
const handleAddChild = useCallback(() => {
|
||||
if (!selectedDept) return;
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ parentId: selectedDept.id });
|
||||
setModalOpen(true);
|
||||
}, [selectedDept, form]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
if (!selectedDept) return;
|
||||
setEditing(selectedDept);
|
||||
form.setFieldsValue({
|
||||
name: selectedDept.name,
|
||||
parentId: selectedDept.parentId,
|
||||
type: selectedDept.type,
|
||||
sortOrder: selectedDept.sortOrder,
|
||||
});
|
||||
setModalOpen(true);
|
||||
}, [selectedDept, form]);
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
setSaving(true);
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const payload = {
|
||||
name: values.name,
|
||||
parentId: values.parentId || null,
|
||||
type: values.type || 'department',
|
||||
sortOrder: values.sortOrder ?? 0,
|
||||
};
|
||||
if (editing) {
|
||||
await api.put(`/departments/${editing.id}`, payload);
|
||||
message.success('更新成功');
|
||||
} else {
|
||||
await api.post('/departments', payload);
|
||||
message.success('创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
await fetchTree();
|
||||
if (editing && selectedId) {
|
||||
fetchDetail(editing.id);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (
|
||||
err &&
|
||||
typeof err === 'object' &&
|
||||
'message' in err &&
|
||||
typeof (err as { message: string }).message === 'string'
|
||||
) {
|
||||
message.error((err as { message: string }).message);
|
||||
}
|
||||
// form validation error falls through silently
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [editing, fetchTree, fetchDetail, selectedId, form]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!selectedDept) return;
|
||||
try {
|
||||
await api.delete(`/departments/${selectedDept.id}`);
|
||||
message.success('删除成功');
|
||||
setSelectedId(null);
|
||||
setSelectedDept(null);
|
||||
setMembers([]);
|
||||
await fetchTree();
|
||||
} catch (err: unknown) {
|
||||
const msg =
|
||||
err && typeof err === 'object' && 'message' in err
|
||||
? (err as { message: string }).message
|
||||
: '删除失败';
|
||||
message.error(msg);
|
||||
}
|
||||
}, [selectedDept, fetchTree]);
|
||||
|
||||
const memberColumns = [
|
||||
{ title: 'ID', dataIndex: 'userId', key: 'userId', width: 60 },
|
||||
{ title: '用户名', dataIndex: ['user', 'username'], key: 'username', width: 120 },
|
||||
{ title: '姓名', dataIndex: ['user', 'name'], key: 'name', width: 120 },
|
||||
{
|
||||
title: '默认部门',
|
||||
dataIndex: 'isDefault',
|
||||
key: 'isDefault',
|
||||
width: 80,
|
||||
render: (v: boolean) => (v ? <Tag color="blue">是</Tag> : null),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h2 style={{ margin: 0 }}>组织架构管理</h2>
|
||||
<Space>
|
||||
<Button onClick={fetchTree} loading={treeLoading}>
|
||||
刷新
|
||||
</Button>
|
||||
<PermissionButton
|
||||
permission="department:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
新增
|
||||
</PermissionButton>
|
||||
</Space>
|
||||
</div>
|
||||
|
||||
<Row gutter={16}>
|
||||
<Col xs={24} sm={24} md={8}>
|
||||
<Card
|
||||
title="部门结构"
|
||||
size="small"
|
||||
style={{ height: 'calc(100vh - 220px)', overflow: 'auto' }}
|
||||
>
|
||||
{treeLoading ? (
|
||||
<Spin style={{ display: 'block', margin: '40px auto' }} />
|
||||
) : (
|
||||
<Tree
|
||||
treeData={treeData}
|
||||
showLine={{ showLeafIcon: false }}
|
||||
selectedKeys={selectedId ? [selectedId] : []}
|
||||
onSelect={handleSelect}
|
||||
blockNode
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Col>
|
||||
|
||||
<Col xs={24} sm={24} md={16}>
|
||||
{selectedDept ? (
|
||||
<Space direction="vertical" style={{ width: '100%' }} size="middle">
|
||||
<Card
|
||||
title={
|
||||
<Space>
|
||||
<span>{selectedDept.name}</span>
|
||||
<Tag color={TYPE_COLORS[selectedDept.type] || 'default'}>
|
||||
{TYPE_LABELS[selectedDept.type] || selectedDept.type}
|
||||
</Tag>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="department:update"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={handleEdit}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="department:create"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAddChild}
|
||||
>
|
||||
新增子部门
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="确定删除该部门?"
|
||||
description={selectedDept.children && selectedDept.children.length > 0
|
||||
? '该部门下存在子部门,可能无法删除'
|
||||
: undefined}
|
||||
onConfirm={handleDelete}
|
||||
okText="确定"
|
||||
cancelText="取消"
|
||||
>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={
|
||||
selectedDept.children && selectedDept.children.length > 0
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Descriptions column={2} size="small" bordered>
|
||||
<Descriptions.Item label="ID">{selectedDept.id}</Descriptions.Item>
|
||||
<Descriptions.Item label="类型">
|
||||
<Tag color={TYPE_COLORS[selectedDept.type] || 'default'}>
|
||||
{TYPE_LABELS[selectedDept.type] || selectedDept.type}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="排序">
|
||||
{selectedDept.sortOrder}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="上级部门 ID">
|
||||
{selectedDept.parentId ?? '无(顶级)'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={selectedDept.status === 'active' ? 'green' : 'red'}>
|
||||
{selectedDept.status}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
<Card title="部门成员" size="small">
|
||||
<Table
|
||||
columns={memberColumns}
|
||||
dataSource={members}
|
||||
rowKey="id"
|
||||
loading={membersLoading}
|
||||
pagination={false}
|
||||
size="small"
|
||||
scroll={{ x: 1000 }}
|
||||
locale={{ emptyText: '暂无成员' }}
|
||||
/>
|
||||
</Card>
|
||||
</Space>
|
||||
) : (
|
||||
<Card
|
||||
size="small"
|
||||
style={{ height: 'calc(100vh - 220px)', display: 'flex', alignItems: 'center', justifyContent: 'center' }}
|
||||
>
|
||||
<Empty description="请在左侧选择一个部门" />
|
||||
</Card>
|
||||
)}
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
<Modal
|
||||
title={editing ? '编辑部门' : '新增部门'}
|
||||
open={modalOpen}
|
||||
onOk={handleSubmit}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
destroyOnHidden
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="部门名称"
|
||||
rules={[{ required: true, message: '请输入部门名称' }]}
|
||||
>
|
||||
<Input placeholder="例如:数学教研组" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="parentId" label="上级部门">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="不选则为顶级部门"
|
||||
options={flatDepts.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name,
|
||||
disabled: editing ? d.id === editing.id : false,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="type" label="类型" initialValue="department">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'campus', label: '校区' },
|
||||
{ value: 'department', label: '部门' },
|
||||
]}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="sortOrder" label="排序序号" initialValue={0}>
|
||||
<InputNumber min={0} style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default DepartmentsPage;
|
||||
@@ -8,18 +8,19 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Tabs,
|
||||
List,
|
||||
Card,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, DeleteOutlined, DollarOutlined, CheckOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
paid: { text: '已缴', color: 'green' },
|
||||
@@ -75,8 +76,8 @@ const DepositsPage: React.FC = () => {
|
||||
const [d, s]: any[] = await Promise.all([api.get('/deposits'), api.get('/students')]);
|
||||
setData(d);
|
||||
setStudents(s);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -86,8 +87,8 @@ const DepositsPage: React.FC = () => {
|
||||
try {
|
||||
const res = await api.get<PendingRefund[]>('/deposits/pending-refunds');
|
||||
setPendingRefunds(res || []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setPendingLoading(false);
|
||||
};
|
||||
@@ -438,6 +439,7 @@ const DepositsPage: React.FC = () => {
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
/>
|
||||
</>
|
||||
),
|
||||
|
||||
@@ -9,11 +9,11 @@ import {
|
||||
InputNumber,
|
||||
Input,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Tabs,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -27,6 +27,7 @@ import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -51,6 +52,7 @@ const ExpensesPage: React.FC = () => {
|
||||
const [selectedRoomKeys, setSelectedRoomKeys] = useState<number[]>([]);
|
||||
const [selectedPersonalKeys, setSelectedPersonalKeys] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
// Dynamic expense type options from API
|
||||
const [typeOptions, setTypeOptions] = useState<{ value: string; label: string }[]>([]);
|
||||
@@ -78,6 +80,8 @@ const ExpensesPage: React.FC = () => {
|
||||
}, []);
|
||||
|
||||
const handleBatchDeleteRoom = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/expenses/room/batch-delete', { ids: selectedRoomKeys });
|
||||
message.success(res?.message || `已删除 ${selectedRoomKeys.length} 条`);
|
||||
@@ -85,10 +89,14 @@ const ExpensesPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量删除失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDeletePersonal = async () => {
|
||||
if (batchLoading) return;
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/expenses/personal/batch-delete', {
|
||||
ids: selectedPersonalKeys,
|
||||
@@ -98,6 +106,8 @@ const ExpensesPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量删除失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -114,8 +124,8 @@ const ExpensesPage: React.FC = () => {
|
||||
setPersonalExpenses(pe);
|
||||
setRooms(rm);
|
||||
setStudents(st);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
@@ -435,6 +445,7 @@ const ExpensesPage: React.FC = () => {
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedRoomKeys,
|
||||
onChange: (keys) => setSelectedRoomKeys(keys as number[]),
|
||||
@@ -557,6 +568,7 @@ const ExpensesPage: React.FC = () => {
|
||||
loading={loading}
|
||||
scroll={{ x: 1200 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedPersonalKeys,
|
||||
onChange: (keys) => setSelectedPersonalKeys(keys as number[]),
|
||||
|
||||
490
apps/admin/src/pages/IntegrationConfig/index.tsx
Normal file
490
apps/admin/src/pages/IntegrationConfig/index.tsx
Normal file
@@ -0,0 +1,490 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Form, Input, Button, Space, Spin, Switch, Alert, Descriptions, Tag,
|
||||
Tabs, Drawer, Tree, Select, TreeSelect, Modal, DatePicker, InputNumber,
|
||||
Row, Col, List,
|
||||
} from 'antd';
|
||||
import {
|
||||
SaveOutlined, ApiOutlined, CheckCircleOutlined, CloseCircleOutlined,
|
||||
SyncOutlined, BankOutlined, UserOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import type { DataNode } from 'antd/es/tree';
|
||||
import type { TreeSelectProps } from 'antd/es/tree-select';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface DingTalkConfig {
|
||||
agentId: string;
|
||||
appSecret: string;
|
||||
corpId: string;
|
||||
startEnable: boolean;
|
||||
}
|
||||
|
||||
interface DingOrgTreeNodeExt {
|
||||
id: number;
|
||||
name: string;
|
||||
parentId: number;
|
||||
children: DingOrgTreeNodeExt[];
|
||||
users: Array<{ userid: string; name: string; mobile: string; deptIds: number[] }>;
|
||||
}
|
||||
|
||||
interface OrgTreeNodeRaw {
|
||||
id: number;
|
||||
name: string;
|
||||
children?: OrgTreeNodeRaw[];
|
||||
}
|
||||
|
||||
interface OrgTreeResponse {
|
||||
success: boolean;
|
||||
data: OrgTreeNodeRaw[];
|
||||
}
|
||||
|
||||
interface OrgTreeWithUsersResponse {
|
||||
success: boolean;
|
||||
data: DingOrgTreeNodeExt[];
|
||||
}
|
||||
|
||||
type DeptPickerTreeNode = NonNullable<TreeSelectProps<number>['treeData']>[number];
|
||||
|
||||
interface ClassItem {
|
||||
id: number;
|
||||
name: string;
|
||||
code: string;
|
||||
classType?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
maxStudents?: number;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface ImportResult {
|
||||
imported: number;
|
||||
skipped: number;
|
||||
}
|
||||
|
||||
const IntegrationConfigPage: React.FC = () => {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [config, setConfig] = useState<DingTalkConfig | null>(null);
|
||||
const [verified, setVerified] = useState<boolean | null>(null);
|
||||
const [form] = Form.useForm<DingTalkConfig>();
|
||||
|
||||
// ── Sync Users Tab ──
|
||||
const [syncRootDeptId, setSyncRootDeptId] = useState<number | undefined>(undefined);
|
||||
const [orgTree, setOrgTree] = useState<DingOrgTreeNodeExt[]>([]);
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [fetchingTree, setFetchingTree] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [deptPickerTree, setDeptPickerTree] = useState<DeptPickerTreeNode[]>([]);
|
||||
const [checkedKeys, setCheckedKeys] = useState<React.Key[]>([]);
|
||||
const [selectedClassId, setSelectedClassId] = useState<number | null>(null);
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [classForm] = Form.useForm();
|
||||
const [classModalOpen, setClassModalOpen] = useState(false);
|
||||
|
||||
|
||||
const fetchConfig = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const res = await api.get<{ success: boolean; data: Array<{ type: string; verify: boolean; config: DingTalkConfig }> }>('/integration/config');
|
||||
const dt = res.data?.find((c) => c.type === 'DINGTALK');
|
||||
if (dt) {
|
||||
setConfig(dt.config);
|
||||
setVerified(dt.verify);
|
||||
form.setFieldsValue(dt.config);
|
||||
}
|
||||
} catch {
|
||||
// not configured
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchConfig();
|
||||
}, []);
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.post('/integration/config', { type: 'DINGTALK', config: values });
|
||||
message.success('配置已保存');
|
||||
await fetchConfig();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
const values = await form.validateFields();
|
||||
setTesting(true);
|
||||
try {
|
||||
const res = await api.post<{ success: boolean; message: string }>('/integration/config/test', {
|
||||
type: 'DINGTALK',
|
||||
config: values,
|
||||
});
|
||||
setVerified(res.success);
|
||||
message.success(res.message);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
setVerified(false);
|
||||
message.error(err?.message || '连接失败');
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const loadDeptTree = async () => {
|
||||
try {
|
||||
const res = await api.get<OrgTreeResponse>('/sync/dingtalk/org-tree');
|
||||
if (res.success && res.data) {
|
||||
const toTreeNode = (nodes: OrgTreeNodeRaw[]): DeptPickerTreeNode[] =>
|
||||
nodes.map((n) => ({
|
||||
title: n.name,
|
||||
value: n.id,
|
||||
children: n.children ? toTreeNode(n.children) : undefined,
|
||||
}));
|
||||
setDeptPickerTree(toTreeNode(res.data));
|
||||
}
|
||||
} catch {
|
||||
message.error('获取部门架构失败');
|
||||
}
|
||||
};
|
||||
|
||||
const fetchClasses = async () => {
|
||||
try {
|
||||
const res = await api.get<ClassItem[] | { data: ClassItem[] }>('/classes');
|
||||
if (Array.isArray(res)) {
|
||||
setClasses(res);
|
||||
} else {
|
||||
setClasses(res.data ?? []);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
};
|
||||
|
||||
const handleFetchOrgTree = async () => {
|
||||
setFetchingTree(true);
|
||||
try {
|
||||
const params: Record<string, string> = {};
|
||||
if (syncRootDeptId) params.rootDeptId = String(syncRootDeptId);
|
||||
const res = await api.get<OrgTreeWithUsersResponse>('/sync/dingtalk/org-tree-with-users', { params });
|
||||
if (res.success && res.data) {
|
||||
setOrgTree(res.data);
|
||||
setCheckedKeys([]);
|
||||
setSelectedClassId(null);
|
||||
setDrawerOpen(true);
|
||||
fetchClasses();
|
||||
} else {
|
||||
message.error('获取组织架构失败');
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '获取组织架构失败');
|
||||
} finally {
|
||||
setFetchingTree(false);
|
||||
}
|
||||
};
|
||||
|
||||
const buildTreeData = useCallback((nodes: DingOrgTreeNodeExt[]): DataNode[] => {
|
||||
return nodes.map((node) => {
|
||||
const users = node.users ?? [];
|
||||
const children: DataNode[] = [
|
||||
...buildTreeData(node.children ?? []),
|
||||
...users.map((u) => ({
|
||||
title: (
|
||||
<Space>
|
||||
<UserOutlined />
|
||||
<span>{u.name}</span>
|
||||
{u.mobile ? <Tag>{u.mobile}</Tag> : null}
|
||||
</Space>
|
||||
),
|
||||
key: `user-${u.userid}`,
|
||||
isLeaf: true,
|
||||
})),
|
||||
];
|
||||
return {
|
||||
title: (
|
||||
<Space size="small">
|
||||
<BankOutlined />
|
||||
<span>{node.name}</span>
|
||||
<Tag>{users.length}人</Tag>
|
||||
</Space>
|
||||
),
|
||||
key: `dept-${node.id}`,
|
||||
// Only attach children when there are any, so empty/leaf departments
|
||||
// don't render a phantom expand arrow that opens to nothing.
|
||||
...(children.length > 0 ? { children } : {}),
|
||||
};
|
||||
});
|
||||
}, []);
|
||||
|
||||
const treeData = useMemo(() => buildTreeData(orgTree), [orgTree, buildTreeData]);
|
||||
|
||||
const extractCheckedUsers = useCallback((): Array<{ dingUserId: string; name: string; mobile?: string }> => {
|
||||
const result: Array<{ dingUserId: string; name: string; mobile?: string }> = [];
|
||||
const walk = (nodes: DingOrgTreeNodeExt[]) => {
|
||||
for (const node of nodes) {
|
||||
for (const u of node.users ?? []) {
|
||||
if (checkedKeys.includes(`user-${u.userid}`)) {
|
||||
result.push({ dingUserId: u.userid, name: u.name, mobile: u.mobile || undefined });
|
||||
}
|
||||
}
|
||||
walk(node.children ?? []);
|
||||
}
|
||||
};
|
||||
walk(orgTree);
|
||||
return result;
|
||||
}, [checkedKeys, orgTree]);
|
||||
|
||||
const handleJoinClass = async () => {
|
||||
if (selectedClassId === null) return message.warning('请先选择一个班级');
|
||||
const users = extractCheckedUsers();
|
||||
if (users.length === 0) return message.warning('请勾选要导入的用户');
|
||||
|
||||
setImporting(true);
|
||||
try {
|
||||
const res = await api.post<ImportResult>(`/classes/${selectedClassId}/students/import`, { users });
|
||||
message.success(`导入 ${res.imported} 人,跳过 ${res.skipped} 人`);
|
||||
setCheckedKeys([]);
|
||||
setSelectedClassId(null);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '导入失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateClass = async () => {
|
||||
try {
|
||||
const values = await classForm.validateFields();
|
||||
const users = extractCheckedUsers();
|
||||
await api.post('/classes', { ...values, users });
|
||||
message.success('班级创建成功');
|
||||
setClassModalOpen(false);
|
||||
classForm.resetFields();
|
||||
setCheckedKeys([]);
|
||||
fetchClasses();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '创建失败');
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const syncTabItems = config
|
||||
? [
|
||||
{
|
||||
key: 'sync-users',
|
||||
label: '同步用户',
|
||||
children: (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
message="从钉钉获取组织架构,勾选用户后批量导入到班级。"
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
<Space>
|
||||
<TreeSelect
|
||||
treeData={deptPickerTree}
|
||||
value={syncRootDeptId}
|
||||
onChange={(v) => setSyncRootDeptId(v)}
|
||||
placeholder="选择起始部门(不选=全部)"
|
||||
allowClear
|
||||
treeDefaultExpandAll
|
||||
style={{ minWidth: 240 }}
|
||||
onDropdownVisibleChange={(open) => { if (open) loadDeptTree(); }}
|
||||
/>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<SyncOutlined />}
|
||||
loading={fetchingTree}
|
||||
onClick={handleFetchOrgTree}
|
||||
>
|
||||
获取组织架构
|
||||
</Button>
|
||||
</Space>
|
||||
|
||||
{drawerOpen && (
|
||||
<Drawer
|
||||
title="钉钉组织架构 — 批量导入"
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
width={900}
|
||||
footer={
|
||||
<Space>
|
||||
<Button onClick={() => { setDrawerOpen(false); }}>取消</Button>
|
||||
<Button
|
||||
type="primary"
|
||||
loading={importing}
|
||||
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0 || selectedClassId === null}
|
||||
onClick={handleJoinClass}
|
||||
>加入选中的班级</Button>
|
||||
<Button
|
||||
disabled={checkedKeys.filter((k) => String(k).startsWith('user-')).length === 0}
|
||||
onClick={() => setClassModalOpen(true)}
|
||||
>创建班级</Button>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Row gutter={16}>
|
||||
<Col span={14}>
|
||||
<div style={{ maxHeight: '60vh', overflow: 'auto' }}>
|
||||
<Tree
|
||||
checkable
|
||||
treeData={treeData}
|
||||
defaultExpandAll
|
||||
showLine={{ showLeafIcon: false }}
|
||||
checkedKeys={checkedKeys}
|
||||
onCheck={(checked) => setCheckedKeys(checked as React.Key[])}
|
||||
/>
|
||||
</div>
|
||||
</Col>
|
||||
<Col span={10}>
|
||||
<Card title="班级列表" size="small"
|
||||
extra={<Button size="small" onClick={() => setClassModalOpen(true)}>+ 创建班级</Button>}>
|
||||
<List
|
||||
dataSource={classes}
|
||||
renderItem={(cls: ClassItem) => (
|
||||
<List.Item
|
||||
onClick={() => setSelectedClassId(cls.id)}
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
background: selectedClassId === cls.id ? '#e6f4ff' : undefined,
|
||||
borderRadius: 4,
|
||||
padding: '8px 12px',
|
||||
}}
|
||||
>
|
||||
<List.Item.Meta title={cls.name} description={`${cls.code} ${cls.classType || ''}`} />
|
||||
</List.Item>
|
||||
)}
|
||||
/>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
|
||||
{ /* Create class Modal */ }
|
||||
<Modal
|
||||
title="创建班级"
|
||||
open={classModalOpen}
|
||||
onOk={handleCreateClass}
|
||||
onCancel={() => { setClassModalOpen(false); classForm.resetFields(); }}
|
||||
confirmLoading={importing}
|
||||
destroyOnClose
|
||||
>
|
||||
<Form form={classForm} layout="vertical">
|
||||
<Form.Item name="name" label="班级名称" rules={[{ required: true }]}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="code" label="班级编码" rules={[{ required: true }]}>
|
||||
<Input placeholder="如 CS2024-01" />
|
||||
</Form.Item>
|
||||
<Form.Item name="classType" label="班型" rules={[{ required: true }]}>
|
||||
<Select options={[
|
||||
{ value: 'culture', label: '文化课' },
|
||||
{ value: 'professional', label: '专业课' },
|
||||
{ value: 'bootcamp', label: '集训营' },
|
||||
{ value: 'sprint', label: '冲刺班' },
|
||||
]} />
|
||||
</Form.Item>
|
||||
<Form.Item name="startDate" label="开班日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="endDate" label="结束日期">
|
||||
<DatePicker style={{ width: '100%' }} />
|
||||
</Form.Item>
|
||||
<Form.Item name="maxStudents" label="最大人数">
|
||||
<InputNumber min={0} style={{ width: '100%' }} placeholder="0=不限制" />
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</Drawer>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
: [];
|
||||
|
||||
const tabItems = [
|
||||
{
|
||||
key: 'config',
|
||||
label: '配置',
|
||||
children: (
|
||||
<Spin spinning={loading}>
|
||||
{config && (
|
||||
<Descriptions size="small" column={2} style={{ marginBottom: 24 }}>
|
||||
<Descriptions.Item label="CorpId">{config.corpId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="AppKey">{config.agentId || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="启用同步">
|
||||
<Tag color={config.startEnable ? 'green' : 'default'}>
|
||||
{config.startEnable ? '已启用' : '未启用'}
|
||||
</Tag>
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
)}
|
||||
|
||||
<Alert
|
||||
type="info"
|
||||
message="配置钉钉应用凭证后,可使用组织架构同步、考勤导入和排班同步功能。"
|
||||
style={{ marginBottom: 24 }}
|
||||
showIcon
|
||||
/>
|
||||
|
||||
<Form form={form} layout="vertical" style={{ maxWidth: 480 }}>
|
||||
<Form.Item name="corpId" label="CorpId(企业ID)" rules={[{ required: true, message: '请输入 CorpId' }]}>
|
||||
<Input placeholder="dingxxxxxxxx" />
|
||||
</Form.Item>
|
||||
<Form.Item name="agentId" label="AppKey(应用凭证)" rules={[{ required: true, message: '请输入 AppKey' }]}>
|
||||
<Input placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="appSecret"
|
||||
label="AppSecret(应用密钥)"
|
||||
rules={[{ required: true, message: '请输入 AppSecret' }]}
|
||||
extra="保存后仅返回脱敏信息,重新编辑时需再次输入完整密钥"
|
||||
>
|
||||
<Input.Password placeholder="从钉钉开放平台获取" />
|
||||
</Form.Item>
|
||||
<Form.Item name="startEnable" label="启用同步" valuePropName="checked">
|
||||
<Switch />
|
||||
</Form.Item>
|
||||
<Space>
|
||||
<Button type="primary" icon={<SaveOutlined />} loading={saving} onClick={handleSave}>
|
||||
保存配置
|
||||
</Button>
|
||||
<Button icon={<ApiOutlined />} loading={testing} onClick={handleTest}>
|
||||
测试连接
|
||||
</Button>
|
||||
</Space>
|
||||
</Form>
|
||||
</Spin>
|
||||
),
|
||||
},
|
||||
...syncTabItems,
|
||||
];
|
||||
|
||||
return (
|
||||
<Card title="钉钉集成配置" extra={
|
||||
<Space>
|
||||
{verified === true && <Tag icon={<CheckCircleOutlined />} color="success">已连接</Tag>}
|
||||
{verified === false && <Tag icon={<CloseCircleOutlined />} color="error">未连接</Tag>}
|
||||
</Space>
|
||||
}>
|
||||
<Tabs items={tabItems} />
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default IntegrationConfigPage;
|
||||
@@ -1,8 +1,10 @@
|
||||
import React, { useCallback, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Form, Input, Button, Card, message, Typography } from 'antd';
|
||||
import { Form, Input, Button, Card, Typography } from 'antd';
|
||||
import { UserOutlined, LockOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
import { writePermissions } from '../../auth/permission-store';
|
||||
|
||||
const { Title } = Typography;
|
||||
|
||||
@@ -16,7 +18,7 @@ const LoginPage: React.FC = () => {
|
||||
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 || []));
|
||||
writePermissions(res.user.permissions || []);
|
||||
message.success('登录成功');
|
||||
navigate('/dashboard');
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space, message } from 'antd';
|
||||
import { List, Typography, Menu, Layout, Button, Empty, Spin, Space } from 'antd';
|
||||
import {
|
||||
BellOutlined,
|
||||
DollarOutlined,
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
} from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { Sider, Content } = Layout;
|
||||
|
||||
|
||||
@@ -9,13 +9,13 @@ import {
|
||||
Input,
|
||||
InputNumber,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
Switch,
|
||||
Alert,
|
||||
Tooltip,
|
||||
Empty,
|
||||
Alert,
|
||||
} from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
@@ -31,13 +31,15 @@ import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
const OccupanciesPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [students, setStudents] = useState<any[]>([]);
|
||||
const [rooms, setRooms] = useState<any[]>([]);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [checkInModal, setCheckInModal] = useState(false);
|
||||
const [checkOutModal, setCheckOutModal] = useState<any>(null);
|
||||
@@ -50,10 +52,13 @@ const OccupanciesPage: React.FC = () => {
|
||||
const [batchCheckOutModal, setBatchCheckOutModal] = useState(false);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [checkInForm] = Form.useForm();
|
||||
const [checkOutForm] = Form.useForm();
|
||||
const [transferForm] = Form.useForm();
|
||||
const [batchCheckOutForm] = Form.useForm();
|
||||
const [availableBeds, setAvailableBeds] = useState<any[]>([]);
|
||||
const [availableLockers, setAvailableLockers] = useState<any[]>([]);
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
@@ -62,9 +67,9 @@ const OccupanciesPage: React.FC = () => {
|
||||
api.get('/occupancies', { params: { active: showActive ? 'true' : undefined, dateFrom: dateRange?.[0]?.format('YYYY-MM-DD'), dateTo: dateRange?.[1]?.format('YYYY-MM-DD') } }),
|
||||
api.get('/students'),
|
||||
api.get('/rooms/overview'),
|
||||
api.get('/tenants'),
|
||||
api.get('/organizations'),
|
||||
])) as PromiseSettledResult<any>[];
|
||||
const labels = ['入住数据', '学生列表', '房间列表', '租赁方'];
|
||||
const labels = ['入住数据', '学生列表', '房间列表', '机构列表'];
|
||||
[occRes, stuRes, rmRes, tnRes].forEach((res, i) => {
|
||||
if (res.status === 'rejected') {
|
||||
message.warning(`${labels[i]}加载失败`);
|
||||
@@ -73,7 +78,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
setData(occRes.status === 'fulfilled' ? occRes.value : []);
|
||||
setStudents(stuRes.status === 'fulfilled' ? stuRes.value : []);
|
||||
setRooms(rmRes.status === 'fulfilled' ? rmRes.value : []);
|
||||
setTenants(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
||||
setOrganizations(tnRes.status === 'fulfilled' ? tnRes.value : []);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
message.error('数据加载异常');
|
||||
@@ -86,6 +91,25 @@ const OccupanciesPage: React.FC = () => {
|
||||
setSelectedRowKeys([]);
|
||||
}, [fetchData]);
|
||||
|
||||
const handleRoomChange = async (roomId: number) => {
|
||||
checkInForm.setFieldValue('bedId', undefined);
|
||||
checkInForm.setFieldValue('lockerId', undefined);
|
||||
if (!roomId) {
|
||||
setAvailableBeds([]);
|
||||
setAvailableLockers([]);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const [beds, lockers] = await Promise.all([
|
||||
api.get<any[]>(`/rooms/${roomId}/beds/available`),
|
||||
api.get<any[]>(`/rooms/${roomId}/lockers/available`),
|
||||
]);
|
||||
setAvailableBeds(beds);
|
||||
setAvailableLockers(lockers);
|
||||
if (beds.length === 1) checkInForm.setFieldValue('bedId', beds[0].id);
|
||||
} catch (e) { console.error(e); }
|
||||
};
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
if (!searchText) return data;
|
||||
const keyword = searchText.toLowerCase();
|
||||
@@ -105,9 +129,11 @@ const OccupanciesPage: React.FC = () => {
|
||||
roomId: values.roomId,
|
||||
checkInDate: values.checkInDate.format('YYYY-MM-DD'),
|
||||
billingStartDate: values.billingStartDate?.format('YYYY-MM-DD'),
|
||||
rentalType: values.rentalType,
|
||||
tenantId: values.tenantId,
|
||||
stayType: values.stayType,
|
||||
responsibleOrganizationId: values.responsibleOrganizationId,
|
||||
notes: values.notes,
|
||||
bedId: values.bedId,
|
||||
lockerId: values.lockerId || undefined,
|
||||
});
|
||||
message.success('入住登记成功');
|
||||
setCheckInModal(false);
|
||||
@@ -164,6 +190,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
|
||||
const handleBatchCheckOut = async () => {
|
||||
const values = await batchCheckOutForm.validateFields();
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/occupancies/batch-check-out', {
|
||||
ids: selectedRowKeys,
|
||||
@@ -178,10 +205,13 @@ const OccupanciesPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量退宿失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/occupancies/batch-delete', { ids: selectedRowKeys });
|
||||
message.success(res?.message || `已删除 ${selectedRowKeys.length} 条`);
|
||||
@@ -189,12 +219,16 @@ const OccupanciesPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量删除失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: '学生', width: 120, render: (_: any, r: any) => r.student?.name || '-' },
|
||||
{ title: '宿舍', width: 120, render: (_: any, r: any) => r.room?.roomNumber || '-' },
|
||||
{ title: '床位', width: 80, render: (_: unknown, r: Record<string, unknown>) => (r.bed as Record<string, string> | undefined)?.bedNumber || '-' },
|
||||
{ title: '柜子', width: 80, render: (_: unknown, r: Record<string, unknown>) => (r.locker as Record<string, string> | undefined)?.lockerNumber || '-' },
|
||||
{ title: '入住日期', dataIndex: 'checkInDate', width: 110 },
|
||||
{ title: '计费起始', dataIndex: 'billingStartDate', width: 110 },
|
||||
{
|
||||
@@ -422,6 +456,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
setBatchCheckOutModal(true);
|
||||
}}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量退宿
|
||||
</PermissionButton>
|
||||
@@ -438,6 +473,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
size="small"
|
||||
icon={<DeleteOutlined />}
|
||||
style={{ marginLeft: 12 }}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量删除
|
||||
</PermissionButton>
|
||||
@@ -457,12 +493,11 @@ const OccupanciesPage: React.FC = () => {
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 1300 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 条` }}
|
||||
rowSelection={rowSelection}
|
||||
/>
|
||||
|
||||
{/* 入住登记弹窗 */}
|
||||
<Modal
|
||||
title="入住登记"
|
||||
open={checkInModal}
|
||||
@@ -499,7 +534,8 @@ const OccupanciesPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="搜索并选择宿舍"
|
||||
options={rooms.map((r: any) => ({
|
||||
onChange={handleRoomChange}
|
||||
options={rooms.map((r) => ({
|
||||
value: r.id,
|
||||
label: `${r.roomNumber} (${r.building || ''}) [${r.currentCount}/${r.capacity}]`,
|
||||
disabled: r.currentCount >= r.capacity,
|
||||
@@ -520,7 +556,7 @@ const OccupanciesPage: React.FC = () => {
|
||||
format="YYYY-MM-DD"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="rentalType" label="租赁类型">
|
||||
<Form.Item name="stayType" label="入住类型">
|
||||
<Select
|
||||
allowClear
|
||||
options={[
|
||||
@@ -530,18 +566,49 @@ const OccupanciesPage: React.FC = () => {
|
||||
placeholder="默认为短租"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="tenantId" label="关联单位">
|
||||
<Form.Item name="responsibleOrganizationId" label="负责机构">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
optionFilterProp="label"
|
||||
placeholder="选择关联单位"
|
||||
options={tenants.map((t: { id: number; name: string }) => ({
|
||||
placeholder="默认取学生所属机构"
|
||||
options={organizations.map((t: { id: number; name: string }) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="bedId"
|
||||
label="床位"
|
||||
rules={[{ required: true, message: '请选择床位' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="请先选择房间"
|
||||
disabled={availableBeds.length === 0}
|
||||
options={availableBeds.map((b) => ({
|
||||
value: b.id,
|
||||
label: b.bedNumber,
|
||||
}))}
|
||||
notFoundContent="该房间暂无可用床位"
|
||||
/>
|
||||
</Form.Item>
|
||||
{availableBeds.length > 0 && (
|
||||
<div style={{ marginTop: -16, marginBottom: 16, color: '#888', fontSize: 12 }}>
|
||||
空闲 {availableBeds.length} 张床位
|
||||
</div>
|
||||
)}
|
||||
<Form.Item name="lockerId" label="柜子(可选)">
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="可选分配柜子"
|
||||
disabled={availableLockers.length === 0}
|
||||
options={availableLockers.map((l) => ({
|
||||
value: l.id,
|
||||
label: l.lockerNumber,
|
||||
}))}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
|
||||
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import { Table, Select, DatePicker, Space, Tag, Tooltip } from 'antd';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const { RangePicker } = DatePicker;
|
||||
|
||||
@@ -40,8 +41,9 @@ const OperationLogsPage: React.FC = () => {
|
||||
const res: any = await api.get('/operation-logs', { params });
|
||||
setData(res.data);
|
||||
setTotal(res.total);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [page, filterModule, dateRange]);
|
||||
|
||||
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
314
apps/admin/src/pages/Organizations/index.tsx
Normal file
@@ -0,0 +1,314 @@
|
||||
import React, { useEffect, useMemo, useState } from 'react';
|
||||
import { Alert, Empty, Form, Input, Modal, Popconfirm, Select, Space, Table, Tag } from 'antd';
|
||||
import { BankOutlined, InboxOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const PRESET_COLORS = [
|
||||
'#ff7875',
|
||||
'#ffa940',
|
||||
'#ffc53d',
|
||||
'#73d13d',
|
||||
'#36cfc9',
|
||||
'#40a9ff',
|
||||
'#597ef7',
|
||||
'#9254de',
|
||||
'#f759ab',
|
||||
'#8c8c8c',
|
||||
];
|
||||
|
||||
interface OrganizationItem {
|
||||
id: number;
|
||||
code: string;
|
||||
name: string;
|
||||
isHost: boolean;
|
||||
contactName?: string;
|
||||
phone?: string;
|
||||
color?: string;
|
||||
notes?: string;
|
||||
status: 'active' | 'archived';
|
||||
}
|
||||
|
||||
const OrganizationsPage: React.FC = () => {
|
||||
const [data, setData] = useState<OrganizationItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editing, setEditing] = useState<OrganizationItem | null>(null);
|
||||
const [form] = Form.useForm();
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>();
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
const keyword = searchText.trim().toLowerCase();
|
||||
return data.filter((item) => {
|
||||
const matchesKeyword =
|
||||
!keyword ||
|
||||
item.name.toLowerCase().includes(keyword) ||
|
||||
item.code.toLowerCase().includes(keyword) ||
|
||||
item.contactName?.toLowerCase().includes(keyword);
|
||||
return matchesKeyword && (!filterStatus || item.status === filterStatus);
|
||||
});
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
const fetchData = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
setData(
|
||||
await api.get<OrganizationItem[]>('/organizations', { params: { includeArchived: true } }),
|
||||
);
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '机构数据加载失败');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
void fetchData();
|
||||
}, []);
|
||||
|
||||
const openEditor = (record?: OrganizationItem) => {
|
||||
setEditing(record ?? null);
|
||||
form.resetFields();
|
||||
if (record) form.setFieldsValue(record);
|
||||
else form.setFieldsValue({ color: PRESET_COLORS[data.length % PRESET_COLORS.length] });
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
setSaving(true);
|
||||
try {
|
||||
if (editing) await api.put(`/organizations/${editing.id}`, values);
|
||||
else await api.post('/organizations', values);
|
||||
message.success(editing ? '机构已更新' : '机构已创建');
|
||||
setModalOpen(false);
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '保存失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: '机构',
|
||||
dataIndex: 'name',
|
||||
width: 220,
|
||||
render: (name: string, record: OrganizationItem) => (
|
||||
<Space>
|
||||
<span
|
||||
style={{
|
||||
width: 10,
|
||||
height: 10,
|
||||
borderRadius: '50%',
|
||||
background: record.color || '#8c8c8c',
|
||||
}}
|
||||
/>
|
||||
<strong>{name}</strong>
|
||||
{record.isHost ? (
|
||||
<Tag color="blue" icon={<BankOutlined />}>
|
||||
本机构
|
||||
</Tag>
|
||||
) : (
|
||||
<Tag>外部机构</Tag>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '机构编码',
|
||||
dataIndex: 'code',
|
||||
width: 130,
|
||||
render: (value: string) => <code>{value}</code>,
|
||||
},
|
||||
{
|
||||
title: '联系人',
|
||||
dataIndex: 'contactName',
|
||||
width: 120,
|
||||
render: (value?: string) => value || '-',
|
||||
},
|
||||
{ title: '电话', dataIndex: 'phone', width: 140, render: (value?: string) => value || '-' },
|
||||
{ title: '备注', dataIndex: 'notes', ellipsis: true, render: (value?: string) => value || '-' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 90,
|
||||
render: (status: string) => (
|
||||
<Tag color={status === 'active' ? 'green' : 'default'}>
|
||||
{status === 'active' ? '正常' : '已归档'}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 160,
|
||||
render: (_: unknown, record: OrganizationItem) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="organization:edit"
|
||||
size="small"
|
||||
onClick={() => openEditor(record)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{!record.isHost && record.status === 'active' ? (
|
||||
<Popconfirm
|
||||
title="归档后仍保留历史学生、入住和租赁记录"
|
||||
onConfirm={async () => {
|
||||
try {
|
||||
await api.delete(`/organizations/${record.id}`);
|
||||
message.success('机构已归档');
|
||||
await fetchData();
|
||||
} catch (error: any) {
|
||||
message.error(error?.message || '归档失败');
|
||||
}
|
||||
}}
|
||||
>
|
||||
<PermissionButton
|
||||
permission="organization:delete"
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : null}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="统一机构管理"
|
||||
description="本机构与外部机构使用同一套资料。学生明确归属机构;教室租赁则单独记录出租机构和承租机构。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索名称、编码或联系人"
|
||||
allowClear
|
||||
style={{ width: 260 }}
|
||||
onChange={(event) => setSearchText(event.target.value)}
|
||||
/>
|
||||
<Select
|
||||
placeholder="全部状态"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={setFilterStatus}
|
||||
options={[
|
||||
{ value: 'active', label: '正常' },
|
||||
{ value: 'archived', label: '已归档' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<PermissionButton
|
||||
permission="organization:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => openEditor()}
|
||||
>
|
||||
添加机构
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
dataSource={filteredData}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无机构" /> }}
|
||||
scroll={{ x: 1100 }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 个机构` }}
|
||||
/>
|
||||
<Modal
|
||||
title={editing ? `编辑机构 · ${editing.name}` : '添加外部机构'}
|
||||
open={modalOpen}
|
||||
onOk={handleSave}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
confirmLoading={saving}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={form} layout="vertical">
|
||||
{editing?.isHost ? (
|
||||
<Alert
|
||||
type="warning"
|
||||
showIcon
|
||||
message="这是系统本机构,不可归档,也不能改为外部机构。"
|
||||
style={{ marginBottom: 16 }}
|
||||
/>
|
||||
) : null}
|
||||
<Form.Item
|
||||
name="name"
|
||||
label="机构名称"
|
||||
rules={[{ required: true, message: '请输入机构名称' }]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="code"
|
||||
label="机构编码"
|
||||
tooltip="用于导入和系统识别,建议使用大写英文、数字、下划线或短横线"
|
||||
rules={[
|
||||
{ required: true },
|
||||
{ pattern: /^[A-Z0-9_-]+$/, message: '仅支持大写英文、数字、下划线和短横线' },
|
||||
]}
|
||||
>
|
||||
<Input
|
||||
disabled={editing?.isHost}
|
||||
placeholder="如 PARTNER_A"
|
||||
onChange={(event) => form.setFieldValue('code', event.target.value.toUpperCase())}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="contactName" label="联系人">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="phone" label="电话">
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item name="color" label="识别颜色">
|
||||
<Space wrap>
|
||||
{PRESET_COLORS.map((color) => (
|
||||
<button
|
||||
type="button"
|
||||
key={color}
|
||||
aria-label={`选择 ${color}`}
|
||||
onClick={() => form.setFieldValue('color', color)}
|
||||
style={{
|
||||
width: 30,
|
||||
height: 30,
|
||||
borderRadius: 6,
|
||||
border: '1px solid #d9d9d9',
|
||||
background: color,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={3} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default OrganizationsPage;
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { Card, Tag, Input, Space, Spin, Empty } from 'antd';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface PermissionItem {
|
||||
id: number;
|
||||
@@ -24,7 +25,7 @@ const PermissionsPage: React.FC = () => {
|
||||
bill: '账单管理',
|
||||
deposit: '押金管理',
|
||||
classroom: '教室管理',
|
||||
tenant: '租赁方',
|
||||
organization: '机构管理',
|
||||
rental: '租赁订单',
|
||||
log: '操作日志',
|
||||
user: '用户管理',
|
||||
@@ -36,7 +37,10 @@ const PermissionsPage: React.FC = () => {
|
||||
api
|
||||
.get('/rbac/permissions/tree')
|
||||
.then((res: any) => setPermTree(res))
|
||||
.catch(console.error)
|
||||
.catch((e: unknown) => {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载权限失败');
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -7,13 +7,14 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
Card,
|
||||
Checkbox,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface PermissionItem {
|
||||
id: number;
|
||||
@@ -52,8 +53,9 @@ const RolesPage: React.FC = () => {
|
||||
]);
|
||||
setData(roles);
|
||||
setAllPerms(permTree);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
@@ -123,7 +125,7 @@ const RolesPage: React.FC = () => {
|
||||
bill: '账单管理',
|
||||
deposit: '押金管理',
|
||||
classroom: '教室管理',
|
||||
tenant: '租赁方',
|
||||
organization: '机构管理',
|
||||
rental: '租赁订单',
|
||||
log: '操作日志',
|
||||
user: '用户管理',
|
||||
@@ -228,6 +230,7 @@ const RolesPage: React.FC = () => {
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 900 }}
|
||||
pagination={false}
|
||||
/>
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Row, Col, Card, Tag, Select, Statistic, Modal, Spin, Badge, Tooltip, Da
|
||||
import { HomeOutlined, UserOutlined, CalendarOutlined, BankOutlined, HistoryOutlined, ShopOutlined } from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
function getCardStyle(room: any): React.CSSProperties {
|
||||
let base: React.CSSProperties;
|
||||
@@ -10,8 +11,8 @@ function getCardStyle(room: any): React.CSSProperties {
|
||||
else if (room.currentCount === 0) base = { background: '#f6ffed', borderColor: '#b7eb8f' };
|
||||
else if (room.currentCount >= room.capacity) base = { background: '#fff2f0', borderColor: '#ffccc7' };
|
||||
else base = { background: '#e6f4ff', borderColor: '#91caff' };
|
||||
if (room.tenantColor) {
|
||||
return { ...base, background: `color-mix(in srgb, ${room.tenantColor} 15%, ${base.background || '#fff'} 85%)` };
|
||||
if (room.organizationColor) {
|
||||
return { ...base, background: `color-mix(in srgb, ${room.organizationColor} 15%, ${base.background || '#fff'} 85%)` };
|
||||
}
|
||||
return base;
|
||||
}
|
||||
@@ -23,18 +24,18 @@ function getStatusLabel(room: any) {
|
||||
return <Tag color="processing">部分入住</Tag>;
|
||||
}
|
||||
|
||||
function getTenantTags(occupants: any[]) {
|
||||
const tenantList = [
|
||||
function getOrganizationTags(occupants: any[]) {
|
||||
const organizationList = [
|
||||
...new Map(
|
||||
occupants
|
||||
.filter((o: any) => o.tenantName)
|
||||
.map((o: any) => [o.tenantId, { name: o.tenantName, color: o.tenantColor }]),
|
||||
.filter((o: any) => o.organizationName)
|
||||
.map((o: any) => [o.organizationId, { name: o.organizationName, color: o.organizationColor }]),
|
||||
).values(),
|
||||
] as { name: string; color: string | null }[];
|
||||
if (tenantList.length === 0) return null;
|
||||
if (organizationList.length === 0) return null;
|
||||
return (
|
||||
<div className="room-card-tag-wrapper" style={{ marginBottom: 6 }}>
|
||||
{tenantList.map((t) => (
|
||||
{organizationList.map((t) => (
|
||||
<Tag
|
||||
key={t.name}
|
||||
color={t.color || 'gold'}
|
||||
@@ -52,7 +53,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
const [data, setData] = useState<any>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedBuilding, setSelectedBuilding] = useState<string>('all');
|
||||
const [selectedTenant, setSelectedTenant] = useState<number | 'all'>('all');
|
||||
const [selectedOrganization, setSelectedOrganization] = useState<number | 'all'>('all');
|
||||
const [detailRoom, setDetailRoom] = useState<any>(null);
|
||||
const [asOf, setAsOf] = useState<Dayjs | null>(null);
|
||||
|
||||
@@ -64,8 +65,9 @@ const RoomVisualPage: React.FC = () => {
|
||||
const params = isHistorical ? { asOf: asOf!.format('YYYY-MM-DD') } : undefined;
|
||||
const res: any = await api.get('/rooms/visual', { params });
|
||||
setData(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [isHistorical, asOf]);
|
||||
@@ -78,7 +80,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
|
||||
const rooms = data.rooms.filter((r: any) => {
|
||||
if (selectedBuilding !== 'all' && r.building !== selectedBuilding) return false;
|
||||
if (selectedTenant !== 'all' && !(r.tenantIds || []).includes(selectedTenant)) return false;
|
||||
if (selectedOrganization !== 'all' && !(r.organizationIds || []).includes(selectedOrganization)) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -86,14 +88,12 @@ const RoomVisualPage: React.FC = () => {
|
||||
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 totalBeds = rooms.reduce((sum: number, r: any) => sum + (r.totalBeds || 0), 0);
|
||||
const occupiedBeds = rooms.reduce((sum: number, r: any) => sum + (r.occupiedBeds || 0), 0);
|
||||
const availableBedsCount = totalBeds - occupiedBeds;
|
||||
const fullRooms = rooms.filter((r: any) => r.currentCount >= r.capacity).length;
|
||||
|
||||
/* getCardStyle, getStatusLabel, getTenantTags are now standalone functions outside the component */
|
||||
/* getCardStyle, getStatusLabel, getOrganizationTags are now standalone functions outside the component */
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -128,12 +128,12 @@ const RoomVisualPage: React.FC = () => {
|
||||
]}
|
||||
/>
|
||||
<Select
|
||||
value={selectedTenant}
|
||||
onChange={setSelectedTenant}
|
||||
value={selectedOrganization}
|
||||
onChange={setSelectedOrganization}
|
||||
style={{ width: 180 }}
|
||||
options={[
|
||||
{ value: 'all', label: '全部租赁方' },
|
||||
...(data.tenants || []).map((t: any) => ({
|
||||
{ value: 'all', label: '全部机构' },
|
||||
...(data.organizations || []).map((t: any) => ({
|
||||
value: t.id,
|
||||
label: (
|
||||
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
|
||||
@@ -180,7 +180,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
<Card size="small">
|
||||
<Statistic title="可安排床位" value={availableBeds} styles={{ value: { color: '#007AFF' } }} />
|
||||
<Statistic title="可安排床位" value={availableBedsCount} styles={{ value: { color: '#007AFF' } }} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} sm={6}>
|
||||
@@ -215,10 +215,10 @@ const RoomVisualPage: React.FC = () => {
|
||||
}}
|
||||
>
|
||||
<span style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 16, fontWeight: 600, color: '#1d1d1f' }}>
|
||||
{room.tenantColor && (
|
||||
{room.organizationColor && (
|
||||
<span style={{
|
||||
width: 10, height: 10, borderRadius: '50%',
|
||||
backgroundColor: room.tenantColor, display: 'inline-block',
|
||||
backgroundColor: room.organizationColor, display: 'inline-block',
|
||||
flexShrink: 0,
|
||||
}} />
|
||||
)}
|
||||
@@ -230,6 +230,11 @@ const RoomVisualPage: React.FC = () => {
|
||||
{room.building && <span>{room.building} </span>}
|
||||
{room.floor && <span>{room.floor}F</span>}
|
||||
</div>
|
||||
{room.totalBeds > 0 && (
|
||||
<div style={{ fontSize: 12, color: room.occupiedBeds >= room.totalBeds ? '#FF3B30' : '#34C759', marginBottom: 6 }}>
|
||||
床位: {room.occupiedBeds}/{room.totalBeds}
|
||||
</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 4, marginBottom: 8 }}>
|
||||
<Badge
|
||||
count={`${room.currentCount}/${room.capacity}`}
|
||||
@@ -252,7 +257,7 @@ const RoomVisualPage: React.FC = () => {
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
{getTenantTags(room.occupants)}
|
||||
{getOrganizationTags(room.occupants)}
|
||||
{room.occupants.length > 0 && (
|
||||
<div className="room-card-tag-wrapper" style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 6 }}>
|
||||
{room.occupants.slice(0, 4).map((o: any) => (
|
||||
@@ -298,10 +303,10 @@ const RoomVisualPage: React.FC = () => {
|
||||
位置:{detailRoom.building || '-'} {detailRoom.floor ? `${detailRoom.floor}F` : ''}
|
||||
</div>
|
||||
|
||||
{detailRoom.tenantColor && (
|
||||
{detailRoom.organizationColor && (
|
||||
<div style={{ marginBottom: 8 }}>
|
||||
<Tag color={detailRoom.tenantColor}>
|
||||
{detailRoom.occupants[0]?.tenantName || '租户'}
|
||||
<Tag color={detailRoom.organizationColor}>
|
||||
{detailRoom.occupants[0]?.organizationName || '机构'}
|
||||
</Tag>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Table,
|
||||
Button,
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
InputNumber,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Badge,
|
||||
Upload,
|
||||
Drawer,
|
||||
Tabs,
|
||||
Empty,
|
||||
} from 'antd';
|
||||
import type { UploadRequestError, UploadRequestOption } from '@rc-component/upload/lib/interface';
|
||||
import {
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
import api from '../../api';
|
||||
import { downloadBlob } from '../../utils/download';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '可入住', color: 'green' },
|
||||
@@ -36,6 +39,20 @@ const statusMap: Record<string, { text: string; color: string }> = {
|
||||
archived: { text: '已归档', color: '#999' },
|
||||
};
|
||||
|
||||
interface BedItem {
|
||||
id: number;
|
||||
bedNumber: string;
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
interface LockerItem {
|
||||
id: number;
|
||||
lockerNumber: string;
|
||||
status: string;
|
||||
notes?: string | null;
|
||||
}
|
||||
|
||||
function parseRoomNumber(input: string) {
|
||||
const cleaned = input.replace(/[((].*?[))]/g, '').trim();
|
||||
const familyMatch = cleaned.match(/^(\d+)-(\d+)-(\d+)$/);
|
||||
@@ -66,7 +83,6 @@ const RoomsPage: React.FC = () => {
|
||||
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('');
|
||||
@@ -75,8 +91,22 @@ const RoomsPage: React.FC = () => {
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [form] = Form.useForm();
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerRoom, setDrawerRoom] = useState<any>(null);
|
||||
const [beds, setBeds] = useState<any[]>([]);
|
||||
const [lockers, setLockers] = useState<any[]>([]);
|
||||
const [bedModalOpen, setBedModalOpen] = useState(false);
|
||||
const [bedEditing, setBedEditing] = useState<any>(null);
|
||||
const [lockerModalOpen, setLockerModalOpen] = useState(false);
|
||||
const [lockerEditing, setLockerEditing] = useState<any>(null);
|
||||
const [bedForm] = Form.useForm();
|
||||
const [lockerForm] = Form.useForm();
|
||||
const [savingBed, setSavingBed] = useState(false);
|
||||
const [savingLocker, setSavingLocker] = useState(false);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/rooms/batch-delete', { ids: selectedRowKeys });
|
||||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 间`);
|
||||
@@ -84,6 +114,8 @@ const RoomsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量归档失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,8 +128,9 @@ const RoomsPage: React.FC = () => {
|
||||
setArchivedCount(archived.length);
|
||||
const filtered = showArchived ? res : res.filter((r: any) => r.status !== 'archived');
|
||||
setData(filtered);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
};
|
||||
@@ -145,14 +178,92 @@ const RoomsPage: React.FC = () => {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const showDetail = async (id: number) => {
|
||||
const fetchBeds = useCallback(async (roomId: number) => {
|
||||
try {
|
||||
const res = await api.get(`/rooms/${id}`);
|
||||
setDetailModal(res);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
const res = await api.get<BedItem[]>(`/rooms/${roomId}/beds`);
|
||||
setBeds(res);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载床位失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const fetchLockers = useCallback(async (roomId: number) => {
|
||||
try {
|
||||
const res = await api.get<LockerItem[]>(`/rooms/${roomId}/lockers`);
|
||||
setLockers(res);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载柜子失败');
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleSaveBed = async () => {
|
||||
const values = await bedForm.validateFields();
|
||||
setSavingBed(true);
|
||||
try {
|
||||
if (bedEditing) {
|
||||
await api.put(`/rooms/${drawerRoom.id}/beds/${bedEditing.id}`, values);
|
||||
} else {
|
||||
await api.post(`/rooms/${drawerRoom.id}/beds`, values);
|
||||
}
|
||||
message.success(bedEditing ? '更新成功' : '添加成功');
|
||||
setBedModalOpen(false);
|
||||
bedForm.resetFields();
|
||||
setBedEditing(null);
|
||||
fetchBeds(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '操作失败'); }
|
||||
finally { setSavingBed(false); }
|
||||
};
|
||||
|
||||
const handleDeleteBed = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/rooms/${drawerRoom.id}/beds/${id}`);
|
||||
message.success('已删除');
|
||||
fetchBeds(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleBatchBeds = async (count: number) => {
|
||||
try {
|
||||
await api.post(`/rooms/${drawerRoom.id}/beds/batch`, { count });
|
||||
message.success(`已生成 ${count} 张床位`);
|
||||
fetchBeds(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '批量生成失败'); }
|
||||
};
|
||||
|
||||
const handleSaveLocker = async () => {
|
||||
const values = await lockerForm.validateFields();
|
||||
setSavingLocker(true);
|
||||
try {
|
||||
if (lockerEditing) {
|
||||
await api.put(`/rooms/${drawerRoom.id}/lockers/${lockerEditing.id}`, values);
|
||||
} else {
|
||||
await api.post(`/rooms/${drawerRoom.id}/lockers`, values);
|
||||
}
|
||||
message.success(lockerEditing ? '更新成功' : '添加成功');
|
||||
setLockerModalOpen(false);
|
||||
lockerForm.resetFields();
|
||||
setLockerEditing(null);
|
||||
fetchLockers(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '操作失败'); }
|
||||
finally { setSavingLocker(false); }
|
||||
};
|
||||
|
||||
const handleDeleteLocker = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/rooms/${drawerRoom.id}/lockers/${id}`);
|
||||
message.success('已删除');
|
||||
fetchLockers(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '删除失败'); }
|
||||
};
|
||||
|
||||
const handleBatchLockers = async (count: number) => {
|
||||
try {
|
||||
await api.post(`/rooms/${drawerRoom.id}/lockers/batch`, { count });
|
||||
message.success(`已生成 ${count} 个柜子`);
|
||||
fetchLockers(drawerRoom.id);
|
||||
} catch (e: any) { message.error(e?.message || '批量生成失败'); }
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
@@ -241,56 +352,58 @@ const RoomsPage: React.FC = () => {
|
||||
{
|
||||
title: '操作',
|
||||
width: 220,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="确定恢复此宿舍?恢复后将重新出现在宿舍总览中。"
|
||||
onConfirm={() => handleRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="room:edit" size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
render: (_: unknown, record: unknown) => {
|
||||
const r = record as { status?: string; id: number };
|
||||
return (
|
||||
<Space>
|
||||
{r.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="归档后不会删除数据,可随时恢复。有在住人员将无法归档。"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
title="确定恢复此宿舍?"
|
||||
onConfirm={() => handleRestore(r.id)}
|
||||
>
|
||||
<PermissionButton permission="room:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
<PermissionButton permission="room:edit" size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="room:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={async () => {
|
||||
const rec = record as { id: number };
|
||||
setDrawerRoom(record);
|
||||
setDrawerOpen(true);
|
||||
await Promise.all([fetchBeds(rec.id), fetchLockers(rec.id)]);
|
||||
}}
|
||||
>
|
||||
查看
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="room:edit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
const rec = record as { id: number };
|
||||
setEditing(rec);
|
||||
form.setFieldsValue(rec);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm title="确定归档?" onConfirm={() => handleArchive(r.id)}>
|
||||
<PermissionButton permission="room:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
);
|
||||
},
|
||||
},
|
||||
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, showDetail]);
|
||||
], [showArchived, buildings, handleBatchDelete, handleRestore, handleArchive, fetchBeds, fetchLockers]);
|
||||
|
||||
return (
|
||||
<div>
|
||||
@@ -338,7 +451,13 @@ const RoomsPage: React.FC = () => {
|
||||
cancelText="取消"
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
>
|
||||
<PermissionButton permission="room:delete" danger icon={<DeleteOutlined />} disabled={selectedRowKeys.length === 0}>
|
||||
<PermissionButton
|
||||
permission="room:delete"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
@@ -399,6 +518,7 @@ const RoomsPage: React.FC = () => {
|
||||
rowKey="id"
|
||||
scroll={{ x: 1200 }}
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
pagination={{ pageSize: 20, showTotal: (total) => `共 ${total} 间` }}
|
||||
rowClassName={(record) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
rowSelection={{
|
||||
@@ -478,27 +598,254 @@ const RoomsPage: React.FC = () => {
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={`宿舍 ${detailModal?.roomNumber} 当前住户`}
|
||||
open={!!detailModal}
|
||||
onCancel={() => setDetailModal(null)}
|
||||
footer={null}
|
||||
width={600}
|
||||
<Drawer
|
||||
title={`${drawerRoom?.roomNumber} 房间详情`}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); setDrawerRoom(null); }}
|
||||
width={640}
|
||||
destroyOnClose
|
||||
>
|
||||
{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>
|
||||
)}
|
||||
<Tabs
|
||||
defaultActiveKey="info"
|
||||
items={[
|
||||
{
|
||||
key: 'info',
|
||||
label: '基本信息',
|
||||
children: drawerRoom && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 12 }}>
|
||||
<div><strong>房间号:</strong>{drawerRoom.roomNumber}</div>
|
||||
<div><strong>楼栋:</strong>{drawerRoom.building || '-'}</div>
|
||||
<div><strong>楼层:</strong>{drawerRoom.floor ?? '-'}</div>
|
||||
<div><strong>类型:</strong>{drawerRoom.roomType || '-'}</div>
|
||||
<div><strong>额定人数:</strong>{drawerRoom.capacity}</div>
|
||||
<div><strong>租赁类别:</strong>{drawerRoom.rentalCategory === 'long' ? '长租' : '短租'}</div>
|
||||
<div><strong>月租金:</strong>{drawerRoom.monthlyRate ? `¥${drawerRoom.monthlyRate}` : '-'}</div>
|
||||
<div><strong>状态:</strong><Tag color={statusMap[drawerRoom.status]?.color}>{statusMap[drawerRoom.status]?.text}</Tag></div>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'beds',
|
||||
label: `床位管理 (${beds.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onClick={() => { setBedEditing(null); bedForm.resetFields(); setBedModalOpen(true); }}
|
||||
>
|
||||
添加床位
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="批量生成床位"
|
||||
description={
|
||||
<InputNumber min={1} max={20} defaultValue={4} id="batch-bed-count" style={{ width: 80 }} />
|
||||
}
|
||||
onConfirm={() => {
|
||||
const input = document.getElementById('batch-bed-count') as HTMLInputElement;
|
||||
handleBatchBeds(input ? parseInt(input.value) || 4 : 4);
|
||||
}}
|
||||
okText="生成"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
>
|
||||
<Button size="small" disabled={drawerRoom?.status === 'archived'}>批量生成</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={beds}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'bedNumber', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (s: string) => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
<Space size="small">
|
||||
<PermissionButton
|
||||
permission="room:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onClick={() => { setBedEditing(r); bedForm.setFieldsValue(r); setBedModalOpen(true); }}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{r.status !== 'occupied' && (
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteBed(r.id)}>
|
||||
<PermissionButton
|
||||
permission="room:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lockers',
|
||||
label: `柜子管理 (${lockers.length})`,
|
||||
children: (
|
||||
<div>
|
||||
<div style={{ marginBottom: 12, display: 'flex', gap: 8 }}>
|
||||
<Button
|
||||
type="primary"
|
||||
size="small"
|
||||
icon={<PlusOutlined />}
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onClick={() => { setLockerEditing(null); lockerForm.resetFields(); setLockerModalOpen(true); }}
|
||||
>
|
||||
添加柜子
|
||||
</Button>
|
||||
<Popconfirm
|
||||
title="批量生成柜子"
|
||||
description={
|
||||
<InputNumber min={1} max={20} defaultValue={4} id="batch-locker-count" style={{ width: 80 }} />
|
||||
}
|
||||
onConfirm={() => {
|
||||
const input = document.getElementById('batch-locker-count') as HTMLInputElement;
|
||||
handleBatchLockers(input ? parseInt(input.value) || 4 : 4);
|
||||
}}
|
||||
okText="生成"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
>
|
||||
<Button size="small" disabled={drawerRoom?.status === 'archived'}>批量生成</Button>
|
||||
</Popconfirm>
|
||||
</div>
|
||||
<Table
|
||||
dataSource={lockers}
|
||||
rowKey="id"
|
||||
pagination={false}
|
||||
size="small"
|
||||
columns={[
|
||||
{ title: '编号', dataIndex: 'lockerNumber', width: 80 },
|
||||
{
|
||||
title: '状态', dataIndex: 'status', width: 80,
|
||||
render: (s: string) => {
|
||||
const map: Record<string, { text: string; color: string }> = {
|
||||
available: { text: '空闲', color: 'green' },
|
||||
occupied: { text: '占用', color: 'blue' },
|
||||
maintenance: { text: '维修', color: 'orange' },
|
||||
};
|
||||
return <Tag color={map[s]?.color}>{map[s]?.text || s}</Tag>;
|
||||
},
|
||||
},
|
||||
{ title: '备注', dataIndex: 'notes', render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '操作', width: 120,
|
||||
render: (_: any, r: any) => (
|
||||
<Space size="small">
|
||||
<PermissionButton
|
||||
permission="room:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
onClick={() => { setLockerEditing(r); lockerForm.setFieldsValue(r); setLockerModalOpen(true); }}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
{r.status !== 'occupied' && (
|
||||
<Popconfirm title="确定删除?" onConfirm={() => handleDeleteLocker(r.id)}>
|
||||
<PermissionButton
|
||||
permission="room:edit"
|
||||
size="small"
|
||||
type="link"
|
||||
danger
|
||||
disabled={drawerRoom?.status === 'archived'}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Drawer>
|
||||
|
||||
<Modal
|
||||
title={bedEditing ? '编辑床位' : '添加床位'}
|
||||
open={bedModalOpen}
|
||||
onOk={handleSaveBed}
|
||||
onCancel={() => { setBedModalOpen(false); setBedEditing(null); }}
|
||||
confirmLoading={savingBed}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={bedForm} layout="vertical">
|
||||
<Form.Item name="bedNumber" label="床位编号" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:1号床" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'available', label: '空闲' },
|
||||
{ value: 'maintenance', label: '维修中' },
|
||||
]}
|
||||
placeholder="默认为空闲"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
|
||||
<Modal
|
||||
title={lockerEditing ? '编辑柜子' : '添加柜子'}
|
||||
open={lockerModalOpen}
|
||||
onOk={handleSaveLocker}
|
||||
onCancel={() => { setLockerModalOpen(false); setLockerEditing(null); }}
|
||||
confirmLoading={savingLocker}
|
||||
okText="保存"
|
||||
>
|
||||
<Form form={lockerForm} layout="vertical">
|
||||
<Form.Item name="lockerNumber" label="柜子编号" rules={[{ required: true }]}>
|
||||
<Input placeholder="如:1号柜" />
|
||||
</Form.Item>
|
||||
<Form.Item name="status" label="状态">
|
||||
<Select
|
||||
options={[
|
||||
{ value: 'available', label: '空闲' },
|
||||
{ value: 'maintenance', label: '维修中' },
|
||||
]}
|
||||
placeholder="默认为空闲"
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,18 +1,45 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
Card, Button, Select, Modal, Form, Input, DatePicker, TimePicker,
|
||||
Popconfirm, message, Space, Spin, Empty, Tag, Tooltip, Segmented,
|
||||
Card,
|
||||
Button,
|
||||
Select,
|
||||
Modal,
|
||||
Form,
|
||||
Input,
|
||||
DatePicker,
|
||||
TimePicker,
|
||||
Popconfirm,
|
||||
Space,
|
||||
Spin,
|
||||
Empty,
|
||||
Tag,
|
||||
Tooltip,
|
||||
Segmented,
|
||||
Badge,
|
||||
Row,
|
||||
Col,
|
||||
Statistic,
|
||||
Alert,
|
||||
Switch,
|
||||
} from 'antd';
|
||||
import {
|
||||
CalendarOutlined,
|
||||
LeftOutlined,
|
||||
RightOutlined,
|
||||
DeleteOutlined,
|
||||
CloudSyncOutlined,
|
||||
PlusOutlined,
|
||||
EditOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import dayjs, { Dayjs } from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
import {
|
||||
buildSchedulePayload,
|
||||
scheduleToFormValues,
|
||||
type ScheduleFormValues,
|
||||
} from './schedule-form';
|
||||
|
||||
// ---- Types ----
|
||||
|
||||
@@ -48,12 +75,22 @@ interface ClassItem {
|
||||
code: string;
|
||||
}
|
||||
|
||||
interface ScheduleFormValues {
|
||||
classId: number;
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
interface ClassTeacherOption {
|
||||
id: number;
|
||||
userId: number;
|
||||
username?: string;
|
||||
name?: string;
|
||||
roleType: string;
|
||||
subject?: string | null;
|
||||
}
|
||||
/** 排班同步返回结果 */
|
||||
interface ScheduleSyncResult {
|
||||
scheduleCount: number;
|
||||
shiftCount: number;
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
}
|
||||
|
||||
const WEEKDAYS = ['周一', '周二', '周三', '周四', '周五', '周六', '周日'];
|
||||
@@ -72,6 +109,7 @@ const SchedulesPage: React.FC = () => {
|
||||
// Data
|
||||
const [classrooms, setClassrooms] = useState<ClassroomItem[]>([]);
|
||||
const [classes, setClasses] = useState<ClassItem[]>([]);
|
||||
const [classTeachers, setClassTeachers] = useState<ClassTeacherOption[]>([]);
|
||||
const [matrix, setMatrix] = useState<Record<number, Record<number, ClassScheduleItem[]>>>({});
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
@@ -81,13 +119,73 @@ const SchedulesPage: React.FC = () => {
|
||||
|
||||
// Modal
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'detail'>('create');
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit' | 'detail'>('create');
|
||||
const [editingSchedule, setEditingSchedule] = useState<ClassScheduleItem | null>(null);
|
||||
const [selectedCell, setSelectedCell] = useState<{
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
} | null>(null);
|
||||
const [selectedSchedules, setSelectedSchedules] = useState<ClassScheduleItem[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
// ── 钉钉排班同步 ──
|
||||
const [syncModalOpen, setSyncModalOpen] = useState(false);
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [syncStatus, setSyncStatus] = useState<{
|
||||
activeSchedules: number;
|
||||
mappedClasses: number;
|
||||
totalClasses: number;
|
||||
} | null>(null);
|
||||
const [syncResult, setSyncResult] = useState<{
|
||||
scheduleCount: number;
|
||||
shiftCount: number;
|
||||
groupCount: number;
|
||||
syncedItems: number;
|
||||
skippedNoMapping: number;
|
||||
groups: Array<{ className: string; groupId: number; itemCount: number }>;
|
||||
} | null>(null);
|
||||
const [syncDateFrom, setSyncDateFrom] = useState<Dayjs>(dayjs);
|
||||
const [syncDays, setSyncDays] = useState(30);
|
||||
const [attendanceMachineOnly, setAttendanceMachineOnly] = useState(false);
|
||||
|
||||
/** 打开同步弹窗时先查询就绪状态 */
|
||||
const openSyncModal = useCallback(async () => {
|
||||
setSyncModalOpen(true);
|
||||
setSyncResult(null);
|
||||
try {
|
||||
const res = await api.get<{
|
||||
success: boolean;
|
||||
data: { activeSchedules: number; mappedClasses: number; totalClasses: number };
|
||||
}>('/sync/schedule/status');
|
||||
setSyncStatus(res.data);
|
||||
} catch {
|
||||
setSyncStatus(null);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/** 执行排班同步 */
|
||||
const handleSyncSchedule = useCallback(async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res = await api.post<{
|
||||
success: boolean;
|
||||
data: ScheduleSyncResult;
|
||||
}>('/sync/schedule/sync', null, {
|
||||
params: {
|
||||
dateFrom: syncDateFrom.format('YYYY-MM-DD'),
|
||||
days: syncDays,
|
||||
attendanceMachineOnly,
|
||||
},
|
||||
});
|
||||
setSyncResult(res.data);
|
||||
message.success(`同步完成:${res.data.syncedItems} 条排班已写入钉钉`);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '同步失败');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
}, [syncDateFrom, syncDays, attendanceMachineOnly]);
|
||||
const [form] = Form.useForm<ScheduleFormValues>();
|
||||
|
||||
// Derived week/month info
|
||||
@@ -132,7 +230,6 @@ const SchedulesPage: React.FC = () => {
|
||||
return weekEnd.format('YYYY-MM-DD');
|
||||
}, [viewMode, weekEnd, calendarDays]);
|
||||
|
||||
|
||||
// ---- Data fetching ----
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
@@ -231,7 +328,10 @@ const SchedulesPage: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
} else {
|
||||
setSelectedSchedules([]);
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({ classroomId, weekDay });
|
||||
setModalOpen(true);
|
||||
}
|
||||
};
|
||||
@@ -261,38 +361,75 @@ const SchedulesPage: React.FC = () => {
|
||||
setModalOpen(true);
|
||||
};
|
||||
|
||||
// ---- Create schedule ----
|
||||
const loadClassTeachers = useCallback(async (classId: number) => {
|
||||
try {
|
||||
const teachers = await api.get<ClassTeacherOption[]>(
|
||||
`/class-schedules/classes/${classId}/teachers`,
|
||||
);
|
||||
setClassTeachers(teachers);
|
||||
return teachers;
|
||||
} catch {
|
||||
setClassTeachers([]);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
const applyClassTeacherDefaults = useCallback(
|
||||
async (classId: number, subject?: string) => {
|
||||
const teachers = await loadClassTeachers(classId);
|
||||
const subjectTeachers = teachers.filter((teacher) => teacher.roleType === 'subject_teacher');
|
||||
const matchedBySubject = subject
|
||||
? subjectTeachers.filter((teacher) => teacher.subject && teacher.subject === subject)
|
||||
: [];
|
||||
const matched = matchedBySubject.length > 0 ? matchedBySubject : subjectTeachers;
|
||||
if (matched.length === 1) {
|
||||
form.setFieldValue('teacherId', matched[0].userId);
|
||||
if (!subject && matched[0].subject) form.setFieldValue('subject', matched[0].subject);
|
||||
} else {
|
||||
form.setFieldValue('teacherId', undefined);
|
||||
}
|
||||
},
|
||||
[form, loadClassTeachers],
|
||||
);
|
||||
|
||||
// ---- Create / edit schedule ----
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!selectedCell) return;
|
||||
if (modalMode === 'create' && !selectedCell) return;
|
||||
if (modalMode === 'edit' && !editingSchedule) return;
|
||||
try {
|
||||
const values = await form.validateFields();
|
||||
const values = (await form.validateFields()) as ScheduleFormValues;
|
||||
setSubmitting(true);
|
||||
const payload = buildSchedulePayload(values);
|
||||
|
||||
const payload = {
|
||||
classId: values.classId,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
classroomId: selectedCell.classroomId,
|
||||
weekDay: selectedCell.weekDay,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
};
|
||||
|
||||
await api.post('/class-schedules', payload);
|
||||
message.success('排课创建成功');
|
||||
if (modalMode === 'edit' && editingSchedule) {
|
||||
await api.put(`/class-schedules/${editingSchedule.id}`, payload);
|
||||
message.success('排课更新成功,请重新同步到钉钉排班');
|
||||
} else {
|
||||
await api.post('/class-schedules', payload);
|
||||
message.success('排课创建成功');
|
||||
}
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string; status?: number };
|
||||
message.error(err?.message || '创建排课失败');
|
||||
message.error(err?.message || (modalMode === 'edit' ? '更新排课失败' : '创建排课失败'));
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const openEditSchedule = (schedule: ClassScheduleItem) => {
|
||||
if (schedule.scheduleType === 'RENTAL') {
|
||||
message.warning('租赁排课请在租赁订单中修改');
|
||||
return;
|
||||
}
|
||||
setEditingSchedule(schedule);
|
||||
setModalMode('edit');
|
||||
form.setFieldsValue(scheduleToFormValues(schedule));
|
||||
void loadClassTeachers(schedule.classId);
|
||||
};
|
||||
|
||||
// ---- Delete schedule ----
|
||||
|
||||
@@ -368,6 +505,14 @@ const SchedulesPage: React.FC = () => {
|
||||
{ label: '月视图', value: 'month' },
|
||||
]}
|
||||
/>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
onClick={openSyncModal}
|
||||
>
|
||||
同步到钉钉排班
|
||||
</PermissionButton>
|
||||
{viewMode === 'week' ? (
|
||||
<>
|
||||
<Button
|
||||
@@ -382,10 +527,7 @@ const SchedulesPage: React.FC = () => {
|
||||
({startDateStr} ~ {endDateStr})
|
||||
</span>
|
||||
</span>
|
||||
<Button
|
||||
icon={<RightOutlined />}
|
||||
onClick={() => setViewDate(viewDate.add(7, 'day'))}
|
||||
>
|
||||
<Button icon={<RightOutlined />} onClick={() => setViewDate(viewDate.add(7, 'day'))}>
|
||||
下一周
|
||||
</Button>
|
||||
</>
|
||||
@@ -439,7 +581,7 @@ const SchedulesPage: React.FC = () => {
|
||||
<Spin spinning={loading}>
|
||||
{classrooms.length === 0 ? (
|
||||
<Empty description="暂无教室数据" />
|
||||
) : (viewMode === 'week' ? (
|
||||
) : viewMode === 'week' ? (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table
|
||||
style={{
|
||||
@@ -639,10 +781,14 @@ const SchedulesPage: React.FC = () => {
|
||||
transition: 'background 0.15s',
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '#f0f5ff' : '#f0f0f0';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? '#f0f5ff'
|
||||
: '#f0f0f0';
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth ? '' : '#fafafa';
|
||||
(e.currentTarget as HTMLElement).style.background = isCurrentMonth
|
||||
? ''
|
||||
: '#fafafa';
|
||||
}}
|
||||
>
|
||||
<div
|
||||
@@ -671,7 +817,7 @@ const SchedulesPage: React.FC = () => {
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
))}
|
||||
)}
|
||||
</Spin>
|
||||
|
||||
{/* Modal */}
|
||||
@@ -679,24 +825,25 @@ const SchedulesPage: React.FC = () => {
|
||||
title={
|
||||
modalMode === 'create'
|
||||
? `新增排课 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||||
: selectedDate
|
||||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
|
||||
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||||
: modalMode === 'edit'
|
||||
? `编辑排课 — ${editingSchedule?.subject || ''}`
|
||||
: selectedDate
|
||||
? `排课详情 — ${selectedDate.format('YYYY-MM-DD')} ${WEEKDAYS[selectedDate.day() === 0 ? 6 : selectedDate.day() - 1]}`
|
||||
: `排课详情 — ${selectedClassroom?.name || ''} · ${selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}`
|
||||
}
|
||||
open={modalOpen}
|
||||
onCancel={() => setModalOpen(false)}
|
||||
onOk={modalMode === 'create' ? handleSubmit : undefined}
|
||||
onCancel={() => {
|
||||
setModalOpen(false);
|
||||
setEditingSchedule(null);
|
||||
}}
|
||||
onOk={modalMode !== 'detail' ? handleSubmit : undefined}
|
||||
confirmLoading={submitting}
|
||||
okText={modalMode === 'create' ? '创建' : undefined}
|
||||
footer={
|
||||
modalMode === 'create'
|
||||
? undefined // use default ok/cancel
|
||||
: null // no footer for detail mode
|
||||
}
|
||||
okText={modalMode === 'edit' ? '保存' : modalMode === 'create' ? '创建' : undefined}
|
||||
footer={modalMode === 'detail' ? null : undefined}
|
||||
width={600}
|
||||
destroyOnHidden
|
||||
>
|
||||
{modalMode === 'create' ? (
|
||||
{modalMode !== 'detail' ? (
|
||||
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
|
||||
<Form.Item
|
||||
name="classId"
|
||||
@@ -708,6 +855,33 @@ const SchedulesPage: React.FC = () => {
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={classOptions}
|
||||
onChange={(classId: number) => {
|
||||
form.setFieldValue('teacherId', undefined);
|
||||
void applyClassTeacherDefaults(classId, form.getFieldValue('subject'));
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="classroomId"
|
||||
label="教室"
|
||||
rules={[{ required: true, message: '请选择教室' }]}
|
||||
>
|
||||
<Select
|
||||
placeholder="选择教室"
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
options={classroomOptions}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="weekDay"
|
||||
label="星期"
|
||||
rules={[{ required: true, message: '请选择星期' }]}
|
||||
>
|
||||
<Select
|
||||
options={WEEKDAY_NUMBERS.map((value) => ({ value, label: WEEKDAYS[value - 1] }))}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -716,25 +890,26 @@ const SchedulesPage: React.FC = () => {
|
||||
label="科目"
|
||||
rules={[{ required: true, message: '请输入科目' }]}
|
||||
>
|
||||
<Input placeholder="如:数学、语文" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
name="teacherId"
|
||||
label="教师ID(可选)"
|
||||
normalize={(v) => (v ? Number(v) : undefined)}
|
||||
>
|
||||
<Input type="number" placeholder="输入教师用户ID" />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="教室">
|
||||
<Input value={selectedClassroom?.name || ''} disabled />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label="星期">
|
||||
<Input
|
||||
value={selectedCell ? WEEKDAYS[selectedCell.weekDay - 1] : ''}
|
||||
disabled
|
||||
placeholder="如:数学、语文"
|
||||
onBlur={(event) => {
|
||||
const classId = form.getFieldValue('classId');
|
||||
if (classId) void applyClassTeacherDefaults(classId, event.target.value);
|
||||
}}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item name="teacherId" label="任课老师">
|
||||
<Select
|
||||
showSearch
|
||||
allowClear
|
||||
placeholder="先选班级;系统会按科目自动带出任课老师"
|
||||
optionFilterProp="label"
|
||||
options={classTeachers.map((teacher) => ({
|
||||
value: teacher.userId,
|
||||
label: `${teacher.name || teacher.username || `#${teacher.userId}`}${teacher.subject ? ` · ${teacher.subject}` : ''}`,
|
||||
}))}
|
||||
notFoundContent="该班级暂无可选教师,请先在班级详情配置教师"
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
@@ -764,6 +939,33 @@ const SchedulesPage: React.FC = () => {
|
||||
</Form>
|
||||
) : (
|
||||
<div style={{ lineHeight: 2 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 12,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontWeight: 500 }}>已有排课</span>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditingSchedule(null);
|
||||
setModalMode('create');
|
||||
form.resetFields();
|
||||
form.setFieldsValue({
|
||||
classroomId: selectedCell?.classroomId,
|
||||
weekDay:
|
||||
selectedCell?.weekDay ?? (selectedDate ? selectedDate.day() || 7 : undefined),
|
||||
dateRange: selectedDate ? [selectedDate, selectedDate] : undefined,
|
||||
});
|
||||
}}
|
||||
>
|
||||
新增排课
|
||||
</Button>
|
||||
</div>
|
||||
{selectedSchedules.length === 0 ? (
|
||||
<Empty description="该时段暂无排课" />
|
||||
) : (
|
||||
@@ -774,7 +976,13 @@ const SchedulesPage: React.FC = () => {
|
||||
style={{ marginBottom: 8 }}
|
||||
styles={{ body: { padding: 12 } }}
|
||||
>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<div>
|
||||
<strong>科目:</strong>
|
||||
@@ -786,8 +994,10 @@ const SchedulesPage: React.FC = () => {
|
||||
</div>
|
||||
{s.teacherId != null && (
|
||||
<div>
|
||||
<strong>教师ID:</strong>
|
||||
{s.teacherId}
|
||||
<strong>教师:</strong>
|
||||
{classTeachers.find((u) => u.userId === s.teacherId)?.name ||
|
||||
classTeachers.find((u) => u.userId === s.teacherId)?.username ||
|
||||
`#${s.teacherId}`}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
@@ -811,21 +1021,33 @@ const SchedulesPage: React.FC = () => {
|
||||
<Tag color={s.status === 'active' ? 'green' : 'default'}>{s.status}</Tag>
|
||||
</div>
|
||||
</div>
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
<Space>
|
||||
{s.scheduleType !== 'RENTAL' && (
|
||||
<PermissionButton
|
||||
permission="schedule:edit"
|
||||
size="small"
|
||||
icon={<EditOutlined />}
|
||||
onClick={() => openEditSchedule(s)}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
)}
|
||||
<Popconfirm
|
||||
title="确认删除该排课?"
|
||||
onConfirm={() => handleDelete(s.id)}
|
||||
okText="删除"
|
||||
cancelText="取消"
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
<PermissionButton
|
||||
permission="schedule:delete"
|
||||
size="small"
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
>
|
||||
删除
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
@@ -833,6 +1055,182 @@ const SchedulesPage: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
{/* ── 钉钉排班同步 Modal ── */}
|
||||
<Modal
|
||||
title="同步排课到钉钉考勤排班"
|
||||
open={syncModalOpen}
|
||||
onCancel={() => {
|
||||
setSyncModalOpen(false);
|
||||
setSyncResult(null);
|
||||
}}
|
||||
footer={
|
||||
syncResult
|
||||
? [
|
||||
<Button
|
||||
key="close"
|
||||
onClick={() => {
|
||||
setSyncModalOpen(false);
|
||||
setSyncResult(null);
|
||||
}}
|
||||
>
|
||||
关闭
|
||||
</Button>,
|
||||
]
|
||||
: [
|
||||
<Button
|
||||
key="cancel"
|
||||
onClick={() => {
|
||||
setSyncModalOpen(false);
|
||||
setSyncResult(null);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>,
|
||||
<Button
|
||||
key="sync"
|
||||
type="primary"
|
||||
icon={<CloudSyncOutlined />}
|
||||
loading={syncing}
|
||||
onClick={handleSyncSchedule}
|
||||
disabled={!syncStatus || syncStatus.activeSchedules === 0}
|
||||
>
|
||||
开始同步
|
||||
</Button>,
|
||||
]
|
||||
}
|
||||
width={560}
|
||||
>
|
||||
{syncResult ? (
|
||||
/* ── 同步结果 ── */
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={6}>
|
||||
<Statistic title="排课" value={syncResult.scheduleCount} suffix="条" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="班次" value={syncResult.shiftCount} suffix="个" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic title="考勤组" value={syncResult.groupCount} suffix="个" />
|
||||
</Col>
|
||||
<Col span={6}>
|
||||
<Statistic
|
||||
title="排班数"
|
||||
value={syncResult.syncedItems}
|
||||
suffix="条"
|
||||
valueStyle={{ color: '#3f8600' }}
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{syncResult.skippedNoMapping > 0 && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={`${syncResult.skippedNoMapping} 条排课因班级无钉钉绑定学生而跳过`}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
{syncResult.groups.length > 0 && (
|
||||
<div>
|
||||
<div style={{ fontWeight: 500, marginBottom: 8 }}>按班级分组:</div>
|
||||
{syncResult.groups.map((g) => (
|
||||
<Tag key={g.groupId} color="blue" style={{ marginBottom: 4 }}>
|
||||
{g.className}:{g.itemCount} 条排班
|
||||
</Tag>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : syncStatus ? (
|
||||
/* ── 同步确认信息 ── */
|
||||
<div>
|
||||
<Row gutter={16} style={{ marginBottom: 16 }}>
|
||||
<Col span={8}>
|
||||
<Statistic title="活跃排课" value={syncStatus.activeSchedules} suffix="条" />
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="已就绪班级"
|
||||
value={syncStatus.mappedClasses}
|
||||
suffix={`/ ${syncStatus.totalClasses}`}
|
||||
valueStyle={{
|
||||
color:
|
||||
syncStatus.mappedClasses < syncStatus.totalClasses ? '#faad14' : '#3f8600',
|
||||
}}
|
||||
/>
|
||||
</Col>
|
||||
<Col span={8}>
|
||||
<Statistic
|
||||
title="无绑定学生班级"
|
||||
value={syncStatus.totalClasses - syncStatus.mappedClasses}
|
||||
suffix="个"
|
||||
/>
|
||||
</Col>
|
||||
</Row>
|
||||
{syncStatus.mappedClasses < syncStatus.totalClasses && (
|
||||
<Alert
|
||||
type="warning"
|
||||
message={`${syncStatus.totalClasses - syncStatus.mappedClasses} 个班级没有已绑定钉钉的学生,其排课将被跳过。请先在钉钉集成页导入并绑定学生。`}
|
||||
style={{ marginBottom: 16 }}
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
<div style={{ marginBottom: 16 }}>
|
||||
<div style={{ marginBottom: 8, fontWeight: 500 }}>同步参数</div>
|
||||
<Space wrap>
|
||||
<span>起始日期:</span>
|
||||
<DatePicker
|
||||
value={syncDateFrom}
|
||||
onChange={(d) => d && setSyncDateFrom(d)}
|
||||
allowClear={false}
|
||||
/>
|
||||
<span>天数:</span>
|
||||
<Select
|
||||
value={syncDays}
|
||||
onChange={setSyncDays}
|
||||
style={{ width: 100 }}
|
||||
options={[
|
||||
{ value: 7, label: '7 天' },
|
||||
{ value: 14, label: '14 天' },
|
||||
{ value: 30, label: '30 天' },
|
||||
{ value: 60, label: '60 天' },
|
||||
{ value: 90, label: '90 天' },
|
||||
]}
|
||||
/>
|
||||
</Space>
|
||||
<div style={{ marginTop: 16 }}>
|
||||
<Space align="start">
|
||||
<Switch checked={attendanceMachineOnly} onChange={setAttendanceMachineOnly} />
|
||||
<div>
|
||||
<div style={{ fontWeight: 500 }}>仅允许考勤机打卡</div>
|
||||
<div style={{ color: '#8c8c8c', fontSize: 12, marginTop: 2 }}>
|
||||
开启后将关闭外勤、定位、Wi-Fi 和手机蓝牙打卡,并禁止无排班打卡。
|
||||
</div>
|
||||
</div>
|
||||
</Space>
|
||||
</div>
|
||||
{attendanceMachineOnly && (
|
||||
<Alert
|
||||
type="info"
|
||||
showIcon
|
||||
message="已存在的同名考勤组也会在本次同步中更新为仅考勤机打卡。"
|
||||
style={{ marginTop: 12 }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{syncStatus.activeSchedules === 0 && (
|
||||
<Alert
|
||||
type="info"
|
||||
message="当前没有活跃排课。请先在排课页面创建排课记录。"
|
||||
showIcon
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Spin tip="查询同步状态..." />
|
||||
)}
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import dayjs from 'dayjs';
|
||||
import { buildSchedulePayload, scheduleToFormValues } from './schedule-form';
|
||||
|
||||
describe('schedule edit form mapping', () => {
|
||||
it('fills an existing schedule into editable form values', () => {
|
||||
const values = scheduleToFormValues({
|
||||
id: 3,
|
||||
classId: 1,
|
||||
classroomId: 1,
|
||||
weekDay: 5,
|
||||
subject: '语文',
|
||||
teacherId: null,
|
||||
startTime: '14:00',
|
||||
endTime: '18:00',
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
});
|
||||
|
||||
expect(values.classroomId).toBe(1);
|
||||
expect(values.weekDay).toBe(5);
|
||||
expect(values.timeRange.map((item) => item.format('HH:mm'))).toEqual(['14:00', '18:00']);
|
||||
expect(values.dateRange.map((item) => item.format('YYYY-MM-DD'))).toEqual([
|
||||
'2026-07-01',
|
||||
'2026-07-31',
|
||||
]);
|
||||
});
|
||||
|
||||
it('builds the update payload from edited form values', () => {
|
||||
expect(
|
||||
buildSchedulePayload({
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 6,
|
||||
subject: '作文',
|
||||
teacherId: 4,
|
||||
timeRange: [dayjs('2026-01-01 13:30'), dayjs('2026-01-01 17:20')],
|
||||
dateRange: [dayjs('2026-08-01'), dayjs('2026-08-31')],
|
||||
}),
|
||||
).toEqual({
|
||||
classId: 1,
|
||||
classroomId: 2,
|
||||
weekDay: 6,
|
||||
subject: '作文',
|
||||
teacherId: 4,
|
||||
startTime: '13:30',
|
||||
endTime: '17:20',
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
});
|
||||
});
|
||||
});
|
||||
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal file
46
apps/admin/src/pages/Schedules/schedule-form.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import dayjs, { type Dayjs } from 'dayjs';
|
||||
|
||||
export interface ScheduleFormValues {
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId?: number;
|
||||
timeRange: [Dayjs, Dayjs];
|
||||
dateRange: [Dayjs, Dayjs];
|
||||
}
|
||||
|
||||
export interface EditableSchedule {
|
||||
id: number;
|
||||
classId: number;
|
||||
classroomId: number;
|
||||
weekDay: number;
|
||||
subject: string;
|
||||
teacherId: number | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
}
|
||||
|
||||
export const scheduleToFormValues = (schedule: EditableSchedule): ScheduleFormValues => ({
|
||||
classId: schedule.classId,
|
||||
classroomId: schedule.classroomId,
|
||||
weekDay: schedule.weekDay,
|
||||
subject: schedule.subject,
|
||||
teacherId: schedule.teacherId ?? undefined,
|
||||
timeRange: [dayjs(`2000-01-01 ${schedule.startTime}`), dayjs(`2000-01-01 ${schedule.endTime}`)],
|
||||
dateRange: [dayjs(schedule.startDate), dayjs(schedule.endDate)],
|
||||
});
|
||||
|
||||
export const buildSchedulePayload = (values: ScheduleFormValues) => ({
|
||||
classId: values.classId,
|
||||
classroomId: values.classroomId,
|
||||
weekDay: values.weekDay,
|
||||
subject: values.subject,
|
||||
teacherId: values.teacherId,
|
||||
startTime: values.timeRange[0].format('HH:mm'),
|
||||
endTime: values.timeRange[1].format('HH:mm'),
|
||||
startDate: values.dateRange[0].format('YYYY-MM-DD'),
|
||||
endDate: values.dateRange[1].format('YYYY-MM-DD'),
|
||||
});
|
||||
@@ -1,37 +1,40 @@
|
||||
import React, { useEffect, useState, useMemo, useCallback } from 'react';
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import {
|
||||
Table,
|
||||
App,
|
||||
Button,
|
||||
Modal,
|
||||
Card,
|
||||
Col,
|
||||
Descriptions,
|
||||
Drawer,
|
||||
Empty,
|
||||
Form,
|
||||
Input,
|
||||
Modal,
|
||||
Popconfirm,
|
||||
Row,
|
||||
Select,
|
||||
Space,
|
||||
message,
|
||||
Table,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
Upload,
|
||||
App,
|
||||
Row,
|
||||
Col,
|
||||
Card,
|
||||
Drawer,
|
||||
Descriptions,
|
||||
} from 'antd';
|
||||
import type { UploadProps } from 'antd';
|
||||
import {
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
DownloadOutlined,
|
||||
UndoOutlined,
|
||||
InboxOutlined,
|
||||
ExportOutlined,
|
||||
DeleteOutlined,
|
||||
DownloadOutlined,
|
||||
ExportOutlined,
|
||||
EyeOutlined,
|
||||
InboxOutlined,
|
||||
PlusOutlined,
|
||||
SwapOutlined,
|
||||
UndoOutlined,
|
||||
UploadOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import StudentProfileContent from '../../components/StudentProfileContent';
|
||||
import { maskPhone, maskIdNumber } from '../../utils/sensitive';
|
||||
import { maskIdNumber, maskPhone } from '../../utils/sensitive';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const statusMap: Record<string, { text: string; color: string }> = {
|
||||
active: { text: '在读', color: 'green' },
|
||||
@@ -64,14 +67,15 @@ const StudentsPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [tenants, setTenants] = useState<any[]>([]);
|
||||
const [organizations, setOrganizations] = useState<any[]>([]);
|
||||
const [editing, setEditing] = useState<any>(null);
|
||||
const [searchName, setSearchName] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
const [filterTenantId, setFilterTenantId] = useState<number | undefined>(undefined);
|
||||
const [filterOrganizationId, setFilterOrganizationId] = useState<number | undefined>(undefined);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
const [archivedCount, setArchivedCount] = useState(0);
|
||||
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
||||
const [batchLoading, setBatchLoading] = useState(false);
|
||||
const [enrollmentData, setEnrollmentData] = useState<Record<number, EnrollmentInfo[]>>({});
|
||||
const [drawerOpen, setDrawerOpen] = useState(false);
|
||||
const [drawerStudentId, setDrawerStudentId] = useState<number | undefined>(undefined);
|
||||
@@ -111,6 +115,7 @@ const StudentsPage: React.FC = () => {
|
||||
};
|
||||
|
||||
const handleBatchDelete = async () => {
|
||||
setBatchLoading(true);
|
||||
try {
|
||||
const res: any = await api.post('/students/batch-delete', { ids: selectedRowKeys });
|
||||
message.success(res?.message || `已批量归档 ${selectedRowKeys.length} 人`);
|
||||
@@ -118,34 +123,43 @@ const StudentsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '批量归档失败');
|
||||
} finally {
|
||||
setBatchLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const params: Record<string, unknown> = { name: searchName || undefined, includeArchived: 'true' };
|
||||
const params: Record<string, unknown> = {
|
||||
name: searchName || undefined,
|
||||
includeArchived: 'true',
|
||||
};
|
||||
if (filterStatus) params.status = filterStatus;
|
||||
if (filterTenantId) params.tenantId = filterTenantId;
|
||||
const res = await api.get('/students', { params }) as Array<Record<string, unknown>>;
|
||||
if (filterOrganizationId) params.organizationId = filterOrganizationId;
|
||||
const res = (await api.get('/students', { params })) as Array<Record<string, unknown>>;
|
||||
const list = res as Array<Record<string, unknown>>;
|
||||
const archived = list.filter((r) => r.status === 'archived');
|
||||
setArchivedCount(archived.length);
|
||||
setData(showArchived ? list : list.filter((r) => r.status !== 'archived'));
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, [searchName, showArchived, filterStatus, filterTenantId]);
|
||||
}, [searchName, showArchived, filterStatus, filterOrganizationId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
}, [fetchData]);
|
||||
|
||||
useEffect(() => {
|
||||
api.get('/tenants', { params: { includeArchived: 'false' } }).then((res: unknown) => {
|
||||
setTenants(res as Array<{ id: number; name: string }>);
|
||||
}).catch(() => {});
|
||||
api
|
||||
.get('/organizations', { params: { includeArchived: 'false' } })
|
||||
.then((res: unknown) => {
|
||||
setOrganizations(res as Array<{ id: number; name: string }>);
|
||||
})
|
||||
.catch(() => {});
|
||||
}, []);
|
||||
const handleSave = async () => {
|
||||
const values = await form.validateFields();
|
||||
@@ -207,6 +221,23 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('下载失败'));
|
||||
};
|
||||
|
||||
const handleMatchImport: UploadProps['customRequest'] = async ({ file, onSuccess, onError }) => {
|
||||
const formData = new FormData();
|
||||
formData.append('file', file as File);
|
||||
try {
|
||||
const res = (await api.post('/students/import-match', formData, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
})) as { message: string };
|
||||
message.success(res.message);
|
||||
onSuccess?.(res);
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '匹配导入失败');
|
||||
onError?.(e instanceof Error ? e : new Error(err?.message || '匹配导入失败'));
|
||||
}
|
||||
};
|
||||
|
||||
const handleExport = () => {
|
||||
const baseURL = import.meta.env.PROD
|
||||
? '/api'
|
||||
@@ -226,128 +257,178 @@ const StudentsPage: React.FC = () => {
|
||||
.catch(() => message.error('导出失败'));
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>{v}</Button>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '电话', v)} title="点击查看完整号码">
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
const columns = useMemo(
|
||||
() => [
|
||||
{ title: 'ID', dataIndex: 'id', width: 70 },
|
||||
{
|
||||
title: '姓名',
|
||||
dataIndex: 'name',
|
||||
width: 120,
|
||||
render: (v: string, record: any) => (
|
||||
<Button type="link" size="small" onClick={() => openDrawer(record.id)}>
|
||||
{v}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||
<Button type="link" size="small" style={{ padding: '8px 4px' }} onClick={() => handleViewSensitive(record.id, '身份证号', v)} title="点击查看完整号码">
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'tenant',
|
||||
width: 100,
|
||||
render: (tenant: { name?: string } | null) =>
|
||||
tenant?.name ? <Tag color="purple" style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{tenant.name}</Tag> : '-',
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => <Tag color={statusMap[s]?.color} style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}>{statusMap[s]?.text || s}</Tag>,
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||
onConfirm={() => handleRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="student:edit" size="small" icon={<UndoOutlined />} type="link">
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
size="small"
|
||||
{
|
||||
title: '电话',
|
||||
dataIndex: 'phone',
|
||||
width: 140,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskPhone(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
onClick={() => openDrawer(record.id)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '电话', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '学号',
|
||||
dataIndex: 'studentNo',
|
||||
width: 120,
|
||||
render: (v: string) => v || '-',
|
||||
},
|
||||
{
|
||||
title: '身份证',
|
||||
dataIndex: 'idNumber',
|
||||
width: 180,
|
||||
render: (v: string, record: any) => {
|
||||
if (!v) return '-';
|
||||
return (
|
||||
<span>
|
||||
<span style={{ marginRight: 4 }}>{maskIdNumber(v)}</span>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
style={{ padding: '8px 4px' }}
|
||||
onClick={() => handleViewSensitive(record.id, '身份证号', v)}
|
||||
title="点击查看完整号码"
|
||||
>
|
||||
<EyeOutlined style={{ fontSize: 12, color: '#999' }} />
|
||||
</Button>
|
||||
</span>
|
||||
);
|
||||
},
|
||||
},
|
||||
{ title: '民族', dataIndex: 'ethnicity', width: 90 },
|
||||
{ title: '紧急联系人', dataIndex: 'emergencyContact', width: 100 },
|
||||
{ title: '紧急联系人电话', dataIndex: 'emergencyPhone', width: 130 },
|
||||
{
|
||||
title: '所属机构',
|
||||
dataIndex: 'organization',
|
||||
width: 100,
|
||||
render: (organization: { name?: string } | null) =>
|
||||
organization?.name ? (
|
||||
<Tag
|
||||
color="purple"
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{organization.name}
|
||||
</Tag>
|
||||
) : (
|
||||
'-'
|
||||
),
|
||||
},
|
||||
{ title: '负责人', dataIndex: 'supervisor', width: 100 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
width: 80,
|
||||
render: (s: string) => (
|
||||
<Tag
|
||||
color={statusMap[s]?.color}
|
||||
style={{ maxWidth: '100%', overflow: 'hidden', textOverflow: 'ellipsis' }}
|
||||
>
|
||||
{statusMap[s]?.text || s}
|
||||
</Tag>
|
||||
),
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 180,
|
||||
render: (_: any, record: any) => (
|
||||
<Space>
|
||||
{record.status === 'archived' ? (
|
||||
<Popconfirm
|
||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
title="确定恢复此学生?恢复后将重新出现在学生列表中。"
|
||||
onConfirm={() => handleRestore(record.id)}
|
||||
okText="恢复"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="student:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
icon={<UndoOutlined />}
|
||||
type="link"
|
||||
>
|
||||
恢复
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleViewSensitive, openDrawer, showArchived, tenants]);
|
||||
) : (
|
||||
<>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
size="small"
|
||||
type="link"
|
||||
onClick={() => openDrawer(record.id)}
|
||||
>
|
||||
档案
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="student:edit"
|
||||
size="small"
|
||||
onClick={() => {
|
||||
setEditing(record);
|
||||
form.setFieldsValue(record);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
编辑
|
||||
</PermissionButton>
|
||||
<Popconfirm
|
||||
title="归档后不会删除数据,可随时恢复。确定归档?"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton
|
||||
permission="student:delete"
|
||||
size="small"
|
||||
icon={<InboxOutlined />}
|
||||
>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</>
|
||||
)}
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
],
|
||||
[handleViewSensitive, openDrawer, showArchived, organizations],
|
||||
);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'space-between', flexWrap: 'wrap', gap: 8 }}>
|
||||
<div
|
||||
style={{
|
||||
marginBottom: 16,
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
placeholder="搜索学生姓名"
|
||||
@@ -355,11 +436,37 @@ const StudentsPage: React.FC = () => {
|
||||
allowClear
|
||||
style={{ width: 250 }}
|
||||
/>
|
||||
<Select placeholder="状态筛选" allowClear style={{ width: 120 }} value={filterStatus} onChange={(v) => { setFilterStatus(v); }}>
|
||||
{Object.entries(statusMap).filter(([k]) => k !== 'archived').map(([k, v]) => <Select.Option key={k} value={k}>{v.text}</Select.Option>)}
|
||||
<Select
|
||||
placeholder="状态筛选"
|
||||
allowClear
|
||||
style={{ width: 120 }}
|
||||
value={filterStatus}
|
||||
onChange={(v) => {
|
||||
setFilterStatus(v);
|
||||
}}
|
||||
>
|
||||
{Object.entries(statusMap)
|
||||
.filter(([k]) => k !== 'archived')
|
||||
.map(([k, v]) => (
|
||||
<Select.Option key={k} value={k}>
|
||||
{v.text}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Select placeholder="所属机构" allowClear style={{ width: 140 }} value={filterTenantId} onChange={(v) => { setFilterTenantId(v); }}>
|
||||
{tenants.map((t: { id: number; name: string }) => <Select.Option key={t.id} value={t.id}>{t.name}</Select.Option>)}
|
||||
<Select
|
||||
placeholder="所属机构"
|
||||
allowClear
|
||||
style={{ width: 140 }}
|
||||
value={filterOrganizationId}
|
||||
onChange={(v) => {
|
||||
setFilterOrganizationId(v);
|
||||
}}
|
||||
>
|
||||
{organizations.map((t: { id: number; name: string }) => (
|
||||
<Select.Option key={t.id} value={t.id}>
|
||||
{t.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
<Button
|
||||
type={showArchived ? 'primary' : 'default'}
|
||||
@@ -383,6 +490,7 @@ const StudentsPage: React.FC = () => {
|
||||
danger
|
||||
icon={<DeleteOutlined />}
|
||||
disabled={selectedRowKeys.length === 0}
|
||||
loading={batchLoading}
|
||||
>
|
||||
批量归档
|
||||
</PermissionButton>
|
||||
@@ -394,6 +502,8 @@ const StudentsPage: React.FC = () => {
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
const host = organizations.find((organization) => organization.isHost);
|
||||
if (host) form.setFieldValue('organizationId', host.id);
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -414,12 +524,15 @@ const StudentsPage: React.FC = () => {
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '导入失败');
|
||||
onError?.(e);
|
||||
onError?.(e instanceof Error ? e : new Error(e?.message || '导入失败'));
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Button icon={<UploadOutlined />}>导入Excel</Button>
|
||||
</Upload>
|
||||
<Upload accept=".xlsx,.xls" showUploadList={false} customRequest={handleMatchImport}>
|
||||
<Button icon={<SwapOutlined />}>匹配导入</Button>
|
||||
</Upload>
|
||||
<PermissionButton
|
||||
permission="student:view"
|
||||
icon={<DownloadOutlined />}
|
||||
@@ -441,6 +554,7 @@ const StudentsPage: React.FC = () => {
|
||||
dataSource={data}
|
||||
rowKey="id"
|
||||
loading={loading}
|
||||
locale={{ emptyText: <Empty description="暂无数据" /> }}
|
||||
scroll={{ x: 1410 }}
|
||||
pagination={{ pageSize: 15, showTotal: (total) => `共 ${total} 人` }}
|
||||
rowClassName={(record: any) => (record.status === 'archived' ? 'archived-row' : '')}
|
||||
@@ -473,8 +587,12 @@ const StudentsPage: React.FC = () => {
|
||||
>
|
||||
<Descriptions column={1} size="small">
|
||||
<Descriptions.Item label="班级">{enr.className || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">{enr.startDate || enr.joinDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">{enr.endDate || enr.leaveDate || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="开班日期">
|
||||
{enr.startDate || enr.joinDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="结课日期">
|
||||
{enr.endDate || enr.leaveDate || '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="状态">
|
||||
<Tag color={enr.status === 'active' ? 'green' : 'default'}>
|
||||
{enr.status || '-'}
|
||||
@@ -501,7 +619,7 @@ const StudentsPage: React.FC = () => {
|
||||
}
|
||||
},
|
||||
}}
|
||||
/>
|
||||
/>
|
||||
<style>{`.archived-row { opacity: 0.6; background: #fafafa !important; }`}</style>
|
||||
<Modal
|
||||
title={editing ? '编辑学生' : '添加学生'}
|
||||
@@ -546,17 +664,20 @@ const StudentsPage: React.FC = () => {
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
name="tenantId"
|
||||
name="organizationId"
|
||||
label="所属机构"
|
||||
tooltip="选择租赁方,留空表示本机构"
|
||||
rules={[{ required: true, message: '请选择所属机构' }]}
|
||||
>
|
||||
<Select
|
||||
allowClear
|
||||
placeholder="选择租赁方"
|
||||
options={tenants.map((t: { id: number; name: string }) => ({
|
||||
value: t.id,
|
||||
label: t.name,
|
||||
}))}
|
||||
showSearch
|
||||
optionFilterProp="label"
|
||||
placeholder="选择所属机构"
|
||||
options={organizations.map(
|
||||
(organization: { id: number; name: string; isHost?: boolean }) => ({
|
||||
value: organization.id,
|
||||
label: organization.isHost ? `${organization.name}(本机构)` : organization.name,
|
||||
}),
|
||||
)}
|
||||
/>
|
||||
</Form.Item>
|
||||
<Form.Item name="supervisor" label="负责人/班主任">
|
||||
@@ -579,14 +700,18 @@ const StudentsPage: React.FC = () => {
|
||||
<Drawer
|
||||
title={null}
|
||||
open={drawerOpen}
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
size={720}
|
||||
>
|
||||
{drawerStudentId && (
|
||||
<StudentProfileContent
|
||||
studentId={drawerStudentId}
|
||||
inDrawer
|
||||
onClose={() => { setDrawerOpen(false); }}
|
||||
onClose={() => {
|
||||
setDrawerOpen(false);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Drawer>
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Card, Tabs, Table, Tag, Empty, Spin, message } from 'antd';
|
||||
import { Card, Tabs, Table, Tag, Empty, Spin } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface AssignedClass {
|
||||
classId: number;
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import React, { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space, message } from 'antd';
|
||||
import { Table, Input, Button, Modal, Form, Select, DatePicker, Tag, Space } from 'antd';
|
||||
import { EditOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
interface TeacherRow {
|
||||
id: number;
|
||||
|
||||
@@ -1,271 +0,0 @@
|
||||
import React, { useEffect, useState, useMemo } from 'react';
|
||||
import { Table, Modal, Form, Input, Select, 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 [saving, setSaving] = useState(false);
|
||||
const [searchText, setSearchText] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string | undefined>(undefined);
|
||||
|
||||
const filteredData = useMemo(() => {
|
||||
let result = data;
|
||||
if (searchText) {
|
||||
const s = searchText.toLowerCase();
|
||||
result = result.filter((d: Record<string, unknown>) =>
|
||||
(typeof d.name === 'string' && d.name.toLowerCase().includes(s)) ||
|
||||
(typeof d.contact === 'string' && d.contact.toLowerCase().includes(s)));
|
||||
}
|
||||
if (filterStatus) result = result.filter((d: Record<string, unknown>) => d.status === filterStatus);
|
||||
return result;
|
||||
}, [data, searchText, filterStatus]);
|
||||
|
||||
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();
|
||||
setSaving(true);
|
||||
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 || '操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleArchive = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/tenants/${id}`);
|
||||
message.success('已归档');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '归档失败');
|
||||
}
|
||||
};
|
||||
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
title: '租赁方名称', width: 120,
|
||||
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', width: 100, render: (v: string) => v || '-' },
|
||||
{ title: '电话', dataIndex: 'phone', width: 120, render: (v: string) => v || '-' },
|
||||
{
|
||||
title: '颜色', width: 80,
|
||||
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>
|
||||
<Popconfirm
|
||||
title="归档后仍可查看历史租赁"
|
||||
onConfirm={() => handleArchive(record.id)}
|
||||
okText="归档"
|
||||
cancelText="取消"
|
||||
>
|
||||
<PermissionButton permission="tenant:delete" size="small" icon={<InboxOutlined />}>
|
||||
归档
|
||||
</PermissionButton>
|
||||
</Popconfirm>
|
||||
</Space>
|
||||
),
|
||||
},
|
||||
], [handleArchive]);
|
||||
|
||||
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('');
|
||||
}}
|
||||
/>
|
||||
<Select placeholder="状态" allowClear style={{ width: 110 }} value={filterStatus} onChange={setFilterStatus}
|
||||
options={[{value:'active',label:'活跃'},{value:'archived',label:'已归档'}]} />
|
||||
<PermissionButton
|
||||
permission="tenant:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={() => {
|
||||
setEditing(null);
|
||||
form.resetFields();
|
||||
setModalOpen(true);
|
||||
}}
|
||||
>
|
||||
添加租赁方
|
||||
</PermissionButton>
|
||||
</div>
|
||||
<Table
|
||||
scroll={{ x: 1000 }}
|
||||
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="保存"
|
||||
confirmLoading={saving}
|
||||
>
|
||||
<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="可视化排期时用的颜色,留空则自动分配">
|
||||
<Space.Compact>
|
||||
<Input placeholder="#40a9ff" style={{ flex: 1 }} />
|
||||
<span
|
||||
style={{
|
||||
padding: '0 4px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
border: '1px solid #d9d9d9',
|
||||
backgroundColor: '#fafafa',
|
||||
gap: 4,
|
||||
}}
|
||||
>
|
||||
{PRESET_COLORS.map((c) => (
|
||||
<span
|
||||
key={c}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`选择颜色 ${c}`}
|
||||
onClick={() => form.setFieldValue('color', c)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
form.setFieldValue('color', c);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'inline-block',
|
||||
width: 28,
|
||||
height: 28,
|
||||
background: c,
|
||||
borderRadius: 3,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid #d9d9d9',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</span>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item name="notes" label="备注">
|
||||
<Input.TextArea rows={2} />
|
||||
</Form.Item>
|
||||
</Form>
|
||||
</Modal>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default TenantsPage;
|
||||
@@ -9,12 +9,12 @@ import {
|
||||
Space,
|
||||
Tag,
|
||||
Popconfirm,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, CloudDownloadOutlined } from '@ant-design/icons';
|
||||
} from 'antd';
|
||||
import { PlusOutlined, EditOutlined, DeleteOutlined, KeyOutlined, IdcardOutlined, InboxOutlined } from '@ant-design/icons';
|
||||
import dayjs from 'dayjs';
|
||||
import api from '../../api';
|
||||
import PermissionButton from '../../components/PermissionButton';
|
||||
import { message } from '../../ui/app-message';
|
||||
|
||||
const UsersPage: React.FC = () => {
|
||||
const [data, setData] = useState<any[]>([]);
|
||||
@@ -29,8 +29,9 @@ const UsersPage: React.FC = () => {
|
||||
const [profileForm] = Form.useForm();
|
||||
const [form] = Form.useForm();
|
||||
const [pwdForm] = Form.useForm();
|
||||
const [syncing, setSyncing] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [showArchived, setShowArchived] = useState(false);
|
||||
|
||||
|
||||
const handleOpenProfile = async (record: any) => {
|
||||
setProfileUser(record);
|
||||
@@ -62,30 +63,18 @@ const UsersPage: React.FC = () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const [users, rolesRes] = await Promise.all([
|
||||
api.get('/rbac/users') as Promise<any[]>,
|
||||
api.get(`/rbac/users?isArchived=${showArchived}`) as Promise<any[]>,
|
||||
api.get('/rbac/roles') as Promise<any[]>,
|
||||
]);
|
||||
setData(users);
|
||||
setRoles(rolesRes);
|
||||
} catch (e) {
|
||||
console.error(e);
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '加载失败,请稍后重试');
|
||||
}
|
||||
setLoading(false);
|
||||
}, []);
|
||||
}, [showArchived]);
|
||||
|
||||
const handleSyncDingTalk = async () => {
|
||||
setSyncing(true);
|
||||
try {
|
||||
const res: any = await api.post('/sync/trigger?platform=dingtalk');
|
||||
const log = res.logs?.[0];
|
||||
message.success(`同步完成:${log?.recordsCount || 0} 条记录`);
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e?.message || '同步失败');
|
||||
} finally {
|
||||
setSyncing(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchData();
|
||||
@@ -137,17 +126,29 @@ const UsersPage: React.FC = () => {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
const handleArchive = async (id: number, archive: boolean) => {
|
||||
try {
|
||||
await api.put(`/rbac/users/${id}/${archive ? 'archive' : 'restore'}`);
|
||||
message.success(archive ? '已归档' : '已恢复');
|
||||
fetchData();
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '操作失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
try {
|
||||
await api.delete(`/rbac/users/${id}`);
|
||||
message.success('已删除');
|
||||
fetchData();
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '删除失败');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '删除失败');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
const handleResetPwd = (record: any) => {
|
||||
setResetTarget(record);
|
||||
pwdForm.resetFields();
|
||||
@@ -161,8 +162,9 @@ const UsersPage: React.FC = () => {
|
||||
await api.put(`/rbac/users/${resetTarget.id}/password`, { password: values.password });
|
||||
message.success('密码已重置');
|
||||
setPwdModalOpen(false);
|
||||
} catch (e: any) {
|
||||
message.error(e.message || '操作失败');
|
||||
} catch (e: unknown) {
|
||||
const err = e as { message?: string };
|
||||
message.error(err?.message || '操作失败');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
@@ -207,9 +209,9 @@ const UsersPage: React.FC = () => {
|
||||
},
|
||||
{
|
||||
title: '操作',
|
||||
width: 280,
|
||||
width: 240,
|
||||
fixed: 'right' as const,
|
||||
render: (_: any, record: any) => (
|
||||
render: (_: unknown, record: any) => (
|
||||
<Space>
|
||||
<PermissionButton
|
||||
permission="user:edit"
|
||||
@@ -238,8 +240,17 @@ const UsersPage: React.FC = () => {
|
||||
>
|
||||
重置密码
|
||||
</PermissionButton>
|
||||
{record.isArchived ? (
|
||||
<Popconfirm title="确认恢复?" onConfirm={() => handleArchive(record.id, false)}>
|
||||
<PermissionButton permission="user:edit" type="link" size="small">恢复</PermissionButton>
|
||||
</Popconfirm>
|
||||
) : (
|
||||
<Popconfirm title="归档后可恢复,确认归档?" onConfirm={() => handleArchive(record.id, true)}>
|
||||
<PermissionButton permission="user:edit" type="link" size="small">归档</PermissionButton>
|
||||
</Popconfirm>
|
||||
)}
|
||||
{record.username !== 'admin' && (
|
||||
<Popconfirm title="确认删除该用户?" onConfirm={() => handleDelete(record.id)}>
|
||||
<Popconfirm title="确认删除?需先归档" onConfirm={() => handleDelete(record.id)}>
|
||||
<PermissionButton permission="user:delete" type="link" size="small" danger icon={<DeleteOutlined />}>
|
||||
删除
|
||||
</PermissionButton>
|
||||
@@ -260,25 +271,28 @@ const UsersPage: React.FC = () => {
|
||||
alignItems: 'center',
|
||||
flexWrap: 'wrap',
|
||||
gap: 8,
|
||||
}}
|
||||
>
|
||||
}}>
|
||||
<h2 style={{ margin: 0 }}>账号管理</h2>
|
||||
<PermissionButton
|
||||
permission="user:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
新增账号
|
||||
</PermissionButton>
|
||||
<PermissionButton
|
||||
permission="sync:trigger"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
loading={syncing}
|
||||
onClick={handleSyncDingTalk}
|
||||
>
|
||||
同步钉钉用户
|
||||
</PermissionButton>
|
||||
<Space wrap>
|
||||
<PermissionButton
|
||||
permission="user:create"
|
||||
type="primary"
|
||||
icon={<PlusOutlined />}
|
||||
onClick={handleAdd}
|
||||
>
|
||||
新增账号
|
||||
</PermissionButton>
|
||||
<span style={{ marginLeft: 8 }}>
|
||||
<InboxOutlined style={{ marginRight: 4 }} />
|
||||
归档
|
||||
<Switch
|
||||
size="small"
|
||||
style={{ marginLeft: 4 }}
|
||||
checked={showArchived}
|
||||
onChange={setShowArchived}
|
||||
/>
|
||||
</span>
|
||||
</Space>
|
||||
</div>
|
||||
<Table
|
||||
columns={columns}
|
||||
|
||||
@@ -114,7 +114,7 @@ export const SAMPLE_OCCUPANCY = {
|
||||
checkInDate: '2026-03-01',
|
||||
billingStartDate: '2026-03-01',
|
||||
billingEndDate: '2026-06-30',
|
||||
rentalType: 'short',
|
||||
stayType: 'short',
|
||||
};
|
||||
|
||||
// ── Bill / Expense (PRD §9-10) ──────────────────────────────────────
|
||||
@@ -169,7 +169,7 @@ export const SAMPLE_CLASSROOM = {
|
||||
status: 'available',
|
||||
};
|
||||
|
||||
// ── Tenant (PRD §12) ────────────────────────────────────────────────
|
||||
// ── Organization (PRD §12) ────────────────────────────────────────────────
|
||||
|
||||
export const SAMPLE_TENANT = {
|
||||
name: '测试合作机构A',
|
||||
@@ -214,13 +214,12 @@ export const PERMISSION_NODES = [
|
||||
'attendance:view', 'attendance:add', 'attendance:update', 'attendance:delete',
|
||||
'attendance:batch',
|
||||
'classroom:view', 'classroom:add', 'classroom:update', 'classroom:delete',
|
||||
'tenant:view', 'tenant:add', 'tenant:update', 'tenant:delete',
|
||||
'organization:view', 'organization:create', 'organization:edit', 'organization:delete',
|
||||
'rental:view', 'rental:add', 'rental:update', 'rental:delete',
|
||||
'archive:view', 'archive:import', 'archive:export',
|
||||
'report:generate',
|
||||
'log:view',
|
||||
'role:view', 'role:add', 'role:update', 'role:delete',
|
||||
'user:view', 'user:add', 'user:update', 'user:delete',
|
||||
'department:view', 'department:add', 'department:update', 'department:delete',
|
||||
'dashboard:view',
|
||||
] as const;
|
||||
|
||||
@@ -47,7 +47,6 @@ export function logout(): void {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
localStorage.removeItem('currentCampusId');
|
||||
}
|
||||
|
||||
// ── API helpers (authenticated) ─────────────────────────────────────
|
||||
|
||||
@@ -18,7 +18,6 @@ afterEach(() => {
|
||||
localStorage.removeItem('token');
|
||||
localStorage.removeItem('user');
|
||||
localStorage.removeItem('permissions');
|
||||
localStorage.removeItem('currentCampusId');
|
||||
});
|
||||
|
||||
export { BASE };
|
||||
|
||||
15
apps/admin/src/ui/AppMessageBridge.tsx
Normal file
15
apps/admin/src/ui/AppMessageBridge.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
import { useEffect } from 'react';
|
||||
import useMessage from 'antd/es/message/useMessage';
|
||||
import { bindMessageApi } from './app-message';
|
||||
|
||||
const AppMessageBridge: React.FC = () => {
|
||||
const [messageApi, contextHolder] = useMessage();
|
||||
|
||||
useEffect(() => {
|
||||
bindMessageApi(messageApi);
|
||||
}, [messageApi]);
|
||||
|
||||
return contextHolder;
|
||||
};
|
||||
|
||||
export default AppMessageBridge;
|
||||
21
apps/admin/src/ui/app-message.integration.test.ts
Normal file
21
apps/admin/src/ui/app-message.integration.test.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
import { bindMessageApi, message } from './app-message';
|
||||
|
||||
describe('app message bridge', () => {
|
||||
it('delegates messages to the Ant Design App context instance', () => {
|
||||
const api = {
|
||||
success: vi.fn(),
|
||||
error: vi.fn(),
|
||||
warning: vi.fn(),
|
||||
};
|
||||
bindMessageApi(api as never);
|
||||
|
||||
message.success('保存成功');
|
||||
message.error('保存失败');
|
||||
message.warning('请检查');
|
||||
|
||||
expect(api.success).toHaveBeenCalledWith('保存成功');
|
||||
expect(api.error).toHaveBeenCalledWith('保存失败');
|
||||
expect(api.warning).toHaveBeenCalledWith('请检查');
|
||||
});
|
||||
});
|
||||
20
apps/admin/src/ui/app-message.ts
Normal file
20
apps/admin/src/ui/app-message.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import type { MessageInstance } from 'antd/es/message/interface';
|
||||
|
||||
let messageApi: MessageInstance | undefined;
|
||||
|
||||
export function bindMessageApi(api: MessageInstance): void {
|
||||
messageApi = api;
|
||||
}
|
||||
|
||||
function requireMessageApi(): MessageInstance {
|
||||
if (!messageApi) {
|
||||
throw new Error('Ant Design message API has not been initialized');
|
||||
}
|
||||
return messageApi;
|
||||
}
|
||||
|
||||
export const message: Pick<MessageInstance, 'success' | 'error' | 'warning'> = {
|
||||
success: (...args) => requireMessageApi().success(...args),
|
||||
error: (...args) => requireMessageApi().error(...args),
|
||||
warning: (...args) => requireMessageApi().warning(...args),
|
||||
};
|
||||
@@ -1,6 +1,7 @@
|
||||
import { defineConfig } from 'vitest/config';
|
||||
import react from '@vitejs/plugin-react';
|
||||
import path from 'node:path';
|
||||
import { playwright } from '@vitest/browser-playwright';
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
@@ -14,7 +15,8 @@ export default defineConfig({
|
||||
browser: {
|
||||
enabled: true,
|
||||
name: 'chromium',
|
||||
provider: 'playwright',
|
||||
provider: playwright(),
|
||||
instances: [{ browser: 'chromium' }],
|
||||
headless: true,
|
||||
// Slow down interactions slightly so UI animations settle
|
||||
slowHijackESM: false,
|
||||
|
||||
14
apps/server/migrate-student-ding-mapping.sql
Normal file
14
apps/server/migrate-student-ding-mapping.sql
Normal file
@@ -0,0 +1,14 @@
|
||||
-- Migration: Replace user_ding_mapping with student_ding_mapping
|
||||
-- Date: 2026-07-09
|
||||
|
||||
-- Drop old table
|
||||
DROP TABLE IF EXISTS user_ding_mapping;
|
||||
|
||||
-- Create new table
|
||||
CREATE TABLE IF NOT EXISTS student_ding_mapping (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
ding_user_id VARCHAR(100) NOT NULL UNIQUE,
|
||||
student_id INTEGER NOT NULL UNIQUE,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (student_id) REFERENCES students(id) ON DELETE CASCADE
|
||||
);
|
||||
@@ -1,22 +0,0 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { AppController } from './app.controller';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
describe('AppController', () => {
|
||||
let appController: AppController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const app: TestingModule = await Test.createTestingModule({
|
||||
controllers: [AppController],
|
||||
providers: [AppService],
|
||||
}).compile();
|
||||
|
||||
appController = app.get<AppController>(AppController);
|
||||
});
|
||||
|
||||
describe('root', () => {
|
||||
it('should return "Hello World!"', () => {
|
||||
expect(appController.getHello()).toBe('Hello World!');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,12 +0,0 @@
|
||||
import { Controller, Get } from '@nestjs/common';
|
||||
import { AppService } from './app.service';
|
||||
|
||||
@Controller()
|
||||
export class AppController {
|
||||
constructor(private readonly appService: AppService) {}
|
||||
|
||||
@Get()
|
||||
getHello(): string {
|
||||
return this.appService.getHello();
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Module, NestModule, MiddlewareConsumer } from '@nestjs/common';
|
||||
import { Module } from '@nestjs/common';
|
||||
import { APP_GUARD } from '@nestjs/core';
|
||||
import { ConfigModule, ConfigService } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
Student,
|
||||
Room,
|
||||
Occupancy,
|
||||
Bed,
|
||||
Locker,
|
||||
RoomExpense,
|
||||
PersonalExpense,
|
||||
Bill,
|
||||
@@ -17,7 +19,7 @@ import {
|
||||
Deposit,
|
||||
DepositInstallment,
|
||||
Classroom,
|
||||
Tenant,
|
||||
Organization,
|
||||
ClassroomRental,
|
||||
Permission,
|
||||
Role,
|
||||
@@ -30,8 +32,6 @@ import {
|
||||
SyncLog,
|
||||
SyncState,
|
||||
Notification,
|
||||
Department,
|
||||
UserDepartment,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
ExpenseType,
|
||||
ResultArchive,
|
||||
ArchiveAttachment,
|
||||
UserDingMapping,
|
||||
StudentDingMapping,
|
||||
} from './entities';
|
||||
import { AuthModule } from './auth/auth.module';
|
||||
import { RbacModule } from './rbac/rbac.module';
|
||||
@@ -55,22 +55,22 @@ import { OperationLogsModule } from './operation-logs/operation-logs.module';
|
||||
import { DepositsModule } from './deposits/deposits.module';
|
||||
import { ClassroomsModule } from './classrooms/classrooms.module';
|
||||
import { ClassesModule } from './classes/classes.module';
|
||||
import { TenantsModule } from './tenants/tenants.module';
|
||||
import { OrganizationsModule } from './organizations/organizations.module';
|
||||
import { AttendanceModule } from './attendance/attendance.module';
|
||||
import { SchedulesModule } from './schedules/schedules.module';
|
||||
import { ClassroomRentalsModule } from './classroom-rentals/classroom-rentals.module';
|
||||
import { SyncModule } from './sync/sync.module';
|
||||
import { NotificationsModule } from './notifications/notifications.module';
|
||||
import { DepartmentsModule } from './departments/departments.module';
|
||||
import { CommonModule } from './common/common.module';
|
||||
import { ArchiveModule } from './archive/archive.module';
|
||||
import { SeedModule } from './seed/seed.module';
|
||||
import { ExpenseTypesModule } from './expense-types/expense-types.module';
|
||||
import { DatabaseMigrationsModule } from './database/database-migrations.module';
|
||||
|
||||
import { IntegrationConfig, IntegrationConfigDetail } from './integration/entities/integration-config.entity';
|
||||
import {
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
} from './integration/entities/integration-config.entity';
|
||||
import { IntegrationConfigModule } from './integration/config/config.module';
|
||||
|
||||
import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
@@ -90,6 +90,8 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
Student,
|
||||
Room,
|
||||
Occupancy,
|
||||
Bed,
|
||||
Locker,
|
||||
RoomExpense,
|
||||
PersonalExpense,
|
||||
Bill,
|
||||
@@ -99,7 +101,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
Deposit,
|
||||
DepositInstallment,
|
||||
Classroom,
|
||||
Tenant,
|
||||
Organization,
|
||||
ClassroomRental,
|
||||
Class,
|
||||
ClassStudent,
|
||||
@@ -110,8 +112,6 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
AttendanceRecord,
|
||||
DingAttendanceRaw,
|
||||
Notification,
|
||||
Department,
|
||||
UserDepartment,
|
||||
StudentProfile,
|
||||
StudentEnrollment,
|
||||
ExamScore,
|
||||
@@ -121,10 +121,10 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
ResultArchive,
|
||||
SyncLog,
|
||||
SyncState,
|
||||
UserDingMapping,
|
||||
StudentDingMapping,
|
||||
IntegrationConfig,
|
||||
IntegrationConfigDetail,
|
||||
];
|
||||
];
|
||||
if (dbType === 'mysql') {
|
||||
return {
|
||||
type: 'mysql' as const,
|
||||
@@ -146,6 +146,7 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
};
|
||||
},
|
||||
}),
|
||||
DatabaseMigrationsModule,
|
||||
AuthModule,
|
||||
RbacModule,
|
||||
StudentsModule,
|
||||
@@ -159,15 +160,12 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
ClassroomsModule,
|
||||
AttendanceModule,
|
||||
ClassesModule,
|
||||
TenantsModule,
|
||||
OrganizationsModule,
|
||||
SchedulesModule,
|
||||
ClassroomRentalsModule,
|
||||
SyncModule,
|
||||
NotificationsModule,
|
||||
DepartmentsModule,
|
||||
CommonModule,
|
||||
ArchiveModule,
|
||||
SeedModule,
|
||||
IntegrationConfigModule,
|
||||
ExpenseTypesModule,
|
||||
],
|
||||
@@ -177,8 +175,4 @@ import { CampusScopeMiddleware } from './common/campus-scope.middleware';
|
||||
{ provide: APP_GUARD, useClass: PermissionGuard },
|
||||
],
|
||||
})
|
||||
export class AppModule implements NestModule {
|
||||
configure(consumer: MiddlewareConsumer) {
|
||||
consumer.apply(CampusScopeMiddleware).forRoutes('*');
|
||||
}
|
||||
}
|
||||
export class AppModule {}
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
@Injectable()
|
||||
export class AppService {
|
||||
getHello(): string {
|
||||
return 'Hello World!';
|
||||
}
|
||||
}
|
||||
@@ -10,9 +10,11 @@ import {
|
||||
Request,
|
||||
UseInterceptors,
|
||||
UploadedFile,
|
||||
Res,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Request as ExpressRequest } from 'express';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
import * as fs from 'fs';
|
||||
import { ArchiveReportService } from './archive-report.service';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import {
|
||||
@@ -321,6 +323,23 @@ export class ArchiveController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Get(':studentId/attachments/:id')
|
||||
@RequirePermission('student:view')
|
||||
async downloadAttachment(
|
||||
@Param('studentId') studentId: string,
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
) {
|
||||
const { fullPath, fileName, mimeType } = await this.archiveService.getAttachmentFile(+studentId, +id);
|
||||
res.setHeader('Content-Type', mimeType);
|
||||
res.setHeader(
|
||||
'Content-Disposition',
|
||||
`inline; filename="${encodeURIComponent(fileName)}"`,
|
||||
);
|
||||
const stream = fs.createReadStream(fullPath);
|
||||
stream.pipe(res);
|
||||
}
|
||||
|
||||
@Delete('attachments/:id')
|
||||
@RequirePermission('student:edit')
|
||||
async deleteAttachment(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { LearningRecord } from '../entities/learning-record.entity';
|
||||
import { ResultArchive } from '../entities/result-archive.entity';
|
||||
import { ArchiveAttachment } from '../entities/archive-attachment.entity';
|
||||
import { AttendanceRecord } from '../entities/attendance-record.entity';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { ArchiveService } from './archive.service';
|
||||
import { ArchiveReportService } from './archive-report.service';
|
||||
@@ -26,7 +25,6 @@ import { ArchiveController } from './archive.controller';
|
||||
ArchiveAttachment,
|
||||
AttendanceRecord,
|
||||
]),
|
||||
CommonModule,
|
||||
NotificationsModule,
|
||||
],
|
||||
controllers: [ArchiveController],
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { StudentProfile } from '../entities/student-profile.entity';
|
||||
@@ -30,10 +30,26 @@ export class ArchiveService {
|
||||
@InjectRepository(LearningRecord) private learningRecordRepo: Repository<LearningRecord>,
|
||||
@InjectRepository(ResultArchive) private resultRepo: Repository<ResultArchive>,
|
||||
@InjectRepository(ArchiveAttachment) private attachmentRepo: Repository<ArchiveAttachment>,
|
||||
private readonly scope: CampusScope,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
get uploadDir(): string {
|
||||
const base = process.env.UPLOAD_DIR || './uploads';
|
||||
return path.resolve(base, 'archive');
|
||||
}
|
||||
|
||||
private resolveAttachmentPath(filePath: string): string {
|
||||
const normalizedPath = filePath.replace(/\\/g, '/');
|
||||
const fullPath = normalizedPath.startsWith('uploads/')
|
||||
? path.resolve(process.cwd(), normalizedPath)
|
||||
: path.resolve(this.uploadDir, normalizedPath);
|
||||
const allowedRoots = [this.uploadDir, path.resolve(process.cwd(), 'uploads', 'archive')];
|
||||
if (!allowedRoots.some((root) => fullPath === root || fullPath.startsWith(`${root}${path.sep}`))) {
|
||||
throw new BadRequestException('路径非法');
|
||||
}
|
||||
return fullPath;
|
||||
}
|
||||
|
||||
async getProfile(studentId: number) {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
@@ -46,24 +62,12 @@ export class ArchiveService {
|
||||
resultArchive,
|
||||
attachments,
|
||||
] = await Promise.all([
|
||||
this.profileRepo.findOne({ where: await this.scope.filter({ studentId }) }),
|
||||
this.enrollmentRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { createdAt: 'DESC' },
|
||||
}),
|
||||
this.examScoreRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { examDate: 'DESC' },
|
||||
}),
|
||||
this.learningRecordRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { recordDate: 'DESC' },
|
||||
}),
|
||||
this.resultRepo.findOne({ where: await this.scope.filter({ studentId }) }),
|
||||
this.attachmentRepo.find({
|
||||
where: await this.scope.filter({ studentId }),
|
||||
order: { createdAt: 'DESC' },
|
||||
}),
|
||||
this.profileRepo.findOne({ where: { studentId } }),
|
||||
this.enrollmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
this.examScoreRepo.find({ where: { studentId }, order: { examDate: 'DESC' } }),
|
||||
this.learningRecordRepo.find({ where: { studentId }, order: { recordDate: 'DESC' } }),
|
||||
this.resultRepo.findOne({ where: { studentId } }),
|
||||
this.attachmentRepo.find({ where: { studentId }, order: { createdAt: 'DESC' } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -173,7 +177,7 @@ export class ArchiveService {
|
||||
const student = await this.studentRepo.findOne({ where: { id: studentId } });
|
||||
if (!student) throw new NotFoundException('学生不存在');
|
||||
|
||||
const uploadDir = path.join(process.cwd(), 'uploads', 'archive');
|
||||
const uploadDir = this.uploadDir;
|
||||
if (!fs.existsSync(uploadDir)) {
|
||||
fs.mkdirSync(uploadDir, { recursive: true });
|
||||
}
|
||||
@@ -187,18 +191,32 @@ export class ArchiveService {
|
||||
studentId,
|
||||
category,
|
||||
fileName: file.originalname,
|
||||
filePath: `uploads/archive/${filename}`,
|
||||
filePath: filename,
|
||||
fileSize: file.size,
|
||||
mimeType: file.mimetype,
|
||||
});
|
||||
return this.attachmentRepo.save(entity);
|
||||
}
|
||||
|
||||
async getAttachmentFile(studentId: number, id: number) {
|
||||
const entity = await this.attachmentRepo.findOne({ where: { id, studentId } });
|
||||
if (!entity) throw new NotFoundException('附件不存在');
|
||||
|
||||
const fullPath = this.resolveAttachmentPath(entity.filePath);
|
||||
if (!fs.existsSync(fullPath)) throw new NotFoundException('附件文件丢失');
|
||||
|
||||
return {
|
||||
fullPath,
|
||||
fileName: entity.fileName || path.basename(fullPath),
|
||||
mimeType: entity.mimeType || 'application/octet-stream',
|
||||
};
|
||||
}
|
||||
|
||||
async deleteAttachment(id: number) {
|
||||
const entity = await this.attachmentRepo.findOne({ where: { id } });
|
||||
if (!entity) throw new NotFoundException('附件不存在');
|
||||
|
||||
const absPath = path.join(process.cwd(), entity.filePath);
|
||||
const absPath = this.resolveAttachmentPath(entity.filePath);
|
||||
if (fs.existsSync(absPath)) {
|
||||
fs.unlinkSync(absPath);
|
||||
}
|
||||
|
||||
128
apps/server/src/attendance/attendance-import.service.spec.ts
Normal file
128
apps/server/src/attendance/attendance-import.service.spec.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
import { BadRequestException } from '@nestjs/common';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
|
||||
describe('AttendanceImportService', () => {
|
||||
const dingRawRepo = {
|
||||
find: jest.fn(),
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const studentRepo = { findOne: jest.fn() };
|
||||
const studentDingMappingRepo = { findOne: jest.fn() };
|
||||
const dingTalkService = {
|
||||
fetchAttendanceResults: jest.fn(),
|
||||
};
|
||||
const attendanceService = {
|
||||
autoMatchDingRecords: jest.fn(),
|
||||
};
|
||||
|
||||
let service: AttendanceImportService;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
service = new AttendanceImportService(
|
||||
dingRawRepo as never,
|
||||
studentRepo as never,
|
||||
studentDingMappingRepo as never,
|
||||
dingTalkService as unknown as DingTalkService,
|
||||
attendanceService as unknown as AttendanceService,
|
||||
);
|
||||
});
|
||||
|
||||
it('splits DingTalk requests by at most 50 users and 7 calendar days without offset pagination', async () => {
|
||||
const userIds = Array.from({ length: 51 }, (_, index) => `user-${index + 1}`);
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([]);
|
||||
|
||||
await (service as any).fetchAllPages({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-10',
|
||||
userIds,
|
||||
});
|
||||
|
||||
expect(dingTalkService.fetchAttendanceResults).toHaveBeenCalledTimes(4);
|
||||
expect(dingTalkService.fetchAttendanceResults.mock.calls.map(([params]) => params)).toEqual([
|
||||
{
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: userIds.slice(0, 50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: userIds.slice(50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-08',
|
||||
endDate: '2026-07-10',
|
||||
userIds: userIds.slice(0, 50),
|
||||
},
|
||||
{
|
||||
startDate: '2026-07-08',
|
||||
endDate: '2026-07-10',
|
||||
userIds: userIds.slice(50),
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('rejects an attendance import without DingTalk user IDs', async () => {
|
||||
await expect(
|
||||
(service as any).fetchAllPages({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: [],
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
|
||||
expect(dingTalkService.fetchAttendanceResults).not.toHaveBeenCalled();
|
||||
});
|
||||
it('stores the DingTalk user name returned with the attendance record', async () => {
|
||||
const entity = await (service as any).mapToEntity({
|
||||
userId: 'ding-1',
|
||||
userName: '张三',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
});
|
||||
|
||||
expect(entity.userName).toBe('张三');
|
||||
});
|
||||
|
||||
it('fills the student name from the DingTalk mapping when saving an imported record', async () => {
|
||||
dingTalkService.fetchAttendanceResults.mockResolvedValue([
|
||||
{
|
||||
userId: 'ding-1',
|
||||
userName: '',
|
||||
workDate: '2026-07-01',
|
||||
timeResult: 'Normal',
|
||||
locationResult: '',
|
||||
planCheckTime: '',
|
||||
actualCheckTime: '2026-07-01T08:00:00.000Z',
|
||||
checkId: 'check-1',
|
||||
checkType: 'OnDuty',
|
||||
},
|
||||
]);
|
||||
dingRawRepo.find.mockResolvedValue([]);
|
||||
studentDingMappingRepo.findOne.mockResolvedValue({ studentId: 3 });
|
||||
studentRepo.findOne.mockResolvedValue({ id: 3, name: '张三' });
|
||||
dingRawRepo.save.mockImplementation(async (entities) => entities);
|
||||
|
||||
await service.importFromDingTalk({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
userIds: ['ding-1'],
|
||||
autoMatch: false,
|
||||
});
|
||||
|
||||
expect(dingRawRepo.save).toHaveBeenCalledWith(
|
||||
[expect.objectContaining({ userName: '张三' })],
|
||||
{ chunk: 50 },
|
||||
);
|
||||
});
|
||||
|
||||
});
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { BadRequestException, Injectable, Logger } from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In } from 'typeorm';
|
||||
import { Subject, Observable } from 'rxjs';
|
||||
import {
|
||||
DingAttendanceRaw,
|
||||
Student,
|
||||
UserDingMapping,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { DingTalkService, DingTalkAttendanceResult } from '../integration/dingtalk.service';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
@@ -33,8 +33,8 @@ export class AttendanceImportService {
|
||||
private readonly dingRawRepo: Repository<DingAttendanceRaw>,
|
||||
@InjectRepository(Student)
|
||||
private readonly studentRepo: Repository<Student>,
|
||||
@InjectRepository(UserDingMapping)
|
||||
private readonly userDingMappingRepo: Repository<UserDingMapping>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private readonly studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
private readonly dingTalkService: DingTalkService,
|
||||
private readonly attendanceService: AttendanceService,
|
||||
) {}
|
||||
@@ -115,7 +115,7 @@ export class AttendanceImportService {
|
||||
const batchSize = 100;
|
||||
for (let i = 0; i < newRecords.length; i += batchSize) {
|
||||
const batch = newRecords.slice(i, i + batchSize);
|
||||
const entities = batch.map((r) => this.mapToEntity(r));
|
||||
const entities = await Promise.all(batch.map((record) => this.mapToEntity(record)));
|
||||
try {
|
||||
await this.dingRawRepo.save(entities, { chunk: 50 });
|
||||
imported += entities.length;
|
||||
@@ -151,42 +151,92 @@ export class AttendanceImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginate through DingTalk attendance API.
|
||||
* The DingTalk API returns max 50 records per page.
|
||||
* DingTalk requires userIds, accepts at most 50 users per request, and
|
||||
* allows a maximum inclusive date range of 7 calendar days.
|
||||
*/
|
||||
private async fetchAllPages(params: {
|
||||
startDate: string;
|
||||
endDate: string;
|
||||
userIds?: string[];
|
||||
}): Promise<DingTalkAttendanceResult[]> {
|
||||
const userIds = [...new Set((params.userIds ?? []).filter(Boolean))];
|
||||
if (userIds.length === 0) {
|
||||
throw new BadRequestException('拉取钉钉考勤必须指定人员范围');
|
||||
}
|
||||
if (params.startDate > params.endDate) {
|
||||
throw new BadRequestException('开始日期不能晚于结束日期');
|
||||
}
|
||||
|
||||
const allResults: DingTalkAttendanceResult[] = [];
|
||||
const pageSize = 50;
|
||||
let offset = 0;
|
||||
let hasMore = true;
|
||||
const userBatches = this.chunk(userIds, 50);
|
||||
const dateRanges = this.splitDateRanges(params.startDate, params.endDate, 7);
|
||||
const totalRequests = userBatches.length * dateRanges.length;
|
||||
let completedRequests = 0;
|
||||
|
||||
while (hasMore) {
|
||||
const batch = await this.dingTalkService.fetchAttendanceResults({
|
||||
startDate: params.startDate,
|
||||
endDate: params.endDate,
|
||||
userIds: params.userIds,
|
||||
offset,
|
||||
limit: pageSize,
|
||||
});
|
||||
|
||||
if (batch.length === 0) {
|
||||
hasMore = false;
|
||||
} else {
|
||||
for (const range of dateRanges) {
|
||||
for (const users of userBatches) {
|
||||
const batch = await this.dingTalkService.fetchAttendanceResults({
|
||||
startDate: range.startDate,
|
||||
endDate: range.endDate,
|
||||
userIds: users,
|
||||
});
|
||||
allResults.push(...batch);
|
||||
offset += batch.length;
|
||||
this.emit('fetching', allResults.length, allResults.length + (batch.length < pageSize ? 0 : pageSize), `Fetched ${allResults.length} records...`);
|
||||
// If last page was smaller than pageSize, we're done
|
||||
if (batch.length < pageSize) hasMore = false;
|
||||
completedRequests++;
|
||||
this.emit(
|
||||
'fetching',
|
||||
completedRequests,
|
||||
totalRequests,
|
||||
`已完成 ${completedRequests}/${totalRequests} 批,获取 ${allResults.length} 条记录`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
private chunk<T>(items: T[], size: number): T[][] {
|
||||
const result: T[][] = [];
|
||||
for (let index = 0; index < items.length; index += size) {
|
||||
result.push(items.slice(index, index + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private splitDateRanges(
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
maxDays: number,
|
||||
): Array<{ startDate: string; endDate: string }> {
|
||||
const ranges: Array<{ startDate: string; endDate: string }> = [];
|
||||
let cursor = this.parseDate(startDate);
|
||||
const end = this.parseDate(endDate);
|
||||
|
||||
while (cursor.getTime() <= end.getTime()) {
|
||||
const rangeEnd = new Date(cursor);
|
||||
rangeEnd.setUTCDate(rangeEnd.getUTCDate() + maxDays - 1);
|
||||
if (rangeEnd.getTime() > end.getTime()) rangeEnd.setTime(end.getTime());
|
||||
ranges.push({
|
||||
startDate: this.formatDate(cursor),
|
||||
endDate: this.formatDate(rangeEnd),
|
||||
});
|
||||
cursor = new Date(rangeEnd);
|
||||
cursor.setUTCDate(cursor.getUTCDate() + 1);
|
||||
}
|
||||
return ranges;
|
||||
}
|
||||
|
||||
private parseDate(value: string): Date {
|
||||
const date = new Date(`${value}T00:00:00.000Z`);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
throw new BadRequestException(`无效日期: ${value}`);
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
private formatDate(value: Date): string {
|
||||
return value.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
/**
|
||||
* Query which dingIds already exist to skip duplicates.
|
||||
*/
|
||||
@@ -206,10 +256,10 @@ export class AttendanceImportService {
|
||||
/**
|
||||
* Map a DingTalk API result to a DingAttendanceRaw entity.
|
||||
*/
|
||||
private mapToEntity(r: DingTalkAttendanceResult): DingAttendanceRaw {
|
||||
private async mapToEntity(r: DingTalkAttendanceResult): Promise<DingAttendanceRaw> {
|
||||
const entity = new DingAttendanceRaw();
|
||||
entity.dingUserId = r.userId;
|
||||
entity.userName = ''; // Will be filled from the result if available
|
||||
entity.userName = r.userName || await this.resolveStudentName(r.userId);
|
||||
entity.attendanceDate = r.workDate;
|
||||
entity.dingId = r.checkId;
|
||||
entity.attendanceType = r.checkType || 'OnDuty';
|
||||
@@ -228,11 +278,20 @@ export class AttendanceImportService {
|
||||
}
|
||||
}
|
||||
|
||||
entity.matchStatus = '未处理';
|
||||
entity.matchStatus = 'unmatched';
|
||||
entity.rawData = JSON.stringify(r);
|
||||
return entity;
|
||||
}
|
||||
|
||||
private async resolveStudentName(dingUserId: string): Promise<string> {
|
||||
const mapping = await this.studentDingMappingRepo.findOne({
|
||||
where: { dingUserId },
|
||||
});
|
||||
if (!mapping) return '';
|
||||
const student = await this.studentRepo.findOne({ where: { id: mapping.studentId } });
|
||||
return student?.name || '';
|
||||
}
|
||||
|
||||
/**
|
||||
* Auto-match unmatched records to students via the dingUserId → userId mapping chain.
|
||||
*
|
||||
|
||||
150
apps/server/src/attendance/attendance.controller.spec.ts
Normal file
150
apps/server/src/attendance/attendance.controller.spec.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
import { BadRequestException, ForbiddenException } from '@nestjs/common';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
|
||||
describe('AttendanceController — DingTalk import scope', () => {
|
||||
const attendanceService = {
|
||||
getTeacherClassDingUserIds: jest.fn(),
|
||||
getImportableClasses: jest.fn(),
|
||||
};
|
||||
const importService = {
|
||||
importFromDingTalk: jest.fn(),
|
||||
};
|
||||
const logService = {
|
||||
log: jest.fn(),
|
||||
};
|
||||
|
||||
let controller: AttendanceController;
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
controller = new AttendanceController(
|
||||
attendanceService as unknown as AttendanceService,
|
||||
importService as unknown as AttendanceImportService,
|
||||
logService as unknown as OperationLogsService,
|
||||
);
|
||||
importService.importFromDingTalk.mockResolvedValue({
|
||||
success: true,
|
||||
imported: 0,
|
||||
skipped: 0,
|
||||
matched: 0,
|
||||
errors: [],
|
||||
duration: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('defaults teacher DingTalk import to today when no date range is provided', async () => {
|
||||
jest.useFakeTimers().setSystemTime(new Date('2026-07-10T08:00:00.000Z'));
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-today']);
|
||||
|
||||
await controller.importFromDingTalk({ classId: 8 }, {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-10',
|
||||
endDate: '2026-07-10',
|
||||
userIds: ['ding-today'],
|
||||
autoMatch: true,
|
||||
});
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it('uses only the selected class students mapped to DingTalk for a teacher import', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1', 'ding-2']);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: true },
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
);
|
||||
|
||||
expect(attendanceService.getTeacherClassDingUserIds).toHaveBeenCalledWith(21, 8, false);
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
});
|
||||
});
|
||||
|
||||
it('does not allow a teacher to supply arbitrary DingTalk user IDs', async () => {
|
||||
await expect(
|
||||
controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', users: 'someone-else', autoMatch: true },
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
),
|
||||
).rejects.toBeInstanceOf(ForbiddenException);
|
||||
|
||||
expect(importService.importFromDingTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('requires teachers to select one of their classes', async () => {
|
||||
await expect(
|
||||
controller.importFromDingTalk({ start: '2026-07-01', end: '2026-07-02', autoMatch: true }, {
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
it('lists only classes available to the current user for DingTalk import', async () => {
|
||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 8, className: '八班' }]);
|
||||
|
||||
await expect(
|
||||
controller.getDingTalkImportClasses({
|
||||
user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false },
|
||||
} as never),
|
||||
).resolves.toEqual([{ classId: 8, className: '八班' }]);
|
||||
|
||||
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(21, false);
|
||||
});
|
||||
|
||||
it('always auto-matches class-scoped imports even if an old client sends autoMatch=false', async () => {
|
||||
attendanceService.getTeacherClassDingUserIds.mockResolvedValue(['ding-1']);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', classId: 8, autoMatch: false },
|
||||
{ user: { id: 21, username: 'teacher', permissions: [], isSuperAdmin: false } } as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ autoMatch: true }),
|
||||
);
|
||||
});
|
||||
|
||||
it('allows class managers to choose any importable class and supply explicit DingTalk users', async () => {
|
||||
attendanceService.getImportableClasses.mockResolvedValue([{ classId: 1, className: '一班' }]);
|
||||
|
||||
await expect(
|
||||
controller.getDingTalkImportClasses({
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'manager',
|
||||
permissions: ['class:edit', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
},
|
||||
} as never),
|
||||
).resolves.toEqual([{ classId: 1, className: '一班' }]);
|
||||
|
||||
expect(attendanceService.getImportableClasses).toHaveBeenCalledWith(7, true);
|
||||
|
||||
await controller.importFromDingTalk(
|
||||
{ start: '2026-07-01', end: '2026-07-02', users: 'ding-1,ding-2', autoMatch: true },
|
||||
{
|
||||
user: {
|
||||
id: 7,
|
||||
username: 'manager',
|
||||
permissions: ['class:edit', 'attendance:create'],
|
||||
isSuperAdmin: false,
|
||||
},
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(importService.importFromDingTalk).toHaveBeenLastCalledWith({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-02',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
autoMatch: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
UseGuards,
|
||||
Request,
|
||||
Res,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { Observable } from 'rxjs';
|
||||
import type { Request as ExpressRequest, Response } from 'express';
|
||||
@@ -34,7 +36,6 @@ import { extractRequestInfo } from '../common/request-utils';
|
||||
import { RequirePermission } from '../auth/decorators/permission.decorator';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
|
||||
/** SSE event shape for @Sse() decorator */
|
||||
interface SseEvent {
|
||||
data: string | Record<string, unknown>;
|
||||
@@ -46,7 +47,8 @@ interface SseEvent {
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
username: string;
|
||||
role?: string;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@@ -58,13 +60,30 @@ export class AttendanceController {
|
||||
private readonly logService: OperationLogsService,
|
||||
) {}
|
||||
|
||||
private getTodayDateOnly(): string {
|
||||
const today = new Date();
|
||||
const year = today.getFullYear();
|
||||
const month = String(today.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(today.getDate()).padStart(2, '0');
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
private canManageAllAttendance(user: RequestUser): boolean {
|
||||
return user.isSuperAdmin === true || user.permissions?.includes('class:edit') === true;
|
||||
}
|
||||
|
||||
private getAccessibleClassIds(user: RequestUser) {
|
||||
return this.service.getAccessibleClassIds(user.id, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
private assertClassAccess(user: RequestUser, classId: number) {
|
||||
return this.service.assertClassAccess(user.id, classId, this.canManageAllAttendance(user));
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
@Post('attendance-records/batch')
|
||||
@RequirePermission('attendance:create')
|
||||
async batchCreate(
|
||||
@Body() dto: BatchCreateAttendanceDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async batchCreate(@Body() dto: BatchCreateAttendanceDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.batchCreate(dto);
|
||||
await this.logService.log({
|
||||
@@ -82,10 +101,7 @@ export class AttendanceController {
|
||||
// ── Generate attendance records from schedules (with optional date range) ──
|
||||
@Post('attendance-records/generate-from-schedules')
|
||||
@RequirePermission('attendance:create')
|
||||
async generateFromSchedules(
|
||||
@Body() dto: GenerateFromSchedulesDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async generateFromSchedules(@Body() dto: GenerateFromSchedulesDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.generateFromSchedules(dto);
|
||||
await this.logService.log({
|
||||
@@ -100,15 +116,17 @@ export class AttendanceController {
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
// ── Export attendance records ──
|
||||
@Get('attendance-records/export')
|
||||
@RequirePermission('attendance:export')
|
||||
async exportRecords(
|
||||
@Query() query: QueryAttendanceRecordsDto,
|
||||
@Res() res: Response,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
const records = await this.service.findAllForExport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const classIds = await this.getAccessibleClassIds(req.user);
|
||||
const records = await this.service.findAllForExport(query, classIds);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -140,9 +158,7 @@ export class AttendanceController {
|
||||
});
|
||||
}
|
||||
|
||||
const dateRange = [query.dateFrom, query.dateTo]
|
||||
.filter(Boolean)
|
||||
.join('-') || '全部';
|
||||
const dateRange = [query.dateFrom, query.dateTo].filter(Boolean).join('-') || '全部';
|
||||
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
@@ -159,8 +175,9 @@ export class AttendanceController {
|
||||
// ── List attendance records with filters ──
|
||||
@Get('attendance-records')
|
||||
@RequirePermission('attendance:view')
|
||||
findAll(@Query() query: QueryAttendanceRecordsDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryAttendanceRecordsDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.findAll(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Update a single attendance record ──
|
||||
@@ -190,10 +207,7 @@ export class AttendanceController {
|
||||
// ── Delete a single attendance record ──
|
||||
@Delete('attendance-records/:id')
|
||||
@RequirePermission('attendance:edit')
|
||||
async remove(
|
||||
@Param('id') id: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async remove(@Param('id') id: string, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.remove(+id);
|
||||
await this.logService.log({
|
||||
@@ -213,29 +227,38 @@ export class AttendanceController {
|
||||
// ── Get distinct classes with attendance records ──
|
||||
@Get('attendance-records/classes')
|
||||
@RequirePermission('attendance:view')
|
||||
getClasses() {
|
||||
return this.service.getClasses();
|
||||
async getClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getClasses(await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
@Get('attendance-records/summary')
|
||||
@RequirePermission('attendance:view')
|
||||
getSummary(@Query() query: AttendanceSummaryQueryDto) {
|
||||
return this.service.getSummary(query);
|
||||
async getSummary(
|
||||
@Query() query: AttendanceSummaryQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getSummary(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Attendance calendar ──
|
||||
@Get('attendance-records/calendar')
|
||||
@RequirePermission('attendance:view')
|
||||
getCalendar(@Query() query: AttendanceCalendarQueryDto) {
|
||||
async getCalendar(
|
||||
@Query() query: AttendanceCalendarQueryDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getCalendar(query);
|
||||
}
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
@Get('ding-attendance-raw')
|
||||
@RequirePermission('attendance:view')
|
||||
getDingRaw(@Query() query: QueryDingRawDto) {
|
||||
return this.service.getDingRaw(query);
|
||||
async getDingRaw(@Query() query: QueryDingRawDto, @Request() req: { user: RequestUser }) {
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
return this.service.getDingRaw(query, await this.getAccessibleClassIds(req.user));
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -270,7 +293,11 @@ export class AttendanceController {
|
||||
@Res() res: Response,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const reportData = await this.service.getReport(query);
|
||||
if (query.classId) await this.assertClassAccess(req.user, query.classId);
|
||||
const reportData = await this.service.getReport(
|
||||
query,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
|
||||
const workbook = new ExcelJS.Workbook();
|
||||
const ws = workbook.addWorksheet('考勤统计报表');
|
||||
@@ -316,7 +343,10 @@ export class AttendanceController {
|
||||
userAgent,
|
||||
});
|
||||
|
||||
res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet');
|
||||
res.setHeader(
|
||||
'Content-Type',
|
||||
'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
|
||||
);
|
||||
res.setHeader('Content-Disposition', 'attachment; filename=attendance-report.xlsx');
|
||||
await workbook.xlsx.write(res);
|
||||
res.end();
|
||||
@@ -325,13 +355,15 @@ export class AttendanceController {
|
||||
// ── Abnormal attendance alerts ──
|
||||
@Get('attendance-records/alerts')
|
||||
@RequirePermission('attendance:view')
|
||||
getAlerts(
|
||||
async getAlerts(
|
||||
@Request() req: { user: RequestUser },
|
||||
@Query('days') days?: string,
|
||||
@Query('threshold') threshold?: string,
|
||||
) {
|
||||
return this.service.getAlerts(
|
||||
days ? +days : 14,
|
||||
threshold ? +threshold : 3,
|
||||
await this.getAccessibleClassIds(req.user),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -345,6 +377,12 @@ export class AttendanceController {
|
||||
// DingTalk attendance import with SSE streaming progress
|
||||
// ═══════════════════════════════════════════════════════════════
|
||||
|
||||
@Get('attendance-records/import/dingtalk/classes')
|
||||
@RequirePermission('attendance:create')
|
||||
getDingTalkImportClasses(@Request() req: { user: RequestUser }) {
|
||||
return this.service.getImportableClasses(req.user.id, this.canManageAllAttendance(req.user));
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger DingTalk attendance import.
|
||||
* Mirrors `dws attendance check result` pipeline:
|
||||
@@ -352,16 +390,38 @@ export class AttendanceController {
|
||||
*/
|
||||
@Post('attendance-records/import/dingtalk')
|
||||
@RequirePermission('attendance:create')
|
||||
async importFromDingTalk(
|
||||
@Body() dto: DingTalkImportDto,
|
||||
@Request() req: { user: RequestUser },
|
||||
) {
|
||||
async importFromDingTalk(@Body() dto: DingTalkImportDto, @Request() req: { user: RequestUser }) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const canManageAll = this.canManageAllAttendance(req.user);
|
||||
let userIds: string[];
|
||||
|
||||
if (dto.users) {
|
||||
if (!canManageAll) {
|
||||
throw new ForbiddenException('仅管理员可指定钉钉用户范围');
|
||||
}
|
||||
userIds = dto.users
|
||||
.split(',')
|
||||
.map((value) => value.trim())
|
||||
.filter(Boolean);
|
||||
} else {
|
||||
if (!dto.classId) {
|
||||
throw new BadRequestException('请选择要拉取考勤的班级');
|
||||
}
|
||||
userIds = await this.service.getTeacherClassDingUserIds(
|
||||
req.user.id,
|
||||
dto.classId,
|
||||
canManageAll,
|
||||
);
|
||||
}
|
||||
|
||||
const startDate = dto.start ?? this.getTodayDateOnly();
|
||||
const endDate = dto.end ?? startDate;
|
||||
|
||||
const result = await this.importService.importFromDingTalk({
|
||||
startDate: dto.start,
|
||||
endDate: dto.end,
|
||||
userIds: dto.users?.split(',').map((s) => s.trim()).filter(Boolean),
|
||||
autoMatch: dto.autoMatch ?? true,
|
||||
startDate,
|
||||
endDate,
|
||||
userIds,
|
||||
autoMatch: true,
|
||||
});
|
||||
|
||||
await this.logService.log({
|
||||
@@ -369,7 +429,7 @@ export class AttendanceController {
|
||||
username: req.user?.username,
|
||||
module: '考勤管理',
|
||||
action: '钉钉考勤导入',
|
||||
detail: `${dto.start}~${dto.end}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
detail: `${startDate}~${endDate}, 导入=${result.imported}, 跳过=${result.skipped}, 匹配=${result.matched}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
@@ -385,7 +445,7 @@ export class AttendanceController {
|
||||
* execute in the standard request pipeline before the SSE handler is invoked.
|
||||
* If this ever breaks after a NestJS upgrade, verify guard execution order.
|
||||
*/
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@Sse('attendance-records/import/dingtalk/stream')
|
||||
@RequirePermission('attendance:view')
|
||||
importProgressStream(): Observable<SseEvent> {
|
||||
return new Observable<SseEvent>((subscriber) => {
|
||||
@@ -401,5 +461,4 @@ export class AttendanceController {
|
||||
return () => subscription.unsubscribe();
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,18 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping } from '../entities';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping } from '../entities';
|
||||
import { AttendanceService } from './attendance.service';
|
||||
import { AttendanceImportService } from './attendance-import.service';
|
||||
import { AttendanceController } from './attendance.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { IntegrationModule } from '../integration/integration.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, UserDingMapping]),
|
||||
TypeOrmModule.forFeature([AttendanceRecord, DingAttendanceRaw, Student, Class, ClassSchedule, ClassStudent, ClassTeacher, StudentDingMapping]),
|
||||
OperationLogsModule,
|
||||
CommonModule,
|
||||
IntegrationModule,
|
||||
],
|
||||
controllers: [AttendanceController],
|
||||
|
||||
@@ -9,8 +9,8 @@ import { Class } from '../entities/class.entity';
|
||||
import { Student } from '../entities/student.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { ClassStudent } from '../entities/class-student.entity';
|
||||
import { UserDingMapping } from '../entities/user-ding-mapping.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { StudentDingMapping } from '../entities/student-ding-mapping.entity';
|
||||
import { ClassTeacher } from '../entities/class-teacher.entity';
|
||||
import { BatchCreateAttendanceDto } from './dto/attendance.dto';
|
||||
|
||||
describe('AttendanceService — batchCreate', () => {
|
||||
@@ -40,8 +40,7 @@ describe('AttendanceService — batchCreate', () => {
|
||||
// Reserved for future tests (auto-match, schedule-based attendance, etc.)
|
||||
const mockScheduleRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const mockClassStudentRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const mockUserDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
const mockCampusScope = { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) };
|
||||
const mockStudentDingMappingRepo = { find: jest.fn().mockResolvedValue([]) };
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
@@ -51,9 +50,9 @@ describe('AttendanceService — batchCreate', () => {
|
||||
{ provide: getRepositoryToken(Class), useValue: mockClassRepo },
|
||||
{ provide: getRepositoryToken(Student), useValue: mockStudentRepo },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: mockScheduleRepo },
|
||||
{ provide: getRepositoryToken(UserDingMapping), useValue: mockUserDingMappingRepo },
|
||||
{ provide: getRepositoryToken(StudentDingMapping), useValue: mockStudentDingMappingRepo },
|
||||
{ provide: getRepositoryToken(ClassStudent), useValue: mockClassStudentRepo },
|
||||
{ provide: CampusScope, useValue: mockCampusScope },
|
||||
{ provide: getRepositoryToken(ClassTeacher), useValue: { findOne: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -102,9 +101,121 @@ describe('AttendanceService — batchCreate', () => {
|
||||
await expect(service.batchCreate(dto)).rejects.toThrow(BadRequestException);
|
||||
});
|
||||
|
||||
it.skip('autoMatchDingRecords with UserDingMapping chain', async () => {
|
||||
// TODO: match dingtalk raw records to students via UserDingMapping lookup,
|
||||
it.skip('autoMatchDingRecords with StudentDingMapping chain', async () => {
|
||||
// TODO: match dingtalk raw records to students via StudentDingMapping lookup,
|
||||
// then to class schedules → ClassStudent association, producing attendance records.
|
||||
// Requires mock setup for UserDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
|
||||
// Requires mock setup for StudentDingMapping, ClassSchedule, ClassStudent, and DingAttendanceRaw repos.
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceService — teacher DingTalk class scope', () => {
|
||||
const classTeacherRepo = {
|
||||
findOne: jest.fn(),
|
||||
find: jest.fn(),
|
||||
};
|
||||
const classStudentRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
const mappingRepo = {
|
||||
find: jest.fn(),
|
||||
};
|
||||
|
||||
const createService = () =>
|
||||
new AttendanceService(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
classTeacherRepo as never,
|
||||
);
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('returns only mapped active students for a class assigned to the teacher', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue({ classId: 8, userId: 21 });
|
||||
classStudentRepo.find.mockResolvedValue([
|
||||
{ studentId: 2 },
|
||||
{ studentId: 1 },
|
||||
{ studentId: 2 },
|
||||
]);
|
||||
mappingRepo.find.mockResolvedValue([
|
||||
{ studentId: 1, dingUserId: 'ding-1' },
|
||||
{ studentId: 2, dingUserId: 'ding-2' },
|
||||
]);
|
||||
|
||||
await expect(createService().getTeacherClassDingUserIds(21, 8, false)).resolves.toEqual([
|
||||
'ding-1',
|
||||
'ding-2',
|
||||
]);
|
||||
});
|
||||
|
||||
it('lists distinct classes assigned to a teacher', async () => {
|
||||
classTeacherRepo.find.mockResolvedValue([
|
||||
{ classId: 8, class: { name: '八班' } },
|
||||
{ classId: 8, class: { name: '八班' } },
|
||||
{ classId: 9, class: { name: '九班' } },
|
||||
]);
|
||||
|
||||
await expect(createService().getImportableClasses(21, false)).resolves.toEqual([
|
||||
{ classId: 8, className: '八班' },
|
||||
{ classId: 9, className: '九班' },
|
||||
]);
|
||||
expect(classTeacherRepo.find).toHaveBeenCalledWith({
|
||||
where: { userId: 21 },
|
||||
relations: ['class'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a class that is not assigned to the teacher', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(createService().getTeacherClassDingUserIds(21, 99, false)).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('AttendanceService — DingTalk raw query', () => {
|
||||
it('returns the paginated shape and filters by class student mappings', async () => {
|
||||
const qb = {
|
||||
leftJoinAndSelect: jest.fn().mockReturnThis(),
|
||||
andWhere: jest.fn().mockReturnThis(),
|
||||
orderBy: jest.fn().mockReturnThis(),
|
||||
addOrderBy: jest.fn().mockReturnThis(),
|
||||
skip: jest.fn().mockReturnThis(),
|
||||
take: jest.fn().mockReturnThis(),
|
||||
getManyAndCount: jest.fn().mockResolvedValue([[{ id: 1 }], 1]),
|
||||
};
|
||||
const dingRepo = { createQueryBuilder: jest.fn().mockReturnValue(qb) };
|
||||
const classStudentRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3 }]) };
|
||||
const mappingRepo = { find: jest.fn().mockResolvedValue([{ studentId: 3, dingUserId: 'ding-3' }]) };
|
||||
const service = new AttendanceService(
|
||||
{} as never,
|
||||
dingRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
classStudentRepo as never,
|
||||
mappingRepo as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await expect(
|
||||
service.getDingRaw({ classId: 8, dateFrom: '2026-07-01', page: 2, pageSize: 10 }),
|
||||
).resolves.toEqual({ list: [{ id: 1 }], total: 1, page: 2, pageSize: 10 });
|
||||
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('ar.dingUserId IN (:...dingUserIds)', {
|
||||
dingUserIds: ['ding-3'],
|
||||
});
|
||||
expect(qb.andWhere).toHaveBeenCalledWith('ar.attendanceDate >= :dateFrom', {
|
||||
dateFrom: '2026-07-01',
|
||||
});
|
||||
expect(qb.skip).toHaveBeenCalledWith(10);
|
||||
expect(qb.take).toHaveBeenCalledWith(10);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,8 +5,7 @@ import {
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Between, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ScheduleType, UserDingMapping } from '../entities';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { AttendanceRecord, DingAttendanceRaw, Class, Student, ClassSchedule, ClassStudent, ClassTeacher, ScheduleType, StudentDingMapping } from '../entities';
|
||||
import {
|
||||
BatchCreateAttendanceDto,
|
||||
AttendanceSummaryQueryDto,
|
||||
@@ -34,21 +33,91 @@ export class AttendanceService {
|
||||
private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(ClassStudent)
|
||||
private classStudentRepo: Repository<ClassStudent>,
|
||||
@InjectRepository(UserDingMapping)
|
||||
private userDingMappingRepo: Repository<UserDingMapping>,
|
||||
private readonly scope: CampusScope,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
@InjectRepository(ClassTeacher)
|
||||
private classTeacherRepo: Repository<ClassTeacher>,
|
||||
) {}
|
||||
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new BadRequestException('只能访问自己任教班级的考勤');
|
||||
}
|
||||
|
||||
/** List classes the current user may select for DingTalk attendance import. */
|
||||
async getImportableClasses(userId: number, isSuperAdmin = false) {
|
||||
if (isSuperAdmin) {
|
||||
const classes = await this.classRepo.find({
|
||||
where: { isArchived: false },
|
||||
order: { name: 'ASC' },
|
||||
});
|
||||
return classes.map((item) => ({ classId: item.id, className: item.name }));
|
||||
}
|
||||
|
||||
const assignments = await this.classTeacherRepo.find({
|
||||
where: { userId },
|
||||
relations: ['class'],
|
||||
});
|
||||
const classes = new Map<number, string>();
|
||||
for (const assignment of assignments) {
|
||||
if (assignment.class && !assignment.class.isArchived) {
|
||||
classes.set(assignment.classId, assignment.class.name);
|
||||
}
|
||||
}
|
||||
return [...classes.entries()]
|
||||
.map(([classId, className]) => ({ classId, className }))
|
||||
.sort((left, right) => left.className.localeCompare(right.className, 'zh-CN'));
|
||||
}
|
||||
|
||||
/** Resolve the DingTalk users a teacher may import for one assigned class. */
|
||||
async getTeacherClassDingUserIds(
|
||||
userId: number,
|
||||
classId: number,
|
||||
isSuperAdmin = false,
|
||||
): Promise<string[]> {
|
||||
if (!isSuperAdmin) {
|
||||
const assignment = await this.classTeacherRepo.findOne({
|
||||
where: { userId, classId },
|
||||
});
|
||||
if (!assignment) {
|
||||
throw new BadRequestException('只能拉取自己任教班级的考勤记录');
|
||||
}
|
||||
} else {
|
||||
const cls = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!cls) throw new NotFoundException(`Class ${classId} not found`);
|
||||
}
|
||||
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId, status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) {
|
||||
throw new BadRequestException('该班级暂无在读学生');
|
||||
}
|
||||
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
const userIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
if (userIds.length === 0) {
|
||||
throw new BadRequestException('该班级学生尚未同步钉钉账号');
|
||||
}
|
||||
return userIds.sort();
|
||||
}
|
||||
|
||||
// ── Batch create attendance records ──
|
||||
async batchCreate(dto: BatchCreateAttendanceDto) {
|
||||
if (!dto.records || dto.records.length === 0) {
|
||||
throw new BadRequestException('records array must not be empty');
|
||||
}
|
||||
|
||||
// Batch-load student departmentIds
|
||||
const ids = [...new Set(dto.records.map((r) => r.studentId))];
|
||||
const students = await this.studentRepo.find({ where: { id: In(ids) } });
|
||||
const deptMap = new Map(students.map((s) => [s.id, s.departmentId]));
|
||||
|
||||
const entities = dto.records.map((r) => {
|
||||
const entity = this.attendanceRepo.create({
|
||||
@@ -60,7 +129,6 @@ export class AttendanceService {
|
||||
remark: r.remark,
|
||||
source: r.source || 'manual',
|
||||
});
|
||||
entity.departmentId = deptMap.get(r.studentId)!;
|
||||
return entity;
|
||||
});
|
||||
|
||||
@@ -130,7 +198,6 @@ export class AttendanceService {
|
||||
status: 'pending',
|
||||
source: 'schedule',
|
||||
});
|
||||
entity.departmentId = cs.student?.departmentId ?? cls.departmentId ?? undefined;
|
||||
entities.push(entity);
|
||||
existingKeys.add(key);
|
||||
}
|
||||
@@ -177,15 +244,15 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Attendance summary ──
|
||||
async getSummary(query: AttendanceSummaryQueryDto) {
|
||||
async getSummary(query: AttendanceSummaryQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { total: 0, present: 0, late: 0, absent: 0, leave: 0, presentRate: 0 };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -279,21 +346,21 @@ export class AttendanceService {
|
||||
source?: string;
|
||||
page?: number;
|
||||
pageSize?: number;
|
||||
}) {
|
||||
}, accessibleClassIds?: number[]) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -318,42 +385,65 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
// ── Get distinct classes with attendance records ──
|
||||
async getClasses() {
|
||||
async getClasses(accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo
|
||||
.createQueryBuilder('ar')
|
||||
.select('DISTINCT ar.classId', 'classId')
|
||||
.where('ar.classId IS NOT NULL');
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
const rows = await qb
|
||||
.orderBy('ar.classId', 'ASC')
|
||||
.getRawMany();
|
||||
const rows = accessibleClassIds
|
||||
? accessibleClassIds.map((classId) => ({ classId }))
|
||||
: await qb.orderBy('ar.classId', 'ASC').getRawMany();
|
||||
|
||||
const classIds = rows.map((r) => r.classId).filter(Boolean) as number[];
|
||||
const classIds = [...new Set(rows.map((r) => Number(r.classId)).filter(Boolean))];
|
||||
if (classIds.length === 0) return [];
|
||||
|
||||
const where = await this.scope.filter({ id: In(classIds) });
|
||||
const where = { id: In(classIds) };
|
||||
const classes = await this.classRepo.find({ where });
|
||||
const nameMap = new Map(classes.map((c) => [c.id, c.name]));
|
||||
return classIds.map((id) => ({ classId: id, className: nameMap.get(id) || `班级${id}` }));
|
||||
}
|
||||
|
||||
// ── DingAttendance raw records ──
|
||||
async getDingRaw(query: QueryDingRawDto) {
|
||||
const where: any = {};
|
||||
async getDingRaw(query: QueryDingRawDto, accessibleClassIds?: number[]) {
|
||||
const page = query.page || 1;
|
||||
const pageSize = query.pageSize || 20;
|
||||
const qb = this.dingRawRepo.createQueryBuilder('ar');
|
||||
|
||||
qb.leftJoinAndSelect('ar.matchedStudent', 'matchedStudent');
|
||||
if (query.matchStatus) {
|
||||
where.matchStatus = query.matchStatus;
|
||||
qb.andWhere('ar.matchStatus = :matchStatus', { matchStatus: query.matchStatus });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
if (query.dateTo) {
|
||||
qb.andWhere('ar.attendanceDate <= :dateTo', { dateTo: query.dateTo });
|
||||
}
|
||||
const scopedClassIds = query.classId ? [query.classId] : accessibleClassIds;
|
||||
if (scopedClassIds) {
|
||||
if (scopedClassIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
const classStudents = await this.classStudentRepo.find({
|
||||
where: { classId: In(scopedClassIds), status: 'active' },
|
||||
});
|
||||
const studentIds = [...new Set(classStudents.map((item) => item.studentId))];
|
||||
if (studentIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
const mappings = await this.studentDingMappingRepo.find({
|
||||
where: { studentId: In(studentIds) },
|
||||
});
|
||||
const dingUserIds = [...new Set(mappings.map((mapping) => mapping.dingUserId).filter(Boolean))];
|
||||
if (dingUserIds.length === 0) return { list: [], total: 0, page, pageSize };
|
||||
qb.andWhere('ar.dingUserId IN (:...dingUserIds)', { dingUserIds });
|
||||
}
|
||||
|
||||
return this.dingRawRepo.find({
|
||||
where,
|
||||
relations: ['matchedStudent'],
|
||||
order: { attendanceDate: 'DESC', checkInTime: 'ASC' },
|
||||
});
|
||||
qb.orderBy('ar.attendanceDate', 'DESC')
|
||||
.addOrderBy('ar.checkInTime', 'ASC')
|
||||
.skip((page - 1) * pageSize)
|
||||
.take(pageSize);
|
||||
|
||||
const [list, total] = await qb.getManyAndCount();
|
||||
return { list, total, page, pageSize };
|
||||
}
|
||||
|
||||
// ── Match a dingtalk record to a student ──
|
||||
@@ -364,44 +454,32 @@ export class AttendanceService {
|
||||
}
|
||||
|
||||
record.matchedStudentId = dto.studentId;
|
||||
record.matchStatus = '已匹配';
|
||||
record.matchStatus = 'matched';
|
||||
return this.dingRawRepo.save(record);
|
||||
}
|
||||
|
||||
// ── Auto-match unmatched dingtalk records via dingUserId → userId mapping chain ──
|
||||
async autoMatchDingRecords(): Promise<{ matched: number; total: number }> {
|
||||
const unmatched = await this.dingRawRepo.find({
|
||||
where: { matchStatus: '未处理' },
|
||||
where: { matchStatus: 'unmatched' },
|
||||
});
|
||||
|
||||
if (unmatched.length === 0) return { matched: 0, total: 0 };
|
||||
|
||||
// Build dingUserId → userId map from the mapping table
|
||||
const mappings = await this.userDingMappingRepo.find();
|
||||
const dingToUserId = new Map<string, number>();
|
||||
// Build dingUserId → studentId map from the mapping table
|
||||
const mappings = await this.studentDingMappingRepo.find();
|
||||
const dingToStudentId = new Map<string, number>();
|
||||
for (const m of mappings) {
|
||||
dingToUserId.set(m.dingUserId, m.userId);
|
||||
}
|
||||
|
||||
// Build userId → studentId map (only students linked to a user)
|
||||
const students = await this.studentRepo.find({
|
||||
where: { userId: In([...dingToUserId.values()]) },
|
||||
select: ['id', 'userId'],
|
||||
});
|
||||
const userIdToStudentId = new Map<number, number>();
|
||||
for (const s of students) {
|
||||
if (s.userId != null) userIdToStudentId.set(s.userId, s.id);
|
||||
dingToStudentId.set(m.dingUserId, m.studentId);
|
||||
}
|
||||
|
||||
let matched = 0;
|
||||
for (const record of unmatched) {
|
||||
const userId = dingToUserId.get(record.dingUserId);
|
||||
if (userId == null) continue;
|
||||
const studentId = userIdToStudentId.get(userId);
|
||||
const studentId = dingToStudentId.get(record.dingUserId);
|
||||
if (studentId == null) continue;
|
||||
|
||||
record.matchedStudentId = studentId;
|
||||
record.matchStatus = '已匹配';
|
||||
record.matchStatus = 'matched';
|
||||
await this.dingRawRepo.save(record);
|
||||
matched++;
|
||||
}
|
||||
@@ -417,18 +495,18 @@ export class AttendanceService {
|
||||
session?: string;
|
||||
status?: string;
|
||||
source?: string;
|
||||
}) {
|
||||
}, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.leftJoinAndSelect('ar.student', 'student')
|
||||
.leftJoinAndSelect('ar.class', 'class');
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -457,10 +535,6 @@ export class AttendanceService {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
|
||||
if (dto.status !== undefined) {
|
||||
record.status = dto.status;
|
||||
@@ -479,22 +553,14 @@ export class AttendanceService {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds && !scopeIds.includes(record.departmentId)) {
|
||||
throw new NotFoundException(`AttendanceRecord ${id} not found`);
|
||||
}
|
||||
|
||||
await this.attendanceRepo.remove(record);
|
||||
return { deleted: true };
|
||||
}
|
||||
|
||||
// ── Class-based attendance report ──
|
||||
async getReport(query: AttendanceReportQueryDto) {
|
||||
async getReport(query: AttendanceReportQueryDto, accessibleClassIds?: number[]) {
|
||||
const qb = this.attendanceRepo.createQueryBuilder('ar');
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('ar.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.leftJoin('ar.class', 'class')
|
||||
.select('class.id', 'classId')
|
||||
@@ -504,6 +570,10 @@ export class AttendanceService {
|
||||
if (query.classId) {
|
||||
qb.andWhere('ar.classId = :classId', { classId: query.classId });
|
||||
}
|
||||
else if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('ar.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
if (query.dateFrom) {
|
||||
qb.andWhere('ar.attendanceDate >= :dateFrom', { dateFrom: query.dateFrom });
|
||||
}
|
||||
@@ -557,7 +627,7 @@ export class AttendanceService {
|
||||
});
|
||||
}
|
||||
// ── Attendance alerts: detect consecutive absences/late ──
|
||||
async getAlerts(days: number = 14, threshold: number = 3) {
|
||||
async getAlerts(days: number = 14, threshold: number = 3, accessibleClassIds?: number[]) {
|
||||
const cutoff = new Date();
|
||||
cutoff.setDate(cutoff.getDate() - days);
|
||||
const cutoffStr = cutoff.toISOString().slice(0, 10);
|
||||
@@ -567,14 +637,14 @@ export class AttendanceService {
|
||||
.leftJoinAndSelect('a.student', 'student')
|
||||
.leftJoinAndSelect('a.class', 'class');
|
||||
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
if (scopeIds) {
|
||||
qb.andWhere('a.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
}
|
||||
|
||||
qb.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] });
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
qb.andWhere('a.classId IN (:...accessibleClassIds)', { accessibleClassIds });
|
||||
}
|
||||
const records = await qb
|
||||
.where('a.attendanceDate >= :cutoff', { cutoff: cutoffStr })
|
||||
.andWhere('a.status IN (:...statuses)', { statuses: ['absent', 'late'] })
|
||||
.orderBy('a.studentId', 'ASC')
|
||||
.addOrderBy('a.attendanceDate', 'DESC')
|
||||
.getMany();
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import { DingTalkService } from '../integration/dingtalk.service';
|
||||
|
||||
|
||||
describe('DingTalkService — attendance records', () => {
|
||||
const originalAppKey = process.env.DINGTALK_APP_KEY;
|
||||
const originalAppSecret = process.env.DINGTALK_APP_SECRET;
|
||||
let service: DingTalkService;
|
||||
|
||||
beforeEach(() => {
|
||||
process.env.DINGTALK_APP_KEY = 'test-app-key';
|
||||
process.env.DINGTALK_APP_SECRET = 'test-app-secret';
|
||||
service = new DingTalkService({} as never, {} as never);
|
||||
Object.assign(service, {
|
||||
accessToken: 'test-token',
|
||||
tokenExpiresAt: Date.now() + 3_600_000,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
global.fetch = undefined as unknown as typeof fetch;
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
if (originalAppKey === undefined) delete process.env.DINGTALK_APP_KEY;
|
||||
else process.env.DINGTALK_APP_KEY = originalAppKey;
|
||||
if (originalAppSecret === undefined) delete process.env.DINGTALK_APP_SECRET;
|
||||
else process.env.DINGTALK_APP_SECRET = originalAppSecret;
|
||||
});
|
||||
|
||||
it('sends the required userIds and does not send unsupported offset/limit fields', async () => {
|
||||
global.fetch = jest.fn().mockResolvedValue({
|
||||
json: jest.fn().mockResolvedValue({ errcode: 0, errmsg: 'ok', recordresult: [] }),
|
||||
}) as jest.MockedFunction<typeof fetch>;
|
||||
|
||||
await service.fetchAttendanceResults({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-07',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
});
|
||||
|
||||
expect(JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)).toEqual({
|
||||
checkDateFrom: '2026-07-01 00:00:00',
|
||||
checkDateTo: '2026-07-07 23:59:59',
|
||||
userIds: ['ding-1', 'ding-2'],
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects missing userIds before calling DingTalk', async () => {
|
||||
await expect(
|
||||
service.fetchAttendanceResults({
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-01',
|
||||
}),
|
||||
).rejects.toThrow('userIds');
|
||||
expect(global.fetch).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -78,8 +78,31 @@ export class AttendanceCalendarQueryDto {
|
||||
export class QueryDingRawDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['未处理', '已匹配', '待匹配'])
|
||||
@IsIn(['unmatched', 'pending', 'matched'])
|
||||
matchStatus?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateFrom?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dateTo?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Type(() => Number)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class QueryAttendanceRecordsDto {
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsDateString,
|
||||
IsBoolean,
|
||||
IsInt,
|
||||
IsNotEmpty,
|
||||
Min,
|
||||
Max,
|
||||
} from 'class-validator';
|
||||
import { IsOptional, IsString, IsDateString, IsInt, Min } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
/**
|
||||
@@ -15,24 +6,30 @@ import { Type } from 'class-transformer';
|
||||
* Mirrors `dws attendance check result` flags.
|
||||
*/
|
||||
export class DingTalkImportDto {
|
||||
/** Start date (YYYY-MM-DD), required */
|
||||
@IsNotEmpty()
|
||||
/** Start date (YYYY-MM-DD). Defaults to today when omitted. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
start: string;
|
||||
start?: string;
|
||||
|
||||
/** End date (YYYY-MM-DD), required, max 1 month span */
|
||||
@IsNotEmpty()
|
||||
/** End date (YYYY-MM-DD). Defaults to start/today when omitted. */
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
end: string;
|
||||
end?: string;
|
||||
|
||||
/** Comma-separated DingTalk user IDs, optional (default: all org users) */
|
||||
/** Target class. Required for non-super-admin users. */
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
@Type(() => Number)
|
||||
classId?: number;
|
||||
|
||||
/** Comma-separated DingTalk user IDs. Super-admin override only. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
users?: string;
|
||||
|
||||
/** Auto-match imported records to students after import */
|
||||
/** @deprecated Imports are always matched through DingTalk user mappings. */
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
autoMatch?: boolean;
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { Controller, Post, Body, Get, Request, Req, UseGuards } from '@nestjs/common';
|
||||
import { Controller, Post, Body, Get, Request, Req } from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/auth.dto';
|
||||
import { JwtAuthGuard } from './guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
import { extractRequestInfo } from '../common/request-utils';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { Public } from './decorators/public.decorator';
|
||||
import { Authenticated } from './decorators/authenticated.decorator';
|
||||
|
||||
@Controller('auth')
|
||||
export class AuthController {
|
||||
@@ -45,8 +45,7 @@ export class AuthController {
|
||||
}
|
||||
}
|
||||
|
||||
@Public()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Authenticated()
|
||||
@Get('profile')
|
||||
getProfile(@Request() req: any) {
|
||||
return req.user;
|
||||
|
||||
29
apps/server/src/auth/auth.service.spec.ts
Normal file
29
apps/server/src/auth/auth.service.spec.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import * as bcrypt from 'bcryptjs';
|
||||
import { AuthService } from './auth.service';
|
||||
|
||||
describe('AuthService — super admin identity', () => {
|
||||
it('marks the preset 超管 role as super admin in the JWT payload', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 1,
|
||||
username: 'admin',
|
||||
name: '管理员',
|
||||
passwordHash: await bcrypt.hash('secret', 4),
|
||||
isActive: true,
|
||||
roles: [{ name: '超管', status: 1 }],
|
||||
}),
|
||||
save: jest.fn(),
|
||||
};
|
||||
const jwtService = { sign: jest.fn().mockReturnValue('token') };
|
||||
const rbacService = {
|
||||
getUserPermissions: jest.fn().mockResolvedValue(['attendance:create']),
|
||||
};
|
||||
const service = new AuthService(userRepo as never, jwtService as never, rbacService as never);
|
||||
|
||||
await service.login({ username: 'admin', password: 'secret' }, '127.0.0.1');
|
||||
|
||||
expect(jwtService.sign).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ isSuperAdmin: true }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -59,7 +59,12 @@ export class AuthService {
|
||||
|
||||
// 获取用户权限
|
||||
const permissions = await this.rbacService.getUserPermissions(user.id);
|
||||
const isSuperAdmin = user.roles?.some((r) => r.name === 'super_admin') ?? false;
|
||||
const isSuperAdmin =
|
||||
user.roles?.some(
|
||||
(role) =>
|
||||
role.status === 1 &&
|
||||
(role.name === '超管' || role.name === 'super_admin' || role.code === 'super_admin'),
|
||||
) ?? false;
|
||||
const payload = { sub: user.id, username: user.username, permissions, isSuperAdmin };
|
||||
|
||||
// 获取角色名称列表
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { SetMetadata } from '@nestjs/common';
|
||||
|
||||
export const AUTHENTICATED_KEY = 'authenticatedOnly';
|
||||
|
||||
/**
|
||||
* 标记为“只需要登录”的接口:仍由全局 JwtAuthGuard 校验 JWT,
|
||||
* 但 PermissionGuard 不要求具体业务权限。
|
||||
*/
|
||||
export const Authenticated = () => SetMetadata(AUTHENTICATED_KEY, true);
|
||||
@@ -8,15 +8,3 @@ export class LoginDto {
|
||||
@MinLength(4)
|
||||
password: string;
|
||||
}
|
||||
|
||||
export class RegisterDto {
|
||||
@IsString()
|
||||
username: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(4)
|
||||
password: string;
|
||||
|
||||
@IsString()
|
||||
name: string;
|
||||
}
|
||||
|
||||
50
apps/server/src/auth/guards/permission.guard.spec.ts
Normal file
50
apps/server/src/auth/guards/permission.guard.spec.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PermissionGuard } from './permission.guard';
|
||||
|
||||
describe('PermissionGuard', () => {
|
||||
const createContext = (user: unknown) =>
|
||||
({
|
||||
getHandler: () => function handler() {},
|
||||
getClass: () => class Controller {},
|
||||
switchToHttp: () => ({ getRequest: () => ({ user }) }),
|
||||
}) as never;
|
||||
|
||||
it('denies routes that forgot to declare permissions', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(false),
|
||||
getAllAndMerge: jest.fn().mockReturnValue(undefined),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: ['dashboard:view'] }))).toBe(false);
|
||||
});
|
||||
|
||||
it('allows explicitly public routes without a user', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValue(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(true);
|
||||
});
|
||||
|
||||
it('allows authenticated-only routes for logged-in users without requiring profile:view', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext({ permissions: [] }))).toBe(true);
|
||||
});
|
||||
|
||||
it('denies authenticated-only routes when no authenticated user is present', () => {
|
||||
const reflector = {
|
||||
getAllAndOverride: jest.fn().mockReturnValueOnce(false).mockReturnValueOnce(true),
|
||||
getAllAndMerge: jest.fn(),
|
||||
};
|
||||
const guard = new PermissionGuard(reflector as never);
|
||||
|
||||
expect(guard.canActivate(createContext(undefined))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -2,18 +2,19 @@ import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
|
||||
import { Reflector } from '@nestjs/core';
|
||||
import { IS_PUBLIC_KEY } from '../decorators/public.decorator';
|
||||
import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
import { AUTHENTICATED_KEY } from '../decorators/authenticated.decorator';
|
||||
|
||||
/**
|
||||
* 权限守卫 — 默认放行策略(⚠️ 安全关键)
|
||||
* 权限守卫 — 默认拒绝策略(安全关键)
|
||||
*
|
||||
* 当 handler/controller 上不存在 @RequirePermission 时,守卫放行(仅需登录即可访问)。
|
||||
* 这是有意的设计选择:所有敏感路由必须显式标注 @RequirePermission。
|
||||
* 当 handler/controller 上不存在 @RequirePermission、@Authenticated 且未标记 @Public 时,守卫拒绝访问。
|
||||
* 所有路由必须显式声明公开、仅登录或所需权限。
|
||||
*
|
||||
* ⚠️ 新增路由时务必添加 @RequirePermission,否则该路由对所有已认证用户开放!
|
||||
* ⚠️ 新增路由时务必添加 @RequirePermission、@Authenticated 或 @Public。
|
||||
* 建议配合 lint 规则确保无遗漏。
|
||||
*/
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
@Injectable()
|
||||
export class PermissionGuard implements CanActivate {
|
||||
constructor(private reflector: Reflector) {}
|
||||
|
||||
canActivate(context: ExecutionContext): boolean {
|
||||
@@ -24,20 +25,28 @@ import { PERMISSION_KEY } from '../decorators/permission.decorator';
|
||||
]);
|
||||
if (isPublic) return true;
|
||||
|
||||
// 2. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
|
||||
// 2. @Authenticated() 只要求已登录,具体 JWT 有效性由 JwtAuthGuard 负责。
|
||||
const authenticatedOnly = this.reflector.getAllAndOverride<boolean>(AUTHENTICATED_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
if (authenticatedOnly) return !!user;
|
||||
|
||||
// 3. 获取所需权限(getAllAndMerge 合并 handler+class 层的所有 metadata)
|
||||
const requiredPermissions = this.reflector.getAllAndMerge<string[]>(PERMISSION_KEY, [
|
||||
context.getHandler(),
|
||||
context.getClass(),
|
||||
]);
|
||||
// 无装饰器 = 仅需登录即可,放行
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return true;
|
||||
// 无权限声明且非 @Public/@Authenticated:默认拒绝,避免新增接口意外裸奔
|
||||
if (!requiredPermissions || requiredPermissions.length === 0) return false;
|
||||
|
||||
// 3. 从 JWT payload 获取用户权限
|
||||
const request = context.switchToHttp().getRequest();
|
||||
const user = request.user;
|
||||
// 4. 从 JWT payload 获取用户权限
|
||||
if (!user || !user.permissions || !Array.isArray(user.permissions)) return false;
|
||||
|
||||
// 4. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
// 5. OR 匹配:用户拥有 requiredPermissions 中任一权限即可通过
|
||||
return requiredPermissions.some((p) => user.permissions.includes(p));
|
||||
}
|
||||
}
|
||||
|
||||
48
apps/server/src/auth/strategies/jwt.strategy.spec.ts
Normal file
48
apps/server/src/auth/strategies/jwt.strategy.spec.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { UnauthorizedException } from '@nestjs/common';
|
||||
import { JwtStrategy } from './jwt.strategy';
|
||||
|
||||
describe('JwtStrategy', () => {
|
||||
const config = { get: jest.fn().mockReturnValue('secret') };
|
||||
|
||||
it('refreshes permissions from the database instead of trusting stale JWT permissions', async () => {
|
||||
const userRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({
|
||||
id: 7,
|
||||
username: 'teacher',
|
||||
isActive: true,
|
||||
isArchived: false,
|
||||
roles: [
|
||||
{
|
||||
name: '老师',
|
||||
status: 1,
|
||||
permissions: [{ code: 'class:view' }, { code: 'attendance:view' }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(
|
||||
strategy.validate({ sub: 7, username: 'teacher', permissions: ['user:delete'] }),
|
||||
).resolves.toEqual({
|
||||
id: 7,
|
||||
username: 'teacher',
|
||||
permissions: ['class:view', 'attendance:view'],
|
||||
isSuperAdmin: false,
|
||||
roles: ['老师'],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
[{ id: 7, isActive: false, isArchived: false, roles: [] }],
|
||||
[{ id: 7, isActive: true, isArchived: true, roles: [] }],
|
||||
[null],
|
||||
])('rejects disabled, archived, or deleted users', async (user) => {
|
||||
const userRepo = { findOne: jest.fn().mockResolvedValue(user) };
|
||||
const strategy = new JwtStrategy(config as never, userRepo as never);
|
||||
|
||||
await expect(strategy.validate({ sub: 7, username: 'teacher' })).rejects.toBeInstanceOf(
|
||||
UnauthorizedException,
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,18 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { Injectable, UnauthorizedException } from '@nestjs/common';
|
||||
import { PassportStrategy } from '@nestjs/passport';
|
||||
import { ExtractJwt, Strategy } from 'passport-jwt';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Request } from 'express';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository } from 'typeorm';
|
||||
import { User } from '../../entities/user.entity';
|
||||
|
||||
@Injectable()
|
||||
export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
constructor(config: ConfigService) {
|
||||
constructor(
|
||||
config: ConfigService,
|
||||
@InjectRepository(User) private readonly userRepo: Repository<User>,
|
||||
) {
|
||||
super({
|
||||
jwtFromRequest: ExtractJwt.fromExtractors([
|
||||
// 1. Standard Bearer header (existing behavior)
|
||||
@@ -25,12 +31,32 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
|
||||
});
|
||||
}
|
||||
|
||||
async validate(payload: any) {
|
||||
async validate(payload: { sub?: number; username?: string }) {
|
||||
if (!payload.sub) throw new UnauthorizedException('登录状态无效');
|
||||
const user = await this.userRepo.findOne({
|
||||
where: { id: payload.sub },
|
||||
relations: ['roles', 'roles.permissions'],
|
||||
});
|
||||
if (!user || !user.isActive || user.isArchived) {
|
||||
throw new UnauthorizedException('账号已失效,请重新登录');
|
||||
}
|
||||
|
||||
const permissions = new Set<string>();
|
||||
const roles: string[] = [];
|
||||
let isSuperAdmin = false;
|
||||
for (const role of user.roles ?? []) {
|
||||
if (role.status !== 1) continue;
|
||||
roles.push(role.name);
|
||||
if (role.name === '超管' || role.name === 'super_admin') isSuperAdmin = true;
|
||||
for (const permission of role.permissions ?? []) permissions.add(permission.code);
|
||||
}
|
||||
|
||||
return {
|
||||
id: payload.sub,
|
||||
username: payload.username,
|
||||
permissions: payload.permissions || [],
|
||||
isSuperAdmin: payload.isSuperAdmin || false,
|
||||
id: user.id,
|
||||
username: user.username,
|
||||
permissions: [...permissions],
|
||||
isSuperAdmin,
|
||||
roles,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { Bill } from '../entities/bill.entity';
|
||||
import { BillItem } from '../entities/bill-item.entity';
|
||||
@@ -27,7 +26,6 @@ import { BillsController } from './bills.controller';
|
||||
Student,
|
||||
]),
|
||||
NotificationsModule,
|
||||
CommonModule,
|
||||
],
|
||||
controllers: [BillsController],
|
||||
providers: [BillsService, BillsExportService],
|
||||
|
||||
@@ -9,7 +9,6 @@ import { PersonalExpense } from '../entities/personal-expense.entity';
|
||||
import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
type MockRepository<T> = Partial<Record<keyof Repository<T>, jest.Mock>>;
|
||||
|
||||
@@ -70,7 +69,6 @@ describe('BillsService — generateBills', () => {
|
||||
{ provide: getRepositoryToken(Room), useValue: roomRepo },
|
||||
{ provide: getRepositoryToken(Deposit), useValue: depositRepo },
|
||||
{ provide: DataSource, useValue: dataSource },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null), filter: jest.fn((w: any) => w) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -96,7 +94,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
rentalType: 'long',
|
||||
stayType: 'long',
|
||||
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
|
||||
} as Occupancy,
|
||||
]),
|
||||
@@ -137,12 +135,12 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2, studentId: 11, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
@@ -189,18 +187,18 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
rentalType: 'long',
|
||||
stayType: 'long',
|
||||
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2, studentId: 11, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 3, studentId: 12, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-15',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
@@ -249,7 +247,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-01-01', billingEndDate: '2026-03-31',
|
||||
rentalType: 'long',
|
||||
stayType: 'long',
|
||||
room: { roomNumber: '101', monthlyRate: '800' as unknown as number } as Room,
|
||||
} as Occupancy,
|
||||
]),
|
||||
@@ -291,7 +289,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-15', billingEndDate: '2026-06-30',
|
||||
rentalType: 'long',
|
||||
stayType: 'long',
|
||||
room: { roomNumber: '101', monthlyRate: '600' as unknown as number } as Room,
|
||||
} as Occupancy,
|
||||
]),
|
||||
@@ -345,12 +343,12 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-10',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
{
|
||||
id: 2, studentId: 11, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-20',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]);
|
||||
}
|
||||
@@ -358,7 +356,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 3, studentId: 12, roomId: 2,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]);
|
||||
});
|
||||
@@ -411,7 +409,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
@@ -454,7 +452,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-07-01', billingEndDate: '2026-07-15',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
@@ -478,7 +476,7 @@ describe('BillsService — generateBills', () => {
|
||||
{
|
||||
id: 1, studentId: 10, roomId: 1,
|
||||
billingStartDate: '2026-06-01', billingEndDate: '2026-06-30',
|
||||
rentalType: 'short', room: undefined,
|
||||
stayType: 'short', room: undefined,
|
||||
} as Occupancy,
|
||||
]),
|
||||
);
|
||||
|
||||
@@ -9,7 +9,7 @@ import { Occupancy } from '../entities/occupancy.entity';
|
||||
import { Room } from '../entities/room.entity';
|
||||
import { Deposit } from '../entities/deposit.entity';
|
||||
import { GenerateBillsDto, UpdateBillStatusDto } from './dto/bill.dto';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
|
||||
@Injectable()
|
||||
export class BillsService {
|
||||
@@ -22,7 +22,6 @@ export class BillsService {
|
||||
@InjectRepository(Room) private roomRepo: Repository<Room>,
|
||||
@InjectRepository(Deposit) private depositRepo: Repository<Deposit>,
|
||||
private dataSource: DataSource,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
/**
|
||||
@@ -83,8 +82,8 @@ export class BillsService {
|
||||
|
||||
|
||||
// 分离长租与短租入住记录
|
||||
const shortTermOccs = occupancies.filter((o) => o.rentalType !== 'long');
|
||||
const longTermOccs = occupancies.filter((o) => o.rentalType === 'long');
|
||||
const shortTermOccs = occupancies.filter((o) => o.stayType !== 'long');
|
||||
const longTermOccs = occupancies.filter((o) => o.stayType === 'long');
|
||||
|
||||
// 长租:按月租费独立计费,不参与人天数分摊
|
||||
for (const occ of longTermOccs) {
|
||||
@@ -181,15 +180,6 @@ export class BillsService {
|
||||
// 合并所有涉及的学生
|
||||
const allStudentIds = new Set([...studentBillData.keys(), ...personalMap.keys()]);
|
||||
// 生成账单
|
||||
// Batch-load student departmentIds
|
||||
const studentIdsArr = [...allStudentIds];
|
||||
const studentDeptMap = new Map<number, number>();
|
||||
if (studentIdsArr.length > 0) {
|
||||
const studentsData: { id: number; department_id: number | null }[] = await this.dataSource.query(
|
||||
`SELECT id, department_id FROM students WHERE id IN (${studentIdsArr.join(',')})`
|
||||
);
|
||||
for (const s of studentsData) if (s.department_id != null) studentDeptMap.set(s.id, s.department_id);
|
||||
}
|
||||
const bills: Bill[] = [];
|
||||
for (const studentId of allStudentIds) {
|
||||
const shared = studentBillData.get(studentId)?.shared || 0;
|
||||
@@ -204,7 +194,6 @@ export class BillsService {
|
||||
personalAmount: personal,
|
||||
totalAmount: total,
|
||||
status: 'draft',
|
||||
departmentId: studentDeptMap.get(studentId),
|
||||
});
|
||||
const savedBill = await this.billRepo.save(bill);
|
||||
|
||||
@@ -229,12 +218,10 @@ export class BillsService {
|
||||
status?: string;
|
||||
expenseType?: string;
|
||||
}) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.billRepo
|
||||
.createQueryBuilder('b')
|
||||
.leftJoinAndSelect('b.student', 'student')
|
||||
.orderBy('b.generatedAt', 'DESC');
|
||||
if (scopeIds) qb.andWhere('b.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.periodStart) qb.andWhere('b.periodStart = :ps', { ps: query.periodStart });
|
||||
if (query?.periodEnd) qb.andWhere('b.periodEnd = :pe', { pe: query.periodEnd });
|
||||
if (query?.studentId) qb.andWhere('b.studentId = :sid', { sid: query.studentId });
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
AddTeacherDto,
|
||||
QueryClassScheduleDto,
|
||||
QueryClassAttendanceSummaryDto,
|
||||
BatchImportStudentsDto,
|
||||
} from './dto/class.dto';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { OperationLogsService } from '../operation-logs/operation-logs.service';
|
||||
@@ -30,6 +31,16 @@ import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { NotificationType } from '../entities/notification.entity';
|
||||
import * as ExcelJS from 'exceljs';
|
||||
|
||||
interface RequestUser {
|
||||
id: number;
|
||||
permissions?: string[];
|
||||
isSuperAdmin?: boolean;
|
||||
}
|
||||
|
||||
interface AuthenticatedRequest {
|
||||
user: RequestUser;
|
||||
}
|
||||
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('classes')
|
||||
export class ClassesController {
|
||||
@@ -39,30 +50,48 @@ export class ClassesController {
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
private assertReadAccess(req: AuthenticatedRequest, classId: number) {
|
||||
const canManageAll =
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true;
|
||||
return this.service.assertClassAccess(req.user.id, classId, canManageAll);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequirePermission('class:view')
|
||||
findAll(@Query() query: QueryClassDto) {
|
||||
return this.service.findAll(query);
|
||||
async findAll(@Query() query: QueryClassDto, @Request() req: AuthenticatedRequest) {
|
||||
const classIds = await this.service.getAccessibleClassIds(
|
||||
req.user.id,
|
||||
req.user.isSuperAdmin === true || req.user.permissions?.includes('class:edit') === true,
|
||||
);
|
||||
return this.service.findAll(query, classIds);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('class:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
async findOne(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.findOne(+id);
|
||||
}
|
||||
|
||||
@Get(':id/schedule')
|
||||
@RequirePermission('class:view')
|
||||
getSchedule(@Param('id') id: string, @Query() query: QueryClassScheduleDto) {
|
||||
async getSchedule(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassScheduleDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getSchedule(+id, query);
|
||||
}
|
||||
|
||||
@Get(':id/attendance-summary')
|
||||
@RequirePermission('class:view')
|
||||
getAttendanceSummary(
|
||||
async getAttendanceSummary(
|
||||
@Param('id') id: string,
|
||||
@Query() query: QueryClassAttendanceSummaryDto,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getAttendanceSummary(+id, query);
|
||||
}
|
||||
|
||||
@@ -85,13 +114,30 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
/** 批量导入学生到班级(通过钉钉用户ID) */
|
||||
@Post(':id/students/import')
|
||||
@RequirePermission('class:edit')
|
||||
async batchImportStudents(@Param('id') id: string, @Body() dto: BatchImportStudentsDto) {
|
||||
return this.service.batchImportStudents(+id, dto.users);
|
||||
}
|
||||
|
||||
/** 归档班级 */
|
||||
@Put(':id/archive')
|
||||
@RequirePermission('class:edit')
|
||||
async archive(@Param('id') id: string) {
|
||||
return this.service.archive(+id);
|
||||
}
|
||||
|
||||
/** 取消归档 */
|
||||
@Put(':id/restore')
|
||||
@RequirePermission('class:edit')
|
||||
async restore(@Param('id') id: string) {
|
||||
return this.service.restore(+id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('class:edit')
|
||||
async update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateClassDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async update(@Param('id') id: string, @Body() dto: UpdateClassDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.update(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -128,7 +174,12 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/roster/export')
|
||||
@RequirePermission('class:view')
|
||||
async exportRoster(@Param('id') id: string, @Res() res: Response) {
|
||||
async exportRoster(
|
||||
@Param('id') id: string,
|
||||
@Res() res: Response,
|
||||
@Request() req: AuthenticatedRequest,
|
||||
) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
const classEntity = await this.service.findOne(+id);
|
||||
const classStudents = await this.service.getStudents(+id);
|
||||
|
||||
@@ -166,17 +217,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/students')
|
||||
@RequirePermission('class:view')
|
||||
getStudents(@Param('id') id: string) {
|
||||
async getStudents(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getStudents(+id);
|
||||
}
|
||||
|
||||
@Post(':id/students')
|
||||
@RequirePermission('class:edit')
|
||||
async addStudents(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddStudentsDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addStudents(@Param('id') id: string, @Body() dto: AddStudentsDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addStudents(+id, dto.studentIds);
|
||||
await this.logService.log({
|
||||
@@ -229,17 +277,14 @@ export class ClassesController {
|
||||
|
||||
@Get(':id/teachers')
|
||||
@RequirePermission('class:view')
|
||||
getTeachers(@Param('id') id: string) {
|
||||
async getTeachers(@Param('id') id: string, @Request() req: AuthenticatedRequest) {
|
||||
await this.assertReadAccess(req, +id);
|
||||
return this.service.getTeachers(+id);
|
||||
}
|
||||
|
||||
@Post(':id/teachers')
|
||||
@RequirePermission('class:edit')
|
||||
async addTeacher(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: AddTeacherDto,
|
||||
@Request() req: any,
|
||||
) {
|
||||
async addTeacher(@Param('id') id: string, @Body() dto: AddTeacherDto, @Request() req: any) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.addTeacher(+id, dto);
|
||||
await this.logService.log({
|
||||
@@ -264,6 +309,29 @@ export class ClassesController {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teacher-assignments/:assignmentId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacherAssignment(
|
||||
@Param('id') id: string,
|
||||
@Param('assignmentId') assignmentId: string,
|
||||
@Request() req: any,
|
||||
) {
|
||||
const { ipAddress, userAgent } = extractRequestInfo(req);
|
||||
const result = await this.service.removeTeacherAssignment(+id, +assignmentId);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
module: '班级管理',
|
||||
action: '移除教师角色',
|
||||
targetId: +id,
|
||||
targetType: 'class',
|
||||
detail: `移除教师分配${assignmentId}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
@Delete(':id/teachers/:userId')
|
||||
@RequirePermission('class:edit')
|
||||
async removeTeacher(
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord } from '../entities';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping } from '../entities';
|
||||
import { ClassesService } from './classes.service';
|
||||
import { ClassesController } from './classes.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord]), OperationLogsModule, NotificationsModule, CommonModule],
|
||||
imports: [TypeOrmModule.forFeature([Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Student, StudentDingMapping]), OperationLogsModule, NotificationsModule],
|
||||
controllers: [ClassesController],
|
||||
providers: [ClassesService],
|
||||
exports: [ClassesService],
|
||||
|
||||
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
64
apps/server/src/classes/classes.scope.spec.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import { ForbiddenException } from '@nestjs/common';
|
||||
import { ClassesService } from './classes.service';
|
||||
|
||||
describe('ClassesService — teacher data scope', () => {
|
||||
const classRepo = { find: jest.fn() };
|
||||
const classStudentRepo = { createQueryBuilder: jest.fn() };
|
||||
const classTeacherRepo = { find: jest.fn(), findOne: jest.fn() };
|
||||
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
classStudentRepo as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
beforeEach(() => jest.clearAllMocks());
|
||||
|
||||
it('returns only class ids assigned to a teacher', async () => {
|
||||
classTeacherRepo.find.mockResolvedValue([{ classId: 3 }, { classId: 5 }, { classId: 3 }]);
|
||||
|
||||
await expect(service.getAccessibleClassIds(21, false)).resolves.toEqual([3, 5]);
|
||||
});
|
||||
|
||||
it('rejects access to a class outside the teacher assignments', async () => {
|
||||
classTeacherRepo.findOne.mockResolvedValue(null);
|
||||
|
||||
await expect(service.assertClassAccess(21, 9, false)).rejects.toBeInstanceOf(
|
||||
ForbiddenException,
|
||||
);
|
||||
});
|
||||
|
||||
it('allows class managers to access any class', async () => {
|
||||
await expect(service.assertClassAccess(21, 9, true)).resolves.toBeUndefined();
|
||||
expect(classTeacherRepo.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('clears denormalized teacher ids when the last teacher for that role is removed', async () => {
|
||||
const classRepo = { update: jest.fn() };
|
||||
const classTeacherRepo = {
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
delete: jest.fn().mockResolvedValue({ affected: 1 }),
|
||||
};
|
||||
const service = new ClassesService(
|
||||
classRepo as never,
|
||||
{} as never,
|
||||
classTeacherRepo as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
);
|
||||
|
||||
await service.removeTeacher(8, 21);
|
||||
|
||||
expect(classRepo.update).toHaveBeenCalledWith(8, {
|
||||
headTeacherId: null,
|
||||
lifeTeacherId: null,
|
||||
academicTeacherId: null,
|
||||
});
|
||||
});
|
||||
@@ -1,9 +1,31 @@
|
||||
import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common';
|
||||
import {
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, In, Like } from 'typeorm';
|
||||
import { Class, ClassStudent, ClassTeacher, ClassSchedule, AttendanceRecord, Classroom } from '../entities';
|
||||
import { CreateClassDto, UpdateClassDto, QueryClassDto, AddTeacherDto, QueryClassScheduleDto, QueryClassAttendanceSummaryDto } from './dto/class.dto';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import {
|
||||
Class,
|
||||
ClassStudent,
|
||||
ClassTeacher,
|
||||
ClassSchedule,
|
||||
AttendanceRecord,
|
||||
Classroom,
|
||||
Student,
|
||||
StudentDingMapping,
|
||||
} from '../entities';
|
||||
import { normalizeDateOnly } from '../database/date-normalization';
|
||||
import {
|
||||
CreateClassDto,
|
||||
UpdateClassDto,
|
||||
QueryClassDto,
|
||||
AddTeacherDto,
|
||||
QueryClassScheduleDto,
|
||||
QueryClassAttendanceSummaryDto,
|
||||
BatchImportStudentsDto,
|
||||
} from './dto/class.dto';
|
||||
|
||||
interface RawStudentCount {
|
||||
classId: string;
|
||||
@@ -23,16 +45,36 @@ export class ClassesService {
|
||||
private scheduleRepo: Repository<ClassSchedule>,
|
||||
@InjectRepository(AttendanceRecord)
|
||||
private attendanceRepo: Repository<AttendanceRecord>,
|
||||
private readonly scope: CampusScope,
|
||||
@InjectRepository(Student)
|
||||
private studentRepo: Repository<Student>,
|
||||
@InjectRepository(StudentDingMapping)
|
||||
private studentDingMappingRepo: Repository<StudentDingMapping>,
|
||||
) {}
|
||||
|
||||
async findAll(query: QueryClassDto) {
|
||||
async getAccessibleClassIds(userId: number, canManageAll = false): Promise<number[] | undefined> {
|
||||
if (canManageAll) return undefined;
|
||||
const assignments = await this.classTeacherRepo.find({ where: { userId } });
|
||||
return [...new Set(assignments.map((assignment) => assignment.classId))];
|
||||
}
|
||||
|
||||
async assertClassAccess(userId: number, classId: number, canManageAll = false): Promise<void> {
|
||||
if (canManageAll) return;
|
||||
const assignment = await this.classTeacherRepo.findOne({ where: { userId, classId } });
|
||||
if (!assignment) throw new ForbiddenException('只能访问自己被分配的班级');
|
||||
}
|
||||
|
||||
async findAll(query: QueryClassDto, accessibleClassIds?: number[]) {
|
||||
let where: Record<string, unknown> = {};
|
||||
if (query.departmentId) where.departmentId = query.departmentId;
|
||||
if (query.status) where.status = query.status;
|
||||
if (query.classType) where.classType = query.classType;
|
||||
if (query.keyword) where.name = Like(`%${query.keyword}%`);
|
||||
where = await this.scope.filter(where);
|
||||
// Default: hide archived, unless explicitly requested
|
||||
where.isArchived = query.isArchived ?? false;
|
||||
|
||||
if (accessibleClassIds) {
|
||||
if (accessibleClassIds.length === 0) return [];
|
||||
where.id = In(accessibleClassIds);
|
||||
}
|
||||
|
||||
const classes = await this.classRepo.find({
|
||||
where,
|
||||
@@ -92,15 +134,23 @@ export class ClassesService {
|
||||
}
|
||||
|
||||
async create(dto: CreateClassDto) {
|
||||
const { studentIds, teachers, ...classData } = dto;
|
||||
const { studentIds, teachers, users, ...classData } = dto;
|
||||
|
||||
const cls = this.classRepo.create(classData);
|
||||
const cls = this.classRepo.create({
|
||||
...classData,
|
||||
startDate: normalizeDateOnly(classData.startDate) ?? undefined,
|
||||
endDate: normalizeDateOnly(classData.endDate) ?? undefined,
|
||||
});
|
||||
const saved = await this.classRepo.save(cls);
|
||||
|
||||
// add students
|
||||
if (studentIds?.length) {
|
||||
const entries = studentIds.map((sid: number) =>
|
||||
this.classStudentRepo.create({ classId: saved.id, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId: saved.id,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
await this.classStudentRepo.save(entries);
|
||||
}
|
||||
@@ -108,7 +158,12 @@ export class ClassesService {
|
||||
// add teachers
|
||||
if (teachers?.length) {
|
||||
const entries = teachers.map((t) =>
|
||||
this.classTeacherRepo.create({ classId: saved.id, userId: t.userId, roleType: t.roleType, subject: t.subject }),
|
||||
this.classTeacherRepo.create({
|
||||
classId: saved.id,
|
||||
userId: t.userId,
|
||||
roleType: t.roleType,
|
||||
subject: t.subject,
|
||||
}),
|
||||
);
|
||||
await this.classTeacherRepo.save(entries);
|
||||
|
||||
@@ -116,19 +171,123 @@ export class ClassesService {
|
||||
await this.syncClassTeacherIds(saved.id);
|
||||
}
|
||||
|
||||
// batch import students by dingUserIds
|
||||
if (users?.length) {
|
||||
await this.batchImportStudents(saved.id, users);
|
||||
}
|
||||
|
||||
return this.findOne(saved.id);
|
||||
}
|
||||
|
||||
async batchImportStudents(
|
||||
classId: number,
|
||||
users: Array<{
|
||||
dingUserId: string;
|
||||
name: string;
|
||||
mobile?: string;
|
||||
}>,
|
||||
): Promise<{ imported: number; skipped: number }> {
|
||||
const classEntity = await this.classRepo.findOne({ where: { id: classId } });
|
||||
if (!classEntity) throw new NotFoundException('班级不存在');
|
||||
|
||||
if (users.length === 0) return { imported: 0, skipped: 0 };
|
||||
|
||||
const dingUserIds = users.map((u) => u.dingUserId);
|
||||
|
||||
// 1. Fetch all existing ding mappings in one query
|
||||
const existingMappings = await this.studentDingMappingRepo.find({
|
||||
where: { dingUserId: In(dingUserIds) },
|
||||
});
|
||||
const dingToStudentId = new Map(existingMappings.map((m) => [m.dingUserId, m.studentId]));
|
||||
|
||||
// 2. Batch create students for new dingUserIds
|
||||
const newUsers = users.filter((u) => !dingToStudentId.has(u.dingUserId));
|
||||
if (newUsers.length > 0) {
|
||||
const newStudents = newUsers.map((u) =>
|
||||
this.studentRepo.create({
|
||||
name: u.name,
|
||||
phone: u.mobile || `dt_${u.dingUserId}`,
|
||||
status: 'active',
|
||||
}),
|
||||
);
|
||||
const savedStudents = await this.studentRepo.save(newStudents);
|
||||
|
||||
const newMappings = savedStudents.map((s, i) =>
|
||||
this.studentDingMappingRepo.create({ dingUserId: newUsers[i].dingUserId, studentId: s.id }),
|
||||
);
|
||||
await this.studentDingMappingRepo.save(newMappings);
|
||||
|
||||
for (let i = 0; i < newUsers.length; i++) {
|
||||
dingToStudentId.set(newUsers[i].dingUserId, savedStudents[i].id);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Fetch existing class-student links in one query
|
||||
const allStudentIds = Array.from(dingToStudentId.values());
|
||||
const alreadyInClass = new Set<number>();
|
||||
if (allStudentIds.length > 0) {
|
||||
const existingClassStudents = await this.classStudentRepo.find({
|
||||
where: { classId, studentId: In(allStudentIds) },
|
||||
});
|
||||
for (const cs of existingClassStudents) {
|
||||
alreadyInClass.add(cs.studentId);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Batch insert new class-student records
|
||||
const newClassStudents = allStudentIds
|
||||
.filter((sid) => !alreadyInClass.has(sid))
|
||||
.map((studentId) =>
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId,
|
||||
status: 'active',
|
||||
joinDate: new Date().toISOString().slice(0, 10),
|
||||
}),
|
||||
);
|
||||
|
||||
if (newClassStudents.length > 0) {
|
||||
await this.classStudentRepo.save(newClassStudents);
|
||||
}
|
||||
|
||||
return { imported: newClassStudents.length, skipped: alreadyInClass.size };
|
||||
}
|
||||
async update(id: number, dto: UpdateClassDto) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
await this.classRepo.update(id, dto);
|
||||
await this.classRepo.update(id, {
|
||||
...dto,
|
||||
...(dto.startDate !== undefined
|
||||
? { startDate: normalizeDateOnly(dto.startDate) ?? undefined }
|
||||
: {}),
|
||||
...(dto.endDate !== undefined
|
||||
? { endDate: normalizeDateOnly(dto.endDate) ?? undefined }
|
||||
: {}),
|
||||
});
|
||||
return this.findOne(id);
|
||||
}
|
||||
|
||||
/** 归档班级(软删除) */
|
||||
async archive(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
await this.classRepo.update(id, { isArchived: true });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** 取消归档 */
|
||||
async restore(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
await this.classRepo.update(id, { isArchived: false });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
/** 物理删除班级(已归档的才能删除) */
|
||||
async remove(id: number) {
|
||||
const cls = await this.classRepo.findOne({ where: { id } });
|
||||
if (!cls) throw new NotFoundException('班级不存在');
|
||||
if (!cls.isArchived) throw new BadRequestException('请先归档再删除');
|
||||
await this.classRepo.remove(cls);
|
||||
return { success: true };
|
||||
}
|
||||
@@ -149,7 +308,11 @@ export class ClassesService {
|
||||
const newIds = studentIds.filter((id) => !existingIds.has(id));
|
||||
|
||||
const entries = newIds.map((sid) =>
|
||||
this.classStudentRepo.create({ classId, studentId: sid, joinDate: new Date().toISOString().split('T')[0] }),
|
||||
this.classStudentRepo.create({
|
||||
classId,
|
||||
studentId: sid,
|
||||
joinDate: new Date().toISOString().split('T')[0],
|
||||
}),
|
||||
);
|
||||
if (entries.length) await this.classStudentRepo.save(entries);
|
||||
|
||||
@@ -174,7 +337,12 @@ export class ClassesService {
|
||||
});
|
||||
if (existing) throw new BadRequestException('该教师已分配此角色');
|
||||
|
||||
const entry = this.classTeacherRepo.create({ classId, userId: dto.userId, roleType: dto.roleType, subject: dto.subject });
|
||||
const entry = this.classTeacherRepo.create({
|
||||
classId,
|
||||
userId: dto.userId,
|
||||
roleType: dto.roleType,
|
||||
subject: dto.subject,
|
||||
});
|
||||
await this.classTeacherRepo.save(entry);
|
||||
|
||||
await this.syncClassTeacherIds(classId);
|
||||
@@ -187,18 +355,22 @@ export class ClassesService {
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async removeTeacherAssignment(classId: number, assignmentId: number) {
|
||||
await this.classTeacherRepo.delete({ id: assignmentId, classId });
|
||||
await this.syncClassTeacherIds(classId);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async syncClassTeacherIds(classId: number) {
|
||||
const teachers = await this.classTeacherRepo.find({ where: { classId } });
|
||||
const updates: Record<string, number> = {};
|
||||
const head = teachers.find((t) => t.roleType === 'head_teacher');
|
||||
const life = teachers.find((t) => t.roleType === 'life_teacher');
|
||||
const academic = teachers.find((t) => t.roleType === 'academic_teacher');
|
||||
if (head) updates.headTeacherId = head.userId;
|
||||
if (life) updates.lifeTeacherId = life.userId;
|
||||
if (academic) updates.academicTeacherId = academic.userId;
|
||||
if (Object.keys(updates).length > 0) {
|
||||
await this.classRepo.update(classId, updates);
|
||||
}
|
||||
await this.classRepo.update(classId, {
|
||||
headTeacherId: head?.userId ?? null,
|
||||
lifeTeacherId: life?.userId ?? null,
|
||||
academicTeacherId: academic?.userId ?? null,
|
||||
} as Partial<Class>);
|
||||
}
|
||||
|
||||
async getSchedule(classId: number, query: QueryClassScheduleDto) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { IsOptional, IsString, IsNotEmpty, IsInt, IsArray, IsDateString, IsEnum, ArrayNotEmpty, ValidateNested } from 'class-validator';
|
||||
import { Type, Transform } from 'class-transformer';
|
||||
import { ClassType, ClassStatus, TeacherRoleType } from '../../entities';
|
||||
|
||||
export class CreateClassDto {
|
||||
@@ -9,8 +9,6 @@ export class CreateClassDto {
|
||||
@IsString() @IsNotEmpty()
|
||||
code: string;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
departmentId?: number;
|
||||
|
||||
@IsEnum(ClassType) @IsString() @IsNotEmpty()
|
||||
classType: string;
|
||||
@@ -42,6 +40,12 @@ export class CreateClassDto {
|
||||
@IsOptional() @IsArray()
|
||||
studentIds?: number[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportUserItem)
|
||||
users?: ImportUserItem[];
|
||||
|
||||
@IsOptional() @IsArray()
|
||||
teachers?: Array<{ userId: number; roleType: string; subject?: string }>;
|
||||
}
|
||||
@@ -53,8 +57,6 @@ export class UpdateClassDto {
|
||||
@IsOptional() @IsString()
|
||||
code?: string;
|
||||
|
||||
@IsOptional() @IsInt()
|
||||
departmentId?: number;
|
||||
|
||||
@IsEnum(ClassType) @IsOptional() @IsString()
|
||||
classType?: string;
|
||||
@@ -85,10 +87,6 @@ export class UpdateClassDto {
|
||||
}
|
||||
|
||||
export class QueryClassDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
status?: string;
|
||||
@@ -98,6 +96,15 @@ export class QueryClassDto {
|
||||
|
||||
@IsOptional() @IsString()
|
||||
keyword?: string;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => {
|
||||
if (typeof value === 'boolean') return value;
|
||||
if (value === 'true' || value === '1') return true;
|
||||
if (value === 'false' || value === '0') return false;
|
||||
return value;
|
||||
})
|
||||
isArchived?: boolean;
|
||||
}
|
||||
|
||||
export class AddStudentsDto {
|
||||
@@ -131,3 +138,21 @@ export class QueryClassAttendanceSummaryDto {
|
||||
@IsOptional() @IsDateString()
|
||||
endDate?: string;
|
||||
}
|
||||
export class BatchImportStudentsDto {
|
||||
@IsArray()
|
||||
@ArrayNotEmpty()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ImportUserItem)
|
||||
users: ImportUserItem[];
|
||||
}
|
||||
|
||||
export class ImportUserItem {
|
||||
@IsString() @IsNotEmpty()
|
||||
dingUserId: string;
|
||||
|
||||
@IsString() @IsNotEmpty()
|
||||
name: string;
|
||||
|
||||
@IsOptional() @IsString()
|
||||
mobile?: string;
|
||||
}
|
||||
|
||||
@@ -36,13 +36,13 @@ export class ClassroomRentalsController {
|
||||
@RequirePermission('rental:view')
|
||||
findAll(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('tenantId') tenantId?: string,
|
||||
@Query('lesseeOrganizationId') lesseeOrganizationId?: string,
|
||||
@Query('month') month?: string,
|
||||
@Query('includeEnded') includeEnded?: string,
|
||||
) {
|
||||
return this.service.findAll({
|
||||
classroomId: classroomId ? +classroomId : undefined,
|
||||
tenantId: tenantId ? +tenantId : undefined,
|
||||
lesseeOrganizationId: lesseeOrganizationId ? +lesseeOrganizationId : undefined,
|
||||
month,
|
||||
includeEnded: includeEnded === 'true',
|
||||
});
|
||||
@@ -58,6 +58,41 @@ export class ClassroomRentalsController {
|
||||
return this.service.getSchedule(y, m);
|
||||
}
|
||||
|
||||
@Get('unavailable-dates')
|
||||
@RequirePermission('rental:view')
|
||||
getUnavailableDates(
|
||||
@Query('classroomId') classroomId?: string,
|
||||
@Query('year') year?: string,
|
||||
@Query('month') month?: string,
|
||||
@Query('excludeId') excludeId?: string,
|
||||
) {
|
||||
const parsedClassroomId = Number(classroomId);
|
||||
const parsedYear = Number(year);
|
||||
const parsedMonth = Number(month);
|
||||
if (!Number.isInteger(parsedClassroomId) || parsedClassroomId <= 0) {
|
||||
throw new BadRequestException('请选择有效教室');
|
||||
}
|
||||
if (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2100) {
|
||||
throw new BadRequestException('年份不合法');
|
||||
}
|
||||
if (!Number.isInteger(parsedMonth) || parsedMonth < 1 || parsedMonth > 12) {
|
||||
throw new BadRequestException('月份必须在 1-12 之间');
|
||||
}
|
||||
const parsedExcludeId = excludeId === undefined ? undefined : Number(excludeId);
|
||||
if (
|
||||
parsedExcludeId !== undefined &&
|
||||
(!Number.isInteger(parsedExcludeId) || parsedExcludeId <= 0)
|
||||
) {
|
||||
throw new BadRequestException('排除的租赁订单不合法');
|
||||
}
|
||||
return this.service.getUnavailableDates(
|
||||
parsedClassroomId,
|
||||
parsedYear,
|
||||
parsedMonth,
|
||||
parsedExcludeId,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('rental:view')
|
||||
findOne(@Param('id') id: string) {
|
||||
@@ -76,7 +111,7 @@ export class ClassroomRentalsController {
|
||||
action: '新增租赁',
|
||||
targetId: result.id,
|
||||
targetType: 'classroom-rental',
|
||||
detail: `教室${dto.classroomId} 租赁方${dto.tenantId} ${dto.startDate}~${dto.endDate}`,
|
||||
detail: `教室${dto.classroomId} 承租机构${dto.lesseeOrganizationId} ${dto.startDate}~${dto.endDate}`,
|
||||
ipAddress,
|
||||
userAgent,
|
||||
});
|
||||
|
||||
@@ -2,15 +2,17 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRentalsController } from './classroom-rentals.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([ClassroomRental, Classroom, Tenant, ClassSchedule]), OperationLogsModule, CommonModule],
|
||||
imports: [
|
||||
TypeOrmModule.forFeature([ClassroomRental, Classroom, Organization, ClassSchedule]),
|
||||
OperationLogsModule,
|
||||
],
|
||||
controllers: [ClassroomRentalsController],
|
||||
providers: [ClassroomRentalsService],
|
||||
exports: [ClassroomRentalsService],
|
||||
|
||||
@@ -1,13 +1,12 @@
|
||||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
import { ConflictException } from '@nestjs/common';
|
||||
import { Repository } from 'typeorm';
|
||||
import { Not, Repository } from 'typeorm';
|
||||
import { ClassroomRentalsService } from './classroom-rentals.service';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
|
||||
function mockQueryBuilder<T>(results: T[] = []) {
|
||||
@@ -29,11 +28,13 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{
|
||||
provide: getRepositoryToken(ClassroomRental),
|
||||
useValue: { createQueryBuilder: jest.fn() },
|
||||
},
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: {} },
|
||||
{ provide: getRepositoryToken(Organization), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { createQueryBuilder: jest.fn() } },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -43,7 +44,12 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
});
|
||||
|
||||
it('returns rental conflicts when no schedule conflicts exist', async () => {
|
||||
const rental = { id: 1, startDate: '2026-03-01', endDate: '2026-03-31', tenant: { name: 'A机构' } } as ClassroomRental;
|
||||
const rental = {
|
||||
id: 1,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
organization: { name: 'A机构' },
|
||||
} as ClassroomRental;
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([rental]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
@@ -58,12 +64,39 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
it('throws ConflictException when an active schedule overlaps the same classroom and date range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{ id: 5, subject: '数学', startDate: '2026-03-01', endDate: '2026-06-30' } as ClassSchedule,
|
||||
{
|
||||
id: 5,
|
||||
subject: '数学',
|
||||
weekDay: 1,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-06-30',
|
||||
} as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(ConflictException);
|
||||
await expect(service.findConflicts(1, '2026-03-15', '2026-04-15')).rejects.toThrow(
|
||||
ConflictException,
|
||||
);
|
||||
});
|
||||
|
||||
it('does not treat a weekly schedule as a conflict when its weekday does not occur in the rental range', async () => {
|
||||
const rentalQb = mockQueryBuilder<ClassroomRental>([]);
|
||||
const scheduleQb = mockQueryBuilder<ClassSchedule>([
|
||||
{
|
||||
id: 5,
|
||||
subject: '数学',
|
||||
weekDay: 1,
|
||||
startDate: '2026-07-01',
|
||||
endDate: '2026-07-31',
|
||||
} as ClassSchedule,
|
||||
]);
|
||||
(rentalRepo.createQueryBuilder as jest.Mock).mockReturnValue(rentalQb);
|
||||
(scheduleRepo.createQueryBuilder as jest.Mock).mockReturnValue(scheduleQb);
|
||||
|
||||
const result = await service.findConflicts(1, '2026-07-01', '2026-07-05');
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does not throw when schedule is outside the requested date range', async () => {
|
||||
@@ -89,15 +122,69 @@ describe('ClassroomRentalsService — findConflicts', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — unavailable dates', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<Pick<Repository<ClassroomRental>, 'find'>>;
|
||||
let scheduleRepo: jest.Mocked<Pick<Repository<ClassSchedule>, 'find'>>;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: { find: jest.fn() } },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: {} },
|
||||
{ provide: getRepositoryToken(Organization), useValue: {} },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: { find: jest.fn() } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
service = module.get<ClassroomRentalsService>(ClassroomRentalsService);
|
||||
rentalRepo = module.get(getRepositoryToken(ClassroomRental));
|
||||
scheduleRepo = module.get(getRepositoryToken(ClassSchedule));
|
||||
});
|
||||
|
||||
it('returns rental days and actual weekly schedule occurrence dates for a month', async () => {
|
||||
rentalRepo.find.mockResolvedValue([
|
||||
{ id: 10, startDate: '2026-07-03', endDate: '2026-07-04' } as ClassroomRental,
|
||||
]);
|
||||
scheduleRepo.find.mockResolvedValue([
|
||||
{ id: 5, weekDay: 1, startDate: '2026-07-01', endDate: '2026-07-31' } as ClassSchedule,
|
||||
]);
|
||||
|
||||
const result = await service.getUnavailableDates(1, 2026, 7);
|
||||
|
||||
expect(result).toEqual({
|
||||
dates: ['2026-07-03', '2026-07-04', '2026-07-06', '2026-07-13', '2026-07-20', '2026-07-27'],
|
||||
});
|
||||
});
|
||||
|
||||
it('excludes the rental being edited', async () => {
|
||||
rentalRepo.find.mockResolvedValue([]);
|
||||
scheduleRepo.find.mockResolvedValue([]);
|
||||
|
||||
await service.getUnavailableDates(1, 2026, 7, 99);
|
||||
|
||||
expect(rentalRepo.find).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ where: expect.objectContaining({ id: Not(99) }) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
let service: ClassroomRentalsService;
|
||||
let rentalRepo: jest.Mocked<
|
||||
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
|
||||
Pick<
|
||||
Repository<ClassroomRental>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
let classroomRepo: jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
|
||||
let tenantRepo: jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
|
||||
let organizationRepo: jest.Mocked<Pick<Repository<Organization>, 'findOne'>>;
|
||||
let scheduleRepo: jest.Mocked<
|
||||
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
|
||||
Pick<
|
||||
Repository<ClassSchedule>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
|
||||
beforeEach(async () => {
|
||||
@@ -109,12 +196,17 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
} as jest.Mocked<
|
||||
Pick<Repository<ClassroomRental>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
|
||||
Pick<
|
||||
Repository<ClassroomRental>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
|
||||
classroomRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Classroom>, 'findOne'>>;
|
||||
|
||||
tenantRepo = { findOne: jest.fn() } as jest.Mocked<Pick<Repository<Tenant>, 'findOne'>>;
|
||||
organizationRepo = { findOne: jest.fn() } as jest.Mocked<
|
||||
Pick<Repository<Organization>, 'findOne'>
|
||||
>;
|
||||
|
||||
scheduleRepo = {
|
||||
findOne: jest.fn(),
|
||||
@@ -124,7 +216,10 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
} as jest.Mocked<
|
||||
Pick<Repository<ClassSchedule>, 'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'>
|
||||
Pick<
|
||||
Repository<ClassSchedule>,
|
||||
'findOne' | 'save' | 'create' | 'update' | 'delete' | 'createQueryBuilder'
|
||||
>
|
||||
>;
|
||||
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
@@ -132,9 +227,8 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
ClassroomRentalsService,
|
||||
{ provide: getRepositoryToken(ClassroomRental), useValue: rentalRepo },
|
||||
{ provide: getRepositoryToken(Classroom), useValue: classroomRepo },
|
||||
{ provide: getRepositoryToken(Tenant), useValue: tenantRepo },
|
||||
{ provide: getRepositoryToken(Organization), useValue: organizationRepo },
|
||||
{ provide: getRepositoryToken(ClassSchedule), useValue: scheduleRepo },
|
||||
{ provide: CampusScope, useValue: { getScopeDepartmentIds: jest.fn().mockResolvedValue(null) } },
|
||||
],
|
||||
}).compile();
|
||||
|
||||
@@ -145,22 +239,43 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
it('saves the rental and creates a RENTAL class_schedule row', async () => {
|
||||
const dto: CreateRentalDto = {
|
||||
classroomId: 1,
|
||||
tenantId: 2,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
};
|
||||
const classroom = { id: 1, departmentId: 10 } as Classroom;
|
||||
const tenant = { id: 2, name: 'Tenant A' } as Tenant;
|
||||
const hostOrganization = {
|
||||
id: 1,
|
||||
name: 'Host',
|
||||
isHost: true,
|
||||
status: 'active',
|
||||
} as Organization;
|
||||
const organization = {
|
||||
id: 2,
|
||||
name: 'Organization A',
|
||||
isHost: false,
|
||||
status: 'active',
|
||||
} as Organization;
|
||||
|
||||
classroomRepo.findOne.mockResolvedValue(classroom);
|
||||
tenantRepo.findOne.mockResolvedValue(tenant);
|
||||
rentalRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassroomRental));
|
||||
rentalRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental));
|
||||
organizationRepo.findOne
|
||||
.mockResolvedValueOnce(hostOrganization)
|
||||
.mockResolvedValueOnce(organization);
|
||||
rentalRepo.create.mockImplementation(
|
||||
(entity) => ({ ...(entity as object) }) as ClassroomRental,
|
||||
);
|
||||
rentalRepo.save.mockImplementation((entity) =>
|
||||
Promise.resolve({ ...(entity as object), id: 1 } as ClassroomRental),
|
||||
);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
scheduleRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassSchedule>([]));
|
||||
scheduleRepo.findOne.mockResolvedValue(null);
|
||||
scheduleRepo.create.mockImplementation((entity) => ({ ...(entity as object) } as ClassSchedule));
|
||||
scheduleRepo.save.mockImplementation((entity) => Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule));
|
||||
scheduleRepo.create.mockImplementation(
|
||||
(entity) => ({ ...(entity as object) }) as ClassSchedule,
|
||||
);
|
||||
scheduleRepo.save.mockImplementation((entity) =>
|
||||
Promise.resolve({ ...(entity as object), id: 100 } as ClassSchedule),
|
||||
);
|
||||
|
||||
const result = await service.create(dto);
|
||||
|
||||
@@ -168,11 +283,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
expect(rentalRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
classroomId: 1,
|
||||
tenantId: 2,
|
||||
lessorOrganizationId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
departmentId: 10,
|
||||
}),
|
||||
);
|
||||
expect(scheduleRepo.create).toHaveBeenCalledWith(
|
||||
@@ -183,12 +298,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
endTime: '23:59',
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
subject: 'Tenant A 租赁',
|
||||
subject: 'Organization A 租赁',
|
||||
teacherId: null,
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: 1,
|
||||
status: 'active',
|
||||
departmentId: 10,
|
||||
}),
|
||||
);
|
||||
expect(scheduleRepo.save).toHaveBeenCalled();
|
||||
@@ -200,13 +314,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
const existingRental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
tenantId: 2,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
notes: '',
|
||||
departmentId: 10,
|
||||
tenant: { id: 2, name: 'Tenant A' } as Tenant,
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
classroom: { id: 1 } as Classroom,
|
||||
} as ClassroomRental;
|
||||
const updatedRental = {
|
||||
@@ -214,7 +327,12 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
} as ClassroomRental;
|
||||
const existingSchedule = { id: 50, rentalId: 1, scheduleType: 'RENTAL', classroomId: 1 } as ClassSchedule;
|
||||
const existingSchedule = {
|
||||
id: 50,
|
||||
rentalId: 1,
|
||||
scheduleType: 'RENTAL',
|
||||
classroomId: 1,
|
||||
} as ClassSchedule;
|
||||
|
||||
rentalRepo.findOne.mockResolvedValueOnce(existingRental).mockResolvedValueOnce(updatedRental);
|
||||
rentalRepo.createQueryBuilder.mockReturnValue(mockQueryBuilder<ClassroomRental>([]));
|
||||
@@ -237,7 +355,7 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
startDate: '2026-04-01',
|
||||
endDate: '2026-04-30',
|
||||
status: 'active',
|
||||
subject: 'Tenant A 租赁',
|
||||
subject: 'Organization A 租赁',
|
||||
}),
|
||||
);
|
||||
expect(scheduleRepo.create).not.toHaveBeenCalled();
|
||||
@@ -248,12 +366,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
tenantId: 2,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
departmentId: 10,
|
||||
tenant: { id: 2, name: 'Tenant A' } as Tenant,
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
const cancelledRental = { ...rental, status: 'cancelled' } as ClassroomRental;
|
||||
|
||||
@@ -274,12 +391,11 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
const rental = {
|
||||
id: 1,
|
||||
classroomId: 1,
|
||||
tenantId: 2,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-03-01',
|
||||
endDate: '2026-03-31',
|
||||
status: 'active',
|
||||
departmentId: 10,
|
||||
tenant: { id: 2, name: 'Tenant A' } as Tenant,
|
||||
lesseeOrganization: { id: 2, name: 'Organization A' } as Organization,
|
||||
} as ClassroomRental;
|
||||
|
||||
rentalRepo.findOne.mockResolvedValue(rental);
|
||||
@@ -291,3 +407,55 @@ describe('ClassroomRentalsService — rental schedule sync', () => {
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassroomRentalsService — organization roles', () => {
|
||||
it('stores explicit lessor and lessee organizations for a rental', async () => {
|
||||
const rentalRepo = {
|
||||
findOne: jest.fn(),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 1 })),
|
||||
create: jest.fn((value) => value),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassroomRental>([])),
|
||||
} as any;
|
||||
const classroomRepo = {
|
||||
findOne: jest.fn().mockResolvedValue({ id: 1, departmentId: 10 }),
|
||||
} as any;
|
||||
const organizationRepo = {
|
||||
findOne: jest
|
||||
.fn()
|
||||
.mockResolvedValueOnce({ id: 1, name: '本机构', isHost: true, status: 'active' })
|
||||
.mockResolvedValueOnce({ id: 2, name: '合作机构', isHost: false, status: 'active' }),
|
||||
} as any;
|
||||
const scheduleRepo = {
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
save: jest.fn(async (value) => ({ ...value, id: 100 })),
|
||||
create: jest.fn((value) => value),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
createQueryBuilder: jest.fn().mockReturnValue(mockQueryBuilder<ClassSchedule>([])),
|
||||
} as any;
|
||||
|
||||
const service = new ClassroomRentalsService(
|
||||
rentalRepo,
|
||||
classroomRepo,
|
||||
organizationRepo,
|
||||
scheduleRepo,
|
||||
);
|
||||
|
||||
await service.create({
|
||||
classroomId: 1,
|
||||
lessorOrganizationId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
startDate: '2026-08-01',
|
||||
endDate: '2026-08-31',
|
||||
} as any);
|
||||
|
||||
expect(rentalRepo.save).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
lessorOrganizationId: 1,
|
||||
lesseeOrganizationId: 2,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,17 @@ import {
|
||||
ConflictException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Repository, Not } from 'typeorm';
|
||||
import { Repository, Not, LessThanOrEqual, MoreThanOrEqual } from 'typeorm';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { Tenant } from '../entities/tenant.entity';
|
||||
import { Organization } from '../entities/organization.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CreateRentalDto, UpdateRentalDto } from './dto/rental.dto';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// 预设色板(与 tenants.service 保持一致,作为颜色兜底)
|
||||
// 预设色板(与 organizations.service 保持一致,作为颜色兜底)
|
||||
const COLOR_PALETTE = [
|
||||
'#ff7875',
|
||||
'#ffa940',
|
||||
@@ -31,13 +31,11 @@ const COLOR_PALETTE = [
|
||||
|
||||
@Injectable()
|
||||
export class ClassroomRentalsService {
|
||||
|
||||
constructor(
|
||||
@InjectRepository(ClassroomRental) private repo: Repository<ClassroomRental>,
|
||||
@InjectRepository(Classroom) private classroomRepo: Repository<Classroom>,
|
||||
@InjectRepository(Tenant) private tenantRepo: Repository<Tenant>,
|
||||
@InjectRepository(Organization) private organizationRepo: Repository<Organization>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
get uploadDir(): string {
|
||||
@@ -53,19 +51,18 @@ export class ClassroomRentalsService {
|
||||
|
||||
async findAll(query?: {
|
||||
classroomId?: number;
|
||||
tenantId?: number;
|
||||
lesseeOrganizationId?: number;
|
||||
month?: string;
|
||||
includeEnded?: boolean;
|
||||
}) {
|
||||
const scopeIds = await this.scope.getScopeDepartmentIds();
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.orderBy('r.startDate', 'DESC');
|
||||
if (scopeIds) qb.andWhere('r.departmentId IN (:...scopeIds)', { scopeIds });
|
||||
if (query?.classroomId) qb.andWhere('r.classroomId = :cid', { cid: query.classroomId });
|
||||
if (query?.tenantId) qb.andWhere('r.tenantId = :tid', { tid: query.tenantId });
|
||||
if (query?.lesseeOrganizationId)
|
||||
qb.andWhere('r.lesseeOrganizationId = :oid', { oid: query.lesseeOrganizationId });
|
||||
if (query?.month) {
|
||||
const [y, m] = query.month.split('-').map(Number);
|
||||
const first = `${y}-${String(m).padStart(2, '0')}-01`;
|
||||
@@ -78,11 +75,55 @@ export class ClassroomRentalsService {
|
||||
}
|
||||
|
||||
async findOne(id: number) {
|
||||
const rental = await this.repo.findOne({ where: { id }, relations: ['classroom', 'tenant'] });
|
||||
const rental = await this.repo.findOne({
|
||||
where: { id },
|
||||
relations: ['classroom', 'lessorOrganization', 'lesseeOrganization'],
|
||||
});
|
||||
if (!rental) throw new NotFoundException('租赁订单不存在');
|
||||
return rental;
|
||||
}
|
||||
|
||||
async getUnavailableDates(classroomId: number, year: number, month: number, excludeId?: number) {
|
||||
const lastDay = new Date(Date.UTC(year, month, 0)).getUTCDate();
|
||||
const monthStart = `${year}-${String(month).padStart(2, '0')}-01`;
|
||||
const monthEnd = `${year}-${String(month).padStart(2, '0')}-${String(lastDay).padStart(2, '0')}`;
|
||||
|
||||
const [rentals, schedules] = await Promise.all([
|
||||
this.repo.find({
|
||||
where: {
|
||||
...(excludeId ? { id: Not(excludeId) } : {}),
|
||||
classroomId,
|
||||
status: Not('cancelled'),
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
this.scheduleRepo.find({
|
||||
where: {
|
||||
classroomId,
|
||||
status: 'active',
|
||||
scheduleType: 'INTERNAL',
|
||||
startDate: LessThanOrEqual(monthEnd),
|
||||
endDate: MoreThanOrEqual(monthStart),
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const unavailableDates = new Set<string>();
|
||||
for (const rental of rentals) {
|
||||
this.addDateRange(
|
||||
unavailableDates,
|
||||
rental.startDate > monthStart ? rental.startDate : monthStart,
|
||||
rental.endDate < monthEnd ? rental.endDate : monthEnd,
|
||||
);
|
||||
}
|
||||
for (const schedule of schedules) {
|
||||
this.addScheduleOccurrences(unavailableDates, schedule, monthStart, monthEnd);
|
||||
}
|
||||
|
||||
return { dates: Array.from(unavailableDates).sort() };
|
||||
}
|
||||
|
||||
/**
|
||||
* 查找与给定区间冲突的租赁订单,同时检测同一教室同一日期段的内部排课
|
||||
* 重叠判定:start1 <= end2 AND start2 <= end1
|
||||
@@ -90,7 +131,7 @@ export class ClassroomRentalsService {
|
||||
async findConflicts(classroomId: number, startDate: string, endDate: string, excludeId?: number) {
|
||||
const qb = this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.where('r.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :end', { end: endDate })
|
||||
@@ -99,13 +140,17 @@ export class ClassroomRentalsService {
|
||||
const rentals = await qb.getMany();
|
||||
|
||||
// 检测同一教室同一日期段是否存在内部排课
|
||||
const scheduleConflicts = await this.scheduleRepo
|
||||
const scheduleCandidates = await this.scheduleRepo
|
||||
.createQueryBuilder('cs')
|
||||
.where('cs.classroomId = :cid', { cid: classroomId })
|
||||
.andWhere('cs.status = :status', { status: 'active' })
|
||||
.andWhere('cs.scheduleType = :scheduleType', { scheduleType: 'INTERNAL' })
|
||||
.andWhere('cs.startDate <= :end', { end: endDate })
|
||||
.andWhere('cs.endDate >= :start', { start: startDate })
|
||||
.getMany();
|
||||
const scheduleConflicts = scheduleCandidates.filter((schedule) =>
|
||||
this.hasScheduleOccurrence(schedule, startDate, endDate),
|
||||
);
|
||||
|
||||
if (scheduleConflicts.length > 0) {
|
||||
throw new ConflictException({
|
||||
@@ -114,7 +159,7 @@ export class ClassroomRentalsService {
|
||||
id: s.id,
|
||||
startDate: s.startDate,
|
||||
endDate: s.endDate,
|
||||
tenantName: `[内部排课] ${s.subject}`,
|
||||
organizationName: `[内部排课] ${s.subject}`,
|
||||
})),
|
||||
});
|
||||
}
|
||||
@@ -122,12 +167,74 @@ export class ClassroomRentalsService {
|
||||
return rentals;
|
||||
}
|
||||
|
||||
private hasScheduleOccurrence(
|
||||
schedule: ClassSchedule,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
): boolean {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return false;
|
||||
|
||||
const startUtc = this.toUtcDate(overlapStart);
|
||||
const endUtc = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = startUtc.getUTCDay() || 7;
|
||||
const daysUntilOccurrence = (schedule.weekDay - startWeekDay + 7) % 7;
|
||||
startUtc.setUTCDate(startUtc.getUTCDate() + daysUntilOccurrence);
|
||||
return startUtc <= endUtc;
|
||||
}
|
||||
|
||||
private toUtcDate(date: string): Date {
|
||||
const [year, month, day] = date.split('-').map(Number);
|
||||
return new Date(Date.UTC(year, month - 1, day));
|
||||
}
|
||||
|
||||
private addDateRange(dates: Set<string>, startDate: string, endDate: string) {
|
||||
const current = this.toUtcDate(startDate);
|
||||
const end = this.toUtcDate(endDate);
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 1);
|
||||
}
|
||||
}
|
||||
|
||||
private addScheduleOccurrences(
|
||||
dates: Set<string>,
|
||||
schedule: ClassSchedule,
|
||||
startDate: string,
|
||||
endDate: string,
|
||||
) {
|
||||
const overlapStart = schedule.startDate > startDate ? schedule.startDate : startDate;
|
||||
const overlapEnd = schedule.endDate < endDate ? schedule.endDate : endDate;
|
||||
if (overlapStart > overlapEnd) return;
|
||||
|
||||
const current = this.toUtcDate(overlapStart);
|
||||
const end = this.toUtcDate(overlapEnd);
|
||||
const startWeekDay = current.getUTCDay() || 7;
|
||||
current.setUTCDate(current.getUTCDate() + ((schedule.weekDay - startWeekDay + 7) % 7));
|
||||
while (current <= end) {
|
||||
dates.add(current.toISOString().slice(0, 10));
|
||||
current.setUTCDate(current.getUTCDate() + 7);
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateRentalDto, userId?: number) {
|
||||
if (dto.startDate > dto.endDate) throw new BadRequestException('起始日期不能晚于结束日期');
|
||||
const classroom = await this.classroomRepo.findOne({ where: { id: dto.classroomId } });
|
||||
if (!classroom) throw new NotFoundException('教室不存在');
|
||||
const tenant = await this.tenantRepo.findOne({ where: { id: dto.tenantId } });
|
||||
if (!tenant) throw new NotFoundException('租赁方不存在');
|
||||
const lessorOrganization = dto.lessorOrganizationId
|
||||
? await this.organizationRepo.findOne({
|
||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||
})
|
||||
: await this.organizationRepo.findOne({ where: { isHost: true, status: 'active' } });
|
||||
if (!lessorOrganization) throw new NotFoundException('出租机构不存在或未启用');
|
||||
const lesseeOrganization = await this.organizationRepo.findOne({
|
||||
where: { id: dto.lesseeOrganizationId, status: 'active' },
|
||||
});
|
||||
if (!lesseeOrganization) throw new NotFoundException('承租机构不存在或未启用');
|
||||
if (lessorOrganization.id === lesseeOrganization.id) {
|
||||
throw new BadRequestException('出租机构和承租机构不能相同');
|
||||
}
|
||||
|
||||
const conflicts = await this.findConflicts(dto.classroomId, dto.startDate, dto.endDate);
|
||||
if (conflicts.length > 0) {
|
||||
@@ -137,14 +244,19 @@ export class ClassroomRentalsService {
|
||||
id: c.id,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
tenantName: c.tenant?.name,
|
||||
organizationName: c.lesseeOrganization?.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
const rental = this.repo.create({ ...dto, createdBy: userId, status: 'active' });
|
||||
rental.departmentId = classroom.departmentId;
|
||||
const rental = this.repo.create({
|
||||
...dto,
|
||||
lessorOrganizationId: lessorOrganization.id,
|
||||
lesseeOrganizationId: lesseeOrganization.id,
|
||||
createdBy: userId,
|
||||
status: 'active',
|
||||
});
|
||||
const saved = await this.repo.save(rental);
|
||||
await this.syncScheduleFromRental(saved, tenant.name);
|
||||
await this.syncScheduleFromRental(saved, lesseeOrganization.name);
|
||||
return saved;
|
||||
}
|
||||
|
||||
@@ -164,11 +276,28 @@ export class ClassroomRentalsService {
|
||||
id: c.id,
|
||||
startDate: c.startDate,
|
||||
endDate: c.endDate,
|
||||
tenantName: c.tenant?.name,
|
||||
organizationName: c.lesseeOrganization?.name,
|
||||
})),
|
||||
});
|
||||
}
|
||||
}
|
||||
const newLessorId = dto.lessorOrganizationId ?? rental.lessorOrganizationId;
|
||||
const newLesseeId = dto.lesseeOrganizationId ?? rental.lesseeOrganizationId;
|
||||
if (newLessorId === newLesseeId) {
|
||||
throw new BadRequestException('出租机构和承租机构不能相同');
|
||||
}
|
||||
if (dto.lessorOrganizationId) {
|
||||
const lessor = await this.organizationRepo.findOne({
|
||||
where: { id: dto.lessorOrganizationId, status: 'active' },
|
||||
});
|
||||
if (!lessor) throw new NotFoundException('出租机构不存在或未启用');
|
||||
}
|
||||
if (dto.lesseeOrganizationId) {
|
||||
const lessee = await this.organizationRepo.findOne({
|
||||
where: { id: dto.lesseeOrganizationId, status: 'active' },
|
||||
});
|
||||
if (!lessee) throw new NotFoundException('承租机构不存在或未启用');
|
||||
}
|
||||
await this.repo.update(id, dto);
|
||||
const updated = await this.findOne(id);
|
||||
if (dto.status === 'cancelled') {
|
||||
@@ -201,8 +330,8 @@ export class ClassroomRentalsService {
|
||||
/**
|
||||
* 同步租赁订单到 class_schedules(schedule_type = 'RENTAL')
|
||||
*/
|
||||
private async syncScheduleFromRental(rental: ClassroomRental, tenantName?: string) {
|
||||
const name = tenantName || rental.tenant?.name || '租赁方';
|
||||
private async syncScheduleFromRental(rental: ClassroomRental, organizationName?: string) {
|
||||
const name = organizationName || rental.lesseeOrganization?.name || '承租机构';
|
||||
const weekDay = this.dateToWeekDay(rental.startDate);
|
||||
let schedule = await this.scheduleRepo.findOne({
|
||||
where: { rentalId: rental.id, scheduleType: 'RENTAL' },
|
||||
@@ -221,7 +350,6 @@ export class ClassroomRentalsService {
|
||||
rentalId: rental.id,
|
||||
status: 'active',
|
||||
notes: rental.notes,
|
||||
departmentId: rental.departmentId,
|
||||
};
|
||||
if (schedule) {
|
||||
await this.scheduleRepo.update(schedule.id, data);
|
||||
@@ -314,13 +442,13 @@ export class ClassroomRentalsService {
|
||||
});
|
||||
const rentals = await this.repo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoinAndSelect('r.tenant', 'tenant')
|
||||
.leftJoinAndSelect('r.lesseeOrganization', 'lesseeOrganization')
|
||||
.leftJoinAndSelect('r.classroom', 'classroom')
|
||||
.where('r.status != :cancelled', { cancelled: 'cancelled' })
|
||||
.andWhere('r.startDate <= :last AND r.endDate >= :first', { first, last })
|
||||
.getMany();
|
||||
|
||||
const tenantMap = new Map<number, any>();
|
||||
const organizationMap = new Map<number, any>();
|
||||
const matrix: Record<number, Record<number, any>> = {};
|
||||
const summary: Record<
|
||||
number,
|
||||
@@ -339,11 +467,13 @@ export class ClassroomRentalsService {
|
||||
const monthEnd = new Date(last);
|
||||
const effStart = start < monthStart ? monthStart : start;
|
||||
const effEnd = end > monthEnd ? monthEnd : end;
|
||||
if (rental.tenant && !tenantMap.has(rental.tenant.id)) {
|
||||
tenantMap.set(rental.tenant.id, {
|
||||
id: rental.tenant.id,
|
||||
name: rental.tenant.name,
|
||||
color: rental.tenant.color || COLOR_PALETTE[rental.tenant.id % COLOR_PALETTE.length],
|
||||
if (rental.lesseeOrganization && !organizationMap.has(rental.lesseeOrganization.id)) {
|
||||
organizationMap.set(rental.lesseeOrganization.id, {
|
||||
id: rental.lesseeOrganization.id,
|
||||
name: rental.lesseeOrganization.name,
|
||||
color:
|
||||
rental.lesseeOrganization.color ||
|
||||
COLOR_PALETTE[rental.lesseeOrganization.id % COLOR_PALETTE.length],
|
||||
});
|
||||
}
|
||||
for (let d = new Date(effStart); d <= effEnd; d.setDate(d.getDate() + 1)) {
|
||||
@@ -352,10 +482,11 @@ export class ClassroomRentalsService {
|
||||
matrix[rental.classroomId][day] = {
|
||||
scheduleType: 'RENTAL',
|
||||
rentalId: rental.id,
|
||||
tenantId: rental.tenantId,
|
||||
tenantName: rental.tenant?.name || '未知',
|
||||
organizationId: rental.lesseeOrganizationId,
|
||||
organizationName: rental.lesseeOrganization?.name || '未知',
|
||||
color:
|
||||
rental.tenant?.color || COLOR_PALETTE[(rental.tenantId || 0) % COLOR_PALETTE.length],
|
||||
rental.lesseeOrganization?.color ||
|
||||
COLOR_PALETTE[(rental.lesseeOrganizationId || 0) % COLOR_PALETTE.length],
|
||||
hasContract: !!rental.contractPath,
|
||||
};
|
||||
}
|
||||
@@ -373,8 +504,12 @@ export class ClassroomRentalsService {
|
||||
|
||||
for (const sched of schedules) {
|
||||
if (!sched.classroomId) continue;
|
||||
const schedStart = new Date(Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()));
|
||||
const schedEnd = new Date(Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()));
|
||||
const schedStart = new Date(
|
||||
Math.max(new Date(sched.startDate).getTime(), new Date(first).getTime()),
|
||||
);
|
||||
const schedEnd = new Date(
|
||||
Math.min(new Date(sched.endDate).getTime(), new Date(last).getTime()),
|
||||
);
|
||||
for (let d = new Date(schedStart); d <= schedEnd; d.setDate(d.getDate() + 1)) {
|
||||
const dow = d.getDay() === 0 ? 7 : d.getDay();
|
||||
if (dow !== sched.weekDay) continue;
|
||||
@@ -413,7 +548,7 @@ export class ClassroomRentalsService {
|
||||
capacity: c.capacity,
|
||||
supervisor: c.supervisor,
|
||||
})),
|
||||
tenants: Array.from(tenantMap.values()),
|
||||
organizations: Array.from(organizationMap.values()),
|
||||
matrix,
|
||||
summary,
|
||||
};
|
||||
|
||||
@@ -1,19 +1,15 @@
|
||||
import {
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsNotEmpty,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsEnum,
|
||||
IsDateString,
|
||||
} from 'class-validator';
|
||||
import { IsOptional, IsString, IsInt, IsNumber, IsEnum, IsDateString } from 'class-validator';
|
||||
|
||||
export class CreateRentalDto {
|
||||
@IsInt()
|
||||
classroomId: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tenantId: number;
|
||||
lessorOrganizationId?: number;
|
||||
|
||||
@IsInt()
|
||||
lesseeOrganizationId: number;
|
||||
|
||||
@IsDateString()
|
||||
startDate: string;
|
||||
@@ -41,7 +37,11 @@ export class UpdateRentalDto {
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
tenantId?: number;
|
||||
lessorOrganizationId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
lesseeOrganizationId?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
|
||||
@@ -211,8 +211,7 @@ export class ClassroomsController {
|
||||
supervisor: String(row.getCell(7).value || '') || undefined,
|
||||
});
|
||||
});
|
||||
const departmentId = req.headers?.['x-campus-id'] ? parseInt(String(req.headers['x-campus-id']), 10) || undefined : undefined;
|
||||
const result = await this.service.batchImport(rows, departmentId);
|
||||
const result = await this.service.batchImport(rows);
|
||||
await this.logService.log({
|
||||
userId: req.user?.id,
|
||||
username: req.user?.username,
|
||||
|
||||
@@ -6,10 +6,9 @@ import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { ClassroomsService } from './classrooms.service';
|
||||
import { ClassroomsController } from './classrooms.controller';
|
||||
import { OperationLogsModule } from '../operation-logs/operation-logs.module';
|
||||
import { CommonModule } from '../common/common.module';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule, CommonModule],
|
||||
imports: [TypeOrmModule.forFeature([Classroom, ClassroomRental, ClassSchedule]), OperationLogsModule],
|
||||
controllers: [ClassroomsController],
|
||||
providers: [ClassroomsService],
|
||||
exports: [ClassroomsService],
|
||||
|
||||
@@ -4,7 +4,6 @@ import { Repository, Not } from 'typeorm';
|
||||
import { Classroom } from '../entities/classroom.entity';
|
||||
import { ClassroomRental } from '../entities/classroom-rental.entity';
|
||||
import { ClassSchedule } from '../entities/class-schedule.entity';
|
||||
import { CampusScope } from '../common/campus-scope';
|
||||
import { CreateClassroomDto, UpdateClassroomDto } from './dto/classroom.dto';
|
||||
|
||||
@Injectable()
|
||||
@@ -14,7 +13,6 @@ export class ClassroomsService {
|
||||
@InjectRepository(Classroom) private repo: Repository<Classroom>,
|
||||
@InjectRepository(ClassroomRental) private rentalRepo: Repository<ClassroomRental>,
|
||||
@InjectRepository(ClassSchedule) private scheduleRepo: Repository<ClassSchedule>,
|
||||
private readonly scope: CampusScope,
|
||||
) {}
|
||||
|
||||
async findAll(query?: { building?: string; roomType?: string; includeArchived?: boolean }) {
|
||||
@@ -22,7 +20,7 @@ export class ClassroomsService {
|
||||
if (query?.building) where.building = query.building;
|
||||
if (query?.roomType) where.roomType = query.roomType;
|
||||
if (!query?.includeArchived) where.status = Not('archived');
|
||||
const list = await this.repo.find({ where: await this.scope.filter(where), order: { building: 'ASC', name: 'ASC' } });
|
||||
const list = await this.repo.find({ where, order: { building: 'ASC', name: 'ASC' } });
|
||||
const usageMap = await this.getCurrentUsageForClassrooms(list.map((c) => c.id));
|
||||
return list.map((c) => ({ ...c, currentUsage: usageMap.get(c.id) ?? null }));
|
||||
}
|
||||
@@ -37,9 +35,7 @@ export class ClassroomsService {
|
||||
async create(dto: CreateClassroomDto) {
|
||||
const exists = await this.repo.findOne({ where: { name: dto.name } });
|
||||
if (exists) throw new BadRequestException(`教室 ${dto.name} 已存在`);
|
||||
const entity = this.repo.create(dto);
|
||||
if (dto.departmentId) entity.departmentId = dto.departmentId;
|
||||
return this.repo.save(entity);
|
||||
return this.repo.save(this.repo.create(dto));
|
||||
}
|
||||
|
||||
async update(id: number, dto: UpdateClassroomDto) {
|
||||
@@ -101,7 +97,7 @@ export class ClassroomsService {
|
||||
|
||||
const rentals = await this.rentalRepo
|
||||
.createQueryBuilder('r')
|
||||
.leftJoin('Tenant', 't', 't.id = r.tenantId')
|
||||
.leftJoin('Organization', 't', 't.id = r.lesseeOrganizationId')
|
||||
.select('r.classroomId', 'classroomId')
|
||||
.addSelect('r.startDate', 'startDate')
|
||||
.addSelect('r.endDate', 'endDate')
|
||||
@@ -136,7 +132,6 @@ export class ClassroomsService {
|
||||
roomType?: string;
|
||||
courseType?: string;
|
||||
}[],
|
||||
departmentId?: number,
|
||||
) {
|
||||
let imported = 0;
|
||||
let skipped = 0;
|
||||
@@ -145,7 +140,7 @@ export class ClassroomsService {
|
||||
if (!row.name?.trim()) { skipped++; continue; }
|
||||
const exists = await this.repo.findOne({ where: { name: row.name.trim() } });
|
||||
if (exists) { errors.push(`教室 ${row.name} 已存在`); skipped++; continue; }
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30, departmentId: departmentId ?? undefined }));
|
||||
await this.repo.save(this.repo.create({ ...row, capacity: row.capacity || 30 }));
|
||||
imported++;
|
||||
}
|
||||
return { message: `成功导入 ${imported} 间教室,跳过 ${skipped} 间`, imported, skipped, errors: errors.length > 0 ? errors : undefined };
|
||||
|
||||
@@ -33,9 +33,6 @@ export class CreateClassroomDto {
|
||||
@IsString()
|
||||
notes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsInt()
|
||||
departmentId?: number;
|
||||
}
|
||||
|
||||
export class UpdateClassroomDto {
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
import { Injectable, NestMiddleware } from '@nestjs/common';
|
||||
import { Request, Response, NextFunction } from 'express';
|
||||
import { CampusScope, CampusRequest } from './campus-scope';
|
||||
|
||||
@Injectable()
|
||||
export class CampusScopeMiddleware implements NestMiddleware {
|
||||
constructor(private readonly scope: CampusScope) {}
|
||||
|
||||
use(req: Request, _res: Response, next: NextFunction): void {
|
||||
(req as CampusRequest).campusScope = this.scope;
|
||||
next();
|
||||
}
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
import { Injectable, Scope, Inject } from '@nestjs/common';
|
||||
import { REQUEST } from '@nestjs/core';
|
||||
import { In } from 'typeorm';
|
||||
import { Request } from 'express';
|
||||
import { DepartmentsService } from '../departments/departments.service';
|
||||
|
||||
export interface CampusRequest extends Request {
|
||||
user?: {
|
||||
id: number;
|
||||
isSuperAdmin?: boolean;
|
||||
};
|
||||
campusScope?: CampusScope;
|
||||
}
|
||||
|
||||
@Injectable({ scope: Scope.REQUEST })
|
||||
export class CampusScope {
|
||||
constructor(
|
||||
@Inject(REQUEST) private req: CampusRequest,
|
||||
private departmentsService: DepartmentsService,
|
||||
) {}
|
||||
|
||||
get userId(): number | undefined {
|
||||
return this.req.user?.id;
|
||||
}
|
||||
|
||||
get isSuperAdmin(): boolean {
|
||||
return this.req.user?.isSuperAdmin ?? false;
|
||||
}
|
||||
|
||||
get currentDepartmentId(): number | null {
|
||||
const raw = this.req.headers?.['x-campus-id'];
|
||||
if (raw === undefined) return null;
|
||||
const str = Array.isArray(raw) ? raw[0] : raw;
|
||||
if (!str) return null;
|
||||
const id = parseInt(str, 10);
|
||||
return Number.isNaN(id) ? null : id;
|
||||
}
|
||||
|
||||
/** Appends departmentId filter. No campus selected = no filtering for super admin; empty result for others. */
|
||||
async filter<T extends Record<string, unknown>>(where: T): Promise<T> {
|
||||
if (this.isSuperAdmin && !this.currentDepartmentId) {
|
||||
return where;
|
||||
}
|
||||
|
||||
const ids = await this.getEffectiveScopeIds();
|
||||
if (ids.length === 0) {
|
||||
// Non-super-admin with no scoping → match nothing, never leak unfiltered data
|
||||
if (!this.isSuperAdmin) {
|
||||
return { ...where, departmentId: In([]) };
|
||||
}
|
||||
return where;
|
||||
}
|
||||
|
||||
return { ...where, departmentId: In(ids) };
|
||||
}
|
||||
|
||||
/** Returns department IDs for QueryBuilder .andWhere(). null = no filtering. */
|
||||
async getScopeDepartmentIds(): Promise<number[] | null> {
|
||||
if (this.isSuperAdmin && !this.currentDepartmentId) return null;
|
||||
const ids = await this.getEffectiveScopeIds();
|
||||
return ids.length > 0 ? ids : null;
|
||||
}
|
||||
|
||||
private async getEffectiveScopeIds(): Promise<number[]> {
|
||||
if (this.currentDepartmentId) {
|
||||
return this.departmentsService.getDescendantIds(this.currentDepartmentId);
|
||||
}
|
||||
|
||||
if (!this.userId) return [];
|
||||
|
||||
const userDeptIds = await this.departmentsService.getUserDepartments(this.userId);
|
||||
const allIds = await Promise.all(
|
||||
userDeptIds.map((id) => this.departmentsService.getDescendantIds(id)),
|
||||
);
|
||||
return [...new Set(allIds.flat())];
|
||||
}
|
||||
}
|
||||
@@ -1,11 +0,0 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { CampusScope } from './campus-scope';
|
||||
import { CampusScopeMiddleware } from './campus-scope.middleware';
|
||||
import { DepartmentsModule } from '../departments/departments.module';
|
||||
|
||||
@Module({
|
||||
imports: [DepartmentsModule],
|
||||
providers: [CampusScope, CampusScopeMiddleware],
|
||||
exports: [CampusScope, CampusScopeMiddleware, DepartmentsModule],
|
||||
})
|
||||
export class CommonModule {}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user