First commit

This commit is contained in:
2026-09-21 12:51:00 +08:00
commit 4fad31e438
145 changed files with 17216 additions and 0 deletions

13
.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
# 后端构建上下文忽略项:只保留 pom.xml 与 src
target/
data/
.idea/
.vscode/
.git/
.gitignore
*.iml
docs/
frontend/
README.md
Dockerfile
docker-compose.yml

2
.gitattributes vendored Normal file
View File

@@ -0,0 +1,2 @@
/mvnw text eol=lf
*.cmd text eol=crlf

40
.gitignore vendored Normal file
View File

@@ -0,0 +1,40 @@
target/
.mvn/wrapper/maven-wrapper.jar
!**/src/main/**/target/
!**/src/test/**/target/
### STS ###
.apt_generated
.classpath
.factorypath
.project
.settings
.springBeans
.sts4-cache
### IntelliJ IDEA ###
.idea
*.iws
*.iml
*.ipr
### NetBeans ###
/nbproject/private/
/nbbuild/
/dist/
/nbdist/
/.nb-gradle/
build/
!**/src/main/**/build/
!**/src/test/**/build/
### VS Code ###
.vscode/
### 前端 ###
frontend/node_modules/
frontend/dist/
frontend/.vite/
### H2 本地数据库文件 ###
data/

3
.mvn/wrapper/maven-wrapper.properties vendored Normal file
View File

@@ -0,0 +1,3 @@
wrapperVersion=3.3.4
distributionType=only-script
distributionUrl=https://repo.maven.apache.org/maven2/org/apache/maven/apache-maven/3.9.16/apache-maven-3.9.16-bin.zip

41
Dockerfile Normal file
View File

@@ -0,0 +1,41 @@
# =====================================================================
# 后端镜像:多阶段构建(Maven 编译 -> JRE 运行)
# 构建:docker build -t lab-backend .
# 运行:docker run -p 8080:8080 -e SPRING_PROFILES_ACTIVE=mariadb \
# -e DB_HOST=host.docker.internal -e DB_USER=lab -e DB_PASSWORD=lab123456 lab-backend
# =====================================================================
# ---------- 构建阶段 ----------
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /build
# 先只复制 pom,利用 Docker 层缓存预热依赖
COPY pom.xml .
RUN mvn -B -q -Dmaven.test.skip=true dependency:go-offline
# 再复制源码并打包(测试在 CI/本地执行,镜像构建跳过)
COPY src ./src
RUN mvn -B -q -Dmaven.test.skip=true package \
&& cp target/*.jar /build/app.jar
# ---------- 运行阶段 ----------
FROM eclipse-temurin:21-jre
WORKDIR /opt/app
# 以非 root 用户运行
RUN useradd -r -u 1001 appuser && chown -R appuser:appuser /opt/app
USER appuser
COPY --from=build --chown=appuser:appuser /build/app.jar /opt/app/app.jar
ENV TZ=Asia/Shanghai \
JAVA_OPTS="-Xms256m -Xmx512m" \
SPRING_PROFILES_ACTIVE=dev
EXPOSE 8080
# 容器存活探针:用 bash 的 /dev/tcp 探测端口,避免安装额外工具
HEALTHCHECK --interval=15s --timeout=5s --start-period=40s --retries=5 \
CMD bash -c 'cat < /dev/null > /dev/tcp/127.0.0.1/8080' || exit 1
ENTRYPOINT ["sh", "-c", "java $JAVA_OPTS -jar /opt/app/app.jar"]

171
README.md Normal file
View File

@@ -0,0 +1,171 @@
# 实验室预约与资源管理平台
面向学校实验室的场景化预约系统:统一管理**实验室**、**设备**与**实验课程**,支持
**单次预约 / 多次预约 / 课程绑定预约**三种业务模式,内置可测试的**时间冲突检测算法**、
**资源状态机**与**基于角色的访问控制(RBAC)**,并提供 Vue3 前端与日历视图。
## 技术栈
| 层次 | 技术 |
| --- | --- |
| 后端 | Spring Boot 4.1.1、MyBatis-Plus 3.5.17(XML 自定义 SQL + QueryWrapper)、JWT(jjwt)、BCrypt |
| 数据库 | H2(默认,免安装演示)/ MariaDB(`mariadb` Profile,schema 脚本已提供) |
| 测试 | JUnit 5、Mockito、MockMvc、AssertJ、JaCoCo(覆盖率门禁) |
| 前端 | Vue 3 + TypeScript + Vite + Element Plus + Pinia + Vue Router + Axios,包管理 pnpm |
## 快速开始
### 1. 启动后端(默认 H2 文件库,无需安装数据库)
```bash
./mvnw spring-boot:run
# 或者:mvn spring-boot:run
```
- 服务地址:http://localhost:8080
- H2 控制台:http://localhost:8080/h2-console (JDBC URL:`jdbc:h2:file:./data/labdb`,用户 `sa`,密码留空)
- 首次启动会自动建表并写入演示数据(`src/main/resources/db/schema.sql`、`data.sql`)
使用 MariaDB/MySQL 时:
```bash
# 先创建数据库:CREATE DATABASE lab DEFAULT CHARSET utf8mb4;
DB_HOST=localhost DB_USER=root DB_PASSWORD=yourpass \
mvn spring-boot:run -Dspring-boot.run.profiles=mariadb
```
### 2. 启动前端
```bash
cd frontend
pnpm install
pnpm dev # http://localhost:5173,已配置 /api 代理到 8080
pnpm build # 生产构建(vue-tsc 类型检查 + vite build)
```
### 3. Docker 一键部署(推荐用于演示/验收)
```bash
docker compose up -d --build # 构建并启动 mariadb + backend + frontend
# 前端:http://localhost (Nginx 托管静态资源并反代 /api)
# 后端:http://localhost:8080
docker compose ps # 查看健康状态
docker compose down # 停止容器,保留数据库数据卷
docker compose down -v # 停止并清空数据库(下次启动重建演示数据)
```
- 编排:`mariadb:11.4`(数据持久化到命名卷)+ 后端多阶段构建镜像(JRE 21,非 root 运行)+ Nginx 前端镜像
- 三个服务均配置健康检查,后端与前端会等待上游就绪后再启动
- 可用环境变量覆盖:`WEB_PORT`(默认 80)、`BACKEND_PORT`(默认 8080)、
`DB_NAME` / `DB_USER` / `DB_PASSWORD` / `DB_ROOT_PASSWORD`、`LAB_JWT_SECRET`
### 4. 演示账号
| 账号 | 密码 | 角色 | 典型能力 |
| --- | --- | --- | --- |
| `admin` | `admin123` | 管理员 | 全部资源与用户管理、审批、状态流转 |
| `teacher1` | `123456` | 教师 | 课程管理、发起课程预约、审批 |
| `student1` | `123456` | 学生 | 发起预约、查看/取消自己的预约 |
## 功能地图
- **认证与权限**:JWT 登录、`@RequireRole` 角色注解、拦截器统一鉴权,前端路由/菜单按角色过滤
- **实验室管理**:增删改查、状态机流转(空闲 / 已预约 / 使用中 / 维护中)、可用实验室推荐
- **设备管理**:增删改查、归属实验室校验、状态机流转
- **实验课程**:教师排课(起止日期 + 星期 + 时间段)、一键生成整期课程绑定预约
- **预约中心**:
- 单次预约(datetimerange)
- 多次预约(按周重复,自动拆分为系列场次)
- 课程绑定预约(按课程排课规则批量生成)
- 冲突预检、审批(通过/驳回)、取消(可整系列)、开始/完成使用
- **日历视图**:按实验室 + 月份展示预约分布,点击查看详情
- **自动任务**:每 10 分钟自动完结已过期的预约并同步资源状态
## 核心设计
### 冲突检测
分层设计:数据库层用索引粗筛(XML 自定义 SQL,可选 `FOR UPDATE` 行锁),
应用层 `ConflictDetector` 做精确判断。
- 区间语义:左闭右开 `[start, end)`,**首尾相接不算冲突**
- 规则校验:时间区间合法性、过去时间、提前预约上限、单次时长、开放时间/跨天、
资源维护状态、容量、课程绑定关系、请求内部重叠
- 冲突分类:`LAB_CONFLICT` / `DEVICE_CONFLICT` / `USER_CONFLICT` 等,返回多冲突明细
- 并发控制:同事务内 `SELECT ... FOR UPDATE` + 冲突复检,避免并发双写
详细说明见 [docs/design.md](docs/design.md)。
### 状态机
- 实验室/设备:`IDLE → RESERVED → IN_USE → IDLE`,`IDLE ↔ MAINTENANCE`,非法迁移抛 `3003`
- 预约:`PENDING → APPROVED → IN_USE → COMPLETED`,`PENDING → REJECTED`,
`PENDING/APPROVED → CANCELLED`,终态不可再流转
## 测试与覆盖率
```bash
mvn verify # 运行全部测试并执行 JaCoCo 覆盖率门禁
```
- 测试规模:**219** 个用例(19 个测试类)(冲突算法边界、状态机、服务层 Mockito、Mapper/接口集成)
- 覆盖率(核心包,排除实体/DTO/VO/配置):**行 98.1%,分支 92.8%**
- 门禁:行 ≥ 90%,分支 ≥ 85%,不达标 `mvn verify` 直接失败
- 报告:`target/site/jacoco/index.html`
## 目录结构
```
├── src/main/java/com/example/lab
│ ├── common/ # 统一响应、异常、注解、上下文、JWT
│ ├── config/ # MyBatis-Plus、Web、安全、业务参数
│ ├── controller/ # REST 接口
│ ├── domain/
│ │ ├── conflict/ # 时间复杂度可控的冲突检测与重复规则展开
│ │ ├── entity/ # 实体
│ │ ├── enums/ # 角色/状态/类型枚举
│ │ └── statemachine/# 通用枚举状态机
│ ├── dto/ vo/ # 请求与视图对象
│ ├── mapper/ # MyBatis-Plus Mapper
│ ├── security/ # 认证授权拦截器
│ └── service/ # 业务服务
├── src/main/resources
│ ├── db/ # H2 / MariaDB 建表与演示数据
│ └── mapper/ # XML 自定义 SQL
├── src/test/java/... # 单元测试 + 集成测试
├── frontend/ # Vue3 + Element Plus 前端
│ ├── Dockerfile # pnpm 构建 + Nginx 托管(含 /api 反向代理)
│ └── nginx.conf
├── Dockerfile # 后端多阶段构建镜像
├── docker-compose.yml # mariadb + backend + frontend 一键编排
└── docs/design.md # 需求分析与详细设计
```
## 主要接口
| 方法 | 路径 | 说明 | 权限 |
| --- | --- | --- | --- |
| POST | `/api/auth/login` | 登录 | 公开 |
| GET | `/api/auth/me` | 当前用户 | 登录 |
| GET | `/api/labs` / `/api/labs/available` | 实验室列表 / 可用推荐 | 登录 |
| POST/PUT/DELETE | `/api/labs/**` | 实验室管理 | 管理员 |
| GET | `/api/devices` / `/api/devices/by-lab/{id}` | 设备列表 | 登录 |
| POST/PUT/DELETE | `/api/devices/**` | 设备管理 | 管理员 |
| GET | `/api/courses` | 课程列表 | 登录 |
| POST/PUT/DELETE | `/api/courses/**` | 课程管理 | 管理员/教师 |
| POST | `/api/reservations` | 单次预约 | 登录 |
| POST | `/api/reservations/recurring` | 多次预约 | 登录 |
| POST | `/api/reservations/course` | 课程绑定预约 | 管理员/授课教师 |
| POST | `/api/reservations/check` | 冲突预检 | 登录 |
| GET | `/api/reservations` / `/{id}` / `/calendar` | 列表 / 详情 / 日历 | 登录(学生仅本人) |
| POST | `/api/reservations/{id}/approve\|reject` | 审批 | 管理员/教师 |
| POST | `/api/reservations/{id}/cancel\|start\|complete` | 取消/开始/完成 | 本人或管理员 |
| GET/POST/PUT/DELETE | `/api/users/**` | 用户管理 | 管理员 |
统一响应格式:
```json
{ "code": 0, "message": "success", "data": {} }
```
业务错误码:`0` 成功、`1xxx` 用户与认证、`2xxx` 资源管理、`3xxx` 预约与冲突、`4xx/5xx` 通用错误。

97
docker-compose.yml Normal file
View File

@@ -0,0 +1,97 @@
# =====================================================================
# 实验室预约与资源管理平台 - 一键部署编排
#
# 启动:docker compose up -d --build
# 访问:前端 http://localhost:${WEB_PORT:-80}
# 后端 http://localhost:${BACKEND_PORT:-8080}
# 停止:docker compose down (保留数据库数据卷)
# docker compose down -v (同时删除数据库数据,首次初始化会重建演示数据)
#
# 可通过环境变量覆盖:DB_NAME / DB_USER / DB_PASSWORD / DB_ROOT_PASSWORD /
# LAB_JWT_SECRET / WEB_PORT / BACKEND_PORT
# =====================================================================
name: lab-reservation
services:
# ---------- 数据库:MariaDB 11 ----------
mariadb:
image: mariadb:11.4
container_name: lab-mariadb
restart: unless-stopped
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD:-root123456}
MARIADB_DATABASE: ${DB_NAME:-lab}
MARIADB_USER: ${DB_USER:-lab}
MARIADB_PASSWORD: ${DB_PASSWORD:-lab123456}
TZ: Asia/Shanghai
# 统一字符集,保证中文与 emoji 正常存取
command:
- --character-set-server=utf8mb4
- --collation-server=utf8mb4_unicode_ci
volumes:
# 数据库数据持久化;首次启动由后端自动建表并写入演示数据
- mariadb-data:/var/lib/mysql
# 需要本地客户端连库调试时可打开端口映射
# ports:
# - "3306:3306"
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
networks:
- lab-net
# ---------- 后端:Spring Boot + MyBatis-Plus ----------
backend:
build:
context: .
dockerfile: Dockerfile
image: lab-backend:latest
container_name: lab-backend
restart: unless-stopped
environment:
# 使用 MariaDB Profile(对应 application-mariadb.yaml)
SPRING_PROFILES_ACTIVE: mariadb
DB_HOST: mariadb
DB_PORT: 3306
DB_NAME: ${DB_NAME:-lab}
DB_USER: ${DB_USER:-lab}
DB_PASSWORD: ${DB_PASSWORD:-lab123456}
# 生产部署请务必覆盖为随机密钥(长度 >= 32 字节)
LAB_JWT_SECRET: ${LAB_JWT_SECRET:-lab-reservation-docker-secret-key-change-me-2026}
TZ: Asia/Shanghai
JAVA_OPTS: "-Xms256m -Xmx512m"
depends_on:
mariadb:
condition: service_healthy
ports:
- "${BACKEND_PORT:-8080}:8080"
networks:
- lab-net
# ---------- 前端:Nginx + Vue3 静态资源 ----------
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
image: lab-frontend:latest
container_name: lab-frontend
restart: unless-stopped
depends_on:
backend:
condition: service_healthy
ports:
- "${WEB_PORT:-80}:80"
networks:
- lab-net
volumes:
# 命名卷保存 MariaDB 数据;docker compose down -v 可连同数据一起清理
mariadb-data:
networks:
lab-net:
driver: bridge

257
docs/design.md Normal file
View File

@@ -0,0 +1,257 @@
# 实验室预约与资源管理平台 - 需求分析与设计说明
## 1. 需求分析
### 1.1 用户角色与需求
| 角色 | 核心诉求 | 主要用例 |
| --- | --- | --- |
| 管理员 ADMIN | 保证资源台账准确、预约秩序可控 | 实验室/设备/课程的增删改查;资源状态流转(报修、启用);用户账号管理;审批任何预约;查看全局日历与统计 |
| 教师 TEACHER | 排课与科研实验,不想被冲突打乱 | 创建/维护自己的实验课程;按课程一键生成整期预约;审批学生预约;发起多次预约(如每周教研) |
| 学生 STUDENT | 快速找到可用资源并预约 | 查询实验室/设备可用性;发起单次/多次预约;查看与取消自己的预约;签到与完成 |
### 1.2 预约场景定义
| 场景 | 输入 | 系统行为 | 对应类型 |
| --- | --- | --- | --- |
| 单次预约 | 实验室(可选设备)、一个开始/结束时间 | 校验冲突后生成 1 条预约;学生进入待审批,教师/管理员直接通过 | `SINGLE` |
| 多次预约 | 日期区间 + 星期集合 + 每日时段 | 按周展开为多个场次,写入 1 条系列 + N 条预约;任一场次冲突则整批拒绝 | `RECURRING` |
| 课程绑定预约 | 课程 ID(可指定设备) | 按课程排课规则展开整期场次,绑定课程 ID;仅授课教师或管理员可发起 | `COURSE` |
**关键业务规则**
1. 冲突判定必须覆盖:同实验室、同设备、同申请人三个维度。
2. 学生预约需要审批;教师/管理员预约自动通过(仍要过冲突校验)。
3. 被驳回/取消/完成的预约不再占用资源。
4. 资源处于「维护中」不可预约;人数不得超过实验室容量。
5. 单次预约 ≤ 8 小时、最多提前 60 天、不得预约过去时间,且需落在实验室开放时间内。
6. 不允许跨天预约(结束时间为次日 00:00 除外)。
## 2. 领域模型
```
┌────────┐ 1 n ┌────────┐ 1 n ┌────────┐
│ Lab │─────────│ Device │ │ Course │
└───┬────┘ └────┬───┘ └───┬────┘
│ 1 │ n │ 1
│ │ │
│ n ┌───────┴─────────────────┘
┌───┴───────────┴───┐
│ Reservation │ n ────── 1 ┌──────────────────┐
│ (场次/时间区间) │ │ ReservationSeries │
└───┬───────────────┘ └──────────────────┘
│ n
│ 1
┌───┴────┐
│ User │
└────────┘
```
**设计要点**
- 「多次预约/课程预约」采用 **系列(Series)+ 场次(Reservation)** 两层结构:
每次实际占用都是一条独立 `reservation` 行,冲突检测、审批、状态流转
都只需处理统一的时间区间模型,避免规则分叉。
- 时间区间统一使用 **左闭右开** `[startTime, endTime)`。
- `reservation.version` 为乐观锁字段,防止并发审批互相覆盖。
### 2.1 数据表
| 表 | 关键字段 | 说明 |
| --- | --- | --- |
| `sys_user` | username(UK), password(BCrypt), role, enabled | 三类角色 |
| `lab` | code(UK), capacity, status, open_time, close_time | 开放时间参与冲突校验 |
| `device` | code(UK), lab_id, status | 设备必须归属实验室 |
| `course` | code(UK), teacher_id, lab_id, student_count, start/end_date, weekdays, start/end_time | 自带排课规则 |
| `reservation_series` | user_id, lab_id, device_id, type, weekdays, 起止日期/时间 | 重复规则原始记录 |
| `reservation` | lab_id, device_id, course_id, user_id, start/end_time, status, version | 冲突检测主体,建立 `(lab_id, start_time, end_time)` 等索引 |
## 3. 状态机模型
### 3.1 资源状态机(实验室 / 设备共用规则)
```mermaid
stateDiagram-v2
[*] --> IDLE
IDLE --> RESERVED: 预约通过
IDLE --> MAINTENANCE: 报修/维护
RESERVED --> IN_USE: 开始使用
RESERVED --> IDLE: 取消/驳回/过期
IN_USE --> IDLE: 完成使用
MAINTENANCE --> IDLE: 维护完成
```
| 起始 \ 目标 | IDLE | RESERVED | IN_USE | MAINTENANCE |
| --- | --- | --- | --- | --- |
| IDLE | — | ✅ | ❌(需经 RESERVED) | ✅ |
| RESERVED | ✅ | — | ✅ | ❌(需先释放) |
| IN_USE | ✅ | ❌(需先释放) | — | ❌ |
| MAINTENANCE | ✅ | ❌ | ❌ | — |
- 实现:`EnumStateMachine` 通用基类 + `LabStateMachine` / `DeviceStateMachine` 注册迁移表;
非法迁移抛出业务错误 `3003`。
- 资源状态由预约生命周期驱动:`ReservationService.syncLabStatus/syncDeviceStatus`
根据「是否存在 IN_USE / 占用态预约」推导目标状态,并沿状态机的合法路径推进
(如 `IDLE → RESERVED → IN_USE`)。
- 维护中的资源不会被状态同步覆盖,保证报修期间状态稳定。
### 3.2 预约状态机
```mermaid
stateDiagram-v2
[*] --> PENDING: 学生提交
[*] --> APPROVED: 教师/管理员提交
PENDING --> APPROVED: 审批通过
PENDING --> REJECTED: 驳回
PENDING --> CANCELLED: 本人/管理员取消
APPROVED --> IN_USE: 签到开始
APPROVED --> CANCELLED: 取消
APPROVED --> COMPLETED: 到期自动完结
IN_USE --> COMPLETED: 完成使用
REJECTED --> [*]
CANCELLED --> [*]
COMPLETED --> [*]
```
## 4. 预约冲突检测算法
### 4.1 规则清单
| 类型 | 触发条件 | 错误码 |
| --- | --- | --- |
| `INVALID_TIME_RANGE` | 时间段为空或 `end <= start` | 3002 |
| `PAST_TIME` | 开始时间早于 `now - 容差(30min)` | 3002 |
| `ADVANCE_LIMIT_EXCEEDED` | 开始时间晚于 `now + 60 天` | 3002 |
| `DURATION_EXCEEDED` | 单段时长 > 8 小时 | 3002 |
| `OUTSIDE_OPENING_HOURS` | 不在实验室开放时间内或跨天 | 3002 |
| `RESOURCE_UNAVAILABLE` | 实验室/设备处于维护中 | 3002 |
| `CAPACITY_EXCEEDED` | 人数 > 实验室容量 | 3002 |
| `COURSE_REQUIRED` / `COURSE_LAB_MISMATCH` / `COURSE_NOT_ACTIVE` | 课程绑定校验 | 3002 / 2203 |
| `INTERNAL_OVERLAP` | 请求内部多个时间段互相重叠 | 3002 |
| `LAB_CONFLICT` / `DEVICE_CONFLICT` / `USER_CONFLICT` | 与已有占用态预约重叠 | 3002 |
分类优先级:同实验室 > 同设备 > 同用户,一条已有预约最多产生一条冲突。
### 4.2 区间重叠算法
采用 **排序 + 双指针扫描**,而不是两两比较:
```
输入:requested[0..n)(请求时间段,乱序),existing[0..m)(占用态已有预约)
1. 过滤非法/被忽略/非占用状态的 existing,两侧各按 start 升序排序
2. pointer = 0
3. for each existing r:
4. while pointer < n and requested[pointer].end <= r.start: pointer++ // 完全在 r 之前的请求段
5. for j = pointer; j < n and requested[j].start < r.end; j++:
6. if r.overlaps(requested[j]): 记录冲突(j, r)
输出:冲突列表
```
- 重叠判定:`a.start < b.end && b.start < a.end`,因此 `10:00-12:00` 与
`12:00-14:00` **不算冲突**(首尾相接合法)。
- 复杂度:排序 `O((n+m) log(n+m))`,扫描近似线性;单次预约时 n=1,实际为一次索引查询。
- 由于 `existing` 按 start 单调递增,`pointer` 只前进不回退。
### 4.3 数据库层粗筛与并发控制
`resources/mapper/ReservationMapper.xml` 中的 `selectConflicting`:
```sql
SELECT ... FROM reservation
WHERE start_time < #{end} AND end_time > #{start} -- 区间重叠
AND status IN (...) -- 只取占用态
AND (lab_id = ? [OR device_id = ?] [OR user_id = ?]) -- 三个维度
[AND id <> #{ignoreId}] -- 更新/审批时排除自身
[FOR UPDATE] -- 并发写场景行锁
```
- 利用 `(lab_id, start_time, end_time)` 等索引把候选集缩小到极少量行,
精确判断交给无副作用的 `ConflictDetector`。
- 创建/审批在同一个 `@Transactional` 中先加锁查询、再检测、最后写入,
避免「先查后写」竞态;审批时还会排除自身做二次冲突复检,
防止多条待审批预约互相重叠。
### 4.4 边界情况(均有单元测试覆盖)
| 场景 | 期望 |
| --- | --- |
| 已有 `[9:00,11:00)`,请求 `[11:00,13:00)` | 不冲突(相接) |
| 已有 `[9:00,11:00)`,请求 `[10:59,13:00)` | 冲突 |
| 已有区间完全包含请求区间 | 冲突 |
| 请求时间段的 end == start | `INVALID_TIME_RANGE` |
| 开始时间等于 `now - 30min` | 允许(容差) |
| 开始时间等于 `now + 60 天` | 允许(边界含) |
| 时长恰好 8 小时 | 允许 |
| 跨天但结束于 00:00 | 允许 |
| 状态为 CANCELLED/REJECTED/COMPLETED 的预约 | 不参与冲突 |
| 重复规则命不中任何日期 / 超过 60 场 | 抛 `INVALID_RECURRENCE_RULE` |
| 请求内部两段互相重叠 | `INTERNAL_OVERLAP` |
## 5. RBAC 权限矩阵
| 操作 | 管理员 | 教师 | 学生 |
| --- | --- | --- | --- |
| 查看实验室/设备/课程/日历 | ✅ | ✅ | ✅ |
| 实验室/设备增删改、状态流转 | ✅ | ❌ | ❌ |
| 课程增删改(仅本人课程) | ✅(任意) | ✅ | ❌ |
| 发起单次/多次预约 | ✅(自动通过) | ✅(自动通过) | ✅(待审批) |
| 发起课程绑定预约 | ✅ | ✅(仅本人课程) | ❌ |
| 审批预约 | ✅(除自己) | ✅(除自己) | ❌ |
| 取消/开始/完成预约 | ✅(任意) | 仅本人 | 仅本人 |
| 查看预约列表 | 全部 | 全部 | 仅本人(强制过滤) |
| 用户管理 | ✅ | ❌ | ❌ |
实现:`@RequireRole` 注解 + `AuthInterceptor`(JWT 解析、角色校验),
服务层再做**数据级权限**(所有者校验、授课教师校验)。
## 6. 接口设计
统一响应:`{ code, message, data }`,`code = 0` 表示成功。
| 分组 | 接口 |
| --- | --- |
| 认证 | `POST /api/auth/login`、`GET /api/auth/me`、`POST /api/auth/logout` |
| 实验室 | `GET /api/labs`、`GET /api/labs/all`、`GET /api/labs/{id}`、`GET /api/labs/available`、`POST/PUT/DELETE`、`PATCH /{id}/status` |
| 设备 | `GET /api/devices`、`GET /api/devices/by-lab/{labId}`、`POST/PUT/DELETE`、`PATCH /{id}/status` |
| 课程 | `GET /api/courses`、`GET /api/courses/all`、`POST/PUT/DELETE`、`PATCH /{id}/finish` |
| 预约 | `POST /api/reservations`、`/recurring`、`/course`、`/check`;`GET /api/reservations`、`/{id}`、`/calendar`;`POST /{id}/approve|reject|cancel|start|complete` |
| 用户 | `GET/POST/PUT/DELETE /api/users/**` |
## 7. 测试设计
### 7.1 测试分层
| 层次 | 代表用例 | 目标 |
| --- | --- | --- |
| 算法单元测试 | `ConflictDetectorTest`(40 例)、`TimeSlotTest`、`RecurrenceExpanderTest` | 全规则 + 边界值,纯函数无数据库 |
| 状态机测试 | `StateMachineTest` | 全部合法/非法迁移与终态 |
| 服务层测试 | `ReservationServiceTest`(44 例)、`LabServiceTest` 等 | Mockito 隔离,覆盖权限、状态同步、异常分支 |
| Mapper 集成测试 | `ReservationMapperTest` | XML SQL 的区间语义、状态过滤、`FOR UPDATE` |
| 接口集成测试 | `ApiIntegrationTest`(12 例) | MockMvc + H2 全链路:登录、RBAC、CRUD、预约生命周期、日历 |
### 7.2 覆盖率(JaCoCo 门禁)
```
mvn verify
```
- 核心包(排除实体/DTO/VO/配置):**行覆盖 98.1%,分支覆盖 92.8%**
- 门禁:行 ≥ 90%、分支 ≥ 85%,不达标构建失败
- 报告位置:`target/site/jacoco/index.html`
## 8. 扩展功能:日历视图
- 后端 `GET /api/reservations/calendar?labId&from&to` 返回区间内预约(一次查询整月)。
- 前端「预约日历」页使用 Element Plus `el-calendar` 自定义日期单元格,
按状态着色(待审批/已通过/使用中/已完成),点击事件查看详情。
- 与冲突算法共用同一份数据模型,所见即校验依据。
## 9. 关键设计取舍
| 问题 | 方案 | 原因 |
| --- | --- | --- |
| 重复预约如何落库 | 系列 + 场次拆行 | 冲突检测/审批/日历统一为区间模型 |
| 冲突检测放数据库还是应用层 | 数据库索引粗筛 + 应用层精判 | 兼顾性能、可测试性与跨库兼容(MySQL 无排他约束) |
| 并发双写 | `FOR UPDATE` + 事务内复检 + 乐观锁 | 预约系统写冲突概率低但后果严重 |
| 权限控制 | JWT 无状态 + 注解拦截 + 服务层数据权限 | 部署简单,规则集中可测 |
| 状态一致性 | 显式状态机 + 资源状态推导 | 避免「有预约却空闲」的脏状态 |

4
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.vite/
Dockerfile

29
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,29 @@
# =====================================================================
# 前端镜像:pnpm 构建静态资源 -> Nginx 托管(同时反代 /api 到后端)
# 构建:docker build -t lab-frontend ./frontend
# =====================================================================
# ---------- 构建阶段 ----------
FROM node:24-alpine AS build
WORKDIR /app
# 使用与本地一致的 pnpm 主版本,锁定依赖保证可复现
RUN npm install -g pnpm@11.3.0
# 先装依赖,充分利用层缓存
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
# ---------- 运行阶段 ----------
FROM nginx:1.27-alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80
HEALTHCHECK --interval=15s --timeout=3s --start-period=10s --retries=3 \
CMD wget -q -O /dev/null http://127.0.0.1/ || exit 1

12
frontend/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>实验室预约与资源管理平台</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

35
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,35 @@
# Nginx 站点配置:SPA 路由回退 + /api 反向代理到后端容器
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# 静态资源压缩
gzip on;
gzip_min_length 1k;
gzip_types text/plain text/css application/javascript application/json image/svg+xml;
# 前端单页应用:刷新子路由时回退到 index.html
location / {
try_files $uri $uri/ /index.html;
}
# 后端接口:转发到 docker-compose 中的 backend 服务
location /api/ {
proxy_pass http://backend:8080;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 60s;
}
# 带哈希的静态资源长期缓存
location /assets/ {
expires 7d;
add_header Cache-Control "public, max-age=604800";
}
}

29
frontend/package.json Normal file
View File

@@ -0,0 +1,29 @@
{
"name": "lab-frontend",
"version": "1.0.0",
"private": true,
"type": "module",
"description": "实验室预约与资源管理平台前端",
"scripts": {
"dev": "vite",
"build": "vue-tsc --noEmit && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.9",
"dayjs": "^1.11.13",
"element-plus": "^2.9.1",
"pinia": "^2.3.0",
"vue": "^3.5.13",
"vue-router": "^4.5.0"
},
"devDependencies": {
"@types/node": "^22.10.2",
"@vitejs/plugin-vue": "^5.2.1",
"typescript": "^5.7.2",
"vite": "^6.0.5",
"vue-tsc": "^2.2.0"
}
}

1432
frontend/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,7 @@
packages:
- '.'
# pnpm 11 起,依赖构建脚本白名单统一配置在 workspace 文件中
allowBuilds:
esbuild: true
vue-demi: true

9
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,9 @@
<template>
<router-view />
</template>
<script setup lang="ts">
/**
* 根组件:仅承载路由出口。
*/
</script>

