From 86c522490f2faee5f3900394446cc15d4e39209c Mon Sep 17 00:00:00 2001 From: KYJCASTER <2016559265w@gmail.com> Date: Mon, 16 Mar 2026 20:11:13 +0800 Subject: [PATCH 1/5] docs: add learning guide and optimize web performance --- .github/workflows/ci.yml | 64 ++++++ README.md | 77 +++++++ docs/exception-codes.md | 43 ++++ docs/improvements.md | 105 ++++++--- docs/learning-guide.md | 14 ++ docs/learning/01-project-overview.md | 131 +++++++++++ docs/learning/02-environment-and-run.md | 156 +++++++++++++ docs/learning/03-backend-guide.md | 173 ++++++++++++++ docs/learning/04-frontend-guide.md | 171 ++++++++++++++ docs/learning/05-auth-and-permission.md | 145 ++++++++++++ docs/learning/06-reservation-workflow.md | 174 ++++++++++++++ docs/learning/07-reproduction-and-practice.md | 217 ++++++++++++++++++ docs/learning/README.md | 62 +++++ scripts/dev-down.sh | 29 +++ scripts/dev-up.sh | 93 ++++++++ server/.env.prod.example | 9 + .../server/common/config/CorsConfig.java | 15 +- .../service/impl/MaintenanceServiceImpl.java | 6 + .../service/impl/NoticeServiceImpl.java | 18 ++ .../service/impl/ReservationServiceImpl.java | 9 + .../src/main/resources/application-prod.yml | 3 +- server/src/main/resources/application.yml | 2 + web/.env.example | 4 + web/package-lock.json | 64 ++++++ web/package.json | 7 +- web/playwright.config.ts | 57 +++++ web/src/api/notice.ts | 9 +- web/src/api/reservation.ts | 9 +- web/src/api/system.ts | 9 +- web/src/components/AppState.vue | 41 ++++ web/src/main.ts | 151 +++++++++++- web/src/router/index.ts | 17 +- web/src/styles/element-plus.css | 36 +++ web/src/types/api.ts | 12 + web/src/utils/echarts.ts | 9 + web/src/utils/request.ts | 29 ++- web/src/views/dashboard/index.vue | 139 ++++++----- web/src/views/error/forbidden.vue | 88 +++++++ web/src/views/login/index.vue | 15 +- web/src/views/maintenance/index.vue | 20 +- web/src/views/notice/index.vue | 20 +- web/src/views/reservation/apply/index.vue | 41 +++- web/src/views/reservation/my/index.vue | 20 +- web/src/views/reservation/pending/index.vue | 20 +- web/src/views/system/category/index.vue | 20 +- web/src/views/system/equipment/index.vue | 20 +- web/src/views/system/lab/index.vue | 20 +- web/src/views/system/role/index.vue | 20 +- web/src/views/system/user/index.vue | 20 +- web/test-results/.last-run.json | 4 + web/tests/e2e/auth.spec.ts | 26 +++ web/tests/e2e/notice.spec.ts | 29 +++ web/tests/e2e/reservation.spec.ts | 152 ++++++++++++ web/vite.config.ts | 57 +++-- 54 files changed, 2730 insertions(+), 171 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 docs/exception-codes.md create mode 100644 docs/learning-guide.md create mode 100644 docs/learning/01-project-overview.md create mode 100644 docs/learning/02-environment-and-run.md create mode 100644 docs/learning/03-backend-guide.md create mode 100644 docs/learning/04-frontend-guide.md create mode 100644 docs/learning/05-auth-and-permission.md create mode 100644 docs/learning/06-reservation-workflow.md create mode 100644 docs/learning/07-reproduction-and-practice.md create mode 100644 docs/learning/README.md create mode 100644 scripts/dev-down.sh create mode 100644 scripts/dev-up.sh create mode 100644 server/.env.prod.example create mode 100644 web/.env.example create mode 100644 web/playwright.config.ts create mode 100644 web/src/components/AppState.vue create mode 100644 web/src/styles/element-plus.css create mode 100644 web/src/types/api.ts create mode 100644 web/src/utils/echarts.ts create mode 100644 web/src/views/error/forbidden.vue create mode 100644 web/test-results/.last-run.json create mode 100644 web/tests/e2e/auth.spec.ts create mode 100644 web/tests/e2e/notice.spec.ts create mode 100644 web/tests/e2e/reservation.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..95853a2 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: + - main + - master + pull_request: + +jobs: + web-build: + runs-on: ubuntu-latest + defaults: + run: + working-directory: web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + - run: npm ci + - run: npm run build + + server-test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: server + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: chmod +x mvnw + - run: ./mvnw test + + web-e2e: + runs-on: ubuntu-latest + needs: + - web-build + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + cache-dependency-path: web/package-lock.json + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: 17 + cache: maven + - run: npm ci + working-directory: web + - run: npx playwright install chromium + working-directory: web + - run: chmod +x mvnw + working-directory: server + - run: npx playwright test + working-directory: web diff --git a/README.md b/README.md index a8637e8..6841a54 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,79 @@ npm run dev 默认地址:`http://localhost:5173`(已通过 Vite 代理 `/api` 到 `http://localhost:8080`) +### 5) 一键启动开发环境 + +首次执行前请确保本机已安装 `docker`、`curl`、`Node.js`、`JDK`,并且前端依赖已安装完成: + +```bash +cd web +npm install +cd .. +chmod +x scripts/dev-up.sh scripts/dev-down.sh +./scripts/dev-up.sh +``` + +脚本会完成这些动作: + +- 启动 `docker compose` 中的 MySQL +- 在后台启动后端开发服务并轮询 `http://127.0.0.1:8080/api/health` +- 在固定端口 `5173` 启动前端开发服务 + +停止本地前后端: + +```bash +./scripts/dev-down.sh +``` + +开发环境默认端口和变量: + +- 前端固定使用 `127.0.0.1:5173` +- 后端固定使用 `127.0.0.1:8080` +- 前端示例变量见 `web/.env.example` +- 后端生产变量模板见 `server/.env.prod.example` + +## CI 门禁 + +仓库已补充 GitHub Actions 工作流:前端执行 `npm run build`,后端执行 `./mvnw test`。工作流文件位于 `.github/workflows/ci.yml`。 + +## E2E 测试 + +前端已接入 Playwright 基础设施,测试文件位于 `web/tests/e2e/`,当前覆盖: + +- 登录跳转与登录成功 +- 错误密码提示 +- 学生预约提交流程 + +首次运行前建议安装浏览器: + +```bash +cd web +npm run test:e2e:install +``` + +启动前后端后执行: + +```bash +cd web +npm run test:e2e +``` + +如测试地址不是默认的 `http://127.0.0.1:5173`,可通过环境变量覆盖: + +```bash +PLAYWRIGHT_BASE_URL=http://127.0.0.1:4173 npm run test:e2e +``` + +## 生产环境变量基线 + +后端生产配置建议至少显式设置以下变量: + +- `DB_HOST` / `DB_PORT` / `DB_NAME` / `DB_USER` / `DB_PASSWORD` +- `JWT_SECRET` +- `CORS_ALLOWED_ORIGIN_PATTERNS` + +示例模板见 `server/.env.prod.example`。其中 `JWT_SECRET` 必须替换,`CORS_ALLOWED_ORIGIN_PATTERNS` 不应保留为宽泛通配。 + ## 默认账号(种子数据) - 管理员:`admin / 123456` @@ -110,3 +183,7 @@ npm run dev - 接口文档:`docs/api.md` - 数据库设计:`docs/db-design.md` +- 异常码约定:`docs/exception-codes.md` +- 优化记录:`docs/improvements.md` +- 学习指南入口:`docs/learning-guide.md` +- 目录版学习指南:`docs/learning/README.md` diff --git a/docs/exception-codes.md b/docs/exception-codes.md new file mode 100644 index 0000000..d8f750f --- /dev/null +++ b/docs/exception-codes.md @@ -0,0 +1,43 @@ +# 统一异常码约定 + +## 通用响应格式 + +后端统一返回: + +```json +{ + "code": 0, + "message": "success", + "data": {} +} +``` + +- `code=0`:业务成功 +- `code!=0`:业务失败或权限失败 + +## 异常码说明 + +| code | 含义 | 典型场景 | +| --- | --- | --- | +| 0 | 成功 | 查询、创建、更新、删除成功 | +| 400 | 请求参数或业务前置条件不满足 | 参数校验失败、时间冲突、状态不允许当前操作 | +| 401 | 未认证或凭证无效 | 未登录、Token 无效、用户名密码错误 | +| 403 | 已认证但无权限 | 学生访问教师接口、账号被停用 | +| 404 | 资源不存在 | 公告不存在、预约不存在、设备不存在 | +| 500 | 服务内部异常 | 未捕获异常、数据库或系统错误 | + +## 前端处理建议 + +- `400`:优先展示接口返回的业务提示,允许用户直接修正输入后重试。 +- `401`:登录页提示凭证错误;已登录页面应跳转登录或提示重新登录。 +- `403`:提示无权限或账号状态异常,不建议自动重试。 +- `404`:提示资源已不存在,并刷新列表或返回上一页。 +- `500`:统一提示“系统异常,请稍后重试”,必要时保留重试按钮。 + +## 当前项目中的约定示例 + +- 登录失败:`401` +- 用户被禁用:`403` +- 预约时间冲突:`400` +- 预约/公告/设备不存在:`404` +- 未处理系统异常:`500` diff --git a/docs/improvements.md b/docs/improvements.md index 7dde510..719afe2 100644 --- a/docs/improvements.md +++ b/docs/improvements.md @@ -1,39 +1,92 @@ -# 项目可改进点(后续迭代建议) +# 项目优化记录与后续建议 -## 1. 运行与环境 +本文档分成两部分: -- 统一开发端口与启动脚本,避免前端出现多个 `517x/520x` 端口并行导致混淆。 -- 增加一键启动脚本(如 `scripts/dev-up.sh`),统一启动前后端与健康检查。 -- `dev` 环境补充数据库自动建库/初始化说明,减少首次启动失败。 +- 第一部分记录已经落地的优化,方便后续接手时快速了解现状。 +- 第二部分保留仍值得继续迭代的方向,避免重复讨论。 -## 2. 前端稳定性 +## 一、已完成优化 -- 为登录页增加更精确的错误提示(区分“密码错误”和“系统异常”)。 -- 统一前端 API 类型定义,避免同一业务对象在多个文件重复映射。 -- 增加全局空状态与错误重试组件,提升弱网场景体验。 +### 1. 运行与环境 -## 3. 后端能力 +- 统一了开发环境端口约定: + - 前端固定 `127.0.0.1:5173` + - 后端固定 `127.0.0.1:8080` +- 补充了开发环境脚本与说明,降低首次启动门槛。 +- 增加了前后端环境变量模板,便于本地和生产环境配置。 -- 增加关键业务日志(预约审批、维修完结、公告发布)便于审计追踪。 -- 为高频查询接口(设备列表、预约列表)预留索引优化策略。 -- 增加统一异常码文档,进一步规范前后端错误语义。 +### 2. CI 与测试基础设施 -## 4. 测试与质量 +- GitHub Actions 已加入基础门禁: + - 前端 `build` + - 后端 `test` + - 前端 E2E 作业 +- 前端已接入 Playwright,并完成基础自动化用例。 +- 当前 E2E 已覆盖的关键链路包括: + - 登录跳转、登录成功、错误密码提示 + - 学生提交预约 + - 教师审批通过预约 + - 教师驳回预约 + - 学生权限边界访问控制 + - 管理员发布公告 +- Playwright 的前端自启方式已从 `npm run dev` 调整为 `build + preview`,用于提升 CI 稳定性。 -- 增加前端 E2E 测试(登录、预约、审批、公告)。 -- 为权限边界补充更多反向用例(例如学生访问教师接口)。 -- 在 CI 中加入前端 `build` + 后端 `test` 的强制门禁。 +### 3. 前端稳定性与体验 -## 5. 部署与交付 +- 登录失败提示已细化,减少“所有错误都一样”的模糊反馈。 +- 已增加通用空状态/错误重试组件,提升接口失败时的可恢复性。 +- 前端路由已补充角色访问控制,未授权页面会跳转到独立的 `403` 页面。 +- 仪表盘已根据角色隐藏无权限入口,减少学生误点管理功能。 -- 补充生产环境变量模板(JWT、DB、CORS)与最小安全基线说明。 -- 增加 Nginx/反向代理示例(HTTPS、缓存、跨域策略)。 -- 增加版本发布记录模板(变更摘要、回滚说明、数据库变更)。 +### 4. 前端性能优化 -## 建议优先级 +- 仪表盘图表改为按需加载,不再把 `echarts` 整包压进首屏主包。 +- Vite 已增加手工分包策略,拆分: + - `vendor-vue` + - `vendor-utils` + - `vendor-echarts` + - `vendor-element-plus` +- Element Plus 已从“整库安装 + 全量图标注册”改为“按需组件注册 + 按需图标注册”。 +- Element Plus 样式已从全量 `index.css` 改为按需样式引入。 +- 最近一次本地构建结果中,前端包体优化效果明显: + - `vendor-element-plus` JS 已从约 `1.09 MB` 降到约 `563 kB` + - Element Plus 样式包已从约 `351 kB` 降到约 `180 kB` -1. 统一启动与端口管理(立即提升联调效率) -2. 前端 E2E 与 CI 门禁(立即提升交付稳定性) -3. 日志与异常码规范(提升维护效率) -4. 部署安全基线与发布模板(提升上线质量) +### 5. 后端可维护性 +- 增加了关键业务日志,覆盖预约审批、维修完结、公告发布等重要操作。 +- 已补充异常码文档,统一前后端错误语义。 + +## 二、当前已知限制 + +### 1. 本地/沙箱环境限制 + +- 当前沙箱环境下,后端和前端服务监听端口可能受到限制。 +- 因此在该环境里,E2E 不一定能完整自启服务跑通。 +- 这类失败不等同于业务代码失败,更像是运行环境限制。 + +### 2. E2E 的实际验证方式 + +- 当前最稳定的方式仍然是手动启动干净的本地前后端进程后,再执行: + - 后端:`./mvnw -Dmaven.repo.local=/tmp/smartlab-m2 spring-boot:run -Dspring-boot.run.profiles=test -Dspring-boot.run.arguments=--server.address=127.0.0.1` + - 前端:`npm run dev` + - 测试:`PLAYWRIGHT_SKIP_WEBSERVER=1 npx playwright test` + +## 三、下一阶段建议 + +### 1. 优先级高 + +- 继续补权限边界 E2E,尤其是教师/管理员对更多管理模块的反向访问用例。 +- 为公告、维修、系统管理补更多“失败路径”测试,而不只验证成功路径。 +- 优化 CI 中 Playwright 的探活与日志输出,便于定位启动失败原因。 + +### 2. 优先级中 + +- 继续压缩 `vendor-echarts`,例如按图表类型继续拆分或在可见时再加载。 +- 评估 Element Plus 运行时代码进一步按路由拆分的可行性。 +- 为仪表盘增加首屏骨架屏或延迟渲染策略,进一步提升感知性能。 + +### 3. 优先级中低 + +- 增加发布记录模板、回滚说明模板和数据库变更检查清单。 +- 补充更完整的生产部署示例,如 Nginx、HTTPS、缓存与跨域策略。 diff --git a/docs/learning-guide.md b/docs/learning-guide.md new file mode 100644 index 0000000..9ffb4eb --- /dev/null +++ b/docs/learning-guide.md @@ -0,0 +1,14 @@ +# 学习指南入口 + +原有单文件学习指南已拆分为完整目录版,便于教学、接手和阶段性学习。 + +请从这里开始: + +- [`docs/learning/README.md`](/mnt/d/smartlab/docs/learning/README.md) + +目录版学习指南适合以下目标: + +- 快速理解项目整体结构 +- 独立把项目跑起来 +- 理解后端、前端、权限、业务链路 +- 在指导下完成复现与二次开发 diff --git a/docs/learning/01-project-overview.md b/docs/learning/01-project-overview.md new file mode 100644 index 0000000..a04be09 --- /dev/null +++ b/docs/learning/01-project-overview.md @@ -0,0 +1,131 @@ +# 01. 项目总览 + +## 1. 这是一个什么项目 + +这是一个面向高校实验室场景的管理系统,核心业务包括: + +- 用户登录与角色权限控制 +- 实验室、设备分类、设备资产管理 +- 学生提交设备预约 +- 教师或管理员审批预约 +- 设备维修管理 +- 公告发布 +- 仪表盘统计展示 + +从项目类型上说,它是一个很典型的“管理后台 + 审批流程 + 权限系统”。 + +这类项目非常适合学习,因为它包含了真实业务系统里最常见的内容: + +- 增删改查 +- 状态流转 +- 权限边界 +- 前后端联动 +- 数据库设计 +- 基础自动化测试 + +## 2. 三种角色分别做什么 + +项目当前主要有三类角色: + +### 管理员 `ADMIN` + +- 管理用户、角色、实验室 +- 管理设备分类、设备 +- 审批预约 +- 处理维修 +- 发布公告 + +### 教师 `TEACHER` + +- 查看设备、实验室 +- 审批预约 +- 处理维修 + +### 学生 `STUDENT` + +- 提交预约申请 +- 查看自己的预约 +- 不能进入审批中心和系统管理页面 + +理解角色差异非常重要,因为这个项目很多设计都围绕“谁能看、谁能改、谁能审批”展开。 + +## 3. 技术栈总览 + +### 后端 + +- Java 17 +- Spring Boot 4 +- Spring Security +- MyBatis-Plus +- MySQL 8 +- JWT +- Maven + +### 前端 + +- Vue 3 +- TypeScript +- Vite +- Vue Router +- Pinia +- Axios +- Element Plus +- ECharts +- Playwright + +## 4. 目录结构怎么理解 + +项目根目录的关键结构如下: + +```text +smartlab/ +├─ server/ # Java 后端 +├─ web/ # Vue 前端 +├─ sql/ # 数据库结构和种子数据 +├─ docs/ # 文档 +├─ scripts/ # 开发辅助脚本 +└─ docker-compose.yml # 容器化启动配置 +``` + +你可以先这样理解: + +- `server/` 负责业务规则和数据访问 +- `web/` 负责页面、交互和接口调用 +- `sql/` 决定数据怎么存 +- `docs/` 说明系统怎么设计 + +## 5. 学这个项目最值得关注什么 + +如果你基础一般,不建议追求“每一行都看懂”。更值得优先掌握的是: + +- 系统模块怎么划分 +- 一个接口从前端到后端怎么走 +- JWT 登录是怎么做的 +- 角色权限是怎么控制的 +- 预约审批为什么会有状态流转 + +只要你能把这些讲明白,后面的代码细节就会越来越容易看。 + +## 6. 推荐你先建立的全局地图 + +开始看代码前,先在脑子里有这样一张图: + +1. 用户在前端页面点击按钮 +2. 前端通过 `api` 层发起请求 +3. 后端 `controller` 接收请求 +4. 后端 `service` 执行业务逻辑 +5. `mapper` 访问数据库 +6. 后端返回 JSON +7. 前端根据返回结果刷新页面 + +这个项目绝大多数功能,都是这条路线的变体。 + +## 7. 建议本章完成后的自测问题 + +看完本章后,你应该能回答: + +- 这个项目主要解决什么问题? +- 项目里有哪三种角色? +- 管理员、教师、学生分别能做什么? +- 后端和前端分别用什么技术栈? +- `server/`、`web/`、`sql/` 各自负责什么? diff --git a/docs/learning/02-environment-and-run.md b/docs/learning/02-environment-and-run.md new file mode 100644 index 0000000..4f3f465 --- /dev/null +++ b/docs/learning/02-environment-and-run.md @@ -0,0 +1,156 @@ +# 02. 环境准备与项目运行 + +这一章的目标很明确:把项目独立跑起来。 + +如果项目跑不起来,后面的学习效率会非常低。因为你看不到页面、点不到功能、没法验证自己的理解是否正确。 + +## 1. 运行前你需要什么 + +### 后端环境 + +- JDK 17 +- Maven 3.9+ 或使用项目自带 `mvnw` +- MySQL 8 + +### 前端环境 + +- Node.js 20+ +- npm + +### 推荐补充 + +- Docker / Docker Compose +- IDEA 或 VS Code +- 一个数据库可视化工具,例如 DataGrip / Navicat + +## 2. 先看数据库脚本 + +项目数据库脚本在: + +- [`schema.sql`](/mnt/d/smartlab/sql/schema.sql) +- [`seed.sql`](/mnt/d/smartlab/sql/seed.sql) + +建议你先知道两件事: + +- `schema.sql` 用来建表 +- `seed.sql` 用来插入初始化数据 + +初始化数据里有默认账号,后面登录会用到。 + +## 3. 推荐启动方式 + +### 方式一:本地前后端分离启动 + +这是最适合学习的方式,因为你能清楚知道前端、后端、数据库分别怎么工作。 + +#### 第一步:启动数据库 + +如果你本机已有 MySQL,可以直接创建数据库并导入脚本。 + +如果没有,建议直接用 Docker 启动 MySQL: + +```bash +docker compose up -d mysql +``` + +#### 第二步:启动后端 + +进入后端目录: + +```bash +cd server +./mvnw spring-boot:run +``` + +后端默认地址: + +- `http://127.0.0.1:8080` +- API 前缀通常通过前端代理映射为 `/api` + +#### 第三步:启动前端 + +进入前端目录: + +```bash +cd web +npm install +npm run dev +``` + +前端默认地址: + +- `http://127.0.0.1:5173` + +## 4. 为什么前端能直接访问 `/api` + +前端开发模式下,Vite 配置了代理,把 `/api` 转发到后端服务。 + +你可以看: + +- [`vite.config.ts`](/mnt/d/smartlab/web/vite.config.ts) + +这意味着: + +- 浏览器访问的是前端地址 +- 前端请求 `/api/...` +- Vite 帮你把请求转发到后端 `127.0.0.1:8080` + +这就是典型的前后端分离开发方式。 + +## 5. 默认测试账号 + +种子数据里默认提供了三类账号: + +- 管理员:`admin / 123456` +- 教师:`teacher / 123456` +- 学生:`student / 123456` + +建议你分别登录一次,观察左侧菜单和页面权限有什么差异。 + +## 6. 第一次运行后你应该做什么 + +项目跑起来后,不要立刻去读代码。先做这些操作: + +1. 分别用三种角色登录 +2. 观察菜单差异 +3. 学生尝试提一个预约 +4. 教师登录后去审批 +5. 管理员尝试进入公告管理和系统管理 + +你做完这些操作后,会自然建立“这个项目是怎么工作的”初步印象。 + +## 7. 常见启动问题 + +### 1. 后端起不来 + +优先检查: + +- JDK 版本是否正确 +- 数据库是否启动 +- 数据库连接配置是否正确 +- 端口 `8080` 是否被占用 + +### 2. 前端起不来 + +优先检查: + +- Node.js 版本是否过低 +- 是否执行过 `npm install` +- 端口 `5173` 是否被占用 + +### 3. 能打开前端但接口报错 + +优先检查: + +- 后端是否真正启动成功 +- Vite 代理是否生效 +- 浏览器网络面板里 `/api` 请求返回什么 + +## 8. 这一章学完后你应该能做到 + +- 独立启动前端、后端、数据库 +- 用默认账号登录系统 +- 说清楚前后端端口分别是什么 +- 解释为什么前端可以请求 `/api` + +如果你连这一步都还不稳,不要急着进入源码细读。 diff --git a/docs/learning/03-backend-guide.md b/docs/learning/03-backend-guide.md new file mode 100644 index 0000000..1ebb56b --- /dev/null +++ b/docs/learning/03-backend-guide.md @@ -0,0 +1,173 @@ +# 03. 后端学习指南 + +这一章解决一个核心问题: + +后端代码应该从哪里开始看,才不会一上来就迷路。 + +## 1. 后端整体思路 + +后端不是一堆零散 Java 文件,而是一个分层结构。 + +你可以先用下面的分层方式理解: + +- `controller` + - 接收请求,返回响应 +- `service` + - 写业务逻辑 +- `mapper` + - 查数据库 +- `entity` + - 对应数据库表 +- `dto` + - 接收前端传入的数据 +- `vo` + - 返回给前端的数据 + +如果你刚开始看项目,请一直带着这个分层视角。 + +## 2. 后端重点目录 + +后端根目录核心在: + +- `server/src/main/java/com/smartlab/server/common` +- `server/src/main/java/com/smartlab/server/modules` +- `server/src/main/java/com/smartlab/server/security` + +### `common` + +这里通常放: + +- 统一返回结构 +- 公共异常 +- 公共配置 +- 通用工具类 + +你可以把它理解为“给整个项目提供基础设施”。 + +### `modules` + +这里是业务模块核心,通常按功能拆分。 + +建议优先关注这些模块: + +- `auth` +- `reservation` +- `notice` +- `maintenance` +- `user / role / permission` + +### `security` + +这里是权限系统的中枢,重点包括: + +- JWT 过滤器 +- Spring Security 配置 +- 无权限和未登录的处理逻辑 + +## 3. 正确的阅读顺序 + +建议按下面顺序读后端代码: + +1. 先看接口暴露了什么:`controller` +2. 再看业务规则:`service` +3. 然后看数据库访问:`mapper` +4. 最后看数据对象:`entity / dto / vo` + +很多同学的问题是,一开始就去看最底层实现,结果看了半天还不知道这个类是干什么的。 + +先看“做什么”,再看“怎么做”,效率会高很多。 + +## 4. 你应该重点理解哪几类代码 + +### 登录认证 + +这是系统入口。 + +你需要搞清楚: + +- 登录接口接收什么 +- 登录成功后返回什么 +- token 怎么生成 +- 当前用户信息怎么获取 + +### 预约业务 + +这是项目最核心的业务模块之一。 + +你需要搞清楚: + +- 学生如何提交预约 +- 预约需要哪些字段 +- 什么情况下不能预约 +- 教师审批通过和驳回分别改了什么 + +### 系统管理 + +这是管理类页面最典型的 CRUD 模块。 + +你需要搞清楚: + +- 用户管理怎么增删改查 +- 角色管理怎么做 +- 权限为什么不是前端说了算,而是后端控制 + +## 5. 学后端时要会提问 + +读代码时,不要只看,要不断问自己: + +- 这个接口是谁调用的? +- 这个方法是“校验数据”还是“操作数据库”? +- 这段逻辑为什么写在 `service` 而不是 `controller`? +- 这个字段是数据库字段,还是前端临时展示字段? + +会问问题,比一味往下读更重要。 + +## 6. 一个典型接口是怎么工作的 + +以“提交预约”为例,后端大致流程通常是: + +1. 前端发送预约请求 +2. `controller` 接收 DTO +3. `service` 判断当前用户是不是学生 +4. `service` 校验时间冲突、设备状态、实验室信息 +5. `mapper` 写入预约表 +6. 返回统一响应结构 + +你读任何业务时,都可以尝试把它翻译成这种步骤。 + +## 7. 学后端最容易踩的坑 + +### 1. 把所有代码都当成业务代码 + +不是。 + +很多代码只是“框架接线”,例如: + +- 安全配置 +- 异常处理 +- 统一返回封装 + +这些代码很重要,但它们不是直接的业务逻辑。 + +### 2. 分不清“谁负责什么” + +你要反复提醒自己: + +- `controller` 不应该塞太多业务判断 +- `service` 才是业务核心 +- `mapper` 主要负责数据库访问 + +### 3. 一开始就钻框架底层 + +没有必要。 + +对初学者来说,更重要的是先理解项目自己的业务,而不是先研究 Spring 底层实现。 + +## 8. 建议本章完成后的任务 + +请你自己完成下面两个小任务: + +1. 找到“登录接口”的 `controller -> service -> security` 调用链 +2. 找到“提交预约”的 `controller -> service -> mapper` 调用链 + +如果你能自己跟通这两条链路,后端就算真正入门了。 diff --git a/docs/learning/04-frontend-guide.md b/docs/learning/04-frontend-guide.md new file mode 100644 index 0000000..101dbd5 --- /dev/null +++ b/docs/learning/04-frontend-guide.md @@ -0,0 +1,171 @@ +# 04. 前端学习指南 + +很多 Java 学习者看到前端就本能跳过,这是不对的。 + +这个项目是前后端分离系统。如果你完全不理解前端,就只能看懂“接口”,但看不懂“系统如何被使用”。 + +## 1. 前端在项目里负责什么 + +前端主要负责: + +- 页面展示 +- 表单输入 +- 路由跳转 +- 角色菜单控制 +- 调用后端接口 +- 把接口返回的数据渲染出来 + +换句话说: + +前端决定“用户看到什么、怎么点、怎么交互”,后端决定“能不能做、怎么存、规则是什么”。 + +## 2. 前端关键目录 + +你应该重点看这些目录: + +- `web/src/router` +- `web/src/stores` +- `web/src/api` +- `web/src/utils/request.ts` +- `web/src/views` +- `web/src/layout` + +## 3. 每个目录是做什么的 + +### `router` + +负责页面路由和访问控制。 + +你会在这里看到: + +- 有哪些页面 +- 每个页面对应哪个组件 +- 哪些页面需要登录 +- 哪些页面限制角色 + +### `stores` + +这里主要保存全局状态,比如当前登录用户、token 等。 + +学习重点: + +- token 存在哪里 +- 用户信息什么时候拉取 +- 退出登录时做了什么 + +### `api` + +这里封装了前端对后端的接口调用。 + +你应该把它理解为: + +- 页面一般不直接写 `axios.get('/xxx')` +- 页面通过 `api` 层调用统一方法 + +这样更清晰,也方便维护。 + +### `utils/request.ts` + +这是 Axios 的封装层,很值得学。 + +重点看: + +- 请求拦截器 +- 响应拦截器 +- token 如何自动带上 +- 错误消息怎么统一处理 + +### `views` + +这里就是页面本体。 + +建议优先看: + +- `login` +- `dashboard` +- `reservation/apply` +- `reservation/my` +- `reservation/pending` + +### `layout` + +这里决定页面外壳,例如: + +- 左侧菜单 +- 顶部用户区域 +- 面包屑 +- 主内容区 + +## 4. 正确的前端阅读顺序 + +建议你按这个顺序看前端: + +1. `router` +2. `layout` +3. `stores` +4. `api` +5. 具体页面 `views` + +先知道“页面有哪些、怎么跳转、谁能进”,再去看页面具体实现。 + +## 5. 跟前端代码时该问什么 + +每看一个页面,都问自己: + +- 这个页面从哪个路由进来? +- 它加载数据时调了哪个 API? +- 提交按钮点下去调了哪个方法? +- 成功后页面怎么跳转或刷新? +- 失败时给用户什么提示? + +你只要坚持这样问,前端页面就不会再像一团杂乱模板。 + +## 6. 一个页面通常怎么工作 + +以预约申请页为例,大概逻辑是: + +1. 页面渲染表单 +2. 用户选择实验室、设备、日期、时间 +3. 点击提交 +4. 调用 `api/reservation` +5. 成功后跳转到“我的预约” +6. 页面显示提示消息 + +你可以把大多数页面都理解成这个模式的不同变体。 + +## 7. 初学者看 Vue 页面最容易乱在哪里 + +### 1. 模板太长 + +不要从模板顶部一路硬读到底。 + +更有效的方式是: + +- 先看页面标题和主要按钮 +- 再找 `onMounted`、`handleSubmit`、`fetchList` 这些关键方法 +- 最后回头看模板绑定了哪些变量 + +### 2. 不知道数据从哪来 + +记住一个基本原则: + +- 页面上的数据,大多来自 `ref/reactive` +- 这些数据通常是 `onMounted` 时调接口得到的 + +### 3. 不知道为什么能直接用组件 + +因为项目入口里已经统一注册了常用 Element Plus 组件和图标。 + +这部分在: + +- [`main.ts`](/mnt/d/smartlab/web/src/main.ts) + +## 8. 建议本章完成后的任务 + +请你自己完成下面三个任务: + +1. 找到登录页提交表单后调用的 API +2. 找到预约申请页点击“提交预约”后走的前端逻辑 +3. 找到为什么学生看不到审批中心菜单 + +如果这三个问题你都能独立回答,前端你就不是“完全陌生”了。 diff --git a/docs/learning/05-auth-and-permission.md b/docs/learning/05-auth-and-permission.md new file mode 100644 index 0000000..24c89a6 --- /dev/null +++ b/docs/learning/05-auth-and-permission.md @@ -0,0 +1,145 @@ +# 05. 认证与权限控制 + +这是这个项目最值得学习的部分之一。 + +如果你只是会写 CRUD,但不懂登录认证和权限控制,那你写出来的系统很难接近真实业务项目。 + +## 1. 先区分两个概念 + +### 认证 Authentication + +认证解决的是: + +- 你是谁? +- 你有没有登录? + +### 授权 Authorization + +授权解决的是: + +- 你能做什么? +- 你有没有权限访问这个页面或接口? + +这个项目里,两者都做了。 + +## 2. 后端权限控制怎么做 + +后端核心在 Spring Security 配置: + +- [`SecurityConfig.java`](/mnt/d/smartlab/server/src/main/java/com/smartlab/server/security/SecurityConfig.java) + +你从这里可以直接看出哪些接口允许谁访问。 + +例如: + +- `/auth/login` 允许匿名访问 +- `/system/**` 只允许管理员 +- 预约审批相关接口只允许管理员和教师 +- 公告管理写操作只允许管理员 + +这就是后端真正的权限边界。 + +记住一个非常重要的原则: + +前端隐藏菜单不等于有权限控制。 +真正的权限控制必须在后端。 + +## 3. JWT 在这个项目里起什么作用 + +登录成功后,后端会生成 token。 + +这个 token 的作用是: + +- 前端后续请求都带上它 +- 后端根据它识别当前用户是谁 +- 后端再根据用户角色判断是否有权访问资源 + +你可以把它理解成: + +- 用户的“登录凭证” +- 但不是 session,而是前后端分离常用的无状态 token + +## 4. 前端登录态怎么处理 + +前端重点看: + +- [`user.ts`](/mnt/d/smartlab/web/src/stores/user.ts) +- [`request.ts`](/mnt/d/smartlab/web/src/utils/request.ts) + +你应该重点理解: + +- token 登录后存到哪里 +- 请求发出时如何自动带 token +- 如果 token 失效,前端会怎么处理 + +## 5. 前端页面权限怎么处理 + +前端路由里定义了页面角色要求: + +- [`index.ts`](/mnt/d/smartlab/web/src/router/index.ts) + +你会看到每个页面的 `meta.roles`,例如: + +- 管理员专属页面 +- 教师和管理员都能访问的页面 +- 三种角色都能访问的页面 + +路由守卫会在跳转前判断当前用户角色。 + +如果不符合要求: + +- 弹出“无权限访问该页面” +- 跳转到 `403` 页面 + +这就是前端的权限体验控制。 + +## 6. 菜单为什么会随着角色变化 + +左侧菜单不是写死给所有人的,而是根据用户角色过滤。 + +重点看: + +- [`menu.ts`](/mnt/d/smartlab/web/src/router/menu.ts) +- [`layout/index.vue`](/mnt/d/smartlab/web/src/layout/index.vue) + +这里体现了一个常见后台设计: + +- 路由负责定义页面和权限 +- 菜单根据当前角色过滤展示 + +## 7. 一条登录后的完整权限链路 + +你可以把权限链路理解成这样: + +1. 用户登录 +2. 后端返回 token +3. 前端保存 token +4. 前端请求用户信息 +5. 前端拿到当前用户角色 +6. 菜单按角色过滤 +7. 路由守卫按角色拦截 +8. 后端接口再次校验角色 + +这里的第 7 步和第 8 步都重要: + +- 第 7 步负责用户体验 +- 第 8 步负责真正安全 + +## 8. 学这一章时的重点问题 + +你应该能回答: + +- 登录后 token 存在哪里? +- 请求为什么会自动带上 token? +- 学生为什么进不了审批中心? +- 为什么就算学生手动输入审批中心 URL,也不能真正操作? +- 前端权限控制和后端权限控制有什么区别? + +## 9. 建议本章完成后的实践任务 + +请你做两个练习: + +1. 找出“学生访问 `/reservation/pending` 时前端发生了什么” +2. 找出“学生调用审批接口时后端为什么会拒绝” + +这两个练习做完,你对权限控制的理解会非常扎实。 diff --git a/docs/learning/06-reservation-workflow.md b/docs/learning/06-reservation-workflow.md new file mode 100644 index 0000000..0390bd3 --- /dev/null +++ b/docs/learning/06-reservation-workflow.md @@ -0,0 +1,174 @@ +# 06. 预约审批业务链路 + +这是整个项目最值得跟通的一条业务链路。 + +因为它同时包含了: + +- 表单提交 +- 角色权限 +- 业务校验 +- 状态流转 +- 前后端联动 + +如果你能把这条链路从前到后讲清楚,说明你已经真正理解了这个项目的核心。 + +## 1. 业务目标是什么 + +这条链路包含两个关键阶段: + +### 阶段一:学生提交预约 + +学生填写: + +- 实验室 +- 设备 +- 日期 +- 开始时间 +- 结束时间 +- 预约用途 + +提交后,生成一条“待审批”的预约记录。 + +### 阶段二:教师或管理员审批 + +审批人可以: + +- 通过 +- 驳回 + +如果驳回,需要填写驳回原因。 + +## 2. 前端入口在哪里 + +### 学生预约申请页 + +- [`reservation/apply/index.vue`](/mnt/d/smartlab/web/src/views/reservation/apply/index.vue) + +### 学生查看我的预约 + +- [`reservation/my/index.vue`](/mnt/d/smartlab/web/src/views/reservation/my/index.vue) + +### 教师审批中心 + +- [`reservation/pending/index.vue`](/mnt/d/smartlab/web/src/views/reservation/pending/index.vue) + +### 前端 API + +- [`reservation.ts`](/mnt/d/smartlab/web/src/api/reservation.ts) + +## 3. 后端核心位置在哪里 + +重点看: + +- `modules/reservation/controller` +- `modules/reservation/service` +- `modules/reservation/mapper` + +你需要重点找这些动作: + +- 提交预约 +- 查询我的预约 +- 查询待审批预约 +- 审批通过 +- 驳回预约 + +## 4. 这条链路的学习方法 + +建议你按下面顺序跟: + +1. 在前端页面找到“提交预约”按钮 +2. 找到按钮点击后调用的方法 +3. 找到它调用的前端 API +4. 找到后端对应接口 +5. 看后端 `service` 里做了哪些校验 +6. 看数据库最终改了什么 +7. 再看审批通过和驳回如何更新状态 + +## 5. 你要重点关注哪些业务规则 + +### 提交预约时 + +你要观察后端是否做了这些判断: + +- 当前用户是否是学生 +- 预约时间是否合法 +- 设备和实验室是否有效 +- 是否存在时间冲突 + +### 审批时 + +你要观察: + +- 谁能审批 +- 审批后状态如何变化 +- 驳回时驳回原因存在哪里 + +## 6. 状态流转怎么理解 + +初学者经常把“审批功能”看成普通 CRUD,其实不完全对。 + +预约审批的本质是“状态流转”。 + +你可以把预约状态理解成: + +- 待审批 +- 已通过 +- 已驳回 +- 已取消 + +这类状态设计在业务系统里非常常见。 + +所以学这条链路,不只是学一个预约功能,更是在学“一个有状态的业务流程怎么设计”。 + +## 7. 前后端怎么配合显示状态 + +后端负责: + +- 存储状态值 +- 控制哪些操作合法 + +前端负责: + +- 把状态渲染成用户能理解的文字或标签 +- 根据状态显示不同按钮 + +例如: + +- 待审批时可能显示“取消” +- 已驳回时显示驳回原因 +- 审批中心里显示“通过”和“驳回”按钮 + +## 8. 这条链路为什么特别适合学习 + +因为它几乎把真实项目里最常见的东西都串起来了: + +- 表单 +- 接口 +- 权限 +- 状态 +- 列表 +- 详情 +- 错误提示 +- 自动化测试 + +很多同学一旦真正吃透这条链路,后面再看公告、维修、用户管理都会轻松很多。 + +## 9. 建议你亲手做的验证 + +请按这个顺序自己操作一次: + +1. 学生登录并提交预约 +2. 去数据库里确认记录是否生成 +3. 教师登录并审批通过 +4. 学生重新查看我的预约 +5. 再创建一条预约并走驳回流程 + +如果你能把这 5 步都跑通,并且知道每一步改了哪些数据,这条业务链路你就真正掌握了。 + +## 10. 本章完成后的自测问题 + +- 学生提交预约时前端调用了哪个 API? +- 后端如何判断只有学生能提交预约? +- 教师审批通过后,哪几个字段可能发生变化? +- 驳回原因最终保存在哪里? +- 前端“我的预约”页面如何展示驳回结果? diff --git a/docs/learning/07-reproduction-and-practice.md b/docs/learning/07-reproduction-and-practice.md new file mode 100644 index 0000000..2cebe1a --- /dev/null +++ b/docs/learning/07-reproduction-and-practice.md @@ -0,0 +1,217 @@ +# 07. 复现与练习路线 + +这一章是整套学习指南里最重要的“落地部分”。 + +因为真正的学习成果,不是“我大概看懂了”,而是: + +- 我能独立复现 +- 我能定位代码 +- 我能自己改 +- 我能自己验证 + +## 1. 什么叫“复现这个项目” + +对学习者来说,复现不是指把项目从头写一遍,而是至少做到: + +1. 独立跑起前后端和数据库 +2. 理解模块结构和主要技术栈 +3. 跟通一条完整业务链路 +4. 能新增一个小功能 +5. 能修一个小 bug +6. 能补一条测试 + +如果你能做到这些,就已经具备“基于这个项目继续学习和开发”的能力了。 + +## 2. 建议的七天练习计划 + +下面给一个适合大学生的节奏,不要求完全按天,但顺序建议尽量保持。 + +### 第 1 天:跑项目 + 熟悉页面 + +任务: + +- 跑起前端、后端、数据库 +- 用三种角色分别登录 +- 把所有菜单点一遍 + +目标: + +- 建立全局印象 + +### 第 2 天:看数据库和接口 + +任务: + +- 阅读 `schema.sql` +- 阅读 `seed.sql` +- 阅读 `docs/api.md` + +目标: + +- 知道数据怎么存,接口大致有哪些 + +### 第 3 天:看登录与权限 + +任务: + +- 看后端安全配置 +- 看前端 `router`、`store`、`request` + +目标: + +- 讲清楚 JWT 和权限控制 + +### 第 4 天:跟通预约申请链路 + +任务: + +- 看预约申请页前端代码 +- 看后端预约提交接口 + +目标: + +- 从页面跟到数据库 + +### 第 5 天:跟通审批链路 + +任务: + +- 看教师审批页面 +- 看后端审批通过/驳回逻辑 + +目标: + +- 理解状态流转 + +### 第 6 天:自己改一个小功能 + +建议功能: + +- 公告增加一个新字段 +- 预约列表加一个筛选条件 +- 某个页面增加状态提示 + +目标: + +- 真正进入“我能修改项目”的阶段 + +### 第 7 天:补一个测试或修一个 bug + +建议任务: + +- 补一条 E2E +- 修一个页面提示问题 +- 修一个接口校验问题 + +目标: + +- 从“读代码”转成“会维护代码” + +## 3. 非常适合学生的练习题 + +### 练习 1:给公告增加作者字段 + +你需要改: + +- 数据库表 +- 后端实体、DTO、VO +- 后端保存和查询逻辑 +- 前端表单和列表展示 + +这个练习可以帮你理解“一个字段如何贯穿全栈”。 + +### 练习 2:给我的预约页面增加日期筛选 + +你需要改: + +- 前端页面表单 +- 前端 API 参数 +- 后端查询接口 +- 数据库查询条件 + +这个练习可以帮你理解“查询条件如何从前端传到数据库”。 + +### 练习 3:补一个权限控制 + +例如: + +- 教师不能访问某个管理员专属功能 + +你需要改: + +- 后端权限配置 +- 前端路由或菜单控制 +- 最好补一条测试 + +这个练习可以帮你真正理解“安全边界不是只靠前端”。 + +## 4. 如果你想彻底理解项目,至少要做这三件事 + +### 第一件:画出系统结构图 + +你不一定要画得很漂亮,但至少应该能自己画出: + +- 前端有哪些主要页面 +- 后端有哪些核心模块 +- 数据库有哪些关键表 + +### 第二件:画出预约审批时序图 + +你可以手绘,也可以用 Mermaid。 + +至少画出: + +- 学生提交 +- 后端校验 +- 数据库存储 +- 教师审批 +- 状态更新 + +### 第三件:自己做一次完整演示 + +演示流程建议: + +1. 学生提交预约 +2. 教师审批 +3. 管理员查看管理页 +4. 展示数据库记录 +5. 讲解关键代码位置 + +只要你能完整讲一遍,说明你对这个项目已经不是“模糊理解”。 + +## 5. 如何判断自己是真的学会了 + +你可以用这份清单自测。 + +如果下面大部分都能做到,说明你已经真的掌握了这个项目: + +- 我能独立启动项目 +- 我能解释项目主要模块 +- 我能说清楚三种角色权限差异 +- 我能跟通登录流程 +- 我能跟通预约审批流程 +- 我能找到前端某个按钮对应的后端接口 +- 我能定位一个字段在前后端和数据库中的位置 +- 我能改一个小功能 +- 我能补一个小测试 + +## 6. 给老师或带队同学的使用建议 + +如果你希望别人“迅速精准地开始学习”,建议直接按下面方式使用这套文档: + +1. 先让学习者阅读总览和运行文档 +2. 要求其独立把项目跑起来 +3. 再按顺序学习后端、前端、权限、预约链路 +4. 最后要求完成一个小改动和一次演示 + +不要让学习者第一天就直接读全部代码。那样通常效率最低。 + +## 7. 最后的建议 + +学习项目时,真正重要的不是“看了多少文件”,而是: + +- 你能不能解释它 +- 你能不能修改它 +- 你能不能验证它 + +如果你每学一块都坚持做到“能解释、能修改、能验证”,那么这份项目就会从“参考代码”变成你的真实能力。 diff --git a/docs/learning/README.md b/docs/learning/README.md new file mode 100644 index 0000000..c39ec76 --- /dev/null +++ b/docs/learning/README.md @@ -0,0 +1,62 @@ +# 智慧实验室项目学习指南 + +这套文档是给“第一次接触完整前后端分离项目”的学习者准备的,尤其适合: + +- Java 基础一般,但想通过项目把知识串起来的大学生 +- 需要带学生做课程设计、实训、毕设原型的老师 +- 接手该项目,希望快速上手的人 + +这套指南的目标不是“读完就觉得懂了”,而是帮助你做到四件事: + +1. 独立把项目跑起来 +2. 说清楚系统的整体结构 +3. 跟通至少一条完整业务链路 +4. 在此基础上自己改功能、修 bug、补测试 + +## 推荐阅读顺序 + +建议按这个顺序读,不要跳着读: + +1. [`01-project-overview.md`](/mnt/d/smartlab/docs/learning/01-project-overview.md) +2. [`02-environment-and-run.md`](/mnt/d/smartlab/docs/learning/02-environment-and-run.md) +3. [`03-backend-guide.md`](/mnt/d/smartlab/docs/learning/03-backend-guide.md) +4. [`04-frontend-guide.md`](/mnt/d/smartlab/docs/learning/04-frontend-guide.md) +5. [`05-auth-and-permission.md`](/mnt/d/smartlab/docs/learning/05-auth-and-permission.md) +6. [`06-reservation-workflow.md`](/mnt/d/smartlab/docs/learning/06-reservation-workflow.md) +7. [`07-reproduction-and-practice.md`](/mnt/d/smartlab/docs/learning/07-reproduction-and-practice.md) + +## 你学完后应该具备的能力 + +如果你认真按顺序完成这套文档,至少应该能做到: + +- 解释这个项目用了哪些技术,以及每项技术负责什么 +- 说清楚前端页面、后端接口、数据库表之间的关系 +- 解释 JWT 登录、角色权限控制是怎么工作的 +- 从前端页面一路跟到后端 Service 和数据库 +- 自己加一个小功能,并知道应该改哪些文件 +- 自己写一条最基础的 E2E 用例或后端业务测试 + +## 学习方式建议 + +- 不要一上来逐行读代码,先建立全局地图。 +- 每读一章,最好自己动手验证一次。 +- 每理解一块内容,都尝试用自己的话复述。 +- 看不懂时,不要硬扛,先退回到“这层代码负责什么”。 + +## 配套文档 + +- 项目说明:[`README.md`](/mnt/d/smartlab/README.md) +- 接口文档:[`api.md`](/mnt/d/smartlab/docs/api.md) +- 数据库设计:[`db-design.md`](/mnt/d/smartlab/docs/db-design.md) +- 异常码文档:[`exception-codes.md`](/mnt/d/smartlab/docs/exception-codes.md) +- 优化记录:[`improvements.md`](/mnt/d/smartlab/docs/improvements.md) + +## 给老师的建议 + +如果你是带学生学习这个项目,建议把学习目标拆成三阶段: + +- 第一阶段:能跑起来,能讲清楚模块和角色 +- 第二阶段:能跟通预约审批链路 +- 第三阶段:能自己加一个小功能并演示 + +只要学生能独立完成这三阶段,说明不是“看过项目”,而是真正进入了项目开发学习状态。 diff --git a/scripts/dev-down.sh b/scripts/dev-down.sh new file mode 100644 index 0000000..f2cbae7 --- /dev/null +++ b/scripts/dev-down.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_DIR="$ROOT_DIR/.run" + +stop_pid() { + local name="$1" + local pid_file="$2" + + if [[ ! -f "$pid_file" ]]; then + echo "$name 未运行" + return + fi + + local pid + pid="$(cat "$pid_file")" + if kill -0 "$pid" 2>/dev/null; then + kill "$pid" + echo "已停止 $name ($pid)" + else + echo "$name 进程不存在,清理 PID 文件" + fi + rm -f "$pid_file" +} + +stop_pid "前端" "$RUN_DIR/web.pid" +stop_pid "后端" "$RUN_DIR/server.pid" diff --git a/scripts/dev-up.sh b/scripts/dev-up.sh new file mode 100644 index 0000000..2bf3720 --- /dev/null +++ b/scripts/dev-up.sh @@ -0,0 +1,93 @@ +#!/usr/bin/env bash + +set -euo pipefail + +ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +RUN_DIR="$ROOT_DIR/.run" +LOG_DIR="$RUN_DIR/logs" +M2_DIR="/tmp/smartlab-m2" +WEB_PORT="${WEB_PORT:-5173}" +WEB_HOST="${WEB_HOST:-127.0.0.1}" +SERVER_PORT="${SERVER_PORT:-8080}" +SERVER_HOST="${SERVER_HOST:-127.0.0.1}" +SERVER_HEALTH_URL="http://${SERVER_HOST}:${SERVER_PORT}/api/health" + +mkdir -p "$LOG_DIR" "$M2_DIR" + +require_command() { + if ! command -v "$1" >/dev/null 2>&1; then + echo "缺少命令: $1" >&2 + exit 1 + fi +} + +start_mysql() { + require_command docker + echo "启动 MySQL 容器..." + (cd "$ROOT_DIR" && docker compose up -d mysql >/dev/null) +} + +start_server() { + require_command bash + local pid_file="$RUN_DIR/server.pid" + local log_file="$LOG_DIR/server.log" + + if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + echo "后端已在运行,跳过启动" + return + fi + + echo "启动后端开发服务..." + ( + cd "$ROOT_DIR/server" + SPRING_PROFILES_ACTIVE=dev nohup ./mvnw -Dmaven.repo.local="$M2_DIR" spring-boot:run >"$log_file" 2>&1 & + echo $! >"$pid_file" + ) +} + +start_web() { + require_command npm + local pid_file="$RUN_DIR/web.pid" + local log_file="$LOG_DIR/web.log" + + if [[ -f "$pid_file" ]] && kill -0 "$(cat "$pid_file")" 2>/dev/null; then + echo "前端已在运行,跳过启动" + return + fi + + echo "启动前端开发服务..." + ( + cd "$ROOT_DIR/web" + WEB_HOST="$WEB_HOST" WEB_PORT="$WEB_PORT" VITE_API_PROXY_TARGET="http://${SERVER_HOST}:${SERVER_PORT}" \ + nohup npm run dev >"$log_file" 2>&1 & + echo $! >"$pid_file" + ) +} + +wait_for_health() { + require_command curl + echo "等待后端健康检查通过..." + for _ in $(seq 1 60); do + if curl -fsS "$SERVER_HEALTH_URL" >/dev/null 2>&1; then + echo "后端健康检查通过: $SERVER_HEALTH_URL" + return + fi + sleep 2 + done + + echo "后端未在预期时间内启动成功,请检查 $LOG_DIR/server.log" >&2 + exit 1 +} + +start_mysql +start_server +wait_for_health +start_web + +cat < originPatterns = Arrays.stream(allowedOriginPatterns.split(",")) + .map(String::trim) + .filter(value -> !value.isEmpty()) + .collect(Collectors.toList()); + return new WebMvcConfigurer() { @Override public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") - .allowedOriginPatterns("*") + .allowedOriginPatterns(originPatterns.toArray(String[]::new)) .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS") .allowedHeaders("*") .allowCredentials(true) @@ -23,4 +35,3 @@ public void addCorsMappings(CorsRegistry registry) { }; } } - diff --git a/server/src/main/java/com/smartlab/server/modules/maintenance/service/impl/MaintenanceServiceImpl.java b/server/src/main/java/com/smartlab/server/modules/maintenance/service/impl/MaintenanceServiceImpl.java index 4dfcd56..edab6b4 100644 --- a/server/src/main/java/com/smartlab/server/modules/maintenance/service/impl/MaintenanceServiceImpl.java +++ b/server/src/main/java/com/smartlab/server/modules/maintenance/service/impl/MaintenanceServiceImpl.java @@ -15,6 +15,8 @@ import com.smartlab.server.modules.user.entity.SysUser; import com.smartlab.server.modules.user.mapper.SysUserMapper; import com.smartlab.server.security.AuthUserPrincipal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -30,6 +32,8 @@ @Service public class MaintenanceServiceImpl implements MaintenanceService { + private static final Logger log = LoggerFactory.getLogger(MaintenanceServiceImpl.class); + private final MaintenanceOrderMapper maintenanceOrderMapper; private final EquipmentMapper equipmentMapper; private final SysUserMapper sysUserMapper; @@ -94,6 +98,8 @@ public void finish(Long id, FinishMaintenanceRequest request) { order.setFinishTime(LocalDateTime.now()); order.setResultDesc(request.getResultDesc()); maintenanceOrderMapper.updateById(order); + log.info("maintenance finished: orderId={}, equipmentId={}, handlerId={}, handlerName={}, resultDesc={}", + order.getId(), order.getEquipmentId(), principal.getUserId(), principal.getUsername(), request.getResultDesc()); } private PageResponse buildPage(Page page) { diff --git a/server/src/main/java/com/smartlab/server/modules/notice/service/impl/NoticeServiceImpl.java b/server/src/main/java/com/smartlab/server/modules/notice/service/impl/NoticeServiceImpl.java index 99653fa..51a34d8 100644 --- a/server/src/main/java/com/smartlab/server/modules/notice/service/impl/NoticeServiceImpl.java +++ b/server/src/main/java/com/smartlab/server/modules/notice/service/impl/NoticeServiceImpl.java @@ -13,6 +13,8 @@ import com.smartlab.server.modules.user.entity.SysUser; import com.smartlab.server.modules.user.mapper.SysUserMapper; import com.smartlab.server.security.AuthUserPrincipal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -28,6 +30,8 @@ @Service public class NoticeServiceImpl implements NoticeService { + private static final Logger log = LoggerFactory.getLogger(NoticeServiceImpl.class); + private final NoticeMapper noticeMapper; private final SysUserMapper sysUserMapper; @@ -66,6 +70,10 @@ public Long create(CreateNoticeRequest request) { notice.setPublisherId(principal.getUserId()); notice.setPublishTime("PUBLISHED".equals(request.getStatus()) ? LocalDateTime.now() : null); noticeMapper.insert(notice); + if ("PUBLISHED".equals(request.getStatus())) { + log.info("notice published on create: noticeId={}, title={}, publisherId={}, publisherName={}", + notice.getId(), notice.getTitle(), principal.getUserId(), principal.getUsername()); + } return notice.getId(); } @@ -80,6 +88,11 @@ public void update(Long id, UpdateNoticeRequest request) { notice.setPublishTime(LocalDateTime.now()); } noticeMapper.updateById(notice); + if (!"PUBLISHED".equals(oldStatus) && "PUBLISHED".equals(notice.getStatus())) { + AuthUserPrincipal principal = currentPrincipal(); + log.info("notice published on update: noticeId={}, title={}, publisherId={}, publisherName={}", + notice.getId(), notice.getTitle(), principal.getUserId(), principal.getUsername()); + } } @Override @@ -93,6 +106,11 @@ public void toggle(Long id) { notice.setPublishTime(LocalDateTime.now()); } noticeMapper.updateById(notice); + if ("PUBLISHED".equals(notice.getStatus())) { + AuthUserPrincipal principal = currentPrincipal(); + log.info("notice published on toggle: noticeId={}, title={}, publisherId={}, publisherName={}", + notice.getId(), notice.getTitle(), principal.getUserId(), principal.getUsername()); + } } @Override diff --git a/server/src/main/java/com/smartlab/server/modules/reservation/service/impl/ReservationServiceImpl.java b/server/src/main/java/com/smartlab/server/modules/reservation/service/impl/ReservationServiceImpl.java index 64ac292..204c4b1 100644 --- a/server/src/main/java/com/smartlab/server/modules/reservation/service/impl/ReservationServiceImpl.java +++ b/server/src/main/java/com/smartlab/server/modules/reservation/service/impl/ReservationServiceImpl.java @@ -18,6 +18,8 @@ import com.smartlab.server.modules.user.entity.SysUser; import com.smartlab.server.modules.user.mapper.SysUserMapper; import com.smartlab.server.security.AuthUserPrincipal; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.stereotype.Service; @@ -35,6 +37,7 @@ @Service public class ReservationServiceImpl implements ReservationService { + private static final Logger log = LoggerFactory.getLogger(ReservationServiceImpl.class); private static final Set CONFLICT_STATUS = Set.of("PENDING", "APPROVED"); private final ReservationMapper reservationMapper; @@ -145,6 +148,9 @@ public void approve(Long id) { reservation.setApproveTime(LocalDateTime.now()); reservation.setRejectReason(null); reservationMapper.updateById(reservation); + log.info("reservation approved: reservationId={}, orderNo={}, applicantId={}, approverId={}, approverName={}", + reservation.getId(), reservation.getOrderNo(), reservation.getUserId(), + principal.getUserId(), principal.getUsername()); } @Override @@ -159,6 +165,9 @@ public void reject(Long id, String reason) { reservation.setApproveTime(LocalDateTime.now()); reservation.setRejectReason(reason); reservationMapper.updateById(reservation); + log.info("reservation rejected: reservationId={}, orderNo={}, applicantId={}, approverId={}, approverName={}, reason={}", + reservation.getId(), reservation.getOrderNo(), reservation.getUserId(), + principal.getUserId(), principal.getUsername(), reason); } private void validateItemsForCreate(CreateReservationRequest request) { diff --git a/server/src/main/resources/application-prod.yml b/server/src/main/resources/application-prod.yml index f52748a..33ea492 100644 --- a/server/src/main/resources/application-prod.yml +++ b/server/src/main/resources/application-prod.yml @@ -19,4 +19,5 @@ smartlab: jwt: secret: ${JWT_SECRET:change-me-to-at-least-32-bytes} expiration-hours: ${JWT_EXPIRE_HOURS:24} - + cors: + allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:https://smartlab.example.com} diff --git a/server/src/main/resources/application.yml b/server/src/main/resources/application.yml index 1a277be..d34365f 100644 --- a/server/src/main/resources/application.yml +++ b/server/src/main/resources/application.yml @@ -22,3 +22,5 @@ smartlab: jwt: secret: ${JWT_SECRET:smartlab-super-secret-key-for-jwt-minimum-32-bytes} expiration-hours: ${JWT_EXPIRE_HOURS:24} + cors: + allowed-origin-patterns: ${CORS_ALLOWED_ORIGIN_PATTERNS:http://127.0.0.1:5173,http://localhost:5173} diff --git a/web/.env.example b/web/.env.example new file mode 100644 index 0000000..2768626 --- /dev/null +++ b/web/.env.example @@ -0,0 +1,4 @@ +WEB_HOST=127.0.0.1 +WEB_PORT=5173 +VITE_API_PROXY_TARGET=http://127.0.0.1:8080 +VITE_API_BASE_URL=/api diff --git a/web/package-lock.json b/web/package-lock.json index 01f3441..34c0a45 100644 --- a/web/package-lock.json +++ b/web/package-lock.json @@ -18,6 +18,7 @@ "vue-router": "^4.6.4" }, "devDependencies": { + "@playwright/test": "^1.55.0", "@types/node": "^24.12.0", "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.9.0", @@ -192,6 +193,22 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "node_modules/@popperjs/core": { "name": "@sxzz/popperjs-es", "version": "2.11.8", @@ -1588,6 +1605,53 @@ } } }, + "node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/playwright/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/postcss": { "version": "8.5.8", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.8.tgz", diff --git a/web/package.json b/web/package.json index 18704f8..11bbb5f 100644 --- a/web/package.json +++ b/web/package.json @@ -5,8 +5,12 @@ "type": "module", "scripts": { "dev": "vite", + "dev:host": "vite --host 0.0.0.0", "build": "vue-tsc -b && vite build", - "preview": "vite preview" + "preview": "vite preview", + "test:e2e": "playwright test", + "test:e2e:headed": "playwright test --headed", + "test:e2e:install": "playwright install --with-deps chromium" }, "dependencies": { "@element-plus/icons-vue": "^2.3.2", @@ -22,6 +26,7 @@ "@types/node": "^24.12.0", "@vitejs/plugin-vue": "^6.0.5", "@vue/tsconfig": "^0.9.0", + "@playwright/test": "^1.55.0", "typescript": "~5.9.3", "vite": "^8.0.0", "vue-tsc": "^3.2.5" diff --git a/web/playwright.config.ts b/web/playwright.config.ts new file mode 100644 index 0000000..041d8e3 --- /dev/null +++ b/web/playwright.config.ts @@ -0,0 +1,57 @@ +import { defineConfig, devices } from '@playwright/test' + +const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173' + +export default defineConfig({ + testDir: './tests/e2e', + timeout: 30_000, + workers: process.env.CI ? 2 : 1, + expect: { + timeout: 5_000 + }, + fullyParallel: true, + forbidOnly: !!process.env.CI, + retries: process.env.CI ? 2 : 0, + reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', + use: { + baseURL, + trace: 'on-first-retry', + screenshot: 'only-on-failure', + video: 'retain-on-failure' + }, + webServer: process.env.PLAYWRIGHT_SKIP_WEBSERVER + ? undefined + : [ + { + command: + './mvnw -Dmaven.repo.local=/tmp/smartlab-m2 spring-boot:run -Dspring-boot.run.profiles=test -Dspring-boot.run.arguments=--server.address=127.0.0.1', + url: 'http://127.0.0.1:8080/api/health', + cwd: '../server', + env: { + ...process.env, + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost' + }, + timeout: 120_000, + reuseExistingServer: true + }, + { + command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', + url: 'http://127.0.0.1:4173', + cwd: '.', + env: { + ...process.env, + NO_PROXY: '127.0.0.1,localhost', + no_proxy: '127.0.0.1,localhost' + }, + timeout: 180_000, + reuseExistingServer: true + } + ], + projects: [ + { + name: 'chromium', + use: { ...devices['Desktop Chrome'] } + } + ] +}) diff --git a/web/src/api/notice.ts b/web/src/api/notice.ts index 3330bc3..fdfa4dc 100644 --- a/web/src/api/notice.ts +++ b/web/src/api/notice.ts @@ -1,4 +1,5 @@ import request from '@/utils/request' +import type { PageResponse } from '@/types/api' export interface Notice { id: number @@ -25,14 +26,6 @@ export interface NoticeQuery { status?: number } -export interface PageResponse { - records: T[] - total: number - pageNum: number - pageSize: number - pages: number -} - const noticeStatusToCode = (status: string): number => (status === 'PUBLISHED' ? 1 : 0) const codeToNoticeStatus = (status?: number): string | undefined => { if (status === undefined) return undefined diff --git a/web/src/api/reservation.ts b/web/src/api/reservation.ts index 7ca00aa..4455de3 100644 --- a/web/src/api/reservation.ts +++ b/web/src/api/reservation.ts @@ -1,4 +1,5 @@ import request from '@/utils/request' +import type { PageResponse } from '@/types/api' export interface Reservation { id: number @@ -40,14 +41,6 @@ export interface ReservationQuery { reservationDate?: string } -export interface PageResponse { - records: T[] - total: number - pageNum: number - pageSize: number - pages: number -} - export interface Equipment { id: number name: string diff --git a/web/src/api/system.ts b/web/src/api/system.ts index b6fbb2b..7f91b5c 100644 --- a/web/src/api/system.ts +++ b/web/src/api/system.ts @@ -1,4 +1,5 @@ import request from '@/utils/request' +import type { PageResponse } from '@/types/api' export interface User { id: number @@ -31,14 +32,6 @@ export interface UserForm { roleId: number } -export interface PageResponse { - records: T[] - total: number - pageNum: number - pageSize: number - pages: number -} - const toPageResponse = (records: T[], pageNum = 1, pageSize = 10): PageResponse => { const safeSize = pageSize > 0 ? pageSize : 10 const total = records.length diff --git a/web/src/components/AppState.vue b/web/src/components/AppState.vue new file mode 100644 index 0000000..bdf9978 --- /dev/null +++ b/web/src/components/AppState.vue @@ -0,0 +1,41 @@ + + + + + diff --git a/web/src/main.ts b/web/src/main.ts index dac07a0..e30933d 100644 --- a/web/src/main.ts +++ b/web/src/main.ts @@ -1,20 +1,159 @@ import { createApp } from 'vue' import { createPinia } from 'pinia' -import ElementPlus from 'element-plus' -import 'element-plus/dist/index.css' -import * as ElementPlusIconsVue from '@element-plus/icons-vue' +import { + ElAside, + ElAvatar, + ElBreadcrumb, + ElBreadcrumbItem, + ElButton, + ElCard, + ElCol, + ElContainer, + ElDatePicker, + ElDescriptions, + ElDescriptionsItem, + ElDialog, + ElDropdown, + ElDropdownItem, + ElDropdownMenu, + ElEmpty, + ElForm, + ElFormItem, + ElHeader, + ElIcon, + ElInput, + ElInputNumber, + ElMain, + ElMenu, + ElMenuItem, + ElOption, + ElPagination, + ElRadio, + ElRadioGroup, + ElRow, + ElSelect, + ElTable, + ElTableColumn, + ElTag, + ElTimeSelect, + ElLoading +} from 'element-plus' +import { + Bell, + Calendar, + Check, + Checked, + Clock, + Cpu, + DataAnalysis, + Document, + EditPen, + Folder, + InfoFilled, + Monitor, + Notification, + Odometer, + OfficeBuilding, + PieChart, + Plus, + Refresh, + School, + Search, + SwitchButton, + Tools, + TrendCharts, + Trophy, + User, + Avatar, + Warning +} from '@element-plus/icons-vue' import App from './App.vue' import router from './router' +import './styles/element-plus.css' import './styles/global.css' const app = createApp(App) -for (const [key, component] of Object.entries(ElementPlusIconsVue)) { - app.component(key, component) +const components = [ + ElAside, + ElAvatar, + ElBreadcrumb, + ElBreadcrumbItem, + ElButton, + ElCard, + ElCol, + ElContainer, + ElDatePicker, + ElDescriptions, + ElDescriptionsItem, + ElDialog, + ElDropdown, + ElDropdownItem, + ElDropdownMenu, + ElEmpty, + ElForm, + ElFormItem, + ElHeader, + ElIcon, + ElInput, + ElInputNumber, + ElMain, + ElMenu, + ElMenuItem, + ElOption, + ElPagination, + ElRadio, + ElRadioGroup, + ElRow, + ElSelect, + ElTable, + ElTableColumn, + ElTag, + ElTimeSelect +] + +const icons = { + Avatar, + Bell, + Calendar, + Check, + Checked, + Clock, + Cpu, + DataAnalysis, + Document, + EditPen, + Folder, + InfoFilled, + Monitor, + Notification, + Odometer, + OfficeBuilding, + PieChart, + Plus, + Refresh, + School, + Search, + SwitchButton, + Tools, + TrendCharts, + Trophy, + User, + Warning } +components.forEach((component) => { + if (component.name) { + app.component(component.name, component) + } +}) + +Object.entries(icons).forEach(([name, component]) => { + app.component(name, component) +}) + app.use(createPinia()) app.use(router) -app.use(ElementPlus) +app.use(ElLoading) app.mount('#app') diff --git a/web/src/router/index.ts b/web/src/router/index.ts index 207d31b..2c69b07 100644 --- a/web/src/router/index.ts +++ b/web/src/router/index.ts @@ -1,4 +1,5 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router' +import { ElMessage } from 'element-plus' import { useUserStore } from '@/stores/user' import Layout from '@/layout/index.vue' @@ -79,6 +80,12 @@ const routes: RouteRecordRaw[] = [ name: 'Notice', component: () => import('@/views/notice/index.vue'), meta: { title: '公告管理', icon: 'Bell', roles: ['ADMIN'] } + }, + { + path: '403', + name: 'Forbidden', + component: () => import('@/views/error/forbidden.vue'), + meta: { title: '无权限' } } ] } @@ -111,7 +118,15 @@ router.beforeEach(async (to, _from, next) => { } } + const allowedRoles = to.meta.roles as string[] | undefined + const currentRole = userStore.userInfo.roleCode + if (allowedRoles && !allowedRoles.includes(currentRole)) { + ElMessage.error('无权限访问该页面') + next({ path: '/403', query: { from: to.fullPath } }) + return + } + next() }) -export default router \ No newline at end of file +export default router diff --git a/web/src/styles/element-plus.css b/web/src/styles/element-plus.css new file mode 100644 index 0000000..c205c31 --- /dev/null +++ b/web/src/styles/element-plus.css @@ -0,0 +1,36 @@ +@import 'element-plus/theme-chalk/base.css'; +@import 'element-plus/theme-chalk/el-aside.css'; +@import 'element-plus/theme-chalk/el-avatar.css'; +@import 'element-plus/theme-chalk/el-breadcrumb.css'; +@import 'element-plus/theme-chalk/el-breadcrumb-item.css'; +@import 'element-plus/theme-chalk/el-button.css'; +@import 'element-plus/theme-chalk/el-card.css'; +@import 'element-plus/theme-chalk/el-col.css'; +@import 'element-plus/theme-chalk/el-container.css'; +@import 'element-plus/theme-chalk/el-descriptions.css'; +@import 'element-plus/theme-chalk/el-descriptions-item.css'; +@import 'element-plus/theme-chalk/el-dialog.css'; +@import 'element-plus/theme-chalk/el-dropdown.css'; +@import 'element-plus/theme-chalk/el-empty.css'; +@import 'element-plus/theme-chalk/el-form.css'; +@import 'element-plus/theme-chalk/el-form-item.css'; +@import 'element-plus/theme-chalk/el-header.css'; +@import 'element-plus/theme-chalk/el-icon.css'; +@import 'element-plus/theme-chalk/el-input.css'; +@import 'element-plus/theme-chalk/el-input-number.css'; +@import 'element-plus/theme-chalk/el-loading.css'; +@import 'element-plus/theme-chalk/el-main.css'; +@import 'element-plus/theme-chalk/el-menu.css'; +@import 'element-plus/theme-chalk/el-message.css'; +@import 'element-plus/theme-chalk/el-message-box.css'; +@import 'element-plus/theme-chalk/el-option.css'; +@import 'element-plus/theme-chalk/el-overlay.css'; +@import 'element-plus/theme-chalk/el-pagination.css'; +@import 'element-plus/theme-chalk/el-radio.css'; +@import 'element-plus/theme-chalk/el-radio-group.css'; +@import 'element-plus/theme-chalk/el-row.css'; +@import 'element-plus/theme-chalk/el-select.css'; +@import 'element-plus/theme-chalk/el-table.css'; +@import 'element-plus/theme-chalk/el-table-column.css'; +@import 'element-plus/theme-chalk/el-tag.css'; +@import 'element-plus/theme-chalk/el-time-select.css'; diff --git a/web/src/types/api.ts b/web/src/types/api.ts new file mode 100644 index 0000000..90f9f27 --- /dev/null +++ b/web/src/types/api.ts @@ -0,0 +1,12 @@ +export interface PageResponse { + records: T[] + total: number + pageNum: number + pageSize: number + pages: number +} + +export interface ApiErrorPayload { + code?: number + message?: string +} diff --git a/web/src/utils/echarts.ts b/web/src/utils/echarts.ts new file mode 100644 index 0000000..52f31c0 --- /dev/null +++ b/web/src/utils/echarts.ts @@ -0,0 +1,9 @@ +import { use } from 'echarts/core' +import { CanvasRenderer } from 'echarts/renderers' +import { LineChart, PieChart } from 'echarts/charts' +import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components' + +use([CanvasRenderer, LineChart, PieChart, GridComponent, LegendComponent, TooltipComponent]) + +export type { ECharts, EChartsCoreOption } from 'echarts/core' +export { graphic, init } from 'echarts/core' diff --git a/web/src/utils/request.ts b/web/src/utils/request.ts index b3ecbf5..5e3aaf3 100644 --- a/web/src/utils/request.ts +++ b/web/src/utils/request.ts @@ -5,12 +5,25 @@ import axios, { type InternalAxiosRequestConfig } from 'axios' import { ElMessage } from 'element-plus' +import type { ApiErrorPayload } from '@/types/api' const request = axios.create({ - baseURL: '/api', + baseURL: import.meta.env.VITE_API_BASE_URL || '/api', timeout: 10000 }) +export class AppRequestError extends Error { + code?: number + status?: number + + constructor(message: string, options?: { code?: number; status?: number }) { + super(message) + this.name = 'AppRequestError' + this.code = options?.code + this.status = options?.status + } +} + interface HttpClient { get(url: string, config?: AxiosRequestConfig): Promise post(url: string, data?: unknown, config?: AxiosRequestConfig): Promise @@ -39,16 +52,22 @@ request.interceptors.response.use( if (code === 0 || code === 200) { return data } - ElMessage.error(message || '请求失败') - return Promise.reject(new Error(message || '请求失败')) + const errorMessage = message || '请求失败' + ElMessage.error(errorMessage) + return Promise.reject(new AppRequestError(errorMessage, { code, status: response.status })) } return payload }, (error: AxiosError) => { - const responseData = error.response?.data as { message?: string } | undefined + const responseData = error.response?.data as ApiErrorPayload | undefined const message = responseData?.message || error.message || '网络错误' ElMessage.error(message) - return Promise.reject(error) + return Promise.reject( + new AppRequestError(message, { + code: responseData?.code, + status: error.response?.status + }) + ) } ) diff --git a/web/src/views/dashboard/index.vue b/web/src/views/dashboard/index.vue index 48f0216..12cb996 100644 --- a/web/src/views/dashboard/index.vue +++ b/web/src/views/dashboard/index.vue @@ -8,7 +8,7 @@ - +
@@ -30,7 +30,7 @@
- +
@@ -41,7 +41,7 @@
- +
@@ -103,7 +103,7 @@
@@ -123,14 +123,18 @@ + + diff --git a/web/src/views/login/index.vue b/web/src/views/login/index.vue index cf0e0af..00b5b2d 100644 --- a/web/src/views/login/index.vue +++ b/web/src/views/login/index.vue @@ -77,6 +77,7 @@ import { ElMessage } from 'element-plus' import type { FormInstance, FormRules } from 'element-plus' import { useUserStore } from '@/stores/user' import { login } from '@/api/auth' +import { AppRequestError } from '@/utils/request' const router = useRouter() const userStore = useUserStore() @@ -111,8 +112,18 @@ const handleLogin = async () => { } ElMessage.success('登录成功') router.push('/dashboard') - } catch { - ElMessage.error('用户名或密码错误') + } catch (error) { + if (error instanceof AppRequestError) { + if (error.code === 401) { + ElMessage.error('用户名或密码错误,请重新输入') + } else if (error.code === 403) { + ElMessage.error('账号已被停用,请联系管理员') + } else { + ElMessage.error('登录服务暂时不可用,请稍后重试') + } + return + } + ElMessage.error('登录服务暂时不可用,请稍后重试') } finally { loading.value = false } diff --git a/web/src/views/maintenance/index.vue b/web/src/views/maintenance/index.vue index 91d351f..8d850a7 100644 --- a/web/src/views/maintenance/index.vue +++ b/web/src/views/maintenance/index.vue @@ -33,7 +33,16 @@
- + + + @@ -140,10 +149,12 @@ import { getMaintenanceList, createMaintenance, finishMaintenance, type Maintena import { getLabList, type Lab } from '@/api/lab' import { getEquipmentList, type Equipment } from '@/api/equipment' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const addLoading = ref(false) const finishLoading = ref(false) +const loadError = ref(false) const maintenanceList = ref([]) const labList = ref([]) const equipmentList = ref([]) @@ -199,6 +210,7 @@ const formatDate = (date: string | null | undefined) => { const fetchMaintenanceList = async () => { loading.value = true + loadError.value = false try { const data = await getMaintenanceList({ pageNum: pagination.pageNum, @@ -208,7 +220,9 @@ const fetchMaintenanceList = async () => { maintenanceList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + maintenanceList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -345,4 +359,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/notice/index.vue b/web/src/views/notice/index.vue index 63b07bd..e754b79 100644 --- a/web/src/views/notice/index.vue +++ b/web/src/views/notice/index.vue @@ -36,7 +36,16 @@
- + + + @@ -114,9 +123,11 @@ import { ref, reactive, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { getNoticeList, createNotice, updateNotice, deleteNotice, toggleNoticeStatus, type Notice, type NoticeForm, type NoticeQuery } from '@/api/notice' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const noticeList = ref([]) const dialogVisible = ref(false) const formRef = ref() @@ -161,6 +172,7 @@ const formatDate = (date: string | null | undefined) => { const fetchNoticeList = async () => { loading.value = true + loadError.value = false try { const data = await getNoticeList({ pageNum: pagination.pageNum, @@ -170,7 +182,9 @@ const fetchNoticeList = async () => { noticeList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + noticeList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -304,4 +318,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/reservation/apply/index.vue b/web/src/views/reservation/apply/index.vue index 7fa68fb..912f1f0 100644 --- a/web/src/views/reservation/apply/index.vue +++ b/web/src/views/reservation/apply/index.vue @@ -16,7 +16,13 @@ - +
+
{{ lab.name }} @@ -24,11 +30,19 @@
+
- +
+ +
{{ selectedEquipment.status === 1 ? '正常' : '维修中' }} @@ -37,6 +51,7 @@
+
+
+
+
+
+
@@ -81,6 +101,7 @@
+
+
- +
+ 提交预约 +
重置 @@ -277,6 +306,10 @@ onMounted(() => { color: var(--text-secondary); } +.field-shell { + width: 100%; +} + .form-card, .tips-card, .status-card { border-radius: 12px; margin-bottom: 16px; @@ -366,4 +399,4 @@ onMounted(() => { .status-item span:last-child { flex: 1; } - \ No newline at end of file + diff --git a/web/src/views/reservation/my/index.vue b/web/src/views/reservation/my/index.vue index 2689f3f..2e8fc3c 100644 --- a/web/src/views/reservation/my/index.vue +++ b/web/src/views/reservation/my/index.vue @@ -28,7 +28,16 @@
- + + + @@ -101,8 +110,10 @@ import { ref, reactive, onMounted } from 'vue' import { ElMessage, ElMessageBox } from 'element-plus' import { getMyReservations, cancelReservation, type Reservation, type ReservationQuery } from '@/api/reservation' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) +const loadError = ref(false) const reservationList = ref([]) const detailVisible = ref(false) const detail = ref(null) @@ -145,6 +156,7 @@ const formatDate = (date: string | null | undefined) => { const fetchReservationList = async () => { loading.value = true + loadError.value = false try { const data = await getMyReservations({ pageNum: pagination.pageNum, @@ -154,7 +166,9 @@ const fetchReservationList = async () => { reservationList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + reservationList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -228,4 +242,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/reservation/pending/index.vue b/web/src/views/reservation/pending/index.vue index 8ba577c..87c9d9c 100644 --- a/web/src/views/reservation/pending/index.vue +++ b/web/src/views/reservation/pending/index.vue @@ -35,7 +35,16 @@
- + + + @@ -102,9 +111,11 @@ import { ElMessage, ElMessageBox } from 'element-plus' import { getPendingReservations, approveReservation, rejectReservation, type Reservation, type ReservationQuery } from '@/api/reservation' import { getLabList, type Lab } from '@/api/lab' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const rejectLoading = ref(false) +const loadError = ref(false) const reservationList = ref([]) const labList = ref([]) const detailVisible = ref(false) @@ -130,6 +141,7 @@ const formatDate = (date: string | undefined) => { const fetchReservationList = async () => { loading.value = true + loadError.value = false try { const data = await getPendingReservations({ pageNum: pagination.pageNum, @@ -139,7 +151,9 @@ const fetchReservationList = async () => { reservationList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + reservationList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -249,4 +263,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/system/category/index.vue b/web/src/views/system/category/index.vue index a7d9c70..a6b82a1 100644 --- a/web/src/views/system/category/index.vue +++ b/web/src/views/system/category/index.vue @@ -30,7 +30,16 @@
- + + + @@ -82,9 +91,11 @@ import { ref, reactive, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { getCategoryList, createCategory, updateCategory, deleteCategory as deleteCategoryApi, type EquipmentCategory, type EquipmentCategoryForm, type EquipmentCategoryQuery } from '@/api/equipment' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const categoryList = ref([]) const dialogVisible = ref(false) const formRef = ref() @@ -119,6 +130,7 @@ const formatDate = (date: string) => { const fetchCategoryList = async () => { loading.value = true + loadError.value = false try { const data = await getCategoryList({ pageNum: pagination.pageNum, @@ -128,7 +140,9 @@ const fetchCategoryList = async () => { categoryList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + categoryList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -247,4 +261,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/system/equipment/index.vue b/web/src/views/system/equipment/index.vue index c55054e..64e974f 100644 --- a/web/src/views/system/equipment/index.vue +++ b/web/src/views/system/equipment/index.vue @@ -46,7 +46,16 @@
- + + + @@ -132,9 +141,11 @@ import { getEquipmentList, createEquipment, updateEquipment, deleteEquipment as import { getCategoryList, type EquipmentCategory } from '@/api/equipment' import { getLabList, type Lab } from '@/api/lab' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const equipmentList = ref([]) const categoryList = ref([]) const labList = ref([]) @@ -183,6 +194,7 @@ const formatDate = (date: string) => { const fetchEquipmentList = async () => { loading.value = true + loadError.value = false try { const data = await getEquipmentList({ pageNum: pagination.pageNum, @@ -192,7 +204,9 @@ const fetchEquipmentList = async () => { equipmentList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + equipmentList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -344,4 +358,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/system/lab/index.vue b/web/src/views/system/lab/index.vue index 2dc1d6c..9964aa6 100644 --- a/web/src/views/system/lab/index.vue +++ b/web/src/views/system/lab/index.vue @@ -36,7 +36,16 @@
- + + + @@ -108,9 +117,11 @@ import { ref, reactive, computed, onMounted } from 'vue' import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'element-plus' import { getLabList, createLab, updateLab, deleteLab as deleteLabApi, type Lab,type LabForm, type LabQuery } from '@/api/lab' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const labList = ref([]) const dialogVisible = ref(false) const formRef = ref() @@ -152,6 +163,7 @@ const formatDate = (date: string) => { const fetchLabList = async () => { loading.value = true + loadError.value = false try { const data = await getLabList({ pageNum: pagination.pageNum, @@ -161,7 +173,9 @@ const fetchLabList = async () => { labList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + labList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -287,4 +301,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/system/role/index.vue b/web/src/views/system/role/index.vue index 994555c..c1af033 100644 --- a/web/src/views/system/role/index.vue +++ b/web/src/views/system/role/index.vue @@ -30,7 +30,16 @@
- + + + @@ -85,9 +94,11 @@ import { ref, reactive, computed, onMounted } from 'vue' import { ElMessage, type FormInstance, type FormRules } from 'element-plus' import { getRoleList, createRole, updateRole, type Role, type RoleForm, type RoleQuery } from '@/api/system' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const roleList = ref([]) const dialogVisible = ref(false) const formRef = ref() @@ -127,6 +138,7 @@ const formatDate = (date: string) => { const fetchRoleList = async () => { loading.value = true + loadError.value = false try { const data = await getRoleList({ pageNum: pagination.pageNum, @@ -136,7 +148,9 @@ const fetchRoleList = async () => { roleList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + roleList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -242,4 +256,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/src/views/system/user/index.vue b/web/src/views/system/user/index.vue index 3bfcfcb..ee8014e 100644 --- a/web/src/views/system/user/index.vue +++ b/web/src/views/system/user/index.vue @@ -41,7 +41,16 @@
- + + + @@ -124,9 +133,11 @@ import { ElMessage, ElMessageBox, type FormInstance, type FormRules } from 'elem import { getUserList, createUser, updateUser, updateUserStatus, type User, type UserForm, type UserQuery } from '@/api/system' import { getRoleList, type Role } from '@/api/system' import dayjs from 'dayjs' +import AppState from '@/components/AppState.vue' const loading = ref(false) const submitLoading = ref(false) +const loadError = ref(false) const userList = ref([]) const roleList = ref([]) const dialogVisible = ref(false) @@ -172,6 +183,7 @@ const formatDate = (date: string) => { const fetchUserList = async () => { loading.value = true + loadError.value = false try { const data = await getUserList({ pageNum: pagination.pageNum, @@ -181,7 +193,9 @@ const fetchUserList = async () => { userList.value = data.records pagination.total = data.total } catch { - // error handled by interceptor + userList.value = [] + pagination.total = 0 + loadError.value = true } finally { loading.value = false } @@ -322,4 +336,4 @@ onMounted(() => { display: flex; justify-content: flex-end; } - \ No newline at end of file + diff --git a/web/test-results/.last-run.json b/web/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/web/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file diff --git a/web/tests/e2e/auth.spec.ts b/web/tests/e2e/auth.spec.ts new file mode 100644 index 0000000..0527789 --- /dev/null +++ b/web/tests/e2e/auth.spec.ts @@ -0,0 +1,26 @@ +import { expect, test } from '@playwright/test' + +const login = async (page: Parameters[0]['page'], username: string, password: string) => { + await page.goto('/login') + await page.getByPlaceholder('请输入用户名').fill(username) + await page.getByPlaceholder('请输入密码').fill(password) + await page.getByRole('button', { name: /登\s*录/ }).click() +} + +test('unauthenticated user is redirected to login', async ({ page }) => { + await page.goto('/dashboard') + await expect(page).toHaveURL(/\/login$/) +}) + +test('user can log in with seed admin account', async ({ page }) => { + await login(page, 'admin', '123456') + await expect(page).toHaveURL(/\/dashboard$/, { timeout: 15_000 }) + await expect(page.getByRole('heading', { name: /欢迎回来,系统管理员/ })).toBeVisible() + await expect(page.locator('.user-name')).toHaveText('系统管理员') +}) + +test('login shows credential error for wrong password', async ({ page }) => { + await login(page, 'admin', 'wrong-password') + await expect(page.getByText('用户名或密码错误,请重新输入')).toBeVisible() + await expect(page).toHaveURL(/\/login$/) +}) diff --git a/web/tests/e2e/notice.spec.ts b/web/tests/e2e/notice.spec.ts new file mode 100644 index 0000000..2141f18 --- /dev/null +++ b/web/tests/e2e/notice.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from '@playwright/test' + +const loginAsAdmin = async (page: Parameters[0]['page']) => { + await page.goto('/login') + await page.getByPlaceholder('请输入用户名').fill('admin') + await page.getByPlaceholder('请输入密码').fill('123456') + await page.getByRole('button', { name: /登\s*录/ }).click() + await expect(page).toHaveURL(/\/dashboard$/) +} + +test('admin can publish a notice', async ({ page }) => { + const noticeTitle = `自动化公告发布校验-${Date.now()}` + const noticeContent = `这是一条由 E2E 自动化在 ${new Date().toISOString()} 发布的公告内容,用于验证管理员发布流程。` + + await loginAsAdmin(page) + await page.goto('/notice') + await expect(page.getByRole('heading', { name: '公告管理' })).toBeVisible() + + await page.getByRole('button', { name: '新增公告' }).click() + await page.getByPlaceholder('请输入公告标题').fill(noticeTitle) + await page.getByPlaceholder('请输入公告内容').fill(noticeContent) + await page.getByRole('radio', { name: '发布' }).click() + await page.getByRole('dialog').getByRole('button', { name: '确定' }).click() + + await expect(page.getByText('新增成功')).toBeVisible() + const publishedRow = page.locator('.el-table__row').filter({ hasText: noticeTitle }).first() + await expect(publishedRow).toBeVisible() + await expect(publishedRow).toContainText('已发布') +}) diff --git a/web/tests/e2e/reservation.spec.ts b/web/tests/e2e/reservation.spec.ts new file mode 100644 index 0000000..c59cc06 --- /dev/null +++ b/web/tests/e2e/reservation.spec.ts @@ -0,0 +1,152 @@ +import { expect, test } from '@playwright/test' + +const loginAsStudent = async (page: Parameters[0]['page']) => { + await page.goto('/login') + await page.getByPlaceholder('请输入用户名').fill('student') + await page.getByPlaceholder('请输入密码').fill('123456') + await page.getByRole('button', { name: /登\s*录/ }).click() + await expect(page).toHaveURL(/\/dashboard$/) +} + +const loginAsTeacher = async (page: Parameters[0]['page']) => { + await page.goto('/login') + await page.getByPlaceholder('请输入用户名').fill('teacher') + await page.getByPlaceholder('请输入密码').fill('123456') + await page.getByRole('button', { name: /登\s*录/ }).click() + await expect(page).toHaveURL(/\/dashboard$/) +} + +const selectTimeOption = async ( + page: Parameters[0]['page'], + testId: string, + optionText: string +) => { + const trigger = page.getByTestId(testId).locator('input[role="combobox"]') + await trigger.click({ force: true }) + const controls = await trigger.getAttribute('aria-controls') + if (!controls) { + throw new Error(`Missing aria-controls for ${testId}`) + } + await page.locator(`#${controls}`).getByRole('option', { name: optionText }).click() +} + +const buildReservationData = (dayOffset: number, purposePrefix: string) => { + const reservationDate = new Date() + reservationDate.setDate(reservationDate.getDate() + dayOffset) + return { + day: String(reservationDate.getDate()), + purpose: `${purposePrefix}-${Date.now()}`, + reservationDate + } +} + +const submitReservation = async ( + page: Parameters[0]['page'], + data: { day: string; purpose: string }, + timeRange: { start: string; end: string } +) => { + await page.goto('/reservation/apply') + await page.getByTestId('reservation-lab').click() + await page.getByRole('option', { name: /基础电路实验室/ }).click() + + await page.getByTestId('reservation-equipment').click() + await page.getByRole('option', { name: /数字示波器/ }).click() + + await page.getByTestId('reservation-date').click() + await page.locator('.el-picker-panel:visible td.available').getByText(data.day, { exact: true }).click() + + await selectTimeOption(page, 'reservation-start-time', timeRange.start) + await selectTimeOption(page, 'reservation-end-time', timeRange.end) + await page.getByTestId('reservation-purpose').locator('textarea').fill(data.purpose) + await page.getByTestId('reservation-submit').getByRole('button', { name: '提交预约' }).click() +} + +test('student can submit a reservation request', async ({ page }) => { + await loginAsStudent(page) + const reservation = buildReservationData(2 + Math.floor(Math.random() * 5), '自动化 E2E 预约提交流程校验') + await submitReservation(page, reservation, { start: '13:00', end: '15:00' }) + + await expect(page).toHaveURL(/\/reservation\/my$/) + await expect(page.getByText('预约申请已提交,请等待审批')).toBeVisible() +}) + +test('teacher can approve a student reservation', async ({ browser }) => { + const reservation = buildReservationData(8 + Math.floor(Math.random() * 4), '自动化 E2E 审批链路校验') + + const studentPage = await browser.newPage() + await loginAsStudent(studentPage) + await submitReservation(studentPage, reservation, { start: '15:00', end: '17:00' }) + await expect(studentPage).toHaveURL(/\/reservation\/my$/) + await studentPage.close() + + const teacherPage = await browser.newPage() + await loginAsTeacher(teacherPage) + await teacherPage.goto('/reservation/pending') + const pendingRow = teacherPage.locator('.el-table__row').filter({ hasText: reservation.purpose }).first() + await expect(pendingRow).toBeVisible() + await pendingRow.getByRole('button', { name: '通过' }).click() + await teacherPage.getByRole('button', { name: '确定' }).click() + await expect(teacherPage.getByText('审批通过')).toBeVisible() + await teacherPage.close() + + const verifyPage = await browser.newPage() + await loginAsStudent(verifyPage) + await verifyPage.goto('/reservation/my') + const approvedRow = verifyPage.locator('.el-table__row').filter({ hasText: reservation.reservationDate.toISOString().slice(0, 10) }).first() + await expect(approvedRow).toBeVisible() + await approvedRow.getByRole('button', { name: '详情' }).click() + await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText(reservation.purpose) + await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText('已通过') + await verifyPage.close() +}) + +test('teacher can reject a student reservation', async ({ browser }) => { + const reservation = buildReservationData(12 + Math.floor(Math.random() * 4), '自动化 E2E 审批驳回校验') + const rejectReason = `设备维护冲突-${Date.now()}` + + const studentPage = await browser.newPage() + await loginAsStudent(studentPage) + await submitReservation(studentPage, reservation, { start: '09:00', end: '11:00' }) + await expect(studentPage).toHaveURL(/\/reservation\/my$/) + await studentPage.close() + + const teacherPage = await browser.newPage() + await loginAsTeacher(teacherPage) + await teacherPage.goto('/reservation/pending') + const pendingRow = teacherPage.locator('.el-table__row').filter({ hasText: reservation.purpose }).first() + await expect(pendingRow).toBeVisible() + await pendingRow.getByRole('button', { name: '驳回' }).click() + await teacherPage.getByPlaceholder('请输入驳回原因').fill(rejectReason) + await teacherPage.getByRole('button', { name: '确定驳回' }).click() + await expect(teacherPage.getByText('已驳回')).toBeVisible() + await teacherPage.close() + + const verifyPage = await browser.newPage() + await loginAsStudent(verifyPage) + await verifyPage.goto('/reservation/my') + const rejectedRow = verifyPage + .locator('.el-table__row') + .filter({ hasText: reservation.reservationDate.toISOString().slice(0, 10) }) + .first() + await expect(rejectedRow).toBeVisible() + await rejectedRow.getByRole('button', { name: '详情' }).click() + await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText(reservation.purpose) + await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText('已驳回') + await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText(rejectReason) + await verifyPage.close() +}) + +test('student cannot access approval center and system management pages', async ({ page }) => { + await loginAsStudent(page) + + await expect(page.getByRole('menuitem', { name: '审批中心' })).toHaveCount(0) + await expect(page.getByRole('menuitem', { name: '用户管理' })).toHaveCount(0) + + await page.goto('/reservation/pending') + await expect(page).toHaveURL(/\/403\?from=%2Freservation%2Fpending$/) + await expect(page.getByRole('heading', { name: '无权限访问' })).toBeVisible() + + await page.goto('/user') + await expect(page).toHaveURL(/\/403\?from=%2Fuser$/) + await expect(page.getByRole('heading', { name: '无权限访问' })).toBeVisible() +}) diff --git a/web/vite.config.ts b/web/vite.config.ts index 46c3568..9ce6c92 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -1,20 +1,49 @@ -import { defineConfig } from 'vite' +import { defineConfig, loadEnv } from 'vite' import vue from '@vitejs/plugin-vue' import { resolve } from 'path' -export default defineConfig({ - plugins: [vue()], - resolve: { - alias: { - '@': resolve(__dirname, 'src') - } - }, - server: { - port: 5173, - proxy: { - '/api': { - target: 'http://localhost:8080', - changeOrigin: true +export default defineConfig(({ mode }) => { + const env = loadEnv(mode, process.cwd(), '') + + return { + plugins: [vue()], + resolve: { + alias: { + '@': resolve(__dirname, 'src') + } + }, + build: { + rollupOptions: { + output: { + manualChunks(id) { + if (!id.includes('node_modules')) { + return + } + if (id.includes('echarts')) { + return 'vendor-echarts' + } + if (id.includes('element-plus') || id.includes('@element-plus')) { + return 'vendor-element-plus' + } + if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) { + return 'vendor-vue' + } + if (id.includes('axios') || id.includes('dayjs')) { + return 'vendor-utils' + } + } + } + } + }, + server: { + host: env.WEB_HOST || '127.0.0.1', + port: Number(env.WEB_PORT || 5173), + strictPort: true, + proxy: { + '/api': { + target: env.VITE_API_PROXY_TARGET || 'http://127.0.0.1:8080', + changeOrigin: true + } } } } From f4ad5415541561dd72e8e05b261c5025fdabd554 Mon Sep 17 00:00:00 2001 From: KYJCASTER <2016559265w@gmail.com> Date: Mon, 16 Mar 2026 20:23:56 +0800 Subject: [PATCH 2/5] test: fix e2e api base url for preview mode --- .github/workflows/ci.yml | 14 +++++++++++++- web/playwright.config.ts | 9 +++++---- web/tests/e2e/auth.spec.ts | 2 +- 3 files changed, 19 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 95853a2..9a7303d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,9 +56,21 @@ jobs: cache: maven - run: npm ci working-directory: web - - run: npx playwright install chromium + - run: npm run test:e2e:install working-directory: web - run: chmod +x mvnw working-directory: server - run: npx playwright test working-directory: web + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-report + path: web/playwright-report + if-no-files-found: ignore + - uses: actions/upload-artifact@v4 + if: always() + with: + name: playwright-test-results + path: web/test-results + if-no-files-found: ignore diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 041d8e3..f8dc4b6 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -4,12 +4,12 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173' export default defineConfig({ testDir: './tests/e2e', - timeout: 30_000, - workers: process.env.CI ? 2 : 1, + timeout: process.env.CI ? 45_000 : 30_000, + workers: 1, expect: { - timeout: 5_000 + timeout: process.env.CI ? 8_000 : 5_000 }, - fullyParallel: true, + fullyParallel: false, forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: process.env.CI ? [['github'], ['html', { open: 'never' }]] : 'list', @@ -41,6 +41,7 @@ export default defineConfig({ cwd: '.', env: { ...process.env, + VITE_API_BASE_URL: 'http://127.0.0.1:8080/api', NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost' }, diff --git a/web/tests/e2e/auth.spec.ts b/web/tests/e2e/auth.spec.ts index 0527789..0a52ce4 100644 --- a/web/tests/e2e/auth.spec.ts +++ b/web/tests/e2e/auth.spec.ts @@ -15,8 +15,8 @@ test('unauthenticated user is redirected to login', async ({ page }) => { test('user can log in with seed admin account', async ({ page }) => { await login(page, 'admin', '123456') await expect(page).toHaveURL(/\/dashboard$/, { timeout: 15_000 }) - await expect(page.getByRole('heading', { name: /欢迎回来,系统管理员/ })).toBeVisible() await expect(page.locator('.user-name')).toHaveText('系统管理员') + await expect(page.getByRole('heading', { name: /欢迎回来/ })).toBeVisible() }) test('login shows credential error for wrong password', async ({ page }) => { From 8e8e5f7a0eb37cab675f13616c7cfe9ec5c68ff7 Mon Sep 17 00:00:00 2001 From: KYJCASTER <2016559265w@gmail.com> Date: Mon, 16 Mar 2026 20:41:37 +0800 Subject: [PATCH 3/5] test: allow playwright preview origin in e2e backend cors --- web/playwright.config.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/web/playwright.config.ts b/web/playwright.config.ts index f8dc4b6..3287687 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -29,6 +29,8 @@ export default defineConfig({ cwd: '../server', env: { ...process.env, + CORS_ALLOWED_ORIGIN_PATTERNS: + 'http://127.0.0.1:4173,http://localhost:4173,http://127.0.0.1:5173,http://localhost:5173', NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost' }, From 0c603c4b1af15fc716007effafba9c9024ec9053 Mon Sep 17 00:00:00 2001 From: KYJCASTER <2016559265w@gmail.com> Date: Mon, 16 Mar 2026 20:52:52 +0800 Subject: [PATCH 4/5] fix: enable security cors for e2e preview login flow --- .../main/java/com/smartlab/server/security/SecurityConfig.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/server/src/main/java/com/smartlab/server/security/SecurityConfig.java b/server/src/main/java/com/smartlab/server/security/SecurityConfig.java index 54171cc..49888f4 100644 --- a/server/src/main/java/com/smartlab/server/security/SecurityConfig.java +++ b/server/src/main/java/com/smartlab/server/security/SecurityConfig.java @@ -6,6 +6,7 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; +import org.springframework.security.config.Customizer; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; @@ -32,6 +33,7 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http + .cors(Customizer.withDefaults()) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(exception -> exception From 1e6cd60481aca0d20a9da8b18c9b9779a972fc68 Mon Sep 17 00:00:00 2001 From: KYJCASTER <2016559265w@gmail.com> Date: Mon, 16 Mar 2026 21:00:07 +0800 Subject: [PATCH 5/5] test: stabilize notice and permission e2e assertions --- web/tests/e2e/notice.spec.ts | 2 +- web/tests/e2e/reservation.spec.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/web/tests/e2e/notice.spec.ts b/web/tests/e2e/notice.spec.ts index 2141f18..196e280 100644 --- a/web/tests/e2e/notice.spec.ts +++ b/web/tests/e2e/notice.spec.ts @@ -19,7 +19,7 @@ test('admin can publish a notice', async ({ page }) => { await page.getByRole('button', { name: '新增公告' }).click() await page.getByPlaceholder('请输入公告标题').fill(noticeTitle) await page.getByPlaceholder('请输入公告内容').fill(noticeContent) - await page.getByRole('radio', { name: '发布' }).click() + await page.locator('.el-radio').filter({ hasText: '发布' }).click() await page.getByRole('dialog').getByRole('button', { name: '确定' }).click() await expect(page.getByText('新增成功')).toBeVisible() diff --git a/web/tests/e2e/reservation.spec.ts b/web/tests/e2e/reservation.spec.ts index c59cc06..d921048 100644 --- a/web/tests/e2e/reservation.spec.ts +++ b/web/tests/e2e/reservation.spec.ts @@ -143,10 +143,10 @@ test('student cannot access approval center and system management pages', async await expect(page.getByRole('menuitem', { name: '用户管理' })).toHaveCount(0) await page.goto('/reservation/pending') - await expect(page).toHaveURL(/\/403\?from=%2Freservation%2Fpending$/) + await expect(page).toHaveURL(/\/403\?from=\/reservation\/pending$/) await expect(page.getByRole('heading', { name: '无权限访问' })).toBeVisible() await page.goto('/user') - await expect(page).toHaveURL(/\/403\?from=%2Fuser$/) + await expect(page).toHaveURL(/\/403\?from=\/user$/) await expect(page.getByRole('heading', { name: '无权限访问' })).toBeVisible() })