diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..ded820a --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(git:*)" + ] + } +} diff --git a/.idea/dataSources.xml b/.idea/dataSources.xml new file mode 100644 index 0000000..2191398 --- /dev/null +++ b/.idea/dataSources.xml @@ -0,0 +1,37 @@ + + + + + mysql.8 + true + true + $PROJECT_DIR$/server/src/main/resources/application-prod.yml + com.mysql.cj.jdbc.Driver + jdbc:mysql://mysql:3306/smartlab?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai + $ProjectFileDir$ + + + h2.unified + true + true + $PROJECT_DIR$/server/src/main/resources/application-test.yml + org.h2.Driver + jdbc:h2:mem:smartlab;MODE=MySQL;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE + $ProjectFileDir$ + + + mysql.8 + true + true + $PROJECT_DIR$/server/src/main/resources/application-dev.yml + com.mysql.cj.jdbc.Driver + jdbc:mysql://localhost:3306/smartlab?useUnicode=true&characterEncoding=UTF-8&serverTimezone=Asia/Shanghai + + + + + + $ProjectFileDir$ + + + \ No newline at end of file diff --git a/README.md b/README.md index 6841a54..c2a88e8 100644 --- a/README.md +++ b/README.md @@ -52,8 +52,8 @@ docker compose up -d --build ### 3) 初始化数据库(首次部署建议执行) ```bash -docker compose exec -T mysql mysql -uroot -proot smartlab < sql/schema.sql -docker compose exec -T mysql mysql -uroot -proot smartlab < sql/seed.sql +docker compose exec -T mysql mysql --default-character-set=utf8mb4 -uroot -proot smartlab < sql/schema.sql +docker compose exec -T mysql mysql --default-character-set=utf8mb4 -uroot -proot smartlab < sql/seed.sql ``` ### 4) 常用运维命令 diff --git a/docker-compose.yml b/docker-compose.yml index 0d16375..669462b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -35,6 +35,7 @@ services: DB_PASSWORD: root JWT_SECRET: smartlab-prod-jwt-secret-change-me-32bytes JWT_EXPIRE_HOURS: 24 + CORS_ALLOWED_ORIGIN_PATTERNS: http://127.0.0.1:5173,http://localhost:5173 ports: - "8080:8080" @@ -51,4 +52,3 @@ services: volumes: mysql_data: - diff --git a/docs/debug.md b/docs/debug.md new file mode 100644 index 0000000..475139e --- /dev/null +++ b/docs/debug.md @@ -0,0 +1,210 @@ +# SmartLab Debug Notes + +日期:2026-03-17 + +## 当前结论 + +目前后端登录接口和前端代理链路都已经验证正常。 + +已确认: + +- `mysql`、`server`、`web` 三个容器都在运行 +- `schema.sql` 已导入成功,业务表已存在 +- `seed.sql` 已至少成功导入到能查出 `sys_user` 和 `sys_role` +- `sys_user` 中已有: + - `admin`, `status = 1`, `role_id = 1` + - `teacher`, `status = 1`, `role_id = 2` + - `student`, `status = 1`, `role_id = 3` +- 直接请求后端登录接口成功: + - `POST http://localhost:8080/api/auth/login` +- 通过前端代理请求登录接口也成功: + - `POST http://localhost:5173/api/auth/login` + +这说明以下链路没有问题: + +- MySQL 基本可用 +- 后端 `/api/auth/login` 可用 +- 前端 Nginx 代理 `/api/*` 到后端可用 + +## 已确认的命令结果 + +### 1. 容器状态 + +`docker compose ps` 显示: + +- `smartlab-mysql` 正常运行 +- `smartlab-server` 正常运行 +- `smartlab-web` 正常运行 + +### 2. 表结构已存在 + +执行: + +```powershell +docker compose exec -T mysql mysql -uroot -proot -e "USE smartlab; SHOW TABLES;" +``` + +已看到这些表: + +- `equipment` +- `equipment_category` +- `lab_room` +- `maintenance_order` +- `notice` +- `reservation` +- `reservation_item` +- `sys_permission` +- `sys_role` +- `sys_role_permission` +- `sys_user` + +### 3. 用户数据已存在 + +执行: + +```powershell +docker compose exec -T mysql mysql -uroot -proot -e "USE smartlab; SELECT id, username, status, role_id FROM sys_user;" +``` + +结果为: + +```text +1 admin 1 1 +2 teacher 1 2 +3 student 1 3 +``` + +### 4. 后端登录接口成功 + +执行: + +```powershell +Invoke-RestMethod -Method Post -Uri "http://localhost:8080/api/auth/login" -ContentType "application/json" -Body '{"username":"admin","password":"123456"}' +``` + +返回: + +- `code = 0` +- `message = success` +- `data.token` 存在 + +### 5. 前端代理登录接口成功 + +执行: + +```powershell +Invoke-RestMethod -Method Post -Uri "http://localhost:5173/api/auth/login" -ContentType "application/json" -Body '{"username":"admin","password":"123456"}' +``` + +返回: + +- `code = 0` +- `message = success` +- `data.token` 存在 + +## 之前出现过的问题 + +### 1. PowerShell 命令经常因手误失败 + +出现过这些拼写问题: + +- `-uroot` 被写成 `-urot` +- `-proot` 被写成 `-proof` +- `-uroot` 被拆成单独一行执行 +- `SHOW TABLES;` 被写成 `SHOW TABLES:` +- `Invoke-RestMethod -Uri` 后漏空格 + +这些都不是项目问题。 + +### 2. PowerShell 管道导入 `seed.sql` 存在编码问题 + +执行过: + +```powershell +Get-Content .\sql\seed.sql | docker compose exec -T mysql mysql -uroot -proot smartlab +``` + +出现过中文内容变成 `????` 的情况,并导致 SQL 语法错误。 + +这说明在 PowerShell 下,用 `Get-Content | docker compose exec ...` 导入含中文的 `seed.sql` 不可靠。 + +### 3. 浏览器里曾出现旧 token 导致的 `/api/auth/me` 401 + +Web 容器日志中看到: + +- 页面加载时曾直接请求 `GET /api/auth/me` +- 该请求返回 `401` + +这很可能是浏览器本地存储里残留了旧 token。 + +## 下次开机后优先做的事 + +### 1. 先确认容器仍然正常 + +```powershell +docker compose ps +``` + +### 2. 直接验证登录接口和 `/auth/me` + +先拿 token: + +```powershell +$login = Invoke-RestMethod -Method Post -Uri "http://localhost:5173/api/auth/login" -ContentType "application/json" -Body '{"username":"admin","password":"123456"}' +$token = $login.data.token +``` + +再验证当前用户接口: + +```powershell +Invoke-RestMethod -Method Get -Uri "http://localhost:5173/api/auth/me" -Headers @{ Authorization = "Bearer $token" } +``` + +需要确认: + +- 这条是否成功返回当前用户信息 +- 如果失败,失败码是不是 `401` + +### 3. 清浏览器本地 token + +在浏览器打开 `http://localhost:5173/login`,按 `F12`,在 `Console` 执行: + +```js +localStorage.clear() +location.reload() +``` + +然后重新用: + +- 用户名:`admin` +- 密码:`123456` + +### 4. 若页面仍失败,重点抓两条请求 + +浏览器开发者工具 `Network` 中只看: + +- `POST /api/auth/login` +- `GET /api/auth/me` + +需要记录: + +- 状态码 +- 响应体 + +## 建议的后续修复方向 + +建议后续做两件事: + +1. 调整数据库初始化方式,避免手工导入 SQL +2. 优化前端登录错误提示 + +原因: + +- 当前初始化过度依赖手工命令,PowerShell 下很容易踩编码和重定向问题 +- 前端现在会把登录成功后 `/auth/me` 的失败笼统显示成“登录服务暂时不可用”,定位不够直接 + +## 文档位置 + +本次记录文件: + +- [docs/debug.md](/mnt/d/smartlab/docs/debug.md) diff --git a/server/src/main/java/com/smartlab/server/common/config/MybatisConfig.java b/server/src/main/java/com/smartlab/server/common/config/MybatisConfig.java index d4b62bd..f7ad517 100644 --- a/server/src/main/java/com/smartlab/server/common/config/MybatisConfig.java +++ b/server/src/main/java/com/smartlab/server/common/config/MybatisConfig.java @@ -1,11 +1,14 @@ package com.smartlab.server.common.config; +import com.baomidou.mybatisplus.core.config.GlobalConfig; +import com.baomidou.mybatisplus.core.incrementer.DefaultIdentifierGenerator; import com.baomidou.mybatisplus.core.MybatisConfiguration; import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor; import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor; import com.baomidou.mybatisplus.extension.spring.MybatisSqlSessionFactoryBean; import org.apache.ibatis.session.SqlSessionFactory; import org.mybatis.spring.SqlSessionTemplate; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -14,17 +17,34 @@ @Configuration public class MybatisConfig { + @Value("${smartlab.id.worker-id:1}") + private long workerId; + + @Value("${smartlab.id.datacenter-id:1}") + private long datacenterId; + @Bean public SqlSessionFactory sqlSessionFactory(DataSource dataSource) throws Exception { MybatisSqlSessionFactoryBean factoryBean = new MybatisSqlSessionFactoryBean(); factoryBean.setDataSource(dataSource); factoryBean.setPlugins(mybatisPlusInterceptor()); + factoryBean.setGlobalConfig(globalConfig()); MybatisConfiguration configuration = new MybatisConfiguration(); configuration.setMapUnderscoreToCamelCase(true); factoryBean.setConfiguration(configuration); return factoryBean.getObject(); } + @Bean + public GlobalConfig globalConfig() { + GlobalConfig.Sequence sequence = new GlobalConfig.Sequence() + .setWorkerId(workerId) + .setDatacenterId(datacenterId); + return new GlobalConfig() + .setSequence(sequence) + .setIdentifierGenerator(new DefaultIdentifierGenerator(workerId, datacenterId)); + } + @Bean public SqlSessionTemplate sqlSessionTemplate(SqlSessionFactory sqlSessionFactory) { return new SqlSessionTemplate(sqlSessionFactory); diff --git a/server/src/main/java/com/smartlab/server/modules/auth/service/impl/AuthServiceImpl.java b/server/src/main/java/com/smartlab/server/modules/auth/service/impl/AuthServiceImpl.java index e0541c1..08acbe9 100644 --- a/server/src/main/java/com/smartlab/server/modules/auth/service/impl/AuthServiceImpl.java +++ b/server/src/main/java/com/smartlab/server/modules/auth/service/impl/AuthServiceImpl.java @@ -3,6 +3,7 @@ import com.smartlab.server.common.exception.BusinessException; import com.smartlab.server.modules.auth.dto.LoginRequest; import com.smartlab.server.modules.auth.model.AuthUserInfo; +import com.smartlab.server.modules.auth.support.AuthStatusSupport; import com.smartlab.server.modules.auth.service.AuthService; import com.smartlab.server.modules.auth.vo.CurrentUserVO; import com.smartlab.server.modules.auth.vo.LoginResponse; @@ -33,7 +34,7 @@ public LoginResponse login(LoginRequest request) { if (userInfo == null || !passwordEncoder.matches(request.getPassword(), userInfo.getPassword())) { throw new BusinessException(401, "用户名或密码错误"); } - if (userInfo.getStatus() == null || userInfo.getStatus() != 1) { + if (!AuthStatusSupport.isUserEnabled(userInfo.getStatus())) { throw new BusinessException(403, "用户已被禁用"); } String token = jwtTokenProvider.generateToken(userInfo.getUserId(), userInfo.getUsername(), userInfo.getRoleCode()); diff --git a/server/src/main/java/com/smartlab/server/modules/auth/support/AuthStatusSupport.java b/server/src/main/java/com/smartlab/server/modules/auth/support/AuthStatusSupport.java new file mode 100644 index 0000000..cb03733 --- /dev/null +++ b/server/src/main/java/com/smartlab/server/modules/auth/support/AuthStatusSupport.java @@ -0,0 +1,11 @@ +package com.smartlab.server.modules.auth.support; + +public final class AuthStatusSupport { + + private AuthStatusSupport() { + } + + public static boolean isUserEnabled(Integer status) { + return status != null && status == 1; + } +} diff --git a/server/src/main/java/com/smartlab/server/modules/dashboard/service/impl/DashboardServiceImpl.java b/server/src/main/java/com/smartlab/server/modules/dashboard/service/impl/DashboardServiceImpl.java index 28b1e49..2445bf0 100644 --- a/server/src/main/java/com/smartlab/server/modules/dashboard/service/impl/DashboardServiceImpl.java +++ b/server/src/main/java/com/smartlab/server/modules/dashboard/service/impl/DashboardServiceImpl.java @@ -49,6 +49,9 @@ public DashboardSummaryVO summary() { long todayReservationCount = reservationMapper.selectCount( new LambdaQueryWrapper().eq(Reservation::getUseDate, today) ); + long pendingApprovalCount = reservationMapper.selectCount( + new LambdaQueryWrapper().eq(Reservation::getStatus, "PENDING") + ); long processingMaintenanceCount = maintenanceOrderMapper.selectCount( new LambdaQueryWrapper().ne(MaintenanceOrder::getStatus, "FINISHED") ); @@ -60,6 +63,7 @@ public DashboardSummaryVO summary() { .collect(Collectors.groupingBy(Equipment::getStatus, Collectors.counting())); DashboardSummaryVO vo = new DashboardSummaryVO(); vo.setTodayReservationCount(todayReservationCount); + vo.setPendingApprovalCount(pendingApprovalCount); vo.setProcessingMaintenanceCount(processingMaintenanceCount); vo.setPublishedNoticeCount(publishedNoticeCount); vo.setEquipmentStatusDistribution(statusDistribution); diff --git a/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardRankVO.java b/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardRankVO.java index 0913558..b07a29f 100644 --- a/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardRankVO.java +++ b/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardRankVO.java @@ -8,5 +8,6 @@ public class DashboardRankVO { private Long equipmentId; private String assetNo; private String name; + private String labName; private Long reservationCount; } diff --git a/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardSummaryVO.java b/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardSummaryVO.java index 69b888d..1f58b93 100644 --- a/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardSummaryVO.java +++ b/server/src/main/java/com/smartlab/server/modules/dashboard/vo/DashboardSummaryVO.java @@ -8,6 +8,7 @@ public class DashboardSummaryVO { private long todayReservationCount; + private long pendingApprovalCount; private long processingMaintenanceCount; private long publishedNoticeCount; private Map equipmentStatusDistribution; diff --git a/server/src/main/java/com/smartlab/server/modules/reservation/mapper/ReservationItemMapper.java b/server/src/main/java/com/smartlab/server/modules/reservation/mapper/ReservationItemMapper.java index a406041..2fe25dc 100644 --- a/server/src/main/java/com/smartlab/server/modules/reservation/mapper/ReservationItemMapper.java +++ b/server/src/main/java/com/smartlab/server/modules/reservation/mapper/ReservationItemMapper.java @@ -16,12 +16,14 @@ public interface ReservationItemMapper extends BaseMapper { ri.equipment_id AS equipmentId, e.asset_no AS assetNo, e.name AS name, + lr.room_name AS labName, COUNT(1) AS reservationCount FROM reservation_item ri JOIN reservation r ON r.id = ri.reservation_id JOIN equipment e ON e.id = ri.equipment_id + JOIN lab_room lr ON lr.id = e.room_id WHERE r.status IN ('PENDING', 'APPROVED', 'FINISHED') - GROUP BY ri.equipment_id, e.asset_no, e.name + GROUP BY ri.equipment_id, e.asset_no, e.name, lr.room_name ORDER BY reservationCount DESC LIMIT #{limit} """) diff --git a/server/src/main/java/com/smartlab/server/modules/user/mapper/SysUserMapper.java b/server/src/main/java/com/smartlab/server/modules/user/mapper/SysUserMapper.java index 3e40845..e836f5d 100644 --- a/server/src/main/java/com/smartlab/server/modules/user/mapper/SysUserMapper.java +++ b/server/src/main/java/com/smartlab/server/modules/user/mapper/SysUserMapper.java @@ -15,7 +15,11 @@ public interface SysUserMapper extends BaseMapper { u.username, u.password, u.real_name, - u.status, + CASE + WHEN u.status IS NULL THEN 0 + WHEN UPPER(TRIM(CAST(u.status AS CHAR))) IN ('1', 'ENABLED', 'ACTIVE', 'TRUE') THEN 1 + ELSE 0 + END AS status, r.role_code, r.role_name FROM sys_user u @@ -31,7 +35,11 @@ public interface SysUserMapper extends BaseMapper { u.username, u.password, u.real_name, - u.status, + CASE + WHEN u.status IS NULL THEN 0 + WHEN UPPER(TRIM(CAST(u.status AS CHAR))) IN ('1', 'ENABLED', 'ACTIVE', 'TRUE') THEN 1 + ELSE 0 + END AS status, r.role_code, r.role_name FROM sys_user u @@ -41,4 +49,3 @@ public interface SysUserMapper extends BaseMapper { """) AuthUserInfo selectAuthInfoById(Long userId); } - diff --git a/server/src/main/java/com/smartlab/server/security/AuthUserDetailsService.java b/server/src/main/java/com/smartlab/server/security/AuthUserDetailsService.java index 5f45a33..8bb4875 100644 --- a/server/src/main/java/com/smartlab/server/security/AuthUserDetailsService.java +++ b/server/src/main/java/com/smartlab/server/security/AuthUserDetailsService.java @@ -2,6 +2,7 @@ import com.smartlab.server.common.exception.BusinessException; import com.smartlab.server.modules.auth.model.AuthUserInfo; +import com.smartlab.server.modules.auth.support.AuthStatusSupport; import com.smartlab.server.modules.user.mapper.SysUserMapper; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; @@ -31,7 +32,7 @@ public UserDetails loadUserByUsername(String username) { public AuthUserPrincipal loadById(Long userId) { AuthUserInfo userInfo = sysUserMapper.selectAuthInfoById(userId); - if (userInfo == null || userInfo.getStatus() == null || userInfo.getStatus() != 1) { + if (userInfo == null || !AuthStatusSupport.isUserEnabled(userInfo.getStatus())) { return null; } return toPrincipal(userInfo); 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 49888f4..7f36c4b 100644 --- a/server/src/main/java/com/smartlab/server/security/SecurityConfig.java +++ b/server/src/main/java/com/smartlab/server/security/SecurityConfig.java @@ -3,10 +3,12 @@ import com.smartlab.server.security.filter.JwtAuthenticationFilter; import com.smartlab.server.security.handler.JwtAccessDeniedHandler; import com.smartlab.server.security.handler.JwtAuthenticationEntryPoint; +import java.util.Arrays; +import java.util.List; +import org.springframework.beans.factory.annotation.Value; 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; @@ -14,6 +16,9 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; @Configuration public class SecurityConfig { @@ -22,6 +27,9 @@ public class SecurityConfig { private final JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint; private final JwtAccessDeniedHandler jwtAccessDeniedHandler; + @Value("${smartlab.cors.allowed-origin-patterns:http://127.0.0.1:5173,http://localhost:5173}") + private String allowedOriginPatterns; + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, JwtAuthenticationEntryPoint jwtAuthenticationEntryPoint, JwtAccessDeniedHandler jwtAccessDeniedHandler) { @@ -33,7 +41,7 @@ public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter, @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .cors(Customizer.withDefaults()) + .cors(cors -> cors.configurationSource(corsConfigurationSource())) .csrf(AbstractHttpConfigurer::disable) .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .exceptionHandling(exception -> exception @@ -43,6 +51,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers("/auth/login").permitAll() .requestMatchers("/health").permitAll() + .requestMatchers(HttpMethod.OPTIONS, "/**").permitAll() .requestMatchers("/system/**").hasRole("ADMIN") .requestMatchers(HttpMethod.GET, "/equipment", "/equipment/**", "/labs", "/labs/**").authenticated() .requestMatchers("/labs/**", "/equipment/categories/**").hasRole("ADMIN") @@ -65,4 +74,21 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + List origins = Arrays.stream(allowedOriginPatterns.split(",")) + .map(String::trim) + .filter(s -> !s.isEmpty()) + .collect(java.util.stream.Collectors.toList()); + configuration.setAllowedOriginPatterns(origins); + configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(Arrays.asList("*")); + configuration.setAllowCredentials(true); + configuration.setMaxAge(3600L); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/server/src/test/java/com/smartlab/server/modules/maintenance/MaintenanceNoticeDashboardIntegrationTest.java b/server/src/test/java/com/smartlab/server/modules/maintenance/MaintenanceNoticeDashboardIntegrationTest.java index f702c94..9b91a25 100644 --- a/server/src/test/java/com/smartlab/server/modules/maintenance/MaintenanceNoticeDashboardIntegrationTest.java +++ b/server/src/test/java/com/smartlab/server/modules/maintenance/MaintenanceNoticeDashboardIntegrationTest.java @@ -101,8 +101,10 @@ void noticeCrudAndDashboardShouldWork() throws Exception { HttpResponse summaryResponse = call("/dashboard/summary", "GET", null, studentToken); Map summaryBody = readMap(summaryResponse.body()); + Map summaryData = (Map) summaryBody.get("data"); assertThat(summaryResponse.statusCode()).isEqualTo(200); assertThat(summaryBody.get("code")).isEqualTo(0); + assertThat(((Number) summaryData.get("pendingApprovalCount")).longValue()).isGreaterThanOrEqualTo(1L); HttpResponse trendResponse = call("/dashboard/trend", "GET", null, studentToken); Map trendBody = readMap(trendResponse.body()); @@ -111,8 +113,11 @@ void noticeCrudAndDashboardShouldWork() throws Exception { HttpResponse rankResponse = call("/dashboard/rank", "GET", null, studentToken); Map rankBody = readMap(rankResponse.body()); + List> rankData = (List>) rankBody.get("data"); assertThat(rankResponse.statusCode()).isEqualTo(200); assertThat(rankBody.get("code")).isEqualTo(0); + assertThat(rankData).isNotEmpty(); + assertThat(rankData.get(0).get("labName")).isEqualTo("基础电路实验室"); } private String login(String username, String password) throws Exception { diff --git a/web/playwright.config.ts b/web/playwright.config.ts index 3287687..627acd7 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -1,6 +1,9 @@ import { defineConfig, devices } from '@playwright/test' -const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:4173' +const testWebHost = '127.0.0.1' +const testWebPort = Number(process.env.PLAYWRIGHT_WEB_PORT || 41731) +const testApiPort = Number(process.env.PLAYWRIGHT_API_PORT || 18080) +const baseURL = process.env.PLAYWRIGHT_BASE_URL || `http://${testWebHost}:${testWebPort}` export default defineConfig({ testDir: './tests/e2e', @@ -23,32 +26,39 @@ export default defineConfig({ ? undefined : [ { + name: 'server', 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', + url: `http://${testWebHost}:${testApiPort}/api/health`, cwd: '../server', env: { ...process.env, + SERVER_PORT: String(testApiPort), CORS_ALLOWED_ORIGIN_PATTERNS: - 'http://127.0.0.1:4173,http://localhost:4173,http://127.0.0.1:5173,http://localhost:5173', + `http://${testWebHost}:${testWebPort},http://localhost:${testWebPort},http://127.0.0.1:5173,http://localhost:5173`, NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost' }, timeout: 120_000, - reuseExistingServer: true + stdout: process.env.CI ? 'pipe' : 'ignore', + stderr: 'pipe', + reuseExistingServer: false }, { - command: 'npm run build && npm run preview -- --host 127.0.0.1 --port 4173', - url: 'http://127.0.0.1:4173', + name: 'web', + command: `npm run build && npm run preview -- --host ${testWebHost} --port ${testWebPort}`, + url: `http://${testWebHost}:${testWebPort}`, cwd: '.', env: { ...process.env, - VITE_API_BASE_URL: 'http://127.0.0.1:8080/api', + VITE_API_BASE_URL: `http://${testWebHost}:${testApiPort}/api`, NO_PROXY: '127.0.0.1,localhost', no_proxy: '127.0.0.1,localhost' }, timeout: 180_000, - reuseExistingServer: true + stdout: process.env.CI ? 'pipe' : 'ignore', + stderr: 'pipe', + reuseExistingServer: false } ], projects: [ diff --git a/web/src/App.vue b/web/src/App.vue index 3e5a568..9f90bbb 100644 --- a/web/src/App.vue +++ b/web/src/App.vue @@ -1,9 +1,13 @@ @@ -337,6 +576,10 @@ onUnmounted(() => { padding: 0; } +.dashboard-state { + margin-bottom: 20px; +} + .welcome-card { background: linear-gradient(135deg, #1e3a5f 0%, #2d5a87 100%); border-radius: 12px; @@ -373,6 +616,30 @@ onUnmounted(() => { cursor: pointer; } +.stat-card-skeleton { + cursor: default; +} + +.stat-skeleton__icon { + width: 48px; + height: 48px; + flex: 0 0 48px; +} + +.stat-skeleton__content { + flex: 1; +} + +.stat-skeleton__value { + width: 56%; + height: 24px; + margin-bottom: 10px; +} + +.stat-skeleton__label { + width: 40%; +} + .stat-card:hover { box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); transform: translateY(-2px); @@ -436,6 +703,62 @@ onUnmounted(() => { height: 280px; } +.chart-skeleton { + min-height: 280px; + display: flex; + flex-direction: column; + justify-content: center; + gap: 16px; +} + +.chart-skeleton__eyebrow { + width: 42%; +} + +.chart-skeleton__canvas { + width: 100%; + height: 200px; + border-radius: 12px; +} + +.chart-skeleton__axis { + display: grid; + grid-template-columns: repeat(7, 1fr); + gap: 10px; +} + +.chart-skeleton__tick { + width: 100%; +} + +.chart-skeleton__donut { + width: 160px; + height: 160px; + align-self: center; +} + +.chart-skeleton__legend { + display: flex; + flex-direction: column; + gap: 12px; + padding: 0 12px; +} + +.chart-skeleton__legend-row { + display: flex; + align-items: center; + gap: 10px; +} + +.chart-skeleton__legend-dot { + width: 12px; + height: 12px; +} + +.chart-skeleton__legend-text { + width: 38%; +} + .content-row { margin-bottom: 20px; } @@ -450,6 +773,57 @@ onUnmounted(() => { border-bottom: 1px solid var(--border-color); } +.list-skeleton { + padding: 8px 0; +} + +.list-skeleton__item { + display: flex; + align-items: center; + gap: 12px; + padding: 12px 0; + border-bottom: 1px solid #f0f0f0; +} + +.list-skeleton__item:last-child { + border-bottom: none; +} + +.list-skeleton__item--notice { + align-items: flex-start; +} + +.list-skeleton__index { + width: 24px; + height: 24px; + border-radius: 6px; + flex: 0 0 24px; +} + +.list-skeleton__notice-dot { + width: 10px; + height: 10px; + margin-top: 6px; + flex: 0 0 10px; +} + +.list-skeleton__content { + flex: 1; +} + +.list-skeleton__title { + width: 58%; + margin-bottom: 10px; +} + +.list-skeleton__meta { + width: 34%; +} + +.list-skeleton__count { + width: 54px; +} + .rank-list { padding: 8px 0; } @@ -521,30 +895,44 @@ onUnmounted(() => { } .notice-dot { - width: 6px; - height: 6px; + width: 8px; + height: 8px; border-radius: 50%; background: var(--primary-color); - margin-top: 6px; - margin-right: 10px; + margin-top: 8px; + margin-right: 12px; flex-shrink: 0; } .notice-content { flex: 1; + min-width: 0; } .notice-title { font-size: 14px; color: var(--text-primary); - margin-bottom: 4px; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; + margin-bottom: 6px; + line-height: 1.5; } .notice-time { font-size: 12px; color: var(--text-muted); } + +@media (max-width: 768px) { + .welcome-card { + padding: 20px; + } + + .chart-skeleton__donut { + width: 132px; + height: 132px; + } + + .chart-skeleton__legend-text { + width: 56%; + } +} diff --git a/web/test-results/.last-run.json b/web/test-results/.last-run.json index 5fca3f8..981eaa9 100644 --- a/web/test-results/.last-run.json +++ b/web/test-results/.last-run.json @@ -1,4 +1,8 @@ { "status": "failed", - "failedTests": [] + "failedTests": [ + "3612482aaacd88f2e168-9d7657abfefdf88743f2", + "3612482aaacd88f2e168-9c94bc69bb22cd642d40", + "3612482aaacd88f2e168-6daa8eb9c50df1cd5c5a" + ] } \ No newline at end of file diff --git a/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/error-context.md b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/error-context.md new file mode 100644 index 0000000..a287c34 --- /dev/null +++ b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/error-context.md @@ -0,0 +1,122 @@ +# Page snapshot + +```yaml +- generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - img [ref=e9] + - generic [ref=e13]: 智慧实验室 + - menubar [ref=e14]: + - menuitem "仪表盘" [ref=e15] [cursor=pointer]: + - img [ref=e17] + - generic [ref=e21]: 仪表盘 + - menuitem "预约申请" [ref=e22] [cursor=pointer]: + - img [ref=e24] + - generic [ref=e26]: 预约申请 + - menuitem "我的预约" [ref=e27] [cursor=pointer]: + - img [ref=e29] + - generic [ref=e31]: 我的预约 + - generic [ref=e32]: + - generic [ref=e33]: + - navigation "Breadcrumb" [ref=e35]: + - generic [ref=e36]: + - link "首页" [ref=e37] + - text: / + - link "预约申请" [ref=e39] + - button "学 学生用户 学生" [ref=e42] [cursor=pointer]: + - generic [ref=e43]: 学 + - generic [ref=e44]: 学生用户 + - generic [ref=e46]: 学生 + - main [ref=e47]: + - generic [ref=e48]: + - generic [ref=e49]: + - heading "预约申请" [level=2] [ref=e50] + - paragraph [ref=e51]: 填写预约信息,提交后等待审批 + - generic [ref=e52]: + - generic [ref=e54]: + - generic [ref=e56]: + - img [ref=e58] + - generic [ref=e60]: 填写预约信息 + - generic [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: "* 实验室" + - generic [ref=e68] [cursor=pointer]: + - generic: + - combobox "* 实验室" [active] [ref=e70] + - generic [ref=e71]: 基础电路实验室 + - img [ref=e74] + - generic [ref=e76]: + - generic [ref=e77]: "* 设备" + - generic [ref=e81] [cursor=pointer]: + - generic: + - combobox "* 设备" [ref=e83] + - generic [ref=e84]: "0" + - img [ref=e87] + - generic [ref=e89]: + - generic [ref=e90]: "* 预约日期" + - generic [ref=e94]: + - img [ref=e97] + - combobox "* 预约日期" [ref=e99] + - group "* 时间段" [ref=e100]: + - generic [ref=e101]: "* 时间段" + - generic [ref=e102]: + - generic [ref=e103]: + - generic [ref=e109]: + - img [ref=e112] + - generic: + - combobox [ref=e117] + - generic: 开始时间 + - img [ref=e120] [cursor=pointer] + - generic [ref=e127]: + - img [ref=e130] + - generic: + - combobox [ref=e135] + - generic: 结束时间 + - img [ref=e138] [cursor=pointer] + - generic [ref=e140]: + - img [ref=e142] + - generic [ref=e144]: "开放时间: 08:00 - 22:00,需提前1天预约" + - generic [ref=e145]: + - generic [ref=e146]: "* 用途说明" + - generic [ref=e149]: + - textbox "* 用途说明" [ref=e150]: + - /placeholder: 请详细说明预约用途,至少10个字符 + - generic [ref=e151]: 0 / 500 + - generic [ref=e153]: + - button "提交预约" [ref=e155] [cursor=pointer]: + - generic [ref=e156]: + - img [ref=e158] + - text: 提交预约 + - button "重置" [ref=e160] [cursor=pointer]: + - generic [ref=e161]: + - img [ref=e163] + - text: 重置 + - generic [ref=e165]: + - generic [ref=e166]: + - generic [ref=e168]: + - img [ref=e170] + - generic [ref=e172]: 预约须知 + - list [ref=e174]: + - listitem [ref=e175]: 预约需提前至少1天 + - listitem [ref=e176]: 每次预约时长不超过4小时 + - listitem [ref=e177]: 请准时使用设备,逾期作废 + - listitem [ref=e178]: 如需取消,请在开始前2小时操作 + - listitem [ref=e179]: 使用完毕请保持设备整洁 + - generic [ref=e180]: + - generic [ref=e182]: + - img [ref=e184] + - generic [ref=e186]: 审批状态说明 + - generic [ref=e188]: + - generic [ref=e189]: + - generic [ref=e191]: 待审批 + - generic [ref=e192]: 等待教师或管理员审批 + - generic [ref=e193]: + - generic [ref=e195]: 已通过 + - generic [ref=e196]: 审批通过,可按时使用 + - generic [ref=e197]: + - generic [ref=e199]: 已驳回 + - generic [ref=e200]: 审批未通过,请查看原因 + - generic [ref=e201]: + - generic [ref=e203]: 已取消 + - generic [ref=e204]: 用户主动取消预约 +``` \ No newline at end of file diff --git a/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/test-failed-1.png b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/test-failed-1.png new file mode 100644 index 0000000..b7660cb Binary files /dev/null and b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/test-failed-1.png differ diff --git a/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/video.webm b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/video.webm new file mode 100644 index 0000000..3f14799 Binary files /dev/null and b/web/test-results/reservation-student-can-submit-a-reservation-request-chromium/video.webm differ diff --git a/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/error-context.md b/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/error-context.md new file mode 100644 index 0000000..a287c34 --- /dev/null +++ b/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/error-context.md @@ -0,0 +1,122 @@ +# Page snapshot + +```yaml +- generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - img [ref=e9] + - generic [ref=e13]: 智慧实验室 + - menubar [ref=e14]: + - menuitem "仪表盘" [ref=e15] [cursor=pointer]: + - img [ref=e17] + - generic [ref=e21]: 仪表盘 + - menuitem "预约申请" [ref=e22] [cursor=pointer]: + - img [ref=e24] + - generic [ref=e26]: 预约申请 + - menuitem "我的预约" [ref=e27] [cursor=pointer]: + - img [ref=e29] + - generic [ref=e31]: 我的预约 + - generic [ref=e32]: + - generic [ref=e33]: + - navigation "Breadcrumb" [ref=e35]: + - generic [ref=e36]: + - link "首页" [ref=e37] + - text: / + - link "预约申请" [ref=e39] + - button "学 学生用户 学生" [ref=e42] [cursor=pointer]: + - generic [ref=e43]: 学 + - generic [ref=e44]: 学生用户 + - generic [ref=e46]: 学生 + - main [ref=e47]: + - generic [ref=e48]: + - generic [ref=e49]: + - heading "预约申请" [level=2] [ref=e50] + - paragraph [ref=e51]: 填写预约信息,提交后等待审批 + - generic [ref=e52]: + - generic [ref=e54]: + - generic [ref=e56]: + - img [ref=e58] + - generic [ref=e60]: 填写预约信息 + - generic [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: "* 实验室" + - generic [ref=e68] [cursor=pointer]: + - generic: + - combobox "* 实验室" [active] [ref=e70] + - generic [ref=e71]: 基础电路实验室 + - img [ref=e74] + - generic [ref=e76]: + - generic [ref=e77]: "* 设备" + - generic [ref=e81] [cursor=pointer]: + - generic: + - combobox "* 设备" [ref=e83] + - generic [ref=e84]: "0" + - img [ref=e87] + - generic [ref=e89]: + - generic [ref=e90]: "* 预约日期" + - generic [ref=e94]: + - img [ref=e97] + - combobox "* 预约日期" [ref=e99] + - group "* 时间段" [ref=e100]: + - generic [ref=e101]: "* 时间段" + - generic [ref=e102]: + - generic [ref=e103]: + - generic [ref=e109]: + - img [ref=e112] + - generic: + - combobox [ref=e117] + - generic: 开始时间 + - img [ref=e120] [cursor=pointer] + - generic [ref=e127]: + - img [ref=e130] + - generic: + - combobox [ref=e135] + - generic: 结束时间 + - img [ref=e138] [cursor=pointer] + - generic [ref=e140]: + - img [ref=e142] + - generic [ref=e144]: "开放时间: 08:00 - 22:00,需提前1天预约" + - generic [ref=e145]: + - generic [ref=e146]: "* 用途说明" + - generic [ref=e149]: + - textbox "* 用途说明" [ref=e150]: + - /placeholder: 请详细说明预约用途,至少10个字符 + - generic [ref=e151]: 0 / 500 + - generic [ref=e153]: + - button "提交预约" [ref=e155] [cursor=pointer]: + - generic [ref=e156]: + - img [ref=e158] + - text: 提交预约 + - button "重置" [ref=e160] [cursor=pointer]: + - generic [ref=e161]: + - img [ref=e163] + - text: 重置 + - generic [ref=e165]: + - generic [ref=e166]: + - generic [ref=e168]: + - img [ref=e170] + - generic [ref=e172]: 预约须知 + - list [ref=e174]: + - listitem [ref=e175]: 预约需提前至少1天 + - listitem [ref=e176]: 每次预约时长不超过4小时 + - listitem [ref=e177]: 请准时使用设备,逾期作废 + - listitem [ref=e178]: 如需取消,请在开始前2小时操作 + - listitem [ref=e179]: 使用完毕请保持设备整洁 + - generic [ref=e180]: + - generic [ref=e182]: + - img [ref=e184] + - generic [ref=e186]: 审批状态说明 + - generic [ref=e188]: + - generic [ref=e189]: + - generic [ref=e191]: 待审批 + - generic [ref=e192]: 等待教师或管理员审批 + - generic [ref=e193]: + - generic [ref=e195]: 已通过 + - generic [ref=e196]: 审批通过,可按时使用 + - generic [ref=e197]: + - generic [ref=e199]: 已驳回 + - generic [ref=e200]: 审批未通过,请查看原因 + - generic [ref=e201]: + - generic [ref=e203]: 已取消 + - generic [ref=e204]: 用户主动取消预约 +``` \ No newline at end of file diff --git a/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/test-failed-1.png b/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/test-failed-1.png new file mode 100644 index 0000000..b7660cb Binary files /dev/null and b/web/test-results/reservation-teacher-can-approve-a-student-reservation-chromium/test-failed-1.png differ diff --git a/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/error-context.md b/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/error-context.md new file mode 100644 index 0000000..a287c34 --- /dev/null +++ b/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/error-context.md @@ -0,0 +1,122 @@ +# Page snapshot + +```yaml +- generic [ref=e4]: + - complementary [ref=e5]: + - generic [ref=e6]: + - img [ref=e9] + - generic [ref=e13]: 智慧实验室 + - menubar [ref=e14]: + - menuitem "仪表盘" [ref=e15] [cursor=pointer]: + - img [ref=e17] + - generic [ref=e21]: 仪表盘 + - menuitem "预约申请" [ref=e22] [cursor=pointer]: + - img [ref=e24] + - generic [ref=e26]: 预约申请 + - menuitem "我的预约" [ref=e27] [cursor=pointer]: + - img [ref=e29] + - generic [ref=e31]: 我的预约 + - generic [ref=e32]: + - generic [ref=e33]: + - navigation "Breadcrumb" [ref=e35]: + - generic [ref=e36]: + - link "首页" [ref=e37] + - text: / + - link "预约申请" [ref=e39] + - button "学 学生用户 学生" [ref=e42] [cursor=pointer]: + - generic [ref=e43]: 学 + - generic [ref=e44]: 学生用户 + - generic [ref=e46]: 学生 + - main [ref=e47]: + - generic [ref=e48]: + - generic [ref=e49]: + - heading "预约申请" [level=2] [ref=e50] + - paragraph [ref=e51]: 填写预约信息,提交后等待审批 + - generic [ref=e52]: + - generic [ref=e54]: + - generic [ref=e56]: + - img [ref=e58] + - generic [ref=e60]: 填写预约信息 + - generic [ref=e62]: + - generic [ref=e63]: + - generic [ref=e64]: "* 实验室" + - generic [ref=e68] [cursor=pointer]: + - generic: + - combobox "* 实验室" [active] [ref=e70] + - generic [ref=e71]: 基础电路实验室 + - img [ref=e74] + - generic [ref=e76]: + - generic [ref=e77]: "* 设备" + - generic [ref=e81] [cursor=pointer]: + - generic: + - combobox "* 设备" [ref=e83] + - generic [ref=e84]: "0" + - img [ref=e87] + - generic [ref=e89]: + - generic [ref=e90]: "* 预约日期" + - generic [ref=e94]: + - img [ref=e97] + - combobox "* 预约日期" [ref=e99] + - group "* 时间段" [ref=e100]: + - generic [ref=e101]: "* 时间段" + - generic [ref=e102]: + - generic [ref=e103]: + - generic [ref=e109]: + - img [ref=e112] + - generic: + - combobox [ref=e117] + - generic: 开始时间 + - img [ref=e120] [cursor=pointer] + - generic [ref=e127]: + - img [ref=e130] + - generic: + - combobox [ref=e135] + - generic: 结束时间 + - img [ref=e138] [cursor=pointer] + - generic [ref=e140]: + - img [ref=e142] + - generic [ref=e144]: "开放时间: 08:00 - 22:00,需提前1天预约" + - generic [ref=e145]: + - generic [ref=e146]: "* 用途说明" + - generic [ref=e149]: + - textbox "* 用途说明" [ref=e150]: + - /placeholder: 请详细说明预约用途,至少10个字符 + - generic [ref=e151]: 0 / 500 + - generic [ref=e153]: + - button "提交预约" [ref=e155] [cursor=pointer]: + - generic [ref=e156]: + - img [ref=e158] + - text: 提交预约 + - button "重置" [ref=e160] [cursor=pointer]: + - generic [ref=e161]: + - img [ref=e163] + - text: 重置 + - generic [ref=e165]: + - generic [ref=e166]: + - generic [ref=e168]: + - img [ref=e170] + - generic [ref=e172]: 预约须知 + - list [ref=e174]: + - listitem [ref=e175]: 预约需提前至少1天 + - listitem [ref=e176]: 每次预约时长不超过4小时 + - listitem [ref=e177]: 请准时使用设备,逾期作废 + - listitem [ref=e178]: 如需取消,请在开始前2小时操作 + - listitem [ref=e179]: 使用完毕请保持设备整洁 + - generic [ref=e180]: + - generic [ref=e182]: + - img [ref=e184] + - generic [ref=e186]: 审批状态说明 + - generic [ref=e188]: + - generic [ref=e189]: + - generic [ref=e191]: 待审批 + - generic [ref=e192]: 等待教师或管理员审批 + - generic [ref=e193]: + - generic [ref=e195]: 已通过 + - generic [ref=e196]: 审批通过,可按时使用 + - generic [ref=e197]: + - generic [ref=e199]: 已驳回 + - generic [ref=e200]: 审批未通过,请查看原因 + - generic [ref=e201]: + - generic [ref=e203]: 已取消 + - generic [ref=e204]: 用户主动取消预约 +``` \ No newline at end of file diff --git a/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/test-failed-1.png b/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/test-failed-1.png new file mode 100644 index 0000000..b7660cb Binary files /dev/null and b/web/test-results/reservation-teacher-can-reject-a-student-reservation-chromium/test-failed-1.png differ diff --git a/web/tests/e2e/auth.spec.ts b/web/tests/e2e/auth.spec.ts index 0a52ce4..b08494f 100644 --- a/web/tests/e2e/auth.spec.ts +++ b/web/tests/e2e/auth.spec.ts @@ -1,11 +1,5 @@ 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() -} +import { login } from './helpers/session' test('unauthenticated user is redirected to login', async ({ page }) => { await page.goto('/dashboard') diff --git a/web/tests/e2e/failure-path.spec.ts b/web/tests/e2e/failure-path.spec.ts new file mode 100644 index 0000000..3b34b41 --- /dev/null +++ b/web/tests/e2e/failure-path.spec.ts @@ -0,0 +1,43 @@ +import { expect, test } from '@playwright/test' +import { loginAsAdmin } from './helpers/session' + +const apiErrorResponse = (message: string) => ({ + status: 500, + contentType: 'application/json', + body: JSON.stringify({ + code: 500, + message + }) +}) + +test('notice page shows retry state when list request fails', async ({ page }) => { + await loginAsAdmin(page) + + const noticeListRoute = /\/api\/notices(\?.*)?$/ + await page.route(noticeListRoute, (route) => route.fulfill(apiErrorResponse('公告服务暂时不可用'))) + + await page.goto('/notice') + await expect(page.getByText(/公告列表加载失败/)).toBeVisible() + await expect(page.getByRole('button', { name: '重新加载' })).toBeVisible() + + await page.unroute(noticeListRoute) + await page.getByRole('button', { name: '重新加载' }).click() + await expect(page.getByText(/公告列表加载失败/)).toHaveCount(0) + await expect(page.locator('.el-table')).toBeVisible() +}) + +test('user management page shows retry state when list request fails', async ({ page }) => { + await loginAsAdmin(page) + + const userListRoute = /\/api\/system\/users(\?.*)?$/ + await page.route(userListRoute, (route) => route.fulfill(apiErrorResponse('用户列表暂时不可用'))) + + await page.goto('/user') + await expect(page.getByText('用户列表加载失败,请稍后重试')).toBeVisible() + await expect(page.getByRole('button', { name: '重新加载' })).toBeVisible() + + await page.unroute(userListRoute) + await page.getByRole('button', { name: '重新加载' }).click() + await expect(page.getByText('用户列表加载失败,请稍后重试')).toHaveCount(0) + await expect(page.locator('.el-table')).toBeVisible() +}) diff --git a/web/tests/e2e/helpers/session.ts b/web/tests/e2e/helpers/session.ts new file mode 100644 index 0000000..cd163e6 --- /dev/null +++ b/web/tests/e2e/helpers/session.ts @@ -0,0 +1,23 @@ +import { expect, type Page } from '@playwright/test' + +export const login = async (page: 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() +} + +export const loginAsAdmin = async (page: Page) => { + await login(page, 'admin', '123456') + await expect(page).toHaveURL(/\/dashboard$/, { timeout: 15_000 }) +} + +export const loginAsTeacher = async (page: Page) => { + await login(page, 'teacher', '123456') + await expect(page).toHaveURL(/\/dashboard$/) +} + +export const loginAsStudent = async (page: Page) => { + await login(page, 'student', '123456') + await expect(page).toHaveURL(/\/dashboard$/) +} diff --git a/web/tests/e2e/notice.spec.ts b/web/tests/e2e/notice.spec.ts index 196e280..8406f05 100644 --- a/web/tests/e2e/notice.spec.ts +++ b/web/tests/e2e/notice.spec.ts @@ -1,12 +1,5 @@ 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$/) -} +import { loginAsAdmin } from './helpers/session' test('admin can publish a notice', async ({ page }) => { const noticeTitle = `自动化公告发布校验-${Date.now()}` diff --git a/web/tests/e2e/permission-boundary.spec.ts b/web/tests/e2e/permission-boundary.spec.ts new file mode 100644 index 0000000..75d4cbc --- /dev/null +++ b/web/tests/e2e/permission-boundary.spec.ts @@ -0,0 +1,21 @@ +import { expect, test } from '@playwright/test' +import { loginAsTeacher } from './helpers/session' + +const adminOnlyMenus = ['用户管理', '角色管理', '实验室管理', '公告管理'] + +const adminOnlyRoutes = ['/user', '/role', '/lab', '/notice'] + +test('teacher cannot access admin-only menus and routes', async ({ page }) => { + await loginAsTeacher(page) + + for (const menuName of adminOnlyMenus) { + await expect(page.getByRole('menuitem', { name: menuName })).toHaveCount(0) + } + + for (const routePath of adminOnlyRoutes) { + await page.goto(routePath) + await expect(page).toHaveURL(new RegExp(`/403\\?from=${routePath.replace('/', '\\/')}$`)) + await expect(page.getByRole('heading', { name: '无权限访问' })).toBeVisible() + await expect(page.getByText(`来源页面:${routePath}`)).toBeVisible() + } +}) diff --git a/web/tests/e2e/reservation.spec.ts b/web/tests/e2e/reservation.spec.ts index d921048..c728638 100644 --- a/web/tests/e2e/reservation.spec.ts +++ b/web/tests/e2e/reservation.spec.ts @@ -1,40 +1,42 @@ import { expect, test } from '@playwright/test' +import { loginAsStudent, loginAsTeacher } from './helpers/session' -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 ( +const selectOptionInField = async ( page: Parameters[0]['page'], testId: string, - optionText: string + optionName: string | RegExp ) => { - const trigger = page.getByTestId(testId).locator('input[role="combobox"]') - await trigger.click({ force: true }) - const controls = await trigger.getAttribute('aria-controls') + const field = page.getByTestId(testId) + const combobox = field.getByRole('combobox').first() + await field.locator('.el-select__wrapper').first().click() + const controls = await combobox.getAttribute('aria-controls') if (!controls) { throw new Error(`Missing aria-controls for ${testId}`) } - await page.locator(`#${controls}`).getByRole('option', { name: optionText }).click() + const option = page + .locator(`#${controls}`) + .getByRole('option', { + name: optionName, + exact: typeof optionName === 'string' + }) + .first() + await expect(option).toBeVisible({ timeout: 10_000 }) + await option.scrollIntoViewIfNeeded() + await option.click() +} + +const formatDate = (date: Date) => { + const year = date.getFullYear() + const month = String(date.getMonth() + 1).padStart(2, '0') + const day = String(date.getDate()).padStart(2, '0') + return `${year}-${month}-${day}` } const buildReservationData = (dayOffset: number, purposePrefix: string) => { const reservationDate = new Date() reservationDate.setDate(reservationDate.getDate() + dayOffset) return { - day: String(reservationDate.getDate()), + reservationDateText: formatDate(reservationDate), purpose: `${purposePrefix}-${Date.now()}`, reservationDate } @@ -42,21 +44,23 @@ const buildReservationData = (dayOffset: number, purposePrefix: string) => { const submitReservation = async ( page: Parameters[0]['page'], - data: { day: string; purpose: string }, + data: { reservationDateText: 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 expect(page).toHaveURL(/\/reservation\/apply$/) + await selectOptionInField(page, 'reservation-lab', /基础电路实验室/) - await page.getByTestId('reservation-equipment').click() - await page.getByRole('option', { name: /数字示波器/ }).click() + await selectOptionInField(page, 'reservation-equipment', /数字示波器/) - await page.getByTestId('reservation-date').click() - await page.locator('.el-picker-panel:visible td.available').getByText(data.day, { exact: true }).click() + const dateInput = page.getByTestId('reservation-date').getByRole('combobox') + await dateInput.click() + await dateInput.fill(data.reservationDateText) + await dateInput.press('Enter') + await expect(dateInput).toHaveValue(data.reservationDateText) - await selectTimeOption(page, 'reservation-start-time', timeRange.start) - await selectTimeOption(page, 'reservation-end-time', timeRange.end) + await selectOptionInField(page, 'reservation-start-time', timeRange.start) + await selectOptionInField(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() } @@ -92,7 +96,7 @@ test('teacher can approve a student reservation', async ({ browser }) => { 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() + const approvedRow = verifyPage.locator('.el-table__row').filter({ hasText: reservation.reservationDateText }).first() await expect(approvedRow).toBeVisible() await approvedRow.getByRole('button', { name: '详情' }).click() await expect(verifyPage.getByRole('dialog', { name: '预约详情' })).toContainText(reservation.purpose) @@ -126,7 +130,7 @@ test('teacher can reject a student reservation', async ({ browser }) => { await verifyPage.goto('/reservation/my') const rejectedRow = verifyPage .locator('.el-table__row') - .filter({ hasText: reservation.reservationDate.toISOString().slice(0, 10) }) + .filter({ hasText: reservation.reservationDateText }) .first() await expect(rejectedRow).toBeVisible() await rejectedRow.getByRole('button', { name: '详情' }).click() diff --git a/web/vite.config.ts b/web/vite.config.ts index 9ce6c92..1dfb171 100644 --- a/web/vite.config.ts +++ b/web/vite.config.ts @@ -4,6 +4,41 @@ import { resolve } from 'path' export default defineConfig(({ mode }) => { const env = loadEnv(mode, process.cwd(), '') + const elementPlusCorePatterns = [ + '/aside', + '/avatar', + '/breadcrumb', + '/button', + '/card', + '/col', + '/container', + '/dropdown', + '/empty', + '/header', + '/icon', + '/loading', + '/main', + '/menu', + '/row', + '/skeleton', + '/tag' + ] + const elementPlusFormPatterns = [ + '/date-picker', + '/form', + '/input', + '/input-number', + '/option', + '/radio', + '/select', + '/time-select' + ] + const elementPlusDataPatterns = [ + '/descriptions', + '/dialog', + '/pagination', + '/table' + ] return { plugins: [vue()], @@ -19,10 +54,44 @@ export default defineConfig(({ mode }) => { if (!id.includes('node_modules')) { return } + if (id.includes('zrender')) { + return 'vendor-echarts-core' + } if (id.includes('echarts')) { + if ( + id.includes('/chart/line/') + || id.includes('/component/grid/') + ) { + return 'vendor-echarts-line' + } + if ( + id.includes('/chart/pie/') + || id.includes('/component/legend/') + ) { + return 'vendor-echarts-pie' + } + if ( + id.includes('/component/tooltip/') + || id.includes('/core') + || id.includes('/renderers') + ) { + return 'vendor-echarts-core' + } return 'vendor-echarts' } - if (id.includes('element-plus') || id.includes('@element-plus')) { + if (id.includes('@element-plus/icons-vue')) { + return 'vendor-element-plus-icons' + } + if (id.includes('element-plus')) { + if (elementPlusCorePatterns.some((pattern) => id.includes(pattern))) { + return 'vendor-element-plus-core' + } + if (elementPlusFormPatterns.some((pattern) => id.includes(pattern))) { + return 'vendor-element-plus-form' + } + if (elementPlusDataPatterns.some((pattern) => id.includes(pattern))) { + return 'vendor-element-plus-data' + } return 'vendor-element-plus' } if (id.includes('vue') || id.includes('pinia') || id.includes('vue-router')) {