351
frontend/src/api/index.ts Normal file
View File

@@ -0,0 +1,351 @@
import { del, get, patch, post, put } from '@/api/request'
import type {
ApiResponse,
ConflictCheckVO,
Course,
Device,
Lab,
LoginResponse,
PageResult,
ReservationQuery,
ReservationVO,
UserVO
} from '@/types'
/** 认证接口 */
export const authApi = {
/**
* 登录。
*
* @param username 用户名
* @param password 密码
* @returns 登录响应
*/
login: (username: string, password: string) =>
post<LoginResponse>('/auth/login', { username, password }),
/**
* 查询当前用户。
*
* @returns 用户信息
*/
me: () => get<UserVO>('/auth/me'),
/**
* 登出(无状态令牌,前端自行清理)。
*/
logout: () => post<void>('/auth/logout')
}
/** 实验室接口 */
export const labApi = {
/**
* 分页查询实验室。
*
* @param params 查询参数
* @returns 分页结果
*/
page: (params: Record<string, unknown>) =>
get<PageResult<Lab>>('/labs', params),
/**
* 查询全部实验室。
*
* @returns 实验室列表
*/
all: () => get<Lab[]>('/labs/all'),
/**
* 查询单项时间段内可用实验室。
*
* @param start 开始时间
* @param end 结束时间
* @param capacity 最少容量
* @returns 可用实验室列表
*/
available: (start: string, end: string, capacity?: number) =>
get<Lab[]>('/labs/available', { start, end, capacity }),
/**
* 新增实验室。
*
* @param body 请求体
* @returns 新建实验室
*/
create: (body: Partial<Lab>) => post<Lab>('/labs', body),
/**
* 修改实验室。
*
* @param id 实验室 ID
* @param body 请求体
* @returns 更新后的实验室
*/
update: (id: number, body: Partial<Lab>) => put<Lab>(`/labs/${id}`, body),
/**
* 删除实验室。
*
* @param id 实验室 ID
*/
remove: (id: number) => del<void>(`/labs/${id}`),
/**
* 变更实验室状态。
*
* @param id 实验室 ID
* @param status 目标状态
* @returns 更新后的实验室
*/
changeStatus: (id: number, status: string) =>
patch<Lab>(`/labs/${id}/status`, { labStatus: status })
}
/** 设备接口 */
export const deviceApi = {
/**
* 分页查询设备。
*
* @param params 查询参数
* @returns 分页结果
*/
page: (params: Record<string, unknown>) => get<PageResult<Device>>('/devices', params),
/**
* 查询实验室下的设备。
*
* @param labId 实验室 ID
* @returns 设备列表
*/
byLab: (labId: number) => get<Device[]>(`/devices/by-lab/${labId}`),
/**
* 新增设备。
*
* @param body 请求体
* @returns 新建设备
*/
create: (body: Partial<Device>) => post<Device>('/devices', body),
/**
* 修改设备。
*
* @param id 设备 ID
* @param body 请求体
* @returns 更新后的设备
*/
update: (id: number, body: Partial<Device>) => put<Device>(`/devices/${id}`, body),
/**
* 删除设备。
*
* @param id 设备 ID
*/
remove: (id: number) => del<void>(`/devices/${id}`),
/**
* 变更设备状态。
*
* @param id 设备 ID
* @param status 目标状态
* @returns 更新后的设备
*/
changeStatus: (id: number, status: string) =>
patch<Device>(`/devices/${id}/status`, { deviceStatus: status })
}
/** 课程接口 */
export const courseApi = {
/**
* 分页查询课程。
*
* @param params 查询参数
* @returns 分页结果
*/
page: (params: Record<string, unknown>) => get<PageResult<Course>>('/courses', params),
/**
* 查询全部课程。
*
* @returns 课程列表
*/
all: () => get<Course[]>('/courses/all'),
/**
* 新增课程。
*
* @param body 请求体
* @returns 新建课程
*/
create: (body: Partial<Course>) => post<Course>('/courses', body),
/**
* 修改课程。
*
* @param id 课程 ID
* @param body 请求体
* @returns 更新后的课程
*/
update: (id: number, body: Partial<Course>) => put<Course>(`/courses/${id}`, body),
/**
* 结束课程。
*
* @param id 课程 ID
* @returns 更新后的课程
*/
finish: (id: number) => patch<Course>(`/courses/${id}/finish`),
/**
* 删除课程。
*
* @param id 课程 ID
*/
remove: (id: number) => del<void>(`/courses/${id}`)
}
/** 预约接口 */
export const reservationApi = {
/**
* 创建单次预约。
*
* @param body 请求体
* @returns 新建预约
*/
create: (body: Record<string, unknown>) => post<ReservationVO>('/reservations', body),
/**
* 创建多次预约。
*
* @param body 请求体
* @returns 生成的预约列表
*/
createRecurring: (body: Record<string, unknown>) =>
post<ReservationVO[]>('/reservations/recurring', body),
/**
* 创建课程绑定预约。
*
* @param body 请求体
* @returns 生成的预约列表
*/
createForCourse: (body: Record<string, unknown>) =>
post<ReservationVO[]>('/reservations/course', body),
/**
* 冲突预检。
*
* @param body 请求体
* @returns 预检结果
*/
check: (body: Record<string, unknown>) =>
post<ConflictCheckVO>('/reservations/check', body),
/**
* 分页查询预约。
*
* @param params 查询条件
* @returns 分页结果
*/
page: (params: ReservationQuery) =>
get<PageResult<ReservationVO>>('/reservations', params as Record<string, unknown>),
/**
* 查询预约详情。
*
* @param id 预约 ID
* @returns 预约详情
*/
detail: (id: number) => get<ReservationVO>(`/reservations/${id}`),
/**
* 查询实验室日历。
*
* @param labId 实验室 ID
* @param from 开始日期
* @param to 结束日期
* @returns 预约列表
*/
calendar: (labId: number, from: string, to: string) =>
get<ReservationVO[]>('/reservations/calendar', { labId, from, to }),
/**
* 审批通过。
*
* @param id 预约 ID
* @returns 更新后的预约
*/
approve: (id: number) => post<ReservationVO>(`/reservations/${id}/approve`),
/**
* 驳回预约。
*
* @param id 预约 ID
* @param reason 驳回原因
* @returns 更新后的预约
*/
reject: (id: number, reason: string) =>
post<ReservationVO>(`/reservations/${id}/reject`, { reason }),
/**
* 取消预约。
*
* @param id 预约 ID
* @param series 是否取消整个系列
* @returns 受影响的预约列表
*/
cancel: (id: number, series: boolean) =>
post<ReservationVO[]>(`/reservations/${id}/cancel?series=${series}`),
/**
* 开始使用。
*
* @param id 预约 ID
* @returns 更新后的预约
*/
start: (id: number) => post<ReservationVO>(`/reservations/${id}/start`),
/**
* 完成使用。
*
* @param id 预约 ID
* @returns 更新后的预约
*/
complete: (id: number) => post<ReservationVO>(`/reservations/${id}/complete`)
}
/** 用户管理接口 */
export const userApi = {
/**
* 查询全部用户。
*
* @returns 用户列表
*/
list: () => get<UserVO[]>('/users'),
/**
* 新增用户。
*
* @param body 请求体
* @returns 新建用户
*/
create: (body: Record<string, unknown>) => post<UserVO>('/users', body),
/**
* 修改用户。
*
* @param id 用户 ID
* @param body 请求体
* @returns 更新后的用户
*/
update: (id: number, body: Record<string, unknown>) => put<UserVO>(`/users/${id}`, body),
/**
* 删除用户。
*
* @param id 用户 ID
*/
remove: (id: number) => del<void>(`/users/${id}`)
}
export type { ApiResponse }

136
frontend/src/api/request.ts Normal file
View File

@@ -0,0 +1,136 @@
import axios, { type AxiosInstance, type AxiosRequestConfig } from 'axios'
import { ElMessage } from 'element-plus'
/** 令牌在 localStorage 中的键名 */
const TOKEN_KEY = 'lab-token'
/**
* 读取本地令牌。
*
* @returns JWT 字符串或空串
*/
export function getToken(): string {
return localStorage.getItem(TOKEN_KEY) ?? ''
}
/**
* 写入本地令牌。
*
* @param token JWT 字符串
*/
export function setToken(token: string): void {
localStorage.setItem(TOKEN_KEY, token)
}
/** 清除本地令牌 */
export function clearToken(): void {
localStorage.removeItem(TOKEN_KEY)
}
/** axios 实例,统一以 /api 为前缀(开发环境由 Vite 代理) */
const request: AxiosInstance = axios.create({
baseURL: '/api',
timeout: 15000
})
/**
* 请求拦截器:附加 Bearer 令牌。
*/
request.interceptors.request.use((config) => {
const token = getToken()
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
/**
* 响应拦截器:统一解包业务码,401 时跳转登录页。
*/
request.interceptors.response.use(
(response) => {
const body = response.data
if (body && typeof body.code === 'number' && body.code !== 0) {
ElMessage.error(body.message || '请求失败')
return Promise.reject(new Error(body.message))
}
return response
},
(error) => {
const status = error?.response?.status
if (status === 401) {
clearToken()
ElMessage.error('登录已失效,请重新登录')
if (window.location.pathname !== '/login') {
window.location.href = '/login'
}
} else if (status === 403) {
ElMessage.error('无权限执行该操作')
} else {
ElMessage.error(error?.response?.data?.message || error.message || '网络异常')
}
return Promise.reject(error)
}
)
/**
* 发起 GET 请求。
*
* @param url 接口路径
* @param params 查询参数
* @returns 业务数据
*/
export async function get<T>(url: string, params?: Record<string, unknown>): Promise<T> {
const { data } = await request.get(url, { params })
return data.data as T
}
/**
* 发起 POST 请求。
*
* @param url 接口路径
* @param body 请求体
* @param config 额外配置
* @returns 业务数据
*/
export async function post<T>(url: string, body?: unknown, config?: AxiosRequestConfig): Promise<T> {
const { data } = await request.post(url, body, config)
return data.data as T
}
/**
* 发起 PUT 请求。
*
* @param url 接口路径
* @param body 请求体
* @returns 业务数据
*/
export async function put<T>(url: string, body?: unknown): Promise<T> {
const { data } = await request.put(url, body)
return data.data as T
}
/**
* 发起 PATCH 请求。
*
* @param url 接口路径
* @param body 请求体
* @returns 业务数据
*/
export async function patch<T>(url: string, body?: unknown): Promise<T> {
const { data } = await request.patch(url, body)
return data.data as T
}
/**
* 发起 DELETE 请求。
*
* @param url 接口路径
* @returns 业务数据
*/
export async function del<T>(url: string): Promise<T> {
const { data } = await request.delete(url)
return data.data as T
}
export default request

7
frontend/src/env.d.ts vendored Normal file
View File

@@ -0,0 +1,7 @@
/// <reference types="vite/client" />
declare module '*.vue' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<Record<string, never>, Record<string, never>, unknown>
export default component
}

View File

@@ -0,0 +1,150 @@
<template>
<el-container class="layout">
<el-aside width="220px" class="aside">
<div class="logo">实验室预约平台</div>
<el-menu :default-active="activeMenu" router class="menu">
<el-menu-item v-for="item in menus" :key="item.path" :index="item.path">
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.title }}</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header class="header">
<div class="header-title">{{ currentTitle }}</div>
<el-dropdown @command="onCommand">
<span class="user-info">
<el-icon><UserFilled /></el-icon>
{{ auth.user?.realName || auth.user?.username }}
<el-tag size="small" type="info" class="role-tag">{{ auth.user?.roleLabel }}</el-tag>
</span>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="logout">退出登录</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</el-header>
<el-main class="main">
<router-view />
</el-main>
</el-container>
</el-container>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessageBox } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { Role } from '@/types'
interface MenuItem {
path: string
title: string
icon: string
roles?: Role[]
}
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
/** 全部菜单项(含角色限制) */
const allMenus: MenuItem[] = [
{ path: '/dashboard', title: '工作台', icon: 'HomeFilled' },
{ path: '/reservations/new', title: '发起预约', icon: 'Plus' },
{ path: '/reservations/mine', title: '我的预约', icon: 'Tickets' },
{ path: '/calendar', title: '预约日历', icon: 'Calendar' },
{ path: '/approvals', title: '预约审批', icon: 'Checked', roles: ['ADMIN', 'TEACHER'] },
{ path: '/labs', title: '实验室管理', icon: 'OfficeBuilding' },
{ path: '/devices', title: '设备管理', icon: 'Monitor' },
{ path: '/courses', title: '实验课程', icon: 'Notebook' },
{ path: '/users', title: '用户管理', icon: 'User', roles: ['ADMIN'] }
]
/** 按当前角色过滤后的菜单 */
const menus = computed(() =>
allMenus.filter((item) => !item.roles || auth.hasRole(...item.roles))
)
/** 当前激活菜单 */
const activeMenu = computed(() => route.path)
/** 当前页面标题 */
const currentTitle = computed(() => (route.meta.title as string) ?? '')
/**
* 处理用户下拉菜单命令。
*
* @param command 命令名
*/
async function onCommand(command: string): Promise<void> {
if (command !== 'logout') {
return
}
await ElMessageBox.confirm('确认退出登录吗?', '提示', { type: 'warning' })
await auth.logout()
router.push('/login')
}
</script>
<style scoped>
.layout {
height: 100%;
}
.aside {
background-color: #1f2d3d;
color: #fff;
}
.logo {
height: 60px;
line-height: 60px;
text-align: center;
font-size: 16px;
font-weight: 600;
color: #fff;
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
}
.menu {
border-right: none;
background-color: transparent;
--el-menu-text-color: #cfd8e3;
--el-menu-hover-bg-color: rgba(255, 255, 255, 0.08);
--el-menu-active-color: #409eff;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
background-color: #fff;
border-bottom: 1px solid #ebeef5;
}
.header-title {
font-size: 16px;
font-weight: 600;
}
.user-info {
display: inline-flex;
align-items: center;
gap: 6px;
cursor: pointer;
color: #303133;
}
.role-tag {
margin-left: 4px;
}
.main {
padding: 16px;
}
</style>

21
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,21 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import ElementPlus from 'element-plus'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import * as ElementPlusIconsVue from '@element-plus/icons-vue'
import 'element-plus/dist/index.css'
import App from './App.vue'
import router from './router'
import './styles/index.css'
const app = createApp(App)
// 注册全部 Element Plus 图标,模板中可直接使用 <el-icon><Calendar /></el-icon>
for (const [name, component] of Object.entries(ElementPlusIconsVue)) {
app.component(name, component)
}
app.use(createPinia())
app.use(router)
app.use(ElementPlus, { locale: zhCn })
app.mount('#app')

View File

@@ -0,0 +1,122 @@
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
import { ElMessage } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
import type { Role } from '@/types'
/**
* 路由表:meta.roles 为空表示登录即可访问。
*/
const routes: RouteRecordRaw[] = [
{
path: '/login',
name: 'login',
component: () => import('@/views/LoginView.vue'),
meta: { public: true, title: '登录' }
},
{
path: '/',
component: () => import('@/layouts/MainLayout.vue'),
redirect: '/dashboard',
children: [
{
path: 'dashboard',
name: 'dashboard',
component: () => import('@/views/DashboardView.vue'),
meta: { title: '工作台' }
},
{
path: 'labs',
name: 'labs',
component: () => import('@/views/LabView.vue'),
meta: { title: '实验室管理' }
},
{
path: 'devices',
name: 'devices',
component: () => import('@/views/DeviceView.vue'),
meta: { title: '设备管理' }
},
{
path: 'courses',
name: 'courses',
component: () => import('@/views/CourseView.vue'),
meta: { title: '实验课程' }
},
{
path: 'reservations/new',
name: 'reservation-create',
component: () => import('@/views/ReservationCreateView.vue'),
meta: { title: '发起预约' }
},
{
path: 'reservations/mine',
name: 'reservation-mine',
component: () => import('@/views/MyReservationsView.vue'),
meta: { title: '我的预约' }
},
{
path: 'approvals',
name: 'approvals',
component: () => import('@/views/ApprovalView.vue'),
meta: { title: '预约审批', roles: ['ADMIN', 'TEACHER'] as Role[] }
},
{
path: 'calendar',
name: 'calendar',
component: () => import('@/views/CalendarView.vue'),
meta: { title: '预约日历' }
},
{
path: 'users',
name: 'users',
component: () => import('@/views/UserView.vue'),
meta: { title: '用户管理', roles: ['ADMIN'] as Role[] }
}
]
},
{
path: '/:pathMatch(.*)*',
redirect: '/dashboard'
}
]
const router = createRouter({
history: createWebHistory(),
routes
})
/**
* 全局前置守卫:登录校验 + 角色校验。
*/
router.beforeEach(async (to) => {
const auth = useAuthStore()
if (to.meta.public) {
return true
}
if (!auth.loggedIn) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (!auth.user) {
try {
await auth.fetchMe()
} catch {
return { path: '/login' }
}
}
const roles = to.meta.roles as Role[] | undefined
if (roles && roles.length > 0 && !auth.hasRole(...roles)) {
ElMessage.error('无权限访问该页面')
return { path: '/dashboard' }
}
return true
})
/**
* 全局后置守卫:更新页面标题。
*/
router.afterEach((to) => {
const title = to.meta.title as string | undefined
document.title = title ? `${title} - 实验室预约平台` : '实验室预约平台'
})
export default router

View File

@@ -0,0 +1,68 @@
import { defineStore } from 'pinia'
import { computed, ref } from 'vue'
import { authApi } from '@/api'
import { clearToken, getToken, setToken } from '@/api/request'
import type { Role, UserVO } from '@/types'
/**
* 认证状态仓库:令牌与当前用户信息。
*/
export const useAuthStore = defineStore('auth', () => {
const token = ref<string>(getToken())
const user = ref<UserVO | null>(null)
/** 是否已登录 */
const loggedIn = computed(() => Boolean(token.value))
/** 当前角色 */
const role = computed<Role | ''>(() => user.value?.role ?? '')
/**
* 登录并保存令牌。
*
* @param username 用户名
* @param password 密码
*/
async function login(username: string, password: string): Promise<void> {
const result = await authApi.login(username, password)
token.value = result.token
user.value = result.user
setToken(result.token)
}
/**
* 拉取当前用户信息(刷新页面后恢复状态)。
*/
async function fetchMe(): Promise<void> {
if (!token.value) {
return
}
user.value = await authApi.me()
}
/**
* 判断当前用户是否属于给定角色集合。
*
* @param roles 允许的角色
* @returns 是否命中
*/
function hasRole(...roles: Role[]): boolean {
return user.value != null && roles.includes(user.value.role)
}
/**
* 退出登录并清理本地状态。
*/
async function logout(): Promise<void> {
try {
await authApi.logout()
} catch {
// 忽略登出接口异常,本地清理优先
}
token.value = ''
user.value = null
clearToken()
}
return { token, user, loggedIn, role, login, fetchMe, hasRole, logout }
})

View File

@@ -0,0 +1,27 @@
html,
body,
#app {
height: 100%;
margin: 0;
}
body {
font-family: 'Helvetica Neue', Arial, 'PingFang SC', 'Microsoft YaHei', sans-serif;
background-color: #f5f7fa;
}
.page-card {
margin-bottom: 16px;
}
.toolbar {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: center;
margin-bottom: 16px;
}
.toolbar .spacer {
flex: 1;
}

153
frontend/src/types/index.ts Normal file
View File

@@ -0,0 +1,153 @@
/** 后端统一响应包装 */
export interface ApiResponse<T> {
code: number
message: string
data: T
}
/** 分页结果 */
export interface PageResult<T> {
records: T[]
total: number
page: number
size: number
}
/** 用户角色 */
export type Role = 'ADMIN' | 'TEACHER' | 'STUDENT'
/** 用户信息 */
export interface UserVO {
id: number
username: string
realName: string
role: Role
roleLabel: string
email?: string
phone?: string
enabled: boolean
}
/** 实验室状态 */
export type LabStatus = 'IDLE' | 'RESERVED' | 'IN_USE' | 'MAINTENANCE'
/** 设备状态 */
export type DeviceStatus = 'IDLE' | 'RESERVED' | 'IN_USE' | 'MAINTENANCE'
/** 实验室 */
export interface Lab {
id: number
code: string
name: string
location: string
capacity: number
description?: string
status: LabStatus
openTime: string
closeTime: string
}
/** 设备 */
export interface Device {
id: number
labId: number
code: string
name: string
model?: string
description?: string
status: DeviceStatus
}
/** 课程状态 */
export type CourseStatus = 'ACTIVE' | 'FINISHED'
/** 实验课程 */
export interface Course {
id: number
code: string
name: string
teacherId: number
labId: number
studentCount: number
startDate: string
endDate: string
weekdays: string
startTime: string
endTime: string
status: CourseStatus
}
/** 预约类型 */
export type ReservationType = 'SINGLE' | 'RECURRING' | 'COURSE'
/** 预约状态 */
export type ReservationStatus =
| 'PENDING'
| 'APPROVED'
| 'REJECTED'
| 'CANCELLED'
| 'IN_USE'
| 'COMPLETED'
/** 预约视图对象 */
export interface ReservationVO {
id: number
seriesId?: number
userId: number
userName?: string
labId: number
labName?: string
deviceId?: number
deviceName?: string
courseId?: number
courseName?: string
type: ReservationType
typeLabel: string
title: string
purpose?: string
startTime: string
endTime: string
participants: number
status: ReservationStatus
statusLabel: string
rejectReason?: string
approverId?: number
approverName?: string
approvedAt?: string
createdAt?: string
}
/** 冲突明细 */
export interface Conflict {
type: string
message: string
slotIndex?: number
conflictReservationId?: number
}
/** 冲突预检结果 */
export interface ConflictCheckVO {
passed: boolean
conflicts: Conflict[]
}
/** 登录响应 */
export interface LoginResponse {
token: string
expiresIn: number
user: UserVO
}
/** 预约查询条件 */
export interface ReservationQuery {
page?: number
size?: number
status?: ReservationStatus | ''
type?: ReservationType | ''
labId?: number | ''
userId?: number
keyword?: string
from?: string
to?: string
mine?: boolean
}

