first runnable version

This commit is contained in:
yoyuzh
2026-03-14 12:28:46 +08:00
parent 8db2fa2aab
commit 6cff15f8dc
35 changed files with 2118 additions and 256 deletions

View File

@@ -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("用户名或密码错误");
}
}

View File

@@ -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("用户名或密码错误");
}
}

View File

@@ -1,6 +1,7 @@
package com.yoyuzh; package com.yoyuzh;
import com.yoyuzh.config.CquApiProperties; import com.yoyuzh.config.CquApiProperties;
import com.yoyuzh.config.CorsProperties;
import com.yoyuzh.config.FileStorageProperties; import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.config.JwtProperties; import com.yoyuzh.config.JwtProperties;
import org.springframework.boot.SpringApplication; import org.springframework.boot.SpringApplication;
@@ -11,7 +12,8 @@ import org.springframework.boot.context.properties.EnableConfigurationProperties
@EnableConfigurationProperties({ @EnableConfigurationProperties({
JwtProperties.class, JwtProperties.class,
FileStorageProperties.class, FileStorageProperties.class,
CquApiProperties.class CquApiProperties.class,
CorsProperties.class
}) })
public class PortalBackendApplication { public class PortalBackendApplication {

View File

@@ -6,6 +6,7 @@ import com.yoyuzh.auth.dto.RegisterRequest;
import com.yoyuzh.auth.dto.UserProfileResponse; import com.yoyuzh.auth.dto.UserProfileResponse;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode; import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.files.FileService;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.AuthenticationManager; import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException; import org.springframework.security.authentication.BadCredentialsException;
@@ -22,6 +23,7 @@ public class AuthService {
private final PasswordEncoder passwordEncoder; private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager; private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider; private final JwtTokenProvider jwtTokenProvider;
private final FileService fileService;
@Transactional @Transactional
public AuthResponse register(RegisterRequest request) { public AuthResponse register(RegisterRequest request) {
@@ -37,6 +39,7 @@ public class AuthService {
user.setEmail(request.email()); user.setEmail(request.email());
user.setPasswordHash(passwordEncoder.encode(request.password())); user.setPasswordHash(passwordEncoder.encode(request.password()));
User saved = userRepository.save(user); User saved = userRepository.save(user);
fileService.ensureDefaultDirectories(saved);
return new AuthResponse(jwtTokenProvider.generateToken(saved.getId(), saved.getUsername()), toProfile(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()) User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在")); .orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
fileService.ensureDefaultDirectories(user);
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user)); return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
} }
@@ -68,6 +72,7 @@ public class AuthService {
created.setPasswordHash(passwordEncoder.encode("1")); created.setPasswordHash(passwordEncoder.encode("1"));
return userRepository.save(created); return userRepository.save(created);
}); });
fileService.ensureDefaultDirectories(user);
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user)); return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
} }

View File

@@ -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<DemoUserSpec> 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<DemoFileSpec> 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<DemoFileSpec> files
) {
}
private record DemoFileSpec(
String path,
String filename,
String contentType,
String content
) {
}
}

View File

@@ -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<String> allowedOrigins = new ArrayList<>(List.of(
"http://localhost:3000",
"http://127.0.0.1:3000"
));
public List<String> getAllowedOrigins() {
return allowedOrigins;
}
public void setAllowedOrigins(List<String> allowedOrigins) {
this.allowedOrigins = allowedOrigins;
}
}

View File

@@ -21,6 +21,11 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; 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 @Configuration
@EnableWebSecurity @EnableWebSecurity
@@ -31,6 +36,7 @@ public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter; private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsService userDetailsService; private final CustomUserDetailsService userDetailsService;
private final ObjectMapper objectMapper; private final ObjectMapper objectMapper;
private final CorsProperties corsProperties;
@Bean @Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
@@ -41,7 +47,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth .authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html") .requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll() .permitAll()
.requestMatchers("/api/files/**", "/api/user/**") .requestMatchers("/api/files/**", "/api/user/**", "/api/cqu/**")
.authenticated() .authenticated()
.anyRequest() .anyRequest()
.permitAll()) .permitAll())
@@ -80,4 +86,18 @@ public class SecurityConfig {
public PasswordEncoder passwordEncoder() { public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(); 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;
}
} }

View File

@@ -20,6 +20,7 @@ public class CquDataService {
private final GradeRepository gradeRepository; private final GradeRepository gradeRepository;
private final CquApiProperties cquApiProperties; private final CquApiProperties cquApiProperties;
@Transactional
public List<CourseResponse> getSchedule(User user, String semester, String studentId) { public List<CourseResponse> getSchedule(User user, String semester, String studentId) {
requireLoginIfNecessary(user); requireLoginIfNecessary(user);
List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream() List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream()
@@ -42,6 +43,7 @@ public class CquDataService {
return responses; return responses;
} }
@Transactional
public List<GradeResponse> getGrades(User user, String semester, String studentId) { public List<GradeResponse> getGrades(User user, String semester, String studentId) {
requireLoginIfNecessary(user); requireLoginIfNecessary(user);
List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream() List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream()

View File

@@ -9,60 +9,101 @@ public final class CquMockDataFactory {
} }
public static List<Map<String, Object>> createSchedule(String semester, String studentId) { public static List<Map<String, Object>> createSchedule(String semester, String studentId) {
return List.of( StudentProfile profile = StudentProfile.fromStudentId(studentId);
Map.of( return profile.schedule().stream()
.map(item -> Map.<String, Object>of(
"studentId", studentId, "studentId", studentId,
"semester", semester, "semester", semester,
"courseName", "高级 Java 程序设计", "courseName", item.courseName(),
"teacher", "李老师", "teacher", item.teacher(),
"classroom", "D1131", "classroom", item.classroom(),
"dayOfWeek", 1, "dayOfWeek", item.dayOfWeek(),
"startTime", 1, "startTime", item.startTime(),
"endTime", 2 "endTime", item.endTime()
), ))
Map.of( .toList();
"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
)
);
} }
public static List<Map<String, Object>> createGrades(String semester, String studentId) { public static List<Map<String, Object>> createGrades(String semester, String studentId) {
return List.of( StudentProfile profile = StudentProfile.fromStudentId(studentId);
Map.of( return profile.grades().stream()
.map(item -> Map.<String, Object>of(
"studentId", studentId, "studentId", studentId,
"semester", semester, "semester", semester,
"courseName", "高级 Java 程序设计", "courseName", item.courseName(),
"grade", 92.0 "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<ScheduleEntry> schedule,
List<GradeEntry> 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)
), ),
Map.of( List.of(
"studentId", studentId, new GradeEntry("高级 Java 程序设计", 92.0),
"semester", semester, new GradeEntry("计算机网络", 88.5),
"courseName", "计算机网络", new GradeEntry("软件工程", 90.0)
"grade", 88.5
),
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "软件工程",
"grade", 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)
)
);
};
}
} }
} }

