Compare commits

...

119 Commits

Author SHA1 Message Date
8f0991a51f feat: replace tenants with organization management 2026-07-10 21:27:26 +08:00
8ed1682b90 fix permissions and teacher attendance workflows 2026-07-10 20:40:52 +08:00
247879f276 docs: add CodeGraph repository guidance
Document the preferred CodeGraph-first workflow for locating and understanding indexed code.
2026-07-10 14:13:07 +08:00
44105c1689 refactor: remove obsolete scaffolding and dead modules
Drop the unused root hello-world controller, empty CommonModule imports, development seeding code, legacy student report entity, stale DTOs, notification hook, and their placeholder tests.
2026-07-10 14:12:56 +08:00
b093d69f6a fix(server): correct archive filters and deposit access
Parse archived class query flags without Boolean string coercion, register the refund approval permission, and grant dorm managers the deposit permission group.
2026-07-10 14:12:44 +08:00
50b3608db4 fix(admin): repair class, role, and room views
Use the RBAC user endpoint for teacher selection, restore role table columns, correct Ant Design props, and add typed room bed and locker responses.
2026-07-10 14:12:08 +08:00
e26a8e816e fix(attendance): normalize DingTalk match statuses
Store and validate stable English status codes for unmatched, pending, and matched attendance records so import, filtering, and auto-match use the same values.
2026-07-10 14:11:55 +08:00
1ca4a4d185 fix(archive): serve student attachments securely
Download attachments through authenticated API requests, support configurable upload paths, validate resolved file locations, and retain compatibility with legacy stored paths.
2026-07-10 14:11:47 +08:00
55881863c1 feat(sync): sync class schedules to DingTalk students
Group active schedules by class, resolve enrolled students through DingTalk mappings, reuse shifts and attendance groups, and expose class-based sync status in the admin UI.
2026-07-10 14:11:39 +08:00
bc6b8b0095 fix: 修复入住管理白屏、重构首页工作台、拍平教务菜单
- Occupancies: 补齐缺失的 Alert import(渲染时 ReferenceError 导致白屏),
  并为两处 api.get 补泛型消除既有类型错误
- Dashboard: 改为待办优先的工作台(待办异常区 + 核心 KPI + 更多指标折叠 +
  图表按重要性重排),修复 fetchData 无限请求循环(loadedRef),
  重活图表用 IntersectionObserver(callback ref)懒加载
- MainLayout: 将三层嵌套的"教室管理"提升为一级菜单,全站菜单统一为两层
2026-07-10 11:16:30 +08:00
f6a84fd6b3 fix: assign each user to single leaf dept to prevent duplicates 2026-07-10 10:09:47 +08:00
fb54a6c466 fix: assign users to leaf departments only, handle multi-branch dept chains 2026-07-10 10:08:07 +08:00
3ccb878092 fix: fetch users from all sub-departments in syncAll and fetchOrgTreeWithUsers 2026-07-10 10:06:35 +08:00
3992f6c33f feat: implement fetchOrgTree and fetchOrgTreeWithUsers via dingtalk API 2026-07-10 10:04:59 +08:00
18060d44a8 fix: pass user name and mobile to backend when importing students 2026-07-10 09:50:45 +08:00
31bcd15189 fix: use real name and phone from frontend when importing students 2026-07-10 09:50:18 +08:00
b261ec5930 feat: change dingUserIds to users array with name/mobile in DTOs 2026-07-10 09:49:41 +08:00
1f9308c1b7 plan: fix student import name/phone from dingtalk 2026-07-10 09:47:26 +08:00
e7e335cd95 spec: fix student import name/phone from dingtalk 2026-07-10 09:45:43 +08:00
97c2ceccde fix: re-apply UX fixes on Bills, Expenses, Deposits
- Add Empty component to antd imports
- Replace console.error with message.error in catch blocks
- Add batchLoading state + guard on batch operations
- Add locale emptyText to Tables
2026-07-09 19:57:10 +08:00
8f28c172c4 fix: close unclosed fragments in Expenses/Deposits, fix brace in Bills 2026-07-09 19:52:33 +08:00
bc2ff2b270 fix: brace mismatch in Classes fetchData try/catch/finally 2026-07-09 19:49:21 +08:00
c59fd6ce92 fix(admin): UX improvements — silent fetch failures, empty states, batch loading guards, dashboard refresh
- Replace console.error-only catches with message.error user-facing notifications
  across Bills, Classes, ClassroomRentals, ClassroomSchedule, Classrooms, Deposits,
  Expenses, OperationLogs, Permissions, Roles, RoomVisual, Rooms, Students,
  Tenants, Users