View File

@@ -0,0 +1,107 @@
import dayjs from 'dayjs'
/** 状态 -> 中文标签 */
export const LAB_STATUS_LABELS: Record<string, string> = {
IDLE: '空闲',
RESERVED: '已预约',
IN_USE: '使用中',
MAINTENANCE: '维护中'
}
/** 预约状态 -> 标签颜色 */
export const RESERVATION_STATUS_TYPES: Record<string, string> = {
PENDING: 'warning',
APPROVED: 'primary',
REJECTED: 'danger',
CANCELLED: 'info',
IN_USE: 'success',
COMPLETED: ''
}
/** 资源状态 -> 标签颜色 */
export const RESOURCE_STATUS_TYPES: Record<string, string> = {
IDLE: 'success',
RESERVED: 'warning',
IN_USE: 'primary',
MAINTENANCE: 'danger'
}
/** 星期数字 -> 中文 */
const WEEKDAY_LABELS: Record<string, string> = {
MON: '周一',
TUE: '周二',
WED: '周三',
THU: '周四',
FRI: '周五',
SAT: '周六',
SUN: '周日'
}
/**
* 格式化日期时间。
*
* @param value ISO 时间字符串
* @returns yyyy-MM-dd HH:mm
*/
export function formatDateTime(value?: string): string {
return value ? dayjs(value).format('YYYY-MM-DD HH:mm') : '-'
}
/**
* 格式化时间(HH:mm),兼容 "HH:mm:ss" 与 "HH:mm"。
*
* @param value 时间字符串
* @returns HH:mm
*/
export function formatTime(value?: string): string {
if (!value) {
return '-'
}
const match = value.match(/(\d{2}):(\d{2})/)
return match ? `${match[1]}:${match[2]}` : value
}
/**
* 格式化星期描述,如 "1,3" -> "周一 / 周三"。
*
* @param value 星期字符串
* @returns 可读文本
*/
export function formatWeekdays(value?: string): string {
if (!value) {
return '-'
}
const numeric = ['MON', 'TUE', 'WED', 'THU', 'FRI', 'SAT', 'SUN']
return value
.split(',')
.map((token) => token.trim())
.filter(Boolean)
.map((token) => {
if (/^[1-7]$/.test(token)) {
return WEEKDAY_LABELS[numeric[Number(token) - 1]]
}
return WEEKDAY_LABELS[token.toUpperCase()] ?? token
})
.join(' / ')
}
/**
* 生成日期偏移字符串。
*
* @param days 偏移天数
* @returns yyyy-MM-dd
*/
export function dateOffset(days: number): string {
return dayjs().add(days, 'day').format('YYYY-MM-DD')
}
/**
* 生成日期时间字符串。
*
* @param days 偏移天数
* @param time HH:mm
* @returns yyyy-MM-ddTHH:mm:ss
*/
export function dateTimeOffset(days: number, time: string): string {
return dayjs().add(days, 'day').format('YYYY-MM-DD') + 'T' + time + ':00'
}

View File