View File

@@ -25,6 +25,7 @@ import java.util.List;
@Service @Service
public class FileService { public class FileService {
private static final List<String> DEFAULT_DIRECTORIES = List.of("下载", "文档", "图片");
private final StoredFileRepository storedFileRepository; private final StoredFileRepository storedFileRepository;
private final Path rootPath; private final Path rootPath;
@@ -118,6 +119,31 @@ public class FileService {
.toList(); .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 @Transactional
public void delete(User user, Long fileId) { public void delete(User user, Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId) StoredFile storedFile = storedFileRepository.findById(fileId)

View File

@@ -30,8 +30,12 @@ app:
max-file-size: 52428800 max-file-size: 52428800
cqu: cqu:
base-url: https://example-cqu-api.local base-url: https://example-cqu-api.local
require-login: false require-login: true
mock-enabled: false mock-enabled: false
cors:
allowed-origins:
- http://localhost:3000
- http://127.0.0.1:3000
springdoc: springdoc:
swagger-ui: swagger-ui:

View File

@@ -4,6 +4,7 @@ import com.yoyuzh.auth.dto.AuthResponse;
import com.yoyuzh.auth.dto.LoginRequest; import com.yoyuzh.auth.dto.LoginRequest;
import com.yoyuzh.auth.dto.RegisterRequest; import com.yoyuzh.auth.dto.RegisterRequest;
import com.yoyuzh.common.BusinessException; import com.yoyuzh.common.BusinessException;
import com.yoyuzh.files.FileService;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks; import org.mockito.InjectMocks;
@@ -38,6 +39,9 @@ class AuthServiceTest {
@Mock @Mock
private JwtTokenProvider jwtTokenProvider; private JwtTokenProvider jwtTokenProvider;
@Mock
private FileService fileService;
@InjectMocks @InjectMocks
private AuthService authService; private AuthService authService;
@@ -60,6 +64,7 @@ class AuthServiceTest {
assertThat(response.token()).isEqualTo("jwt-token"); assertThat(response.token()).isEqualTo("jwt-token");
assertThat(response.user().username()).isEqualTo("alice"); assertThat(response.user().username()).isEqualTo("alice");
verify(passwordEncoder).encode("plain-password"); verify(passwordEncoder).encode("plain-password");
verify(fileService).ensureDefaultDirectories(any(User.class));
} }
@Test @Test
@@ -90,6 +95,7 @@ class AuthServiceTest {
new UsernamePasswordAuthenticationToken("alice", "plain-password")); new UsernamePasswordAuthenticationToken("alice", "plain-password"));
assertThat(response.token()).isEqualTo("jwt-token"); assertThat(response.token()).isEqualTo("jwt-token");
assertThat(response.user().email()).isEqualTo("alice@example.com"); assertThat(response.user().email()).isEqualTo("alice@example.com");
verify(fileService).ensureDefaultDirectories(user);
} }
@Test @Test
@@ -102,4 +108,22 @@ class AuthServiceTest {
.isInstanceOf(BusinessException.class) .isInstanceOf(BusinessException.class)
.hasMessageContaining("用户名或密码错误"); .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));
}
} }

View File

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

View File

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

View File

@@ -26,4 +26,17 @@ class CquMockDataFactoryTest {
assertThat(result.get(0)).containsEntry("studentId", "20230001"); assertThat(result.get(0)).containsEntry("studentId", "20230001");
assertThat(result.get(0)).containsKey("grade"); assertThat(result.get(0)).containsKey("grade");
} }
@Test
void shouldReturnDifferentMockDataForDifferentStudents() {
List<Map<String, Object>> firstSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2023123456");
List<Map<String, Object>> secondSchedule = CquMockDataFactory.createSchedule("2025-2026-1", "2022456789");
List<Map<String, Object>> firstGrades = CquMockDataFactory.createGrades("2025-2026-1", "2023123456");
List<Map<String, Object>> 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());
}
} }

View File

@@ -7,7 +7,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir; import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl; 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.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.times;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class) @ExtendWith(MockitoExtension.class)
@@ -89,6 +90,25 @@ class FileServiceTest {
assertThat(result.items().get(0).filename()).isEqualTo("notes.txt"); 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) { private User createUser(Long id) {
User user = new User(); User user = new User();
user.setId(id); user.setId(id);

View File

@@ -7,3 +7,12 @@ GEMINI_API_KEY="MY_GEMINI_API_KEY"
# AI Studio automatically injects this at runtime with the Cloud Run service URL. # AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints. # Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL" 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"

View File

@@ -8,7 +8,8 @@
"build": "vite build", "build": "vite build",
"preview": "vite preview", "preview": "vite preview",
"clean": "rm -rf dist", "clean": "rm -rf dist",
"lint": "tsc --noEmit" "lint": "tsc --noEmit",
"test": "node --import tsx --test src/**/*.test.ts"
}, },
"dependencies": { "dependencies": {
"@google/genai": "^1.29.0", "@google/genai": "^1.29.0",

View File

@@ -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<void>;
devLogin: (username?: string) => Promise<void>;
logout: () => void;
refreshProfile: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
function buildSession(auth: AuthResponse): AuthSession {
return {
token: auth.token,
user: auth.user,
};
}
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<AuthSession | null>(() => 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<UserProfile>('/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<UserProfile>('/user/profile');
const nextSession = {
...currentSession,
user,
};
saveStoredSession(nextSession);
setSession(nextSession);
}
async function login(payload: LoginPayload) {
const auth = await apiRequest<AuthResponse>('/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<AuthResponse>(
`/auth/dev-login${params.size ? `?${params.toString()}` : ''}`,
{
method: 'POST',
},
);
const nextSession = buildSession(auth);
saveStoredSession(nextSession);
setSession(nextSession);
}
function logout() {
clearStoredSession();
setSession(null);
}
return (
<AuthContext.Provider
value={{
ready,
session,
user: session?.user || null,
login,
devLogin,
logout,
refreshProfile,
}}
>
{children}
</AuthContext.Provider>
);
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}

View File

@@ -1,8 +1,10 @@
import React from 'react'; import React from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom'; import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { cn } from '@/src/lib/utils';
import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react'; import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react';
import { clearStoredSession } from '@/src/lib/session';
import { cn } from '@/src/lib/utils';
const NAV_ITEMS = [ const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard }, { name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen }, { name: '网盘', path: '/files', icon: FolderOpen },
@@ -14,6 +16,7 @@ export function Layout() {
const navigate = useNavigate(); const navigate = useNavigate();
const handleLogout = () => { const handleLogout = () => {
clearStoredSession();
navigate('/login'); navigate('/login');
}; };
@@ -88,4 +91,3 @@ export function Layout() {
</div> </div>
); );
} }

