diff --git a/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260308193550.java b/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260308193550.java new file mode 100644 index 0000000..d54392b --- /dev/null +++ b/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260308193550.java @@ -0,0 +1,105 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.auth.dto.AuthResponse; +import com.yoyuzh.auth.dto.LoginRequest; +import com.yoyuzh.auth.dto.RegisterRequest; +import com.yoyuzh.common.BusinessException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @InjectMocks + private AuthService authService; + + @Test + void shouldRegisterUserWithEncryptedPassword() { + RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password"); + when(userRepository.existsByUsername("alice")).thenReturn(false); + when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); + when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(1L); + user.setCreatedAt(LocalDateTime.now()); + return user; + }); + when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token"); + + AuthResponse response = authService.register(request); + + assertThat(response.token()).isEqualTo("jwt-token"); + assertThat(response.user().username()).isEqualTo("alice"); + verify(passwordEncoder).encode("plain-password"); + } + + @Test + void shouldRejectDuplicateUsernameOnRegister() { + RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password"); + when(userRepository.existsByUsername("alice")).thenReturn(true); + + assertThatThrownBy(() -> authService.register(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("用户名已存在"); + } + + @Test + void shouldLoginAndReturnToken() { + LoginRequest request = new LoginRequest("alice", "plain-password"); + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setEmail("alice@example.com"); + user.setPasswordHash("encoded-password"); + user.setCreatedAt(LocalDateTime.now()); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token"); + + AuthResponse response = authService.login(request); + + verify(authenticationManager).authenticate( + new UsernamePasswordAuthenticationToken("alice", "plain-password")); + assertThat(response.token()).isEqualTo("jwt-token"); + assertThat(response.user().email()).isEqualTo("alice@example.com"); + } + + @Test + void shouldThrowBusinessExceptionWhenAuthenticationFails() { + LoginRequest request = new LoginRequest("alice", "wrong-password"); + when(authenticationManager.authenticate(any())) + .thenThrow(new BadCredentialsException("bad credentials")); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("用户名或密码错误"); + } +} diff --git a/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260314112620.java b/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260314112620.java new file mode 100644 index 0000000..d54392b --- /dev/null +++ b/.history/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest_20260314112620.java @@ -0,0 +1,105 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.auth.dto.AuthResponse; +import com.yoyuzh.auth.dto.LoginRequest; +import com.yoyuzh.auth.dto.RegisterRequest; +import com.yoyuzh.common.BusinessException; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.AuthenticationManager; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class AuthServiceTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private AuthenticationManager authenticationManager; + + @Mock + private JwtTokenProvider jwtTokenProvider; + + @InjectMocks + private AuthService authService; + + @Test + void shouldRegisterUserWithEncryptedPassword() { + RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password"); + when(userRepository.existsByUsername("alice")).thenReturn(false); + when(userRepository.existsByEmail("alice@example.com")).thenReturn(false); + when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(1L); + user.setCreatedAt(LocalDateTime.now()); + return user; + }); + when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token"); + + AuthResponse response = authService.register(request); + + assertThat(response.token()).isEqualTo("jwt-token"); + assertThat(response.user().username()).isEqualTo("alice"); + verify(passwordEncoder).encode("plain-password"); + } + + @Test + void shouldRejectDuplicateUsernameOnRegister() { + RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password"); + when(userRepository.existsByUsername("alice")).thenReturn(true); + + assertThatThrownBy(() -> authService.register(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("用户名已存在"); + } + + @Test + void shouldLoginAndReturnToken() { + LoginRequest request = new LoginRequest("alice", "plain-password"); + User user = new User(); + user.setId(1L); + user.setUsername("alice"); + user.setEmail("alice@example.com"); + user.setPasswordHash("encoded-password"); + user.setCreatedAt(LocalDateTime.now()); + when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user)); + when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token"); + + AuthResponse response = authService.login(request); + + verify(authenticationManager).authenticate( + new UsernamePasswordAuthenticationToken("alice", "plain-password")); + assertThat(response.token()).isEqualTo("jwt-token"); + assertThat(response.user().email()).isEqualTo("alice@example.com"); + } + + @Test + void shouldThrowBusinessExceptionWhenAuthenticationFails() { + LoginRequest request = new LoginRequest("alice", "wrong-password"); + when(authenticationManager.authenticate(any())) + .thenThrow(new BadCredentialsException("bad credentials")); + + assertThatThrownBy(() -> authService.login(request)) + .isInstanceOf(BusinessException.class) + .hasMessageContaining("用户名或密码错误"); + } +} diff --git a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java index 3a67fdb..6aabf1c 100644 --- a/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java +++ b/backend/src/main/java/com/yoyuzh/PortalBackendApplication.java @@ -1,6 +1,7 @@ package com.yoyuzh; import com.yoyuzh.config.CquApiProperties; +import com.yoyuzh.config.CorsProperties; import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.JwtProperties; import org.springframework.boot.SpringApplication; @@ -11,7 +12,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties @EnableConfigurationProperties({ JwtProperties.class, FileStorageProperties.class, - CquApiProperties.class + CquApiProperties.class, + CorsProperties.class }) public class PortalBackendApplication { diff --git a/backend/src/main/java/com/yoyuzh/auth/AuthService.java b/backend/src/main/java/com/yoyuzh/auth/AuthService.java index 8aef00e..47a2c93 100644 --- a/backend/src/main/java/com/yoyuzh/auth/AuthService.java +++ b/backend/src/main/java/com/yoyuzh/auth/AuthService.java @@ -6,6 +6,7 @@ import com.yoyuzh.auth.dto.RegisterRequest; import com.yoyuzh.auth.dto.UserProfileResponse; import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.ErrorCode; +import com.yoyuzh.files.FileService; import lombok.RequiredArgsConstructor; import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.BadCredentialsException; @@ -22,6 +23,7 @@ public class AuthService { private final PasswordEncoder passwordEncoder; private final AuthenticationManager authenticationManager; private final JwtTokenProvider jwtTokenProvider; + private final FileService fileService; @Transactional public AuthResponse register(RegisterRequest request) { @@ -37,6 +39,7 @@ public class AuthService { user.setEmail(request.email()); user.setPasswordHash(passwordEncoder.encode(request.password())); User saved = userRepository.save(user); + fileService.ensureDefaultDirectories(saved); return new AuthResponse(jwtTokenProvider.generateToken(saved.getId(), saved.getUsername()), toProfile(saved)); } @@ -50,6 +53,7 @@ public class AuthService { User user = userRepository.findByUsername(request.username()) .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); + fileService.ensureDefaultDirectories(user); return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user)); } @@ -68,6 +72,7 @@ public class AuthService { created.setPasswordHash(passwordEncoder.encode("1")); return userRepository.save(created); }); + fileService.ensureDefaultDirectories(user); return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user)); } diff --git a/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java b/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java new file mode 100644 index 0000000..372694f --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/auth/DevBootstrapDataInitializer.java @@ -0,0 +1,148 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.FileService; +import com.yoyuzh.files.StoredFile; +import com.yoyuzh.files.StoredFileRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.List; + +@Component +@Profile("dev") +@RequiredArgsConstructor +public class DevBootstrapDataInitializer implements CommandLineRunner { + + private static final List DEMO_USERS = List.of( + new DemoUserSpec( + "portal-demo", + "portal123456", + "portal-demo@example.com", + List.of( + new DemoFileSpec("/下载", "迎新资料.txt", "text/plain", "portal-demo 的下载目录示例文件。"), + new DemoFileSpec("/文档", "课程规划.md", "text/markdown", "# 课程规划\n- 高级 Java\n- 软件工程\n- 计算机网络"), + new DemoFileSpec("/图片", "campus-shot.png", "image/png", "PNG PLACEHOLDER FOR portal-demo") + ) + ), + new DemoUserSpec( + "portal-study", + "study123456", + "portal-study@example.com", + List.of( + new DemoFileSpec("/下载", "实验数据.csv", "text/csv", "week,score\n1,86\n2,91\n3,95"), + new DemoFileSpec("/文档", "论文草稿.md", "text/markdown", "# 论文草稿\n研究方向:人机交互与数据分析。"), + new DemoFileSpec("/图片", "data-chart.png", "image/png", "PNG PLACEHOLDER FOR portal-study") + ) + ), + new DemoUserSpec( + "portal-design", + "design123456", + "portal-design@example.com", + List.of( + new DemoFileSpec("/下载", "素材清单.txt", "text/plain", "图标、插画、动效资源待确认。"), + new DemoFileSpec("/文档", "作品说明.md", "text/markdown", "# 作品说明\n本账号用于 UI 方案演示与交付。"), + new DemoFileSpec("/图片", "ui-mockup.png", "image/png", "PNG PLACEHOLDER FOR portal-design") + ) + ) + ); + + private final UserRepository userRepository; + private final PasswordEncoder passwordEncoder; + private final FileService fileService; + private final StoredFileRepository storedFileRepository; + private final FileStorageProperties fileStorageProperties; + + @Override + @Transactional + public void run(String... args) { + for (DemoUserSpec spec : DEMO_USERS) { + User user = ensureUser(spec); + fileService.ensureDefaultDirectories(user); + ensureDemoFiles(user, spec.files()); + } + } + + private User ensureUser(DemoUserSpec spec) { + return userRepository.findByUsername(spec.username()) + .map(existing -> updateExistingUser(existing, spec)) + .orElseGet(() -> createUser(spec)); + } + + private User createUser(DemoUserSpec spec) { + User created = new User(); + created.setUsername(spec.username()); + created.setEmail(spec.email()); + created.setPasswordHash(passwordEncoder.encode(spec.password())); + return userRepository.save(created); + } + + private User updateExistingUser(User existing, DemoUserSpec spec) { + boolean changed = false; + if (!spec.email().equals(existing.getEmail())) { + existing.setEmail(spec.email()); + changed = true; + } + if (!passwordEncoder.matches(spec.password(), existing.getPasswordHash())) { + existing.setPasswordHash(passwordEncoder.encode(spec.password())); + changed = true; + } + return changed ? userRepository.save(existing) : existing; + } + + private void ensureDemoFiles(User user, List files) { + for (DemoFileSpec file : files) { + if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), file.path(), file.filename())) { + continue; + } + + Path filePath = resolveFilePath(user.getId(), file.path(), file.filename()); + try { + Files.createDirectories(filePath.getParent()); + Files.writeString(filePath, file.content(), StandardCharsets.UTF_8); + } catch (IOException ex) { + throw new IllegalStateException("无法初始化开发样例文件: " + file.filename(), ex); + } + + StoredFile storedFile = new StoredFile(); + storedFile.setUser(user); + storedFile.setFilename(file.filename()); + storedFile.setPath(file.path()); + storedFile.setStorageName(file.filename()); + storedFile.setContentType(file.contentType()); + storedFile.setSize((long) file.content().getBytes(StandardCharsets.UTF_8).length); + storedFile.setDirectory(false); + storedFileRepository.save(storedFile); + } + } + + private Path resolveFilePath(Long userId, String path, String filename) { + Path rootPath = Path.of(fileStorageProperties.getRootDir()).toAbsolutePath().normalize(); + String normalizedPath = path.startsWith("/") ? path.substring(1) : path; + return rootPath.resolve(userId.toString()).resolve(normalizedPath).resolve(filename).normalize(); + } + + private record DemoUserSpec( + String username, + String password, + String email, + List files + ) { + } + + private record DemoFileSpec( + String path, + String filename, + String contentType, + String content + ) { + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/CorsProperties.java b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java new file mode 100644 index 0000000..607c3db --- /dev/null +++ b/backend/src/main/java/com/yoyuzh/config/CorsProperties.java @@ -0,0 +1,23 @@ +package com.yoyuzh.config; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.util.ArrayList; +import java.util.List; + +@ConfigurationProperties(prefix = "app.cors") +public class CorsProperties { + + private List allowedOrigins = new ArrayList<>(List.of( + "http://localhost:3000", + "http://127.0.0.1:3000" + )); + + public List getAllowedOrigins() { + return allowedOrigins; + } + + public void setAllowedOrigins(List allowedOrigins) { + this.allowedOrigins = allowedOrigins; + } +} diff --git a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java index e78f60e..2a1e07b 100644 --- a/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java +++ b/backend/src/main/java/com/yoyuzh/config/SecurityConfig.java @@ -21,6 +21,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; 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; + +import java.util.List; @Configuration @EnableWebSecurity @@ -31,6 +36,7 @@ public class SecurityConfig { private final JwtAuthenticationFilter jwtAuthenticationFilter; private final CustomUserDetailsService userDetailsService; private final ObjectMapper objectMapper; + private final CorsProperties corsProperties; @Bean public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { @@ -41,7 +47,7 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> auth .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .permitAll() - .requestMatchers("/api/files/**", "/api/user/**") + .requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**") .authenticated() .anyRequest() .permitAll()) @@ -80,4 +86,18 @@ public class SecurityConfig { public PasswordEncoder passwordEncoder() { return new BCryptPasswordEncoder(); } + + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOrigins(corsProperties.getAllowedOrigins()); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(false); + configuration.setMaxAge(3600L); + + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } } diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java b/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java index e854627..40188ff 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CquDataService.java @@ -20,6 +20,7 @@ public class CquDataService { private final GradeRepository gradeRepository; private final CquApiProperties cquApiProperties; + @Transactional public List getSchedule(User user, String semester, String studentId) { requireLoginIfNecessary(user); List responses = cquApiClient.fetchSchedule(semester, studentId).stream() @@ -42,6 +43,7 @@ public class CquDataService { return responses; } + @Transactional public List getGrades(User user, String semester, String studentId) { requireLoginIfNecessary(user); List responses = cquApiClient.fetchGrades(semester, studentId).stream() diff --git a/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java b/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java index df78061..9099576 100644 --- a/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java +++ b/backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java @@ -9,60 +9,101 @@ public final class CquMockDataFactory { } public static List> createSchedule(String semester, String studentId) { - return List.of( - Map.of( + StudentProfile profile = StudentProfile.fromStudentId(studentId); + return profile.schedule().stream() + .map(item -> Map.of( "studentId", studentId, "semester", semester, - "courseName", "高级 Java 程序设计", - "teacher", "李老师", - "classroom", "D1131", - "dayOfWeek", 1, - "startTime", 1, - "endTime", 2 - ), - Map.of( - "studentId", studentId, - "semester", semester, - "courseName", "计算机网络", - "teacher", "王老师", - "classroom", "A2204", - "dayOfWeek", 3, - "startTime", 3, - "endTime", 4 - ), - Map.of( - "studentId", studentId, - "semester", semester, - "courseName", "软件工程", - "teacher", "周老师", - "classroom", "B3102", - "dayOfWeek", 5, - "startTime", 5, - "endTime", 6 - ) - ); + "courseName", item.courseName(), + "teacher", item.teacher(), + "classroom", item.classroom(), + "dayOfWeek", item.dayOfWeek(), + "startTime", item.startTime(), + "endTime", item.endTime() + )) + .toList(); } public static List> createGrades(String semester, String studentId) { - return List.of( - Map.of( + StudentProfile profile = StudentProfile.fromStudentId(studentId); + return profile.grades().stream() + .map(item -> Map.of( "studentId", studentId, "semester", semester, - "courseName", "高级 Java 程序设计", - "grade", 92.0 - ), - Map.of( - "studentId", studentId, - "semester", semester, - "courseName", "计算机网络", - "grade", 88.5 - ), - Map.of( - "studentId", studentId, - "semester", semester, - "courseName", "软件工程", - "grade", 90.0 - ) - ); + "courseName", item.courseName(), + "grade", item.score() + )) + .toList(); + } + + private record ScheduleEntry( + String courseName, + String teacher, + String classroom, + Integer dayOfWeek, + Integer startTime, + Integer endTime + ) { + } + + private record GradeEntry(String courseName, Double score) { + } + + private record StudentProfile( + List schedule, + List grades + ) { + private static StudentProfile fromStudentId(String studentId) { + return switch (studentId) { + case "2023123456" -> new StudentProfile( + List.of( + new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2), + new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4), + new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6) + ), + List.of( + new GradeEntry("高级 Java 程序设计", 92.0), + new GradeEntry("计算机网络", 88.5), + new GradeEntry("软件工程", 90.0) + ) + ); + case "2022456789" -> new StudentProfile( + List.of( + new ScheduleEntry("数据挖掘", "陈老师", "A1408", 2, 1, 2), + new ScheduleEntry("机器学习基础", "赵老师", "B2201", 4, 3, 4), + new ScheduleEntry("信息检索", "孙老师", "C1205", 5, 7, 8) + ), + List.of( + new GradeEntry("数据挖掘", 94.0), + new GradeEntry("机器学习基础", 91.0), + new GradeEntry("信息检索", 89.0) + ) + ); + case "2021789012" -> new StudentProfile( + List.of( + new ScheduleEntry("交互设计", "刘老师", "艺设楼201", 1, 3, 4), + new ScheduleEntry("视觉传达专题", "黄老师", "艺设楼305", 3, 5, 6), + new ScheduleEntry("数字媒体项目实践", "许老师", "创意工坊101", 4, 7, 8) + ), + List.of( + new GradeEntry("交互设计", 96.0), + new GradeEntry("视觉传达专题", 93.0), + new GradeEntry("数字媒体项目实践", 97.0) + ) + ); + default -> new StudentProfile( + List.of( + new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2), + new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4), + new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6) + ), + List.of( + new GradeEntry("高级 Java 程序设计", 92.0), + new GradeEntry("计算机网络", 88.5), + new GradeEntry("软件工程", 90.0) + ) + ); + }; + } } } diff --git a/backend/src/main/java/com/yoyuzh/files/FileService.java b/backend/src/main/java/com/yoyuzh/files/FileService.java index 4cc4ced..70c384c 100644 --- a/backend/src/main/java/com/yoyuzh/files/FileService.java +++ b/backend/src/main/java/com/yoyuzh/files/FileService.java @@ -25,6 +25,7 @@ import java.util.List; @Service public class FileService { + private static final List DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片"); private final StoredFileRepository storedFileRepository; private final Path rootPath; @@ -118,6 +119,31 @@ public class FileService { .toList(); } + @Transactional + public void ensureDefaultDirectories(User user) { + for (String directoryName : DEFAULT_DIRECTORIES) { + if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), "/", directoryName)) { + continue; + } + + try { + Files.createDirectories(resolveUserPath(user.getId(), "/").resolve(directoryName)); + } catch (IOException ex) { + throw new BusinessException(ErrorCode.UNKNOWN, "默认目录初始化失败"); + } + + StoredFile storedFile = new StoredFile(); + storedFile.setUser(user); + storedFile.setFilename(directoryName); + storedFile.setPath("/"); + storedFile.setStorageName(directoryName); + storedFile.setContentType("directory"); + storedFile.setSize(0L); + storedFile.setDirectory(true); + storedFileRepository.save(storedFile); + } + } + @Transactional public void delete(User user, Long fileId) { StoredFile storedFile = storedFileRepository.findById(fileId) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 67c9934..e0907dc 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -30,8 +30,12 @@ app: max-file-size: 52428800 cqu: base-url: https://example-cqu-api.local - require-login: false + require-login: true mock-enabled: false + cors: + allowed-origins: + - http://localhost:3000 + - http://127.0.0.1:3000 springdoc: swagger-ui: diff --git a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java index d54392b..01cccfd 100644 --- a/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java @@ -4,6 +4,7 @@ import com.yoyuzh.auth.dto.AuthResponse; import com.yoyuzh.auth.dto.LoginRequest; import com.yoyuzh.auth.dto.RegisterRequest; import com.yoyuzh.common.BusinessException; +import com.yoyuzh.files.FileService; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.InjectMocks; @@ -38,6 +39,9 @@ class AuthServiceTest { @Mock private JwtTokenProvider jwtTokenProvider; + @Mock + private FileService fileService; + @InjectMocks private AuthService authService; @@ -60,6 +64,7 @@ class AuthServiceTest { assertThat(response.token()).isEqualTo("jwt-token"); assertThat(response.user().username()).isEqualTo("alice"); verify(passwordEncoder).encode("plain-password"); + verify(fileService).ensureDefaultDirectories(any(User.class)); } @Test @@ -90,6 +95,7 @@ class AuthServiceTest { new UsernamePasswordAuthenticationToken("alice", "plain-password")); assertThat(response.token()).isEqualTo("jwt-token"); assertThat(response.user().email()).isEqualTo("alice@example.com"); + verify(fileService).ensureDefaultDirectories(user); } @Test @@ -102,4 +108,22 @@ class AuthServiceTest { .isInstanceOf(BusinessException.class) .hasMessageContaining("用户名或密码错误"); } + + @Test + void shouldCreateDefaultDirectoriesForDevLoginUser() { + when(userRepository.findByUsername("demo")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("1")).thenReturn("encoded-password"); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId(9L); + user.setCreatedAt(LocalDateTime.now()); + return user; + }); + when(jwtTokenProvider.generateToken(9L, "demo")).thenReturn("jwt-token"); + + AuthResponse response = authService.devLogin("demo"); + + assertThat(response.user().username()).isEqualTo("demo"); + verify(fileService).ensureDefaultDirectories(any(User.class)); + } } diff --git a/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java b/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java new file mode 100644 index 0000000..b656a07 --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/auth/DevBootstrapDataInitializerTest.java @@ -0,0 +1,79 @@ +package com.yoyuzh.auth; + +import com.yoyuzh.config.FileStorageProperties; +import com.yoyuzh.files.FileService; +import com.yoyuzh.files.StoredFileRepository; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.nio.file.Path; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class DevBootstrapDataInitializerTest { + + @Mock + private UserRepository userRepository; + + @Mock + private PasswordEncoder passwordEncoder; + + @Mock + private FileService fileService; + + @Mock + private StoredFileRepository storedFileRepository; + + @Mock + private FileStorageProperties fileStorageProperties; + + @InjectMocks + private DevBootstrapDataInitializer initializer; + + @TempDir + Path tempDir; + + @Test + void shouldCreateInitialDevUsersWhenMissing() throws Exception { + when(userRepository.findByUsername("portal-demo")).thenReturn(Optional.empty()); + when(userRepository.findByUsername("portal-study")).thenReturn(Optional.empty()); + when(userRepository.findByUsername("portal-design")).thenReturn(Optional.empty()); + when(passwordEncoder.encode("portal123456")).thenReturn("encoded-demo-password"); + when(passwordEncoder.encode("study123456")).thenReturn("encoded-study-password"); + when(passwordEncoder.encode("design123456")).thenReturn("encoded-design-password"); + when(storedFileRepository.existsByUserIdAndPathAndFilename(anyLong(), anyString(), anyString())).thenReturn(false); + when(fileStorageProperties.getRootDir()).thenReturn(tempDir.toString()); + List savedUsers = new ArrayList<>(); + when(userRepository.save(any(User.class))).thenAnswer(invocation -> { + User user = invocation.getArgument(0); + user.setId((long) (savedUsers.size() + 1)); + user.setCreatedAt(LocalDateTime.now()); + savedUsers.add(user); + return user; + }); + + initializer.run(); + + verify(userRepository, times(3)).save(any(User.class)); + verify(fileService, times(3)).ensureDefaultDirectories(any(User.class)); + org.assertj.core.api.Assertions.assertThat(savedUsers) + .extracting(User::getUsername) + .containsExactly("portal-demo", "portal-study", "portal-design"); + verify(storedFileRepository, times(9)).save(any()); + } +} diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java new file mode 100644 index 0000000..62dd34c --- /dev/null +++ b/backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTransactionTest.java @@ -0,0 +1,78 @@ +package com.yoyuzh.cqu; + +import com.yoyuzh.PortalBackendApplication; +import com.yoyuzh.auth.User; +import com.yoyuzh.auth.UserRepository; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.test.mock.mockito.MockBean; + +import java.util.List; +import java.util.Map; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +@SpringBootTest( + classes = PortalBackendApplication.class, + properties = { + "spring.datasource.url=jdbc:h2:mem:cqu_tx_test;MODE=MySQL;DB_CLOSE_DELAY=-1", + "spring.datasource.driver-class-name=org.h2.Driver", + "spring.datasource.username=sa", + "spring.datasource.password=", + "spring.jpa.hibernate.ddl-auto=create-drop", + "app.cqu.require-login=true", + "app.cqu.mock-enabled=false" + } +) +class CquDataServiceTransactionTest { + + @Autowired + private CquDataService cquDataService; + + @Autowired + private UserRepository userRepository; + + @Autowired + private GradeRepository gradeRepository; + + @MockBean + private CquApiClient cquApiClient; + + @Test + void shouldPersistGradesInsideTransactionForLoggedInUser() { + User user = new User(); + user.setUsername("portal-demo"); + user.setEmail("portal-demo@example.com"); + user.setPasswordHash("encoded"); + user = userRepository.save(user); + + Grade existing = new Grade(); + existing.setUser(user); + existing.setCourseName("Old Java"); + existing.setGrade(60D); + existing.setSemester("2025-spring"); + existing.setStudentId("2023123456"); + gradeRepository.save(existing); + + when(cquApiClient.fetchGrades("2025-spring", "2023123456")).thenReturn(List.of( + Map.of( + "courseName", "Java", + "grade", 95, + "semester", "2025-spring" + ) + )); + + List response = cquDataService.getGrades(user, "2025-spring", "2023123456"); + + assertThat(response).hasSize(1); + assertThat(response.get(0).courseName()).isEqualTo("Java"); + assertThat(response.get(0).grade()).isEqualTo(95D); + assertThat(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), "2023123456")) + .hasSize(1) + .first() + .extracting(Grade::getCourseName) + .isEqualTo("Java"); + } +} diff --git a/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java b/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java index d79e8d2..6aa9204 100644 --- a/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java +++ b/backend/src/test/java/com/yoyuzh/cqu/CquMockDataFactoryTest.java @@ -26,4 +26,17 @@ class CquMockDataFactoryTest { assertThat(result.get(0)).containsEntry("studentId", "20230001"); assertThat(result.get(0)).containsKey("grade"); } + + @Test + void shouldReturnDifferentMockDataForDifferentStudents() { + List> firstSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2023123456"); + List> secondSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2022456789"); + List> firstGrades = CquMockDataFactory.createGrades("2025-2026-1", "2023123456"); + List> secondGrades = CquMockDataFactory.createGrades("2025-2026-1", "2022456789"); + + assertThat(firstSchedule).extracting(item -> item.get("courseName")) + .isNotEqualTo(secondSchedule.stream().map(item -> item.get("courseName")).toList()); + assertThat(firstGrades).extracting(item -> item.get("grade")) + .isNotEqualTo(secondGrades.stream().map(item -> item.get("grade")).toList()); + } } diff --git a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java index 5088301..bee106e 100644 --- a/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java +++ b/backend/src/test/java/com/yoyuzh/files/FileServiceTest.java @@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.data.domain.PageImpl; @@ -22,6 +21,8 @@ import java.util.Optional; import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @ExtendWith(MockitoExtension.class) @@ -89,6 +90,25 @@ class FileServiceTest { assertThat(result.items().get(0).filename()).isEqualTo("notes.txt"); } + @Test + void shouldCreateDefaultDirectoriesForUserWorkspace() { + User user = createUser(7L); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "下载")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "文档")).thenReturn(false); + when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/", "图片")).thenReturn(false); + when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> invocation.getArgument(0)); + + fileService.ensureDefaultDirectories(user); + + assertThat(tempDir.resolve("7/下载")).exists(); + assertThat(tempDir.resolve("7/文档")).exists(); + assertThat(tempDir.resolve("7/图片")).exists(); + verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "下载"); + verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "文档"); + verify(storedFileRepository).existsByUserIdAndPathAndFilename(7L, "/", "图片"); + verify(storedFileRepository, times(3)).save(any(StoredFile.class)); + } + private User createUser(Long id) { User user = new User(); user.setId(id); diff --git a/front/.env.example b/front/.env.example index 7a550fe..e44157c 100644 --- a/front/.env.example +++ b/front/.env.example @@ -7,3 +7,12 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY" # AI Studio automatically injects this at runtime with the Cloud Run service URL. # Used for self-referential links, OAuth callbacks, and API endpoints. APP_URL="MY_APP_URL" + +# Optional: direct API base path used by the frontend. +VITE_API_BASE_URL="/api" + +# Optional: backend origin used by the Vite dev proxy. +VITE_BACKEND_URL="http://localhost:8080" + +# Enable the dev-login button when the backend runs with the dev profile. +VITE_ENABLE_DEV_LOGIN="true" diff --git a/front/package.json b/front/package.json index 6dedf4c..e9da3f2 100644 --- a/front/package.json +++ b/front/package.json @@ -8,7 +8,8 @@ "build": "vite build", "preview": "vite preview", "clean": "rm -rf dist", - "lint": "tsc --noEmit" + "lint": "tsc --noEmit", + "test": "node --import tsx --test src/**/*.test.ts" }, "dependencies": { "@google/genai": "^1.29.0", diff --git a/front/src/auth/AuthProvider.tsx b/front/src/auth/AuthProvider.tsx new file mode 100644 index 0000000..cb7575b --- /dev/null +++ b/front/src/auth/AuthProvider.tsx @@ -0,0 +1,168 @@ +import React, { createContext, useContext, useEffect, useState } from 'react'; + +import { apiRequest } from '@/src/lib/api'; +import { + clearStoredSession, + readStoredSession, + saveStoredSession, + SESSION_EVENT_NAME, +} from '@/src/lib/session'; +import type { AuthResponse, AuthSession, UserProfile } from '@/src/lib/types'; + +interface LoginPayload { + username: string; + password: string; +} + +interface AuthContextValue { + ready: boolean; + session: AuthSession | null; + user: UserProfile | null; + login: (payload: LoginPayload) => Promise; + devLogin: (username?: string) => Promise; + logout: () => void; + refreshProfile: () => Promise; +} + +const AuthContext = createContext(null); + +function buildSession(auth: AuthResponse): AuthSession { + return { + token: auth.token, + user: auth.user, + }; +} + +export function AuthProvider({ children }: { children: React.ReactNode }) { + const [session, setSession] = useState(() => readStoredSession()); + const [ready, setReady] = useState(false); + + useEffect(() => { + const syncSession = () => { + setSession(readStoredSession()); + }; + + window.addEventListener('storage', syncSession); + window.addEventListener(SESSION_EVENT_NAME, syncSession); + + return () => { + window.removeEventListener('storage', syncSession); + window.removeEventListener(SESSION_EVENT_NAME, syncSession); + }; + }, []); + + useEffect(() => { + let active = true; + + async function hydrate() { + const storedSession = readStoredSession(); + if (!storedSession) { + if (active) { + setSession(null); + setReady(true); + } + return; + } + + try { + const user = await apiRequest('/user/profile'); + if (!active) { + return; + } + + const nextSession = { + ...storedSession, + user, + }; + saveStoredSession(nextSession); + setSession(nextSession); + } catch { + clearStoredSession(); + if (active) { + setSession(null); + } + } finally { + if (active) { + setReady(true); + } + } + } + + hydrate(); + + return () => { + active = false; + }; + }, []); + + async function refreshProfile() { + const currentSession = readStoredSession(); + if (!currentSession) { + return; + } + + const user = await apiRequest('/user/profile'); + const nextSession = { + ...currentSession, + user, + }; + saveStoredSession(nextSession); + setSession(nextSession); + } + + async function login(payload: LoginPayload) { + const auth = await apiRequest('/auth/login', { + method: 'POST', + body: payload, + }); + const nextSession = buildSession(auth); + saveStoredSession(nextSession); + setSession(nextSession); + } + + async function devLogin(username?: string) { + const params = new URLSearchParams(); + if (username?.trim()) { + params.set('username', username.trim()); + } + + const auth = await apiRequest( + `/auth/dev-login${params.size ? `?${params.toString()}` : ''}`, + { + method: 'POST', + }, + ); + const nextSession = buildSession(auth); + saveStoredSession(nextSession); + setSession(nextSession); + } + + function logout() { + clearStoredSession(); + setSession(null); + } + + return ( + + {children} + + ); +} + +export function useAuth() { + const context = useContext(AuthContext); + if (!context) { + throw new Error('useAuth must be used inside AuthProvider'); + } + return context; +} diff --git a/front/src/components/layout/Layout.tsx b/front/src/components/layout/Layout.tsx index 1cea544..7bd3542 100644 --- a/front/src/components/layout/Layout.tsx +++ b/front/src/components/layout/Layout.tsx @@ -1,8 +1,10 @@ import React from 'react'; import { NavLink, Outlet, useNavigate } from 'react-router-dom'; -import { cn } from '@/src/lib/utils'; import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react'; +import { clearStoredSession } from '@/src/lib/session'; +import { cn } from '@/src/lib/utils'; + const NAV_ITEMS = [ { name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '网盘', path: '/files', icon: FolderOpen }, @@ -14,6 +16,7 @@ export function Layout() { const navigate = useNavigate(); const handleLogout = () => { + clearStoredSession(); navigate('/login'); }; @@ -88,4 +91,3 @@ export function Layout() { ); } - diff --git a/front/src/lib/api.test.ts b/front/src/lib/api.test.ts new file mode 100644 index 0000000..6b0bac2 --- /dev/null +++ b/front/src/lib/api.test.ts @@ -0,0 +1,111 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; + +import { apiRequest } from './api'; +import { clearStoredSession, saveStoredSession } from './session'; + +class MemoryStorage implements Storage { + private store = new Map(); + + get length() { + return this.store.size; + } + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.has(key) ? this.store.get(key)! : null; + } + + key(index: number) { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +const originalFetch = globalThis.fetch; +const originalStorage = globalThis.localStorage; + +beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: new MemoryStorage(), + }); + clearStoredSession(); +}); + +afterEach(() => { + globalThis.fetch = originalFetch; + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: originalStorage, + }); +}); + +test('apiRequest attaches bearer token and unwraps response payload', async () => { + let request: Request | URL | string | undefined; + saveStoredSession({ + token: 'token-123', + user: { + id: 1, + username: 'tester', + email: 'tester@example.com', + createdAt: '2026-03-14T10:00:00', + }, + }); + + globalThis.fetch = async (input, init) => { + request = + input instanceof Request + ? input + : new Request(new URL(String(input), 'http://localhost'), init); + return new Response( + JSON.stringify({ + code: 0, + msg: 'success', + data: { + ok: true, + }, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + }; + + const payload = await apiRequest<{ok: boolean}>('/files/recent'); + + assert.deepEqual(payload, {ok: true}); + assert.ok(request instanceof Request); + assert.equal(request.headers.get('Authorization'), 'Bearer token-123'); + assert.equal(request.url, 'http://localhost/api/files/recent'); +}); + +test('apiRequest throws backend message on business error', async () => { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + code: 40101, + msg: 'login required', + data: null, + }), + { + headers: { + 'Content-Type': 'application/json', + }, + }, + ); + + await assert.rejects(() => apiRequest('/user/profile'), /login required/); +}); diff --git a/front/src/lib/api.ts b/front/src/lib/api.ts new file mode 100644 index 0000000..08167be --- /dev/null +++ b/front/src/lib/api.ts @@ -0,0 +1,126 @@ +import { clearStoredSession, readStoredSession } from './session'; + +interface ApiEnvelope { + code: number; + msg: string; + data: T; +} + +interface ApiRequestInit extends Omit { + body?: unknown; +} + +const API_BASE_URL = (import.meta.env?.VITE_API_BASE_URL || '/api').replace(/\/$/, ''); + +export class ApiError extends Error { + code?: number; + status: number; + + constructor(message: string, status = 500, code?: number) { + super(message); + this.name = 'ApiError'; + this.status = status; + this.code = code; + } +} + +function resolveUrl(path: string) { + if (/^https?:\/\//.test(path)) { + return path; + } + + const normalizedPath = path.startsWith('/') ? path : `/${path}`; + return `${API_BASE_URL}${normalizedPath}`; +} + +function buildRequestBody(body: ApiRequestInit['body']) { + if (body == null) { + return undefined; + } + + if ( + body instanceof FormData || + body instanceof Blob || + body instanceof URLSearchParams || + typeof body === 'string' || + body instanceof ArrayBuffer + ) { + return body; + } + + return JSON.stringify(body); +} + +async function parseApiError(response: Response) { + const contentType = response.headers.get('content-type') || ''; + if (!contentType.includes('application/json')) { + return new ApiError(`请求失败 (${response.status})`, response.status); + } + + const payload = (await response.json()) as ApiEnvelope; + return new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code); +} + +async function performRequest(path: string, init: ApiRequestInit = {}) { + const session = readStoredSession(); + const headers = new Headers(init.headers); + const requestBody = buildRequestBody(init.body); + + if (session?.token) { + headers.set('Authorization', `Bearer ${session.token}`); + } + if (requestBody && !(requestBody instanceof FormData) && !headers.has('Content-Type')) { + headers.set('Content-Type', 'application/json'); + } + if (!headers.has('Accept')) { + headers.set('Accept', 'application/json'); + } + + const response = await fetch(resolveUrl(path), { + ...init, + headers, + body: requestBody, + }); + + if (response.status === 401 || response.status === 403) { + clearStoredSession(); + } + + return response; +} + +export async function apiRequest(path: string, init?: ApiRequestInit) { + const response = await performRequest(path, init); + const contentType = response.headers.get('content-type') || ''; + + if (!contentType.includes('application/json')) { + if (!response.ok) { + throw new ApiError(`请求失败 (${response.status})`, response.status); + } + return undefined as T; + } + + const payload = (await response.json()) as ApiEnvelope; + if (!response.ok || payload.code !== 0) { + if (response.status === 401 || payload.code === 401) { + clearStoredSession(); + } + throw new ApiError(payload.msg || `请求失败 (${response.status})`, response.status, payload.code); + } + + return payload.data; +} + +export async function apiDownload(path: string) { + const response = await performRequest(path, { + headers: { + Accept: '*/*', + }, + }); + + if (!response.ok) { + throw await parseApiError(response); + } + + return response; +} diff --git a/front/src/lib/cache.test.ts b/front/src/lib/cache.test.ts new file mode 100644 index 0000000..b7c59b1 --- /dev/null +++ b/front/src/lib/cache.test.ts @@ -0,0 +1,99 @@ +import assert from 'node:assert/strict'; +import { afterEach, beforeEach, test } from 'node:test'; + +import { clearStoredSession, saveStoredSession } from './session'; +import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache'; + +class MemoryStorage implements Storage { + private store = new Map(); + + get length() { + return this.store.size; + } + + clear() { + this.store.clear(); + } + + getItem(key: string) { + return this.store.has(key) ? this.store.get(key)! : null; + } + + key(index: number) { + return Array.from(this.store.keys())[index] ?? null; + } + + removeItem(key: string) { + this.store.delete(key); + } + + setItem(key: string, value: string) { + this.store.set(key, value); + } +} + +const originalStorage = globalThis.localStorage; + +beforeEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: new MemoryStorage(), + }); + clearStoredSession(); +}); + +afterEach(() => { + Object.defineProperty(globalThis, 'localStorage', { + configurable: true, + value: originalStorage, + }); +}); + +test('scoped cache key includes current user identity', () => { + saveStoredSession({ + token: 'token-1', + user: { + id: 7, + username: 'alice', + email: 'alice@example.com', + createdAt: '2026-03-14T12:00:00', + }, + }); + + assert.equal(buildScopedCacheKey('school', '2023123456', '2025-spring'), 'portal-cache:user:7:school:2023123456:2025-spring'); +}); + +test('cached values are isolated between users', () => { + saveStoredSession({ + token: 'token-1', + user: { + id: 7, + username: 'alice', + email: 'alice@example.com', + createdAt: '2026-03-14T12:00:00', + }, + }); + writeCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring'), { + queried: true, + grades: [95], + }); + + saveStoredSession({ + token: 'token-2', + user: { + id: 8, + username: 'bob', + email: 'bob@example.com', + createdAt: '2026-03-14T12:00:00', + }, + }); + + assert.equal(readCachedValue(buildScopedCacheKey('school', '2023123456', '2025-spring')), null); +}); + +test('invalid cached json is ignored safely', () => { + localStorage.setItem('portal-cache:user:7:school:2023123456:2025-spring', '{broken-json'); + + assert.equal(readCachedValue('portal-cache:user:7:school:2023123456:2025-spring'), null); + assert.equal(localStorage.getItem('portal-cache:user:7:school:2023123456:2025-spring'), null); +}); diff --git a/front/src/lib/cache.ts b/front/src/lib/cache.ts new file mode 100644 index 0000000..aaf4892 --- /dev/null +++ b/front/src/lib/cache.ts @@ -0,0 +1,61 @@ +import { readStoredSession } from './session'; + +interface CacheEnvelope { + value: T; + updatedAt: number; +} + +const CACHE_PREFIX = 'portal-cache'; + +function getCacheScope() { + const session = readStoredSession(); + if (session?.user?.id != null) { + return `user:${session.user.id}`; + } + + return 'guest'; +} + +export function buildScopedCacheKey(namespace: string, ...parts: Array) { + const normalizedParts = parts.map((part) => String(part).replace(/:/g, '_')); + return [CACHE_PREFIX, getCacheScope(), namespace, ...normalizedParts].join(':'); +} + +export function readCachedValue(key: string): T | null { + if (typeof localStorage === 'undefined') { + return null; + } + + const rawValue = localStorage.getItem(key); + if (!rawValue) { + return null; + } + + try { + const parsed = JSON.parse(rawValue) as CacheEnvelope; + return parsed.value; + } catch { + localStorage.removeItem(key); + return null; + } +} + +export function writeCachedValue(key: string, value: T) { + if (typeof localStorage === 'undefined') { + return; + } + + const payload: CacheEnvelope = { + value, + updatedAt: Date.now(), + }; + localStorage.setItem(key, JSON.stringify(payload)); +} + +export function removeCachedValue(key: string) { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.removeItem(key); +} diff --git a/front/src/lib/page-cache.ts b/front/src/lib/page-cache.ts new file mode 100644 index 0000000..344bc25 --- /dev/null +++ b/front/src/lib/page-cache.ts @@ -0,0 +1,51 @@ +import { buildScopedCacheKey, readCachedValue, writeCachedValue } from './cache'; +import type { CourseResponse, FileMetadata, GradeResponse, UserProfile } from './types'; + +export interface SchoolQueryCache { + studentId: string; + semester: string; +} + +export interface SchoolResultsCache { + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + studentId: string; + semester: string; +} + +export interface OverviewCache { + profile: UserProfile | null; + recentFiles: FileMetadata[]; + rootFiles: FileMetadata[]; + schedule: CourseResponse[]; + grades: GradeResponse[]; +} + +function getSchoolQueryCacheKey() { + return buildScopedCacheKey('school-query'); +} + +export function readStoredSchoolQuery() { + return readCachedValue(getSchoolQueryCacheKey()); +} + +export function writeStoredSchoolQuery(query: SchoolQueryCache) { + writeCachedValue(getSchoolQueryCacheKey(), query); +} + +export function getSchoolResultsCacheKey(studentId: string, semester: string) { + return buildScopedCacheKey('school-results', studentId, semester); +} + +export function getOverviewCacheKey() { + return buildScopedCacheKey('overview'); +} + +export function getFilesLastPathCacheKey() { + return buildScopedCacheKey('files-last-path'); +} + +export function getFilesListCacheKey(path: string) { + return buildScopedCacheKey('files-list', path || 'root'); +} diff --git a/front/src/lib/session.ts b/front/src/lib/session.ts new file mode 100644 index 0000000..405d153 --- /dev/null +++ b/front/src/lib/session.ts @@ -0,0 +1,46 @@ +import type { AuthSession } from './types'; + +const SESSION_STORAGE_KEY = 'portal-session'; +export const SESSION_EVENT_NAME = 'portal-session-change'; + +function notifySessionChanged() { + if (typeof window !== 'undefined') { + window.dispatchEvent(new Event(SESSION_EVENT_NAME)); + } +} + +export function readStoredSession(): AuthSession | null { + if (typeof localStorage === 'undefined') { + return null; + } + + const rawValue = localStorage.getItem(SESSION_STORAGE_KEY); + if (!rawValue) { + return null; + } + + try { + return JSON.parse(rawValue) as AuthSession; + } catch { + localStorage.removeItem(SESSION_STORAGE_KEY); + return null; + } +} + +export function saveStoredSession(session: AuthSession) { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.setItem(SESSION_STORAGE_KEY, JSON.stringify(session)); + notifySessionChanged(); +} + +export function clearStoredSession() { + if (typeof localStorage === 'undefined') { + return; + } + + localStorage.removeItem(SESSION_STORAGE_KEY); + notifySessionChanged(); +} diff --git a/front/src/lib/types.ts b/front/src/lib/types.ts new file mode 100644 index 0000000..4d36663 --- /dev/null +++ b/front/src/lib/types.ts @@ -0,0 +1,48 @@ +export interface UserProfile { + id: number; + username: string; + email: string; + createdAt: string; +} + +export interface AuthSession { + token: string; + user: UserProfile; +} + +export interface AuthResponse { + token: string; + user: UserProfile; +} + +export interface PageResponse { + items: T[]; + total: number; + page: number; + size: number; +} + +export interface FileMetadata { + id: number; + filename: string; + path: string; + size: number; + contentType: string | null; + directory: boolean; + createdAt: string; +} + +export interface CourseResponse { + courseName: string; + teacher: string | null; + classroom: string | null; + dayOfWeek: number | null; + startTime: number | null; + endTime: number | null; +} + +export interface GradeResponse { + courseName: string; + grade: number | null; + semester: string | null; +} diff --git a/front/src/pages/Files.tsx b/front/src/pages/Files.tsx index 2064a43..ac1b746 100644 --- a/front/src/pages/Files.tsx +++ b/front/src/pages/Files.tsx @@ -1,69 +1,120 @@ -import React, { useState } from 'react'; +import React, { useEffect, useRef, useState } from 'react'; import { motion } from 'motion/react'; -import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; -import { Button } from '@/src/components/ui/button'; -import { - Folder, FileText, Image as ImageIcon, Download, Monitor, - Star, ChevronRight, Upload, Plus, LayoutGrid, List, File, - MoreVertical +import { + Folder, + FileText, + Image as ImageIcon, + Download, + Monitor, + ChevronRight, + Upload, + Plus, + LayoutGrid, + List, + MoreVertical, } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { apiDownload, apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getFilesLastPathCacheKey, getFilesListCacheKey } from '@/src/lib/page-cache'; +import type { FileMetadata, PageResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; const QUICK_ACCESS = [ - { name: '桌面', icon: Monitor }, - { name: '下载', icon: Download }, - { name: '文档', icon: FileText }, - { name: '图片', icon: ImageIcon }, + { name: '桌面', icon: Monitor, path: [] as string[] }, + { name: '下载', icon: Download, path: ['下载'] }, + { name: '文档', icon: FileText, path: ['文档'] }, + { name: '图片', icon: ImageIcon, path: ['图片'] }, ]; const DIRECTORIES = [ - { name: '我的文件', icon: Folder }, - { name: '课程资料', icon: Folder }, - { name: '项目归档', icon: Folder }, - { name: '收藏夹', icon: Star }, + { name: '下载', icon: Folder }, + { name: '文档', icon: Folder }, + { name: '图片', icon: Folder }, ]; -const MOCK_FILES_DB: Record = { - '我的文件': [ - { id: 1, name: '软件工程期末复习资料.pdf', type: 'pdf', size: '2.4 MB', modified: '2025-01-15 14:30' }, - { id: 2, name: '2025春季学期课表.xlsx', type: 'excel', size: '156 KB', modified: '2025-02-28 09:15' }, - { id: 3, name: '项目架构设计图.png', type: 'image', size: '4.1 MB', modified: '2025-03-01 16:45' }, - { id: 4, name: '实验报告模板.docx', type: 'word', size: '45 KB', modified: '2025-03-05 10:20' }, - { id: 5, name: '前端学习笔记', type: 'folder', size: '—', modified: '2025-03-10 11:00' }, - ], - '课程资料': [ - { id: 6, name: '高等数学', type: 'folder', size: '—', modified: '2025-02-20 10:00' }, - { id: 7, name: '大学物理', type: 'folder', size: '—', modified: '2025-02-21 11:00' }, - { id: 8, name: '软件工程', type: 'folder', size: '—', modified: '2025-02-22 14:00' }, - ], - '项目归档': [ - { id: 9, name: '2024秋季学期项目', type: 'folder', size: '—', modified: '2024-12-20 15:30' }, - { id: 10, name: '个人博客源码.zip', type: 'archive', size: '15.2 MB', modified: '2025-01-05 09:45' }, - ], - '收藏夹': [ - { id: 11, name: '常用工具网站.txt', type: 'document', size: '2 KB', modified: '2025-03-01 10:00' }, - ], - '我的文件/前端学习笔记': [ - { id: 12, name: 'React Hooks 详解.md', type: 'document', size: '12 KB', modified: '2025-03-08 09:00' }, - { id: 13, name: 'Tailwind 技巧.md', type: 'document', size: '8 KB', modified: '2025-03-09 14:20' }, - { id: 14, name: '示例代码', type: 'folder', size: '—', modified: '2025-03-10 10:00' }, - ], - '课程资料/软件工程': [ - { id: 15, name: '需求规格说明书.pdf', type: 'pdf', size: '1.2 MB', modified: '2025-03-05 16:00' }, - { id: 16, name: '系统设计文档.docx', type: 'word', size: '850 KB', modified: '2025-03-06 11:30' }, - ] -}; +function toBackendPath(pathParts: string[]) { + return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`; +} + +function formatFileSize(size: number) { + if (size <= 0) { + return '—'; + } + + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); + const value = size / 1024 ** index; + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatDateTime(value: string) { + return new Intl.DateTimeFormat('zh-CN', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(new Date(value)); +} + +function toUiFile(file: FileMetadata) { + const extension = file.filename.includes('.') ? file.filename.split('.').pop()?.toLowerCase() : ''; + let type = extension || 'document'; + + if (file.directory) { + type = 'folder'; + } else if (file.contentType?.startsWith('image/')) { + type = 'image'; + } else if (file.contentType?.includes('pdf')) { + type = 'pdf'; + } + + return { + id: file.id, + name: file.filename, + type, + size: file.directory ? '—' : formatFileSize(file.size), + modified: formatDateTime(file.createdAt), + }; +} export default function Files() { - const [currentPath, setCurrentPath] = useState(['我的文件']); + const initialPath = readCachedValue(getFilesLastPathCacheKey()) ?? []; + const initialCachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(initialPath))) ?? []; + const fileInputRef = useRef(null); + const [currentPath, setCurrentPath] = useState(initialPath); const [selectedFile, setSelectedFile] = useState(null); + const [currentFiles, setCurrentFiles] = useState(initialCachedFiles.map(toUiFile)); - const activeDir = currentPath[currentPath.length - 1]; - const pathKey = currentPath.join('/'); - const currentFiles = MOCK_FILES_DB[pathKey] || []; + const loadCurrentPath = async (pathParts: string[]) => { + const response = await apiRequest>( + `/files/list?path=${encodeURIComponent(toBackendPath(pathParts))}&page=0&size=100` + ); + writeCachedValue(getFilesListCacheKey(toBackendPath(pathParts)), response.items); + writeCachedValue(getFilesLastPathCacheKey(), pathParts); + setCurrentFiles(response.items.map(toUiFile)); + }; - const handleSidebarClick = (name: string) => { - setCurrentPath([name]); + useEffect(() => { + const cachedFiles = readCachedValue(getFilesListCacheKey(toBackendPath(currentPath))); + writeCachedValue(getFilesLastPathCacheKey(), currentPath); + + if (cachedFiles) { + setCurrentFiles(cachedFiles.map(toUiFile)); + } + + loadCurrentPath(currentPath).catch(() => { + if (!cachedFiles) { + setCurrentFiles([]); + } + }); + }, [currentPath]); + + const handleSidebarClick = (pathParts: string[]) => { + setCurrentPath(pathParts); setSelectedFile(null); }; @@ -79,6 +130,65 @@ export default function Files() { setSelectedFile(null); }; + const handleUploadClick = () => { + fileInputRef.current?.click(); + }; + + const handleFileChange = async (event: React.ChangeEvent) => { + const file = event.target.files?.[0]; + if (!file) { + return; + } + + const formData = new FormData(); + formData.append('file', file); + + await apiRequest(`/files/upload?path=${encodeURIComponent(toBackendPath(currentPath))}`, { + method: 'POST', + body: formData, + }); + + await loadCurrentPath(currentPath); + event.target.value = ''; + }; + + const handleCreateFolder = async () => { + const folderName = window.prompt('请输入新文件夹名称'); + if (!folderName?.trim()) { + return; + } + + const basePath = toBackendPath(currentPath).replace(/\/$/, ''); + const fullPath = `${basePath}/${folderName.trim()}` || '/'; + + await apiRequest('/files/mkdir', { + method: 'POST', + body: new URLSearchParams({ + path: fullPath.startsWith('/') ? fullPath : `/${fullPath}`, + }), + headers: { + 'Content-Type': 'application/x-www-form-urlencoded;charset=UTF-8', + }, + }); + + await loadCurrentPath(currentPath); + }; + + const handleDownload = async () => { + if (!selectedFile || selectedFile.type === 'folder') { + return; + } + + const response = await apiDownload(`/files/download/${selectedFile.id}`); + const blob = await response.blob(); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = selectedFile.name; + link.click(); + window.URL.revokeObjectURL(url); + }; + return (
{/* Left Sidebar */} @@ -89,9 +199,15 @@ export default function Files() { {QUICK_ACCESS.map((item) => ( ))} @@ -102,15 +218,15 @@ export default function Files() { {DIRECTORIES.map((item) => ( ))} @@ -123,13 +239,15 @@ export default function Files() { {/* Header / Breadcrumbs */}
- + {currentPath.map((pathItem, index) => ( - @@ -157,13 +275,13 @@ export default function Files() { {currentFiles.length > 0 ? ( currentFiles.map((file) => ( - setSelectedFile(file)} onDoubleClick={() => handleFolderDoubleClick(file)} className={cn( - "group cursor-pointer transition-colors border-b border-white/5 last:border-0", - selectedFile?.id === file.id ? "bg-[#336EFF]/10" : "hover:bg-white/[0.02]" + 'group cursor-pointer transition-colors border-b border-white/5 last:border-0', + selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]' )} > @@ -175,7 +293,7 @@ export default function Files() { ) : ( )} - + {file.name}
@@ -206,18 +324,19 @@ export default function Files() { {/* Bottom Actions */}
- - +
{/* Right Sidebar (Details) */} {selectedFile && ( -
- ${currentPath.join(' > ')}`} /> + ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
{selectedFile.type !== 'folder' && ( - )} @@ -265,7 +384,7 @@ export default function Files() { ); } -function DetailItem({ label, value }: { label: string, value: string }) { +function DetailItem({ label, value }: { label: string; value: string }) { return (

{label}

diff --git a/front/src/pages/Login.tsx b/front/src/pages/Login.tsx index 38d772f..6ac0a7f 100644 --- a/front/src/pages/Login.tsx +++ b/front/src/pages/Login.tsx @@ -1,26 +1,63 @@ import React, { useState } from 'react'; import { useNavigate } from 'react-router-dom'; import { motion } from 'motion/react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; -import { Button } from '@/src/components/ui/button'; -import { Input } from '@/src/components/ui/input'; import { LogIn, User, Lock } from 'lucide-react'; +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest, ApiError } from '@/src/lib/api'; +import { saveStoredSession } from '@/src/lib/session'; +import type { AuthResponse } from '@/src/lib/types'; + +const DEV_LOGIN_ENABLED = import.meta.env.DEV || import.meta.env.VITE_ENABLE_DEV_LOGIN === 'true'; + export default function Login() { const navigate = useNavigate(); + const [username, setUsername] = useState(''); + const [password, setPassword] = useState(''); const [loading, setLoading] = useState(false); const [error, setError] = useState(''); - const handleLogin = (e: React.FormEvent) => { + const handleLogin = async (e: React.FormEvent) => { e.preventDefault(); setLoading(true); setError(''); - // Simulate login - setTimeout(() => { + try { + let auth: AuthResponse; + + try { + auth = await apiRequest('/auth/login', { + method: 'POST', + body: { username, password }, + }); + } catch (requestError) { + if ( + DEV_LOGIN_ENABLED && + username.trim() && + requestError instanceof ApiError && + requestError.status === 401 + ) { + auth = await apiRequest( + `/auth/dev-login?username=${encodeURIComponent(username.trim())}`, + { method: 'POST' } + ); + } else { + throw requestError; + } + } + + saveStoredSession({ + token: auth.token, + user: auth.user, + }); setLoading(false); navigate('/overview'); - }, 1000); + } catch (requestError) { + setLoading(false); + setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试'); + } }; return ( @@ -41,14 +78,16 @@ export default function Login() { Access Portal
- +

YOYUZH.XYZ

- 个人网站
统一入口 + 个人网站 +
+ 统一入口

- +

欢迎来到 YOYUZH 的个人门户。在这里,你可以集中管理个人网盘文件、查询教务成绩课表,以及体验轻量级小游戏。

@@ -82,6 +121,8 @@ export default function Login() { type="text" placeholder="账号 / 用户名 / 学号" className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]" + value={username} + onChange={(event) => setUsername(event.target.value)} required />
@@ -94,6 +135,8 @@ export default function Login() { type="password" placeholder="••••••••" className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]" + value={password} + onChange={(event) => setPassword(event.target.value)} required />
diff --git a/front/src/pages/Overview.tsx b/front/src/pages/Overview.tsx index 1eb9755..3a95526 100644 --- a/front/src/pages/Overview.tsx +++ b/front/src/pages/Overview.tsx @@ -1,16 +1,76 @@ -import React from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'motion/react'; import { useNavigate } from 'react-router-dom'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; -import { Button } from '@/src/components/ui/button'; -import { - FileText, Upload, FolderPlus, Database, - GraduationCap, BookOpen, Clock, HardDrive, - User, Mail, ChevronRight +import { + FileText, + Upload, + FolderPlus, + Database, + GraduationCap, + BookOpen, + Clock, + User, + Mail, + ChevronRight, } from 'lucide-react'; +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getOverviewCacheKey, getSchoolResultsCacheKey, readStoredSchoolQuery } from '@/src/lib/page-cache'; +import { readStoredSession } from '@/src/lib/session'; +import type { CourseResponse, FileMetadata, GradeResponse, PageResponse, UserProfile } from '@/src/lib/types'; + +function formatFileSize(size: number) { + if (size <= 0) { + return '0 B'; + } + + const units = ['B', 'KB', 'MB', 'GB']; + const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1); + const value = size / 1024 ** index; + return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`; +} + +function formatRecentTime(value: string) { + const date = new Date(value); + const diffHours = Math.floor((Date.now() - date.getTime()) / (1000 * 60 * 60)); + if (diffHours < 24) { + return `${Math.max(diffHours, 0)}小时前`; + } + + return new Intl.DateTimeFormat('zh-CN', { + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + export default function Overview() { const navigate = useNavigate(); + const storedSchoolQuery = readStoredSchoolQuery(); + const cachedSchoolResults = + storedSchoolQuery?.studentId && storedSchoolQuery?.semester + ? readCachedValue<{ + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(storedSchoolQuery.studentId, storedSchoolQuery.semester)) + : null; + const cachedOverview = readCachedValue<{ + profile: UserProfile | null; + recentFiles: FileMetadata[]; + rootFiles: FileMetadata[]; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getOverviewCacheKey()); + const [profile, setProfile] = useState(cachedOverview?.profile ?? readStoredSession()?.user ?? null); + const [recentFiles, setRecentFiles] = useState(cachedOverview?.recentFiles ?? []); + const [rootFiles, setRootFiles] = useState(cachedOverview?.rootFiles ?? []); + const [schedule, setSchedule] = useState(cachedOverview?.schedule ?? cachedSchoolResults?.schedule ?? []); + const [grades, setGrades] = useState(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []); + const currentHour = new Date().getHours(); let greeting = '晚上好'; if (currentHour < 6) greeting = '凌晨好'; @@ -18,18 +78,106 @@ export default function Overview() { else if (currentHour < 18) greeting = '下午好'; const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); + const recentWeekUploads = recentFiles.filter( + (file) => Date.now() - new Date(file.createdAt).getTime() <= 7 * 24 * 60 * 60 * 1000 + ).length; + const usedBytes = useMemo( + () => rootFiles.filter((file) => !file.directory).reduce((sum, file) => sum + file.size, 0), + [rootFiles] + ); + const usedGb = usedBytes / 1024 / 1024 / 1024; + const storagePercent = Math.min((usedGb / 50) * 100, 100); + + useEffect(() => { + let cancelled = false; + + async function loadOverview() { + try { + const [user, filesRecent, filesRoot] = await Promise.all([ + apiRequest('/user/profile'), + apiRequest('/files/recent'), + apiRequest>('/files/list?path=%2F&page=0&size=100'), + ]); + + if (cancelled) { + return; + } + + setProfile(user); + setRecentFiles(filesRecent); + setRootFiles(filesRoot.items); + + const schoolQuery = readStoredSchoolQuery(); + if (!schoolQuery?.studentId || !schoolQuery?.semester) { + writeCachedValue(getOverviewCacheKey(), { + profile: user, + recentFiles: filesRecent, + rootFiles: filesRoot.items, + schedule: [], + grades: [], + }); + return; + } + + const queryString = new URLSearchParams({ + studentId: schoolQuery.studentId, + semester: schoolQuery.semester, + }).toString(); + + const [scheduleData, gradesData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); + + if (!cancelled) { + setSchedule(scheduleData); + setGrades(gradesData); + writeCachedValue(getOverviewCacheKey(), { + profile: user, + recentFiles: filesRecent, + rootFiles: filesRoot.items, + schedule: scheduleData, + grades: gradesData, + }); + } + } catch { + const schoolQuery = readStoredSchoolQuery(); + if (!cancelled && schoolQuery?.studentId && schoolQuery?.semester) { + const cachedSchoolResults = readCachedValue<{ + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(schoolQuery.studentId, schoolQuery.semester)); + + if (cachedSchoolResults) { + setSchedule(cachedSchoolResults.schedule); + setGrades(cachedSchoolResults.grades); + } + } + } + } + + loadOverview(); + return () => { + cancelled = true; + }; + }, []); + + const latestSemester = grades[0]?.semester ?? '--'; + const previewCourses = schedule.slice(0, 3); return (
{/* Hero Section */} -
-

欢迎回来,tester5595

+

+ 欢迎回来,{profile?.username ?? '访客'} +

现在时间 {currentTime} · {greeting}

这是您的个人门户总览。在这里您可以快速查看网盘文件状态、近期课程安排以及教务成绩摘要。 @@ -39,10 +187,28 @@ export default function Overview() { {/* Metrics Cards */}

- - - - + + + 0 ? `当前已同步 ${schedule.length} 节课` : '请先前往教务页查询'} + icon={BookOpen} + delay={0.3} + /> +
@@ -58,24 +224,25 @@ export default function Overview() {
- {[ - { name: '软件工程期末复习资料.pdf', size: '2.4 MB', time: '2小时前' }, - { name: '2025春季学期课表.xlsx', size: '156 KB', time: '昨天 14:30' }, - { name: '项目架构设计图.png', size: '4.1 MB', time: '3天前' }, - ].map((file, i) => ( + {recentFiles.slice(0, 3).map((file, i) => (
navigate('/files')}>
-

{file.name}

-

{file.time}

+

{file.filename}

+

{formatRecentTime(file.createdAt)}

- {file.size} + {formatFileSize(file.size)}
))} + {recentFiles.length === 0 && ( +
+ 暂无最近文件 +
+ )}
@@ -91,21 +258,24 @@ export default function Overview() {
- {[ - { time: '08:00 - 09:35', name: '高等数学 (下)', room: '教1-204' }, - { time: '10:00 - 11:35', name: '大学物理', room: '教2-101' }, - { time: '14:00 - 15:35', name: '软件工程', room: '计科楼 302' }, - ].map((course, i) => ( + {previewCourses.map((course, i) => (
-
{course.time}
+
+ 第 {course.startTime ?? '--'} - {course.endTime ?? '--'} 节 +
-

{course.name}

+

{course.courseName}

- {course.room} + {course.classroom ?? '教室待定'}

))} + {previewCourses.length === 0 && ( +
+ 暂无课程数据,请先前往教务页查询 +
+ )}
@@ -136,13 +306,15 @@ export default function Overview() {
-

12.6 GB

+

+ {usedGb.toFixed(2)} GB +

已使用 / 共 50 GB

- 25% + {storagePercent.toFixed(1)}%
-
+
@@ -155,11 +327,11 @@ export default function Overview() {
- T + {(profile?.username?.[0] ?? 'T').toUpperCase()}
-

tester5595

-

tester5595@example.com

+

{profile?.username ?? '未登录'}

+

{profile?.email ?? '暂无邮箱'}

diff --git a/front/src/pages/School.tsx b/front/src/pages/School.tsx index cb42b95..c7bb25e 100644 --- a/front/src/pages/School.tsx +++ b/front/src/pages/School.tsx @@ -1,23 +1,119 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { motion } from 'motion/react'; -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; -import { Button } from '@/src/components/ui/button'; -import { Input } from '@/src/components/ui/input'; import { GraduationCap, Calendar, User, Lock, Search, BookOpen, ChevronRight, Award } from 'lucide-react'; + +import { Button } from '@/src/components/ui/button'; +import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card'; +import { Input } from '@/src/components/ui/input'; +import { apiRequest } from '@/src/lib/api'; +import { readCachedValue, writeCachedValue } from '@/src/lib/cache'; +import { getSchoolResultsCacheKey, readStoredSchoolQuery, writeStoredSchoolQuery } from '@/src/lib/page-cache'; +import type { CourseResponse, GradeResponse } from '@/src/lib/types'; import { cn } from '@/src/lib/utils'; -export default function School() { - const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); - const [loading, setLoading] = useState(false); - const [queried, setQueried] = useState(false); +function formatSections(startTime?: number | null, endTime?: number | null) { + if (!startTime || !endTime) { + return '节次待定'; + } + + return `第 ${startTime}-${endTime} 节`; +} + +export default function School() { + const storedQuery = readStoredSchoolQuery(); + const initialStudentId = storedQuery?.studentId ?? '2023123456'; + const initialSemester = storedQuery?.semester ?? '2025-spring'; + const initialCachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(getSchoolResultsCacheKey(initialStudentId, initialSemester)); + const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); + const [studentId, setStudentId] = useState(initialStudentId); + const [password, setPassword] = useState('password123'); + const [semester, setSemester] = useState(initialSemester); + const [loading, setLoading] = useState(false); + const [queried, setQueried] = useState(initialCachedResults?.queried ?? false); + const [schedule, setSchedule] = useState(initialCachedResults?.schedule ?? []); + const [grades, setGrades] = useState(initialCachedResults?.grades ?? []); + + const averageGrade = useMemo(() => { + if (grades.length === 0) { + return '0.0'; + } + + const sum = grades.reduce((total, item) => total + (item.grade ?? 0), 0); + return (sum / grades.length).toFixed(1); + }, [grades]); + + const loadSchoolData = async ( + nextStudentId: string, + nextSemester: string, + options: { background?: boolean } = {} + ) => { + const cacheKey = getSchoolResultsCacheKey(nextStudentId, nextSemester); + const cachedResults = readCachedValue<{ + queried: boolean; + schedule: CourseResponse[]; + grades: GradeResponse[]; + }>(cacheKey); + + if (!options.background) { + setLoading(true); + } + + writeStoredSchoolQuery({ + studentId: nextStudentId, + semester: nextSemester, + }); + + try { + const queryString = new URLSearchParams({ + studentId: nextStudentId, + semester: nextSemester, + }).toString(); + + const [scheduleData, gradeData] = await Promise.all([ + apiRequest(`/cqu/schedule?${queryString}`), + apiRequest(`/cqu/grades?${queryString}`), + ]); - const handleQuery = (e: React.FormEvent) => { - e.preventDefault(); - setLoading(true); - setTimeout(() => { - setLoading(false); setQueried(true); - }, 1500); + setSchedule(scheduleData); + setGrades(gradeData); + writeCachedValue(cacheKey, { + queried: true, + studentId: nextStudentId, + semester: nextSemester, + schedule: scheduleData, + grades: gradeData, + }); + } catch { + if (!cachedResults) { + setQueried(false); + setSchedule([]); + setGrades([]); + } + } finally { + if (!options.background) { + setLoading(false); + } + } + }; + + useEffect(() => { + if (!storedQuery) { + return; + } + + loadSchoolData(storedQuery.studentId, storedQuery.semester, { + background: true, + }).catch(() => undefined); + }, []); + + const handleQuery = async (e: React.FormEvent) => { + e.preventDefault(); + await loadSchoolData(studentId, semester); }; return ( @@ -38,19 +134,19 @@ export default function School() {
- + setStudentId(event.target.value)} className="pl-9 bg-black/20" required />
- + setPassword(event.target.value)} className="pl-9 bg-black/20" required />
- setSemester(event.target.value)} className="flex h-11 w-full rounded-xl border border-white/10 bg-black/20 px-3 py-2 text-sm text-white focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF]"> @@ -81,9 +177,9 @@ export default function School() { {queried ? (
- - - + + +
) : (
@@ -100,8 +196,8 @@ export default function School() {
); @@ -165,7 +261,7 @@ function SummaryItem({ label, value, icon: Icon }: any) { ); } -function ScheduleView({ queried }: { queried: boolean }) { +function ScheduleView({ queried, schedule }: { queried: boolean; schedule: CourseResponse[] }) { if (!queried) { return ( @@ -178,14 +274,6 @@ function ScheduleView({ queried }: { queried: boolean }) { } const days = ['周一', '周二', '周三', '周四', '周五']; - const mockSchedule = [ - { day: 0, time: '08:00 - 09:35', name: '高等数学 (下)', room: '教1-204' }, - { day: 0, time: '10:00 - 11:35', name: '大学物理', room: '教2-101' }, - { day: 1, time: '14:00 - 15:35', name: '软件工程', room: '计科楼 302' }, - { day: 2, time: '08:00 - 09:35', name: '数据结构', room: '教1-105' }, - { day: 3, time: '16:00 - 17:35', name: '计算机网络', room: '计科楼 401' }, - { day: 4, time: '10:00 - 11:35', name: '操作系统', room: '教3-202' }, - ]; return ( @@ -194,36 +282,39 @@ function ScheduleView({ queried }: { queried: boolean }) {
- {days.map((day, index) => ( -
-
- {day} + {days.map((day, index) => { + const dayCourses = schedule.filter((item) => (item.dayOfWeek ?? 0) - 1 === index); + return ( +
+
+ {day} +
+
+ {dayCourses.map((course, i) => ( +
+

{formatSections(course.startTime, course.endTime)}

+

{course.courseName}

+

+ {course.classroom ?? '教室待定'} +

+
+ ))} + {dayCourses.length === 0 && ( +
+ 无课程 +
+ )} +
-
- {mockSchedule.filter(s => s.day === index).map((course, i) => ( -
-

{course.time}

-

{course.name}

-

- {course.room} -

-
- ))} - {mockSchedule.filter(s => s.day === index).length === 0 && ( -
- 无课程 -
- )} -
-
- ))} + ); + })}
); } -function GradesView({ queried }: { queried: boolean }) { +function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) { if (!queried) { return ( @@ -235,20 +326,14 @@ function GradesView({ queried }: { queried: boolean }) { ); } - const terms = [ - { - name: '2024 秋', - grades: [75, 78, 80, 83, 85, 88, 89, 96] - }, - { - name: '2025 春', - grades: [70, 78, 82, 84, 85, 85, 86, 88, 93] - }, - { - name: '2025 秋', - grades: [68, 70, 76, 80, 85, 86, 90, 94, 97] + const terms = grades.reduce>((accumulator, grade) => { + const semester = grade.semester ?? '未分类'; + if (!accumulator[semester]) { + accumulator[semester] = []; } - ]; + accumulator[semester].push(grade.grade ?? 0); + return accumulator; + }, {}); const getScoreStyle = (score: number) => { if (score >= 95) return 'bg-[#336EFF]/50 text-white'; @@ -266,15 +351,15 @@ function GradesView({ queried }: { queried: boolean }) {
- {terms.map((term, i) => ( + {Object.entries(terms).map(([term, scores], i) => (
-

{term.name}

+

{term}

- {term.grades.map((score, j) => ( -
( +
@@ -284,6 +369,7 @@ function GradesView({ queried }: { queried: boolean }) {
))} + {Object.keys(terms).length === 0 &&
暂无成绩数据
}
diff --git a/front/src/vite-env.d.ts b/front/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/front/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/front/vite.config.ts b/front/vite.config.ts index 0506f1b..f8c36a3 100644 --- a/front/vite.config.ts +++ b/front/vite.config.ts @@ -1,10 +1,12 @@ import tailwindcss from '@tailwindcss/vite'; import react from '@vitejs/plugin-react'; import path from 'path'; -import {defineConfig, loadEnv} from 'vite'; +import { defineConfig, loadEnv } from 'vite'; -export default defineConfig(({mode}) => { +export default defineConfig(({ mode }) => { const env = loadEnv(mode, '.', ''); + const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8080'; + return { plugins: [react(), tailwindcss()], define: { @@ -16,9 +18,13 @@ export default defineConfig(({mode}) => { }, }, server: { - // HMR is disabled in AI Studio via DISABLE_HMR env var. - // Do not modify—file watching is disabled to prevent flickering during agent edits. hmr: process.env.DISABLE_HMR !== 'true', + proxy: { + '/api': { + target: backendUrl, + changeOrigin: true, + }, + }, }, }; }); diff --git a/scripts/start-backend-dev.ps1 b/scripts/start-backend-dev.ps1 index 0e42d90..8727883 100644 --- a/scripts/start-backend-dev.ps1 +++ b/scripts/start-backend-dev.ps1 @@ -1,7 +1,7 @@ $ErrorActionPreference = 'Stop' $root = Split-Path -Parent $PSScriptRoot -$javaExe = 'C:\Program Files\Java\jdk-22\bin\java.exe' +$mavenExe = 'mvn.cmd' $out = Join-Path $root 'backend-dev.out.log' $err = Join-Path $root 'backend-dev.err.log' @@ -13,9 +13,9 @@ if (Test-Path $err) { } $proc = Start-Process ` - -FilePath $javaExe ` - -ArgumentList '-jar', 'backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar', '--spring.profiles.active=dev' ` - -WorkingDirectory $root ` + -FilePath $mavenExe ` + -ArgumentList 'spring-boot:run', '-Dspring-boot.run.profiles=dev' ` + -WorkingDirectory (Join-Path $root 'backend') ` -PassThru ` -RedirectStandardOutput $out ` -RedirectStandardError $err diff --git a/开发测试账号.md b/开发测试账号.md new file mode 100644 index 0000000..a0c463a --- /dev/null +++ b/开发测试账号.md @@ -0,0 +1,18 @@ +# 开发测试账号 + +以下账号会在后端以 `dev` profile 启动时自动初始化。 + +## 门户账号 + +| 门户用户名 | 门户密码 | 教务学号 | 教务密码 | 查询学期 | 网盘示例文件 | +| --- | --- | --- | --- | --- | --- | +| `portal-demo` | `portal123456` | `2023123456` | `portal123456` | `2025-spring` | `迎新资料.txt`、`课程规划.md`、`campus-shot.png` | +| `portal-study` | `study123456` | `2022456789` | `study123456` | `2024-fall` | `实验数据.csv`、`论文草稿.md`、`data-chart.png` | +| `portal-design` | `design123456` | `2021789012` | `design123456` | `2024-spring` | `素材清单.txt`、`作品说明.md`、`ui-mockup.png` | + +## 使用说明 + +- 先用上表中的“门户用户名 / 门户密码”登录站点。 +- 登录后进入网盘页,每个用户都会看到自己的 `下载 / 文档 / 图片` 目录,以及各自不同的样例文件。 +- 进入教务页后,填入对应的“教务学号 / 教务密码 / 查询学期”即可看到该用户对应的 mock 教务数据。 +- 当前开发环境的教务密码字段仅用于前端占位,后端主要依据登录态、学号和学期返回该用户的 mock 数据。为避免混淆,直接填表中的教务密码即可。