@@ -0,0 +1,161 @@
<template>
<el-card>
<div class="toolbar">
<el-radio-group v-model="query.status" @change="load">
<el-radio-button label="">全部</el-radio-button>
<el-radio-button label="PENDING">待审批</el-radio-button>
<el-radio-button label="APPROVED">已通过</el-radio-button>
<el-radio-button label="REJECTED">已驳回</el-radio-button>
</el-radio-group>
<el-select v-model="query.labId" placeholder="全部实验室" clearable style="width: 200px" @change="load">
<el-option v-for="lab in labs" :key="lab.id" :label="lab.name" :value="lab.id" />
</el-select>
<el-input v-model="query.keyword" placeholder="标题关键字" clearable style="width: 180px" @keyup.enter="load" @clear="load" />
<el-button type="primary" @click="load">查询</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="userName" label="申请人" width="110" />
<el-table-column prop="labName" label="实验室" min-width="140" />
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
{{ formatDateTime(row.startTime) }} ~ {{ formatDateTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column prop="participants" label="人数" width="70" align="center" />
<el-table-column prop="typeLabel" label="类型" width="110" />
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="RESERVATION_STATUS_TYPES[row.status] as any">{{ row.statusLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<template v-if="row.status === 'PENDING'">
<el-button size="small" type="success" @click="approve(row)">通过</el-button>
<el-button size="small" type="danger" @click="openReject(row)">驳回</el-button>
</template>
<span v-else class="muted">已处理</span>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next, sizes"
:total="total"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[10, 20, 50]"
@current-change="load"
@size-change="load"
/>
<el-dialog v-model="rejectVisible" title="驳回预约" width="420px">
<el-form>
<el-form-item label="驳回原因" required>
<el-input v-model="rejectReason" type="textarea" :rows="3" placeholder="请输入驳回原因" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="rejectVisible = false">取消</el-button>
<el-button type="danger" @click="confirmReject">确认驳回</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { labApi, reservationApi } from '@/api'
import { RESERVATION_STATUS_TYPES, formatDateTime } from '@/utils/format'
import type { Lab, ReservationVO } from '@/types'
const loading = ref(false)
const rows = ref<ReservationVO[]>([])
const labs = ref<Lab[]>([])
const total = ref(0)
const rejectVisible = ref(false)
const rejectReason = ref('')
const rejectTarget = ref<ReservationVO | null>(null)
const query = reactive({ page: 1, size: 10, status: 'PENDING', labId: '' as number | '', keyword: '' })
/**
* 加载待审批列表。
*/
async function load(): Promise<void> {
loading.value = true
try {
const result = await reservationApi.page({
page: query.page,
size: query.size,
status: (query.status || undefined) as ReservationVO['status'] | undefined,
labId: query.labId || undefined,
keyword: query.keyword || undefined
})
rows.value = result.records
total.value = result.total
} finally {
loading.value = false
}
}
/**
* 审批通过。
*
* @param row 行数据
*/
async function approve(row: ReservationVO): Promise<void> {
await ElMessageBox.confirm(`确认通过「${row.title}」的预约申请吗?`, '审批', { type: 'info' })
await reservationApi.approve(row.id)
ElMessage.success('已通过')
await load()
}
/**
* 打开驳回对话框。
*
* @param row 行数据
*/
function openReject(row: ReservationVO): void {
rejectTarget.value = row
rejectReason.value = ''
rejectVisible.value = true
}
/**
* 提交驳回。
*/
async function confirmReject(): Promise<void> {
if (!rejectTarget.value) {
return
}
if (!rejectReason.value.trim()) {
ElMessage.warning('请填写驳回原因')
return
}
await reservationApi.reject(rejectTarget.value.id, rejectReason.value.trim())
ElMessage.success('已驳回')
rejectVisible.value = false
await load()
}
onMounted(async () => {
labs.value = await labApi.all()
await load()
})
</script>
<style scoped>
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.muted {
color: #c0c4cc;
font-size: 13px;
}
</style>

View File

@@ -0,0 +1,177 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="labId" placeholder="请选择实验室" style="width: 260px" @change="load">
<el-option
v-for="lab in labs"
:key="lab.id"
:label="`${lab.name}(${lab.location})`"
:value="lab.id"
/>
</el-select>
<el-tag v-for="(color, status) in LEGEND" :key="status" :type="color as any">
{{ STATUS_LABELS[status] }}
</el-tag>
<div class="spacer" />
<el-button @click="load">刷新</el-button>
</div>
<el-calendar v-model="currentDate" v-loading="loading">
<template #date-cell="{ data }">
<div class="cell" :class="{ today: data.isSelected }">
<div class="day">{{ data.day.split('-')[2] }}</div>
<div
v-for="item in eventsOf(data.day)"
:key="item.id"
class="event"
:class="item.status.toLowerCase()"
@click.stop="showDetail(item)"
>
{{ formatTime(item.startTime) }} {{ item.title }}
</div>
</div>
</template>
</el-calendar>
<el-dialog v-model="detailVisible" title="预约详情" width="440px">
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="标题">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="申请人">{{ detail.userName }}</el-descriptions-item>
<el-descriptions-item label="实验室">{{ detail.labName }}</el-descriptions-item>
<el-descriptions-item label="时间">
{{ formatDateTime(detail.startTime) }} ~ {{ formatDateTime(detail.endTime) }}
</el-descriptions-item>
<el-descriptions-item label="状态">
<el-tag :type="RESERVATION_STATUS_TYPES[detail.status] as any">{{ detail.statusLabel }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="用途">{{ detail.purpose || '-' }}</el-descriptions-item>
</el-descriptions>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import dayjs from 'dayjs'
import { labApi, reservationApi } from '@/api'
import { RESERVATION_STATUS_TYPES, formatDateTime, formatTime } from '@/utils/format'
import type { Lab, ReservationVO } from '@/types'
const STATUS_LABELS: Record<string, string> = {
PENDING: '待审批',
APPROVED: '已通过',
IN_USE: '使用中',
COMPLETED: '已完成'
}
const LEGEND: Record<string, string> = {
PENDING: 'warning',
APPROVED: 'primary',
IN_USE: 'success',
COMPLETED: 'info'
}
const labs = ref<Lab[]>([])
const labId = ref<number | undefined>(undefined)
const currentDate = ref(new Date())
const loading = ref(false)
const events = ref<ReservationVO[]>([])
const detailVisible = ref(false)
const detail = ref<ReservationVO | null>(null)
/** 当前展示月份,格式 yyyy-MM */
const month = computed(() => dayjs(currentDate.value).format('YYYY-MM'))
/**
* 加载选中月份的预约。
*/
async function load(): Promise<void> {
if (!labId.value) {
events.value = []
return
}
loading.value = true
try {
const start = dayjs(month.value + '-01')
const end = start.endOf('month')
events.value = await reservationApi.calendar(
labId.value,
start.format('YYYY-MM-DD'),
end.format('YYYY-MM-DD')
)
} finally {
loading.value = false
}
}
/**
* 查询某天的预约。
*
* @param day yyyy-MM-dd
* @returns 预约列表
*/
function eventsOf(day: string): ReservationVO[] {
return events.value.filter((item) => item.startTime.startsWith(day))
}
/**
* 显示预约详情。
*
* @param item 预约
*/
function showDetail(item: ReservationVO): void {
detail.value = item
detailVisible.value = true
}
watch(currentDate, load)
onMounted(async () => {
labs.value = await labApi.all()
if (labs.value.length > 0) {
labId.value = labs.value[0].id
await load()
}
})
</script>
<style scoped>
.cell {
height: 100%;
padding: 2px 4px;
overflow: hidden;
}
.day {
font-size: 12px;
color: #909399;
}
.event {
margin-top: 2px;
padding: 1px 4px;
border-radius: 3px;
font-size: 11px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
background-color: #ecf5ff;
color: #409eff;
}
.event.pending {
background-color: #fdf6ec;
color: #e6a23c;
}
.event.in_use {
background-color: #f0f9eb;
color: #67c23a;
}
.event.completed {
background-color: #f4f4f5;
color: #909399;
}
</style>

View File

@@ -0,0 +1,339 @@
<template>
<el-card>
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="按名称/编号搜索"
clearable
style="width: 220px"
@keyup.enter="load"
@clear="load"
/>
<el-select v-model="query.labId" placeholder="全部实验室" clearable style="width: 200px" @change="load">
<el-option v-for="lab in labs" :key="lab.id" :label="lab.name" :value="lab.id" />
</el-select>
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 150px" @change="load">
<el-option label="进行中" value="ACTIVE" />
<el-option label="已结束" value="FINISHED" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button v-if="canManage" type="success" @click="openDialog()">
<el-icon><Plus /></el-icon>&nbsp;新增课程
</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="code" label="编号" width="110" />
<el-table-column prop="name" label="课程名称" min-width="170" />
<el-table-column label="授课教师" width="110">
<template #default="{ row }">{{ userNames[row.teacherId] ?? `#${row.teacherId}` }}</template>
</el-table-column>
<el-table-column label="实验室" min-width="160">
<template #default="{ row }">{{ labName(row.labId) }}</template>
</el-table-column>
<el-table-column prop="studentCount" label="人数" width="70" align="center" />
<el-table-column label="排课" min-width="230">
<template #default="{ row }">
{{ row.startDate }} ~ {{ row.endDate }} / {{ formatWeekdays(row.weekdays) }}
{{ formatTime(row.startTime) }}~{{ formatTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column label="状态" width="100" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 'ACTIVE' ? 'success' : 'info'">
{{ row.status === 'ACTIVE' ? '进行中' : '已结束' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="320" fixed="right">
<template #default="{ row }">
<el-button size="small" type="primary" @click="generate(row)">生成预约</el-button>
<el-button v-if="canManage" size="small" @click="openDialog(row)">编辑</el-button>
<el-button v-if="canManage" size="small" type="warning" @click="finish(row)">结束</el-button>
<el-button v-if="canManage" size="small" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next, sizes"
:total="total"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[10, 20, 50]"
@current-change="load"
@size-change="load"
/>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑课程' : '新增课程'" width="560px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="100px">
<el-form-item label="课程编号" prop="code">
<el-input v-model="form.code" />
</el-form-item>
<el-form-item label="课程名称" prop="name">
<el-input v-model="form.name" />
</el-form-item>
<el-form-item label="绑定实验室" prop="labId">
<el-select v-model="form.labId" placeholder="请选择">
<el-option v-for="lab in labs" :key="lab.id" :label="`${lab.name}(容量 ${lab.capacity})`" :value="lab.id" />
</el-select>
</el-form-item>
<el-form-item label="选课人数" prop="studentCount">
<el-input-number v-model="form.studentCount" :min="1" />
</el-form-item>
<el-form-item label="起止日期" prop="startDate">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始" end-placeholder="结束" />
</el-form-item>
<el-form-item label="上课星期" prop="weekdays">
<el-select v-model="weekdayList" multiple placeholder="可多选">
<el-option v-for="(label, value) in WEEKDAY_OPTIONS" :key="value" :label="label" :value="value" />
</el-select>
</el-form-item>
<el-form-item label="上课时间" prop="startTime">
<el-time-picker v-model="form.startTime" format="HH:mm" value-format="HH:mm" placeholder="开始" />
<span class="time-sep">~</span>
<el-time-picker v-model="form.endTime" format="HH:mm" value-format="HH:mm" placeholder="结束" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
<el-dialog v-model="generateVisible" title="生成课程绑定预约" width="480px">
<el-alert
type="info"
:closable="false"
title="将按课程的排课规则一次性生成整期预约,系统会自动做冲突校验;如存在冲突会拒绝整批生成。"
/>
<el-form class="generate-form" label-width="90px">
<el-form-item label="设备(可选)">
<el-select v-model="generateDeviceId" clearable placeholder="不指定设备">
<el-option v-for="device in devices" :key="device.id" :label="device.name" :value="device.id" />
</el-select>
</el-form-item>
<el-form-item label="标题">
<el-input v-model="generateTitle" placeholder="默认使用课程名" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="generateVisible = false">取消</el-button>
<el-button type="primary" :loading="generating" @click="confirmGenerate">生成</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { courseApi, deviceApi, labApi, reservationApi, userApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { formatTime, formatWeekdays } from '@/utils/format'
import type { Course, Device, Lab, ReservationVO } from '@/types'
const WEEKDAY_OPTIONS: Record<string, string> = {
MON: '周一',
TUE: '周二',
WED: '周三',
THU: '周四',
FRI: '周五',
SAT: '周六',
SUN: '周日'
}
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<Course[]>([])
const labs = ref<Lab[]>([])
const devices = ref<Device[]>([])
const userNames = ref<Record<number, string>>({})
const total = ref(0)
const query = reactive({ page: 1, size: 10, keyword: '', labId: '' as number | '', status: '' })
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<Partial<Course>>({})
const dateRange = ref<[string, string] | null>(null)
const weekdayList = ref<string[]>([])
const generateVisible = ref(false)
const generating = ref(false)
const generateCourse = ref<Course | null>(null)
const generateDeviceId = ref<number | undefined>(undefined)
const generateTitle = ref('')
/** 是否具备课程管理权限 */
const canManage = computed(() => auth.hasRole('ADMIN', 'TEACHER'))
/** 表单校验规则 */
const rules: FormRules = {
code: [{ required: true, message: '请输入编号', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
labId: [{ required: true, message: '请选择实验室', trigger: 'change' }],
studentCount: [{ required: true, message: '请输入人数', trigger: 'blur' }],
startDate: [{ required: true, message: '请选择日期', trigger: 'change' }],
weekdays: [{ required: true, message: '请选择星期', trigger: 'change' }],
startTime: [{ required: true, message: '请选择时间', trigger: 'change' }]
}
/**
* 加载课程列表。
*/
async function load(): Promise<void> {
loading.value = true
try {
const result = await courseApi.page({
page: query.page,
size: query.size,
keyword: query.keyword || undefined,
labId: query.labId || undefined,
status: query.status || undefined
})
rows.value = result.records
total.value = result.total
} finally {
loading.value = false
}
}
/**
* 取实验室名称。
*
* @param labId 实验室 ID
* @returns 名称
*/
function labName(labId: number): string {
return labs.value.find((item) => item.id === labId)?.name ?? `#${labId}`
}
/**
* 打开新增/编辑对话框。
*
* @param row 行数据
*/
function openDialog(row?: Course): void {
Object.keys(form).forEach((key) => delete (form as Record<string, unknown>)[key])
if (row) {
Object.assign(form, row)
dateRange.value = [row.startDate, row.endDate]
weekdayList.value = row.weekdays.split(',').map((token) => token.trim()).filter(Boolean)
} else {
form.studentCount = 30
form.startTime = '10:00'
form.endTime = '12:00'
dateRange.value = null
weekdayList.value = []
}
dialogVisible.value = true
}
/**
* 保存课程。
*/
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid || !dateRange.value || weekdayList.value.length === 0) {
ElMessage.warning('请完整填写排课信息')
return
}
const payload = {
...form,
startDate: dateRange.value[0],
endDate: dateRange.value[1],
weekdays: weekdayList.value.join(',')
}
if (form.id) {
await courseApi.update(form.id, payload)
ElMessage.success('修改成功')
} else {
await courseApi.create(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await load()
}
/**
* 结束课程。
*
* @param row 行数据
*/
async function finish(row: Course): Promise<void> {
await ElMessageBox.confirm(`确认结束课程「${row.name}」吗?`, '提示', { type: 'warning' })
await courseApi.finish(row.id)
ElMessage.success('课程已结束')
await load()
}
/**
* 删除课程。
*
* @param row 行数据
*/
async function remove(row: Course): Promise<void> {
await ElMessageBox.confirm(`确认删除课程「${row.name}」吗?`, '提示', { type: 'warning' })
await courseApi.remove(row.id)
ElMessage.success('删除成功')
await load()
}
/**
* 打开生成课程预约对话框。
*
* @param row 课程
*/
async function generate(row: Course): Promise<void> {
generateCourse.value = row
generateDeviceId.value = undefined
generateTitle.value = ''
devices.value = await deviceApi.byLab(row.labId)
generateVisible.value = true
}
/**
* 确认生成课程绑定预约。
*/
async function confirmGenerate(): Promise<void> {
if (!generateCourse.value) {
return
}
generating.value = true
try {
const created: ReservationVO[] = await reservationApi.createForCourse({
courseId: generateCourse.value.id,
deviceId: generateDeviceId.value,
title: generateTitle.value || undefined
})
ElMessage.success(`已生成 ${created.length} 场预约`)
generateVisible.value = false
} finally {
generating.value = false
}
}
onMounted(async () => {
const [labList, users] = await Promise.all([labApi.all(), userApi.list().catch(() => [])])
labs.value = labList
userNames.value = Object.fromEntries(users.map((user) => [user.id, user.realName]))
await load()
})
</script>
<style scoped>
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.time-sep {
margin: 0 8px;
}
.generate-form {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,129 @@
<template>
<div>
<el-row :gutter="16">
<el-col v-for="card in cards" :key="card.title" :xs="12" :sm="6">
<el-card class="stat-card" shadow="hover">
<div class="stat-value">{{ card.value }}</div>
<div class="stat-title">{{ card.title }}</div>
</el-card>
</el-col>
</el-row>
<el-row :gutter="16" class="quick-row">
<el-col :span="24">
<el-card>
<template #header>
<span>快捷入口</span>
</template>
<el-space wrap>
<el-button type="primary" @click="router.push('/reservations/new')">
<el-icon><Plus /></el-icon>&nbsp;发起预约
</el-button>
<el-button @click="router.push('/calendar')">
<el-icon><Calendar /></el-icon>&nbsp;查看日历
</el-button>
<el-button v-if="auth.hasRole('ADMIN', 'TEACHER')" @click="router.push('/approvals')">
<el-icon><Checked /></el-icon>&nbsp;待我审批
</el-button>
<el-button v-if="auth.hasRole('ADMIN')" @click="router.push('/users')">
<el-icon><User /></el-icon>&nbsp;用户管理
</el-button>
</el-space>
</el-card>
</el-col>
</el-row>
<el-card class="table-card">
<template #header>
<span>我的最近预约</span>
</template>
<el-table :data="recent" v-loading="loading" empty-text="暂无预约">
<el-table-column prop="title" label="标题" min-width="140" />
<el-table-column prop="labName" label="实验室" min-width="140" />
<el-table-column label="时间" min-width="220">
<template #default="{ row }">
{{ formatDateTime(row.startTime) }} ~ {{ formatDateTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column prop="typeLabel" label="类型" width="110" />
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="RESERVATION_STATUS_TYPES[row.status] as any">{{ row.statusLabel }}</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { labApi, deviceApi, reservationApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { RESERVATION_STATUS_TYPES, formatDateTime } from '@/utils/format'
import type { ReservationVO } from '@/types'
const router = useRouter()
const auth = useAuthStore()
const loading = ref(false)
const recent = ref<ReservationVO[]>([])
/** 顶部统计卡片数据 */
const cards = reactive([
{ title: '实验室总数', value: 0 },
{ title: '设备总数', value: 0 },
{ title: '我的预约', value: 0 },
{ title: '待审批预约', value: 0 }
])
/**
* 加载统计与最近预约。
*/
async function load(): Promise<void> {
loading.value = true
try {
const [labs, devices, mine, pending] = await Promise.all([
labApi.page({ page: 1, size: 1 }),
deviceApi.page({ page: 1, size: 1 }),
reservationApi.page({ page: 1, size: 5, mine: true }),
reservationApi.page({ page: 1, size: 1, status: 'PENDING' })
])
cards[0].value = labs.total
cards[1].value = devices.total
cards[2].value = mine.total
cards[3].value = pending.total
recent.value = mine.records
} finally {
loading.value = false
}
}
onMounted(load)
</script>
<style scoped>
.stat-card {
text-align: center;
}
.stat-value {
font-size: 28px;
font-weight: 600;
color: #409eff;
}
.stat-title {
margin-top: 8px;
color: #909399;
font-size: 13px;
}
.quick-row {
margin-top: 16px;
}
.table-card {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,221 @@
<template>
<el-card>
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="按名称/编号搜索"
clearable
style="width: 220px"
@keyup.enter="load"
@clear="load"
/>
<el-select v-model="query.labId" placeholder="全部实验室" clearable style="width: 200px" @change="load">
<el-option v-for="lab in labs" :key="lab.id" :label="lab.name" :value="lab.id" />
</el-select>
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 150px" @change="load">
<el-option v-for="(label, value) in LAB_STATUS_LABELS" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button v-if="auth.hasRole('ADMIN')" type="success" @click="openDialog()">
<el-icon><Plus /></el-icon>&nbsp;新增设备
</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="code" label="编号" width="150" />
<el-table-column prop="name" label="名称" min-width="150" />
<el-table-column prop="model" label="型号" min-width="150" />
<el-table-column label="所属实验室" min-width="160">
<template #default="{ row }">{{ labName(row.labId) }}</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="RESOURCE_STATUS_TYPES[row.status] as any">{{ LAB_STATUS_LABELS[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="250" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDialog(row)">详情</el-button>
<el-dropdown v-if="auth.hasRole('ADMIN')" @command="(status: string) => changeStatus(row, status)">
<el-button size="small" type="primary">状态<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="IDLE">空闲</el-dropdown-item>
<el-dropdown-item command="MAINTENANCE">维护中</el-dropdown-item>
<el-dropdown-item command="RESERVED">已预约</el-dropdown-item>
<el-dropdown-item command="IN_USE">使用中</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="auth.hasRole('ADMIN')" size="small" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next, sizes"
:total="total"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[10, 20, 50]"
@current-change="load"
@size-change="load"
/>
<el-dialog v-model="dialogVisible" :title="form.id ? '设备详情' : '新增设备'" width="500px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="所属实验室" prop="labId">
<el-select v-model="form.labId" :disabled="!editable" placeholder="请选择">
<el-option v-for="lab in labs" :key="lab.id" :label="lab.name" :value="lab.id" />
</el-select>
</el-form-item>
<el-form-item label="编号" prop="code">
<el-input v-model="form.code" :disabled="!editable" />
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" :disabled="!editable" />
</el-form-item>
<el-form-item label="型号">
<el-input v-model="form.model" :disabled="!editable" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" :disabled="!editable" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editable ? '取消' : '关闭' }}</el-button>
<el-button v-if="editable" type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { deviceApi, labApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { LAB_STATUS_LABELS, RESOURCE_STATUS_TYPES } from '@/utils/format'
import type { Device, Lab } from '@/types'
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<Device[]>([])
const labs = ref<Lab[]>([])
const total = ref(0)
const query = reactive({ page: 1, size: 10, keyword: '', labId: '' as number | '', status: '' })
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<Partial<Device>>({})
/** 是否可编辑(管理员) */
const editable = computed(() => auth.hasRole('ADMIN'))
/** 表单校验规则 */
const rules: FormRules = {
labId: [{ required: true, message: '请选择实验室', trigger: 'change' }],
code: [{ required: true, message: '请输入编号', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }]
}
/**
* 加载设备与实验室选项。
*/
async function load(): Promise<void> {
loading.value = true
try {
const result = await deviceApi.page({
page: query.page,
size: query.size,
keyword: query.keyword || undefined,
labId: query.labId || undefined,
status: query.status || undefined
})
rows.value = result.records
total.value = result.total
} finally {
loading.value = false
}
}
/**
* 根据实验室 ID 取名称。
*
* @param labId 实验室 ID
* @returns 名称
*/
function labName(labId: number): string {
return labs.value.find((item) => item.id === labId)?.name ?? `#${labId}`
}
/**
* 打开新增/详情对话框。
*
* @param row 行数据
*/
function openDialog(row?: Device): void {
Object.keys(form).forEach((key) => delete (form as Record<string, unknown>)[key])
if (row) {
Object.assign(form, row)
}
dialogVisible.value = true
}
/**
* 保存设备。
*/
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
if (form.id) {
await deviceApi.update(form.id, form)
ElMessage.success('修改成功')
} else {
await deviceApi.create(form)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await load()
}
/**
* 删除设备。
*
* @param row 行数据
*/
async function remove(row: Device): Promise<void> {
await ElMessageBox.confirm(`确认删除设备「${row.name}」吗?`, '提示', { type: 'warning' })
await deviceApi.remove(row.id)
ElMessage.success('删除成功')
await load()
}
/**
* 变更设备状态。
*
* @param row 行数据
* @param status 目标状态
*/
async function changeStatus(row: Device, status: string): Promise<void> {
await deviceApi.changeStatus(row.id, status)
ElMessage.success('状态已更新')
await load()
}
onMounted(async () => {
labs.value = await labApi.all()
await load()
})
</script>
<style scoped>
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,223 @@
<template>
<el-card>
<div class="toolbar">
<el-input
v-model="query.keyword"
placeholder="按名称/编号搜索"
clearable
style="width: 220px"
@keyup.enter="load"
@clear="load"
/>
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 160px" @change="load">
<el-option v-for="(label, value) in LAB_STATUS_LABELS" :key="value" :label="label" :value="value" />
</el-select>
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button v-if="auth.hasRole('ADMIN')" type="success" @click="openDialog()">
<el-icon><Plus /></el-icon>&nbsp;新增实验室
</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="code" label="编号" width="130" />
<el-table-column prop="name" label="名称" min-width="180" />
<el-table-column prop="location" label="位置" min-width="140" />
<el-table-column prop="capacity" label="容量" width="80" align="center" />
<el-table-column label="开放时间" width="140" align="center">
<template #default="{ row }">
{{ formatTime(row.openTime) }} ~ {{ formatTime(row.closeTime) }}
</template>
</el-table-column>
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="RESOURCE_STATUS_TYPES[row.status] as any">{{ LAB_STATUS_LABELS[row.status] }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDialog(row)">详情</el-button>
<el-dropdown v-if="auth.hasRole('ADMIN')" @command="(status: string) => changeStatus(row, status)">
<el-button size="small" type="primary">状态<el-icon class="el-icon--right"><ArrowDown /></el-icon></el-button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item command="IDLE">空闲</el-dropdown-item>
<el-dropdown-item command="MAINTENANCE">维护中</el-dropdown-item>
<el-dropdown-item command="RESERVED">已预约</el-dropdown-item>
<el-dropdown-item command="IN_USE">使用中</el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
<el-button v-if="auth.hasRole('ADMIN')" size="small" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next, sizes"
:total="total"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[10, 20, 50]"
@current-change="load"
@size-change="load"
/>
<el-dialog v-model="dialogVisible" :title="form.id ? '实验室详情' : '新增实验室'" width="520px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="编号" prop="code">
<el-input v-model="form.code" :disabled="Boolean(form.id) && !auth.hasRole('ADMIN')" />
</el-form-item>
<el-form-item label="名称" prop="name">
<el-input v-model="form.name" :disabled="!editable" />
</el-form-item>
<el-form-item label="位置" prop="location">
<el-input v-model="form.location" :disabled="!editable" />
</el-form-item>
<el-form-item label="容量" prop="capacity">
<el-input-number v-model="form.capacity" :min="1" :disabled="!editable" />
</el-form-item>
<el-form-item label="开放时间">
<el-time-picker v-model="openTime" format="HH:mm" value-format="HH:mm" :disabled="!editable" placeholder="开始" />
<span class="time-sep">~</span>
<el-time-picker v-model="closeTime" format="HH:mm" value-format="HH:mm" :disabled="!editable" placeholder="结束" />
</el-form-item>
<el-form-item label="描述">
<el-input v-model="form.description" type="textarea" :rows="3" :disabled="!editable" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">{{ editable ? '取消' : '关闭' }}</el-button>
<el-button v-if="editable" type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { labApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import { LAB_STATUS_LABELS, RESOURCE_STATUS_TYPES, formatTime } from '@/utils/format'
import type { Lab } from '@/types'
const auth = useAuthStore()
const loading = ref(false)
const rows = ref<Lab[]>([])
const total = ref(0)
const query = reactive({ page: 1, size: 10, keyword: '', status: '' })
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<Partial<Lab>>({})
const openTime = ref('08:00')
const closeTime = ref('22:00')
/** 是否处于可编辑模式(非管理员只读) */
const editable = computed(() => auth.hasRole('ADMIN'))
/** 表单校验规则 */
const rules: FormRules = {
code: [{ required: true, message: '请输入编号', trigger: 'blur' }],
name: [{ required: true, message: '请输入名称', trigger: 'blur' }],
location: [{ required: true, message: '请输入位置', trigger: 'blur' }],
capacity: [{ required: true, message: '请输入容量', trigger: 'blur' }]
}
/**
* 加载实验室列表。
*/
async function load(): Promise<void> {
loading.value = true
try {
const result = await labApi.page({
page: query.page,
size: query.size,
keyword: query.keyword || undefined,
status: query.status || undefined
})
rows.value = result.records
total.value = result.total
} finally {
loading.value = false
}
}
/**
* 打开新增/详情对话框。
*
* @param row 行数据,空表示新增
*/
function openDialog(row?: Lab): void {
Object.keys(form).forEach((key) => delete (form as Record<string, unknown>)[key])
if (row) {
Object.assign(form, row)
openTime.value = formatTime(row.openTime)
closeTime.value = formatTime(row.closeTime)
} else {
form.capacity = 30
openTime.value = '08:00'
closeTime.value = '22:00'
}
dialogVisible.value = true
}
/**
* 保存实验室。
*/
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
const payload = { ...form, openTime: openTime.value, closeTime: closeTime.value }
if (form.id) {
await labApi.update(form.id, payload)
ElMessage.success('修改成功')
} else {
await labApi.create(payload)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await load()
}
/**
* 删除实验室。
*
* @param row 行数据
*/
async function remove(row: Lab): Promise<void> {
await ElMessageBox.confirm(`确认删除实验室「${row.name}」吗?`, '提示', { type: 'warning' })
await labApi.remove(row.id)
ElMessage.success('删除成功')
await load()
}
/**
* 变更实验室状态。
*
* @param row 行数据
* @param status 目标状态
*/
async function changeStatus(row: Lab, status: string): Promise<void> {
await labApi.changeStatus(row.id, status)
ElMessage.success('状态已更新')
await load()
}
onMounted(load)
</script>
<style scoped>
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
.time-sep {
margin: 0 8px;
}
</style>

View File

@@ -0,0 +1,127 @@
<template>
<div class="login-page">
<el-card class="login-card">
<h2 class="title">实验室预约与资源管理平台</h2>
<p class="subtitle">Laboratory Reservation & Resource Management</p>
<el-form ref="formRef" :model="form" :rules="rules" label-position="top" @submit.prevent>
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" placeholder="请输入用户名" size="large" clearable />
</el-form-item>
<el-form-item label="密码" prop="password">
<el-input
v-model="form.password"
type="password"
placeholder="请输入密码"
size="large"
show-password
@keyup.enter="onSubmit"
/>
</el-form-item>
<el-button type="primary" size="large" class="submit" :loading="loading" @click="onSubmit">
登 录
</el-button>
</el-form>
<el-divider>演示账号</el-divider>
<div class="demo-accounts">
<el-button size="small" @click="fill('admin', 'admin123')">管理员 admin</el-button>
<el-button size="small" @click="fill('teacher1', '123456')">教师 teacher1</el-button>
<el-button size="small" @click="fill('student1', '123456')">学生 student1</el-button>
</div>
</el-card>
</div>
</template>
<script setup lang="ts">
import { reactive, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { useAuthStore } from '@/stores/auth'
const router = useRouter()
const route = useRoute()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const loading = ref(false)
const form = reactive({ username: '', password: '' })
/** 表单校验规则 */
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
/**
* 快速填充演示账号。
*
* @param username 用户名
* @param password 密码
*/
function fill(username: string, password: string): void {
form.username = username
form.password = password
}
/**
* 提交登录。
*/
async function onSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
loading.value = true
try {
await auth.login(form.username, form.password)
ElMessage.success('登录成功')
const redirect = (route.query.redirect as string) || '/dashboard'
router.push(redirect)
} finally {
loading.value = false
}
}
</script>
<style scoped>
.login-page {
height: 100%;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(135deg, #1f2d3d 0%, #3a6073 100%);
}
.login-card {
width: 420px;
padding: 8px 12px;
}
.title {
margin: 0 0 4px;
text-align: center;
font-size: 20px;
}
.subtitle {
margin: 0 0 24px;
text-align: center;
color: #909399;
font-size: 12px;
}
.submit {
width: 100%;
}
.demo-accounts {
display: flex;
justify-content: space-between;
gap: 8px;
}
.demo-accounts .el-button {
flex: 1;
margin: 0;
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<el-card>
<div class="toolbar">
<el-select v-model="query.status" placeholder="全部状态" clearable style="width: 150px" @change="load">
<el-option v-for="(label, value) in STATUS_LABELS" :key="value" :label="label" :value="value" />
</el-select>
<el-select v-model="query.type" placeholder="全部类型" clearable style="width: 160px" @change="load">
<el-option label="单次预约" value="SINGLE" />
<el-option label="多次预约" value="RECURRING" />
<el-option label="课程绑定" value="COURSE" />
</el-select>
<el-select v-model="query.labId" placeholder="全部实验室" clearable style="width: 200px" @change="load">
<el-option v-for="lab in labs" :key="lab.id" :label="lab.name" :value="lab.id" />
</el-select>
<el-input v-model="query.keyword" placeholder="标题关键字" clearable style="width: 180px" @keyup.enter="load" @clear="load" />
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD" start-placeholder="开始日期" end-placeholder="结束日期" @change="onDateChange" />
<el-button type="primary" @click="load">查询</el-button>
<div class="spacer" />
<el-button type="success" @click="router.push('/reservations/new')">发起预约</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="title" label="标题" min-width="150" />
<el-table-column prop="labName" label="实验室" min-width="150" />
<el-table-column prop="deviceName" label="设备" min-width="120">
<template #default="{ row }">{{ row.deviceName || '-' }}</template>
</el-table-column>
<el-table-column label="时间" min-width="230">
<template #default="{ row }">
{{ formatDateTime(row.startTime) }}<br />
{{ formatDateTime(row.endTime) }}
</template>
</el-table-column>
<el-table-column prop="participants" label="人数" width="70" align="center" />
<el-table-column prop="typeLabel" label="类型" width="110" />
<el-table-column label="状态" width="110" align="center">
<template #default="{ row }">
<el-tag :type="RESERVATION_STATUS_TYPES[row.status] as any">{{ row.statusLabel }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="260" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="showDetail(row)">详情</el-button>
<el-button
v-if="['PENDING', 'APPROVED'].includes(row.status)"
size="small"
type="warning"
@click="cancel(row)"
>
取消
</el-button>
<el-button v-if="row.status === 'APPROVED'" size="small" type="primary" @click="start(row)">
开始使用
</el-button>
<el-button v-if="row.status === 'IN_USE'" size="small" type="success" @click="complete(row)">
完成
</el-button>
</template>
</el-table-column>
</el-table>
<el-pagination
class="pagination"
layout="total, prev, pager, next, sizes"
:total="total"
v-model:current-page="query.page"
v-model:page-size="query.size"
:page-sizes="[10, 20, 50]"
@current-change="load"
@size-change="load"
/>
<el-drawer v-model="detailVisible" title="预约详情" size="440px">
<el-descriptions v-if="detail" :column="1" border>
<el-descriptions-item label="标题">{{ detail.title }}</el-descriptions-item>
<el-descriptions-item label="预约人">{{ detail.userName }}</el-descriptions-item>
<el-descriptions-item label="实验室">{{ detail.labName }}</el-descriptions-item>
<el-descriptions-item label="设备">{{ detail.deviceName || '-' }}</el-descriptions-item>
<el-descriptions-item label="课程">{{ detail.courseName || '-' }}</el-descriptions-item>
<el-descriptions-item label="开始">{{ formatDateTime(detail.startTime) }}</el-descriptions-item>
<el-descriptions-item label="结束">{{ formatDateTime(detail.endTime) }}</el-descriptions-item>
<el-descriptions-item label="人数">{{ detail.participants }}</el-descriptions-item>
<el-descriptions-item label="类型">{{ detail.typeLabel }}</el-descriptions-item>
<el-descriptions-item label="状态">{{ detail.statusLabel }}</el-descriptions-item>
<el-descriptions-item label="审批人">{{ detail.approverName || '-' }}</el-descriptions-item>
<el-descriptions-item label="驳回原因">{{ detail.rejectReason || '-' }}</el-descriptions-item>
<el-descriptions-item label="用途">{{ detail.purpose || '-' }}</el-descriptions-item>
</el-descriptions>
</el-drawer>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, ElMessageBox } from 'element-plus'
import { labApi, reservationApi } from '@/api'
import { RESERVATION_STATUS_TYPES, formatDateTime } from '@/utils/format'
import type { Lab, ReservationVO } from '@/types'
const STATUS_LABELS: Record<string, string> = {
PENDING: '待审批',
APPROVED: '已通过',
REJECTED: '已驳回',
CANCELLED: '已取消',
IN_USE: '使用中',
COMPLETED: '已完成'
}
const router = useRouter()
const loading = ref(false)
const rows = ref<ReservationVO[]>([])
const labs = ref<Lab[]>([])
const total = ref(0)
const dateRange = ref<[string, string] | null>(null)
const query = reactive({
page: 1,
size: 10,
status: '',
type: '',
labId: '' as number | '',
keyword: '',
mine: true
})
const detailVisible = ref(false)
const detail = ref<ReservationVO | null>(null)
/**
* 加载我的预约列表。
*/
async function load(): Promise<void> {
loading.value = true
try {
const result = await reservationApi.page({
page: query.page,
size: query.size,
mine: true,
status: (query.status || undefined) as ReservationVO['status'] | undefined,
type: (query.type || undefined) as ReservationVO['type'] | undefined,
labId: query.labId || undefined,
keyword: query.keyword || undefined,
from: dateRange.value?.[0],
to: dateRange.value?.[1]
})
rows.value = result.records
total.value = result.total
} finally {
loading.value = false
}
}
/**
* 日期范围变更后重新查询。
*/
function onDateChange(): void {
query.page = 1
load()
}
/**
* 展示预约详情。
*
* @param row 行数据
*/
function showDetail(row: ReservationVO): void {
detail.value = row
detailVisible.value = true
}
/**
* 取消预约,系列预约可选择整体取消。
*
* @param row 行数据
*/
async function cancel(row: ReservationVO): Promise<void> {
const wholeSeries = row.seriesId != null
? await ElMessageBox.confirm('该预约属于重复系列,是否取消整个系列?', '取消预约', {
confirmButtonText: '取消整个系列',
cancelButtonText: '仅取消本次',
distinguishCancelAndClose: true,
type: 'warning'
})
.then(() => true)
.catch((action) => {
if (action === 'cancel') {
return false
}
throw action
})
: false
await reservationApi.cancel(row.id, wholeSeries)
ElMessage.success('已取消')
await load()
}
/**
* 开始使用。
*
* @param row 行数据
*/
async function start(row: ReservationVO): Promise<void> {
await reservationApi.start(row.id)
ElMessage.success('已开始使用')
await load()
}
/**
* 完成使用。
*
* @param row 行数据
*/
async function complete(row: ReservationVO): Promise<void> {
await reservationApi.complete(row.id)
ElMessage.success('已完成')
await load()
}
onMounted(async () => {
labs.value = await labApi.all()
await load()
})
</script>
<style scoped>
.pagination {
margin-top: 16px;
justify-content: flex-end;
}
</style>

View File

@@ -0,0 +1,372 @@
<template>
<el-row :gutter="16">
<el-col :xs="24" :lg="16">
<el-card>
<template #header>
<span>发起预约</span>
</template>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<el-tab-pane label="单次预约" name="SINGLE" />
<el-tab-pane label="多次预约" name="RECURRING" />
<el-tab-pane v-if="canUseCourse" label="课程绑定预约" name="COURSE" />
</el-tabs>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<el-form-item v-if="activeTab === 'COURSE'" label="实验课程" prop="courseId">
<el-select v-model="form.courseId" placeholder="请选择课程" @change="onCourseChange">
<el-option
v-for="course in courses"
:key="course.id"
:label="`${course.name}(${course.code})`"
:value="course.id"
/>
</el-select>
</el-form-item>
<el-form-item label="实验室" prop="labId">
<el-select v-model="form.labId" placeholder="请选择实验室" @change="onLabChange">
<el-option
v-for="lab in labs"
:key="lab.id"
:label="`${lab.name}(${lab.location},容量 ${lab.capacity})`"
:value="lab.id"
:disabled="lab.status === 'MAINTENANCE'"
/>
</el-select>
</el-form-item>
<el-form-item label="设备(可选)">
<el-select v-model="form.deviceId" clearable placeholder="不指定设备" :disabled="!form.labId">
<el-option
v-for="device in devices"
:key="device.id"
:label="`${device.name}(${device.code})`"
:value="device.id"
:disabled="device.status === 'MAINTENANCE'"
/>
</el-select>
</el-form-item>
<template v-if="activeTab === 'SINGLE'">
<el-form-item label="使用时间" prop="timeRange">
<el-date-picker
v-model="form.timeRange"
type="datetimerange"
start-placeholder="开始时间"
end-placeholder="结束时间"
format="YYYY-MM-DD HH:mm"
value-format="YYYY-MM-DDTHH:mm:ss"
/>
</el-form-item>
</template>
<template v-else-if="activeTab === 'RECURRING'">
<el-form-item label="重复日期" prop="dateRange">
<el-date-picker
v-model="form.dateRange"
type="daterange"
start-placeholder="开始日期"
end-placeholder="结束日期"
value-format="YYYY-MM-DD"
/>
</el-form-item>
<el-form-item label="重复星期" prop="weekdayList">
<el-checkbox-group v-model="form.weekdayList">
<el-checkbox v-for="(label, value) in WEEKDAYS" :key="value" :value="value">{{ label }}</el-checkbox>
</el-checkbox-group>
</el-form-item>
<el-form-item label="每次时段" prop="timeOfDay">
<el-time-picker v-model="form.timeOfDay" is-range format="HH:mm" value-format="HH:mm" start-placeholder="开始" end-placeholder="结束" />
</el-form-item>
</template>
<el-form-item label="标题" prop="title">
<el-input v-model="form.title" placeholder="例如:毕业设计调试" />
</el-form-item>
<el-form-item label="用途说明">
<el-input v-model="form.purpose" type="textarea" :rows="3" placeholder="选填" />
</el-form-item>
<el-form-item label="参与人数" prop="participants">
<el-input-number v-model="form.participants" :min="1" />
</el-form-item>
<el-form-item>
<el-button :loading="checking" @click="onCheck">
<el-icon><Search /></el-icon>&nbsp;冲突预检
</el-button>
<el-button type="primary" :loading="submitting" @click="onSubmit">
<el-icon><Check /></el-icon>&nbsp;提交预约
</el-button>
</el-form-item>
</el-form>
</el-card>
</el-col>
<el-col :xs="24" :lg="8">
<el-card>
<template #header>
<span>冲突校验结果</span>
</template>
<el-empty v-if="!checked" description="点击「冲突预检」查看结果" />
<el-result
v-else-if="conflicts.length === 0"
icon="success"
title="可以预约"
sub-title="未检测到时间冲突"
/>
<div v-else>
<el-alert type="error" :closable="false" title="存在冲突,无法提交" show-icon />
<el-table :data="conflicts" class="conflict-table" size="small">
<el-table-column prop="message" label="冲突说明" min-width="200" />
</el-table>
</div>
</el-card>
<el-card class="tips">
<template #header>
<span>预约规则提示</span>
</template>
<ul class="rule-list">
<li>同一实验室、设备在相同时间段内只能被一个有效预约占用</li>
<li>相邻时段首尾相接不算冲突(例如 10:00-12:00 与 12:00-14:00)</li>
<li>单次预约最长 8 小时,最多提前 60 天,且需落在实验室开放时间内</li>
<li>学生提交后需要教师或管理员审批,教师/管理员提交后自动通过</li>
</ul>
</el-card>
</el-col>
</el-row>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { useRouter } from 'vue-router'
import { ElMessage, type FormInstance, type FormRules } from 'element-plus'
import { courseApi, deviceApi, labApi, reservationApi } from '@/api'
import { useAuthStore } from '@/stores/auth'
import type { Conflict, Course, Device, Lab, ReservationType } from '@/types'
const WEEKDAYS: Record<string, string> = {
MON: '周一',
TUE: '周二',
WED: '周三',
THU: '周四',
FRI: '周五',
SAT: '周六',
SUN: '周日'
}
interface CreateForm {
courseId?: number
labId?: number
deviceId?: number
title: string
purpose: string
participants: number
timeRange: [string, string] | null
dateRange: [string, string] | null
weekdayList: string[]
timeOfDay: [string, string] | null
}
const router = useRouter()
const auth = useAuthStore()
const formRef = ref<FormInstance>()
const activeTab = ref<ReservationType>('SINGLE')
const labs = ref<Lab[]>([])
const devices = ref<Device[]>([])
const courses = ref<Course[]>([])
const checking = ref(false)
const submitting = ref(false)
const checked = ref(false)
const conflicts = ref<Conflict[]>([])
const form = reactive<CreateForm>({
title: '',
purpose: '',
participants: 1,
timeRange: null,
dateRange: null,
weekdayList: [],
timeOfDay: null
})
/** 是否可用课程绑定预约(教师与管理员) */
const canUseCourse = computed(() => auth.hasRole('ADMIN', 'TEACHER'))
/** 表单校验规则 */
const rules: FormRules = {
labId: [{ required: true, message: '请选择实验室', trigger: 'change' }],
title: [{ required: true, message: '请输入标题', trigger: 'blur' }],
courseId: [{ required: true, message: '请选择课程', trigger: 'change' }],
timeRange: [{ required: true, message: '请选择使用时间', trigger: 'change' }],
dateRange: [{ required: true, message: '请选择重复日期', trigger: 'change' }],
weekdayList: [{ required: true, message: '请选择重复星期', trigger: 'change' }],
timeOfDay: [{ required: true, message: '请选择每次时段', trigger: 'change' }]
}
/**
* 切换预约类型时清空校验结果。
*/
function onTabChange(): void {
checked.value = false
conflicts.value = []
formRef.value?.clearValidate()
}
/**
* 切换实验室时刷新设备列表。
*
* @param labId 实验室 ID
*/
async function onLabChange(labId: number): Promise<void> {
form.deviceId = undefined
devices.value = await deviceApi.byLab(labId)
}
/**
* 选择课程后自动带出实验室与标题。
*
* @param courseId 课程 ID
*/
async function onCourseChange(courseId: number): Promise<void> {
const course = courses.value.find((item) => item.id === courseId)
if (!course) {
return
}
form.labId = course.labId
form.title = course.name
form.participants = course.studentCount
await onLabChange(course.labId)
}
/**
* 构造后端请求体。
*
* @returns 请求体或 null(参数不完整)
*/
function buildPayload(): Record<string, unknown> | null {
if (!form.labId || !form.title) {
return null
}
const base = {
labId: form.labId,
deviceId: form.deviceId,
participants: form.participants,
title: form.title,
purpose: form.purpose
}
if (activeTab.value === 'SINGLE') {
if (!form.timeRange) {
return null
}
return {
...base,
type: 'SINGLE',
startTime: form.timeRange[0],
endTime: form.timeRange[1]
}
}
if (activeTab.value === 'RECURRING') {
if (!form.dateRange || !form.timeOfDay || form.weekdayList.length === 0) {
return null
}
return {
...base,
type: 'RECURRING',
startDate: form.dateRange[0],
endDate: form.dateRange[1],
weekdays: form.weekdayList.join(','),
startTimeOfDay: form.timeOfDay[0],
endTimeOfDay: form.timeOfDay[1]
}
}
if (!form.courseId) {
return null
}
return { ...base, type: 'COURSE', courseId: form.courseId }
}
/**
* 冲突预检。
*/
async function onCheck(): Promise<void> {
const payload = buildPayload()
if (!payload) {
ElMessage.warning('请先完整填写预约信息')
return
}
checking.value = true
try {
const result = await reservationApi.check(payload)
checked.value = true
conflicts.value = result.conflicts
if (result.passed) {
ElMessage.success('未检测到冲突')
}
} finally {
checking.value = false
}
}
/**
* 提交预约。
*/
async function onSubmit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
const payload = buildPayload()
if (!payload) {
ElMessage.warning('请先完整填写预约信息')
return
}
submitting.value = true
try {
if (activeTab.value === 'SINGLE') {
await reservationApi.create(payload)
} else if (activeTab.value === 'RECURRING') {
const created = await reservationApi.createRecurring(payload)
ElMessage.success(`已生成 ${created.length} 场预约`)
} else {
const created = await reservationApi.createForCourse(payload)
ElMessage.success(`已生成 ${created.length} 场预约`)
}
if (activeTab.value === 'SINGLE') {
ElMessage.success('预约已提交')
}
router.push('/reservations/mine')
} finally {
submitting.value = false
}
}
onMounted(async () => {
labs.value = await labApi.all()
if (canUseCourse.value) {
courses.value = await courseApi.all()
}
if (auth.user?.role === 'STUDENT') {
form.participants = 1
}
})
</script>
<style scoped>
.conflict-table {
margin-top: 12px;
}
.tips {
margin-top: 16px;
}
.rule-list {
margin: 0;
padding-left: 18px;
color: #606266;
font-size: 13px;
line-height: 1.9;
}
</style>

View File

@@ -0,0 +1,170 @@
<template>
<el-card>
<div class="toolbar">
<div class="spacer" />
<el-button type="success" @click="openDialog()">
<el-icon><Plus /></el-icon>&nbsp;新增用户
</el-button>
</div>
<el-table :data="rows" v-loading="loading">
<el-table-column prop="id" label="ID" width="70" />
<el-table-column prop="username" label="用户名" width="140" />
<el-table-column prop="realName" label="姓名" width="120" />
<el-table-column prop="roleLabel" label="角色" width="100">
<template #default="{ row }">
<el-tag :type="row.role === 'ADMIN' ? 'danger' : row.role === 'TEACHER' ? 'warning' : 'info'">
{{ row.roleLabel }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="email" label="邮箱" min-width="180" />
<el-table-column prop="phone" label="电话" width="140" />
<el-table-column label="状态" width="90" align="center">
<template #default="{ row }">
<el-tag :type="row.enabled ? 'success' : 'info'">{{ row.enabled ? '启用' : '禁用' }}</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="170" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="openDialog(row)">编辑</el-button>
<el-button size="small" type="danger" @click="remove(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
<el-dialog v-model="dialogVisible" :title="form.id ? '编辑用户' : '新增用户'" width="480px">
<el-form ref="formRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="用户名" prop="username">
<el-input v-model="form.username" :disabled="Boolean(form.id)" />
</el-form-item>
<el-form-item v-if="!form.id" label="初始密码" prop="password">
<el-input v-model="form.password" type="password" show-password />
</el-form-item>
<el-form-item v-else label="重置密码">
<el-input v-model="form.password" type="password" show-password placeholder="留空则不修改" />
</el-form-item>
<el-form-item label="姓名" prop="realName">
<el-input v-model="form.realName" />
</el-form-item>
<el-form-item label="角色" prop="role">
<el-select v-model="form.role">
<el-option label="管理员" value="ADMIN" />
<el-option label="教师" value="TEACHER" />
<el-option label="学生" value="STUDENT" />
</el-select>
</el-form-item>
<el-form-item label="邮箱">
<el-input v-model="form.email" />
</el-form-item>
<el-form-item label="电话">
<el-input v-model="form.phone" />
</el-form-item>
<el-form-item v-if="form.id" label="启用">
<el-switch v-model="form.enabled" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="submit">保存</el-button>
</template>
</el-dialog>
</el-card>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus'
import { userApi } from '@/api'
import type { Role, UserVO } from '@/types'
interface UserForm {
id?: number
username?: string
password?: string
realName?: string
role?: Role
email?: string
phone?: string
enabled?: boolean
}
const loading = ref(false)
const rows = ref<UserVO[]>([])
const dialogVisible = ref(false)
const formRef = ref<FormInstance>()
const form = reactive<UserForm>({ role: 'STUDENT', enabled: true })
/** 表单校验规则 */
const rules: FormRules = {
username: [{ required: true, message: '请输入用户名', trigger: 'blur' }],
password: [{ required: true, message: '请输入密码', trigger: 'blur' }],
realName: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
role: [{ required: true, message: '请选择角色', trigger: 'change' }]
}
/**
* 加载用户列表。
*/
async function load(): Promise<void> {
loading.value = true
try {
rows.value = await userApi.list()
} finally {
loading.value = false
}
}
/**
* 打开新增/编辑对话框。
*
* @param row 行数据
*/
function openDialog(row?: UserVO): void {
Object.keys(form).forEach((key) => delete (form as Record<string, unknown>)[key])
if (row) {
Object.assign(form, row, { password: '' })
} else {
Object.assign(form, { role: 'STUDENT', enabled: true })
}
dialogVisible.value = true
}
/**
* 保存用户。
*/
async function submit(): Promise<void> {
const valid = await formRef.value?.validate().catch(() => false)
if (!valid) {
return
}
if (form.id) {
const payload: Record<string, unknown> = { ...form }
delete payload.username
if (!payload.password) {
delete payload.password
}
await userApi.update(form.id, payload)
ElMessage.success('修改成功')
} else {
await userApi.create(form as Record<string, unknown>)
ElMessage.success('新增成功')
}
dialogVisible.value = false
await load()
}
/**
* 删除用户。
*
* @param row 行数据
*/
async function remove(row: UserVO): Promise<void> {
await ElMessageBox.confirm(`确认删除用户「${row.realName}」吗?`, '提示', { type: 'warning' })
await userApi.remove(row.id)
ElMessage.success('删除成功')
await load()
}
onMounted(load)
</script>

25
frontend/tsconfig.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "preserve",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"baseUrl": ".",
"paths": {
"@/*": ["./src/*"]
},
"types": ["node"]
},
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.vue", "vite.config.ts"]
}

24
frontend/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
/**
* Vite 配置:开发代理 /api 到后端 8080,避免跨域。
*/
export default defineConfig({
plugins: [vue()],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
port: 5173,
proxy: {
'/api': {
target: 'http://localhost:8080',
changeOrigin: true
}
}
}
})

295
mvnw vendored Executable file
View File

@@ -0,0 +1,295 @@
#!/bin/sh
# ----------------------------------------------------------------------------
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing,
# software distributed under the License is distributed on an
# "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
# KIND, either express or implied. See the License for the
# specific language governing permissions and limitations
# under the License.
# ----------------------------------------------------------------------------
# ----------------------------------------------------------------------------
# Apache Maven Wrapper startup batch script, version 3.3.4
#
# Optional ENV vars
# -----------------
# JAVA_HOME - location of a JDK home dir, required when download maven via java source
# MVNW_REPOURL - repo url base for downloading maven distribution
# MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
# MVNW_VERBOSE - true: enable verbose log; debug: trace the mvnw script; others: silence the output
# ----------------------------------------------------------------------------
set -euf
[ "${MVNW_VERBOSE-}" != debug ] || set -x
# OS specific support.
native_path() { printf %s\\n "$1"; }
case "$(uname)" in
CYGWIN* | MINGW*)
[ -z "${JAVA_HOME-}" ] || JAVA_HOME="$(cygpath --unix "$JAVA_HOME")"
native_path() { cygpath --path --windows "$1"; }
;;
esac
# set JAVACMD and JAVACCMD
set_java_home() {
# For Cygwin and MinGW, ensure paths are in Unix format before anything is touched
if [ -n "${JAVA_HOME-}" ]; then
if [ -x "$JAVA_HOME/jre/sh/java" ]; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD="$JAVA_HOME/jre/sh/java"
JAVACCMD="$JAVA_HOME/jre/sh/javac"
else
JAVACMD="$JAVA_HOME/bin/java"
JAVACCMD="$JAVA_HOME/bin/javac"
if [ ! -x "$JAVACMD" ] || [ ! -x "$JAVACCMD" ]; then
echo "The JAVA_HOME environment variable is not defined correctly, so mvnw cannot run." >&2
echo "JAVA_HOME is set to \"$JAVA_HOME\", but \"\$JAVA_HOME/bin/java\" or \"\$JAVA_HOME/bin/javac\" does not exist." >&2
return 1
fi
fi
else
JAVACMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v java
)" || :
JAVACCMD="$(
'set' +e
'unset' -f command 2>/dev/null
'command' -v javac
)" || :
if [ ! -x "${JAVACMD-}" ] || [ ! -x "${JAVACCMD-}" ]; then
echo "The java/javac command does not exist in PATH nor is JAVA_HOME set, so mvnw cannot run." >&2
return 1
fi
fi
}
# hash string like Java String::hashCode
hash_string() {
str="${1:-}" h=0
while [ -n "$str" ]; do
char="${str%"${str#?}"}"
h=$(((h * 31 + $(LC_CTYPE=C printf %d "'$char")) % 4294967296))
str="${str#?}"
done
printf %x\\n $h
}
verbose() { :; }
[ "${MVNW_VERBOSE-}" != true ] || verbose() { printf %s\\n "${1-}"; }
die() {
printf %s\\n "$1" >&2
exit 1
}
trim() {
# MWRAPPER-139:
# Trims trailing and leading whitespace, carriage returns, tabs, and linefeeds.
# Needed for removing poorly interpreted newline sequences when running in more
# exotic environments such as mingw bash on Windows.
printf "%s" "${1}" | tr -d '[:space:]'
}
scriptDir="$(dirname "$0")"
scriptName="$(basename "$0")"
# parse distributionUrl and optional distributionSha256Sum, requires .mvn/wrapper/maven-wrapper.properties
while IFS="=" read -r key value; do
case "${key-}" in
distributionUrl) distributionUrl=$(trim "${value-}") ;;
distributionSha256Sum) distributionSha256Sum=$(trim "${value-}") ;;
esac
done <"$scriptDir/.mvn/wrapper/maven-wrapper.properties"
[ -n "${distributionUrl-}" ] || die "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
case "${distributionUrl##*/}" in
maven-mvnd-*bin.*)
MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/
case "${PROCESSOR_ARCHITECTURE-}${PROCESSOR_ARCHITEW6432-}:$(uname -a)" in
*AMD64:CYGWIN* | *AMD64:MINGW*) distributionPlatform=windows-amd64 ;;
:Darwin*x86_64) distributionPlatform=darwin-amd64 ;;
:Darwin*arm64) distributionPlatform=darwin-aarch64 ;;
:Linux*x86_64*) distributionPlatform=linux-amd64 ;;
*)
echo "Cannot detect native platform for mvnd on $(uname)-$(uname -m), use pure java version" >&2
distributionPlatform=linux-amd64
;;
esac
distributionUrl="${distributionUrl%-bin.*}-$distributionPlatform.zip"
;;
maven-mvnd-*) MVN_CMD=mvnd.sh _MVNW_REPO_PATTERN=/maven/mvnd/ ;;
*) MVN_CMD="mvn${scriptName#mvnw}" _MVNW_REPO_PATTERN=/org/apache/maven/ ;;
esac
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
[ -z "${MVNW_REPOURL-}" ] || distributionUrl="$MVNW_REPOURL$_MVNW_REPO_PATTERN${distributionUrl#*"$_MVNW_REPO_PATTERN"}"
distributionUrlName="${distributionUrl##*/}"
distributionUrlNameMain="${distributionUrlName%.*}"
distributionUrlNameMain="${distributionUrlNameMain%-bin}"
MAVEN_USER_HOME="${MAVEN_USER_HOME:-${HOME}/.m2}"
MAVEN_HOME="${MAVEN_USER_HOME}/wrapper/dists/${distributionUrlNameMain-}/$(hash_string "$distributionUrl")"
exec_maven() {
unset MVNW_VERBOSE MVNW_USERNAME MVNW_PASSWORD MVNW_REPOURL || :
exec "$MAVEN_HOME/bin/$MVN_CMD" "$@" || die "cannot exec $MAVEN_HOME/bin/$MVN_CMD"
}
if [ -d "$MAVEN_HOME" ]; then
verbose "found existing MAVEN_HOME at $MAVEN_HOME"
exec_maven "$@"
fi
case "${distributionUrl-}" in
*?-bin.zip | *?maven-mvnd-?*-?*.zip) ;;
*) die "distributionUrl is not valid, must match *-bin.zip or maven-mvnd-*.zip, but found '${distributionUrl-}'" ;;
esac
# prepare tmp dir
if TMP_DOWNLOAD_DIR="$(mktemp -d)" && [ -d "$TMP_DOWNLOAD_DIR" ]; then
clean() { rm -rf -- "$TMP_DOWNLOAD_DIR"; }
trap clean HUP INT TERM EXIT
else
die "cannot create temp dir"
fi
mkdir -p -- "${MAVEN_HOME%/*}"
# Download and Install Apache Maven
verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
verbose "Downloading from: $distributionUrl"
verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
# select .zip or .tar.gz
if ! command -v unzip >/dev/null; then
distributionUrl="${distributionUrl%.zip}.tar.gz"
distributionUrlName="${distributionUrl##*/}"
fi
# verbose opt
__MVNW_QUIET_WGET=--quiet __MVNW_QUIET_CURL=--silent __MVNW_QUIET_UNZIP=-q __MVNW_QUIET_TAR=''
[ "${MVNW_VERBOSE-}" != true ] || __MVNW_QUIET_WGET='' __MVNW_QUIET_CURL='' __MVNW_QUIET_UNZIP='' __MVNW_QUIET_TAR=v
# normalize http auth
case "${MVNW_PASSWORD:+has-password}" in
'') MVNW_USERNAME='' MVNW_PASSWORD='' ;;
has-password) [ -n "${MVNW_USERNAME-}" ] || MVNW_USERNAME='' MVNW_PASSWORD='' ;;
esac
if [ -z "${MVNW_USERNAME-}" ] && command -v wget >/dev/null; then
verbose "Found wget ... using wget"
wget ${__MVNW_QUIET_WGET:+"$__MVNW_QUIET_WGET"} "$distributionUrl" -O "$TMP_DOWNLOAD_DIR/$distributionUrlName" || die "wget: Failed to fetch $distributionUrl"
elif [ -z "${MVNW_USERNAME-}" ] && command -v curl >/dev/null; then
verbose "Found curl ... using curl"
curl ${__MVNW_QUIET_CURL:+"$__MVNW_QUIET_CURL"} -f -L -o "$TMP_DOWNLOAD_DIR/$distributionUrlName" "$distributionUrl" || die "curl: Failed to fetch $distributionUrl"
elif set_java_home; then
verbose "Falling back to use Java to download"
javaSource="$TMP_DOWNLOAD_DIR/Downloader.java"
targetZip="$TMP_DOWNLOAD_DIR/$distributionUrlName"
cat >"$javaSource" <<-END
public class Downloader extends java.net.Authenticator
{
protected java.net.PasswordAuthentication getPasswordAuthentication()
{
return new java.net.PasswordAuthentication( System.getenv( "MVNW_USERNAME" ), System.getenv( "MVNW_PASSWORD" ).toCharArray() );
}
public static void main( String[] args ) throws Exception
{
setDefault( new Downloader() );
java.nio.file.Files.copy( java.net.URI.create( args[0] ).toURL().openStream(), java.nio.file.Paths.get( args[1] ).toAbsolutePath().normalize() );
}
}
END
# For Cygwin/MinGW, switch paths to Windows format before running javac and java
verbose " - Compiling Downloader.java ..."
"$(native_path "$JAVACCMD")" "$(native_path "$javaSource")" || die "Failed to compile Downloader.java"
verbose " - Running Downloader.java ..."
"$(native_path "$JAVACMD")" -cp "$(native_path "$TMP_DOWNLOAD_DIR")" Downloader "$distributionUrl" "$(native_path "$targetZip")"
fi
# If specified, validate the SHA-256 sum of the Maven distribution zip file
if [ -n "${distributionSha256Sum-}" ]; then
distributionSha256Result=false
if [ "$MVN_CMD" = mvnd.sh ]; then
echo "Checksum validation is not supported for maven-mvnd." >&2
echo "Please disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
elif command -v sha256sum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | sha256sum -c - >/dev/null 2>&1; then
distributionSha256Result=true
fi
elif command -v shasum >/dev/null; then
if echo "$distributionSha256Sum $TMP_DOWNLOAD_DIR/$distributionUrlName" | shasum -a 256 -c >/dev/null 2>&1; then
distributionSha256Result=true
fi
else
echo "Checksum validation was requested but neither 'sha256sum' or 'shasum' are available." >&2
echo "Please install either command, or disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties." >&2
exit 1
fi
if [ $distributionSha256Result = false ]; then
echo "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised." >&2
echo "If you updated your Maven version, you need to update the specified distributionSha256Sum property." >&2
exit 1
fi
fi
# unzip and move
if command -v unzip >/dev/null; then
unzip ${__MVNW_QUIET_UNZIP:+"$__MVNW_QUIET_UNZIP"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -d "$TMP_DOWNLOAD_DIR" || die "failed to unzip"
else
tar xzf${__MVNW_QUIET_TAR:+"$__MVNW_QUIET_TAR"} "$TMP_DOWNLOAD_DIR/$distributionUrlName" -C "$TMP_DOWNLOAD_DIR" || die "failed to untar"
fi
# Find the actual extracted directory name (handles snapshots where filename != directory name)
actualDistributionDir=""
# First try the expected directory name (for regular distributions)
if [ -d "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain" ]; then
if [ -f "$TMP_DOWNLOAD_DIR/$distributionUrlNameMain/bin/$MVN_CMD" ]; then
actualDistributionDir="$distributionUrlNameMain"
fi
fi
# If not found, search for any directory with the Maven executable (for snapshots)
if [ -z "$actualDistributionDir" ]; then
# enable globbing to iterate over items
set +f
for dir in "$TMP_DOWNLOAD_DIR"/*; do
if [ -d "$dir" ]; then
if [ -f "$dir/bin/$MVN_CMD" ]; then
actualDistributionDir="$(basename "$dir")"
break
fi
fi
done
set -f
fi
if [ -z "$actualDistributionDir" ]; then
verbose "Contents of $TMP_DOWNLOAD_DIR:"
verbose "$(ls -la "$TMP_DOWNLOAD_DIR")"
die "Could not find Maven distribution directory in extracted archive"
fi
verbose "Found extracted Maven distribution directory: $actualDistributionDir"
printf %s\\n "$distributionUrl" >"$TMP_DOWNLOAD_DIR/$actualDistributionDir/mvnw.url"
mv -- "$TMP_DOWNLOAD_DIR/$actualDistributionDir" "$MAVEN_HOME" || [ -d "$MAVEN_HOME" ] || die "fail to move MAVEN_HOME"
clean || :
exec_maven "$@"

189
mvnw.cmd vendored Normal file
View File

@@ -0,0 +1,189 @@
<# : batch portion
@REM ----------------------------------------------------------------------------
@REM Licensed to the Apache Software Foundation (ASF) under one
@REM or more contributor license agreements. See the NOTICE file
@REM distributed with this work for additional information
@REM regarding copyright ownership. The ASF licenses this file
@REM to you under the Apache License, Version 2.0 (the
@REM "License"); you may not use this file except in compliance
@REM with the License. You may obtain a copy of the License at
@REM
@REM http://www.apache.org/licenses/LICENSE-2.0
@REM
@REM Unless required by applicable law or agreed to in writing,
@REM software distributed under the License is distributed on an
@REM "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
@REM KIND, either express or implied. See the License for the
@REM specific language governing permissions and limitations
@REM under the License.
@REM ----------------------------------------------------------------------------
@REM ----------------------------------------------------------------------------
@REM Apache Maven Wrapper startup batch script, version 3.3.4
@REM
@REM Optional ENV vars
@REM MVNW_REPOURL - repo url base for downloading maven distribution
@REM MVNW_USERNAME/MVNW_PASSWORD - user and password for downloading maven
@REM MVNW_VERBOSE - true: enable verbose log; others: silence the output
@REM ----------------------------------------------------------------------------
@IF "%__MVNW_ARG0_NAME__%"=="" (SET __MVNW_ARG0_NAME__=%~nx0)
@SET __MVNW_CMD__=
@SET __MVNW_ERROR__=
@SET __MVNW_PSMODULEP_SAVE=%PSModulePath%
@SET PSModulePath=
@FOR /F "usebackq tokens=1* delims==" %%A IN (`powershell -noprofile "& {$scriptDir='%~dp0'; $script='%__MVNW_ARG0_NAME__%'; icm -ScriptBlock ([Scriptblock]::Create((Get-Content -Raw '%~f0'))) -NoNewScope}"`) DO @(
IF "%%A"=="MVN_CMD" (set __MVNW_CMD__=%%B) ELSE IF "%%B"=="" (echo %%A) ELSE (echo %%A=%%B)
)
@SET PSModulePath=%__MVNW_PSMODULEP_SAVE%
@SET __MVNW_PSMODULEP_SAVE=
@SET __MVNW_ARG0_NAME__=
@SET MVNW_USERNAME=
@SET MVNW_PASSWORD=
@IF NOT "%__MVNW_CMD__%"=="" ("%__MVNW_CMD__%" %*)
@echo Cannot start maven from wrapper >&2 && exit /b 1
@GOTO :EOF
: end batch / begin powershell #>
$ErrorActionPreference = "Stop"
if ($env:MVNW_VERBOSE -eq "true") {
$VerbosePreference = "Continue"
}
# calculate distributionUrl, requires .mvn/wrapper/maven-wrapper.properties
$distributionUrl = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionUrl
if (!$distributionUrl) {
Write-Error "cannot read distributionUrl property in $scriptDir/.mvn/wrapper/maven-wrapper.properties"
}
switch -wildcard -casesensitive ( $($distributionUrl -replace '^.*/','') ) {
"maven-mvnd-*" {
$USE_MVND = $true
$distributionUrl = $distributionUrl -replace '-bin\.[^.]*$',"-windows-amd64.zip"
$MVN_CMD = "mvnd.cmd"
break
}
default {
$USE_MVND = $false
$MVN_CMD = $script -replace '^mvnw','mvn'
break
}
}
# apply MVNW_REPOURL and calculate MAVEN_HOME
# maven home pattern: ~/.m2/wrapper/dists/{apache-maven-<version>,maven-mvnd-<version>-<platform>}/<hash>
if ($env:MVNW_REPOURL) {
$MVNW_REPO_PATTERN = if ($USE_MVND -eq $False) { "/org/apache/maven/" } else { "/maven/mvnd/" }
$distributionUrl = "$env:MVNW_REPOURL$MVNW_REPO_PATTERN$($distributionUrl -replace "^.*$MVNW_REPO_PATTERN",'')"
}
$distributionUrlName = $distributionUrl -replace '^.*/',''
$distributionUrlNameMain = $distributionUrlName -replace '\.[^.]*$','' -replace '-bin$',''
$MAVEN_M2_PATH = "$HOME/.m2"
if ($env:MAVEN_USER_HOME) {
$MAVEN_M2_PATH = "$env:MAVEN_USER_HOME"
}
if (-not (Test-Path -Path $MAVEN_M2_PATH)) {
New-Item -Path $MAVEN_M2_PATH -ItemType Directory | Out-Null
}
$MAVEN_WRAPPER_DISTS = $null
if ((Get-Item $MAVEN_M2_PATH).Target[0] -eq $null) {
$MAVEN_WRAPPER_DISTS = "$MAVEN_M2_PATH/wrapper/dists"
} else {
$MAVEN_WRAPPER_DISTS = (Get-Item $MAVEN_M2_PATH).Target[0] + "/wrapper/dists"
}
$MAVEN_HOME_PARENT = "$MAVEN_WRAPPER_DISTS/$distributionUrlNameMain"
$MAVEN_HOME_NAME = ([System.Security.Cryptography.SHA256]::Create().ComputeHash([byte[]][char[]]$distributionUrl) | ForEach-Object {$_.ToString("x2")}) -join ''
$MAVEN_HOME = "$MAVEN_HOME_PARENT/$MAVEN_HOME_NAME"
if (Test-Path -Path "$MAVEN_HOME" -PathType Container) {
Write-Verbose "found existing MAVEN_HOME at $MAVEN_HOME"
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"
exit $?
}
if (! $distributionUrlNameMain -or ($distributionUrlName -eq $distributionUrlNameMain)) {
Write-Error "distributionUrl is not valid, must end with *-bin.zip, but found $distributionUrl"
}
# prepare tmp dir
$TMP_DOWNLOAD_DIR_HOLDER = New-TemporaryFile
$TMP_DOWNLOAD_DIR = New-Item -Itemtype Directory -Path "$TMP_DOWNLOAD_DIR_HOLDER.dir"
$TMP_DOWNLOAD_DIR_HOLDER.Delete() | Out-Null
trap {
if ($TMP_DOWNLOAD_DIR.Exists) {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
}
New-Item -Itemtype Directory -Path "$MAVEN_HOME_PARENT" -Force | Out-Null
# Download and Install Apache Maven
Write-Verbose "Couldn't find MAVEN_HOME, downloading and installing it ..."
Write-Verbose "Downloading from: $distributionUrl"
Write-Verbose "Downloading to: $TMP_DOWNLOAD_DIR/$distributionUrlName"
$webclient = New-Object System.Net.WebClient
if ($env:MVNW_USERNAME -and $env:MVNW_PASSWORD) {
$webclient.Credentials = New-Object System.Net.NetworkCredential($env:MVNW_USERNAME, $env:MVNW_PASSWORD)
}
[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12
$webclient.DownloadFile($distributionUrl, "$TMP_DOWNLOAD_DIR/$distributionUrlName") | Out-Null
# If specified, validate the SHA-256 sum of the Maven distribution zip file
$distributionSha256Sum = (Get-Content -Raw "$scriptDir/.mvn/wrapper/maven-wrapper.properties" | ConvertFrom-StringData).distributionSha256Sum
if ($distributionSha256Sum) {
if ($USE_MVND) {
Write-Error "Checksum validation is not supported for maven-mvnd. `nPlease disable validation by removing 'distributionSha256Sum' from your maven-wrapper.properties."
}
Import-Module $PSHOME\Modules\Microsoft.PowerShell.Utility -Function Get-FileHash
if ((Get-FileHash "$TMP_DOWNLOAD_DIR/$distributionUrlName" -Algorithm SHA256).Hash.ToLower() -ne $distributionSha256Sum) {
Write-Error "Error: Failed to validate Maven distribution SHA-256, your Maven distribution might be compromised. If you updated your Maven version, you need to update the specified distributionSha256Sum property."
}
}
# unzip and move
Expand-Archive "$TMP_DOWNLOAD_DIR/$distributionUrlName" -DestinationPath "$TMP_DOWNLOAD_DIR" | Out-Null
# Find the actual extracted directory name (handles snapshots where filename != directory name)
$actualDistributionDir = ""
# First try the expected directory name (for regular distributions)
$expectedPath = Join-Path "$TMP_DOWNLOAD_DIR" "$distributionUrlNameMain"
$expectedMvnPath = Join-Path "$expectedPath" "bin/$MVN_CMD"
if ((Test-Path -Path $expectedPath -PathType Container) -and (Test-Path -Path $expectedMvnPath -PathType Leaf)) {
$actualDistributionDir = $distributionUrlNameMain
}
# If not found, search for any directory with the Maven executable (for snapshots)
if (!$actualDistributionDir) {
Get-ChildItem -Path "$TMP_DOWNLOAD_DIR" -Directory | ForEach-Object {
$testPath = Join-Path $_.FullName "bin/$MVN_CMD"
if (Test-Path -Path $testPath -PathType Leaf) {
$actualDistributionDir = $_.Name
}
}
}
if (!$actualDistributionDir) {
Write-Error "Could not find Maven distribution directory in extracted archive"
}
Write-Verbose "Found extracted Maven distribution directory: $actualDistributionDir"
Rename-Item -Path "$TMP_DOWNLOAD_DIR/$actualDistributionDir" -NewName $MAVEN_HOME_NAME | Out-Null
try {
Move-Item -Path "$TMP_DOWNLOAD_DIR/$MAVEN_HOME_NAME" -Destination $MAVEN_HOME_PARENT | Out-Null
} catch {
if (! (Test-Path -Path "$MAVEN_HOME" -PathType Container)) {
Write-Error "fail to move MAVEN_HOME"
}
} finally {
try { Remove-Item $TMP_DOWNLOAD_DIR -Recurse -Force | Out-Null }
catch { Write-Warning "Cannot remove $TMP_DOWNLOAD_DIR" }
}
Write-Output "MVN_CMD=$MAVEN_HOME/bin/$MVN_CMD"

183
pom.xml Normal file
View File

@@ -0,0 +1,183 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.1</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.example</groupId>
<artifactId>lab</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>lab</name>
<description>实验室预约与资源管理平台</description>
<properties>
<java.version>21</java.version>
<mybatis-plus.version>3.5.17</mybatis-plus.version>
<jjwt.version>0.12.6</jjwt.version>
<jacoco.version>0.8.13</jacoco.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>
<!-- ORM:MyBatis-Plus(Spring Boot 4 专用 starter) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot4-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- 分页插件依赖 JSqlParser(3.5.9 起独立成包) -->
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-jsqlparser</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<!-- 密码加密(BCrypt),仅引入 crypto 模块,避免整包 Spring Security -->
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-crypto</artifactId>
</dependency>
<!-- JWT -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<!-- 默认运行数据库:H2(file 模式,免安装即可演示) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- 生产数据库:MariaDB/MySQL -->
<dependency>
<groupId>org.mariadb.jdbc</groupId>
<artifactId>mariadb-java-client</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot 4 起 MockMvc 测试支持独立成模块 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
<!-- 覆盖率统计与门禁:核心包行覆盖率 >= 90% -->
<plugin>
<groupId>org.jacoco</groupId>
<artifactId>jacoco-maven-plugin</artifactId>
<version>${jacoco.version}</version>
<executions>
<execution>
<id>prepare-agent</id>
<goals>
<goal>prepare-agent</goal>
</goals>
</execution>
<execution>
<id>report</id>
<phase>test</phase>
<goals>
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<phase>verify</phase>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
<limit>
<counter>BRANCH</counter>
<value>COVEREDRATIO</value>
<minimum>0.85</minimum>
</limit>
</limits>
</rule>
</rules>
<excludes>
<exclude>com/example/lab/LabApplication.class</exclude>
<exclude>com/example/lab/domain/entity/**</exclude>
<exclude>com/example/lab/domain/enums/**</exclude>
<exclude>com/example/lab/dto/**</exclude>
<exclude>com/example/lab/vo/**</exclude>
<exclude>com/example/lab/common/api/**</exclude>
<exclude>com/example/lab/config/**</exclude>
</excludes>
</configuration>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,28 @@
package com.example.lab;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
import org.springframework.scheduling.annotation.EnableScheduling;
/**
* 实验室预约与资源管理平台启动类。
*
* <p>扫描 Mapper 接口、配置属性类,并开启定时任务(用于预约状态自动流转)。</p>
*/
@SpringBootApplication
@MapperScan("com.example.lab.mapper")
@ConfigurationPropertiesScan
@EnableScheduling
public class LabApplication {
/**
* 应用入口。
*
* @param args 命令行参数
*/
public static void main(String[] args) {
SpringApplication.run(LabApplication.class, args);
}
}

View File

@@ -0,0 +1,28 @@
package com.example.lab.common.annotation;
import com.example.lab.domain.enums.Role;
import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* 角色访问控制注解。
*
* <p>标注在 Controller 类或方法上,由 {@code AuthInterceptor} 在请求进入时校验;
* 方法级注解优先于类级注解。缺省表示「登录即可访问」。</p>
*/
@Documented
@Target({ElementType.TYPE, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireRole {
/**
* 允许访问的角色列表,满足任意一个即放行。
*
* @return 角色数组
*/
Role[] value();
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.common.api;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 统一响应包装。
*
* @param <T> 业务数据类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class ApiResponse<T> {
/** 业务状态码,0 表示成功 */
private int code;
/** 提示信息 */
private String message;
/** 业务数据 */
private T data;
/**
* 构造成功响应(无数据)。
*
* @param <T> 数据类型
* @return 成功响应
*/
public static <T> ApiResponse<T> ok() {
return new ApiResponse<>(0, "success", null);
}
/**
* 构造成功响应。
*
* @param data 业务数据
* @param <T> 数据类型
* @return 成功响应
*/
public static <T> ApiResponse<T> ok(T data) {
return new ApiResponse<>(0, "success", data);
}
/**
* 构造失败响应。
*
* @param code 业务状态码
* @param message 错误信息
* @param <T> 数据类型
* @return 失败响应
*/
public static <T> ApiResponse<T> error(int code, String message) {
return new ApiResponse<>(code, message, null);
}
}

View File

@@ -0,0 +1,42 @@
package com.example.lab.common.api;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.util.List;
/**
* 分页结果包装。
*
* @param <T> 列表元素类型
*/
@Data
@NoArgsConstructor
@AllArgsConstructor
public class PageResult<T> {
/** 当前页数据 */
private List<T> records;
/** 总记录数 */
private long total;
/** 页码,从 1 开始 */
private long page;
/** 每页大小 */
private long size;
/**
* 由 MyBatis-Plus 分页对象构造分页结果。
*
* @param page 分页对象
* @param <T> 元素类型
* @return 分页结果
*/
public static <T> PageResult<T> of(IPage<T> page) {
return new PageResult<>(page.getRecords(), page.getTotal(), page.getCurrent(), page.getSize());
}
}

View File

@@ -0,0 +1,32 @@
package com.example.lab.common.context;
import com.example.lab.domain.enums.Role;
/**
* 登录用户上下文。
*
* @param id 用户 ID
* @param username 登录名
* @param realName 真实姓名
* @param role 角色
*/
public record LoginUser(Long id, String username, String realName, Role role) {
/**
* 是否为管理员。
*
* @return true 表示管理员
*/
public boolean isAdmin() {
return role != null && role.isAdmin();
}
/**
* 是否具备审批资格。
*
* @return true 表示可审批
*/
public boolean canApprove() {
return role != null && role.canApprove();
}
}

View File

@@ -0,0 +1,65 @@
package com.example.lab.common.context;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
/**
* 基于 {@link ThreadLocal} 的当前登录用户上下文。
*
* <p>由认证拦截器写入、请求结束后清理,业务层通过
* {@link #requireUser()} / {@link #requireUserId()} 获取当前操作人。</p>
*/
public final class UserContext {
private static final ThreadLocal<LoginUser> HOLDER = new ThreadLocal<>();
private UserContext() {
}
/**
* 写入当前登录用户。
*
* @param user 登录用户
*/
public static void set(LoginUser user) {
HOLDER.set(user);
}
/**
* 获取当前登录用户,可能为 null。
*
* @return 登录用户或 null
*/
public static LoginUser get() {
return HOLDER.get();
}
/**
* 获取当前登录用户,未登录时抛出 401 业务异常。
*
* @return 登录用户
*/
public static LoginUser requireUser() {
LoginUser user = HOLDER.get();
if (user == null) {
throw new BizException(ErrorCode.UNAUTHORIZED);
}
return user;
}
/**
* 获取当前登录用户 ID。
*
* @return 用户 ID
*/
public static Long requireUserId() {
return requireUser().id();
}
/**
* 清理上下文,防止线程复用导致的数据串扰。
*/
public static void clear() {
HOLDER.remove();
}
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.common.exception;
import lombok.Getter;
/**
* 业务异常,携带 {@link ErrorCode} 便于全局异常处理器映射为统一响应。
*/
@Getter
public class BizException extends RuntimeException {
/** 业务错误码 */
private final ErrorCode errorCode;
/**
* 使用错误码默认信息构造异常。
*
* @param errorCode 业务错误码
*/
public BizException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}
/**
* 使用自定义信息构造异常。
*
* @param errorCode 业务错误码
* @param message 自定义错误信息
*/
public BizException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
/**
* 判断是否为指定错误码。
*
* @param code 错误码
* @return true 表示一致
*/
public boolean is(ErrorCode code) {
return this.errorCode == code;
}
}

View File

@@ -0,0 +1,75 @@
package com.example.lab.common.exception;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 业务错误码字典。
*
* <p>1xxx 用户与认证,2xxx 资源管理,3xxx 预约与冲突,9xxx 系统级错误。</p>
*/
@Getter
@RequiredArgsConstructor
public enum ErrorCode {
/** 请求参数不合法 */
BAD_REQUEST(400, "请求参数不合法"),
/** 未登录或登录已失效 */
UNAUTHORIZED(401, "未登录或登录已失效"),
/** 无权限执行该操作 */
FORBIDDEN(403, "无权限执行该操作"),
/** 资源不存在 */
NOT_FOUND(404, "资源不存在"),
/** 请求冲突 */
CONFLICT(409, "请求冲突"),
/** 服务器内部错误 */
INTERNAL_ERROR(500, "服务器内部错误"),
/** 用户名或密码错误 */
LOGIN_FAILED(1001, "用户名或密码错误"),
/** 账号被禁用 */
USER_DISABLED(1002, "账号已被禁用"),
/** 用户名已存在 */
USERNAME_EXISTS(1003, "用户名已存在"),
/** 用户不存在 */
USER_NOT_FOUND(1004, "用户不存在"),
/** 实验室不存在 */
LAB_NOT_FOUND(2001, "实验室不存在"),
/** 实验室编号重复 */
LAB_CODE_EXISTS(2002, "实验室编号已存在"),
/** 实验室当前状态不可预约 */
LAB_NOT_RESERVABLE(2003, "实验室当前状态不可预约"),
/** 设备不存在 */
DEVICE_NOT_FOUND(2101, "设备不存在"),
/** 设备编号重复 */
DEVICE_CODE_EXISTS(2102, "设备编号已存在"),
/** 设备不属于指定实验室 */
DEVICE_LAB_MISMATCH(2103, "设备不属于该实验室"),
/** 设备当前状态不可预约 */
DEVICE_NOT_RESERVABLE(2104, "设备当前状态不可预约"),
/** 课程不存在 */
COURSE_NOT_FOUND(2201, "课程不存在"),
/** 课程编号重复 */
COURSE_CODE_EXISTS(2202, "课程编号已存在"),
/** 课程已结束 */
COURSE_NOT_ACTIVE(2203, "课程已结束,无法生成预约"),
/** 预约不存在 */
RESERVATION_NOT_FOUND(3001, "预约不存在"),
/** 存在时间冲突 */
RESERVATION_CONFLICT(3002, "存在时间冲突"),
/** 预约状态不允许该操作 */
INVALID_STATE_TRANSITION(3003, "当前状态不允许该操作"),
/** 预约时间不合法 */
INVALID_TIME_RANGE(3004, "预约时间不合法"),
/** 重复规则不合法 */
INVALID_RECURRENCE_RULE(3005, "重复规则不合法"),
/** 超出容量限制 */
CAPACITY_EXCEEDED(3006, "参与人数超出实验室容量"),
/** 只能操作自己的数据 */
NOT_RESERVATION_OWNER(3007, "只能操作自己的预约");
private final int code;
private final String message;
}

View File

@@ -0,0 +1,107 @@
package com.example.lab.common.exception;
import com.example.lab.common.api.ApiResponse;
import lombok.extern.slf4j.Slf4j;
import org.springframework.dao.DuplicateKeyException;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
/**
* 全局异常处理器:把各种异常统一转换为 {@link ApiResponse},避免堆栈信息泄漏给前端。
*/
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
/**
* 处理业务异常。
*
* @param ex 业务异常
* @return 统一错误响应
*/
@ExceptionHandler(BizException.class)
public ApiResponse<Void> handleBizException(BizException ex) {
log.warn("业务异常: code={}, message={}", ex.getErrorCode().getCode(), ex.getMessage());
return ApiResponse.error(ex.getErrorCode().getCode(), ex.getMessage());
}
/**
* 处理请求体参数校验失败。
*
* @param ex 校验异常
* @return 统一错误响应,message 中包含每个字段的校验信息
*/
@ExceptionHandler(MethodArgumentNotValidException.class)
public ApiResponse<Void> handleValidationException(MethodArgumentNotValidException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理表单/查询参数绑定校验失败。
*
* @param ex 绑定异常
* @return 统一错误响应
*/
@ExceptionHandler(BindException.class)
public ApiResponse<Void> handleBindException(BindException ex) {
String message = ex.getBindingResult().getFieldErrors().stream()
.map(this::formatFieldError)
.collect(Collectors.joining("; "));
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), message);
}
/**
* 处理请求体无法解析(如日期格式错误、JSON 语法错误)。
*
* @param ex 解析异常
* @return 统一错误响应
*/
@ExceptionHandler(HttpMessageNotReadableException.class)
public ApiResponse<Void> handleNotReadable(HttpMessageNotReadableException ex) {
log.warn("请求体解析失败: {}", ex.getMessage());
return ApiResponse.error(ErrorCode.BAD_REQUEST.getCode(), "请求体格式错误");
}
/**
* 处理数据库唯一键冲突。
*
* @param ex 唯一键冲突异常
* @return 统一错误响应
*/
@ExceptionHandler(DuplicateKeyException.class)
public ApiResponse<Void> handleDuplicateKey(DuplicateKeyException ex) {
log.warn("唯一键冲突: {}", ex.getMessage());
return ApiResponse.error(ErrorCode.CONFLICT.getCode(), "数据已存在,请检查唯一字段");
}
/**
* 兜底异常处理。
*
* @param ex 未知异常
* @return 统一错误响应
*/
@ExceptionHandler(Exception.class)
public ApiResponse<Void> handleException(Exception ex) {
log.error("系统异常", ex);
return ApiResponse.error(ErrorCode.INTERNAL_ERROR.getCode(), ErrorCode.INTERNAL_ERROR.getMessage());
}
/**
* 格式化字段错误信息。
*
* @param error 字段错误
* @return "字段: 信息" 形式的文本
*/
private String formatFieldError(FieldError error) {
return error.getField() + ": " + error.getDefaultMessage();
}
}

View File

@@ -0,0 +1,93 @@
package com.example.lab.common.util;
import com.example.lab.common.context.LoginUser;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.config.LabProperties;
import com.example.lab.domain.enums.Role;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
/**
* JWT 工具:负责登录令牌的签发与解析。
*/
@Component
public class JwtUtils {
private final SecretKey key;
private final long expireMinutes;
/**
* 构造 JWT 工具。
*
* @param properties 业务配置,提供密钥与有效期
*/
public JwtUtils(LabProperties properties) {
byte[] secretBytes = properties.getJwt().getSecret().getBytes(StandardCharsets.UTF_8);
if (secretBytes.length < 32) {
throw new IllegalStateException("lab.jwt.secret 长度必须不少于 32 字节");
}
this.key = Keys.hmacShaKeyFor(secretBytes);
this.expireMinutes = properties.getJwt().getExpireMinutes();
}
/**
* 为登录用户签发令牌。
*
* @param user 登录用户
* @return JWT 字符串
*/
public String generateToken(LoginUser user) {
Date now = new Date();
Date expiration = new Date(now.getTime() + expireMinutes * 60_000L);
return Jwts.builder()
.subject(String.valueOf(user.id()))
.claim("username", user.username())
.claim("realName", user.realName())
.claim("role", user.role().name())
.issuedAt(now)
.expiration(expiration)
.signWith(key)
.compact();
}
/**
* 解析令牌并还原登录用户。
*
* @param token JWT 字符串
* @return 登录用户
* @throws BizException 令牌非法或已过期时抛出 401
*/
public LoginUser parseToken(String token) {
try {
Claims claims = Jwts.parser()
.verifyWith(key)
.build()
.parseSignedClaims(token)
.getPayload();
return new LoginUser(
Long.valueOf(claims.getSubject()),
claims.get("username", String.class),
claims.get("realName", String.class),
Role.valueOf(claims.get("role", String.class)));
} catch (JwtException | IllegalArgumentException | NullPointerException ex) {
throw new BizException(ErrorCode.UNAUTHORIZED, "登录令牌无效或已过期");
}
}
/**
* 获取令牌有效期(分钟)。
*
* @return 有效期分钟数
*/
public long getExpireMinutes() {
return expireMinutes;
}
}

View File

@@ -0,0 +1,70 @@
package com.example.lab.config;
import com.example.lab.domain.enums.ReservationStatus;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import java.util.EnumSet;
import java.util.List;
import java.util.Set;
/**
* 预约业务可调参数,对应 application.yaml 中 {@code lab.*} 配置。
*/
@Data
@ConfigurationProperties(prefix = "lab")
public class LabProperties {
/** JWT 相关配置 */
private Jwt jwt = new Jwt();
/** 预约规则配置 */
private Reservation reservation = new Reservation();
/**
* JWT 配置项。
*/
@Data
public static class Jwt {
/** 签名密钥,长度需 >= 32 字节 */
private String secret;
/** 令牌有效期(分钟) */
private long expireMinutes = 720;
}
/**
* 预约规则配置项。
*/
@Data
public static class Reservation {
/** 单次预约最大时长(小时) */
private int maxDurationHours = 8;
/** 最多提前预约天数 */
private int maxAdvanceDays = 60;
/** 允许的过去时间容差(分钟),用于「现在立刻开始」的场景 */
private int backwardToleranceMinutes = 30;
/** 参与冲突检测的预约状态 */
private List<ReservationStatus> blockingStatuses =
List.of(ReservationStatus.PENDING, ReservationStatus.APPROVED, ReservationStatus.IN_USE);
/** 重复预约系列允许生成的最大场次数 */
private int maxSeriesOccurrences = 60;
/**
* 获取参与冲突检测的状态集合。
*
* @return 状态集合
*/
public Set<ReservationStatus> blockingStatusSet() {
return blockingStatuses == null || blockingStatuses.isEmpty()
? ReservationStatus.DEFAULT_BLOCKING
: EnumSet.copyOf(blockingStatuses);
}
}
}

View File

@@ -0,0 +1,63 @@
package com.example.lab.config;
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.OptimisticLockerInnerInterceptor;
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
import org.apache.ibatis.reflection.MetaObject;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import java.time.LocalDateTime;
/**
* MyBatis-Plus 配置:分页插件、乐观锁插件、公共字段自动填充。
*/
@Configuration
public class MybatisPlusConfig {
/**
* 注册 MyBatis-Plus 拦截器链。
*
* @return 拦截器
*/
@Bean
public MybatisPlusInterceptor mybatisPlusInterceptor() {
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
interceptor.addInnerInterceptor(new OptimisticLockerInnerInterceptor());
interceptor.addInnerInterceptor(new PaginationInnerInterceptor());
return interceptor;
}
/**
* 注册公共字段填充器:createdAt / updatedAt 自动写入当前时间。
*
* @return 填充器
*/
@Bean
public MetaObjectHandler metaObjectHandler() {
return new MetaObjectHandler() {
/**
* 插入时填充创建时间与更新时间。
*
* @param metaObject 目标对象
*/
@Override
public void insertFill(MetaObject metaObject) {
LocalDateTime now = LocalDateTime.now();
strictInsertFill(metaObject, "createdAt", LocalDateTime.class, now);
strictInsertFill(metaObject, "updatedAt", LocalDateTime.class, now);
}
/**
* 更新时填充更新时间。
*
* @param metaObject 目标对象
*/
@Override
public void updateFill(MetaObject metaObject) {
strictUpdateFill(metaObject, "updatedAt", LocalDateTime.class, LocalDateTime.now());
}
};
}
}

View File

@@ -0,0 +1,23 @@
package com.example.lab.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
/**
* 安全相关 Bean 配置。
*/
@Configuration
public class SecurityConfig {
/**
* 密码编码器:使用 BCrypt(自带盐值,抗彩虹表)。
*
* @return 密码编码器
*/
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,45 @@
package com.example.lab.config;
import com.example.lab.security.AuthInterceptor;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Web MVC 配置:注册认证拦截器与跨域规则。
*/
@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
private final AuthInterceptor authInterceptor;
/**
* 注册认证拦截器,覆盖所有 {@code /api/**} 接口,登录接口除外。
*
* @param registry 拦截器注册表
*/
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(authInterceptor)
.addPathPatterns("/api/**")
.excludePathPatterns("/api/auth/login");
}
/**
* 配置跨域,方便前端开发服务器(Vite)直接联调。
*
* @param registry 跨域注册表
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.controller;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.dto.LoginRequest;
import com.example.lab.service.AuthService;
import com.example.lab.vo.LoginResponse;
import com.example.lab.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
* 认证接口:登录、当前用户、登出。
*/
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
/**
* 用户登录。
*
* @param request 登录请求
* @return 令牌与用户信息
*/
@PostMapping("/login")
public ApiResponse<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.ok(authService.login(request));
}
/**
* 查询当前登录用户。
*
* @return 用户信息
*/
@GetMapping("/me")
public ApiResponse<UserVO> me() {
return ApiResponse.ok(authService.currentUser());
}
/**
* 登出(JWT 无状态,前端丢弃令牌即可,此接口用于审计埋点)。
*
* @return 空响应
*/
@PostMapping("/logout")
public ApiResponse<Void> logout() {
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,136 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.CourseRequest;
import com.example.lab.service.CourseService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 实验课程接口:查询对所有登录用户开放,写操作仅管理员与教师可用。
*/
@RestController
@RequestMapping("/api/courses")
@RequiredArgsConstructor
public class CourseController {
private final CourseService courseService;
/**
* 分页查询课程。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param labId 实验室
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Course>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long labId,
@RequestParam(required = false) CourseStatus status) {
return ApiResponse.ok(courseService.page(page, size, keyword, labId, status));
}
/**
* 查询全部课程(下拉选项用)。
*
* @return 课程列表
*/
@GetMapping("/all")
public ApiResponse<List<Course>> all() {
return ApiResponse.ok(courseService.listAll());
}
/**
* 查询某教师开设的课程。
*
* @param teacherId 教师 ID
* @return 课程列表
*/
@GetMapping("/by-teacher/{teacherId}")
public ApiResponse<List<Course>> byTeacher(@PathVariable Long teacherId) {
return ApiResponse.ok(courseService.listByTeacher(teacherId));
}
/**
* 查询课程详情。
*
* @param id 课程 ID
* @return 课程
*/
@GetMapping("/{id}")
public ApiResponse<Course> get(@PathVariable Long id) {
return ApiResponse.ok(courseService.get(id));
}
/**
* 新增课程(管理员/教师)。
*
* @param request 新增请求
* @return 新建课程
*/
@PostMapping
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> create(@Valid @RequestBody CourseRequest request) {
return ApiResponse.ok(courseService.create(request));
}
/**
* 修改课程(管理员/授课教师)。
*
* @param id 课程 ID
* @param request 修改请求
* @return 更新后的课程
*/
@PutMapping("/{id}")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> update(@PathVariable Long id, @Valid @RequestBody CourseRequest request) {
return ApiResponse.ok(courseService.update(id, request));
}
/**
* 结束课程(管理员/授课教师)。
*
* @param id 课程 ID
* @return 更新后的课程
*/
@PatchMapping("/{id}/finish")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Course> finish(@PathVariable Long id) {
return ApiResponse.ok(courseService.finish(id));
}
/**
* 删除课程(管理员/授课教师)。
*
* @param id 课程 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<Void> delete(@PathVariable Long id) {
courseService.delete(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,128 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.DeviceRequest;
import com.example.lab.dto.StatusChangeRequest;
import com.example.lab.service.DeviceService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 设备接口:查询对所有登录用户开放,增删改与状态变更仅管理员可用。
*/
@RestController
@RequestMapping("/api/devices")
@RequiredArgsConstructor
public class DeviceController {
private final DeviceService deviceService;
/**
* 分页查询设备。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param labId 所属实验室
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Device>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) Long labId,
@RequestParam(required = false) DeviceStatus status) {
return ApiResponse.ok(deviceService.page(page, size, keyword, labId, status));
}
/**
* 查询实验室下的设备(预约表单联动用)。
*
* @param labId 实验室 ID
* @return 设备列表
*/
@GetMapping("/by-lab/{labId}")
public ApiResponse<List<Device>> byLab(@PathVariable Long labId) {
return ApiResponse.ok(deviceService.listByLab(labId));
}
/**
* 查询设备详情。
*
* @param id 设备 ID
* @return 设备
*/
@GetMapping("/{id}")
public ApiResponse<Device> get(@PathVariable Long id) {
return ApiResponse.ok(deviceService.get(id));
}
/**
* 新增设备(管理员)。
*
* @param request 新增请求
* @return 新建设备
*/
@PostMapping
@RequireRole(Role.ADMIN)
public ApiResponse<Device> create(@Valid @RequestBody DeviceRequest request) {
return ApiResponse.ok(deviceService.create(request));
}
/**
* 修改设备(管理员)。
*
* @param id 设备 ID
* @param request 修改请求
* @return 更新后的设备
*/
@PutMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Device> update(@PathVariable Long id, @Valid @RequestBody DeviceRequest request) {
return ApiResponse.ok(deviceService.update(id, request));
}
/**
* 删除设备(管理员)。
*
* @param id 设备 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Void> delete(@PathVariable Long id) {
deviceService.delete(id);
return ApiResponse.ok();
}
/**
* 变更设备状态(管理员)。
*
* @param id 设备 ID
* @param request 状态请求
* @return 更新后的设备
*/
@PatchMapping("/{id}/status")
@RequireRole(Role.ADMIN)
public ApiResponse<Device> changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) {
return ApiResponse.ok(deviceService.changeStatus(id, request.getDeviceStatus()));
}
}

View File

@@ -0,0 +1,143 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.enums.LabStatus;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.LabRequest;
import com.example.lab.dto.StatusChangeRequest;
import com.example.lab.service.LabService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDateTime;
import java.util.List;
/**
* 实验室接口:查询对所有登录用户开放,增删改与状态变更仅管理员可用。
*/
@RestController
@RequestMapping("/api/labs")
@RequiredArgsConstructor
public class LabController {
private final LabService labService;
/**
* 分页查询实验室。
*
* @param page 页码
* @param size 每页大小
* @param keyword 关键字
* @param status 状态
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<Lab>> page(@RequestParam(defaultValue = "1") long page,
@RequestParam(defaultValue = "10") long size,
@RequestParam(required = false) String keyword,
@RequestParam(required = false) LabStatus status) {
return ApiResponse.ok(labService.page(page, size, keyword, status));
}
/**
* 查询全部实验室(下拉选项用)。
*
* @return 实验室列表
*/
@GetMapping("/all")
public ApiResponse<List<Lab>> all() {
return ApiResponse.ok(labService.listAll());
}
/**
* 查询实验室详情。
*
* @param id 实验室 ID
* @return 实验室
*/
@GetMapping("/{id}")
public ApiResponse<Lab> get(@PathVariable Long id) {
return ApiResponse.ok(labService.get(id));
}
/**
* 查询指定时间段内可用的实验室。
*
* @param start 开始时间
* @param end 结束时间
* @param capacity 最少容量
* @return 可用实验室列表
*/
@GetMapping("/available")
public ApiResponse<List<Lab>> available(
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime start,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE_TIME) LocalDateTime end,
@RequestParam(required = false) Integer capacity) {
return ApiResponse.ok(labService.listAvailable(start, end, capacity));
}
/**
* 新增实验室(管理员)。
*
* @param request 新增请求
* @return 新建实验室
*/
@PostMapping
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> create(@Valid @RequestBody LabRequest request) {
return ApiResponse.ok(labService.create(request));
}
/**
* 修改实验室(管理员)。
*
* @param id 实验室 ID
* @param request 修改请求
* @return 更新后的实验室
*/
@PutMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> update(@PathVariable Long id, @Valid @RequestBody LabRequest request) {
return ApiResponse.ok(labService.update(id, request));
}
/**
* 删除实验室(管理员)。
*
* @param id 实验室 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
@RequireRole(Role.ADMIN)
public ApiResponse<Void> delete(@PathVariable Long id) {
labService.delete(id);
return ApiResponse.ok();
}
/**
* 变更实验室状态(管理员),流转受状态机约束。
*
* @param id 实验室 ID
* @param request 状态请求
* @return 更新后的实验室
*/
@PatchMapping("/{id}/status")
@RequireRole(Role.ADMIN)
public ApiResponse<Lab> changeStatus(@PathVariable Long id, @Valid @RequestBody StatusChangeRequest request) {
return ApiResponse.ok(labService.changeStatus(id, request.getLabStatus()));
}
}

View File

@@ -0,0 +1,183 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.common.api.PageResult;
import com.example.lab.domain.enums.Role;
import com.example.lab.domain.conflict.Conflict;
import com.example.lab.dto.ConflictCheckRequest;
import com.example.lab.dto.CourseReservationRequest;
import com.example.lab.dto.RecurringReservationRequest;
import com.example.lab.dto.RejectRequest;
import com.example.lab.dto.ReservationCreateRequest;
import com.example.lab.dto.ReservationQuery;
import com.example.lab.service.ReservationService;
import com.example.lab.vo.ConflictCheckVO;
import com.example.lab.vo.ReservationVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.format.annotation.DateTimeFormat;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.time.LocalDate;
import java.util.List;
/**
* 预约接口:单次/多次/课程绑定预约、冲突预检、审批与状态流转、日历视图。
*/
@RestController
@RequestMapping("/api/reservations")
@RequiredArgsConstructor
public class ReservationController {
private final ReservationService reservationService;
/**
* 创建单次预约。
*
* @param request 预约请求
* @return 新建预约
*/
@PostMapping
public ApiResponse<ReservationVO> create(@Valid @RequestBody ReservationCreateRequest request) {
return ApiResponse.ok(reservationService.create(request));
}
/**
* 创建多次预约。
*
* @param request 多次预约请求
* @return 生成的预约列表
*/
@PostMapping("/recurring")
public ApiResponse<List<ReservationVO>> createRecurring(@Valid @RequestBody RecurringReservationRequest request) {
return ApiResponse.ok(reservationService.createRecurring(request));
}
/**
* 创建课程绑定预约(管理员/授课教师)。
*
* @param request 课程预约请求
* @return 生成的预约列表
*/
@PostMapping("/course")
public ApiResponse<List<ReservationVO>> createForCourse(@Valid @RequestBody CourseReservationRequest request) {
return ApiResponse.ok(reservationService.createForCourse(request));
}
/**
* 冲突预检,不落库。
*
* @param request 预检请求
* @return 冲突检查结果
*/
@PostMapping("/check")
public ApiResponse<ConflictCheckVO> check(@Valid @RequestBody ConflictCheckRequest request) {
List<Conflict> conflicts = reservationService.check(request);
return ApiResponse.ok(new ConflictCheckVO(conflicts.isEmpty(), conflicts));
}
/**
* 分页查询预约。
*
* @param query 查询条件
* @return 分页结果
*/
@GetMapping
public ApiResponse<PageResult<ReservationVO>> page(ReservationQuery query) {
return ApiResponse.ok(reservationService.page(query));
}
/**
* 查询预约详情。
*
* @param id 预约 ID
* @return 预约详情
*/
@GetMapping("/{id}")
public ApiResponse<ReservationVO> get(@PathVariable Long id) {
return ApiResponse.ok(reservationService.get(id));
}
/**
* 查询实验室日历。
*
* @param labId 实验室 ID
* @param from 开始日期
* @param to 结束日期
* @return 预约列表
*/
@GetMapping("/calendar")
public ApiResponse<List<ReservationVO>> calendar(
@RequestParam Long labId,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate from,
@RequestParam @DateTimeFormat(iso = DateTimeFormat.ISO.DATE) LocalDate to) {
return ApiResponse.ok(reservationService.calendar(labId, from, to));
}
/**
* 审批通过(管理员/教师)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/approve")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<ReservationVO> approve(@PathVariable Long id) {
return ApiResponse.ok(reservationService.approve(id));
}
/**
* 驳回预约(管理员/教师)。
*
* @param id 预约 ID
* @param request 驳回请求
* @return 更新后的预约
*/
@PostMapping("/{id}/reject")
@RequireRole({Role.ADMIN, Role.TEACHER})
public ApiResponse<ReservationVO> reject(@PathVariable Long id, @Valid @RequestBody RejectRequest request) {
return ApiResponse.ok(reservationService.reject(id, request.getReason()));
}
/**
* 取消预约(本人或管理员)。
*
* @param id 预约 ID
* @param series 是否取消整个系列
* @return 受影响的预约列表
*/
@PostMapping("/{id}/cancel")
public ApiResponse<List<ReservationVO>> cancel(@PathVariable Long id,
@RequestParam(defaultValue = "false") boolean series) {
return ApiResponse.ok(reservationService.cancel(id, series));
}
/**
* 开始使用(本人或管理员)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/start")
public ApiResponse<ReservationVO> start(@PathVariable Long id) {
return ApiResponse.ok(reservationService.start(id));
}
/**
* 完成使用(本人或管理员)。
*
* @param id 预约 ID
* @return 更新后的预约
*/
@PostMapping("/{id}/complete")
public ApiResponse<ReservationVO> complete(@PathVariable Long id) {
return ApiResponse.ok(reservationService.complete(id));
}
}

View File

@@ -0,0 +1,89 @@
package com.example.lab.controller;
import com.example.lab.common.annotation.RequireRole;
import com.example.lab.common.api.ApiResponse;
import com.example.lab.domain.enums.Role;
import com.example.lab.dto.UserCreateRequest;
import com.example.lab.dto.UserUpdateRequest;
import com.example.lab.service.UserService;
import com.example.lab.vo.UserVO;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
/**
* 用户管理接口:仅管理员可访问。
*/
@RestController
@RequestMapping("/api/users")
@RequireRole(Role.ADMIN)
@RequiredArgsConstructor
public class UserController {
private final UserService userService;
/**
* 查询全部用户。
*
* @return 用户列表
*/
@GetMapping
public ApiResponse<List<UserVO>> list() {
return ApiResponse.ok(userService.list());
}
/**
* 查询用户详情。
*
* @param id 用户 ID
* @return 用户信息
*/
@GetMapping("/{id}")
public ApiResponse<UserVO> get(@PathVariable Long id) {
return ApiResponse.ok(userService.get(id));
}
/**
* 新增用户。
*
* @param request 新增请求
* @return 新建用户
*/
@PostMapping
public ApiResponse<UserVO> create(@Valid @RequestBody UserCreateRequest request) {
return ApiResponse.ok(userService.create(request));
}
/**
* 修改用户。
*
* @param id 用户 ID
* @param request 修改请求
* @return 更新后的用户
*/
@PutMapping("/{id}")
public ApiResponse<UserVO> update(@PathVariable Long id, @RequestBody UserUpdateRequest request) {
return ApiResponse.ok(userService.update(id, request));
}
/**
* 删除用户。
*
* @param id 用户 ID
* @return 空响应
*/
@DeleteMapping("/{id}")
public ApiResponse<Void> delete(@PathVariable Long id) {
userService.delete(id);
return ApiResponse.ok();
}
}

View File

@@ -0,0 +1,46 @@
package com.example.lab.domain.conflict;
/**
* 一条冲突明细。
*
* @param type 冲突类型
* @param message 可读的冲突描述
* @param slotIndex 请求时间段序号,从 0 开始(与请求 slots 顺序一致)
* @param conflictReservationId 冲突的已有预约 ID,非资源冲突时为 null
*/
public record Conflict(ConflictType type, String message, Integer slotIndex, Long conflictReservationId) {
/**
* 构造不关联已有预约的冲突。
*
* @param type 冲突类型
* @param message 描述
* @param slotIndex 时间段序号
* @return 冲突明细
*/
public static Conflict of(ConflictType type, String message, Integer slotIndex) {
return new Conflict(type, message, slotIndex, null);
}
/**
* 构造关联已有预约的冲突。
*
* @param type 冲突类型
* @param message 描述
* @param slotIndex 时间段序号
* @param reservation 冲突的已有预约 ID
* @return 冲突明细
*/
public static Conflict ofReservation(ConflictType type, String message, Integer slotIndex, Long reservation) {
return new Conflict(type, message, slotIndex, reservation);
}
/**
* 是否与已有预约冲突。
*
* @return true 表示由其他预约引起
*/
public boolean causedByReservation() {
return conflictReservationId != null;
}
}

View File

@@ -0,0 +1,358 @@
package com.example.lab.domain.conflict;
import com.example.lab.config.LabProperties;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.CourseStatus;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.Duration;
import java.time.LocalDateTime;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
import java.util.ArrayList;
import java.util.Comparator;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Objects;
import java.util.Set;
/**
* 预约冲突检测器:平台的核心算法组件。
*
* <p>检测分两层:</p>
* <ol>
* <li><b>规则校验</b>:时间区间合法性、是否过去、提前预约上限、单次时长、
* 开放时间、资源状态、容量、课程绑定关系;</li>
* <li><b>区间重叠检测</b>:对每个请求时间段,与已有「占用态」预约做两路归并扫描,
* 时间复杂度 O(n·log n + m·log m + k),其中 k 为实际重叠对数。</li>
* </ol>
*
* <p>本类不访问数据库,所有数据由 {@link ConflictQuery} 传入,便于单元测试与复用。</p>
*/
@Component
@RequiredArgsConstructor
public class ConflictDetector {
private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm");
private final LabProperties properties;
/**
* 执行完整的冲突检测。
*
* @param query 检测入参
* @return 冲突列表,空列表表示校验通过
*/
public List<Conflict> detect(ConflictQuery query) {
Objects.requireNonNull(query, "query 不能为 null");
List<Conflict> conflicts = new ArrayList<>();
List<TimeSlot> slots = query.getRequestedSlots();
if (slots == null || slots.isEmpty()) {
conflicts.add(Conflict.of(ConflictType.INVALID_TIME_RANGE, "预约时间段不能为空", null));
return conflicts;
}
LocalDateTime now = query.getNow() == null ? LocalDateTime.now() : query.getNow();
validateTimeWindow(query, slots, now, conflicts);
validateOpeningHours(query, slots, conflicts);
validateResourceAvailability(query, conflicts);
validateCapacity(query, conflicts);
validateCourseBinding(query, conflicts);
validateInternalOverlap(slots, conflicts);
conflicts.addAll(detectReservationConflicts(query));
return new ArrayList<>(new LinkedHashSet<>(conflicts));
}
/**
* 在请求时间段与已有预约之间执行重叠检测。
*
* <p>算法:两侧分别按开始时间升序排序,使用单调右移的指针 i 跳过
* 「结束时间不晚于当前已有预约开始时间」的请求段,再从 i 向后扫描
* 所有开始时间早于当前已有预约结束时间的请求段并逐一判断是否真正重叠。
* 由于已有预约也按开始时间有序,指针无需回退,整体保持近线性复杂度。</p>
*
* @param requested 请求时间段(可乱序)
* @param existing 已有预约(可乱序,允许包含无关记录)
* @param ignoreId 需要忽略的预约 ID(更新自身时使用)
* @return 重叠匹配列表
*/
public List<Overlap> findOverlaps(List<TimeSlot> requested, List<Reservation> existing, Long ignoreId) {
List<Overlap> overlaps = new ArrayList<>();
if (requested == null || existing == null) {
return overlaps;
}
List<IndexedSlot> slots = new ArrayList<>();
for (int index = 0; index < requested.size(); index++) {
TimeSlot slot = requested.get(index);
if (slot != null && slot.isValid()) {
slots.add(new IndexedSlot(index, slot));
}
}
slots.sort(Comparator.comparing(s -> s.slot().start()));
Set<ReservationStatus> blockingStatuses = properties.getReservation().blockingStatusSet();
List<Reservation> reservations = existing.stream()
.filter(Objects::nonNull)
.filter(r -> r.getId() == null || !r.getId().equals(ignoreId))
.filter(r -> r.getStatus() == null || blockingStatuses.contains(r.getStatus()))
.filter(r -> r.getStartTime() != null && r.getEndTime() != null
&& r.getStartTime().isBefore(r.getEndTime()))
.sorted(Comparator.comparing(Reservation::getStartTime))
.toList();
int pointer = 0;
for (Reservation reservation : reservations) {
TimeSlot reservationSlot = TimeSlot.of(reservation.getStartTime(), reservation.getEndTime());
while (pointer < slots.size() && !slots.get(pointer).slot().end().isAfter(reservationSlot.start())) {
pointer++;
}
for (int j = pointer; j < slots.size() && slots.get(j).slot().start().isBefore(reservationSlot.end()); j++) {
if (reservationSlot.overlapsWith(slots.get(j).slot())) {
overlaps.add(new Overlap(slots.get(j).index(), reservation));
}
}
}
return overlaps;
}
/**
* 校验每个时间段的合法性、过去时间、提前预约上限与单次时长。
*
* @param query 检测入参
* @param slots 请求时间段
* @param now 当前时间
* @param conflicts 冲突收集器
*/
private void validateTimeWindow(ConflictQuery query, List<TimeSlot> slots, LocalDateTime now,
List<Conflict> conflicts) {
int maxDurationHours = properties.getReservation().getMaxDurationHours();
int maxAdvanceDays = properties.getReservation().getMaxAdvanceDays();
int tolerance = properties.getReservation().getBackwardToleranceMinutes();
LocalDateTime earliest = now.minusMinutes(Math.max(tolerance, 0));
LocalDateTime latest = now.plusDays(Math.max(maxAdvanceDays, 0));
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot == null || !slot.isValid()) {
conflicts.add(Conflict.of(ConflictType.INVALID_TIME_RANGE, "第 " + (index + 1) + " 个时间段不合法", index));
continue;
}
if (slot.start().isBefore(earliest)) {
conflicts.add(Conflict.of(ConflictType.PAST_TIME,
"第 " + (index + 1) + " 个时间段早于当前时间", index));
}
if (slot.start().isAfter(latest)) {
conflicts.add(Conflict.of(ConflictType.ADVANCE_LIMIT_EXCEEDED,
"最多只能提前 " + maxAdvanceDays + " 天预约", index));
}
long minutes = slot.durationMinutes();
if (minutes > Duration.ofHours(Math.max(maxDurationHours, 0)).toMinutes()) {
conflicts.add(Conflict.of(ConflictType.DURATION_EXCEEDED,
"单次预约不能超过 " + maxDurationHours + " 小时", index));
}
}
}
/**
* 校验时间段是否落在实验室开放时间之内。
*
* @param query 检测入参
* @param slots 请求时间段
* @param conflicts 冲突收集器
*/
private void validateOpeningHours(ConflictQuery query, List<TimeSlot> slots, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab == null || lab.getOpenTime() == null || lab.getCloseTime() == null) {
return;
}
LocalTime open = lab.getOpenTime();
LocalTime close = lab.getCloseTime();
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot == null || !slot.isValid()) {
continue;
}
boolean sameDay = slot.isSameDay();
LocalTime start = slot.start().toLocalTime();
LocalTime end = slot.end().toLocalTime();
boolean endAtMidnight = end.equals(LocalTime.MIDNIGHT);
boolean insideOpen = !start.isBefore(open);
boolean insideClose = endAtMidnight || !end.isAfter(close);
if (!sameDay && !endAtMidnight) {
conflicts.add(Conflict.of(ConflictType.OUTSIDE_OPENING_HOURS,
"暂不支持跨天预约,第 " + (index + 1) + " 个时间段跨天", index));
} else if (!insideOpen || !insideClose) {
conflicts.add(Conflict.of(ConflictType.OUTSIDE_OPENING_HOURS,
"第 " + (index + 1) + " 个时间段不在开放时间 " + open + "~" + close + " 内", index));
}
}
}
/**
* 校验实验室与设备当前状态是否允许预约。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateResourceAvailability(ConflictQuery query, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab != null && lab.getStatus() != null && !lab.getStatus().isReservable()) {
conflicts.add(Conflict.of(ConflictType.RESOURCE_UNAVAILABLE,
"实验室「" + lab.getName() + "」当前状态为" + lab.getStatus().getLabel(), null));
}
Device device = query.getDevice();
if (device != null && device.getStatus() != null && !device.getStatus().isReservable()) {
conflicts.add(Conflict.of(ConflictType.RESOURCE_UNAVAILABLE,
"设备「" + device.getName() + "」当前状态为" + device.getStatus().getLabel(), null));
}
}
/**
* 校验参与人数是否超过实验室容量。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateCapacity(ConflictQuery query, List<Conflict> conflicts) {
Lab lab = query.getLab();
if (lab == null) {
return;
}
Integer capacity = lab.getCapacity();
Integer participants = query.getParticipants();
if (capacity != null && participants != null && participants > capacity) {
conflicts.add(Conflict.of(ConflictType.CAPACITY_EXCEEDED,
"参与人数 " + participants + " 超过实验室容量 " + capacity, null));
}
}
/**
* 校验课程绑定预约的关联关系:必须有课程、课程实验室一致、课程未结束。
*
* @param query 检测入参
* @param conflicts 冲突收集器
*/
private void validateCourseBinding(ConflictQuery query, List<Conflict> conflicts) {
if (query.getType() != ReservationType.COURSE) {
return;
}
Course course = query.getCourse();
if (course == null) {
conflicts.add(Conflict.of(ConflictType.COURSE_REQUIRED, "课程绑定预约必须指定课程", null));
return;
}
Lab lab = query.getLab();
if (lab != null && course.getLabId() != null && !course.getLabId().equals(lab.getId())) {
conflicts.add(Conflict.of(ConflictType.COURSE_LAB_MISMATCH,
"课程「" + course.getName() + "」绑定的是其他实验室", null));
}
if (course.getStatus() == CourseStatus.FINISHED) {
conflicts.add(Conflict.of(ConflictType.COURSE_NOT_ACTIVE,
"课程「" + course.getName() + "」已结束", null));
}
if (lab != null && lab.getCapacity() != null && course.getStudentCount() != null
&& course.getStudentCount() > lab.getCapacity()) {
conflicts.add(Conflict.of(ConflictType.CAPACITY_EXCEEDED,
"课程选课人数 " + course.getStudentCount() + " 超过实验室容量 " + lab.getCapacity(), null));
}
}
/**
* 校验请求内部多个时间段是否互相重叠(例如重复规则中同一天出现两次)。
*
* @param slots 请求时间段
* @param conflicts 冲突收集器
*/
private void validateInternalOverlap(List<TimeSlot> slots, List<Conflict> conflicts) {
List<IndexedSlot> valid = new ArrayList<>();
for (int index = 0; index < slots.size(); index++) {
TimeSlot slot = slots.get(index);
if (slot != null && slot.isValid()) {
valid.add(new IndexedSlot(index, slot));
}
}
valid.sort(Comparator.comparing(s -> s.slot().start()));
for (int i = 1; i < valid.size(); i++) {
IndexedSlot previous = valid.get(i - 1);
IndexedSlot current = valid.get(i);
if (previous.slot().overlapsWith(current.slot())) {
conflicts.add(Conflict.of(ConflictType.INTERNAL_OVERLAP,
"第 " + (previous.index() + 1) + " 与第 " + (current.index() + 1) + " 个时间段相互重叠",
current.index()));
}
}
}
/**
* 把与已有预约的重叠匹配转换成带类型的冲突明细。
*
* <p>分类优先级:同实验室 &gt; 同设备 &gt; 同用户,保证一条已有预约最多产生一条冲突。</p>
*
* @param query 检测入参
* @return 冲突列表
*/
private List<Conflict> detectReservationConflicts(ConflictQuery query) {
List<Conflict> conflicts = new ArrayList<>();
for (Overlap overlap : findOverlaps(query.getRequestedSlots(), query.getExistingReservations(),
query.getIgnoreReservationId())) {
Reservation reservation = overlap.reservation();
ConflictType type;
String resource;
if (sameId(query.getLab() == null ? null : query.getLab().getId(), reservation.getLabId())) {
type = ConflictType.LAB_CONFLICT;
resource = "实验室";
} else if (query.getDevice() != null
&& sameId(query.getDevice().getId(), reservation.getDeviceId())) {
type = ConflictType.DEVICE_CONFLICT;
resource = "设备";
} else if (sameId(query.getUserId(), reservation.getUserId())) {
type = ConflictType.USER_CONFLICT;
resource = "本人";
} else {
continue;
}
conflicts.add(Conflict.ofReservation(type,
resource + "在 " + format(reservation) + " 已被预约 #" + reservation.getId(),
overlap.slotIndex(), reservation.getId()));
}
return conflicts;
}
/**
* 判断两个 ID 是否相同且非空。
*
* @param left 左 ID
* @param right 右 ID
* @return true 表示相同
*/
private boolean sameId(Long left, Long right) {
return left != null && left.equals(right);
}
/**
* 格式化已有预约时间用于提示。
*
* @param reservation 预约
* @return 时间文本
*/
private String format(Reservation reservation) {
return TIME_FORMAT.format(reservation.getStartTime()) + "~" + reservation.getEndTime().toLocalTime();
}
/**
* 带原始下标的时间段,排序后仍能定位到请求顺序。
*
* @param index 原始下标
* @param slot 时间段
*/
private record IndexedSlot(int index, TimeSlot slot) {
}
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.domain.conflict;
import com.example.lab.domain.entity.Course;
import com.example.lab.domain.entity.Device;
import com.example.lab.domain.entity.Lab;
import com.example.lab.domain.entity.Reservation;
import com.example.lab.domain.enums.ReservationType;
import lombok.Builder;
import lombok.Data;
import java.time.LocalDateTime;
import java.util.List;
/**
* 冲突检测输入参数。
*
* <p>该类是纯粹的入参载体:调用方负责把数据库中的实验室、设备、课程、
* 候选时间段以及可能冲突的已有预约一次性查出来,检测器本身不访问数据库,
* 因此可以非常方便地进行单元测试。</p>
*/
@Data
@Builder
public class ConflictQuery {
/** 请求占用的时间段列表(单次预约只有 1 个,多次/课程预约有 N 个) */
private List<TimeSlot> requestedSlots;
/** 可能参与冲突的已有预约(建议只查候选时间范围内的记录) */
private List<Reservation> existingReservations;
/** 校验时忽略的预约 ID(更新或审批已有预约时排除自身) */
private Long ignoreReservationId;
/** 目标实验室 */
private Lab lab;
/** 目标设备,可为空 */
private Device device;
/** 关联课程,仅课程绑定预约需要 */
private Course course;
/** 参与人数 */
private Integer participants;
/** 发起预约的用户 ID,用于检测「本人时间冲突」 */
private Long userId;
/** 预约类型 */
private ReservationType type;
/** 当前时间,允许注入以便测试 */
private LocalDateTime now;
}

View File

@@ -0,0 +1,43 @@
package com.example.lab.domain.conflict;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 冲突类型字典,每种类型对应一条明确的预约规则。
*/
@Getter
@RequiredArgsConstructor
public enum ConflictType {
/** 时间段为空或 end <= start */
INVALID_TIME_RANGE("时间区间不合法"),
/** 预约时间早于当前时间 */
PAST_TIME("不能预约过去的时间"),
/** 超出最大提前预约天数 */
ADVANCE_LIMIT_EXCEEDED("超出可提前预约的天数上限"),
/** 单次时长超过上限 */
DURATION_EXCEEDED("单次预约时长超过上限"),
/** 落在实验室开放时间之外或跨天 */
OUTSIDE_OPENING_HOURS("不在实验室开放时间内"),
/** 实验室/设备处于维护中等不可预约状态 */
RESOURCE_UNAVAILABLE("资源当前不可预约"),
/** 参与人数超过实验室容量 */
CAPACITY_EXCEEDED("参与人数超过实验室容量"),
/** 课程绑定预约缺少课程 */
COURSE_REQUIRED("课程绑定预约必须关联课程"),
/** 课程与实验室不匹配 */
COURSE_LAB_MISMATCH("课程绑定的实验室与预约实验室不一致"),
/** 课程已结束 */
COURSE_NOT_ACTIVE("课程已结束,不能生成预约"),
/** 请求内部多个时间段彼此重叠 */
INTERNAL_OVERLAP("提交的多个时间段相互重叠"),
/** 与同实验室已有预约冲突 */
LAB_CONFLICT("实验室在该时间段已被占用"),
/** 与同设备已有预约冲突 */
DEVICE_CONFLICT("设备在该时间段已被占用"),
/** 与本人的其他预约冲突 */
USER_CONFLICT("该时间段与本人的其他预约冲突");
private final String label;
}

View File

@@ -0,0 +1,12 @@
package com.example.lab.domain.conflict;
import com.example.lab.domain.entity.Reservation;
/**
* 单次预约请求与已有预约的重叠匹配结果。
*
* @param slotIndex 请求时间段序号,从 0 开始
* @param reservation 与之重叠的已有预约
*/
public record Overlap(int slotIndex, Reservation reservation) {
}

View File

@@ -0,0 +1,154 @@
package com.example.lab.domain.conflict;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import com.example.lab.config.LabProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.ArrayList;
import java.util.EnumSet;
import java.util.LinkedHashSet;
import java.util.List;
import java.util.Locale;
import java.util.Set;
/**
* 重复规则展开器:把「每周几 + 起止日期 + 每日时间段」展开成具体的时间段列表。
*
* <p>支持两种星期写法:英文缩写({@code MON,WED,FRI})与 ISO 数字({@code 1,3,5},1 表示周一)。</p>
*/
@Component
@RequiredArgsConstructor
public class RecurrenceExpander {
/** 英文缩写到 DayOfWeek 的映射 */
private static final Set<String> ENGLISH_ALIASES = Set.of(
"MON", "TUES", "TUE", "WED", "THUR", "THU", "FRI", "SAT", "SUN");
private final LabProperties properties;
/**
* 展开重复规则。
*
* @param rule 重复规则
* @return 按时间升序排列的时间段列表
* @throws BizException 规则非法、无有效场次或超过最大场次数时抛出
*/
public List<TimeSlot> expand(RecurrenceRule rule) {
validate(rule);
int maxOccurrences = Math.max(properties.getReservation().getMaxSeriesOccurrences(), 1);
List<TimeSlot> slots = new ArrayList<>();
LocalDate date = rule.startDate();
while (!date.isAfter(rule.endDate())) {
if (rule.weekdays().contains(date.getDayOfWeek())) {
if (slots.size() >= maxOccurrences) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE,
"重复预约场次超过上限 " + maxOccurrences + " 次");
}
slots.add(TimeSlot.of(date.atTime(rule.startTime()), date.atTime(rule.endTime())));
}
date = date.plusDays(1);
}
if (slots.isEmpty()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复规则没有生成任何有效场次");
}
return slots;
}
/**
* 解析星期字符串。
*
* @param text 形如 "MON,WED,FRI"、"1,3,5" 的文本
* @return 星期集合
* @throws BizException 文本为空或包含非法记号时抛出
*/
public Set<DayOfWeek> parseWeekdays(String text) {
if (text == null || text.isBlank()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复星期不能为空");
}
Set<DayOfWeek> weekdays = EnumSet.noneOf(DayOfWeek.class);
for (String token : text.split(",")) {
String normalized = token.trim().toUpperCase(Locale.ROOT);
if (normalized.isEmpty()) {
continue;
}
weekdays.add(parseWeekday(normalized));
}
if (weekdays.isEmpty()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复星期不能为空");
}
return weekdays;
}
/**
* 解析单个星期记号。
*
* @param token 星期记号,如 MON / 1
* @return 星期
* @throws BizException 记号非法时抛出
*/
private DayOfWeek parseWeekday(String token) {
if (token.length() == 1 && Character.isDigit(token.charAt(0))) {
int value = Integer.parseInt(token);
if (value >= 1 && value <= 7) {
return DayOfWeek.of(value);
}
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "非法星期记号: " + token);
}
if (ENGLISH_ALIASES.contains(token)) {
return switch (token) {
case "MON" -> DayOfWeek.MONDAY;
case "TUE", "TUES" -> DayOfWeek.TUESDAY;
case "WED" -> DayOfWeek.WEDNESDAY;
case "THU", "THUR" -> DayOfWeek.THURSDAY;
case "FRI" -> DayOfWeek.FRIDAY;
case "SAT" -> DayOfWeek.SATURDAY;
default -> DayOfWeek.SUNDAY;
};
}
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "非法星期记号: " + token);
}
/**
* 校验重复规则的完整性。
*
* @param rule 重复规则
* @throws BizException 规则不完整或时间区间非法时抛出
*/
private void validate(RecurrenceRule rule) {
if (rule == null || !rule.isComplete()) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "重复规则参数不完整");
}
if (rule.endDate().isBefore(rule.startDate())) {
throw new BizException(ErrorCode.INVALID_RECURRENCE_RULE, "结束日期不能早于开始日期");
}
LocalTime start = rule.startTime();
LocalTime end = rule.endTime();
if (!start.isBefore(end)) {
throw new BizException(ErrorCode.INVALID_TIME_RANGE, "每日开始时间必须早于结束时间");
}
}
/**
* 按天返回原始日期集合(供调试与统计使用)。
*
* @param rule 重复规则
* @return 命中的日期集合,保持插入顺序
*/
public Set<LocalDate> resolveDates(RecurrenceRule rule) {
validate(rule);
Set<LocalDate> dates = new LinkedHashSet<>();
LocalDate date = rule.startDate();
while (!date.isAfter(rule.endDate())) {
if (rule.weekdays().contains(date.getDayOfWeek())) {
dates.add(date);
}
date = date.plusDays(1);
}
return dates;
}
}