111
front/src/lib/api.test.ts Normal file
View File

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

126
front/src/lib/api.ts Normal file
View File

@@ -0,0 +1,126 @@
import { clearStoredSession, readStoredSession } from './session';
interface ApiEnvelope<T> {
code: number;
msg: string;
data: T;
}
interface ApiRequestInit extends Omit<RequestInit, 'body'> {
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<null>;
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<T>(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<T>;
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;
}

View File

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

61
front/src/lib/cache.ts Normal file
View File

@@ -0,0 +1,61 @@
import { readStoredSession } from './session';
interface CacheEnvelope<T> {
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<string | number>) {
const normalizedParts = parts.map((part) => String(part).replace(/:/g, '_'));
return [CACHE_PREFIX, getCacheScope(), namespace, ...normalizedParts].join(':');
}
export function readCachedValue<T>(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<T>;
return parsed.value;
} catch {
localStorage.removeItem(key);
return null;
}
}
export function writeCachedValue<T>(key: string, value: T) {
if (typeof localStorage === 'undefined') {
return;
}
const payload: CacheEnvelope<T> = {
value,
updatedAt: Date.now(),
};
localStorage.setItem(key, JSON.stringify(payload));
}
export function removeCachedValue(key: string) {
if (typeof localStorage === 'undefined') {
return;
}
localStorage.removeItem(key);
}

View File

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

46
front/src/lib/session.ts Normal file
View File

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

48
front/src/lib/types.ts Normal file
View File

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

View File