- Add Empty component via Table locale prop on list pages: Bills, Classes,
  ClassroomRentals, Classrooms, Deposits, Expenses (room+personal), Occupancies,
  Rooms, Students, Tenants, Roles
- Add batchLoading state to batch delete/update operations: Bills (batchDelete,
  batchUpdateStatus), Expenses (batchDeleteRoom, batchDeletePersonal),
  Occupancies (batchCheckOut, batchDelete), Rooms (batchDelete),
  Students (batchDelete)
- Add refreshLoading indicator to Dashboard header when re-fetching data
- Consistent error pattern: catch (e: unknown) { const err = e as { message?: string }; message.error(...); }
2026-07-09 18:03:42 +08:00
6029d8e2fd refactor(server): remove Department/UserDepartment entities, CampusScope, and departmentId from all entities
- Delete department.entity.ts, user-department.entity.ts
- Remove Department/UserDepartment from entities/index.ts
- Remove departmentId column from 18 entities (AttendanceRecord, ArchiveAttachment, Bill, ClassSchedule, Classroom, ClassroomRental, Deposit, DepositInstallment, ExamScore, LearningRecord, Occupancy, PersonalExpense, ResultArchive, Room, RoomExpense, Student, StudentEnrollment, StudentProfile, StudentReport)
- Remove departments/ module entirely
- Delete campus-scope.ts, campus-scope.middleware.ts (request-utils.ts kept — it's just IP extraction)
- Simplify common.module.ts to empty module
- Remove CampusScopeMiddleware from app.module.ts
- Remove all CampusScope injections and filter calls across all services
- Remove departmentId from all DTOs and controllers
- Simplify dingtalk/wecom sync to only sync users (no dept table)
- Update seed module to remove department seeding
- Clean frontend compilation
2026-07-09 17:51:32 +08:00
b0f7883f33 feat(admin): remove all department/campus scoping from frontend
- Delete CampusSwitcher component and useCampus hook
- Delete Departments page and its route
- Remove campus header interceptor from API client
- Remove departmentId from Class interfaces
- Remove campusLocation from student profile
- Remove department permissions from test fixtures
- Remove currentCampusId from test cleanup

TypeScript compiles clean.
2026-07-09 17:40:57 +08:00
22bc1876d8 refactor: remove departmentId from Class entity 2026-07-09 17:32:37 +08:00
40fef906cd fix: resolve DingTalk dept ID to local department ID in class create 2026-07-09 17:28:12 +08:00
9b7a8e37b4 fix: resolve 5 review findings
Critical 1: Remove broken '钉钉绑定' tab (IntegConfig) — deleted /rbac/user-ding-mappings calls
Critical 2: Fix decorator placement in CreateClassDto — @IsOptional @IsArray restored to teachers
Important 1: Restore sync.service.spec.ts from 1fa3363, strip importDingTalkUsers tests
Important 2: Fix N+1 in batchImportStudents — batched find/save with In() operator
Important 3: Add migrate-student-ding-mapping.sql
2026-07-09 17:25:44 +08:00
1f63a53222 feat: rewrite sync drawer with checkable tree + class batch import 2026-07-09 17:16:08 +08:00
91035e9f2c feat: add batch-import students to class endpoint 2026-07-09 17:12:23 +08:00
7b8691866e refactor: remove User-based import and RBAC UserDingMapping endpoints 2026-07-09 17:10:33 +08:00
570ef7b7e9 revert: remove deepest-2-levels filter, restore full org tree 2026-07-09 17:07:22 +08:00
e87e5e2096 refactor: syncAll creates Student + StudentDingMapping directly 2026-07-09 17:05:54 +08:00
73c1ea7a76 fix: correct studentId-as-userId bugs in 3 files
- dingtalk.service.ts syncOneUser: find Student by userId before mapping,
  use Student.id (not User.id) as studentId FK
- sync.service.ts importDingTalkUsers: type coercion for studentId
  (method deleted in Task 4, minimal compile fix)
- schedule-sync.service.ts getStatus: remove broken teacher mapping
  query (teacher scheduling deprecated); hardcode mappedTeachers=0,
  totalTeachers=0
2026-07-09 17:04:35 +08:00
86f126671c fix: repair 5 sites where StudentDingMapping.studentId was misused as User FK
- dingtalk.service.ts syncOneUser: query studentRepo (not userRepo) by mapping.studentId
- sync.service.ts importDingTalkUsers: skip User role lookup for existing mappings
- attendance.service.ts autoMatchDingRecords: use studentId directly, remove second-hop query
- schedule-sync.service.ts syncAll: skip teacher mapping block (deprecated)
- rbac.service.ts getUnboundUsers: return [] (method deleted in Task 4)
2026-07-09 17:00:00 +08:00
ef1b46b9f4 refactor: replace UserDingMapping with StudentDingMapping entity 2026-07-09 16:53:25 +08:00
1fa336331c docs: student ding mapping + batch operations implementation plan 2026-07-09 16:49:38 +08:00
6762a7f661 docs: student ding mapping + drawer batch operations spec 2026-07-09 16:46:32 +08:00
c1d065a5e6 fix(students): remove staff role from status map 2026-07-09 16:32:02 +08:00
4592502876 feat(sync): add import request/result logging 2026-07-09 16:32:02 +08:00
6b232eb4ed fix(seed): only seed with SEED_DEV=true, not auto on dev env 2026-07-09 16:32:01 +08:00
c56f297f95 refactor: remove SeedModule from app imports (manual seed only) 2026-07-09 16:32:01 +08:00
b81a06e77c refactor(classes): remove CampusScope dependency 2026-07-09 16:32:01 +08:00
5145c3e4b4 fix(layout): use controlled sidebar openKeys to preserve manual expand/collapse across navigation 2026-07-09 16:31:56 +08:00
d1d5b4681b feat(schedules): replace teacher ID input with searchable user select, show teacher name, add schedule button 2026-07-09 16:31:53 +08:00
c928ee0f65 feat(rbac): add user-ding-mapping CRUD endpoints 2026-07-09 16:31:50 +08:00
4f3b5112b0 feat: filter org-tree-with-users to deepest 2 levels 2026-07-09 16:12:13 +08:00
0509175a57 feat: add getDeptDepthMap BFS method for org tree depth tracking 2026-07-09 16:08:53 +08:00
a6891b32d7 docs: implementation plan for org tree deepest levels filter 2026-07-09 16:07:26 +08:00
3ee1ebaa1e docs: org tree deepest levels only design spec 2026-07-09 16:06:09 +08:00
7e4f06058c fix(admin): match flat API response shape, no nested data envelope 2026-07-09 15:52:40 +08:00
64c8964ea4 fix(server): handle existing User from syncAll in importDingTalkUsers to avoid UNIQUE constraint error 2026-07-09 15:51:04 +08:00
2da2e68318 fix(admin): refactor inline map arrow to avoid oxc TSX parser confusion 2026-07-09 15:46:24 +08:00
d2d0ca31ad fix(admin): use type pattern consistent with rest of codebase (oxc compat) 2026-07-09 15:43:46 +08:00
867e1aab1f fix(admin): avoid TSX generic syntax to fix oxc parser in Vite 8 2026-07-09 15:41:30 +08:00
8bfb8bf7ae fix(admin): fix syntax error — missing closing parens in map after console.log removal 2026-07-09 15:39:03 +08:00
4d4db7626c chore: remove dead CampusScope references from attendance spec test 2026-07-09 15:35:59 +08:00
3240c7cfdd fix(server): restore @InjectRepository(UserDingMapping) accidentally removed with scope 2026-07-09 15:31:35 +08:00
7f355952fd fix(server): remove CampusScope from AttendanceService 2026-07-09 15:29:50 +08:00
897ffbe018 fix(server): restore staff exclusion in StudentsService.findAll default filter 2026-07-09 15:28:18 +08:00
9b36284f1a fix(server): remove CampusScope from StudentsService 2026-07-09 15:25:54 +08:00
b624c52b30 fix(admin): restore nested ImportUsersResponse and remove debug console.log 2026-07-09 15:24:54 +08:00
f7df3bdcf1 fix(admin): only show class-mark button on leaf departments with users 2026-07-09 15:18:23 +08:00
3ea7f9a2b7 docs: add DingTalk import fix implementation plan 2026-07-09 15:17:25 +08:00
a568d9a871 docs: add DingTalk import fix design spec 2026-07-09 15:15:56 +08:00
7666baceff fix: add missing Bed and Locker entities to TypeORM registration 2026-07-09 14:00:58 +08:00
db3e4aae84 fix(admin): display classCount and warnings in import response 2026-07-09 13:01:07 +08:00
eeacab6130 test(sync): add class-marking import test cases (7 new, 5 fixed) 2026-07-09 12:53:03 +08:00
7f42c12392 feat(admin): add class marking modal in DingTalk org import drawer 2026-07-09 12:41:29 +08:00
0f3fec6b44 fix(sync): remove unused classStudentRepo injection, use transactional manager for class lookup 2026-07-09 12:37:49 +08:00
00de6cbd3a feat(sync): importDingTalkUsers supports class creation and teacher/student linking 2026-07-09 12:33:10 +08:00
5b92521426 docs: remove 班主任 distinction — all teachers use roleType='teacher' 2026-07-09 12:24:35 +08:00
166066f49f feat(sync): add ImportClassItemDto and expose deptIds in org-tree-with-users 2026-07-09 12:21:39 +08:00
e90f73cb60 fix: broken Form.Item tag in check-in room select 2026-07-09 12:19:04 +08:00
74e6171e17 feat: add bed occupancy stats to RoomVisual cards and API 2026-07-09 12:18:30 +08:00
0d82c96ec6 docs: dingtalk import class marking design spec 2026-07-09 12:18:21 +08:00
ba94a0e091 feat: add bed/locker selection to check-in form and occupancy table 2026-07-09 12:16:51 +08:00
f959d2adf2 docs: add task-7 fix report 2026-07-09 12:13:21 +08:00
5700fa556b fix: restore RoomPage component wrapper and fix action column 2026-07-09 12:13:19 +08:00
e572f92017 feat: upgrade Room detail to Drawer with Bed/Locker tabs 2026-07-09 12:08:06 +08:00
454a0d24c6 fix: release beds/lockers in batchCheckOut
The batchCheckOut method was not releasing assigned beds and lockers
after checkout, leaving them orphaned as 'occupied'. Added the same
release pattern used in checkOut() — using runner.manager.update()
since batchCheckOut operates inside a QueryRunner transaction.
2026-07-09 12:05:36 +08:00
54d8d0545e feat: integrate Bed/Locker into Occupancy check-in/out flow 2026-07-09 12:02:43 +08:00
3bed3409fe feat: add Bed and Locker REST routes to RoomsController 2026-07-09 12:00:04 +08:00
79d6e97bd8 fix: restore CampusScope injection, fix batchCreate bed/locker numbering 2026-07-09 11:58:42 +08:00
12fa2e974f feat: add Bed and Locker CRUD to RoomsService 2026-07-09 11:55:37 +08:00
87a3f0c554 chore: remove unused IsArray/ArrayMinSize imports from Bed DTO 2026-07-09 11:54:24 +08:00
9b7dee17f3 feat: add Bed and Locker DTOs 2026-07-09 11:53:38 +08:00
a6fbb71251 feat: add bed_id and locker_id to Occupancy entity 2026-07-09 11:52:34 +08:00
513b6c11e4 fix: add @Unique constraints for Bed and Locker entities 2026-07-09 11:51:29 +08:00
ad6c303369 feat: add Bed and Locker entities with Room FK 2026-07-09 11:49:59 +08:00
36a1aca6f0 docs: bed/locker management implementation plan (10 tasks) 2026-07-09 11:48:29 +08:00
543fddbc1e docs: bed & locker management design spec 2026-07-09 11:44:24 +08:00
115ee5206e Merge branch 'feat/dingtalk-sync-role-selection' 2026-07-09 11:33:07 +08:00
fb8c897b4f fix: disable import button when roles not loaded (null defaultTeacherRoleId guard) 2026-07-09 11:32:01 +08:00
0cfdab95ee fix: add DTO validation, transactional imports, role-not-found handling, null role guard 2026-07-09 11:28:11 +08:00
33e6cb445c fix: parallel fetches, error handling, extract UserTreeNode component 2026-07-09 11:16:47 +08:00
a42039d1df feat: add sync users Tab with Drawer tree to IntegrationConfig 2026-07-09 11:13:05 +08:00
ff6cb03419 feat: add org-tree-with-users and import-users endpoints 2026-07-09 11:08:33 +08:00
8ba3327074 fix: handle duplicate usernames and failed role lookups in importDingTalkUsers 2026-07-09 11:07:25 +08:00
3790b2dabb refactor: remove sync and mark-staff buttons from Users page 2026-07-09 11:06:58 +08:00
cd7f9c90b3 feat: add importDingTalkUsers and org-tree-with-users to SyncService 2026-07-09 11:05:17 +08:00
3835110204 feat: add importDingTalkUsers and org-tree-with-users to SyncService 2026-07-09 11:04:24 +08:00
2b60a10927 feat: add fetchOrgTreeWithUsers to DingTalkService 2026-07-09 10:58:26 +08:00
5abc27c1f0 chore: add .worktrees to gitignore 2026-07-09 10:57:19 +08:00
3578ba92ed docs: implementation plan for dingtalk sync role selection 2026-07-09 10:56:24 +08:00
423472daf2 docs: dingtalk sync role selection design 2026-07-09 10:52:09 +08:00
40aad81087 fix: use rimraf for cross-platform clean command 2026-07-09 10:38:17 +08:00
129b011958 feat: add clean script to remove build artifacts, db, and node_modules 2026-07-09 10:37:53 +08:00
5ddd6d305a feat: ordered dev startup with concurrently + wait-on (server → admin) 2026-07-09 10:26:49 +08:00
731f74e51a refactor: remove scheduled cron sync, manual trigger only 2026-07-09 10:24:12 +08:00
e8dec3b171 feat: add DingTalk integration config page 2026-07-09 10:14:51 +08:00
7df7695073 fix: missing '>' in header div tag on Users page 2026-07-09 10:11:07 +08:00
903f090293 feat: add staff status filter in student management 2026-07-09 10:07:51 +08:00
e20e22fc65 feat: add mark-staff/restore-student buttons in user management 2026-07-09 10:07:34 +08:00
17ef1adcaa feat: Students list excludes staff by default 2026-07-09 10:07:04 +08:00
14eec2852a feat: add mark-staff/mark-student endpoints with studentStatus in user list 2026-07-09 10:06:31 +08:00
6e0ad8759f feat: syncOneUser skips Students with non-active status 2026-07-09 10:05:41 +08:00
6850777509 plan: student-role separation implementation (6 tasks) 2026-07-09 10:04:55 +08:00
9e828756ea spec: student-role separation design (batch-create + manual untag) 2026-07-09 10:01:57 +08:00
220 changed files with 15767 additions and 5036 deletions

View File

@@ -1,19 +0,0 @@
{
"permissions": {
"allow": [
"mcp__codegraph__*"
]
},
"hooks": {
"UserPromptSubmit": [
{
"hooks": [
{
"type": "command",
"command": "codegraph prompt-hook"
}
]
}
]
}
}

4
.gitignore vendored
View File

@@ -50,3 +50,7 @@ apps/server/dist/
apps/admin/node_modules/
apps/admin/dist/
.codegraph/
.worktrees/
.claude/
.omp/
.superpowers/

View File

@@ -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/` 下,每个模块独立目录

View File

@@ -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",

View File

@@ -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>

View File

@@ -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;
});

View 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));
}

View File

@@ -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;

View 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;

View File

@@ -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

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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 };
}

View File

@@ -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).

View File

@@ -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: [

View File

@@ -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>
);
};

View File

@@ -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[]),

View File

@@ -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="选择教师"

View File

@@ -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 }}
/>

View File

@@ -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="日租金(可选)">

View File

@@ -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));
});
});

View File

@@ -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>

View File

@@ -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

View File

@@ -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;

View File

@@ -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="暂无数据" /> }}
/>
</>
),

View File

@@ -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[]),

View 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;

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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>

View File

@@ -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]);

View 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;

View File

@@ -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));
}, []);

View File

@@ -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}
/>

View File

@@ -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>
)}

View File

@@ -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>
);

View File

@@ -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>
);
};

View File

@@ -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',
});
});
});

View 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'),
});

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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}

View File

@@ -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;

View File

@@ -47,7 +47,6 @@ export function logout(): void {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
}
// ── API helpers (authenticated) ─────────────────────────────────────

View File

@@ -18,7 +18,6 @@ afterEach(() => {
localStorage.removeItem('token');
localStorage.removeItem('user');
localStorage.removeItem('permissions');
localStorage.removeItem('currentCampusId');
});
export { BASE };

View 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;

View 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('请检查');
});
});

View 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),
};

View File

@@ -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,

View 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
);

View File

@@ -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!');
});
});
});

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -1,8 +0,0 @@
import { Injectable } from '@nestjs/common';
@Injectable()
export class AppService {
getHello(): string {
return 'Hello World!';
}
}

View File

@@ -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) {

View File

@@ -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],

View File

@@ -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);
}

View 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 },
);
});
});

View File

@@ -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.
*

View 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,
});
});
});

View File

@@ -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();
});
}
}
}

View File

@@ -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],

View File

@@ -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);
});
});

View File

@@ -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();

View File

@@ -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();
});
});

View File

@@ -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 {

View File

@@ -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;
}

View File

@@ -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;

View 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 }),
);
});
});

View File

@@ -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 };
// 获取角色名称列表

View File

@@ -0,0 +1,9 @@
import { SetMetadata } from '@nestjs/common';
export const AUTHENTICATED_KEY = 'authenticatedOnly';
/**
* 标记为“只需要登录”的接口:仍由全局 JwtAuthGuard 校验 JWT
* 但 PermissionGuard 不要求具体业务权限。
*/
export const Authenticated = () => SetMetadata(AUTHENTICATED_KEY, true);

View File

@@ -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;
}

View 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);
});
});

View File

@@ -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));
}
}

View 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,
);
});
});

View File

@@ -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,
};
}
}

View File

@@ -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],

View File

@@ -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,
]),
);

View File

@@ -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 });

View File

@@ -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(

View File

@@ -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],

View 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,
});
});

View File

@@ -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) {

View File

@@ -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;
}

View File

@@ -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,
});

View File

@@ -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],

View File

@@ -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,
}),
);
});
});

View File

@@ -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_schedulesschedule_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,
};

View File

@@ -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()

View File

@@ -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,

View File

@@ -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],

View File

@@ -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 };

View File

@@ -33,9 +33,6 @@ export class CreateClassroomDto {
@IsString()
notes?: string;
@IsOptional()
@IsInt()
departmentId?: number;
}
export class UpdateClassroomDto {

View File

@@ -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();
}
}

View File

@@ -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())];
}
}

View File

@@ -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