View File

@@ -0,0 +1,33 @@
package com.example.lab.domain.conflict;
import java.time.DayOfWeek;
import java.time.LocalDate;
import java.time.LocalTime;
import java.util.Set;
/**
* 重复预约规则:在 {@code [startDate, endDate]} 日期区间内,
* 每个命中的星期产生一个 {@code [startTime, endTime)} 时间段。
*
* @param startDate 重复起始日期(含)
* @param endDate 重复结束日期(含)
* @param weekdays 命中的星期集合
* @param startTime 每次开始时间
* @param endTime 每次结束时间
*/
public record RecurrenceRule(LocalDate startDate,
LocalDate endDate,
Set<DayOfWeek> weekdays,
LocalTime startTime,
LocalTime endTime) {
/**
* 规则是否基本完整(非空字段)。
*
* @return true 表示字段齐全
*/
public boolean isComplete() {
return startDate != null && endDate != null && weekdays != null && !weekdays.isEmpty()
&& startTime != null && endTime != null;
}
}

View File

@@ -0,0 +1,86 @@
package com.example.lab.domain.conflict;
import java.time.Duration;
import java.time.LocalDateTime;
/**
* 左闭右开时间区间 {@code [start, end)},是冲突检测的最小计算单元。
*
* <p>约定:区间长度为 0 或为负均视为非法;相邻区间(前者的 end 等于后者的 start)
* <b>不</b>算冲突,这样可以支持「上一场刚结束、下一场立即开始」的连续排课。</p>
*
* @param start 开始时间(含)
* @param end 结束时间(不含)
*/
public record TimeSlot(LocalDateTime start, LocalDateTime end) {
/**
* 构造时间区间。
*
* @param start 开始时间
* @param end 结束时间
* @return 时间区间
*/
public static TimeSlot of(LocalDateTime start, LocalDateTime end) {
return new TimeSlot(start, end);
}
/**
* 判断区间是否合法。
*
* @return true 表示 start、end 均非空且 start 早于 end
*/
public boolean isValid() {
return start != null && end != null && start.isBefore(end);
}
/**
* 区间时长。
*
* @return 时长
*/
public Duration duration() {
return Duration.between(start, end);
}
/**
* 区间时长(分钟)。
*
* @return 分钟数
*/
public long durationMinutes() {
return duration().toMinutes();
}
/**
* 判断与另一区间是否重叠(左闭右开语义,边界相接不算重叠)。
*
* @param other 另一区间
* @return true 表示存在实际重叠
*/
public boolean overlapsWith(TimeSlot other) {
if (other == null || !isValid() || !other.isValid()) {
return false;
}
return start.isBefore(other.end) && other.start.isBefore(end);
}
/**
* 判断区间是否包含某时刻,采用 {@code [start, end)} 语义。
*
* @param time 时刻
* @return true 表示包含
*/
public boolean contains(LocalDateTime time) {
return time != null && !time.isBefore(start) && time.isBefore(end);
}
/**
* 是否落在同一天内(跨天预约需要单独审批,平台默认不允许)。
*
* @return true 表示开始与结束在同一天
*/
public boolean isSameDay() {
return isValid() && start.toLocalDate().equals(end.toLocalDate());
}
}

