first runnable version
This commit is contained in:
@@ -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("用户名或密码错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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("用户名或密码错误");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
|
|
||||||
|
|||||||
@@ -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));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
) {
|
||||||
|
}
|
||||||
|
}
|
||||||
23
backend/src/main/java/com/yoyuzh/config/CorsProperties.java
Normal file
23
backend/src/main/java/com/yoyuzh/config/CorsProperties.java
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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()
|
||||||
),
|
))
|
||||||
Map.of(
|
.toList();
|
||||||
"studentId", studentId,
|
}
|
||||||
"semester", semester,
|
|
||||||
"courseName", "计算机网络",
|
private record ScheduleEntry(
|
||||||
"grade", 88.5
|
String courseName,
|
||||||
),
|
String teacher,
|
||||||
Map.of(
|
String classroom,
|
||||||
"studentId", studentId,
|
Integer dayOfWeek,
|
||||||
"semester", semester,
|
Integer startTime,
|
||||||
"courseName", "软件工程",
|
Integer endTime
|
||||||
"grade", 90.0
|
) {
|
||||||
)
|
}
|
||||||
);
|
|
||||||
|
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)
|
||||||
|
),
|
||||||
|
List.of(
|
||||||
|
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||||
|
new GradeEntry("计算机网络", 88.5),
|
||||||
|
new GradeEntry("软件工程", 90.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case "2022456789" -> new StudentProfile(
|
||||||
|
List.of(
|
||||||
|
new ScheduleEntry("数据挖掘", "陈老师", "A1408", 2, 1, 2),
|
||||||
|
new ScheduleEntry("机器学习基础", "赵老师", "B2201", 4, 3, 4),
|
||||||
|
new ScheduleEntry("信息检索", "孙老师", "C1205", 5, 7, 8)
|
||||||
|
),
|
||||||
|
List.of(
|
||||||
|
new GradeEntry("数据挖掘", 94.0),
|
||||||
|
new GradeEntry("机器学习基础", 91.0),
|
||||||
|
new GradeEntry("信息检索", 89.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
case "2021789012" -> new StudentProfile(
|
||||||
|
List.of(
|
||||||
|
new ScheduleEntry("交互设计", "刘老师", "艺设楼201", 1, 3, 4),
|
||||||
|
new ScheduleEntry("视觉传达专题", "黄老师", "艺设楼305", 3, 5, 6),
|
||||||
|
new ScheduleEntry("数字媒体项目实践", "许老师", "创意工坊101", 4, 7, 8)
|
||||||
|
),
|
||||||
|
List.of(
|
||||||
|
new GradeEntry("交互设计", 96.0),
|
||||||
|
new GradeEntry("视觉传达专题", 93.0),
|
||||||
|
new GradeEntry("数字媒体项目实践", 97.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
default -> new StudentProfile(
|
||||||
|
List.of(
|
||||||
|
new ScheduleEntry("高级 Java 程序设计", "李老师", "D1131", 1, 1, 2),
|
||||||
|
new ScheduleEntry("计算机网络", "王老师", "A2204", 3, 3, 4),
|
||||||
|
new ScheduleEntry("软件工程", "周老师", "B3102", 5, 5, 6)
|
||||||
|
),
|
||||||
|
List.of(
|
||||||
|
new GradeEntry("高级 Java 程序设计", 92.0),
|
||||||
|
new GradeEntry("计算机网络", 88.5),
|
||||||
|
new GradeEntry("软件工程", 90.0)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
168
front/src/auth/AuthProvider.tsx
Normal file
168
front/src/auth/AuthProvider.tsx
Normal 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;
|
||||||
|
}
|
||||||
@@ -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
111
front/src/lib/api.test.ts
Normal 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
126
front/src/lib/api.ts
Normal 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;
|
||||||
|
}
|
||||||
99
front/src/lib/cache.test.ts
Normal file
99
front/src/lib/cache.test.ts
Normal 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
61
front/src/lib/cache.ts
Normal 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);
|
||||||
|
}
|
||||||
51
front/src/lib/page-cache.ts
Normal file
51
front/src/lib/page-cache.ts
Normal 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
46
front/src/lib/session.ts
Normal 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
48
front/src/lib/types.ts
Normal 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;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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);
|
}
|
||||||
|
|
||||||
|
return `第 ${startTime}-${endTime} 节`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function School() {
|
||||||
|
const storedQuery = readStoredSchoolQuery();
|
||||||
|
const initialStudentId = storedQuery?.studentId ?? '2023123456';
|
||||||
|
const initialSemester = storedQuery?.semester ?? '2025-spring';
|
||||||
|
const initialCachedResults = readCachedValue<{
|
||||||
|
queried: boolean;
|
||||||
|
schedule: CourseResponse[];
|
||||||
|
grades: GradeResponse[];
|
||||||
|
}>(getSchoolResultsCacheKey(initialStudentId, initialSemester));
|
||||||
|
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule');
|
||||||
|
const [studentId, setStudentId] = useState(initialStudentId);
|
||||||
|
const [password, setPassword] = useState('password123');
|
||||||
|
const [semester, setSemester] = useState(initialSemester);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [queried, setQueried] = useState(initialCachedResults?.queried ?? false);
|
||||||
|
const [schedule, setSchedule] = useState<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);
|
||||||
|
}
|
||||||
|
|
||||||
|
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}`),
|
||||||
|
]);
|
||||||
|
|
||||||
const handleQuery = (e: React.FormEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setLoading(true);
|
|
||||||
setTimeout(() => {
|
|
||||||
setLoading(false);
|
|
||||||
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) => {
|
||||||
<div key={day} className="space-y-3">
|
const dayCourses = schedule.filter((item) => (item.dayOfWeek ?? 0) - 1 === index);
|
||||||
<div className="text-center py-2 bg-white/5 rounded-lg text-sm font-medium text-slate-300">
|
return (
|
||||||
{day}
|
<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">
|
||||||
|
{day}
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{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">
|
||||||
|
<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.courseName}</p>
|
||||||
|
<p className="text-xs text-slate-400 flex items-center gap-1">
|
||||||
|
<ChevronRight className="w-3 h-3" /> {course.classroom ?? '教室待定'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{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>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
);
|
||||||
{mockSchedule.filter(s => s.day === index).map((course, i) => (
|
})}
|
||||||
<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-sm font-medium text-white leading-tight mb-2">{course.name}</p>
|
|
||||||
<p className="text-xs text-slate-400 flex items-center gap-1">
|
|
||||||
<ChevronRight className="w-3 h-3" /> {course.room}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{mockSchedule.filter(s => s.day === index).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>
|
|
||||||
)}
|
|
||||||
</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
1
front/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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
18
开发测试账号.md
Normal 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 数据。为避免混淆,直接填表中的教务密码即可。
|
||||||
Reference in New Issue
Block a user