@@ -1,69 +1,120 @@
import React, { useState } from 'react'; import React, { useEffect, useRef, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { import {
Folder, FileText, Image as ImageIcon, Download, Monitor, Folder,
Star, ChevronRight, Upload, Plus, LayoutGrid, List, File, FileText,
MoreVertical Image as ImageIcon,
Download,
Monitor,
ChevronRight,
Upload,
Plus,
LayoutGrid,
List,
MoreVertical,
} from 'lucide-react'; } 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'; import { cn } from '@/src/lib/utils';
const QUICK_ACCESS = [ const QUICK_ACCESS = [
{ name: '桌面', icon: Monitor }, { name: '桌面', icon: Monitor, path: [] as string[] },
{ name: '下载', icon: Download }, { name: '下载', icon: Download, path: ['下载'] },
{ name: '文档', icon: FileText }, { name: '文档', icon: FileText, path: ['文档'] },
{ name: '图片', icon: ImageIcon }, { name: '图片', icon: ImageIcon, path: ['图片'] },
]; ];
const DIRECTORIES = [ const DIRECTORIES = [
{ name: '我的文件', icon: Folder }, { name: '下载', icon: Folder },
{ name: '课程资料', icon: Folder }, { name: '文档', icon: Folder },
{ name: '项目归档', icon: Folder }, { name: '图片', icon: Folder },
{ name: '收藏夹', icon: Star },
]; ];
const MOCK_FILES_DB: Record<string, any[]> = { function toBackendPath(pathParts: string[]) {
'我的文件': [ return pathParts.length === 0 ? '/' : `/${pathParts.join('/')}`;
{ 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' }, function formatFileSize(size: number) {
{ id: 4, name: '实验报告模板.docx', type: 'word', size: '45 KB', modified: '2025-03-05 10:20' }, if (size <= 0) {
{ id: 5, name: '前端学习笔记', type: 'folder', size: '—', modified: '2025-03-10 11:00' }, return '—';
], }
'课程资料': [
{ id: 6, name: '高等数学', type: 'folder', size: '—', modified: '2025-02-20 10:00' }, const units = ['B', 'KB', 'MB', 'GB'];
{ id: 7, name: '大学物理', type: 'folder', size: '—', modified: '2025-02-21 11:00' }, const index = Math.min(Math.floor(Math.log(size) / Math.log(1024)), units.length - 1);
{ id: 8, name: '软件工程', type: 'folder', size: '—', modified: '2025-02-22 14:00' }, const value = size / 1024 ** index;
], return `${value.toFixed(value >= 10 || index === 0 ? 0 : 1)} ${units[index]}`;
'项目归档': [ }
{ 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' }, function formatDateTime(value: string) {
], return new Intl.DateTimeFormat('zh-CN', {
'收藏夹': [ year: 'numeric',
{ id: 11, name: '常用工具网站.txt', type: 'document', size: '2 KB', modified: '2025-03-01 10:00' }, month: '2-digit',
], day: '2-digit',
'我的文件/前端学习笔记': [ hour: '2-digit',
{ id: 12, name: 'React Hooks 详解.md', type: 'document', size: '12 KB', modified: '2025-03-08 09:00' }, minute: '2-digit',
{ id: 13, name: 'Tailwind 技巧.md', type: 'document', size: '8 KB', modified: '2025-03-09 14:20' }, }).format(new Date(value));
{ id: 14, name: '示例代码', type: 'folder', size: '—', modified: '2025-03-10 10:00' }, }
],
'课程资料/软件工程': [ function toUiFile(file: FileMetadata) {
{ id: 15, name: '需求规格说明书.pdf', type: 'pdf', size: '1.2 MB', modified: '2025-03-05 16:00' }, const extension = file.filename.includes('.') ? file.filename.split('.').pop()?.toLowerCase() : '';
{ id: 16, name: '系统设计文档.docx', type: 'word', size: '850 KB', modified: '2025-03-06 11:30' }, 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() { export default function Files() {
const [currentPath, setCurrentPath] = useState<string[]>(['我的文件']); const initialPath = readCachedValue<string[]>(getFilesLastPathCacheKey()) ?? [];
const initialCachedFiles = readCachedValue<FileMetadata[]>(getFilesListCacheKey(toBackendPath(initialPath))) ?? [];
const fileInputRef = useRef<HTMLInputElement | null>(null);
const [currentPath, setCurrentPath] = useState<string[]>(initialPath);
const [selectedFile, setSelectedFile] = useState<any | null>(null); const [selectedFile, setSelectedFile] = useState<any | null>(null);
const [currentFiles, setCurrentFiles] = useState<any[]>(initialCachedFiles.map(toUiFile));
const activeDir = currentPath[currentPath.length - 1]; const loadCurrentPath = async (pathParts: string[]) => {
const pathKey = currentPath.join('/'); const response = await apiRequest<PageResponse<FileMetadata>>(
const currentFiles = MOCK_FILES_DB[pathKey] || []; `/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) => { useEffect(() => {
setCurrentPath([name]); const cachedFiles = readCachedValue<FileMetadata[]>(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); setSelectedFile(null);
}; };
@@ -79,6 +130,65 @@ export default function Files() {
setSelectedFile(null); setSelectedFile(null);
}; };
const handleUploadClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = async (event: React.ChangeEvent<HTMLInputElement>) => {
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 ( return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]"> <div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */} {/* Left Sidebar */}
@@ -89,9 +199,15 @@ export default function Files() {
{QUICK_ACCESS.map((item) => ( {QUICK_ACCESS.map((item) => (
<button <button
key={item.name} key={item.name}
className="w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium text-slate-300 hover:text-white hover:bg-white/5 transition-colors" onClick={() => handleSidebarClick(item.path)}
className={cn(
'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
currentPath.join('/') === item.path.join('/')
? 'bg-[#336EFF]/20 text-[#336EFF]'
: 'text-slate-300 hover:text-white hover:bg-white/5'
)}
> >
<item.icon className="w-4 h-4 text-slate-400" /> <item.icon className={cn('w-4 h-4', currentPath.join('/') === item.path.join('/') ? 'text-[#336EFF]' : 'text-slate-400')} />
{item.name} {item.name}
</button> </button>
))} ))}
@@ -102,15 +218,15 @@ export default function Files() {
{DIRECTORIES.map((item) => ( {DIRECTORIES.map((item) => (
<button <button
key={item.name} key={item.name}
onClick={() => handleSidebarClick(item.name)} onClick={() => handleSidebarClick([item.name])}
className={cn( className={cn(
"w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors", 'w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors',
currentPath.length === 1 && currentPath[0] === item.name currentPath.length === 1 && currentPath[0] === item.name
? "bg-[#336EFF]/20 text-[#336EFF]" ? 'bg-[#336EFF]/20 text-[#336EFF]'
: "text-slate-300 hover:text-white hover:bg-white/5" : 'text-slate-300 hover:text-white hover:bg-white/5'
)} )}
> >
<item.icon className={cn("w-4 h-4", currentPath.length === 1 && currentPath[0] === item.name ? "text-[#336EFF]" : "text-slate-400")} /> <item.icon className={cn('w-4 h-4', currentPath.length === 1 && currentPath[0] === item.name ? 'text-[#336EFF]' : 'text-slate-400')} />
{item.name} {item.name}
</button> </button>
))} ))}
@@ -123,13 +239,15 @@ export default function Files() {
{/* Header / Breadcrumbs */} {/* Header / Breadcrumbs */}
<div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0"> <div className="p-4 border-b border-white/10 flex items-center justify-between shrink-0">
<div className="flex items-center text-sm text-slate-400"> <div className="flex items-center text-sm text-slate-400">
<button className="hover:text-white transition-colors"></button> <button className="hover:text-white transition-colors" onClick={() => handleSidebarClick([])}>
</button>
{currentPath.map((pathItem, index) => ( {currentPath.map((pathItem, index) => (
<React.Fragment key={index}> <React.Fragment key={index}>
<ChevronRight className="w-4 h-4 mx-1" /> <ChevronRight className="w-4 h-4 mx-1" />
<button <button
onClick={() => handleBreadcrumbClick(index)} onClick={() => handleBreadcrumbClick(index)}
className={cn("transition-colors", index === currentPath.length - 1 ? "text-white font-medium" : "hover:text-white")} className={cn('transition-colors', index === currentPath.length - 1 ? 'text-white font-medium' : 'hover:text-white')}
> >
{pathItem} {pathItem}
</button> </button>
@@ -162,8 +280,8 @@ export default function Files() {
onClick={() => setSelectedFile(file)} onClick={() => setSelectedFile(file)}
onDoubleClick={() => handleFolderDoubleClick(file)} onDoubleClick={() => handleFolderDoubleClick(file)}
className={cn( className={cn(
"group cursor-pointer transition-colors border-b border-white/5 last:border-0", '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]" selectedFile?.id === file.id ? 'bg-[#336EFF]/10' : 'hover:bg-white/[0.02]'
)} )}
> >
<td className="py-3 pl-4"> <td className="py-3 pl-4">
@@ -175,7 +293,7 @@ export default function Files() {
) : ( ) : (
<FileText className="w-5 h-5 text-blue-400" /> <FileText className="w-5 h-5 text-blue-400" />
)} )}
<span className={cn("text-sm font-medium", selectedFile?.id === file.id ? "text-[#336EFF]" : "text-slate-200")}> <span className={cn('text-sm font-medium', selectedFile?.id === file.id ? 'text-[#336EFF]' : 'text-slate-200')}>
{file.name} {file.name}
</span> </span>
</div> </div>
@@ -206,12 +324,13 @@ export default function Files() {
{/* Bottom Actions */} {/* Bottom Actions */}
<div className="p-4 border-t border-white/10 flex items-center gap-3 shrink-0 bg-white/[0.01]"> <div className="p-4 border-t border-white/10 flex items-center gap-3 shrink-0 bg-white/[0.01]">
<Button variant="default" className="gap-2"> <Button variant="default" className="gap-2" onClick={handleUploadClick}>
<Upload className="w-4 h-4" /> <Upload className="w-4 h-4" />
</Button> </Button>
<Button variant="outline" className="gap-2"> <Button variant="outline" className="gap-2" onClick={handleCreateFolder}>
<Plus className="w-4 h-4" /> <Plus className="w-4 h-4" />
</Button> </Button>
<input ref={fileInputRef} type="file" className="hidden" onChange={handleFileChange} />
</div> </div>
</Card> </Card>
@@ -241,14 +360,14 @@ export default function Files() {
</div> </div>
<div className="space-y-4"> <div className="space-y-4">
<DetailItem label="位置" value={`网盘 > ${currentPath.join(' > ')}`} /> <DetailItem label="位置" value={`网盘 > ${currentPath.length === 0 ? '根目录' : currentPath.join(' > ')}`} />
<DetailItem label="大小" value={selectedFile.size} /> <DetailItem label="大小" value={selectedFile.size} />
<DetailItem label="修改时间" value={selectedFile.modified} /> <DetailItem label="修改时间" value={selectedFile.modified} />
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} /> <DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
</div> </div>
{selectedFile.type !== 'folder' && ( {selectedFile.type !== 'folder' && (
<Button variant="outline" className="w-full gap-2 mt-4"> <Button variant="outline" className="w-full gap-2 mt-4" onClick={handleDownload}>
<Download className="w-4 h-4" /> <Download className="w-4 h-4" />
</Button> </Button>
)} )}
@@ -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 ( return (
<div> <div>
<p className="text-xs font-medium text-slate-500 mb-1">{label}</p> <p className="text-xs font-medium text-slate-500 mb-1">{label}</p>

View File

@@ -1,26 +1,63 @@
import React, { useState } from 'react'; import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/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 { LogIn, User, Lock } from 'lucide-react'; 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() { export default function Login() {
const navigate = useNavigate(); const navigate = useNavigate();
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState(''); const [error, setError] = useState('');
const handleLogin = (e: React.FormEvent) => { const handleLogin = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setLoading(true); setLoading(true);
setError(''); setError('');
// Simulate login try {
setTimeout(() => { let auth: AuthResponse;
try {
auth = await apiRequest<AuthResponse>('/auth/login', {
method: 'POST',
body: { username, password },
});
} catch (requestError) {
if (
DEV_LOGIN_ENABLED &&
username.trim() &&
requestError instanceof ApiError &&
requestError.status === 401
) {
auth = await apiRequest<AuthResponse>(
`/auth/dev-login?username=${encodeURIComponent(username.trim())}`,
{ method: 'POST' }
);
} else {
throw requestError;
}
}
saveStoredSession({
token: auth.token,
user: auth.user,
});
setLoading(false); setLoading(false);
navigate('/overview'); navigate('/overview');
}, 1000); } catch (requestError) {
setLoading(false);
setError(requestError instanceof Error ? requestError.message : '登录失败,请稍后重试');
}
}; };
return ( return (
@@ -45,7 +82,9 @@ export default function Login() {
<div className="space-y-2"> <div className="space-y-2">
<h2 className="text-xl text-[#336EFF] font-bold tracking-widest uppercase">YOYUZH.XYZ</h2> <h2 className="text-xl text-[#336EFF] font-bold tracking-widest uppercase">YOYUZH.XYZ</h2>
<h1 className="text-5xl md:text-6xl font-bold text-white leading-tight"> <h1 className="text-5xl md:text-6xl font-bold text-white leading-tight">
<br />
<br />
</h1> </h1>
</div> </div>
@@ -82,6 +121,8 @@ export default function Login() {
type="text" type="text"
placeholder="账号 / 用户名 / 学号" placeholder="账号 / 用户名 / 学号"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]" className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={username}
onChange={(event) => setUsername(event.target.value)}
required required
/> />
</div> </div>
@@ -94,6 +135,8 @@ export default function Login() {
type="password" type="password"
placeholder="••••••••" placeholder="••••••••"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]" className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
value={password}
onChange={(event) => setPassword(event.target.value)}
required required
/> />
</div> </div>

View File

@@ -1,16 +1,76 @@
import React from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom'; 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 { import {
FileText, Upload, FolderPlus, Database, FileText,
GraduationCap, BookOpen, Clock, HardDrive, Upload,
User, Mail, ChevronRight FolderPlus,
Database,
GraduationCap,
BookOpen,
Clock,
User,
Mail,
ChevronRight,
} from 'lucide-react'; } 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() { export default function Overview() {
const navigate = useNavigate(); 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<UserProfile | null>(cachedOverview?.profile ?? readStoredSession()?.user ?? null);
const [recentFiles, setRecentFiles] = useState<FileMetadata[]>(cachedOverview?.recentFiles ?? []);
const [rootFiles, setRootFiles] = useState<FileMetadata[]>(cachedOverview?.rootFiles ?? []);
const [schedule, setSchedule] = useState<CourseResponse[]>(cachedOverview?.schedule ?? cachedSchoolResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(cachedOverview?.grades ?? cachedSchoolResults?.grades ?? []);
const currentHour = new Date().getHours(); const currentHour = new Date().getHours();
let greeting = '晚上好'; let greeting = '晚上好';
if (currentHour < 6) greeting = '凌晨好'; if (currentHour < 6) greeting = '凌晨好';
@@ -18,6 +78,92 @@ export default function Overview() {
else if (currentHour < 18) greeting = '下午好'; else if (currentHour < 18) greeting = '下午好';
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' }); 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<UserProfile>('/user/profile'),
apiRequest<FileMetadata[]>('/files/recent'),
apiRequest<PageResponse<FileMetadata>>('/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<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/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 ( return (
<div className="space-y-6"> <div className="space-y-6">
@@ -29,7 +175,9 @@ export default function Overview() {
> >
<div className="absolute top-0 right-0 w-64 h-64 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-20" /> <div className="absolute top-0 right-0 w-64 h-64 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-2"> <div className="relative z-10 space-y-2">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">tester5595</h1> <h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">
{profile?.username ?? '访客'}
</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p> <p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed"> <p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
@@ -39,10 +187,28 @@ export default function Overview() {
{/* Metrics Cards */} {/* Metrics Cards */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<MetricCard title="网盘文件总数" value="128" desc="包含 4 个分类" icon={FileText} delay={0.1} /> <MetricCard title="网盘文件总数" value={`${rootFiles.length}`} desc="当前根目录统计" icon={FileText} delay={0.1} />
<MetricCard title="最近 7 天上传" value="6" desc="最新更新于 2 小时前" icon={Upload} delay={0.2} /> <MetricCard
<MetricCard title="本周课程" value="18" desc="今日还有 2 节课" icon={BookOpen} delay={0.3} /> title="最近 7 天上传"
<MetricCard title="已录入成绩" value="42" desc="最近学期2025 秋" icon={GraduationCap} delay={0.4} /> value={`${recentWeekUploads}`}
desc={recentFiles[0] ? `最新更新于 ${formatRecentTime(recentFiles[0].createdAt)}` : '暂无最近上传'}
icon={Upload}
delay={0.2}
/>
<MetricCard
title="本周课程"
value={`${schedule.length}`}
desc={schedule.length > 0 ? `当前已同步 ${schedule.length} 节课` : '请先前往教务页查询'}
icon={BookOpen}
delay={0.3}
/>
<MetricCard
title="已录入成绩"
value={`${grades.length}`}
desc={`最近学期:${latestSemester}`}
icon={GraduationCap}
delay={0.4}
/>
</div> </div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
@@ -58,24 +224,25 @@ export default function Overview() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-2"> <div className="space-y-2">
{[ {recentFiles.slice(0, 3).map((file, i) => (
{ 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) => (
<div key={i} className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group" onClick={() => navigate('/files')}> <div key={i} className="flex items-center justify-between p-3 rounded-xl hover:bg-white/5 transition-colors cursor-pointer group" onClick={() => navigate('/files')}>
<div className="flex items-center gap-4 overflow-hidden"> <div className="flex items-center gap-4 overflow-hidden">
<div className="w-10 h-10 rounded-xl bg-[#336EFF]/10 flex items-center justify-center shrink-0 group-hover:bg-[#336EFF]/20 transition-colors"> <div className="w-10 h-10 rounded-xl bg-[#336EFF]/10 flex items-center justify-center shrink-0 group-hover:bg-[#336EFF]/20 transition-colors">
<FileText className="w-5 h-5 text-[#336EFF]" /> <FileText className="w-5 h-5 text-[#336EFF]" />
</div> </div>
<div className="truncate"> <div className="truncate">
<p className="text-sm font-medium text-white truncate">{file.name}</p> <p className="text-sm font-medium text-white truncate">{file.filename}</p>
<p className="text-xs text-slate-400 mt-0.5">{file.time}</p> <p className="text-xs text-slate-400 mt-0.5">{formatRecentTime(file.createdAt)}</p>
</div> </div>
</div> </div>
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{file.size}</span> <span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{formatFileSize(file.size)}</span>
</div> </div>
))} ))}
{recentFiles.length === 0 && (
<div className="p-3 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -91,21 +258,24 @@ export default function Overview() {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="space-y-3"> <div className="space-y-3">
{[ {previewCourses.map((course, i) => (
{ 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) => (
<div key={i} className="flex items-center gap-4 p-4 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors"> <div key={i} className="flex items-center gap-4 p-4 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.04] transition-colors">
<div className="w-28 shrink-0 text-sm font-mono text-[#336EFF] bg-[#336EFF]/10 px-2 py-1 rounded-md text-center">{course.time}</div> <div className="w-28 shrink-0 text-sm font-mono text-[#336EFF] bg-[#336EFF]/10 px-2 py-1 rounded-md text-center">
{course.startTime ?? '--'} - {course.endTime ?? '--'}
</div>
<div className="flex-1 truncate"> <div className="flex-1 truncate">
<p className="text-sm font-medium text-white truncate">{course.name}</p> <p className="text-sm font-medium text-white truncate">{course.courseName}</p>
<p className="text-xs text-slate-400 flex items-center gap-1.5 mt-1"> <p className="text-xs text-slate-400 flex items-center gap-1.5 mt-1">
<Clock className="w-3.5 h-3.5" /> {course.room} <Clock className="w-3.5 h-3.5" /> {course.classroom ?? '教室待定'}
</p> </p>
</div> </div>
</div> </div>
))} ))}
{previewCourses.length === 0 && (
<div className="p-4 rounded-xl border border-dashed border-white/10 text-sm text-slate-500">
</div>
)}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -136,13 +306,15 @@ export default function Overview() {
<CardContent className="space-y-5"> <CardContent className="space-y-5">
<div className="flex justify-between items-end"> <div className="flex justify-between items-end">
<div className="space-y-1"> <div className="space-y-1">
<p className="text-3xl font-bold text-white tracking-tight">12.6 <span className="text-sm text-slate-400 font-normal">GB</span></p> <p className="text-3xl font-bold text-white tracking-tight">
{usedGb.toFixed(2)} <span className="text-sm text-slate-400 font-normal">GB</span>
</p>
<p className="text-xs text-slate-500 uppercase tracking-wider">使 / 50 GB</p> <p className="text-xs text-slate-500 uppercase tracking-wider">使 / 50 GB</p>
</div> </div>
<span className="text-xl font-mono text-[#336EFF] font-medium">25%</span> <span className="text-xl font-mono text-[#336EFF] font-medium">{storagePercent.toFixed(1)}%</span>
</div> </div>
<div className="h-2.5 w-full bg-black/40 rounded-full overflow-hidden shadow-inner"> <div className="h-2.5 w-full bg-black/40 rounded-full overflow-hidden shadow-inner">
<div className="h-full bg-gradient-to-r from-[#336EFF] to-blue-400 rounded-full" style={{ width: '25%' }} /> <div className="h-full bg-gradient-to-r from-[#336EFF] to-blue-400 rounded-full" style={{ width: `${storagePercent}%` }} />
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
@@ -155,11 +327,11 @@ export default function Overview() {
<CardContent> <CardContent>
<div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5"> <div className="flex items-center gap-4 p-4 rounded-xl bg-white/[0.02] border border-white/5">
<div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg"> <div className="w-12 h-12 rounded-full bg-gradient-to-br from-indigo-500 to-purple-500 flex items-center justify-center text-white font-bold text-xl shadow-lg">
T {(profile?.username?.[0] ?? 'T').toUpperCase()}
</div> </div>
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">tester5595</p> <p className="text-sm font-semibold text-white truncate">{profile?.username ?? '未登录'}</p>
<p className="text-xs text-slate-400 truncate mt-0.5">tester5595@example.com</p> <p className="text-xs text-slate-400 truncate mt-0.5">{profile?.email ?? '暂无邮箱'}</p>
</div> </div>
</div> </div>
</CardContent> </CardContent>

View File

@@ -1,23 +1,119 @@
import React, { useState } from 'react'; import React, { useEffect, useMemo, useState } from 'react';
import { motion } from 'motion/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 { 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'; import { cn } from '@/src/lib/utils';
export default function School() { function formatSections(startTime?: number | null, endTime?: number | null) {
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule'); if (!startTime || !endTime) {
const [loading, setLoading] = useState(false); return '节次待定';
const [queried, setQueried] = useState(false); }
const handleQuery = (e: React.FormEvent) => { return `${startTime}-${endTime}`;
e.preventDefault(); }
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<CourseResponse[]>(initialCachedResults?.schedule ?? []);
const [grades, setGrades] = useState<GradeResponse[]>(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); setLoading(true);
setTimeout(() => { }
setLoading(false);
writeStoredSchoolQuery({
studentId: nextStudentId,
semester: nextSemester,
});
try {
const queryString = new URLSearchParams({
studentId: nextStudentId,
semester: nextSemester,
}).toString();
const [scheduleData, gradeData] = await Promise.all([
apiRequest<CourseResponse[]>(`/cqu/schedule?${queryString}`),
apiRequest<GradeResponse[]>(`/cqu/grades?${queryString}`),
]);
setQueried(true); 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 ( return (
@@ -38,19 +134,19 @@ export default function School() {
<label className="text-xs font-medium text-slate-400 ml-1"></label> <label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative"> <div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" /> <User className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input defaultValue="2023123456" className="pl-9 bg-black/20" required /> <Input value={studentId} onChange={(event) => setStudentId(event.target.value)} className="pl-9 bg-black/20" required />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label> <label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative"> <div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" /> <Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-500" />
<Input type="password" defaultValue="password123" className="pl-9 bg-black/20" required /> <Input type="password" value={password} onChange={(event) => setPassword(event.target.value)} className="pl-9 bg-black/20" required />
</div> </div>
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label> <label className="text-xs font-medium text-slate-400 ml-1"></label>
<select 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]"> <select value={semester} onChange={(event) => 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]">
<option value="2025-spring">2025 </option> <option value="2025-spring">2025 </option>
<option value="2024-fall">2024 </option> <option value="2024-fall">2024 </option>
<option value="2024-spring">2024 </option> <option value="2024-spring">2024 </option>
@@ -81,9 +177,9 @@ export default function School() {
<CardContent> <CardContent>
{queried ? ( {queried ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4"> <div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryItem label="当前缓存账号" value="2023123456" icon={User} /> <SummaryItem label="当前缓存账号" value={studentId} icon={User} />
<SummaryItem label="已保存课表学期" value="2025 春" icon={Calendar} /> <SummaryItem label="已保存课表学期" value={semester} icon={Calendar} />
<SummaryItem label="已保存成绩" value="3 个学期" icon={Award} /> <SummaryItem label="已保存成绩" value={`${averageGrade}`} icon={Award} />
</div> </div>
) : ( ) : (
<div className="h-40 flex flex-col items-center justify-center text-slate-500 space-y-3 border border-dashed border-white/10 rounded-xl bg-white/[0.01]"> <div className="h-40 flex flex-col items-center justify-center text-slate-500 space-y-3 border border-dashed border-white/10 rounded-xl bg-white/[0.01]">
@@ -100,8 +196,8 @@ export default function School() {
<button <button
onClick={() => setActiveTab('schedule')} onClick={() => setActiveTab('schedule')}
className={cn( className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all", 'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'schedule' ? "bg-[#336EFF] text-white shadow-md" : "text-slate-400 hover:text-white" activeTab === 'schedule' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white'
)} )}
> >
@@ -109,8 +205,8 @@ export default function School() {
<button <button
onClick={() => setActiveTab('grades')} onClick={() => setActiveTab('grades')}
className={cn( className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all", 'px-6 py-2 text-sm font-medium rounded-lg transition-all',
activeTab === 'grades' ? "bg-[#336EFF] text-white shadow-md" : "text-slate-400 hover:text-white" activeTab === 'grades' ? 'bg-[#336EFF] text-white shadow-md' : 'text-slate-400 hover:text-white'
)} )}
> >
@@ -124,7 +220,7 @@ export default function School() {
animate={{ opacity: 1, y: 0 }} animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }} transition={{ duration: 0.3 }}
> >
{activeTab === 'schedule' ? <ScheduleView queried={queried} /> : <GradesView queried={queried} />} {activeTab === 'schedule' ? <ScheduleView queried={queried} schedule={schedule} /> : <GradesView queried={queried} grades={grades} />}
</motion.div> </motion.div>
</div> </div>
); );
@@ -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) { if (!queried) {
return ( return (
<Card> <Card>
@@ -178,14 +274,6 @@ function ScheduleView({ queried }: { queried: boolean }) {
} }
const days = ['周一', '周二', '周三', '周四', '周五']; 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 ( return (
<Card> <Card>
@@ -194,36 +282,39 @@ function ScheduleView({ queried }: { queried: boolean }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4"> <div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{days.map((day, index) => ( {days.map((day, index) => {
const dayCourses = schedule.filter((item) => (item.dayOfWeek ?? 0) - 1 === index);
return (
<div key={day} className="space-y-3"> <div key={day} className="space-y-3">
<div className="text-center py-2 bg-white/5 rounded-lg text-sm font-medium text-slate-300"> <div className="text-center py-2 bg-white/5 rounded-lg text-sm font-medium text-slate-300">
{day} {day}
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
{mockSchedule.filter(s => s.day === index).map((course, i) => ( {dayCourses.map((course, i) => (
<div key={i} className="p-3 rounded-xl bg-[#336EFF]/10 border border-[#336EFF]/20 hover:bg-[#336EFF]/20 transition-colors"> <div key={i} className="p-3 rounded-xl bg-[#336EFF]/10 border border-[#336EFF]/20 hover:bg-[#336EFF]/20 transition-colors">
<p className="text-xs font-mono text-[#336EFF] mb-1">{course.time}</p> <p className="text-xs font-mono text-[#336EFF] mb-1">{formatSections(course.startTime, course.endTime)}</p>
<p className="text-sm font-medium text-white leading-tight mb-2">{course.name}</p> <p className="text-sm font-medium text-white leading-tight mb-2">{course.courseName}</p>
<p className="text-xs text-slate-400 flex items-center gap-1"> <p className="text-xs text-slate-400 flex items-center gap-1">
<ChevronRight className="w-3 h-3" /> {course.room} <ChevronRight className="w-3 h-3" /> {course.classroom ?? '教室待定'}
</p> </p>
</div> </div>
))} ))}
{mockSchedule.filter(s => s.day === index).length === 0 && ( {dayCourses.length === 0 && (
<div className="h-24 rounded-xl border border-dashed border-white/10 flex items-center justify-center text-xs text-slate-500"> <div className="h-24 rounded-xl border border-dashed border-white/10 flex items-center justify-center text-xs text-slate-500">
</div> </div>
)} )}
</div> </div>
</div> </div>
))} );
})}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>
); );
} }
function GradesView({ queried }: { queried: boolean }) { function GradesView({ queried, grades }: { queried: boolean; grades: GradeResponse[] }) {
if (!queried) { if (!queried) {
return ( return (
<Card> <Card>
@@ -235,20 +326,14 @@ function GradesView({ queried }: { queried: boolean }) {
); );
} }
const terms = [ const terms = grades.reduce<Record<string, number[]>>((accumulator, grade) => {
{ const semester = grade.semester ?? '未分类';
name: '2024 秋', if (!accumulator[semester]) {
grades: [75, 78, 80, 83, 85, 88, 89, 96] accumulator[semester] = [];
},
{
name: '2025 春',
grades: [70, 78, 82, 84, 85, 85, 86, 88, 93]
},
{
name: '2025 秋',
grades: [68, 70, 76, 80, 85, 86, 90, 94, 97]
} }
]; accumulator[semester].push(grade.grade ?? 0);
return accumulator;
}, {});
const getScoreStyle = (score: number) => { const getScoreStyle = (score: number) => {
if (score >= 95) return 'bg-[#336EFF]/50 text-white'; if (score >= 95) return 'bg-[#336EFF]/50 text-white';
@@ -266,15 +351,15 @@ function GradesView({ queried }: { queried: boolean }) {
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{terms.map((term, i) => ( {Object.entries(terms).map(([term, scores], i) => (
<div key={i} className="flex flex-col"> <div key={i} className="flex flex-col">
<h3 className="text-sm font-bold text-white border-b border-white/5 pb-3 mb-4">{term.name}</h3> <h3 className="text-sm font-bold text-white border-b border-white/5 pb-3 mb-4">{term}</h3>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
{term.grades.map((score, j) => ( {scores.map((score, j) => (
<div <div
key={j} key={j}
className={cn( className={cn(
"w-full py-1.5 rounded-full text-xs font-mono font-medium text-center transition-colors", 'w-full py-1.5 rounded-full text-xs font-mono font-medium text-center transition-colors',
getScoreStyle(score) getScoreStyle(score)
)} )}
> >
@@ -284,6 +369,7 @@ function GradesView({ queried }: { queried: boolean }) {
</div> </div>
</div> </div>
))} ))}
{Object.keys(terms).length === 0 && <div className="text-sm text-slate-500"></div>}
</div> </div>
</CardContent> </CardContent>
</Card> </Card>

1
front/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View File

@@ -1,10 +1,12 @@
import tailwindcss from '@tailwindcss/vite'; import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react'; import react from '@vitejs/plugin-react';
import path from 'path'; 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 env = loadEnv(mode, '.', '');
const backendUrl = env.VITE_BACKEND_URL || 'http://localhost:8080';
return { return {
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
define: { define: {
@@ -16,9 +18,13 @@ export default defineConfig(({mode}) => {
}, },
}, },
server: { 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', hmr: process.env.DISABLE_HMR !== 'true',
proxy: {
'/api': {
target: backendUrl,
changeOrigin: true,
},
},
}, },
}; };
}); });

View File

@@ -1,7 +1,7 @@
$ErrorActionPreference = 'Stop' $ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot $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' $out = Join-Path $root 'backend-dev.out.log'
$err = Join-Path $root 'backend-dev.err.log' $err = Join-Path $root 'backend-dev.err.log'
@@ -13,9 +13,9 @@ if (Test-Path $err) {
} }
$proc = Start-Process ` $proc = Start-Process `
-FilePath $javaExe ` -FilePath $mavenExe `
-ArgumentList '-jar', 'backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar', '--spring.profiles.active=dev' ` -ArgumentList 'spring-boot:run', '-Dspring-boot.run.profiles=dev' `
-WorkingDirectory $root ` -WorkingDirectory (Join-Path $root 'backend') `
-PassThru ` -PassThru `
-RedirectStandardOutput $out ` -RedirectStandardOutput $out `
-RedirectStandardError $err -RedirectStandardError $err

18
开发测试账号.md Normal file
View File

@@ -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 数据。为避免混淆,直接填表中的教务密码即可。