View File

@@ -0,0 +1,75 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.CourseStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 实验课程实体。
*
* <p>课程自带周期性排课规则(起止日期 + 星期 + 每日时间段),
* 教师可基于课程一键生成整学期的课程绑定预约。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("course")
public class Course {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 课程编号,全局唯一 */
private String code;
/** 课程名称 */
private String name;
/** 授课教师 ID */
private Long teacherId;
/** 绑定实验室 ID */
private Long labId;
/** 选课人数 */
private Integer studentCount;
/** 课程开始日期 */
private LocalDate startDate;
/** 课程结束日期 */
private LocalDate endDate;
/** 上课星期,如 "MON,WED" */
private String weekdays;
/** 每次上课开始时间 */
private LocalTime startTime;
/** 每次上课结束时间 */
private LocalTime endTime;
/** 课程状态 */
private CourseStatus status;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,55 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.DeviceStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 设备实体,设备必须隶属于某个实验室。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("device")
public class Device {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属实验室 ID */
private Long labId;
/** 设备编号,全局唯一 */
private String code;
/** 设备名称 */
private String name;
/** 设备型号 */
private String model;
/** 描述 */
private String description;
/** 状态:空闲 / 已预约 / 使用中 / 维护中 */
private DeviceStatus status;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,62 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.LabStatus;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 实验室实体。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("lab")
public class Lab {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 实验室编号,全局唯一 */
private String code;
/** 实验室名称 */
private String name;
/** 所在位置 */
private String location;
/** 容纳人数 */
private Integer capacity;
/** 描述 */
private String description;
/** 状态:空闲 / 已预约 / 使用中 / 维护中 */
private LabStatus status;
/** 开放开始时间,如 08:00 */
private LocalTime openTime;
/** 开放结束时间,如 22:00 */
private LocalTime closeTime;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,91 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.baomidou.mybatisplus.annotation.Version;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 预约场次实体,是整个系统的核心业务对象。
*
* <p>冲突检测的最小单位就是一条 {@code Reservation} 的
* [{@code startTime}, {@code endTime}) 左闭右开区间。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("reservation")
public class Reservation {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 所属系列 ID,单次预约为空 */
private Long seriesId;
/** 预约人 ID */
private Long userId;
/** 实验室 ID */
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 课程 ID,课程绑定预约必填 */
private Long courseId;
/** 预约类型 */
private ReservationType type;
/** 标题 */
private String title;
/** 用途说明 */
private String purpose;
/** 开始时间(含) */
private LocalDateTime startTime;
/** 结束时间(不含) */
private LocalDateTime endTime;
/** 参与人数 */
private Integer participants;
/** 状态 */
private ReservationStatus status;
/** 驳回原因 */
private String rejectReason;
/** 审批人 ID */
private Long approverId;
/** 审批时间 */
private LocalDateTime approvedAt;
/** 乐观锁版本号,防止并发审批覆盖 */
@Version
private Integer version;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,78 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.ReservationType;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 重复预约系列实体。
*
* <p>「多次预约」在数据库中拆分为:一条系列记录 + N 条 {@link Reservation} 场次记录,
* 这样每一次占用都是独立的行,冲突检测与审批都可以按统一的时间区间模型处理。</p>
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("reservation_series")
public class ReservationSeries {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 发起人 ID */
private Long userId;
/** 实验室 ID */
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 系列类型:多次预约 / 课程绑定预约 */
private ReservationType type;
/** 标题 */
private String title;
/** 用途说明 */
private String purpose;
/** 重复开始日期 */
private LocalDate startDate;
/** 重复结束日期 */
private LocalDate endDate;
/** 重复星期,如 "MON,WED" */
private String weekdays;
/** 每次占用开始时间 */
private LocalTime startTime;
/** 每次占用结束时间 */
private LocalTime endTime;
/** 参与人数 */
private Integer participants;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.domain.entity;
import com.baomidou.mybatisplus.annotation.FieldFill;
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import com.example.lab.domain.enums.Role;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.time.LocalDateTime;
/**
* 系统用户实体(管理员 / 教师 / 学生)。
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
@TableName("sys_user")
public class User {
/** 主键 */
@TableId(type = IdType.AUTO)
private Long id;
/** 登录名,全局唯一 */
private String username;
/** BCrypt 加密后的密码 */
private String password;
/** 真实姓名 */
private String realName;
/** 角色 */
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
/** 是否启用 */
private Boolean enabled;
/** 创建时间 */
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createdAt;
/** 更新时间 */
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updatedAt;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 实验课程状态。
*/
@Getter
@RequiredArgsConstructor
public enum CourseStatus {
/** 进行中,可生成课程绑定预约 */
ACTIVE("进行中"),
/** 已结束 */
FINISHED("已结束");
private final String label;
}

View File

@@ -0,0 +1,32 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 设备状态机中的状态定义,流转规则与 {@link LabStatus} 一致。
*/
@Getter
@RequiredArgsConstructor
public enum DeviceStatus {
/** 空闲,可被预约 */
IDLE("空闲"),
/** 已被预约 */
RESERVED("已预约"),
/** 使用中 */
IN_USE("使用中"),
/** 维护中,不可预约 */
MAINTENANCE("维护中");
private final String label;
/**
* 是否允许被预约。
*
* @return true 表示可预约
*/
public boolean isReservable() {
return this == IDLE || this == RESERVED;
}
}

View File

@@ -0,0 +1,39 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 实验室状态机中的状态定义。
*
* <p>合法流转路径(详见 {@code LabStateMachine}):</p>
* <pre>
* IDLE(空闲) --预约--> RESERVED(已预约) --开始使用--> IN_USE(使用中) --结束--> IDLE
* IDLE --报修--> MAINTENANCE(维护中) --完成--> IDLE
* RESERVED --取消/驳回--> IDLE
* </pre>
*/
@Getter
@RequiredArgsConstructor
public enum LabStatus {
/** 空闲,可被预约 */
IDLE("空闲"),
/** 已被预约(当前时段存在有效预约) */
RESERVED("已预约"),
/** 使用中 */
IN_USE("使用中"),
/** 维护中,不可预约 */
MAINTENANCE("维护中");
private final String label;
/**
* 是否允许被预约。
*
* @return true 表示可预约
*/
public boolean isReservable() {
return this == IDLE || this == RESERVED;
}
}

View File

@@ -0,0 +1,58 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
import java.util.EnumSet;
import java.util.Set;
/**
* 预约状态机中的状态定义。
*
* <pre>
* PENDING(待审批) --通过--> APPROVED(已通过) --开始--> IN_USE(使用中) --结束--> COMPLETED(已完成)
* PENDING --驳回--> REJECTED(已驳回)
* PENDING/APPROVED --取消--> CANCELLED(已取消)
* </pre>
*/
@Getter
@RequiredArgsConstructor
public enum ReservationStatus {
/** 待审批 */
PENDING("待审批"),
/** 已通过 */
APPROVED("已通过"),
/** 已驳回 */
REJECTED("已驳回"),
/** 已取消 */
CANCELLED("已取消"),
/** 使用中 */
IN_USE("使用中"),
/** 已完成 */
COMPLETED("已完成");
private final String label;
/** 参与时间冲突检测的状态集合:只有这些状态代表资源被实际占用。 */
public static final Set<ReservationStatus> DEFAULT_BLOCKING =
EnumSet.of(PENDING, APPROVED, IN_USE);
/**
* 默认是否占用资源(参与冲突检测)。
*
* @return true 表示占用资源
*/
public boolean isBlocking() {
return DEFAULT_BLOCKING.contains(this);
}
/**
* 是否为终态(不可再流转)。
*
* @return true 表示终态
*/
public boolean isTerminal() {
return this == REJECTED || this == CANCELLED || this == COMPLETED;
}
}

View File

@@ -0,0 +1,21 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 预约类型。
*/
@Getter
@RequiredArgsConstructor
public enum ReservationType {
/** 单次预约:一次提交仅占用一个时间段 */
SINGLE("单次预约"),
/** 多次预约:按周规则批量生成多个时间段(同一系列) */
RECURRING("多次预约"),
/** 课程绑定预约:由实验课程自动展开全部上课时间 */
COURSE("课程绑定预约");
private final String label;
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.domain.enums;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
/**
* 系统角色枚举,用于 RBAC 权限控制。
*
* <ul>
* <li>{@link #ADMIN} 管理员:资源、用户、预约的全量管理权限</li>
* <li>{@link #TEACHER} 教师:创建课程/课程绑定预约,审批本课程预约</li>
* <li>{@link #STUDENT} 学生:创建个人预约、查看自己的预约</li>
* </ul>
*/
@Getter
@RequiredArgsConstructor
public enum Role {
/** 管理员 */
ADMIN("管理员"),
/** 教师 */
TEACHER("教师"),
/** 学生 */
STUDENT("学生");
private final String label;
/**
* 是否为管理员。
*
* @return true 表示管理员角色
*/
public boolean isAdmin() {
return this == ADMIN;
}
/**
* 是否为教师。
*
* @return true 表示教师角色
*/
public boolean isTeacher() {
return this == TEACHER;
}
/**
* 是否具备审批资格(管理员或教师)。
*
* @return true 表示可以审批预约
*/
public boolean canApprove() {
return this == ADMIN || this == TEACHER;
}
}

View File

@@ -0,0 +1,31 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.DeviceStatus;
import org.springframework.stereotype.Component;
/**
* 设备状态机,迁移规则与 {@link LabStateMachine} 保持一致。
*/
@Component
public class DeviceStateMachine extends EnumStateMachine<DeviceStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public DeviceStateMachine() {
register(DeviceStatus.IDLE, DeviceStatus.RESERVED, DeviceStatus.MAINTENANCE);
register(DeviceStatus.RESERVED, DeviceStatus.IDLE, DeviceStatus.IN_USE);
register(DeviceStatus.IN_USE, DeviceStatus.IDLE);
register(DeviceStatus.MAINTENANCE, DeviceStatus.IDLE);
}
/**
* 提供状态枚举类型。
*
* @return DeviceStatus.class
*/
@Override
protected Class<DeviceStatus> getStateType() {
return DeviceStatus.class;
}
}

View File

@@ -0,0 +1,80 @@
package com.example.lab.domain.statemachine;
import com.example.lab.common.exception.BizException;
import com.example.lab.common.exception.ErrorCode;
import java.util.Collections;
import java.util.EnumMap;
import java.util.EnumSet;
import java.util.Map;
import java.util.Set;
/**
* 通用枚举状态机。
*
* <p>子类在构造时通过 {@link #register(Enum, Enum...)} 声明所有合法迁移,
* 未声明的迁移一律视为非法。</p>
*
* @param <S> 状态枚举类型
*/
public abstract class EnumStateMachine<S extends Enum<S>> {
/** 状态迁移表:from -> 允许到达的 to 集合 */
private final Map<S, Set<S>> transitions = new EnumMap<>(getStateType());
/**
* 返回状态枚举类型,供子类提供。
*
* @return 状态枚举 Class
*/
protected abstract Class<S> getStateType();
/**
* 注册合法迁移。
*
* @param from 起始状态
* @param to 允许到达的一个或多个状态
*/
protected void register(S from, S... to) {
Set<S> targets = transitions.computeIfAbsent(from, key -> EnumSet.noneOf(getStateType()));
Collections.addAll(targets, to);
}
/**
* 判断迁移是否合法。
*
* @param from 起始状态
* @param to 目标状态
* @return true 表示合法
*/
public boolean canTransit(S from, S to) {
if (from == null || to == null) {
return false;
}
return transitions.getOrDefault(from, Set.of()).contains(to);
}
/**
* 断言迁移合法,否则抛出业务异常。
*
* @param from 起始状态
* @param to 目标状态
* @throws BizException 迁移非法时抛出
*/
public void assertTransit(S from, S to) {
if (!canTransit(from, to)) {
throw new BizException(ErrorCode.INVALID_STATE_TRANSITION,
"状态不允许从 " + from + " 流转到 " + to);
}
}
/**
* 查询某状态的所有可达状态。
*
* @param from 起始状态
* @return 可达状态集合(不可变)
*/
public Set<S> nextStates(S from) {
return Collections.unmodifiableSet(transitions.getOrDefault(from, EnumSet.noneOf(getStateType())));
}
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.LabStatus;
import org.springframework.stereotype.Component;
/**
* 实验室状态机。
*
* <pre>
* IDLE -> RESERVED, MAINTENANCE
* RESERVED -> IDLE, IN_USE
* IN_USE -> IDLE
* MAINTENANCE -> IDLE
* </pre>
*
* <p>说明:维护操作必须先释放预约(RESERVED -> IDLE),
* 避免出现「有预约却被维护」的脏状态。</p>
*/
@Component
public class LabStateMachine extends EnumStateMachine<LabStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public LabStateMachine() {
register(LabStatus.IDLE, LabStatus.RESERVED, LabStatus.MAINTENANCE);
register(LabStatus.RESERVED, LabStatus.IDLE, LabStatus.IN_USE);
register(LabStatus.IN_USE, LabStatus.IDLE);
register(LabStatus.MAINTENANCE, LabStatus.IDLE);
}
/**
* 提供状态枚举类型。
*
* @return LabStatus.class
*/
@Override
protected Class<LabStatus> getStateType() {
return LabStatus.class;
}
}

View File

@@ -0,0 +1,39 @@
package com.example.lab.domain.statemachine;
import com.example.lab.domain.enums.ReservationStatus;
import org.springframework.stereotype.Component;
/**
* 预约状态机。
*
* <pre>
* PENDING -> APPROVED, REJECTED, CANCELLED
* APPROVED -> IN_USE, CANCELLED, COMPLETED(时间已过而未签到,由定时任务自动完成)
* IN_USE -> COMPLETED
* REJECTED / CANCELLED / COMPLETED 为终态
* </pre>
*/
@Component
public class ReservationStateMachine extends EnumStateMachine<ReservationStatus> {
/**
* 构造状态机并注册合法迁移。
*/
public ReservationStateMachine() {
register(ReservationStatus.PENDING,
ReservationStatus.APPROVED, ReservationStatus.REJECTED, ReservationStatus.CANCELLED);
register(ReservationStatus.APPROVED,
ReservationStatus.IN_USE, ReservationStatus.CANCELLED, ReservationStatus.COMPLETED);
register(ReservationStatus.IN_USE, ReservationStatus.COMPLETED);
}
/**
* 提供状态枚举类型。
*
* @return ReservationStatus.class
*/
@Override
protected Class<ReservationStatus> getStateType() {
return ReservationStatus.class;
}
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.ReservationType;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.LocalTime;
/**
* 预约冲突预检请求:不落库,仅返回冲突明细,供前端表单实时校验。
*/
@Data
public class ConflictCheckRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID */
private Long deviceId;
/** 课程 ID,仅课程绑定预约需要 */
private Long courseId;
/** 预约类型 */
@NotNull(message = "预约类型不能为空")
private ReservationType type = ReservationType.SINGLE;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
/** 单次模式:开始时间 */
private LocalDateTime startTime;
/** 单次模式:结束时间 */
private LocalDateTime endTime;
/** 多次模式:开始日期 */
private LocalDate startDate;
/** 多次模式:结束日期 */
private LocalDate endDate;
/** 多次模式:星期 */
private String weekdays;
/** 多次模式:每日开始时间 */
private LocalTime startTimeOfDay;
/** 多次模式:每日结束时间 */
private LocalTime endTimeOfDay;
}

View File

@@ -0,0 +1,56 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* 实验课程新增/修改请求。
*/
@Data
public class CourseRequest {
/** 课程编号 */
@NotBlank(message = "课程编号不能为空")
private String code;
/** 课程名称 */
@NotBlank(message = "课程名称不能为空")
private String name;
/** 授课教师 ID,缺省为当前教师 */
private Long teacherId;
/** 绑定实验室 ID */
@NotNull(message = "绑定实验室不能为空")
private Long labId;
/** 选课人数 */
@NotNull(message = "选课人数不能为空")
@Min(value = 1, message = "选课人数至少为 1")
private Integer studentCount;
/** 课程开始日期 */
@NotNull(message = "开始日期不能为空")
private LocalDate startDate;
/** 课程结束日期 */
@NotNull(message = "结束日期不能为空")
private LocalDate endDate;
/** 上课星期,如 "MON,WED" */
@NotBlank(message = "上课星期不能为空")
private String weekdays;
/** 每次上课开始时间 */
@NotNull(message = "上课开始时间不能为空")
private LocalTime startTime;
/** 每次上课结束时间 */
@NotNull(message = "上课结束时间不能为空")
private LocalTime endTime;
}

View File

@@ -0,0 +1,24 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 课程绑定预约请求:以课程自带排课规则生成整期预约。
*/
@Data
public class CourseReservationRequest {
/** 课程 ID */
@NotNull(message = "课程不能为空")
private Long courseId;
/** 指定设备 ID,可为空 */
private Long deviceId;
/** 标题,为空时使用课程名 */
private String title;
/** 用途说明 */
private String purpose;
}

View File

@@ -0,0 +1,30 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 设备新增/修改请求。
*/
@Data
public class DeviceRequest {
/** 所属实验室 ID */
@NotNull(message = "所属实验室不能为空")
private Long labId;
/** 设备编号 */
@NotBlank(message = "设备编号不能为空")
private String code;
/** 设备名称 */
@NotBlank(message = "设备名称不能为空")
private String name;
/** 设备型号 */
private String model;
/** 描述 */
private String description;
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalTime;
/**
* 实验室新增/修改请求。
*/
@Data
public class LabRequest {
/** 实验室编号 */
@NotBlank(message = "实验室编号不能为空")
private String code;
/** 实验室名称 */
@NotBlank(message = "实验室名称不能为空")
private String name;
/** 位置 */
@NotBlank(message = "位置不能为空")
private String location;
/** 容量 */
@NotNull(message = "容量不能为空")
@Min(value = 1, message = "容量至少为 1")
private Integer capacity;
/** 描述 */
private String description;
/** 开放开始时间 */
private LocalTime openTime;
/** 开放结束时间 */
private LocalTime closeTime;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 登录请求。
*/
@Data
public class LoginRequest {
/** 登录名 */
@NotBlank(message = "用户名不能为空")
private String username;
/** 密码(明文,传输层由 HTTPS 保障) */
@NotBlank(message = "密码不能为空")
private String password;
}

View File

@@ -0,0 +1,54 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDate;
import java.time.LocalTime;
/**
* 多次预约请求:按周重复批量生成场次。
*/
@Data
public class RecurringReservationRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 标题 */
@NotBlank(message = "标题不能为空")
private String title;
/** 用途说明 */
private String purpose;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
/** 重复起始日期 */
@NotNull(message = "重复开始日期不能为空")
private LocalDate startDate;
/** 重复结束日期 */
@NotNull(message = "重复结束日期不能为空")
private LocalDate endDate;
/** 重复星期,如 "MON,WED" */
@NotBlank(message = "重复星期不能为空")
private String weekdays;
/** 每次开始时间 */
@NotNull(message = "开始时间不能为空")
private LocalTime startTime;
/** 每次结束时间 */
@NotNull(message = "结束时间不能为空")
private LocalTime endTime;
}

View File

@@ -0,0 +1,15 @@
package com.example.lab.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 驳回预约请求。
*/
@Data
public class RejectRequest {
/** 驳回原因 */
@NotBlank(message = "驳回原因不能为空")
private String reason;
}

View File

@@ -0,0 +1,41 @@
package com.example.lab.dto;
import jakarta.validation.constraints.Min;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
import java.time.LocalDateTime;
/**
* 单次预约请求。
*/
@Data
public class ReservationCreateRequest {
/** 实验室 ID */
@NotNull(message = "实验室不能为空")
private Long labId;
/** 设备 ID,可为空 */
private Long deviceId;
/** 标题 */
@NotBlank(message = "标题不能为空")
private String title;
/** 用途说明 */
private String purpose;
/** 开始时间 */
@NotNull(message = "开始时间不能为空")
private LocalDateTime startTime;
/** 结束时间 */
@NotNull(message = "结束时间不能为空")
private LocalDateTime endTime;
/** 参与人数 */
@Min(value = 1, message = "参与人数至少为 1")
private Integer participants;
}

View File

@@ -0,0 +1,44 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.ReservationStatus;
import com.example.lab.domain.enums.ReservationType;
import lombok.Data;
import java.time.LocalDate;
/**
* 预约分页查询条件。
*/
@Data
public class ReservationQuery {
/** 页码,从 1 开始 */
private long page = 1;
/** 每页大小 */
private long size = 10;
/** 状态过滤 */
private ReservationStatus status;
/** 类型过滤 */
private ReservationType type;
/** 实验室过滤 */
private Long labId;
/** 用户过滤(管理员可用) */
private Long userId;
/** 标题关键字 */
private String keyword;
/** 时间范围-开始日期 */
private LocalDate from;
/** 时间范围-结束日期 */
private LocalDate to;
/** 是否只看自己的预约 */
private boolean mine;
}

View File

@@ -0,0 +1,19 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.DeviceStatus;
import com.example.lab.domain.enums.LabStatus;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 资源状态变更请求(实验室 / 设备共用)。
*/
@Data
public class StatusChangeRequest {
/** 实验室目标状态,设备状态变更时可为空 */
private LabStatus labStatus;
/** 设备目标状态,实验室状态变更时可为空 */
private DeviceStatus deviceStatus;
}

View File

@@ -0,0 +1,35 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.Role;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import lombok.Data;
/**
* 新增用户请求(仅管理员)。
*/
@Data
public class UserCreateRequest {
/** 登录名 */
@NotBlank(message = "用户名不能为空")
private String username;
/** 初始密码 */
@NotBlank(message = "密码不能为空")
private String password;
/** 真实姓名 */
@NotBlank(message = "姓名不能为空")
private String realName;
/** 角色 */
@NotNull(message = "角色不能为空")
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
}

View File

@@ -0,0 +1,29 @@
package com.example.lab.dto;
import com.example.lab.domain.enums.Role;
import lombok.Data;
/**
* 更新用户请求(仅管理员),字段为 {@code null} 表示不修改。
*/
@Data
public class UserUpdateRequest {
/** 真实姓名 */
private String realName;
/** 角色 */
private Role role;
/** 邮箱 */
private String email;
/** 联系电话 */
private String phone;
/** 是否启用 */
private Boolean enabled;
/** 新密码,为空表示不重置 */
private String password;
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Course;
/**
* 实验课程表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface CourseMapper extends BaseMapper<Course> {
}

View File

@@ -0,0 +1,10 @@
package com.example.lab.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.example.lab.domain.entity.Device;
/**
* 设备表 Mapper:全部查询均可用 MyBatis-Plus 的 QueryWrapper 完成。
*/
public interface DeviceMapper extends BaseMapper<Device> {
}

Some files were not shown because too many files have changed in this diff Show More