add backend

This commit is contained in:
yoyuzh
2026-03-14 11:03:07 +08:00
parent d993d3f943
commit 8db2fa2aab
130 changed files with 15152 additions and 11861 deletions

16
.gemini/settings.json Normal file
View File

@@ -0,0 +1,16 @@
{
"general": {
"defaultApprovalMode": "plan"
},
"ui": {
"footer": {
"hideModelInfo": false,
"hideContextPercentage": false
},
"showMemoryUsage": true,
"showModelInfoInChat": true
},
"model": {
"name": "gemini-3-pro"
}
}

8
.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
backend/target/
data/
storage/
backend-dev.out.log
backend-dev.err.log
frontend-dev.out.log
frontend-dev.err.log
vue/dist/

71
backend/README.md Normal file
View File

@@ -0,0 +1,71 @@
# yoyuzh-portal-backend
`yoyuzh.xyz` 的 Spring Boot 3.x 后端,提供:
- 用户注册、登录、JWT 鉴权、用户信息接口
- 个人网盘上传、下载、删除、目录管理、分页列表
- CQU 课表与成绩聚合接口
- Swagger 文档、统一异常、日志输出
## 环境要求
- JDK 17+
- Maven 3.9+
- 生产环境使用 MySQL 8.x 或 openGauss
## 启动
默认配置:
```bash
mvn spring-boot:run
```
本地联调建议使用 `dev` 环境:
```bash
mvn spring-boot:run -Dspring-boot.run.profiles=dev
```
`dev` 环境特点:
- 数据库使用 H2 文件库
- CQU 接口返回 mock 数据
- 方便和 `vue/` 前端直接联调
## 访问地址
- Swagger: `http://localhost:8080/swagger-ui.html`
- H2 Console: `http://localhost:8080/h2-console`(仅 `dev` 环境)
## 数据库脚本
- MySQL: `sql/mysql-init.sql`
- openGauss: `sql/opengauss-init.sql`
## 主要接口
- `POST /api/auth/register`
- `POST /api/auth/login`
- `GET /api/user/profile`
- `POST /api/files/upload`
- `POST /api/files/mkdir`
- `GET /api/files/list`
- `GET /api/files/download/{fileId}`
- `DELETE /api/files/{fileId}`
- `GET /api/cqu/schedule`
- `GET /api/cqu/grades`
## CQU 配置
部署到真实环境时修改:
```yaml
app:
cqu:
base-url: https://your-cqu-api
require-login: false
mock-enabled: false
```
当前 Java 后端保留了 HTTP 适配点;本地 `dev` 环境使用 mock 数据先把前后端链路跑通。

View File

@@ -0,0 +1 @@
^C

1772
backend/dev-backend.log Normal file

File diff suppressed because it is too large Load Diff

108
backend/pom.xml Normal file
View File

@@ -0,0 +1,108 @@
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.8</version>
<relativePath/>
</parent>
<groupId>com.yoyuzh</groupId>
<artifactId>yoyuzh-portal-backend</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>yoyuzh-portal-backend</name>
<description>Spring Boot backend for yoyuzh.xyz</description>
<properties>
<java.version>17</java.version>
<jjwt.version>0.12.6</jjwt.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.springdoc</groupId>
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
<version>2.6.0</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>${jjwt.version}</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-jackson</artifactId>
<version>${jjwt.version}</version>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.springframework.security</groupId>
<artifactId>spring-security-test</artifactId>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-junit-jupiter</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,54 @@
CREATE DATABASE IF NOT EXISTS yoyuzh_portal DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
USE yoyuzh_portal;
CREATE TABLE IF NOT EXISTS portal_user (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(64) NOT NULL,
email VARCHAR(128) NOT NULL,
password_hash VARCHAR(255) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_portal_user_username UNIQUE (username),
CONSTRAINT uk_portal_user_email UNIQUE (email)
);
CREATE TABLE IF NOT EXISTS portal_file (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
filename VARCHAR(255) NOT NULL,
path VARCHAR(512) NOT NULL,
storage_name VARCHAR(255) NOT NULL,
content_type VARCHAR(255),
size BIGINT NOT NULL,
is_directory BIT NOT NULL DEFAULT b'0',
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_portal_file_user FOREIGN KEY (user_id) REFERENCES portal_user (id),
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
);
CREATE TABLE IF NOT EXISTS portal_course (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
course_name VARCHAR(255) NOT NULL,
teacher VARCHAR(255),
classroom VARCHAR(255),
day_of_week INT,
start_time INT,
end_time INT,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_portal_course_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
);
CREATE TABLE IF NOT EXISTS portal_grade (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
user_id BIGINT NOT NULL,
course_name VARCHAR(255) NOT NULL,
grade DOUBLE NOT NULL,
semester VARCHAR(64) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_portal_grade_user FOREIGN KEY (user_id) REFERENCES portal_user (id)
);
CREATE INDEX idx_user_created_at ON portal_user (created_at);
CREATE INDEX idx_file_created_at ON portal_file (created_at);
CREATE INDEX idx_course_user_created ON portal_course (user_id, created_at);
CREATE INDEX idx_grade_user_created ON portal_grade (user_id, created_at);

View File

@@ -0,0 +1,46 @@
CREATE TABLE IF NOT EXISTS portal_user (
id BIGSERIAL PRIMARY KEY,
username VARCHAR(64) NOT NULL UNIQUE,
email VARCHAR(128) NOT NULL UNIQUE,
password_hash VARCHAR(255) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS portal_file (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_user (id),
filename VARCHAR(255) NOT NULL,
path VARCHAR(512) NOT NULL,
storage_name VARCHAR(255) NOT NULL,
content_type VARCHAR(255),
size BIGINT NOT NULL,
is_directory BOOLEAN NOT NULL DEFAULT FALSE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uk_portal_file_user_path_name UNIQUE (user_id, path, filename)
);
CREATE TABLE IF NOT EXISTS portal_course (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_user (id),
course_name VARCHAR(255) NOT NULL,
teacher VARCHAR(255),
classroom VARCHAR(255),
day_of_week INTEGER,
start_time INTEGER,
end_time INTEGER,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS portal_grade (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES portal_user (id),
course_name VARCHAR(255) NOT NULL,
grade DOUBLE PRECISION NOT NULL,
semester VARCHAR(64) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_user_created_at ON portal_user (created_at);
CREATE INDEX IF NOT EXISTS idx_file_created_at ON portal_file (created_at);
CREATE INDEX IF NOT EXISTS idx_course_user_created ON portal_course (user_id, created_at);
CREATE INDEX IF NOT EXISTS idx_grade_user_created ON portal_grade (user_id, created_at);

View File

@@ -0,0 +1,21 @@
package com.yoyuzh;
import com.yoyuzh.config.CquApiProperties;
import com.yoyuzh.config.FileStorageProperties;
import com.yoyuzh.config.JwtProperties;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
@SpringBootApplication
@EnableConfigurationProperties({
JwtProperties.class,
FileStorageProperties.class,
CquApiProperties.class
})
public class PortalBackendApplication {
public static void main(String[] args) {
SpringApplication.run(PortalBackendApplication.class, args);
}
}

View File

@@ -0,0 +1,33 @@
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.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
@Operation(summary = "用户注册")
@PostMapping("/register")
public ApiResponse<AuthResponse> register(@Valid @RequestBody RegisterRequest request) {
return ApiResponse.success(authService.register(request));
}
@Operation(summary = "用户登录")
@PostMapping("/login")
public ApiResponse<AuthResponse> login(@Valid @RequestBody LoginRequest request) {
return ApiResponse.success(authService.login(request));
}
}

View File

@@ -0,0 +1,83 @@
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.auth.dto.UserProfileResponse;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import lombok.RequiredArgsConstructor;
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 org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final AuthenticationManager authenticationManager;
private final JwtTokenProvider jwtTokenProvider;
@Transactional
public AuthResponse register(RegisterRequest request) {
if (userRepository.existsByUsername(request.username())) {
throw new BusinessException(ErrorCode.UNKNOWN, "用户名已存在");
}
if (userRepository.existsByEmail(request.email())) {
throw new BusinessException(ErrorCode.UNKNOWN, "邮箱已存在");
}
User user = new User();
user.setUsername(request.username());
user.setEmail(request.email());
user.setPasswordHash(passwordEncoder.encode(request.password()));
User saved = userRepository.save(user);
return new AuthResponse(jwtTokenProvider.generateToken(saved.getId(), saved.getUsername()), toProfile(saved));
}
public AuthResponse login(LoginRequest request) {
try {
authenticationManager.authenticate(
new UsernamePasswordAuthenticationToken(request.username(), request.password()));
} catch (BadCredentialsException ex) {
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误");
}
User user = userRepository.findByUsername(request.username())
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
}
@Transactional
public AuthResponse devLogin(String username) {
String candidate = username == null ? "" : username.trim();
if (candidate.isEmpty()) {
candidate = "1";
}
final String finalCandidate = candidate;
User user = userRepository.findByUsername(finalCandidate).orElseGet(() -> {
User created = new User();
created.setUsername(finalCandidate);
created.setEmail(finalCandidate + "@dev.local");
created.setPasswordHash(passwordEncoder.encode("1"));
return userRepository.save(created);
});
return new AuthResponse(jwtTokenProvider.generateToken(user.getId(), user.getUsername()), toProfile(user));
}
public UserProfileResponse getProfile(String username) {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
return toProfile(user);
}
private UserProfileResponse toProfile(User user) {
return new UserProfileResponse(user.getId(), user.getUsername(), user.getEmail(), user.getCreatedAt());
}
}

View File

@@ -0,0 +1,31 @@
package com.yoyuzh.auth;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
@Service
@RequiredArgsConstructor
public class CustomUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
User user = userRepository.findByUsername(username)
.orElseThrow(() -> new UsernameNotFoundException("用户不存在"));
return org.springframework.security.core.userdetails.User.withUsername(user.getUsername())
.password(user.getPasswordHash())
.authorities("ROLE_USER")
.build();
}
public User loadDomainUser(String username) {
return userRepository.findByUsername(username)
.orElseThrow(() -> new BusinessException(ErrorCode.NOT_LOGGED_IN, "用户不存在"));
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.auth;
import com.yoyuzh.auth.dto.AuthResponse;
import com.yoyuzh.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
@Profile("dev")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class DevAuthController {
private final AuthService authService;
@Operation(summary = "开发环境免密登录")
@PostMapping("/dev-login")
public ApiResponse<AuthResponse> devLogin(@RequestParam(required = false) String username) {
return ApiResponse.success(authService.devLogin(username));
}
}

View File

@@ -0,0 +1,62 @@
package com.yoyuzh.auth;
import com.yoyuzh.config.JwtProperties;
import io.jsonwebtoken.Claims;
import io.jsonwebtoken.Jwts;
import io.jsonwebtoken.security.Keys;
import jakarta.annotation.PostConstruct;
import org.springframework.stereotype.Component;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.time.Instant;
import java.util.Date;
@Component
public class JwtTokenProvider {
private final JwtProperties jwtProperties;
private SecretKey secretKey;
public JwtTokenProvider(JwtProperties jwtProperties) {
this.jwtProperties = jwtProperties;
}
@PostConstruct
public void init() {
secretKey = Keys.hmacShaKeyFor(jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8));
}
public String generateToken(Long userId, String username) {
Instant now = Instant.now();
return Jwts.builder()
.subject(username)
.claim("uid", userId)
.issuedAt(Date.from(now))
.expiration(Date.from(now.plusSeconds(jwtProperties.getExpirationSeconds())))
.signWith(secretKey)
.compact();
}
public boolean validateToken(String token) {
try {
Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token);
return true;
} catch (Exception ex) {
return false;
}
}
public String getUsername(String token) {
return parseClaims(token).getSubject();
}
public Long getUserId(String token) {
Object uid = parseClaims(token).get("uid");
return uid == null ? null : Long.parseLong(uid.toString());
}
private Claims parseClaims(String token) {
return Jwts.parser().verifyWith(secretKey).build().parseSignedClaims(token).getPayload();
}
}

View File

@@ -0,0 +1,84 @@
package com.yoyuzh.auth;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_user", indexes = {
@Index(name = "uk_user_username", columnList = "username", unique = true),
@Index(name = "uk_user_email", columnList = "email", unique = true),
@Index(name = "idx_user_created_at", columnList = "created_at")
})
public class User {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 64, unique = true)
private String username;
@Column(nullable = false, length = 128, unique = true)
private String email;
@Column(name = "password_hash", nullable = false, length = 255)
private String passwordHash;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public String getUsername() {
return username;
}
public void setUsername(String username) {
this.username = username;
}
public String getEmail() {
return email;
}
public void setEmail(String email) {
this.email = email;
}
public String getPasswordHash() {
return passwordHash;
}
public void setPasswordHash(String passwordHash) {
this.passwordHash = passwordHash;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,24 @@
package com.yoyuzh.auth;
import com.yoyuzh.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/user")
@RequiredArgsConstructor
public class UserController {
private final AuthService authService;
@Operation(summary = "获取用户信息")
@GetMapping("/profile")
public ApiResponse<?> profile(@AuthenticationPrincipal UserDetails userDetails) {
return ApiResponse.success(authService.getProfile(userDetails.getUsername()));
}
}

View File

@@ -0,0 +1,13 @@
package com.yoyuzh.auth;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByUsername(String username);
boolean existsByEmail(String email);
Optional<User> findByUsername(String username);
}

View File

@@ -0,0 +1,4 @@
package com.yoyuzh.auth.dto;
public record AuthResponse(String token, UserProfileResponse user) {
}

View File

@@ -0,0 +1,9 @@
package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.NotBlank;
public record LoginRequest(
@NotBlank String username,
@NotBlank String password
) {
}

View File

@@ -0,0 +1,12 @@
package com.yoyuzh.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
public record RegisterRequest(
@NotBlank @Size(min = 3, max = 64) String username,
@NotBlank @Email @Size(max = 128) String email,
@NotBlank @Size(min = 6, max = 64) String password
) {
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.auth.dto;
import java.time.LocalDateTime;
public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
}

View File

@@ -0,0 +1,16 @@
package com.yoyuzh.common;
public record ApiResponse<T>(int code, String msg, T data) {
public static <T> ApiResponse<T> success(T data) {
return new ApiResponse<>(0, "success", data);
}
public static ApiResponse<Void> success() {
return new ApiResponse<>(0, "success", null);
}
public static ApiResponse<Void> error(ErrorCode errorCode, String msg) {
return new ApiResponse<>(errorCode.getCode(), msg, null);
}
}

View File

@@ -0,0 +1,15 @@
package com.yoyuzh.common;
public class BusinessException extends RuntimeException {
private final ErrorCode errorCode;
public BusinessException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
public ErrorCode getErrorCode() {
return errorCode;
}
}

View File

@@ -0,0 +1,18 @@
package com.yoyuzh.common;
public enum ErrorCode {
UNKNOWN(1000),
NOT_LOGGED_IN(1001),
PERMISSION_DENIED(1002),
FILE_NOT_FOUND(1003);
private final int code;
ErrorCode(int code) {
this.code = code;
}
public int getCode() {
return code;
}
}

View File

@@ -0,0 +1,51 @@
package com.yoyuzh.common;
import jakarta.validation.ConstraintViolationException;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(BusinessException.class)
public ResponseEntity<ApiResponse<Void>> handleBusinessException(BusinessException ex) {
HttpStatus status = switch (ex.getErrorCode()) {
case NOT_LOGGED_IN -> HttpStatus.UNAUTHORIZED;
case PERMISSION_DENIED -> HttpStatus.FORBIDDEN;
case FILE_NOT_FOUND -> HttpStatus.NOT_FOUND;
default -> HttpStatus.BAD_REQUEST;
};
return ResponseEntity.status(status).body(ApiResponse.error(ex.getErrorCode(), ex.getMessage()));
}
@ExceptionHandler({MethodArgumentNotValidException.class, ConstraintViolationException.class})
public ResponseEntity<ApiResponse<Void>> handleValidationException(Exception ex) {
return ResponseEntity.badRequest().body(ApiResponse.error(ErrorCode.UNKNOWN, ex.getMessage()));
}
@ExceptionHandler(AccessDeniedException.class)
public ResponseEntity<ApiResponse<Void>> handleAccessDenied(AccessDeniedException ex) {
return ResponseEntity.status(HttpStatus.FORBIDDEN)
.body(ApiResponse.error(ErrorCode.PERMISSION_DENIED, "没有权限访问该资源"));
}
@ExceptionHandler(BadCredentialsException.class)
public ResponseEntity<ApiResponse<Void>> handleBadCredentials(BadCredentialsException ex) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(ApiResponse.error(ErrorCode.NOT_LOGGED_IN, "用户名或密码错误"));
}
@ExceptionHandler(Exception.class)
public ResponseEntity<ApiResponse<Void>> handleUnknown(Exception ex) {
log.error("Unhandled exception", ex);
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR)
.body(ApiResponse.error(ErrorCode.UNKNOWN, "服务器内部错误"));
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.common;
import java.util.List;
public record PageResponse<T>(List<T> items, long total, int page, int size) {
}

View File

@@ -0,0 +1,35 @@
package com.yoyuzh.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.cqu")
public class CquApiProperties {
private String baseUrl = "https://example-cqu-api.local";
private boolean requireLogin = false;
private boolean mockEnabled = false;
public String getBaseUrl() {
return baseUrl;
}
public void setBaseUrl(String baseUrl) {
this.baseUrl = baseUrl;
}
public boolean isRequireLogin() {
return requireLogin;
}
public void setRequireLogin(boolean requireLogin) {
this.requireLogin = requireLogin;
}
public boolean isMockEnabled() {
return mockEnabled;
}
public void setMockEnabled(boolean mockEnabled) {
this.mockEnabled = mockEnabled;
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.storage")
public class FileStorageProperties {
private String rootDir = "./storage";
private long maxFileSize = 50 * 1024 * 1024L;
public String getRootDir() {
return rootDir;
}
public void setRootDir(String rootDir) {
this.rootDir = rootDir;
}
public long getMaxFileSize() {
return maxFileSize;
}
public void setMaxFileSize(long maxFileSize) {
this.maxFileSize = maxFileSize;
}
}

View File

@@ -0,0 +1,45 @@
package com.yoyuzh.config;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.JwtTokenProvider;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtTokenProvider jwtTokenProvider;
private final CustomUserDetailsService userDetailsService;
@Override
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String header = request.getHeader("Authorization");
if (header != null && header.startsWith("Bearer ")) {
String token = header.substring(7);
if (jwtTokenProvider.validateToken(token)
&& SecurityContextHolder.getContext().getAuthentication() == null) {
String username = jwtTokenProvider.getUsername(token);
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities());
authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
SecurityContextHolder.getContext().setAuthentication(authentication);
}
}
filterChain.doFilter(request, response);
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.config;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.jwt")
public class JwtProperties {
private String secret = "change-me-change-me-change-me-change-me";
private long expirationSeconds = 86400;
public String getSecret() {
return secret;
}
public void setSecret(String secret) {
this.secret = secret;
}
public long getExpirationSeconds() {
return expirationSeconds;
}
public void setExpirationSeconds(long expirationSeconds) {
this.expirationSeconds = expirationSeconds;
}
}

View File

@@ -0,0 +1,26 @@
package com.yoyuzh.config;
import io.swagger.v3.oas.models.Components;
import io.swagger.v3.oas.models.OpenAPI;
import io.swagger.v3.oas.models.info.Info;
import io.swagger.v3.oas.models.security.SecurityRequirement;
import io.swagger.v3.oas.models.security.SecurityScheme;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class OpenApiConfig {
@Bean
public OpenAPI openAPI() {
return new OpenAPI()
.info(new Info().title("yoyuzh.xyz Backend API").version("1.0.0").description("Personal portal backend"))
.addSecurityItem(new SecurityRequirement().addList("bearerAuth"))
.components(new Components().addSecuritySchemes("bearerAuth",
new SecurityScheme()
.name("Authorization")
.type(SecurityScheme.Type.HTTP)
.scheme("bearer")
.bearerFormat("JWT")));
}
}

View File

@@ -0,0 +1,14 @@
package com.yoyuzh.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.client.RestClient;
@Configuration
public class RestClientConfig {
@Bean
public RestClient restClient(RestClient.Builder builder) {
return builder.build();
}
}

View File

@@ -0,0 +1,83 @@
package com.yoyuzh.config;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.ErrorCode;
import lombok.RequiredArgsConstructor;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.MediaType;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.dao.DaoAuthenticationProvider;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
@RequiredArgsConstructor
public class SecurityConfig {
private final JwtAuthenticationFilter jwtAuthenticationFilter;
private final CustomUserDetailsService userDetailsService;
private final ObjectMapper objectMapper;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.cors(Customizer.withDefaults())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/v3/api-docs/**", "/swagger-ui/**", "/swagger-ui.html")
.permitAll()
.requestMatchers("/api/files/**", "/api/user/**")
.authenticated()
.anyRequest()
.permitAll())
.authenticationProvider(authenticationProvider())
.exceptionHandling(ex -> ex
.authenticationEntryPoint((request, response, e) -> {
response.setStatus(401);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(),
ApiResponse.error(ErrorCode.NOT_LOGGED_IN, "用户未登录"));
})
.accessDeniedHandler((request, response, e) -> {
response.setStatus(403);
response.setContentType(MediaType.APPLICATION_JSON_VALUE);
objectMapper.writeValue(response.getWriter(),
ApiResponse.error(ErrorCode.PERMISSION_DENIED, "权限不足"));
}))
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
@Bean
public AuthenticationProvider authenticationProvider() {
DaoAuthenticationProvider provider = new DaoAuthenticationProvider();
provider.setUserDetailsService(userDetailsService);
provider.setPasswordEncoder(passwordEncoder());
return provider;
}
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}

View File

@@ -0,0 +1,154 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_course", indexes = {
@Index(name = "idx_course_user_semester", columnList = "user_id,semester,student_id"),
@Index(name = "idx_course_user_created", columnList = "user_id,created_at")
})
public class Course {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "course_name", nullable = false, length = 255)
private String courseName;
@Column(length = 64)
private String semester;
@Column(name = "student_id", length = 64)
private String studentId;
@Column(length = 255)
private String teacher;
@Column(length = 255)
private String classroom;
@Column(name = "day_of_week")
private Integer dayOfWeek;
@Column(name = "start_time")
private Integer startTime;
@Column(name = "end_time")
private Integer endTime;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public String getSemester() {
return semester;
}
public void setSemester(String semester) {
this.semester = semester;
}
public String getStudentId() {
return studentId;
}
public void setStudentId(String studentId) {
this.studentId = studentId;
}
public String getTeacher() {
return teacher;
}
public void setTeacher(String teacher) {
this.teacher = teacher;
}
public String getClassroom() {
return classroom;
}
public void setClassroom(String classroom) {
this.classroom = classroom;
}
public Integer getDayOfWeek() {
return dayOfWeek;
}
public void setDayOfWeek(Integer dayOfWeek) {
this.dayOfWeek = dayOfWeek;
}
public Integer getStartTime() {
return startTime;
}
public void setStartTime(Integer startTime) {
this.startTime = startTime;
}
public Integer getEndTime() {
return endTime;
}
public void setEndTime(Integer endTime) {
this.endTime = endTime;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.cqu;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface CourseRepository extends JpaRepository<Course, Long> {
List<Course> findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(Long userId, String studentId, String semester);
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.cqu;
public record CourseResponse(
String courseName,
String teacher,
String classroom,
Integer dayOfWeek,
Integer startTime,
Integer endTime
) {
}

View File

@@ -0,0 +1,40 @@
package com.yoyuzh.cqu;
import com.yoyuzh.config.CquApiProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.core.ParameterizedTypeReference;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.util.List;
import java.util.Map;
@Component
@RequiredArgsConstructor
public class CquApiClient {
private final RestClient restClient;
private final CquApiProperties properties;
public List<Map<String, Object>> fetchSchedule(String semester, String studentId) {
if (properties.isMockEnabled()) {
return CquMockDataFactory.createSchedule(semester, studentId);
}
return restClient.get()
.uri(properties.getBaseUrl() + "/schedule?semester={semester}&studentId={studentId}", semester, studentId)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
}
public List<Map<String, Object>> fetchGrades(String semester, String studentId) {
if (properties.isMockEnabled()) {
return CquMockDataFactory.createGrades(semester, studentId);
}
return restClient.get()
.uri(properties.getBaseUrl() + "/grades?semester={semester}&studentId={studentId}", semester, studentId)
.retrieve()
.body(new ParameterizedTypeReference<>() {
});
}
}

View File

@@ -0,0 +1,44 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.ApiResponse;
import io.swagger.v3.oas.annotations.Operation;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.List;
@RestController
@RequestMapping("/api/cqu")
@RequiredArgsConstructor
public class CquController {
private final CquDataService cquDataService;
private final CustomUserDetailsService userDetailsService;
@Operation(summary = "获取课表")
@GetMapping("/schedule")
public ApiResponse<List<CourseResponse>> schedule(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam String semester,
@RequestParam String studentId) {
return ApiResponse.success(cquDataService.getSchedule(resolveUser(userDetails), semester, studentId));
}
@Operation(summary = "获取成绩")
@GetMapping("/grades")
public ApiResponse<List<GradeResponse>> grades(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam String semester,
@RequestParam String studentId) {
return ApiResponse.success(cquDataService.getGrades(resolveUser(userDetails), semester, studentId));
}
private User resolveUser(UserDetails userDetails) {
return userDetails == null ? null : userDetailsService.loadDomainUser(userDetails.getUsername());
}
}

View File

@@ -0,0 +1,129 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.config.CquApiProperties;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
import java.util.Map;
@Service
@RequiredArgsConstructor
public class CquDataService {
private final CquApiClient cquApiClient;
private final CourseRepository courseRepository;
private final GradeRepository gradeRepository;
private final CquApiProperties cquApiProperties;
public List<CourseResponse> getSchedule(User user, String semester, String studentId) {
requireLoginIfNecessary(user);
List<CourseResponse> responses = cquApiClient.fetchSchedule(semester, studentId).stream()
.map(this::toCourseResponse)
.toList();
if (user != null) {
saveCourses(user, semester, studentId, responses);
return courseRepository.findByUserIdAndStudentIdAndSemesterOrderByDayOfWeekAscStartTimeAsc(
user.getId(), studentId, semester)
.stream()
.map(item -> new CourseResponse(
item.getCourseName(),
item.getTeacher(),
item.getClassroom(),
item.getDayOfWeek(),
item.getStartTime(),
item.getEndTime()))
.toList();
}
return responses;
}
public List<GradeResponse> getGrades(User user, String semester, String studentId) {
requireLoginIfNecessary(user);
List<GradeResponse> responses = cquApiClient.fetchGrades(semester, studentId).stream()
.map(this::toGradeResponse)
.toList();
if (user != null) {
saveGrades(user, semester, studentId, responses);
return gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(user.getId(), studentId)
.stream()
.map(item -> new GradeResponse(item.getCourseName(), item.getGrade(), item.getSemester()))
.toList();
}
return responses;
}
private void requireLoginIfNecessary(User user) {
if (cquApiProperties.isRequireLogin() && user == null) {
throw new BusinessException(ErrorCode.NOT_LOGGED_IN, "该接口需要登录后访问");
}
}
@Transactional
protected void saveCourses(User user, String semester, String studentId, List<CourseResponse> responses) {
courseRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
courseRepository.saveAll(responses.stream().map(item -> {
Course course = new Course();
course.setUser(user);
course.setCourseName(item.courseName());
course.setSemester(semester);
course.setStudentId(studentId);
course.setTeacher(item.teacher());
course.setClassroom(item.classroom());
course.setDayOfWeek(item.dayOfWeek());
course.setStartTime(item.startTime());
course.setEndTime(item.endTime());
return course;
}).toList());
}
@Transactional
protected void saveGrades(User user, String semester, String studentId, List<GradeResponse> responses) {
gradeRepository.deleteByUserIdAndStudentIdAndSemester(user.getId(), studentId, semester);
gradeRepository.saveAll(responses.stream().map(item -> {
Grade grade = new Grade();
grade.setUser(user);
grade.setCourseName(item.courseName());
grade.setGrade(item.grade());
grade.setSemester(item.semester() == null ? semester : item.semester());
grade.setStudentId(studentId);
return grade;
}).toList());
}
private CourseResponse toCourseResponse(Map<String, Object> source) {
return new CourseResponse(
stringValue(source, "courseName"),
stringValue(source, "teacher"),
stringValue(source, "classroom"),
intValue(source, "dayOfWeek"),
intValue(source, "startTime"),
intValue(source, "endTime"));
}
private GradeResponse toGradeResponse(Map<String, Object> source) {
return new GradeResponse(
stringValue(source, "courseName"),
doubleValue(source, "grade"),
stringValue(source, "semester"));
}
private String stringValue(Map<String, Object> source, String key) {
Object value = source.get(key);
return value == null ? null : value.toString();
}
private Integer intValue(Map<String, Object> source, String key) {
Object value = source.get(key);
return value == null ? null : Integer.parseInt(value.toString());
}
private Double doubleValue(Map<String, Object> source, String key) {
Object value = source.get(key);
return value == null ? null : Double.parseDouble(value.toString());
}
}

View File

@@ -0,0 +1,68 @@
package com.yoyuzh.cqu;
import java.util.List;
import java.util.Map;
public final class CquMockDataFactory {
private CquMockDataFactory() {
}
public static List<Map<String, Object>> createSchedule(String semester, String studentId) {
return List.of(
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "高级 Java 程序设计",
"teacher", "李老师",
"classroom", "D1131",
"dayOfWeek", 1,
"startTime", 1,
"endTime", 2
),
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "计算机网络",
"teacher", "王老师",
"classroom", "A2204",
"dayOfWeek", 3,
"startTime", 3,
"endTime", 4
),
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "软件工程",
"teacher", "周老师",
"classroom", "B3102",
"dayOfWeek", 5,
"startTime", 5,
"endTime", 6
)
);
}
public static List<Map<String, Object>> createGrades(String semester, String studentId) {
return List.of(
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "高级 Java 程序设计",
"grade", 92.0
),
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "计算机网络",
"grade", 88.5
),
Map.of(
"studentId", studentId,
"semester", semester,
"courseName", "软件工程",
"grade", 90.0
)
);
}
}

View File

@@ -0,0 +1,110 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_grade", indexes = {
@Index(name = "idx_grade_user_semester", columnList = "user_id,semester,student_id"),
@Index(name = "idx_grade_user_created", columnList = "user_id,created_at")
})
public class Grade {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(name = "course_name", nullable = false, length = 255)
private String courseName;
@Column(nullable = false)
private Double grade;
@Column(nullable = false, length = 64)
private String semester;
@Column(name = "student_id", length = 64)
private String studentId;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getCourseName() {
return courseName;
}
public void setCourseName(String courseName) {
this.courseName = courseName;
}
public Double getGrade() {
return grade;
}
public void setGrade(Double grade) {
this.grade = grade;
}
public String getSemester() {
return semester;
}
public void setSemester(String semester) {
this.semester = semester;
}
public String getStudentId() {
return studentId;
}
public void setStudentId(String studentId) {
this.studentId = studentId;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,11 @@
package com.yoyuzh.cqu;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
public interface GradeRepository extends JpaRepository<Grade, Long> {
List<Grade> findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(Long userId, String studentId);
void deleteByUserIdAndStudentIdAndSemester(Long userId, String studentId, String semester);
}

View File

@@ -0,0 +1,8 @@
package com.yoyuzh.cqu;
public record GradeResponse(
String courseName,
Double grade,
String semester
) {
}

View File

@@ -0,0 +1,77 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.CustomUserDetailsService;
import com.yoyuzh.common.ApiResponse;
import com.yoyuzh.common.PageResponse;
import io.swagger.v3.oas.annotations.Operation;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.ModelAttribute;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RequestPart;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
import java.util.List;
@RestController
@RequestMapping("/api/files")
@RequiredArgsConstructor
public class FileController {
private final FileService fileService;
private final CustomUserDetailsService userDetailsService;
@Operation(summary = "上传文件")
@PostMapping("/upload")
public ApiResponse<FileMetadataResponse> upload(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "/") String path,
@RequestPart("file") MultipartFile file) {
return ApiResponse.success(fileService.upload(userDetailsService.loadDomainUser(userDetails.getUsername()), path, file));
}
@Operation(summary = "创建目录")
@PostMapping("/mkdir")
public ApiResponse<FileMetadataResponse> mkdir(@AuthenticationPrincipal UserDetails userDetails,
@Valid @ModelAttribute MkdirRequest request) {
return ApiResponse.success(fileService.mkdir(userDetailsService.loadDomainUser(userDetails.getUsername()), request.path()));
}
@Operation(summary = "分页列出文件")
@GetMapping("/list")
public ApiResponse<PageResponse<FileMetadataResponse>> list(@AuthenticationPrincipal UserDetails userDetails,
@RequestParam(defaultValue = "/") String path,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "10") int size) {
return ApiResponse.success(fileService.list(userDetailsService.loadDomainUser(userDetails.getUsername()), path, page, size));
}
@Operation(summary = "最近文件")
@GetMapping("/recent")
public ApiResponse<List<FileMetadataResponse>> recent(@AuthenticationPrincipal UserDetails userDetails) {
return ApiResponse.success(fileService.recent(userDetailsService.loadDomainUser(userDetails.getUsername())));
}
@Operation(summary = "下载文件")
@GetMapping("/download/{fileId}")
public ResponseEntity<byte[]> download(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
return fileService.download(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
}
@Operation(summary = "删除文件")
@DeleteMapping("/{fileId}")
public ApiResponse<Void> delete(@AuthenticationPrincipal UserDetails userDetails,
@PathVariable Long fileId) {
fileService.delete(userDetailsService.loadDomainUser(userDetails.getUsername()), fileId);
return ApiResponse.success();
}
}

View File

@@ -0,0 +1,14 @@
package com.yoyuzh.files;
import java.time.LocalDateTime;
public record FileMetadataResponse(
Long id,
String filename,
String path,
long size,
String contentType,
boolean directory,
LocalDateTime createdAt
) {
}

View File

@@ -0,0 +1,216 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.common.ErrorCode;
import com.yoyuzh.common.PageResponse;
import com.yoyuzh.config.FileStorageProperties;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.http.HttpHeaders;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.net.URLEncoder;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardCopyOption;
import java.util.List;
@Service
public class FileService {
private final StoredFileRepository storedFileRepository;
private final Path rootPath;
private final long maxFileSize;
public FileService(StoredFileRepository storedFileRepository, FileStorageProperties properties) {
this.storedFileRepository = storedFileRepository;
this.rootPath = Path.of(properties.getRootDir()).toAbsolutePath().normalize();
this.maxFileSize = properties.getMaxFileSize();
try {
Files.createDirectories(rootPath);
} catch (IOException ex) {
throw new IllegalStateException("无法初始化存储目录", ex);
}
}
@Transactional
public FileMetadataResponse upload(User user, String path, MultipartFile multipartFile) {
String normalizedPath = normalizeDirectoryPath(path);
String filename = StringUtils.cleanPath(multipartFile.getOriginalFilename());
if (!StringUtils.hasText(filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件名不能为空");
}
if (multipartFile.getSize() > maxFileSize) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件大小超出限制");
}
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), normalizedPath, filename)) {
throw new BusinessException(ErrorCode.UNKNOWN, "同目录下文件已存在");
}
Path targetDir = resolveUserPath(user.getId(), normalizedPath);
Path targetFile = targetDir.resolve(filename).normalize();
try {
Files.createDirectories(targetDir);
Files.copy(multipartFile.getInputStream(), targetFile, StandardCopyOption.REPLACE_EXISTING);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "文件上传失败");
}
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(filename);
storedFile.setPath(normalizedPath);
storedFile.setStorageName(filename);
storedFile.setContentType(multipartFile.getContentType());
storedFile.setSize(multipartFile.getSize());
storedFile.setDirectory(false);
return toResponse(storedFileRepository.save(storedFile));
}
@Transactional
public FileMetadataResponse mkdir(User user, String path) {
String normalizedPath = normalizeDirectoryPath(path);
if ("/".equals(normalizedPath)) {
throw new BusinessException(ErrorCode.UNKNOWN, "根目录无需创建");
}
String parentPath = extractParentPath(normalizedPath);
String directoryName = extractLeafName(normalizedPath);
if (storedFileRepository.existsByUserIdAndPathAndFilename(user.getId(), parentPath, directoryName)) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录已存在");
}
try {
Files.createDirectories(resolveUserPath(user.getId(), normalizedPath));
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录创建失败");
}
StoredFile storedFile = new StoredFile();
storedFile.setUser(user);
storedFile.setFilename(directoryName);
storedFile.setPath(parentPath);
storedFile.setStorageName(directoryName);
storedFile.setContentType("directory");
storedFile.setSize(0L);
storedFile.setDirectory(true);
return toResponse(storedFileRepository.save(storedFile));
}
public PageResponse<FileMetadataResponse> list(User user, String path, int page, int size) {
String normalizedPath = normalizeDirectoryPath(path);
Page<StoredFile> result = storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
user.getId(), normalizedPath, PageRequest.of(page, size));
List<FileMetadataResponse> items = result.getContent().stream().map(this::toResponse).toList();
return new PageResponse<>(items, result.getTotalElements(), page, size);
}
public List<FileMetadataResponse> recent(User user) {
return storedFileRepository.findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(user.getId())
.stream()
.map(this::toResponse)
.toList();
}
@Transactional
public void delete(User user, Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
if (!storedFile.getUser().getId().equals(user.getId())) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限删除该文件");
}
try {
Path basePath = resolveUserPath(user.getId(), storedFile.getPath());
Path target = storedFile.isDirectory()
? basePath.resolve(storedFile.getFilename()).normalize()
: basePath.resolve(storedFile.getStorageName()).normalize();
Files.deleteIfExists(target);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.UNKNOWN, "删除文件失败");
}
storedFileRepository.delete(storedFile);
}
public ResponseEntity<byte[]> download(User user, Long fileId) {
StoredFile storedFile = storedFileRepository.findById(fileId)
.orElseThrow(() -> new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在"));
if (!storedFile.getUser().getId().equals(user.getId())) {
throw new BusinessException(ErrorCode.PERMISSION_DENIED, "没有权限下载该文件");
}
if (storedFile.isDirectory()) {
throw new BusinessException(ErrorCode.UNKNOWN, "目录不支持下载");
}
try {
Path filePath = resolveUserPath(user.getId(), storedFile.getPath()).resolve(storedFile.getStorageName()).normalize();
byte[] body = Files.readAllBytes(filePath);
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION,
"attachment; filename*=UTF-8''" + URLEncoder.encode(storedFile.getFilename(), StandardCharsets.UTF_8))
.contentType(MediaType.parseMediaType(
storedFile.getContentType() == null ? MediaType.APPLICATION_OCTET_STREAM_VALUE : storedFile.getContentType()))
.body(body);
} catch (IOException ex) {
throw new BusinessException(ErrorCode.FILE_NOT_FOUND, "文件不存在");
}
}
private FileMetadataResponse toResponse(StoredFile storedFile) {
String logicalPath = storedFile.getPath();
if (storedFile.isDirectory()) {
logicalPath = "/".equals(storedFile.getPath())
? "/" + storedFile.getFilename()
: storedFile.getPath() + "/" + storedFile.getFilename();
}
return new FileMetadataResponse(
storedFile.getId(),
storedFile.getFilename(),
logicalPath,
storedFile.getSize(),
storedFile.getContentType(),
storedFile.isDirectory(),
storedFile.getCreatedAt());
}
private String normalizeDirectoryPath(String path) {
if (!StringUtils.hasText(path) || "/".equals(path.trim())) {
return "/";
}
String normalized = path.replace("\\", "/").trim();
if (!normalized.startsWith("/")) {
normalized = "/" + normalized;
}
normalized = normalized.replaceAll("/{2,}", "/");
if (normalized.contains("..")) {
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
}
if (normalized.endsWith("/") && normalized.length() > 1) {
normalized = normalized.substring(0, normalized.length() - 1);
}
return normalized;
}
private Path resolveUserPath(Long userId, String normalizedPath) {
Path userRoot = rootPath.resolve(userId.toString()).normalize();
Path relative = "/".equals(normalizedPath) ? Path.of("") : Path.of(normalizedPath.substring(1));
Path resolved = userRoot.resolve(relative).normalize();
if (!resolved.startsWith(userRoot)) {
throw new BusinessException(ErrorCode.UNKNOWN, "路径不合法");
}
return resolved;
}
private String extractParentPath(String normalizedPath) {
int lastSlash = normalizedPath.lastIndexOf('/');
return lastSlash <= 0 ? "/" : normalizedPath.substring(0, lastSlash);
}
private String extractLeafName(String normalizedPath) {
return normalizedPath.substring(normalizedPath.lastIndexOf('/') + 1);
}
}

View File

@@ -0,0 +1,6 @@
package com.yoyuzh.files;
import jakarta.validation.constraints.NotBlank;
public record MkdirRequest(@NotBlank String path) {
}

View File

@@ -0,0 +1,132 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.FetchType;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Index;
import jakarta.persistence.JoinColumn;
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import java.time.LocalDateTime;
@Entity
@Table(name = "portal_file", indexes = {
@Index(name = "uk_file_user_path_name", columnList = "user_id,path,filename", unique = true),
@Index(name = "idx_file_created_at", columnList = "created_at")
})
public class StoredFile {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false, length = 255)
private String filename;
@Column(nullable = false, length = 512)
private String path;
@Column(name = "storage_name", nullable = false, length = 255)
private String storageName;
@Column(name = "content_type", length = 255)
private String contentType;
@Column(nullable = false)
private Long size;
@Column(name = "is_directory", nullable = false)
private boolean directory;
@Column(name = "created_at", nullable = false)
private LocalDateTime createdAt;
@PrePersist
public void prePersist() {
if (createdAt == null) {
createdAt = LocalDateTime.now();
}
}
public Long getId() {
return id;
}
public void setId(Long id) {
this.id = id;
}
public User getUser() {
return user;
}
public void setUser(User user) {
this.user = user;
}
public String getFilename() {
return filename;
}
public void setFilename(String filename) {
this.filename = filename;
}
public String getPath() {
return path;
}
public void setPath(String path) {
this.path = path;
}
public String getStorageName() {
return storageName;
}
public void setStorageName(String storageName) {
this.storageName = storageName;
}
public String getContentType() {
return contentType;
}
public void setContentType(String contentType) {
this.contentType = contentType;
}
public Long getSize() {
return size;
}
public void setSize(Long size) {
this.size = size;
}
public boolean isDirectory() {
return directory;
}
public void setDirectory(boolean directory) {
this.directory = directory;
}
public LocalDateTime getCreatedAt() {
return createdAt;
}
public void setCreatedAt(LocalDateTime createdAt) {
this.createdAt = createdAt;
}
}

View File

@@ -0,0 +1,32 @@
package com.yoyuzh.files;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface StoredFileRepository extends JpaRepository<StoredFile, Long> {
@Query("""
select case when count(f) > 0 then true else false end
from StoredFile f
where f.user.id = :userId and f.path = :path and f.filename = :filename
""")
boolean existsByUserIdAndPathAndFilename(@Param("userId") Long userId,
@Param("path") String path,
@Param("filename") String filename);
@Query("""
select f from StoredFile f
where f.user.id = :userId and f.path = :path
order by f.directory desc, f.createdAt desc
""")
Page<StoredFile> findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(@Param("userId") Long userId,
@Param("path") String path,
Pageable pageable);
List<StoredFile> findTop12ByUserIdAndDirectoryFalseOrderByCreatedAtDesc(Long userId);
}

View File

@@ -0,0 +1,17 @@
spring:
datasource:
url: jdbc:h2:file:./data/yoyuzh_portal_dev;MODE=MySQL;AUTO_SERVER=TRUE;DB_CLOSE_DELAY=-1
username: sa
password:
driver-class-name: org.h2.Driver
jpa:
hibernate:
ddl-auto: update
h2:
console:
enabled: true
path: /h2-console
app:
cqu:
mock-enabled: true

View File

@@ -0,0 +1,41 @@
server:
port: 8080
spring:
application:
name: yoyuzh-portal-backend
datasource:
url: jdbc:mysql://localhost:3306/yoyuzh_portal?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8
username: root
password: root
driver-class-name: com.mysql.cj.jdbc.Driver
jpa:
hibernate:
ddl-auto: update
open-in-view: false
properties:
hibernate:
format_sql: true
servlet:
multipart:
max-file-size: 50MB
max-request-size: 50MB
app:
jwt:
secret: change-me-change-me-change-me-change-me
expiration-seconds: 86400
storage:
root-dir: ./storage
max-file-size: 52428800
cqu:
base-url: https://example-cqu-api.local
require-login: false
mock-enabled: false
springdoc:
swagger-ui:
path: /swagger-ui.html
logging:
config: classpath:logback.xml

View File

@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<property name="CONSOLE_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n"/>
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
<encoder>
<pattern>${CONSOLE_PATTERN}</pattern>
</encoder>
</appender>
<root level="INFO">
<appender-ref ref="CONSOLE"/>
</root>
</configuration>

View File

@@ -0,0 +1,105 @@
package com.yoyuzh.auth;
import com.yoyuzh.auth.dto.AuthResponse;
import com.yoyuzh.auth.dto.LoginRequest;
import com.yoyuzh.auth.dto.RegisterRequest;
import com.yoyuzh.common.BusinessException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.security.authentication.AuthenticationManager;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.crypto.password.PasswordEncoder;
import java.time.LocalDateTime;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class AuthServiceTest {
@Mock
private UserRepository userRepository;
@Mock
private PasswordEncoder passwordEncoder;
@Mock
private AuthenticationManager authenticationManager;
@Mock
private JwtTokenProvider jwtTokenProvider;
@InjectMocks
private AuthService authService;
@Test
void shouldRegisterUserWithEncryptedPassword() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
when(userRepository.existsByUsername("alice")).thenReturn(false);
when(userRepository.existsByEmail("alice@example.com")).thenReturn(false);
when(passwordEncoder.encode("plain-password")).thenReturn("encoded-password");
when(userRepository.save(any(User.class))).thenAnswer(invocation -> {
User user = invocation.getArgument(0);
user.setId(1L);
user.setCreatedAt(LocalDateTime.now());
return user;
});
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
AuthResponse response = authService.register(request);
assertThat(response.token()).isEqualTo("jwt-token");
assertThat(response.user().username()).isEqualTo("alice");
verify(passwordEncoder).encode("plain-password");
}
@Test
void shouldRejectDuplicateUsernameOnRegister() {
RegisterRequest request = new RegisterRequest("alice", "alice@example.com", "plain-password");
when(userRepository.existsByUsername("alice")).thenReturn(true);
assertThatThrownBy(() -> authService.register(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("用户名已存在");
}
@Test
void shouldLoginAndReturnToken() {
LoginRequest request = new LoginRequest("alice", "plain-password");
User user = new User();
user.setId(1L);
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setPasswordHash("encoded-password");
user.setCreatedAt(LocalDateTime.now());
when(userRepository.findByUsername("alice")).thenReturn(Optional.of(user));
when(jwtTokenProvider.generateToken(1L, "alice")).thenReturn("jwt-token");
AuthResponse response = authService.login(request);
verify(authenticationManager).authenticate(
new UsernamePasswordAuthenticationToken("alice", "plain-password"));
assertThat(response.token()).isEqualTo("jwt-token");
assertThat(response.user().email()).isEqualTo("alice@example.com");
}
@Test
void shouldThrowBusinessExceptionWhenAuthenticationFails() {
LoginRequest request = new LoginRequest("alice", "wrong-password");
when(authenticationManager.authenticate(any()))
.thenThrow(new BadCredentialsException("bad credentials"));
assertThatThrownBy(() -> authService.login(request))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("用户名或密码错误");
}
}

View File

@@ -0,0 +1,86 @@
package com.yoyuzh.cqu;
import com.yoyuzh.auth.User;
import com.yoyuzh.config.CquApiProperties;
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 java.time.LocalDateTime;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.anyList;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class CquDataServiceTest {
@Mock
private CquApiClient cquApiClient;
@Mock
private CourseRepository courseRepository;
@Mock
private GradeRepository gradeRepository;
@InjectMocks
private CquDataService cquDataService;
@Test
void shouldNormalizeScheduleFromRemoteApi() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(false);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties);
when(cquApiClient.fetchSchedule("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
"courseName", "Java",
"teacher", "Zhang",
"classroom", "A101",
"dayOfWeek", 1,
"startTime", 1,
"endTime", 2
)));
List<CourseResponse> response = cquDataService.getSchedule(null, "2025-2026-1", "20230001");
assertThat(response).hasSize(1);
assertThat(response.get(0).courseName()).isEqualTo("Java");
assertThat(response.get(0).teacher()).isEqualTo("Zhang");
}
@Test
void shouldPersistGradesForLoggedInUserWhenAvailable() {
CquApiProperties properties = new CquApiProperties();
properties.setRequireLogin(true);
cquDataService = new CquDataService(cquApiClient, courseRepository, gradeRepository, properties);
User user = new User();
user.setId(1L);
user.setUsername("alice");
user.setEmail("alice@example.com");
user.setPasswordHash("encoded");
user.setCreatedAt(LocalDateTime.now());
when(cquApiClient.fetchGrades("2025-2026-1", "20230001")).thenReturn(List.of(Map.of(
"courseName", "Java",
"grade", 95,
"semester", "2025-2026-1"
)));
Grade persisted = new Grade();
persisted.setUser(user);
persisted.setCourseName("Java");
persisted.setGrade(95D);
persisted.setSemester("2025-2026-1");
persisted.setStudentId("20230001");
when(gradeRepository.saveAll(anyList())).thenReturn(List.of(persisted));
when(gradeRepository.findByUserIdAndStudentIdOrderBySemesterAscGradeDesc(1L, "20230001"))
.thenReturn(List.of(persisted));
List<GradeResponse> response = cquDataService.getGrades(user, "2025-2026-1", "20230001");
assertThat(response).hasSize(1);
assertThat(response.get(0).grade()).isEqualTo(95D);
}
}

View File

@@ -0,0 +1,29 @@
package com.yoyuzh.cqu;
import org.junit.jupiter.api.Test;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
class CquMockDataFactoryTest {
@Test
void shouldCreateMockScheduleForStudentAndSemester() {
List<Map<String, Object>> result = CquMockDataFactory.createSchedule("2025-2026-1", "20230001");
assertThat(result).isNotEmpty();
assertThat(result.get(0)).containsEntry("courseName", "高级 Java 程序设计");
assertThat(result.get(0)).containsEntry("semester", "2025-2026-1");
}
@Test
void shouldCreateMockGradesForStudentAndSemester() {
List<Map<String, Object>> result = CquMockDataFactory.createGrades("2025-2026-1", "20230001");
assertThat(result).isNotEmpty();
assertThat(result.get(0)).containsEntry("studentId", "20230001");
assertThat(result.get(0)).containsKey("grade");
}
}

View File

@@ -0,0 +1,114 @@
package com.yoyuzh.files;
import com.yoyuzh.auth.User;
import com.yoyuzh.common.BusinessException;
import com.yoyuzh.config.FileStorageProperties;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.PageRequest;
import org.springframework.mock.web.MockMultipartFile;
import java.nio.file.Path;
import java.time.LocalDateTime;
import java.util.List;
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.when;
@ExtendWith(MockitoExtension.class)
class FileServiceTest {
@Mock
private StoredFileRepository storedFileRepository;
private FileService fileService;
@TempDir
Path tempDir;
@BeforeEach
void setUp() {
FileStorageProperties properties = new FileStorageProperties();
properties.setRootDir(tempDir.toString());
properties.setMaxFileSize(50 * 1024 * 1024);
fileService = new FileService(storedFileRepository, properties);
}
@Test
void shouldStoreUploadedFileUnderUserDirectory() {
User user = createUser(7L);
MockMultipartFile multipartFile = new MockMultipartFile(
"file", "notes.txt", "text/plain", "hello".getBytes());
when(storedFileRepository.existsByUserIdAndPathAndFilename(7L, "/docs", "notes.txt")).thenReturn(false);
when(storedFileRepository.save(any(StoredFile.class))).thenAnswer(invocation -> {
StoredFile file = invocation.getArgument(0);
file.setId(10L);
return file;
});
FileMetadataResponse response = fileService.upload(user, "/docs", multipartFile);
assertThat(response.id()).isEqualTo(10L);
assertThat(response.path()).isEqualTo("/docs");
assertThat(response.directory()).isFalse();
assertThat(tempDir.resolve("7/docs/notes.txt")).exists();
}
@Test
void shouldRejectDeletingOtherUsersFile() {
User owner = createUser(1L);
User requester = createUser(2L);
StoredFile storedFile = createFile(100L, owner, "/docs", "notes.txt");
when(storedFileRepository.findById(100L)).thenReturn(Optional.of(storedFile));
assertThatThrownBy(() -> fileService.delete(requester, 100L))
.isInstanceOf(BusinessException.class)
.hasMessageContaining("没有权限");
}
@Test
void shouldListFilesByPathWithPagination() {
User user = createUser(7L);
StoredFile file = createFile(100L, user, "/docs", "notes.txt");
when(storedFileRepository.findByUserIdAndPathOrderByDirectoryDescCreatedAtDesc(
7L, "/docs", PageRequest.of(0, 10)))
.thenReturn(new PageImpl<>(List.of(file)));
var result = fileService.list(user, "/docs", 0, 10);
assertThat(result.items()).hasSize(1);
assertThat(result.items().get(0).filename()).isEqualTo("notes.txt");
}
private User createUser(Long id) {
User user = new User();
user.setId(id);
user.setUsername("user-" + id);
user.setEmail("user-" + id + "@example.com");
user.setPasswordHash("encoded");
user.setCreatedAt(LocalDateTime.now());
return user;
}
private StoredFile createFile(Long id, User user, String path, String filename) {
StoredFile file = new StoredFile();
file.setId(id);
file.setUser(user);
file.setFilename(filename);
file.setPath(path);
file.setSize(5L);
file.setDirectory(false);
file.setStorageName(filename);
file.setCreatedAt(LocalDateTime.now());
return file;
}
}

9
front/.env.example Normal file
View File

@@ -0,0 +1,9 @@
# GEMINI_API_KEY: Required for Gemini AI API calls.
# AI Studio automatically injects this at runtime from user secrets.
# Users configure this via the Secrets panel in the AI Studio UI.
GEMINI_API_KEY="MY_GEMINI_API_KEY"
# APP_URL: The URL where this applet is hosted.
# AI Studio automatically injects this at runtime with the Cloud Run service URL.
# Used for self-referential links, OAuth callbacks, and API endpoints.
APP_URL="MY_APP_URL"

8
front/.gitignore vendored Normal file
View File

@@ -0,0 +1,8 @@
node_modules/
build/
dist/
coverage/
.DS_Store
*.log
.env*
!.env.example

View File

@@ -0,0 +1,8 @@
{
"hash": "1eac4ae6",
"configHash": "19e214db",
"lockfileHash": "126cd023",
"browserHash": "c5ddb224",
"optimized": {},
"chunks": {}
}

View File

@@ -0,0 +1,3 @@
{
"type": "module"
}

20
front/README.md Normal file
View File

@@ -0,0 +1,20 @@
<div align="center">
<img width="1200" height="475" alt="GHBanner" src="https://github.com/user-attachments/assets/0aa67016-6eaf-458a-adb2-6e31a0763ed6" />
</div>
# Run and deploy your AI Studio app
This contains everything you need to run your app locally.
View your app in AI Studio: https://ai.studio/apps/7dcdc5c7-28c0-4121-959b-77273973e0ef
## Run Locally
**Prerequisites:** Node.js
1. Install dependencies:
`npm install`
2. Set the `GEMINI_API_KEY` in [.env.local](.env.local) to your Gemini API key
3. Run the app:
`npm run dev`

View File

@@ -2,12 +2,12 @@
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>test1</title> <title>My Google AI Studio App</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="root"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.tsx"></script>
</body> </body>
</html> </html>

5
front/metadata.json Normal file
View File

@@ -0,0 +1,5 @@
{
"name": "Personal Portal",
"description": "A unified personal portal for managing files, school schedules, grades, and games with a glassmorphism design.",
"requestFramePermissions": []
}

5281
front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

39
front/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "react-example",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite --port=3000 --host=0.0.0.0",
"build": "vite build",
"preview": "vite preview",
"clean": "rm -rf dist",
"lint": "tsc --noEmit"
},
"dependencies": {
"@google/genai": "^1.29.0",
"@tailwindcss/vite": "^4.1.14",
"@vitejs/plugin-react": "^5.0.4",
"better-sqlite3": "^12.4.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"dotenv": "^17.2.3",
"express": "^4.21.2",
"lucide-react": "^0.546.0",
"motion": "^12.23.24",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"react-router-dom": "^7.13.1",
"tailwind-merge": "^3.5.0",
"vite": "^6.2.0"
},
"devDependencies": {
"@types/express": "^4.17.21",
"@types/node": "^22.14.0",
"autoprefixer": "^10.4.21",
"tailwindcss": "^4.1.14",
"tsx": "^4.21.0",
"typescript": "~5.8.2",
"vite": "^6.2.0"
}
}

25
front/src/App.tsx Normal file
View File

@@ -0,0 +1,25 @@
import React from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { Layout } from './components/layout/Layout';
import Login from './pages/Login';
import Overview from './pages/Overview';
import Files from './pages/Files';
import School from './pages/School';
import Games from './pages/Games';
export default function App() {
return (
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route path="/" element={<Layout />}>
<Route index element={<Navigate to="/overview" replace />} />
<Route path="overview" element={<Overview />} />
<Route path="files" element={<Files />} />
<Route path="school" element={<School />} />
<Route path="games" element={<Games />} />
</Route>
</Routes>
</BrowserRouter>
);
}

View File

@@ -0,0 +1,91 @@
import React from 'react';
import { NavLink, Outlet, useNavigate } from 'react-router-dom';
import { cn } from '@/src/lib/utils';
import { LayoutDashboard, FolderOpen, GraduationCap, Gamepad2, LogOut } from 'lucide-react';
const NAV_ITEMS = [
{ name: '总览', path: '/overview', icon: LayoutDashboard },
{ name: '网盘', path: '/files', icon: FolderOpen },
{ name: '教务', path: '/school', icon: GraduationCap },
{ name: '游戏', path: '/games', icon: Gamepad2 },
];
export function Layout() {
const navigate = useNavigate();
const handleLogout = () => {
navigate('/login');
};
return (
<div className="min-h-screen flex flex-col bg-[#07101D] text-white relative overflow-hidden">
{/* Animated Gradient Background */}
<div className="fixed inset-0 z-0 pointer-events-none">
<div className="absolute top-[-10%] left-[-10%] w-[40%] h-[40%] rounded-full bg-[#336EFF] opacity-20 mix-blend-screen filter blur-[120px] animate-blob" />
<div className="absolute top-[20%] right-[-10%] w-[50%] h-[50%] rounded-full bg-purple-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-2000" />
<div className="absolute bottom-[-20%] left-[20%] w-[60%] h-[60%] rounded-full bg-indigo-600 opacity-20 mix-blend-screen filter blur-[120px] animate-blob animation-delay-4000" />
</div>
{/* Top Navigation */}
<header className="sticky top-0 z-50 w-full glass-panel border-b border-white/10 bg-[#07101D]/60 backdrop-blur-xl">
<div className="container mx-auto px-4 h-16 flex items-center justify-between">
{/* Brand */}
<div className="flex items-center gap-3">
<div className="w-8 h-8 rounded-lg bg-gradient-to-br from-[#336EFF] to-blue-400 flex items-center justify-center shadow-lg shadow-[#336EFF]/20">
<span className="text-white font-bold text-lg leading-none">Y</span>
</div>
<div className="flex flex-col">
<span className="text-white font-bold text-sm tracking-wider">YOYUZH.XYZ</span>
<span className="text-slate-400 text-[10px] uppercase tracking-widest">Personal Portal</span>
</div>
</div>
{/* Nav Links */}
<nav className="hidden md:flex items-center gap-2">
{NAV_ITEMS.map((item) => (
<NavLink
key={item.path}
to={item.path}
className={({ isActive }) =>
cn(
'flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all duration-200 relative overflow-hidden group',
isActive
? 'text-white shadow-md shadow-[#336EFF]/20'
: 'text-slate-400 hover:text-white hover:bg-white/5'
)
}
>
{({ isActive }) => (
<>
{isActive && (
<div className="absolute inset-0 bg-[#336EFF] opacity-100 z-0" />
)}
<item.icon className="w-4 h-4 relative z-10" />
<span className="relative z-10">{item.name}</span>
</>
)}
</NavLink>
))}
</nav>
{/* User / Actions */}
<div className="flex items-center gap-4">
<button
onClick={handleLogout}
className="text-slate-400 hover:text-white transition-colors p-2 rounded-xl hover:bg-white/5 relative z-10"
aria-label="Logout"
>
<LogOut className="w-5 h-5" />
</button>
</div>
</div>
</header>
{/* Main Content */}
<main className="flex-1 container mx-auto px-4 py-8 relative z-10">
<Outlet />
</main>
</div>
);
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "default" | "outline" | "ghost" | "glass"
size?: "default" | "sm" | "lg" | "icon"
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant = "default", size = "default", ...props }, ref) => {
return (
<button
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-xl text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50",
{
"bg-[#336EFF] text-white hover:bg-[#2958cc] shadow-md shadow-[#336EFF]/20": variant === "default",
"border border-white/20 bg-transparent hover:bg-white/10 text-white": variant === "outline",
"hover:bg-white/10 text-white": variant === "ghost",
"glass-panel hover:bg-white/10 text-white": variant === "glass",
"h-10 px-4 py-2": size === "default",
"h-9 rounded-lg px-3": size === "sm",
"h-11 rounded-xl px-8": size === "lg",
"h-10 w-10": size === "icon",
},
className
)}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button }

View File

@@ -0,0 +1,78 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"glass-panel rounded-2xl text-white shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn("text-sm text-slate-400", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import { cn } from "@/src/lib/utils"
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-11 w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm text-white ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-slate-500 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#336EFF] disabled:cursor-not-allowed disabled:opacity-50 transition-colors",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

88
front/src/index.css Normal file
View File

@@ -0,0 +1,88 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap');
@import "tailwindcss";
@theme {
--font-sans: "Inter", ui-sans-serif, system-ui, sans-serif;
--font-mono: "JetBrains Mono", ui-monospace, SFMono-Regular, monospace;
--color-bg-base: #07101D;
--color-primary: #336EFF;
--color-primary-hover: #2958cc;
--color-text-primary: #FFFFFF;
--color-text-secondary: #94A3B8; /* slate-400 */
--color-text-tertiary: rgba(255, 255, 255, 0.3);
--color-glass-bg: rgba(255, 255, 255, 0.03);
--color-glass-border: rgba(255, 255, 255, 0.08);
--color-glass-hover: rgba(255, 255, 255, 0.06);
--color-glass-active: rgba(255, 255, 255, 0.1);
}
body {
background-color: var(--color-bg-base);
color: var(--color-text-primary);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Custom Scrollbar */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: rgba(255, 255, 255, 0.1);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: rgba(255, 255, 255, 0.2);
}
/* Glassmorphism utilities */
.glass-panel {
background: var(--color-glass-bg);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--color-glass-border);
}
.glass-panel-hover:hover {
background: var(--color-glass-hover);
}
.glass-panel-active {
background: var(--color-glass-active);
}
/* Animations */
@keyframes blob {
0% {
transform: translate(0px, 0px) scale(1);
}
33% {
transform: translate(30px, -50px) scale(1.1);
}
66% {
transform: translate(-20px, 20px) scale(0.9);
}
100% {
transform: translate(0px, 0px) scale(1);
}
}
.animate-blob {
animation: blob 10s infinite alternate ease-in-out;
}
.animation-delay-2000 {
animation-delay: 2s;
}
.animation-delay-4000 {
animation-delay: 4s;
}

6
front/src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx";
import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}

10
front/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import {StrictMode} from 'react';
import {createRoot} from 'react-dom/client';
import App from './App.tsx';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);

275
front/src/pages/Files.tsx Normal file
View File

@@ -0,0 +1,275 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import {
Folder, FileText, Image as ImageIcon, Download, Monitor,
Star, ChevronRight, Upload, Plus, LayoutGrid, List, File,
MoreVertical
} from 'lucide-react';
import { cn } from '@/src/lib/utils';
const QUICK_ACCESS = [
{ name: '桌面', icon: Monitor },
{ name: '下载', icon: Download },
{ name: '文档', icon: FileText },
{ name: '图片', icon: ImageIcon },
];
const DIRECTORIES = [
{ name: '我的文件', icon: Folder },
{ name: '课程资料', icon: Folder },
{ name: '项目归档', icon: Folder },
{ name: '收藏夹', icon: Star },
];
const MOCK_FILES_DB: Record<string, any[]> = {
'我的文件': [
{ id: 1, name: '软件工程期末复习资料.pdf', type: 'pdf', size: '2.4 MB', modified: '2025-01-15 14:30' },
{ id: 2, name: '2025春季学期课表.xlsx', type: 'excel', size: '156 KB', modified: '2025-02-28 09:15' },
{ id: 3, name: '项目架构设计图.png', type: 'image', size: '4.1 MB', modified: '2025-03-01 16:45' },
{ id: 4, name: '实验报告模板.docx', type: 'word', size: '45 KB', modified: '2025-03-05 10:20' },
{ id: 5, name: '前端学习笔记', type: 'folder', size: '—', modified: '2025-03-10 11:00' },
],
'课程资料': [
{ id: 6, name: '高等数学', type: 'folder', size: '—', modified: '2025-02-20 10:00' },
{ id: 7, name: '大学物理', type: 'folder', size: '—', modified: '2025-02-21 11:00' },
{ id: 8, name: '软件工程', type: 'folder', size: '—', modified: '2025-02-22 14:00' },
],
'项目归档': [
{ id: 9, name: '2024秋季学期项目', type: 'folder', size: '—', modified: '2024-12-20 15:30' },
{ id: 10, name: '个人博客源码.zip', type: 'archive', size: '15.2 MB', modified: '2025-01-05 09:45' },
],
'收藏夹': [
{ id: 11, name: '常用工具网站.txt', type: 'document', size: '2 KB', modified: '2025-03-01 10:00' },
],
'我的文件/前端学习笔记': [
{ id: 12, name: 'React Hooks 详解.md', type: 'document', size: '12 KB', modified: '2025-03-08 09:00' },
{ id: 13, name: 'Tailwind 技巧.md', type: 'document', size: '8 KB', modified: '2025-03-09 14:20' },
{ id: 14, name: '示例代码', type: 'folder', size: '—', modified: '2025-03-10 10:00' },
],
'课程资料/软件工程': [
{ id: 15, name: '需求规格说明书.pdf', type: 'pdf', size: '1.2 MB', modified: '2025-03-05 16:00' },
{ id: 16, name: '系统设计文档.docx', type: 'word', size: '850 KB', modified: '2025-03-06 11:30' },
]
};
export default function Files() {
const [currentPath, setCurrentPath] = useState<string[]>(['我的文件']);
const [selectedFile, setSelectedFile] = useState<any | null>(null);
const activeDir = currentPath[currentPath.length - 1];
const pathKey = currentPath.join('/');
const currentFiles = MOCK_FILES_DB[pathKey] || [];
const handleSidebarClick = (name: string) => {
setCurrentPath([name]);
setSelectedFile(null);
};
const handleFolderDoubleClick = (file: any) => {
if (file.type === 'folder') {
setCurrentPath([...currentPath, file.name]);
setSelectedFile(null);
}
};
const handleBreadcrumbClick = (index: number) => {
setCurrentPath(currentPath.slice(0, index + 1));
setSelectedFile(null);
};
return (
<div className="flex flex-col lg:flex-row gap-6 h-[calc(100vh-8rem)]">
{/* Left Sidebar */}
<Card className="w-full lg:w-64 shrink-0 flex flex-col h-full overflow-y-auto">
<CardContent className="p-4 space-y-6">
<div className="space-y-1">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2">访</p>
{QUICK_ACCESS.map((item) => (
<button
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"
>
<item.icon className="w-4 h-4 text-slate-400" />
{item.name}
</button>
))}
</div>
<div className="space-y-1">
<p className="px-3 text-xs font-semibold text-slate-500 uppercase tracking-wider mb-2"></p>
{DIRECTORIES.map((item) => (
<button
key={item.name}
onClick={() => handleSidebarClick(item.name)}
className={cn(
"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
? "bg-[#336EFF]/20 text-[#336EFF]"
: "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.name}
</button>
))}
</div>
</CardContent>
</Card>
{/* Middle Content */}
<Card className="flex-1 flex flex-col h-full overflow-hidden">
{/* Header / Breadcrumbs */}
<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">
<button className="hover:text-white transition-colors"></button>
{currentPath.map((pathItem, index) => (
<React.Fragment key={index}>
<ChevronRight className="w-4 h-4 mx-1" />
<button
onClick={() => handleBreadcrumbClick(index)}
className={cn("transition-colors", index === currentPath.length - 1 ? "text-white font-medium" : "hover:text-white")}
>
{pathItem}
</button>
</React.Fragment>
))}
</div>
<div className="flex items-center gap-2 bg-black/20 p-1 rounded-lg">
<button className="p-1.5 rounded-md bg-white/10 text-white"><List className="w-4 h-4" /></button>
<button className="p-1.5 rounded-md text-slate-400 hover:text-white"><LayoutGrid className="w-4 h-4" /></button>
</div>
</div>
{/* File List */}
<div className="flex-1 overflow-y-auto p-4">
<table className="w-full text-left border-collapse">
<thead>
<tr className="text-xs font-semibold text-slate-500 uppercase tracking-wider border-b border-white/5">
<th className="pb-3 pl-4 font-medium"></th>
<th className="pb-3 font-medium hidden md:table-cell"></th>
<th className="pb-3 font-medium hidden lg:table-cell"></th>
<th className="pb-3 font-medium"></th>
<th className="pb-3"></th>
</tr>
</thead>
<tbody>
{currentFiles.length > 0 ? (
currentFiles.map((file) => (
<tr
key={file.id}
onClick={() => setSelectedFile(file)}
onDoubleClick={() => handleFolderDoubleClick(file)}
className={cn(
"group cursor-pointer transition-colors border-b border-white/5 last:border-0",
selectedFile?.id === file.id ? "bg-[#336EFF]/10" : "hover:bg-white/[0.02]"
)}
>
<td className="py-3 pl-4">
<div className="flex items-center gap-3">
{file.type === 'folder' ? (
<Folder className="w-5 h-5 text-[#336EFF]" />
) : file.type === 'image' ? (
<ImageIcon className="w-5 h-5 text-purple-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")}>
{file.name}
</span>
</div>
</td>
<td className="py-3 text-sm text-slate-400 hidden md:table-cell">{file.modified}</td>
<td className="py-3 text-sm text-slate-400 hidden lg:table-cell uppercase">{file.type}</td>
<td className="py-3 text-sm text-slate-400 font-mono">{file.size}</td>
<td className="py-3 pr-4 text-right">
<button className="p-1.5 rounded-md text-slate-500 opacity-0 group-hover:opacity-100 hover:bg-white/10 hover:text-white transition-all">
<MoreVertical className="w-4 h-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="py-12 text-center text-slate-500">
<div className="flex flex-col items-center justify-center space-y-3">
<Folder className="w-12 h-12 opacity-20" />
<p className="text-sm"></p>
</div>
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* Bottom Actions */}
<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">
<Upload className="w-4 h-4" />
</Button>
<Button variant="outline" className="gap-2">
<Plus className="w-4 h-4" />
</Button>
</div>
</Card>
{/* Right Sidebar (Details) */}
{selectedFile && (
<motion.div
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
className="w-full lg:w-72 shrink-0"
>
<Card className="h-full">
<CardHeader className="pb-4 border-b border-white/10">
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent className="p-6 space-y-6">
<div className="flex flex-col items-center text-center space-y-3">
<div className="w-16 h-16 rounded-2xl bg-[#336EFF]/10 flex items-center justify-center">
{selectedFile.type === 'folder' ? (
<Folder className="w-8 h-8 text-[#336EFF]" />
) : selectedFile.type === 'image' ? (
<ImageIcon className="w-8 h-8 text-purple-400" />
) : (
<FileText className="w-8 h-8 text-blue-400" />
)}
</div>
<h3 className="text-sm font-medium text-white break-all">{selectedFile.name}</h3>
</div>
<div className="space-y-4">
<DetailItem label="位置" value={`网盘 > ${currentPath.join(' > ')}`} />
<DetailItem label="大小" value={selectedFile.size} />
<DetailItem label="修改时间" value={selectedFile.modified} />
<DetailItem label="类型" value={selectedFile.type.toUpperCase()} />
</div>
{selectedFile.type !== 'folder' && (
<Button variant="outline" className="w-full gap-2 mt-4">
<Download className="w-4 h-4" />
</Button>
)}
{selectedFile.type === 'folder' && (
<Button variant="default" className="w-full gap-2 mt-4" onClick={() => handleFolderDoubleClick(selectedFile)}>
</Button>
)}
</CardContent>
</Card>
</motion.div>
)}
</div>
);
}
function DetailItem({ label, value }: { label: string, value: string }) {
return (
<div>
<p className="text-xs font-medium text-slate-500 mb-1">{label}</p>
<p className="text-sm text-slate-300">{value}</p>
</div>
);
}

109
front/src/pages/Games.tsx Normal file
View File

@@ -0,0 +1,109 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Gamepad2, Rocket, Cat, Car, Play } from 'lucide-react';
import { cn } from '@/src/lib/utils';
const GAMES = [
{
id: 'cat',
name: 'CAT',
description: '简单的小猫升级游戏,通过点击获取经验,解锁不同形态的猫咪。',
icon: Cat,
color: 'from-orange-400 to-red-500',
category: 'featured'
},
{
id: 'race',
name: 'RACE',
description: '赛车休闲小游戏,躲避障碍物,挑战最高分记录。',
icon: Car,
color: 'from-blue-400 to-indigo-500',
category: 'featured'
}
];
export default function Games() {
const [activeTab, setActiveTab] = useState<'featured' | 'all'>('featured');
return (
<div className="space-y-8">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<div className="absolute top-0 right-0 w-64 h-64 bg-purple-500 rounded-full mix-blend-screen filter blur-[100px] opacity-20" />
<div className="relative z-10 space-y-4 max-w-2xl">
<div className="inline-flex items-center gap-2 px-3 py-1.5 rounded-full bg-white/5 border border-white/10 w-fit">
<Gamepad2 className="w-4 h-4 text-purple-400" />
<span className="text-xs text-slate-300 font-medium tracking-wide uppercase">Entertainment</span>
</div>
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight"></h1>
<p className="text-sm text-slate-400 leading-relaxed">
</p>
</div>
</motion.div>
{/* Category Tabs */}
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
<button
onClick={() => setActiveTab('featured')}
className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all",
activeTab === 'featured' ? "bg-white/10 text-white shadow-md" : "text-slate-400 hover:text-white"
)}
>
</button>
<button
onClick={() => setActiveTab('all')}
className={cn(
"px-6 py-2 text-sm font-medium rounded-lg transition-all",
activeTab === 'all' ? "bg-white/10 text-white shadow-md" : "text-slate-400 hover:text-white"
)}
>
</button>
</div>
{/* Game Grid */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{GAMES.map((game, index) => (
<motion.div
key={game.id}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.1 }}
>
<Card className="h-full flex flex-col hover:bg-white/[0.04] transition-colors group overflow-hidden relative">
<div className={cn("absolute top-0 left-0 w-full h-1 bg-gradient-to-r", game.color)} />
<CardHeader className="pb-4">
<div className="flex items-start justify-between">
<div className={cn("w-12 h-12 rounded-2xl flex items-center justify-center bg-gradient-to-br shadow-lg", game.color)}>
<game.icon className="w-6 h-6 text-white" />
</div>
<span className="text-[10px] font-bold uppercase tracking-wider text-slate-500 bg-white/5 px-2 py-1 rounded-md">
{game.category}
</span>
</div>
<CardTitle className="text-xl mt-4">{game.name}</CardTitle>
<CardDescription className="line-clamp-2 mt-2">
{game.description}
</CardDescription>
</CardHeader>
<CardContent className="mt-auto pt-4">
<Button className="w-full gap-2 group-hover:bg-white group-hover:text-black transition-all">
<Play className="w-4 h-4" fill="currentColor" /> Launch
</Button>
</CardContent>
</Card>
</motion.div>
))}
</div>
</div>
);
}

130
front/src/pages/Login.tsx Normal file
View File

@@ -0,0 +1,130 @@
import React, { useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { LogIn, User, Lock } from 'lucide-react';
export default function Login() {
const navigate = useNavigate();
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const handleLogin = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setError('');
// Simulate login
setTimeout(() => {
setLoading(false);
navigate('/overview');
}, 1000);
};
return (
<div className="min-h-screen flex items-center justify-center bg-[#07101D] relative overflow-hidden">
{/* Background Glow */}
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-[#336EFF] rounded-full mix-blend-screen filter blur-[128px] opacity-20 animate-pulse" />
<div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-600 rounded-full mix-blend-screen filter blur-[128px] opacity-20" />
<div className="container mx-auto px-4 grid lg:grid-cols-2 gap-12 items-center relative z-10">
{/* Left Side: Brand Info */}
<motion.div
initial={{ opacity: 0, x: -50 }}
animate={{ opacity: 1, x: 0 }}
transition={{ duration: 0.8, ease: 'easeOut' }}
className="flex flex-col space-y-6 max-w-lg"
>
<div className="inline-flex items-center gap-2 px-4 py-2 rounded-full glass-panel border-white/10 w-fit">
<span className="w-2 h-2 rounded-full bg-[#336EFF] animate-pulse" />
<span className="text-sm text-slate-300 font-medium tracking-wide uppercase">Access Portal</span>
</div>
<div className="space-y-2">
<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">
<br />
</h1>
</div>
<p className="text-lg text-slate-400 leading-relaxed">
YOYUZH
</p>
</motion.div>
{/* Right Side: Login Form */}
<motion.div
initial={{ opacity: 0, y: 50 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.2, ease: 'easeOut' }}
className="w-full max-w-md mx-auto lg:mx-0 lg:ml-auto"
>
<Card className="border-white/10 backdrop-blur-2xl bg-white/5 shadow-2xl">
<CardHeader className="space-y-1 pb-8">
<CardTitle className="text-2xl font-bold text-white flex items-center gap-2">
<LogIn className="w-6 h-6 text-[#336EFF]" />
</CardTitle>
<CardDescription className="text-slate-400">
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleLogin} className="space-y-6">
<div className="space-y-4">
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="text"
placeholder="账号 / 用户名 / 学号"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
required
/>
</div>
</div>
<div className="space-y-2">
<label className="text-sm font-medium text-slate-300 ml-1"></label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 w-5 h-5 text-slate-500" />
<Input
type="password"
placeholder="••••••••"
className="pl-10 bg-black/20 border-white/10 focus-visible:ring-[#336EFF]"
required
/>
</div>
</div>
</div>
{error && (
<div className="p-3 rounded-xl bg-red-500/10 border border-red-500/20 text-red-400 text-sm">
{error}
</div>
)}
<Button
type="submit"
className="w-full h-12 text-base font-semibold"
disabled={loading}
>
{loading ? (
<span className="flex items-center gap-2">
<span className="w-4 h-4 border-2 border-white/20 border-t-white rounded-full animate-spin" />
...
</span>
) : (
'进入系统'
)}
</Button>
</form>
</CardContent>
</Card>
</motion.div>
</div>
</div>
);
}

View File

@@ -0,0 +1,208 @@
import React from 'react';
import { motion } from 'motion/react';
import { useNavigate } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import {
FileText, Upload, FolderPlus, Database,
GraduationCap, BookOpen, Clock, HardDrive,
User, Mail, ChevronRight
} from 'lucide-react';
export default function Overview() {
const navigate = useNavigate();
const currentHour = new Date().getHours();
let greeting = '晚上好';
if (currentHour < 6) greeting = '凌晨好';
else if (currentHour < 12) greeting = '早上好';
else if (currentHour < 18) greeting = '下午好';
const currentTime = new Date().toLocaleTimeString('zh-CN', { hour: '2-digit', minute: '2-digit' });
return (
<div className="space-y-6">
{/* Hero Section */}
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
className="glass-panel rounded-3xl p-8 relative overflow-hidden"
>
<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">
<h1 className="text-3xl md:text-4xl font-bold text-white tracking-tight">tester5595</h1>
<p className="text-[#336EFF] font-medium"> {currentTime} · {greeting}</p>
<p className="text-sm text-slate-400 mt-4 max-w-xl leading-relaxed">
</p>
</div>
</motion.div>
{/* Metrics Cards */}
<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="最近 7 天上传" value="6" desc="最新更新于 2 小时前" icon={Upload} delay={0.2} />
<MetricCard title="本周课程" value="18" desc="今日还有 2 节课" icon={BookOpen} delay={0.3} />
<MetricCard title="已录入成绩" value="42" desc="最近学期2025 秋" icon={GraduationCap} delay={0.4} />
</div>
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Left Column */}
<div className="lg:col-span-2 space-y-6">
{/* Recent Files */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle></CardTitle>
<Button variant="ghost" size="sm" className="text-xs text-slate-400" onClick={() => navigate('/files')}>
<ChevronRight className="w-4 h-4 ml-1" />
</Button>
</CardHeader>
<CardContent>
<div className="space-y-2">
{[
{ 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 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">
<FileText className="w-5 h-5 text-[#336EFF]" />
</div>
<div className="truncate">
<p className="text-sm font-medium text-white truncate">{file.name}</p>
<p className="text-xs text-slate-400 mt-0.5">{file.time}</p>
</div>
</div>
<span className="text-xs text-slate-500 font-mono shrink-0 ml-4">{file.size}</span>
</div>
))}
</div>
</CardContent>
</Card>
{/* Schedule */}
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle> / </CardTitle>
<div className="flex bg-black/20 rounded-lg p-1">
<button className="px-3 py-1 text-xs font-medium rounded-md bg-[#336EFF] text-white shadow-sm transition-colors"></button>
<button className="px-3 py-1 text-xs font-medium rounded-md text-slate-400 hover:text-white transition-colors"></button>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[
{ 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 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="flex-1 truncate">
<p className="text-sm font-medium text-white truncate">{course.name}</p>
<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}
</p>
</div>
</div>
))}
</div>
</CardContent>
</Card>
</div>
{/* Right Column */}
<div className="space-y-6">
{/* Quick Actions */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-3">
<QuickAction icon={Upload} label="上传文件" onClick={() => navigate('/files')} />
<QuickAction icon={FolderPlus} label="新建文件夹" onClick={() => navigate('/files')} />
<QuickAction icon={Database} label="进入网盘" onClick={() => navigate('/files')} />
<QuickAction icon={GraduationCap} label="查询成绩" onClick={() => navigate('/school')} />
</div>
</CardContent>
</Card>
{/* Storage */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
</CardHeader>
<CardContent className="space-y-5">
<div className="flex justify-between items-end">
<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-xs text-slate-500 uppercase tracking-wider">使 / 50 GB</p>
</div>
<span className="text-xl font-mono text-[#336EFF] font-medium">25%</span>
</div>
<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>
</CardContent>
</Card>
{/* Account Info */}
<Card>
<CardHeader className="pb-4">
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<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">
T
</div>
<div className="flex-1 min-w-0">
<p className="text-sm font-semibold text-white truncate">tester5595</p>
<p className="text-xs text-slate-400 truncate mt-0.5">tester5595@example.com</p>
</div>
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function MetricCard({ title, value, desc, icon: Icon, delay }: any) {
return (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay }}
>
<Card className="h-full hover:bg-white/[0.04] transition-colors">
<CardContent className="p-6 flex flex-col gap-4">
<div className="flex justify-between items-start">
<div className="w-12 h-12 rounded-xl bg-gradient-to-br from-[#336EFF]/20 to-blue-500/10 flex items-center justify-center border border-[#336EFF]/20">
<Icon className="w-6 h-6 text-[#336EFF]" />
</div>
<span className="text-3xl font-bold text-white tracking-tight">{value}</span>
</div>
<div className="mt-2">
<p className="text-sm font-medium text-slate-300">{title}</p>
<p className="text-xs text-slate-500 mt-1">{desc}</p>
</div>
</CardContent>
</Card>
</motion.div>
);
}
function QuickAction({ icon: Icon, label, onClick }: any) {
return (
<button
onClick={onClick}
className="flex flex-col items-center justify-center gap-3 p-4 rounded-xl border border-white/5 bg-white/[0.02] hover:bg-white/[0.06] hover:border-white/10 transition-all group"
>
<Icon className="w-6 h-6 text-slate-400 group-hover:text-[#336EFF] transition-colors" />
<span className="text-xs font-medium text-slate-300 group-hover:text-white transition-colors">{label}</span>
</button>
);
}

291
front/src/pages/School.tsx Normal file
View File

@@ -0,0 +1,291 @@
import React, { useState } from 'react';
import { motion } from 'motion/react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/src/components/ui/card';
import { Button } from '@/src/components/ui/button';
import { Input } from '@/src/components/ui/input';
import { GraduationCap, Calendar, User, Lock, Search, BookOpen, ChevronRight, Award } from 'lucide-react';
import { cn } from '@/src/lib/utils';
export default function School() {
const [activeTab, setActiveTab] = useState<'schedule' | 'grades'>('schedule');
const [loading, setLoading] = useState(false);
const [queried, setQueried] = useState(false);
const handleQuery = (e: React.FormEvent) => {
e.preventDefault();
setLoading(true);
setTimeout(() => {
setLoading(false);
setQueried(true);
}, 1500);
};
return (
<div className="space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Query Form */}
<Card className="lg:col-span-1">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Search className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleQuery} className="space-y-4">
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<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 />
</div>
</div>
<div className="space-y-2">
<label className="text-xs font-medium text-slate-400 ml-1"></label>
<div className="relative">
<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 />
</div>
</div>
<div className="space-y-2">
<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]">
<option value="2025-spring">2025 </option>
<option value="2024-fall">2024 </option>
<option value="2024-spring">2024 </option>
</select>
</div>
<div className="grid grid-cols-2 gap-3 pt-2">
<Button type="submit" disabled={loading} className="w-full">
{loading ? '查询中...' : '查询课表'}
</Button>
<Button type="submit" variant="outline" disabled={loading} className="w-full" onClick={() => setActiveTab('grades')}>
{loading ? '查询中...' : '查询成绩'}
</Button>
</div>
</form>
</CardContent>
</Card>
{/* Data Summary */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<DatabaseIcon className="w-5 h-5 text-[#336EFF]" />
</CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
{queried ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryItem label="当前缓存账号" value="2023123456" icon={User} />
<SummaryItem label="已保存课表学期" value="2025 春" icon={Calendar} />
<SummaryItem label="已保存成绩" value="3 个学期" icon={Award} />
</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]">
<Search className="w-8 h-8 opacity-50" />
<p className="text-sm"></p>
</div>
)}
</CardContent>
</Card>
</div>
{/* View Toggle */}
<div className="flex bg-black/20 p-1 rounded-xl w-fit">
<button
onClick={() => setActiveTab('schedule')}
className={cn(
"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"
)}
>
</button>
<button
onClick={() => setActiveTab('grades')}
className={cn(
"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"
)}
>
</button>
</div>
{/* Content Area */}
<motion.div
key={activeTab}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
{activeTab === 'schedule' ? <ScheduleView queried={queried} /> : <GradesView queried={queried} />}
</motion.div>
</div>
);
}
function DatabaseIcon(props: any) {
return (
<svg
{...props}
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
>
<ellipse cx="12" cy="5" rx="9" ry="3" />
<path d="M3 5V19A9 3 0 0 0 21 19V5" />
<path d="M3 12A9 3 0 0 0 21 12" />
</svg>
);
}
function SummaryItem({ label, value, icon: Icon }: any) {
return (
<div className="p-4 rounded-xl bg-white/[0.02] border border-white/5 flex items-center gap-4">
<div className="w-10 h-10 rounded-lg bg-[#336EFF]/10 flex items-center justify-center shrink-0">
<Icon className="w-5 h-5 text-[#336EFF]" />
</div>
<div>
<p className="text-xs text-slate-400 mb-0.5">{label}</p>
<p className="text-sm font-medium text-white">{value}</p>
</div>
</div>
);
}
function ScheduleView({ queried }: { queried: boolean }) {
if (!queried) {
return (
<Card>
<CardContent className="h-64 flex flex-col items-center justify-center text-slate-500">
<BookOpen className="w-12 h-12 mb-4 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
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 (
<Card>
<CardHeader>
<CardTitle></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-5 gap-4">
{days.map((day, index) => (
<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">
{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>
</CardContent>
</Card>
);
}
function GradesView({ queried }: { queried: boolean }) {
if (!queried) {
return (
<Card>
<CardContent className="h-64 flex flex-col items-center justify-center text-slate-500">
<Award className="w-12 h-12 mb-4 opacity-20" />
<p></p>
</CardContent>
</Card>
);
}
const terms = [
{
name: '2024 秋',
grades: [75, 78, 80, 83, 85, 88, 89, 96]
},
{
name: '2025 春',
grades: [70, 78, 82, 84, 85, 85, 86, 88, 93]
},
{
name: '2025 秋',
grades: [68, 70, 76, 80, 85, 86, 90, 94, 97]
}
];
const getScoreStyle = (score: number) => {
if (score >= 95) return 'bg-[#336EFF]/50 text-white';
if (score >= 90) return 'bg-[#336EFF]/40 text-white/90';
if (score >= 85) return 'bg-[#336EFF]/30 text-white/80';
if (score >= 80) return 'bg-slate-700/60 text-white/70';
if (score >= 75) return 'bg-slate-700/40 text-white/60';
return 'bg-slate-800/60 text-white/50';
};
return (
<Card className="bg-[#0f172a]/80 backdrop-blur-sm border-slate-800/50">
<CardHeader className="pb-2">
<CardTitle className="text-lg font-medium text-white"></CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-3 gap-8">
{terms.map((term, i) => (
<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>
<div className="flex flex-col gap-2">
{term.grades.map((score, j) => (
<div
key={j}
className={cn(
"w-full py-1.5 rounded-full text-xs font-mono font-medium text-center transition-colors",
getScoreStyle(score)
)}
>
{score}
</div>
))}
</div>
</div>
))}
</div>
</CardContent>
</Card>
);
}

26
front/tsconfig.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"target": "ES2022",
"experimentalDecorators": true,
"useDefineForClassFields": false,
"module": "ESNext",
"lib": [
"ES2022",
"DOM",
"DOM.Iterable"
],
"skipLibCheck": true,
"moduleResolution": "bundler",
"isolatedModules": true,
"moduleDetection": "force",
"allowJs": true,
"jsx": "react-jsx",
"paths": {
"@/*": [
"./*"
]
},
"allowImportingTsExtensions": true,
"noEmit": true
}
}

24
front/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import tailwindcss from '@tailwindcss/vite';
import react from '@vitejs/plugin-react';
import path from 'path';
import {defineConfig, loadEnv} from 'vite';
export default defineConfig(({mode}) => {
const env = loadEnv(mode, '.', '');
return {
plugins: [react(), tailwindcss()],
define: {
'process.env.GEMINI_API_KEY': JSON.stringify(env.GEMINI_API_KEY),
},
resolve: {
alias: {
'@': path.resolve(__dirname, '.'),
},
},
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',
},
};
});

130
scripts/local-smoke.ps1 Normal file
View File

@@ -0,0 +1,130 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$backendLogOut = Join-Path $root 'backend-dev.out.log'
$backendLogErr = Join-Path $root 'backend-dev.err.log'
$frontendLogOut = Join-Path $root 'frontend-dev.out.log'
$frontendLogErr = Join-Path $root 'frontend-dev.err.log'
$javaExe = 'C:\Program Files\Java\jdk-22\bin\java.exe'
Remove-Item $backendLogOut, $backendLogErr, $frontendLogOut, $frontendLogErr -ErrorAction SilentlyContinue
$backend = Start-Process `
-FilePath $javaExe `
-ArgumentList '-jar', 'backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar', '--spring.profiles.active=dev' `
-WorkingDirectory $root `
-PassThru `
-RedirectStandardOutput $backendLogOut `
-RedirectStandardError $backendLogErr
try {
$backendReady = $false
for ($i = 0; $i -lt 40; $i++) {
Start-Sleep -Seconds 2
try {
$response = Invoke-WebRequest -Uri 'http://127.0.0.1:8080/swagger-ui.html' -UseBasicParsing -TimeoutSec 3
if ($response.StatusCode -eq 200) {
$backendReady = $true
break
}
}
catch {
}
}
if (-not $backendReady) {
throw '后端启动失败'
}
$userSuffix = Get-Random -Minimum 1000 -Maximum 9999
$username = "tester$userSuffix"
$email = "tester$userSuffix@example.com"
$password = 'pass123456'
$registerBody = @{
username = $username
email = $email
password = $password
} | ConvertTo-Json
$register = Invoke-RestMethod `
-Uri 'http://127.0.0.1:8080/api/auth/register' `
-Method Post `
-ContentType 'application/json' `
-Body $registerBody
$token = $register.data.token
if (-not $token) {
throw '注册未返回 token'
}
$headers = @{ Authorization = "Bearer $token" }
$profile = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/api/user/profile' -Headers $headers -Method Get
if ($profile.data.username -ne $username) {
throw '用户信息校验失败'
}
Invoke-RestMethod `
-Uri 'http://127.0.0.1:8080/api/files/mkdir' `
-Headers $headers `
-Method Post `
-ContentType 'application/x-www-form-urlencoded' `
-Body 'path=/docs' | Out-Null
$tempFile = Join-Path $root 'backend-upload-smoke.txt'
Set-Content -Path $tempFile -Value 'hello portal' -Encoding UTF8
& curl.exe -s -X POST -H "Authorization: Bearer $token" -F "path=/docs" -F "file=@$tempFile" http://127.0.0.1:8080/api/files/upload | Out-Null
$files = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/api/files/list?path=%2Fdocs&page=0&size=10' -Headers $headers -Method Get
if ($files.data.items.Count -lt 1) {
throw '文件列表为空'
}
$schedule = Invoke-RestMethod -Uri 'http://127.0.0.1:8080/api/cqu/schedule?semester=2025-2026-1&studentId=20230001' -Headers $headers -Method Get
if ($schedule.data.Count -lt 1) {
throw '课表接口为空'
}
$frontend = Start-Process `
-FilePath 'cmd.exe' `
-ArgumentList '/c', 'npm run dev -- --host 127.0.0.1 --port 4173' `
-WorkingDirectory (Join-Path $root 'vue') `
-PassThru `
-RedirectStandardOutput $frontendLogOut `
-RedirectStandardError $frontendLogErr
try {
$frontendReady = $false
for ($i = 0; $i -lt 30; $i++) {
Start-Sleep -Seconds 2
try {
$index = Invoke-WebRequest -Uri 'http://127.0.0.1:4173' -UseBasicParsing -TimeoutSec 3
if ($index.StatusCode -eq 200) {
$frontendReady = $true
break
}
}
catch {
}
}
if (-not $frontendReady) {
throw '前端启动失败'
}
Write-Output "BACKEND_OK username=$username"
Write-Output "FILES_OK count=$($files.data.items.Count)"
Write-Output "SCHEDULE_OK count=$($schedule.data.Count)"
Write-Output 'FRONTEND_OK url=http://127.0.0.1:4173'
}
finally {
if ($frontend -and -not $frontend.HasExited) {
Stop-Process -Id $frontend.Id -Force
}
}
}
finally {
Remove-Item (Join-Path $root 'backend-upload-smoke.txt') -ErrorAction SilentlyContinue
if ($backend -and -not $backend.HasExited) {
Stop-Process -Id $backend.Id -Force
}
}

View File

@@ -0,0 +1,37 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$javaExe = 'C:\Program Files\Java\jdk-22\bin\java.exe'
$out = Join-Path $root 'backend-dev.out.log'
$err = Join-Path $root 'backend-dev.err.log'
if (Test-Path $out) {
Remove-Item $out -Force
}
if (Test-Path $err) {
Remove-Item $err -Force
}
$proc = Start-Process `
-FilePath $javaExe `
-ArgumentList '-jar', 'backend/target/yoyuzh-portal-backend-0.0.1-SNAPSHOT.jar', '--spring.profiles.active=dev' `
-WorkingDirectory $root `
-PassThru `
-RedirectStandardOutput $out `
-RedirectStandardError $err
Start-Sleep -Seconds 10
try {
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:8080/swagger-ui.html' -UseBasicParsing -TimeoutSec 5
Write-Output "PID=$($proc.Id)"
Write-Output "STATUS=$($resp.StatusCode)"
Write-Output 'URL=http://127.0.0.1:8080/swagger-ui.html'
}
catch {
Write-Output "PID=$($proc.Id)"
Write-Output 'STATUS=STARTED_BUT_NOT_READY'
if (Test-Path $err) {
Get-Content -Tail 40 $err
}
}

View File

@@ -0,0 +1,36 @@
$ErrorActionPreference = 'Stop'
$root = Split-Path -Parent $PSScriptRoot
$frontendLogOut = Join-Path $root 'frontend-dev.out.log'
$frontendLogErr = Join-Path $root 'frontend-dev.err.log'
if (Test-Path $frontendLogOut) {
Remove-Item $frontendLogOut -Force
}
if (Test-Path $frontendLogErr) {
Remove-Item $frontendLogErr -Force
}
$proc = Start-Process `
-FilePath 'cmd.exe' `
-ArgumentList '/c', 'npm run dev -- --host 127.0.0.1 --port 4173' `
-WorkingDirectory (Join-Path $root 'vue') `
-PassThru `
-RedirectStandardOutput $frontendLogOut `
-RedirectStandardError $frontendLogErr
Start-Sleep -Seconds 6
try {
$resp = Invoke-WebRequest -Uri 'http://127.0.0.1:4173' -UseBasicParsing -TimeoutSec 5
Write-Output "PID=$($proc.Id)"
Write-Output "STATUS=$($resp.StatusCode)"
Write-Output 'URL=http://127.0.0.1:4173'
}
catch {
Write-Output "PID=$($proc.Id)"
Write-Output 'STATUS=STARTED_BUT_NOT_READY'
if (Test-Path $frontendLogErr) {
Get-Content -Tail 40 $frontendLogErr
}
}

View File

@@ -1,267 +0,0 @@
下面这份是**工程级**的 TODO ListMarkdown按“能上线”的路径拆好了里程碑 → 任务 → 验收点。你前端已经做了一部分,就从 **FE-Desktop / FE-Apps** 里把已完成的勾上即可。
---
# Web Desktop 项目工程 TODO可上线版
> 维护规则:
>
> * 每个任务尽量做到“可交付 + 可验收”。
> * 任务粒度1~4 小时能完成为宜。
> * 每周至少推进一个 Milestone 到可演示状态。
---
## 0. 里程碑总览
* [ ] **M0工程骨架就绪能跑通 dev / staging**
* [ ] **M1账号体系 + 桌面壳可用(基础可演示)**
* [ ] **M2网盘 MVPOSS 直传闭环)**
* [ ] **M3分享/审计/配额/管理后台(上线门槛)**
* [ ] **M4Campus BFF 接 Rust API课表/成绩缓存降级)**
* [ ] **M5论坛/地图完善 + 监控告警 + 上线演练**
---
## 1. M0 工程骨架就绪
### Repo / 工程结构
* [ ] 初始化 mono-repo 或多 repo 结构(建议:`frontend/` `backend/` `infra/`
* [ ] 统一 lint/formatESLint/Prettier + 后端 formatter
* [ ] 统一 commit 规范可选commitlint
* [ ] 统一环境变量模板:`.env.example`(前后端分开)
* [ ] 基础 README本地启动、部署、配置项说明
### 本地开发环境
* [ ] docker-composedb + redis + backend + (可选) nginx
* [ ] 一键启动脚本:`make dev` / `npm run dev:all`
* [ ] staging 配置:独立域名/反代/证书(哪怕自签)
### 基础 CI至少跑检查
* [ ] PR 触发lint + typecheck + unit test最小集合
* [ ] build 产物frontend build / backend build
**验收点**
* [ ] 新电脑 clone 后 30 分钟内能跑起来(含 db
---
## 2. M1 账号体系 + 桌面壳
### BE-Auth
* [ ] 用户注册/登录JWT + refresh 或 session 二选一)
* [ ] 密码加密argon2/bcrypt
* [ ] `GET /auth/me`
* [ ] 登录失败限流(例如 5 次/5 分钟)
* [ ] 基础用户状态normal / banned
* [ ] request_id 全链路middleware
### FE-Auth
* [ ] 登录/注册/找回页面
* [ ] token/会话续期策略
* [ ] 全局错误处理(统一 toast + request_id
### FE-Desktop你已做一部分这里把你已有的勾上
* [ ] 桌面布局:图标/分组/壁纸/主题
* [ ] 窗口系统:打开/关闭/最小化/最大化/拖拽/层级
* [ ] 最近使用 / 收藏
* [ ] 全局搜索:应用搜索(先做)
* [ ] 通知中心壳(先只做 UI
### BE-Desktop
* [ ] user_settings 表layout/theme/wallpaper
* [ ] `GET /desktop/settings` / `PUT /desktop/settings`
* [ ] `GET /desktop/apps`(服务端下发应用配置,方便后续开关)
**验收点**
* [ ] 新用户登录后能看到桌面;布局修改刷新后不丢
* [ ] 被封禁用户无法登录(提示明确)
---
## 3. M2 网盘 MVPOSS 直传闭环)
### BE-Drive 元数据
* [ ] files 表user_id, parent_id, name, size, mime, oss_key, deleted_at…
* [ ] 目录增删改查create folder / rename / move / list
* [ ] 软删除 + 回收站 list/restore
* [ ] 文件名净化(防 XSS/路径注入)
### BE-OSS 直传
* [ ] `POST /drive/upload/init`:生成 oss_key + STS/Policy带过期时间
* [ ] 分片策略chunk_size / multipart建议直接支持
* [ ] `POST /drive/upload/complete`:写入元数据(校验 size/etag
* [ ] `GET /drive/download/{id}`:签名 URL短期有效
* [ ] 下载审计:记录 download_sign
### FE-Drive
* [ ] 文件列表:分页/排序/面包屑
* [ ] 上传:小文件 + 大文件分片 + 断点续传
* [ ] 上传队列:暂停/继续/失败重试
* [ ] 预览:图片/PDF/文本
* [ ] 删除/恢复/彻底删除(回收站)
* [ ] 文件搜索(文件名)
**验收点**
* [ ] 上传→列表出现→预览/下载→删除→回收站恢复闭环
* [ ] 网络断开后能续传(至少同一次会话内)
---
## 4. M3 分享 / 审计 / 配额 / 管理后台(上线门槛)
### BE-Share
* [ ] 创建分享:有效期、提取码、权限(预览/下载)
* [ ] 分享访问页:`GET /share/{token}`
* [ ] 下载:`POST /share/{token}/download`(校验提取码后返回签名 URL
* [ ] 撤销分享:立即失效
* [ ] 分享访问审计ip/ua/time/count
### BE-Quota & RateLimit
* [ ] 用户配额:总容量、单文件大小、日上传/日下载
* [ ] 配额校验upload/init、complete、download/sign
* [ ] 限流:登录、绑定校园、成绩刷新、签名下载、分享访问
### BE-Audit
* [ ] audit_logs关键操作埋点upload_init/upload_complete/download_sign/share_create…
* [ ] 查询接口:按 user/action/time 过滤(管理员)
### Admin最小管理后台
* [ ] 用户管理:封禁/解封
* [ ] 配额配置:默认值 + 单用户覆盖(可选)
* [ ] OSS 配置bucket/STS 策略(至少可查看)
* [ ] 审计查询页
**验收点**
* [ ] 超配额时前后端提示一致且不可绕过
* [ ] 分享链接可用、可撤销、访问可审计
* [ ] 管理员能查到关键操作日志
---
## 5. M4 Campus BFF接 Rust API课表/成绩)
> 核心:**平台后端不让前端直连 Rust API**,统一做鉴权、缓存、熔断、错误码映射。
### BE-Campus 绑定与凭据
* [ ] `POST /campus/bind`:绑定校园账号(加密存储 credential / 或保存 rust session_token
* [ ] `POST /campus/unbind`:解绑并删除凭据
* [ ] 凭据加密密钥不入库env + KMS 可选)
* [ ] 绑定/查询限流(防封控)
### BE-Campus Rust API 网关层
* [ ] Rust API client超时、重试只读、熔断
* [ ] 健康检查:/healthz 探测 + 指标
* [ ] DTO 适配层Rust 返回字段变化不直接打爆前端
* [ ] 错误码映射Rust error → 平台 error code
### BE-Campus 缓存与降级
* [ ] campus_cache课表/成绩 TTL课表 12h成绩 24h
* [ ] 手动刷新冷却时间(成绩建议更长)
* [ ] Rust 不可用时返回缓存 + 标注更新时间
### FE-Campus
* [ ] 绑定页面(学号/密码或 token
* [ ] 课表周视图/日视图
* [ ] 成绩学期视图 + 列表
* [ ] “刷新”按钮(带冷却提示)
* [ ] “数据更新时间 / 当前为缓存”提示
**验收点**
* [ ] Rust API 挂了:仍能展示缓存且不白屏
* [ ] 频繁刷新会被限流并提示
---
## 6. M5 论坛/地图完善 + 监控告警 + 上线演练
### Forum按 Rust API 能力)
* [ ] 板块列表/帖子列表/详情/评论
* [ ] 发帖/评论(幂等键 Idempotency-Key
* [ ] 内容风控:频率限制 + 基础敏感词(最小)
* [ ] 举报入口(最小)
* [ ] 通知:回复/提及(站内通知)
### Map
* [ ] POI 展示:分类 + 搜索
* [ ] 地图 SDK 接入Leaflet/高德/腾讯择一)
* [ ] POI 缓存 7d + 更新策略
* [ ]可选POI 后台维护
### Observability上线前必须补
* [ ] 指标API 错误率、P95、Rust 成功率、OSS 上传失败率
* [ ] 日志:结构化 + request_id
* [ ] 告警Rust 健康异常、错误率激增、DB/Redis 异常
* [ ] 错误追踪Sentry 或同类(可选但强建议)
### 安全加固(上线前必做清单)
* [ ] CSP/安全头X-Frame-Options 等)
* [ ] 上传文件类型限制 + 文件名净化
* [ ] 权限回归测试:越权访问用例全覆盖
* [ ] Secrets 全部迁移到安全配置(不进仓库)
### 上线演练
* [ ] staging 环境全链路演练(含 OSS、Rust API
* [ ] 灰度发布流程(最小:可回滚)
* [ ] 数据库备份与恢复演练
* [ ] 压测(最少测下载签名/列表/校园查询)
**验收点**
* [ ] staging → prod 一键发布可回滚
* [ ] 关键告警触发能收到(邮件/IM 随便一种)
---
## 7. 你当前“前端已做一部分”的对齐清单(快速标记)
把你已经完成的模块在这里勾上,方便我后续给你拆“下一步最优先做什么”:
* [ ] 桌面图标布局
* [ ] 窗口拖拽/层级
* [ ] 应用打开/关闭/最小化
* [ ] 主题/壁纸
* [ ] 网盘 UI列表/上传面板/预览)
* [ ] 校园 UI课表/成绩/论坛/地图)
* [ ] 游戏应用容器
---
## 8. 最小上线 Checklist不做这些别上线
* [ ] 后端鉴权与资源隔离(不可只靠前端)
* [ ] OSS 长期密钥不下发前端(只给 STS/签名)
* [ ] 下载签名短期有效 + 审计
* [ ] 限流(登录/绑定/校园刷新/签名下载/分享访问)
* [ ] Rust API 超时/熔断/缓存降级
* [ ] 结构化日志 + request_id
* [ ] staging 环境演练 + 回滚方案

24
vue/.gitignore vendored
View File

@@ -1,24 +0,0 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

View File

@@ -1,3 +0,0 @@
{
"recommendations": ["Vue.volar"]
}

View File

@@ -1,11 +0,0 @@
# Vue 3 + TypeScript + Vite
This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about the recommended Project Setup and IDE Support in the [Vue Docs TypeScript Guide](https://vuejs.org/guide/typescript/overview.html#project-setup).
## Project Structure
- `public/`: static source assets (includes `race` and `t_race` game files)
- `src/`: Vue app source code
- `dist/`: build output directory generated by `npm run build` (not source, can be deleted anytime)

1834
vue/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,32 +0,0 @@
{
"name": "test1",
"version": "0.0.0",
"private": true,
"description": "This template should help get you started developing with Vue 3 and TypeScript in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.",
"keywords": [
"123456"
],
"license": "ISC",
"author": "yoyuzh",
"type": "module",
"main": "index.js",
"scripts": {
"dev": "vite",
"build": "vue-tsc -b && vite build",
"preview": "vite preview",
"test": "vitest run"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.2",
"vue": "^3.5.25"
},
"devDependencies": {
"@types/node": "^24.10.1",
"@vitejs/plugin-vue": "^6.0.2",
"@vue/tsconfig": "^0.8.1",
"typescript": "~5.9.3",
"vite": "^7.3.1",
"vitest": "^4.0.18",
"vue-tsc": "^3.1.5"
}
}

View File

@@ -1,178 +0,0 @@
'use strict';
///////////////////////////////////////////////////////////////////////////////
// Audio settings
let soundEnable = 1;
let soundVolume = .3;
///////////////////////////////////////////////////////////////////////////////
class Sound
{
constructor(zzfxSound)
{
if (!soundEnable) return;
// generate zzfx sound now for fast playback
this.randomness = zzfxSound[1] || 0;
this.samples = zzfxG(...zzfxSound);
}
play(volume=1, pitch=1)
{
if (!soundEnable) return;
// play the sound
const playbackRate = pitch + this.randomness*rand(-pitch,pitch);
return playSamples(this.samples, volume, playbackRate);
}
playNote(semitoneOffset, pos, volume)
{ return this.play(pos, volume, 2**(semitoneOffset/12), 0); }
}
///////////////////////////////////////////////////////////////////////////////
let audioContext;
function playSamples(samples, volume, rate)
{
const sampleRate=zzfxR;
if (!soundEnable || isTouchDevice && !audioContext)
return;
if (!audioContext)
audioContext = new AudioContext; // create audio context
// prevent sounds from building up if they can't be played
if (audioContext.state != 'running')
{
// fix stalled audio
audioContext.resume();
return; // prevent suspended sounds from building up
}
// create buffer and source
const buffer = audioContext.createBuffer(1, samples.length, sampleRate),
source = audioContext.createBufferSource();
// copy samples to buffer and setup source
buffer.getChannelData(0).set(samples);
source.buffer = buffer;
source.playbackRate.value = rate;
// create and connect gain node (createGain is more widely spported then GainNode construtor)
const gainNode = audioContext.createGain();
gainNode.gain.value = soundVolume*volume;
gainNode.connect(audioContext.destination);
// connect source to stereo panner and gain
//source.connect(new StereoPannerNode(audioContext, {'pan':clamp(pan, -1, 1)})).connect(gainNode);
source.connect(gainNode);
// play and return sound
source.start();
return source;
}
///////////////////////////////////////////////////////////////////////////////
// ZzFXMicro - Zuper Zmall Zound Zynth - v1.3.1 by Frank Force
const zzfxR = 44100;
function zzfxG
(
// parameters
volume = 1, randomness, frequency = 220, attack = 0, sustain = 0,
release = .1, shape = 0, shapeCurve = 1, slide = 0, deltaSlide = 0,
pitchJump = 0, pitchJumpTime = 0, repeatTime = 0, noise = 0, modulation = 0,
bitCrush = 0, delay = 0, sustainVolume = 1, decay = 0, tremolo = 0, filter = 0
)
{
// init parameters
let PI2 = PI*2, sampleRate = zzfxR,
startSlide = slide *= 500 * PI2 / sampleRate / sampleRate,
startFrequency = frequency *= PI2 / sampleRate, // no randomness
// rand(1 + randomness, 1-randomness) * PI2 / sampleRate,
b = [], t = 0, tm = 0, i = 0, j = 1, r = 0, c = 0, s = 0, f, length,
// biquad LP/HP filter
quality = 2, w = PI2 * abs(filter) * 2 / sampleRate,
cos = Math.cos(w), alpha = Math.sin(w) / 2 / quality,
a0 = 1 + alpha, a1 = -2*cos / a0, a2 = (1 - alpha) / a0,
b0 = (1 + sign(filter) * cos) / 2 / a0,
b1 = -(sign(filter) + cos) / a0, b2 = b0,
x2 = 0, x1 = 0, y2 = 0, y1 = 0;
// scale by sample rate
attack = attack * sampleRate + 9; // minimum attack to prevent pop
decay *= sampleRate;
sustain *= sampleRate;
release *= sampleRate;
delay *= sampleRate;
deltaSlide *= 500 * PI2 / sampleRate**3;
modulation *= PI2 / sampleRate;
pitchJump *= PI2 / sampleRate;
pitchJumpTime *= sampleRate;
repeatTime = repeatTime * sampleRate | 0;
ASSERT(shape != 3 && shape != 2); // need save space
// generate waveform
for(length = attack + decay + sustain + release + delay | 0;
i < length; b[i++] = s * volume) // sample
{
if (!(++c%(bitCrush*100|0))) // bit crush
{
s = shape? shape>1?
//shape>2? shape>3? // wave shape
//Math.sin(t**3) : // 4 noise
//clamp(Math.tan(t),1,-1): // 3 tan
1-(2*t/PI2%2+2)%2: // 2 saw
1-4*abs(Math.round(t/PI2)-t/PI2): // 1 triangle
Math.sin(t); // 0 sin
s = (repeatTime ?
1 - tremolo + tremolo*Math.sin(PI2*i/repeatTime) // tremolo
: 1) *
sign(s)*(abs(s)**shapeCurve) * // curve
(i < attack ? i/attack : // attack
i < attack + decay ? // decay
1-((i-attack)/decay)*(1-sustainVolume) : // decay falloff
i < attack + decay + sustain ? // sustain
sustainVolume : // sustain volume
i < length - delay ? // release
(length - i - delay)/release * // release falloff
sustainVolume : // release volume
0); // post release
s = delay ? s/2 + (delay > i ? 0 : // delay
(i<length-delay? 1 : (length-i)/delay) * // release delay
b[i-delay|0]/2/volume) : s; // sample delay
if (filter) // apply filter
s = y1 = b2*x2 + b1*(x2=x1) + b0*(x1=s) - a2*y2 - a1*(y2=y1);
}
f = (frequency += slide += deltaSlide) *// frequency
Math.cos(modulation*tm++); // modulation
t += f + f*noise*Math.sin(i**5); // noise
if (j && ++j > pitchJumpTime) // pitch jump
{
frequency += pitchJump; // apply pitch jump
startFrequency += pitchJump; // also apply to start
j = 0; // stop pitch jump time
}
if (repeatTime && !(++r % repeatTime)) // repeat
{
frequency = startFrequency; // reset frequency
slide = startSlide; // reset slide
j = j || 1; // reset pitch jump time
}
}
return b;
}

View File

@@ -1,261 +0,0 @@
'use strict';
const debug = 1;
let enhancedMode = 1;
let enableAsserts = 1;
let devMode = 0;
let downloadLink, debugMesh, debugTile, debugCapture, debugCanvas;
let debugGenerativeCanvas=0, debugInfo=0, debugSkipped=0;
let debugGenerativeCanvasCached, showMap;
let freeCamPos, freeCamRot, mouseDelta;
const js13kBuildLevel2 = 0; // more space is needed for js13k
function ASSERT(assert, output)
{ enableAsserts&&(output ? console.assert(assert, output) : console.assert(assert)); }
function LOG() { console.log(...arguments); }
///////////////////////////////////////////////////////////////////////////////
function debugInit()
{
freeCamPos = vec3();
freeCamRot = vec3();
mouseDelta = vec3();
debugCanvas = document.createElement('canvas');
downloadLink = document.createElement('a');
}
function debugUpdate()
{
if (!devMode)
return;
if (keyWasPressed('KeyG')) // free Cam
{
freeCamMode = !freeCamMode;
if (!freeCamMode)
{
document.exitPointerLock();
cameraPos = vec3();
cameraRot = vec3();
}
}
if (freeCamMode)
{
if (!document.pointerLockElement)
{
mainCanvas.requestPointerLock();
freeCamPos = cameraPos.copy();
freeCamRot = cameraRot.copy();
}
const input = vec3(
keyIsDown('KeyD') - keyIsDown('KeyA'),
keyIsDown('KeyE') - keyIsDown('KeyQ'),
keyIsDown('KeyW') - keyIsDown('KeyS'));
const moveSpeed = keyIsDown('ShiftLeft') ? 500 : 100;
const turnSpeed = 2;
const moveDirection = input.rotateX(freeCamRot.x).rotateY(-freeCamRot.y);
freeCamPos = freeCamPos.add(moveDirection.scale(moveSpeed));
freeCamRot = freeCamRot.add(vec3(mouseDelta.y,mouseDelta.x).scale(turnSpeed));
freeCamRot.x = clamp(freeCamRot.x, -PI/2, PI/2);
mouseDelta = vec3();
}
if (keyWasPressed('Digit1') || keyWasPressed('Digit2'))
{
const d = keyWasPressed('Digit2') ? 1 : -1;
playerVehicle.pos.z += d * checkpointDistance;
playerVehicle.pos.z = max(playerVehicle.pos.z, 0);
checkpointTimeLeft = 40;
debugSkipped = 1;
}
if (keyIsDown('Digit3') || keyIsDown('Digit4'))
{
const v = keyIsDown('Digit4') ? 1e3 : -1e3;
playerVehicle.pos.z += v;
playerVehicle.pos.z = max(playerVehicle.pos.z, 0);
const trackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
playerVehicle.pos.y = trackInfo.offset.y;
playerVehicle.pos.x = 0;
// update world heading based on speed and track turn
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
worldHeading += v*cameraTrackInfo.offset.x/turnWorldScale;
debugSkipped = 1;
}
if (keyWasPressed('Digit5'))
checkpointTimeLeft=12
if (keyWasPressed('Digit6'))
{
// randomize track
trackSeed = randInt(1e9);
//initGenerative();
const endLevel = levelInfoList.pop();
shuffle(endLevel.scenery);
shuffle(levelInfoList);
for(let i=levelInfoList.length; i--;)
{
const info = levelInfoList[i];
info.level = i;
info.randomize();
}
levelInfoList.push(endLevel);
buildTrack();
for(const s in spriteList)
{
const sprite = spriteList[s];
if (sprite instanceof GameSprite)
sprite.randomize();
}
const playerTrackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
playerVehicle.pos.y = playerTrackInfo.offset.y;
//gameStart();
}
if (keyWasPressed('Digit7'))
debugGenerativeCanvas = !debugGenerativeCanvas;
if (keyWasPressed('Digit0'))
debugCapture = 1;
if (keyWasPressed('KeyQ') && !freeCamMode)
testDrive = !testDrive
if (keyWasPressed('KeyU'))
sound_win.play();
if (debug && keyWasPressed('KeyV'))
spawnVehicle(playerVehicle.pos.z-1300)
//if (!document.hasFocus())
// testDrive = 1;
}
function debugDraw()
{
if (!debug)
return;
if (debugInfo && !debugCapture)
drawHUDText((averageFPS|0) + 'fps / ' + glBatchCountTotal + ' / ' + glDrawCalls + ' / ' + vehicles.length, vec3(.98,.12),.03, undefined, 'monospace','right');
const c = mainCanvas;
const context = mainContext;
if (testDrive && !titleScreenMode && !freeRide)
drawHUDText('AUTO', vec3(.5,.95),.05,RED);
if (showMap)
{
// draw track map preview
context.save();
context.beginPath();
for(let k=2;k--;)
{
let x=0, v=0;
let p = vec3();
let d = vec3(0,-.5);
for(let i=0; i < 1e3; i++)
{
let j = playerVehicle.pos.z/trackSegmentLength+i-100|0;
if (!track[j])
continue;
const t = track[j];
const o = t.offset;
v += o.x;
p = p.add(d.rotateZ(v*.005));
if (j%5==0)
{
let y = o.y;
let w = t.width/199;
const h = k ? 5 : -y*.01;
context.fillStyle=hsl(y*.0001,1,k?0:.5,k?.5:1);
context.fillRect(c.width-200+p.x,c.height-100+p.y+h,w,w);
//context.fillRect(c.width-200+x/199,c.height-100-i/2+o,w,w);
}
}
}
context.restore();
}
if (debugGenerativeCanvas)
{
const s = 512;
//context.imageSmoothingEnabled = false;
context.drawImage(debugGenerativeCanvasCached, 0, 0, s, s);
// context.strokeRect(0, 0, s, s);
}
if (debugCapture)
{
debugCapture = 0;
const context = debugCanvas.getContext('2d');
debugCanvas.width = mainCanvas.width;
debugCanvas.height = mainCanvas.height;
context.fillStyle = '#000';
context.fillRect(0,0,mainCanvas.width,mainCanvas.height);
context.drawImage(glCanvas, 0, 0);
context.drawImage(mainCanvas, 0, 0);
debugSaveCanvas(debugCanvas);
}
{
// test render
//debugMesh = cylinderMesh;
debugMesh && debugMesh.render(buildMatrix(cameraPos.add(vec3(0,400,1000)), vec3(0,time,0), vec3(200)), WHITE);
//debugTile = vec3(0,1)
if (debugTile)
{
const s = 256*2, w = generativeTileSize, v = debugTile.scale(w);
const x = mainCanvas.width/2-s/2;
context.fillStyle = '#5f5';
context.fillRect(x, 0, s, s);
context.drawImage(debugGenerativeCanvasCached, v.x, v.y, w, w, x, 0, s, s);
context.strokeRect(x, 0, s, s);
//pushTrackObject(cameraPos.add(vec3(0,0,100)), vec3(100), WHITE, debugTile);
}
}
if (0) // world cube
{
const r = vec3(0,-worldHeading,0);
const m1 = buildMatrix(vec3(2220,1e3,2e3), r, vec3(200));
cubeMesh.render(m1, hsl(0,.8,.5));
}
if (0)
{
// test noise
context.fillStyle = '#fff';
context.fillRect(0, 0, 500, 500);
context.fillStyle = '#000';
for(let i=0; i < 1e3; i++)
{
const n = noise1D(i/129-time*9)*99;
context.fillRect(i, 200+n, 9, 9);
}
}
//cubeMesh.render(buildMatrix(vec3(0,-500,0), vec3(0), vec3(1e5,10,1e5)), RED); // ground
//cylinderMesh.render(buildMatrix(cameraPos.add(vec3(0,400,1000)), vec3(time,time/2,time/3), vec3(200)), WHITE);
//let t = new Tile(vec3(64*2,0), vec3(128));
//pushSprite(cameraPos.add(vec3(0,400,1000)), vec3(200), WHITE, t);
glRender();
}
///////////////////////////////////////////////////////////////////////////////
function debugSaveCanvas(canvas, filename='screenshot', type='image/png')
{ debugSaveDataURL(canvas.toDataURL(type), filename); }
function debugSaveText(text, filename='text', type='text/plain')
{ debugSaveDataURL(URL.createObjectURL(new Blob([text], {'type':type})), filename); }
function debugSaveDataURL(dataURL, filename)
{
downloadLink.download = filename;
downloadLink.href = dataURL;
downloadLink.click();
}

View File

@@ -1,472 +0,0 @@
'use strict';
let cubeMesh, quadMesh, shadowMesh, cylinderMesh, carMesh, carWheel;
const bleedPixels = 8;
const WHITE = rgb();
const BLACK = rgb(0,0,0);
const RED = rgb(1,0,0);
const ORANGE = rgb(1,.5,0);
const YELLOW = rgb(1,1,0);
const GREEN = rgb(0,1,0);
const CYAN = rgb(0,1,1);
const BLUE = rgb(0,0,1);
const PURPLE = rgb(.5,0,1);
const MAGENTA= rgb(1,0,1);
const GRAY = rgb(.5,.5,.5);
let spriteList;
let testGameSprite;
///////////////////////////////////////////////////////////////////////////////
function initSprites()
{
//spriteList
//(tilePos, size=1e3, sizeRandomness=0, windScale=0, collideSize=60)
spriteList = {};
// trees
spriteList.tree_palm = new GameSprite(vec3(0,1),1500,.2,.1,.04);
spriteList.tree_palm.trackFace = 1;
spriteList.tree_oak = new GameSprite(vec3(1,1),2e3,.5,.06,.1);
spriteList.tree_stump = new GameSprite(vec3(2,1),1e3,.6,.04);
spriteList.tree_dead = new GameSprite(vec3(3,1),1e3,.3,.1,.06);
spriteList.tree_pink = new GameSprite(vec3(4,1),1500,.3,.1,.04);
spriteList.tree_pink.trackFace = 1;
spriteList.tree_bush = new GameSprite(vec3(5,1),1e3,.5,.1,.06);
spriteList.tree_fall = new GameSprite(vec3(6,1),1500,.3,.1,.1);
//TB(spriteList.tree_flower = new GameSprite(vec3(7,1),2e3,.3,.05,200));
spriteList.tree_snow = new GameSprite(vec3(4,3),1300,.3,.06,.1)
spriteList.tree_yellow = new GameSprite(vec3(5,3),1e3,.3,.06,.1)
spriteList.tree_huge = new GameSprite(vec3(3,1),1e4,.5,.1,.1)
spriteList.tree_huge.colorHSL = vec3(.8, 0, .5);
spriteList.tree_huge.shadowScale = 0;
// smaller tree shadows
spriteList.tree_palm.shadowScale =
spriteList.tree_oak.shadowScale =
spriteList.tree_stump.shadowScale =
spriteList.tree_dead.shadowScale =
spriteList.tree_pink.shadowScale =
spriteList.tree_bush.shadowScale =
spriteList.tree_fall.shadowScale =
spriteList.tree_snow.shadowScale =
spriteList.tree_yellow.shadowScale = .7;
// grass and flowers
spriteList.grass_plain = new GameSprite(vec3(0,3),500,.5,1);
spriteList.grass_plain.colorHSL = vec3(.3, .4, .5);
spriteList.grass_dead = new GameSprite(vec3(0,3),600,.3,1);
spriteList.grass_dead.colorHSL = vec3(.13, .6, .7);
spriteList.grass_flower1 = new GameSprite(vec3(1,3),500,.3,1);
spriteList.grass_flower2 = new GameSprite(vec3(2,3),500,.3,1);
spriteList.grass_flower3 = new GameSprite(vec3(3,3),500,.3,1);
spriteList.grass_red = new GameSprite(vec3(0,3),700,.3,1)
spriteList.grass_red.colorHSL = vec3(0, .8, .5);
spriteList.grass_snow = new GameSprite(vec3(0,3),300,.5,1)
spriteList.grass_snow.colorHSL = vec3(.4, 1, .9);
spriteList.grass_large = new GameSprite(vec3(0,3),1e3,.5,1);
spriteList.grass_large.colorHSL = vec3(.4, .4, .5);
//spriteList.grass_huge = new GameSprite(vec3(0,3),1e4,.6,.5,5e3);
//spriteList.grass_huge.colorHSL = vec3(.8, .5, .5);
//spriteList.grass_huge.hueRandomness = .2;
// billboards
spriteList.billboards = [];
const PB = (s)=>spriteList.billboards.push(s);
PB(spriteList.sign_opGames = new GameSprite(vec3(5,2),600,0,.02,.5,0));
PB(spriteList.sign_js13k = new GameSprite(vec3(0,2),600,0,.02,1,0));
PB(spriteList.sign_zzfx = new GameSprite(vec3(1,2),500,0,.02,.5,0));
PB(spriteList.sign_avalanche = new GameSprite(vec3(7,2),600,0,.02,1,0));
PB(spriteList.sign_github = new GameSprite(vec3(2,2),750,0,.02,.5,0));
//PB(spriteList.sign_littlejs = new GameSprite(vec3(4,2),600,0,.02,1,0));
spriteList.sign_frankForce = new GameSprite(vec3(3,2),500,0,.02,1,0);
//PB(spriteList.sign_dwitter = new GameSprite(vec3(6,2),550,0,.02,1,0));
// signs
spriteList.sign_turn = new GameSprite(vec3(0,5),500,0,.05,.5);
spriteList.sign_turn.trackFace = 1; // signs face track
//spriteList.sign_curve = new GameSprite(vec3(1,5),500,0,.05,.5);
//spriteList.sign_curve.trackFace = 1; // signs face track
//spriteList.sign_warning = new GameSprite(vec3(2,5),500,0,.05,1,0);
//spriteList.sign_speed = new GameSprite(vec3(4,5),500,0,.05,50,0);
//spriteList.sign_interstate = new GameSprite(vec3(5,5),500,0,.05,50,0);
// rocks
spriteList.rock_tall = new GameSprite(vec3(1,4),1e3,.3,0,.6,0);
spriteList.rock_big = new GameSprite(vec3(2,4),800,.2,0,.57,0);
spriteList.rock_huge = new GameSprite(vec3(1,4),5e3,.7,0,.6,0);
spriteList.rock_huge.shadowScale = 0;
spriteList.rock_huge.colorHSL = vec3(.08, 1, .8);
spriteList.rock_huge.hueRandomness = .01;
spriteList.rock_huge2 = new GameSprite(vec3(2,4),8e3,.5,0,.25,0);
spriteList.rock_huge2.shadowScale = 0;
spriteList.rock_huge2.colorHSL = vec3(.05, 1, .8);
spriteList.rock_huge2.hueRandomness = .01;
spriteList.rock_huge3 = new GameSprite(vec3(2,4),8e3,.7,0,.5,0);
spriteList.rock_huge3.shadowScale = 0;
spriteList.rock_huge3.colorHSL = vec3(.05, 1, .8);
spriteList.rock_huge3.hueRandomness = .01;
spriteList.rock_weird = new GameSprite(vec3(2,4),5e3,.5,0,1,0);
spriteList.rock_weird.shadowScale = 0;
spriteList.rock_weird.colorHSL = vec3(.8, 1, .8);
spriteList.rock_weird.hueRandomness = .2;
spriteList.rock_weird2 = new GameSprite(vec3(1,4),1e3,.5,0,.5,0);
spriteList.rock_weird2.colorHSL = vec3(0, 0, .2);
spriteList.tunnel1 = new GameSprite(vec3(6,4),1e4,.0,0,0,0);
spriteList.tunnel1.shadowScale = 0;
spriteList.tunnel1.colorHSL = vec3(.05, 1, .8);
spriteList.tunnel1.tunnelArch = 1;
spriteList.tunnel2 = new GameSprite(vec3(7,4),5e3,0,0,0,0);
spriteList.tunnel2.shadowScale = 0;
spriteList.tunnel2.colorHSL = vec3(0, 0, .1);
spriteList.tunnel2.tunnelLong = 1;
spriteList.tunnel2Front = new GameSprite(vec3(7,4),5e3,0,0,0,0);
spriteList.tunnel2Front.shadowScale = 0;
spriteList.tunnel2Front.colorHSL = vec3(0,0,.8);
//spriteList.tunnel2_rock = new GameSprite(vec3(6,6),1e4,.2,0,.5,0);
//spriteList.tunnel2_rock.colorHSL = vec3(.15, .5, .8);
// hazards
spriteList.hazard_rocks = new GameSprite(vec3(3,4),600,.2,0,.9);
spriteList.hazard_rocks.shadowScale = 0;
spriteList.hazard_rocks.isBump = 1;
spriteList.hazard_rocks.spriteYOffset = -.02;
spriteList.hazard_sand = new GameSprite(vec3(4,4),600,.2,0,.9);
spriteList.hazard_sand.shadowScale = 0;
spriteList.hazard_sand.isSlow = 1;
spriteList.hazard_sand.spriteYOffset = -.02;
//spriteList.hazard_snow = new GameSprite(vec3(6,6),500,.1,0,300,0);
//spriteList.hazard_snow.isSlow = 1;
// special sprites
spriteList.water = new GameSprite(vec3(5,4),6e3,.5,1);
spriteList.water.shadowScale = 0;
spriteList.sign_start = new GameSprite(vec3(1,6),2300,0,.01,0,0);
spriteList.sign_start.shadowScale = 0;
spriteList.sign_goal = new GameSprite(vec3(0,6),2300,0,.01,0,0);
spriteList.sign_goal.shadowScale = 0;
spriteList.sign_checkpoint1 = new GameSprite(vec3(6,0),1e3,0,.01,0,0);
spriteList.sign_checkpoint1.shadowScale = 0;
spriteList.sign_checkpoint2 = new GameSprite(vec3(7,0),1e3,0,.01,0,0);
spriteList.sign_checkpoint2.shadowScale = 0;
spriteList.telephonePole = new GameSprite(vec3(0,4),1800,0,0,.03,0);
//spriteList.parts_girder = new GameSprite(vec3(0,6),500,0,.05,30,0);
spriteList.telephonePole.shadowScale = .3;
spriteList.grave_stone = new GameSprite(vec3(2,6),500,.3,.05,.5,0);
spriteList.grave_stone.lightnessRandomness = .5;
spriteList.light_tunnel = new GameSprite(vec3(0,0),200,0,0,0,0);
spriteList.light_tunnel.shadowScale = 0;
// horizon sprites
spriteList.horizon_city = new GameSprite(vec3(3,6),0,0,0,0,1);
spriteList.horizon_city.hueRandomness =
spriteList.horizon_city.lightnessRandomness = .15;
spriteList.horizon_city.colorHSL = vec3(1); // vary color
spriteList.horizon_islands = new GameSprite(vec3(7,6));
spriteList.horizon_islands.colorHSL = vec3(.25, .5, .6);
spriteList.horizon_islands.canMirror = 0;
spriteList.horizon_redMountains = new GameSprite(vec3(7,6));
spriteList.horizon_redMountains.colorHSL = vec3(.05, .7, .7);
spriteList.horizon_redMountains.canMirror = 0;
spriteList.horizon_brownMountains = new GameSprite(vec3(7,6));
spriteList.horizon_brownMountains.colorHSL = vec3(.1, .5, .6);
spriteList.horizon_brownMountains.canMirror = 0;
spriteList.horizon_smallMountains = new GameSprite(vec3(6,6));
spriteList.horizon_smallMountains.colorHSL = vec3(.1, .5, .6);
spriteList.horizon_smallMountains.canMirror = 0;
spriteList.horizon_desert = new GameSprite(vec3(6,6));
spriteList.horizon_desert.colorHSL = vec3(.15, .5, .8);
spriteList.horizon_desert.canMirror = 0;
spriteList.horizon_snow = new GameSprite(vec3(7,6));
spriteList.horizon_snow.colorHSL = vec3(0,0,1);
spriteList.horizon_snow.canMirror = 0;
spriteList.horizon_graveyard = new GameSprite(vec3(6,6));
spriteList.horizon_graveyard.colorHSL = vec3(.2, .4, .8);
spriteList.horizon_graveyard.canMirror = 0;
spriteList.horizon_weird = new GameSprite(vec3(7,6));
spriteList.horizon_weird.colorHSL = vec3(.7, .5, .6);
spriteList.horizon_weird.canMirror = 0;
if (!js13kBuildLevel2)
{
spriteList.horizon_mountains = new GameSprite(vec3(7,6));
spriteList.horizon_mountains.colorHSL = vec3(0, 0, .7);
spriteList.horizon_mountains.canMirror = 0;
}
// more sprites
spriteList.circle = new GameSprite(vec3());
spriteList.dot = new GameSprite(vec3(1,0));
spriteList.carShadow = new GameSprite(vec3(2,0));
spriteList.carLicense = new GameSprite(vec3(3,0));
spriteList.carNumber = new GameSprite(vec3(4,0));
}
// a sprite that can be placed on the track
class GameSprite
{
constructor(tilePos, size=1e3, sizeRandomness=0, windScale=0, collideScale=0, canMirror=1)
{
this.spriteTile = vec3(
(tilePos.x * generativeTileSize + bleedPixels) / generativeCanvasSize,
(tilePos.y * generativeTileSize + bleedPixels) / generativeCanvasSize,
);
this.size = size;
this.sizeRandomness = sizeRandomness;
this.windScale = windScale;
this.collideScale = collideScale;
this.canMirror = canMirror; // allow mirroring
this.trackFace = 0; // face track if close
this.spriteYOffset = 0; // how much to offset the sprite from the ground
this.shadowScale = 1.2;
// color
this.colorHSL = vec3(0,0,1);
this.hueRandomness = .05;
this.lightnessRandomness = .01;
}
getRandomSpriteColor()
{
const c = this.colorHSL.copy();
c.x += random.floatSign(this.hueRandomness);
c.z += random.floatSign(this.lightnessRandomness);
return c.getHSLColor();
}
getRandomSpriteScale() { return 1+random.floatSign(this.sizeRandomness); }
randomize()
{
this.colorHSL.x = random.float(-.1,.1);
this.colorHSL.y = clamp(this.colorHSL.y+random.float(-.1,.1));
this.colorHSL.z = clamp(this.colorHSL.z+random.float(-.1,.1));
this.hueRandomness = .05;
this.lightnessRandomness = .01;
}
}
///////////////////////////////////////////////////////////////////////////////
const getAspect =()=> mainCanvasSize.x/mainCanvasSize.y;
function drawInit()
{
{
// cube
const points = [vec3(-1,1),vec3(1,1),vec3(1,-1),vec3(-1,-1)];
cubeMesh = new Mesh().buildExtrude(points);
}
{
// quad
const points1 = [vec3(-1,1),vec3(1,1),vec3(-1,-1),vec3(1,-1)];
const uvs1 = points1.map(p=>p.multiply(vec3(.5,-.5,.5)).add(vec3(.5)));
quadMesh = new Mesh(points1, points1.map(p=>vec3(0,0,1)), uvs1);
shadowMesh = quadMesh.transform(0,vec3(PI/2,0));
}
{
// cylinder
const points = [];
const sides = 12;
for(let i=sides; i--;)
{
const a = i/sides*PI*2;
points.push(vec3(1,0).rotateZ(a));
}
cylinderMesh = new Mesh().buildExtrude(points);
}
{
// car bottom
const points =
[
vec3(-1,.5),
vec3(-.7,.4),
vec3(-.2,.5),
vec3(.1,.5),
vec3(1,.2),
vec3(1,.2),
vec3(1,0),
vec3(-1,0),
]
carMesh = new Mesh().buildExtrude(points,.5);
carMesh = carMesh.transform(0,vec3(0,-PI/2));
carWheel = cylinderMesh.transform(0,vec3(0,-PI/2));
}
}
///////////////////////////////////////////////////////////////////////////////
class Mesh
{
constructor(points, normals, uvs)
{
this.points = points;
this.normals = normals;
this.uvs = uvs;
}
render(transform, color)
{
glPushVerts(this.points, this.normals, color);
glRender(transform);
}
renderTile(transform, color, tile)
{
//ASSERT(tile instanceof SpriteTile);
const uvs = this.uvs.map(uv=>(vec3(spriteSize-spriteSize*uv.x+tile.x,uv.y*spriteSize+tile.y)));
// todo, figure out why this is backwards
//const uvs = this.uvs.map(uv=>uv.multiply(tile.size).add(tile.pos));
glPushVerts(this.points, this.normals, color, uvs);
glRender(transform);
}
buildExtrude(facePoints, size=1)
{
// convert list of 2d points into a 3d shape
const points = [], normals = [];
const vertCount = facePoints.length + 2;
for (let k=2; k--;)
for (let i=vertCount; i--;)
{
// build top and bottom of mesh
const j = clamp(i-1, 0, vertCount-3); // degenerate tri at ends
const h = j>>1;
let m = j%2 == vertCount%2 ? h : vertCount-3-h;
if (!k) // hack to fix glitch in mesh due to concave shape
m = mod(vertCount+2-m, facePoints.length);
const point = facePoints[m].copy();
point.z = k?size:-size;
points.push(point);
normals.push(vec3(0,0,point.z));
}
for (let i = facePoints.length; i--;)
{
// build sides of mesh
const point1 = facePoints[i];
const point2 = facePoints[(i+1)%facePoints.length];
const s = vec3(0,0,size);
const pointA = point1.add(s);
const pointB = point2.add(s);
const pointC = point1.subtract(s);
const pointD = point2.subtract(s);
const sidePoints = [pointA, pointA, pointB, pointC, pointD, pointD];
const normal = pointC.subtract(pointD).cross(pointA.subtract(pointC)).normalize();
for (const p of sidePoints)
{
points.push(p);
normals.push(normal);
}
}
return new Mesh(points, normals);
}
transform(pos, rot, scale)
{
const m = buildMatrix(pos, rot, scale);
const m2 = buildMatrix(0, rot);
return new Mesh(
this.points.map(p=>p.transform(m)),
this.normals.map(p=>p.transform(m2)),
this.uvs
);
}
/*combine(mesh, pos, rot, scale)
{
const m = buildMatrix(pos, rot, scale);
const m2 = buildMatrix(0, rot);
this.points.push(...mesh.points.map(p=>p.transform(m)));
this.normals && this.normals.push(...mesh.normals.map(p=>p.transform(m2)));
this.uvs && this.uvs.push(...mesh.uvs);
return this;
}*/
}
///////////////////////////////////////////////////////////////////////////////
function pushGradient(pos, size, color, color2)
{
const mesh = quadMesh;
const points = mesh.points.map(p=>p.multiply(size).addSelf(pos));
const colors = [color, color, color2, color2];
glPushColoredVerts(points, colors);
}
function pushSprite(pos, size, color, tile, skew=0)
{
const mesh = quadMesh;
const points = mesh.points.map(p=>vec3(p.x*abs(size.x)+pos.x, p.y*abs(size.y)+pos.y,pos.z));
// apply skew
const o = skew*size.y;
points[0].x += o;
points[1].x += o;
// apply texture
if (tile)
{
//ASSERT(tile instanceof SpriteTile);
let tilePosX = tile.x;
let tilePosY = tile.y;
let tileSizeX = spriteSize;
let tileSizeY = spriteSize;
if (size.x < 0)
tilePosX -= tileSizeX *= -1;
if (size.y < 0)
tilePosY -= tileSizeY *= -1;
const uvs = mesh.uvs.map(uv=>
vec3(uv.x*tileSizeX+tilePosX, uv.y*tileSizeY+tilePosY));
glPushVertsCapped(points, 0, color, uvs);
}
else
glPushVertsCapped(points, 0, color);
}
function pushShadow(pos, xSize, zSize)
{
if (optimizedCulling && pos.z > 2e4)
return; // cull far shadows
const color = rgb(0,0,0,.7)
const tile = spriteList.dot.spriteTile;
const mesh = shadowMesh;
const points = mesh.points.map(p=>vec3(p.x*xSize+pos.x,pos.y,p.z*zSize+pos.z));
const uvs = mesh.uvs.map(uv=>
vec3(uv.x*spriteSize+tile.x, uv.y*spriteSize+tile.y));
glPushVertsCapped(points, 0, color, uvs);
}
///////////////////////////////////////////////////////////////////////////////
// Fullscreen mode
/** Returns true if fullscreen mode is active
* @return {Boolean}
* @memberof Draw */
function isFullscreen() { return !!document.fullscreenElement; }
/** Toggle fullsceen mode
* @memberof Draw */
function toggleFullscreen()
{
const element = document.body;
if (isFullscreen())
{
if (document.exitFullscreen)
document.exitFullscreen();
}
else if (element.requestFullscreen)
element.requestFullscreen();
else if (element.webkitRequestFullscreen)
element.webkitRequestFullscreen();
else if (element.mozRequestFullScreen)
element.mozRequestFullScreen();
}

View File

@@ -1,433 +0,0 @@
'use strict';
// debug settings
let testLevel;
let quickStart;
let disableAiVehicles;
let testDrive;
let freeCamMode;
let testLevelInfo;
let testQuick;
const js13kBuild = 1; // fixes for legacy code made during js13k
///////////////////////////////////////////////////
// settings
const pixelate = 0;
const canvasFixedSize = 0;
const frameRate = 60;
const timeDelta = 1/frameRate;
const pixelateScale = 3;
const clampAspectRatios = enhancedMode;
const optimizedCulling = 1;
const random = new Random;
let autoPause = enhancedMode;
let autoFullscreen = 0;
// setup
const laneWidth = 1400; // how wide is track
const trackSegmentLength = 100; // length of each segment
const drawDistance = 1e3; // how many track segments to draw
const cameraPlayerOffset = vec3(0,680,1050);
const checkpointTrackSegments = testQuick?1e3:4500;
const checkpointDistance = checkpointTrackSegments*trackSegmentLength;
const startCheckpointTime = 45;
const extraCheckpointTime = 40;
const levelLerpRange = .1;
const levelGoal = 10;
const playerStartZ = 2e3;
const turnWorldScale = 2e4;
const testStartZ = testLevel ? testLevel*checkpointDistance-1e3 : quickStart&&!testLevelInfo?5e3:0;
let mainCanvasSize;// = pixelate ? vec3(640, 420) : vec3(1280, 720);
let mainCanvas, mainContext;
let time, frame, frameTimeLastMS, averageFPS, frameTimeBufferMS, paused;
let checkpointTimeLeft, startCountdown, startCountdownTimer, gameOverTimer, nextCheckpointDistance;
let raceTime, playerLevel, playerWin, playerNewDistanceRecord, playerNewRecord;
let checkpointSoundCount, checkpointSoundTimer, vehicleSpawnTimer;
let titleScreenMode = 1, titleModeStartCount = 0;
let trackSeed = 1331;
///////////////////////////////
// game variables
let cameraPos, cameraRot, cameraOffset;
let worldHeading, mouseControl;
let track, vehicles, playerVehicle;
let freeRide;
///////////////////////////////
function gameInit()
{
if (enhancedMode)
{
console.log(`Dr1v3n Wild by Frank Force`);
console.log(`www.frankforce.com 🚗🌴`);
}
if (quickStart || testLevel)
titleScreenMode = 0;
debug && debugInit();
glInit();
document.body.appendChild(mainCanvas = document.createElement('canvas'));
mainContext = mainCanvas.getContext('2d');
const styleCanvas = 'position:absolute;' + // position
(clampAspectRatios?'top:50%;left:50%;transform:translate(-50%,-50%);':'') + // center
(pixelate?' image-rendering: pixelated':''); // pixelated
glCanvas.style.cssText = mainCanvas.style.cssText = styleCanvas;
if (!clampAspectRatios)
document.body.style.margin = '0px';
drawInit();
inputInit()
initGenerative();
initSprites();
initLevelInfos();
gameStart();
gameUpdate();
}
function gameStart()
{
time = frame = frameTimeLastMS = averageFPS = frameTimeBufferMS =
cameraOffset = checkpointTimeLeft = raceTime = playerLevel = playerWin = playerNewDistanceRecord = playerNewRecord = freeRide = checkpointSoundCount = 0;
startCountdown = quickStart || testLevel ? 0 : 4;
worldHeading = titleScreenMode ? rand(7) : .8;
checkpointTimeLeft = startCheckpointTime;
nextCheckpointDistance = checkpointDistance;
startCountdownTimer = new Timer;
gameOverTimer = new Timer;
vehicleSpawnTimer = new Timer;
checkpointSoundTimer = new Timer;
cameraPos = vec3();
cameraRot = vec3();
vehicles = [];
buildTrack();
vehicles.push(playerVehicle = new PlayerVehicle(testStartZ?testStartZ:playerStartZ, hsl(0,.8,.5)));
if (titleScreenMode)
{
const level = titleModeStartCount*2%9;
playerVehicle.pos.z = 8e4+level*checkpointDistance;
}
if (enhancedMode)
{
// match camera to ground at start
cameraOffset = playerVehicle.pos.z - cameraPlayerOffset.z;
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
cameraPos.y = cameraTrackInfo.offset.y;
cameraRot.x = cameraTrackInfo.pitch/3;
}
}
function gameUpdateInternal()
{
if (titleScreenMode)
{
// update title screen
if (mouseWasPressed(0) || keyWasPressed('Space') || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9)))
{
titleScreenMode = 0;
gameStart();
}
if (time > 60)
{
// restart
++titleModeStartCount;
gameStart();
}
}
else
{
if (startCountdown > 0 && !startCountdownTimer.active())
{
--startCountdown;
sound_beep.play(1,startCountdown?1:2);
//speak(startCountdown || 'GO!' );
startCountdownTimer.set(1);
}
if (gameOverTimer.get() > 1 && (mouseWasPressed(0) || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9))) || gameOverTimer.get() > 9)
{
// go back to title screen after a while
titleScreenMode = 1;
titleModeStartCount = 0;
gameStart();
}
if (keyWasPressed('Escape') || isUsingGamepad && gamepadWasPressed(8))
{
// go back to title screen
sound_bump.play(2);
titleScreenMode = 1;
++titleModeStartCount;
gameStart();
}
/*if (keyWasPressed('KeyR'))
{
titleScreenMode = 0;
sound_lose.play(1,2);
gameStart();
}*/
if (freeRide)
{
// free ride mode
startCountdown = 0;
}
else if (keyWasPressed('KeyF'))
{
// enter free ride mode
freeRide = 1;
sound_lose.play(.5,3);
}
if (!startCountdown && !freeRide && !gameOverTimer.isSet())
{
// race mode
raceTime += timeDelta;
const lastCheckpointTimeLeft = checkpointTimeLeft;
checkpointTimeLeft -= timeDelta;
if (checkpointTimeLeft < 4)
if ((lastCheckpointTimeLeft|0) != (checkpointTimeLeft|0))
{
// low time warning
sound_beep.play(1,3);
}
const playerDistance = playerVehicle.pos.z;
const minRecordDistance = 5e3;
if (bestDistance && !playerNewDistanceRecord && playerDistance > bestDistance && playerDistance > minRecordDistance)
{
// new distance record
sound_win.play(1,2);
playerNewDistanceRecord = 1;
//speak('NEW RECORD');
}
if (checkpointTimeLeft <= 0)
{
if (!(debug && debugSkipped))
if (playerDistance > minRecordDistance)
if (!bestDistance || playerDistance > bestDistance)
{
playerNewDistanceRecord = 1;
bestDistance = playerDistance;
writeSaveData();
}
// game over
checkpointTimeLeft = 0;
//speak('GAME OVER');
gameOverTimer.set();
sound_lose.play();
}
}
}
updateCars();
}
function gameUpdate(frameTimeMS=0)
{
if (!clampAspectRatios)
mainCanvasSize = vec3(mainCanvas.width=innerWidth, mainCanvas.height=innerHeight);
else
{
// more complex aspect ratio handling
const innerAspect = innerWidth / innerHeight;
if (canvasFixedSize)
{
// clear canvas and set fixed size
mainCanvas.width = mainCanvasSize.x;
mainCanvas.height = mainCanvasSize.y;
}
else
{
const minAspect = .45, maxAspect = 3;
const correctedWidth = innerAspect > maxAspect ? innerHeight * maxAspect :
innerAspect < minAspect ? innerHeight * minAspect : innerWidth;
if (pixelate)
{
const w = correctedWidth / pixelateScale | 0;
const h = innerHeight / pixelateScale | 0;
mainCanvasSize = vec3(mainCanvas.width = w, mainCanvas.height = h);
}
else
mainCanvasSize = vec3(mainCanvas.width=correctedWidth, mainCanvas.height=innerHeight);
}
// fit to window by adding space on top or bottom if necessary
const fixedAspect = mainCanvas.width / mainCanvas.height;
mainCanvas.style.width = glCanvas.style.width = innerAspect < fixedAspect ? '100%' : '';
mainCanvas.style.height = glCanvas.style.height = innerAspect < fixedAspect ? '' : '100%';
}
if (enhancedMode)
{
document.body.style.cursor = // fun cursors!
!mouseControl ? 'auto': mouseIsDown(2) ? 'grabbing' : mouseIsDown(0) ? 'pointer' : 'grab';
if (paused)
{
// hack: special input handling when paused
inputUpdate();
if (keyWasPressed('Space') || keyWasPressed('KeyP')
|| mouseWasPressed(0) || isUsingGamepad && (gamepadWasPressed(0)||gamepadWasPressed(9)))
{
paused = 0;
sound_checkpoint.play(.5);
}
if (keyWasPressed('Escape') || isUsingGamepad && gamepadWasPressed(8))
{
// go back to title screen
paused = 0;
sound_bump.play(2);
titleScreenMode = 1;
++titleModeStartCount;
gameStart();
}
inputUpdatePost();
}
}
// update time keeping
let frameTimeDeltaMS = frameTimeMS - frameTimeLastMS;
frameTimeLastMS = frameTimeMS;
const debugSpeedUp = devMode && (keyIsDown('Equal')|| keyIsDown('NumpadAdd')); // +
const debugSpeedDown = devMode && keyIsDown('Minus') || keyIsDown('NumpadSubtract'); // -
if (debug) // +/- to speed/slow time
frameTimeDeltaMS *= debugSpeedUp ? 20 : debugSpeedDown ? .1 : 1;
averageFPS = lerp(.05, averageFPS, 1e3/(frameTimeDeltaMS||1));
frameTimeBufferMS += paused ? 0 : frameTimeDeltaMS;
frameTimeBufferMS = min(frameTimeBufferMS, 50); // clamp in case of slow framerate
// apply flux capacitor, improves smoothness of framerate in some browsers
let fluxCapacitor = 0;
if (frameTimeBufferMS < 0 && frameTimeBufferMS > -9)
{
// the flux capacitor is what makes time travel possible
// force at least one update each frame since it is waiting for refresh
// -9 needed to prevent fast speeds on > 60fps monitors
fluxCapacitor = frameTimeBufferMS;
frameTimeBufferMS = 0;
}
// update multiple frames if necessary in case of slow framerate
for (;frameTimeBufferMS >= 0; frameTimeBufferMS -= 1e3/frameRate)
{
// increment frame and update time
time = frame++ / frameRate;
gameUpdateInternal();
enhancedModeUpdate();
debugUpdate();
inputUpdate();
if (enhancedMode && !titleScreenMode)
if (keyWasPressed('KeyP') || isUsingGamepad && gamepadWasPressed(9))
if (!gameOverTimer.isSet())
{
// update pause
paused = 1;
sound_checkpoint.play(.5,.5);
}
updateCamera();
trackPreUpdate();
inputUpdatePost();
}
// add the time smoothing back in
frameTimeBufferMS += fluxCapacitor;
//mainContext.imageSmoothingEnabled = !pixelate;
//glContext.imageSmoothingEnabled = !pixelate;
glPreRender(mainCanvasSize);
drawScene();
touchGamepadRender();
drawHUD();
debugDraw();
requestAnimationFrame(gameUpdate);
}
function enhancedModeUpdate()
{
if (!enhancedMode)
return;
if (document.hasFocus())
{
if (autoFullscreen && !isFullscreen())
toggleFullscreen();
autoFullscreen = 0;
}
if (!titleScreenMode && !isTouchDevice && autoPause && !document.hasFocus())
paused = 1; // pause when losing focus
if (keyWasPressed('Home')) // dev mode
devMode || (debugInfo = devMode = 1);
if (keyWasPressed('KeyI')) // debug info
debugInfo = !debugInfo;
if (keyWasPressed('KeyM')) // toggle mute
{
if (soundVolume)
sound_bump.play(.4,3);
soundVolume = soundVolume ? 0 : .3;
if (soundVolume)
sound_bump.play();
}
if (keyWasPressed('KeyR')) // restart
{
titleScreenMode = 0;
sound_lose.play(1,2);
gameStart();
}
}
function updateCamera()
{
// update camera
const lastCameraOffset = cameraOffset;
cameraOffset = playerVehicle.pos.z - cameraPlayerOffset.z;
const cameraTrackInfo = new TrackSegmentInfo(cameraOffset);
const playerTrackInfo = new TrackSegmentInfo(playerVehicle.pos.z);
// update world heading based on speed and track turn
const v = cameraOffset - lastCameraOffset;
worldHeading += v*cameraTrackInfo.offset.x/turnWorldScale;
// put camera above player
cameraPos.y = playerTrackInfo.offset.y + (titleScreenMode?1e3:cameraPlayerOffset.y);
// move camera with player
cameraPos.x = playerVehicle.pos.x;
// slight tilt camera with road
cameraRot.x = lerp(.1,cameraRot.x, cameraTrackInfo.pitch/3);
if (freeCamMode)
{
cameraPos = freeCamPos.copy();
cameraRot = freeCamRot.copy();
}
}
///////////////////////////////////////
// save data
const saveName = 'DW';
let bestTime = localStorage[saveName+3]*1 || 0;
let bestDistance = localStorage[saveName+4]*1 || 0;
function writeSaveData()
{
localStorage[saveName+3] = bestTime;
localStorage[saveName+4] = bestDistance;
}

File diff suppressed because it is too large Load Diff

View File

@@ -1,168 +0,0 @@
'use strict';
const showTitle = 1;
function drawHUD()
{
if (freeCamMode)
return;
if (enhancedMode && paused)
{
// paused
drawHUDText('-暂停-', vec3(.5,.9), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
}
if (titleScreenMode)
{
if (showTitle)
for(let j=2;j--;)
{
// draw logo
const text = '零界时速';
const pos = vec3(.5,.3-j*.15).multiply(mainCanvasSize);
let size = mainCanvasSize.y/9;
const weight = 900;
const style = 'italic';
const font = 'arial';
if (enhancedMode && getAspect() < .6)
size = mainCanvasSize.x/5;
const context = mainContext;
context.strokeStyle = BLACK;
context.textAlign = 'center';
let totalWidth = 0;
for(let k=2;k--;)
for(let i=0;i<text.length;i++)
{
const p = Math.sin(i-time*2-j*2);
let size2 = (size + p*mainCanvasSize.y/20);
if (enhancedMode)
size2 *= lerp(time*2-2+j,0,1)
context.font = `${style} ${weight} ${size2}px ${font}`;
const c = text[i];
const w = context.measureText(c).width;
if (k)
{
totalWidth += w;
continue;
}
const x = pos.x+w/3-totalWidth/2;
for(let f = 2;f--;)
{
const o = f*mainCanvasSize.y/99;
context.fillStyle = hsl(.15-p/9,1,f?0:.75-p*.25);
context.fillText(c, x+o, pos.y+o);
}
pos.x += w;
}
}
if (!enhancedMode || time > 5)
{
if (bestTime && (!enhancedMode || time%20<10))
{
const timeString = formatTimeString(bestTime);
if (!js13kBuildLevel2)
drawHUDText('最佳时间', vec3(.5,.9), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
drawHUDText(timeString, vec3(.5,.97), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
}
else if (enhancedMode && !isTouchDevice)
{
drawHUDText('点击开始', vec3(.5,.97), .07, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
}
}
}
else if (startCountdownTimer.active() || startCountdown)
{
// count down
const a = 1-time%1;
const t = !startCountdown && startCountdownTimer.active() ? '出发!' : startCountdown|0;
const c = (startCountdown?RED:GREEN).copy();
c.a = a;
drawHUDText(t, vec3(.5,.2), .25-a*.1, c, undefined,undefined,900,undefined,undefined,.03);
}
else
{
const wave1 = .04*(1 - abs(Math.sin(time*2)));
if (gameOverTimer.isSet())
{
// win screen
const c = playerWin?YELLOW:WHITE;
const wave2 = .04*(1 - abs(Math.sin(time*2+PI/2)));
drawHUDText(playerWin?'你':'游戏', vec3(.5,.2), .1+wave1, c, undefined,undefined,900,'italic',.5,undefined,4);
drawHUDText(playerWin?'获胜!':'结束!', vec3(.5,.3), .1+wave2, c, undefined,undefined,900,'italic',.5,undefined,4);
if (playerNewRecord || playerNewDistanceRecord && !bestTime)
drawHUDText('新纪录', vec3(.5,.6), .08+wave1/4, RED, 'monospace',undefined,900,undefined,undefined,undefined,3);
}
else if (!startCountdownTimer.active() && !freeRide)
{
// big center checkpoint time
const c = checkpointTimeLeft < 4 ? RED : checkpointTimeLeft < 11 ? YELLOW : WHITE;
const t = checkpointTimeLeft|0;
let y=.13, s=.14;
if (enhancedMode && getAspect() < .6)
y=.14, s=.1;
drawHUDText(t, vec3(.5,y), s, c, undefined,undefined,900,undefined,undefined,.04);
}
if (!freeRide)
{
if (playerWin)
{
// current time
const timeString = formatTimeString(raceTime);
if (!js13kBuildLevel2)
drawHUDText('时间', vec3(.5,.43), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
drawHUDText(timeString, vec3(.5), .08, undefined, 'monospace',undefined,900,undefined,undefined,undefined,3);
}
else
{
// current time
const timeString = formatTimeString(raceTime);
drawHUDText(timeString, vec3(.01,.05), .05, undefined, 'monospace','left');
// current stage
const level = debug&&testLevelInfo ? testLevelInfo.level+1 :playerLevel+1;
drawHUDText('关卡 '+level, vec3(.99,.05), .05, undefined, 'monospace','right');
}
}
}
if (debugInfo&&!titleScreenMode) // mph
{
const mph = playerVehicle.velocity.z|0;
const mphPos = vec3(.01,.95);
drawHUDText(mph+' 公里/时', mphPos, .08, undefined,undefined,'left',900,'italic');
}
}
///////////////////////////////////////////////////////////////////////////////
function drawHUDText(text, pos, size=.1, color=WHITE, font='arial', textAlign='center', weight=400, style='', width, shadowScale=.07, outline)
{
size *= mainCanvasSize.y;
if (width)
width *= mainCanvasSize.y;
pos = pos.multiply(mainCanvasSize);
const context = mainContext;
context.lineCap = context.lineJoin = 'round';
context.font = `${style} ${weight} ${size}px ${font}`;
context.textAlign = textAlign;
const shadowOffset = size*shadowScale;
context.fillStyle = rgb(0,0,0,color.a);
if (shadowOffset)
context.fillText(text, pos.x+shadowOffset, pos.y+shadowOffset, width);
context.lineWidth = outline;
outline && context.strokeText(text, pos.x, pos.y, width);
context.fillStyle = color;
context.fillText(text, pos.x, pos.y, width);
}

View File

@@ -1,36 +0,0 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Race Game</title>
<style>
html,
body {
margin: 0;
width: 100%;
height: 100%;
overflow: hidden;
background: #000;
}
</style>
</head>
<body>
<script src="./release.js"></script>
<script src="./utilities.js"></script>
<script src="./audio.js"></script>
<script src="./draw.js"></script>
<script src="./game.js"></script>
<script src="./generative.js"></script>
<script src="./hud.js"></script>
<script src="./input.js"></script>
<script src="./levels.js"></script>
<script src="./scene.js"></script>
<script src="./sounds.js"></script>
<script src="./track.js"></script>
<script src="./trackGen.js"></script>
<script src="./vehicle.js"></script>
<script src="./webgl.js"></script>
<script src="./main.js"></script>
</body>
</html>

View File

@@ -1,402 +0,0 @@
'use strict';
const gamepadsEnable = enhancedMode;
const inputWASDEmulateDirection = enhancedMode;
const allowTouch = enhancedMode;
const isTouchDevice = allowTouch && window.ontouchstart !== undefined;
const touchGamepadEnable = enhancedMode;
const touchGamepadAlpha = .3;
///////////////////////////////////////////////////////////////////////////////
// Input user functions
const keyIsDown = (key) => inputData[key] & 1;
const keyWasPressed = (key) => inputData[key] & 2 ? 1 : 0;
const keyWasReleased = (key) => inputData[key] & 4 ? 1 : 0;
const clearInput = () => inputData = [];
let mousePos = vec3();
const mouseIsDown = keyIsDown;
const mouseWasPressed = keyWasPressed;
const mouseWasReleased = keyWasReleased;
let isUsingGamepad;
const gamepadIsDown = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 1);
const gamepadWasPressed = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 2);
const gamepadWasReleased = (key, gamepad=0) => !!(gamepadData[gamepad][key] & 4);
const gamepadStick = (stick, gamepad=0) =>
gamepadStickData[gamepad] ? gamepadStickData[gamepad][stick] || vec3() : vec3();
const gamepadGetValue = (key, gamepad=0) => gamepadDataValues[gamepad][key];
///////////////////////////////////////////////////////////////////////////////
// Input event handlers
let inputData = []; // track what keys are down
function inputInit()
{
if (gamepadsEnable)
{
gamepadData = [];
gamepadStickData = [];
gamepadDataValues = [];
gamepadData[0] = [];
gamepadDataValues[0] = [];
}
onkeydown = (e)=>
{
isUsingGamepad = 0;
if (!e.repeat)
{
inputData[e.code] = 3;
if (inputWASDEmulateDirection)
inputData[remapKey(e.code)] = 3;
}
}
onkeyup = (e)=>
{
inputData[e.code] = 4;
if (inputWASDEmulateDirection)
inputData[remapKey(e.code)] = 4;
}
// mouse event handlers
onmousedown = (e)=>
{
isUsingGamepad = 0;
inputData[e.button] = 3;
mousePos = mouseToScreen(vec3(e.x,e.y));
}
onmouseup = (e)=> inputData[e.button] = inputData[e.button] & 2 | 4;
onmousemove = (e)=>
{
mousePos = mouseToScreen(vec3(e.x,e.y));
if (freeCamMode)
{
mouseDelta.x += e.movementX/mainCanvasSize.x;
mouseDelta.y += e.movementY/mainCanvasSize.y;
}
}
oncontextmenu = (e)=> false; // prevent right click menu
// handle remapping wasd keys to directions
const remapKey = (c) => inputWASDEmulateDirection ?
c == 'KeyW' ? 'ArrowUp' :
c == 'KeyS' ? 'ArrowDown' :
c == 'KeyA' ? 'ArrowLeft' :
c == 'KeyD' ? 'ArrowRight' : c : c;
// init touch input
isTouchDevice && touchInputInit();
}
function inputUpdate()
{
// clear input when lost focus (prevent stuck keys)
isTouchDevice || document.hasFocus() || clearInput();
gamepadsEnable && gamepadsUpdate();
}
function inputUpdatePost()
{
// clear input to prepare for next frame
for (const i in inputData)
inputData[i] &= 1;
}
// convert a mouse position to screen space
const mouseToScreen = (mousePos) =>
{
if (!clampAspectRatios)
{
// canvas always takes up full screen
return vec3(mousePos.x/mainCanvasSize.x,mousePos.y/mainCanvasSize.y);
}
else
{
const rect = mainCanvas.getBoundingClientRect();
return vec3(percent(mousePos.x, rect.left, rect.right), percent(mousePos.y, rect.top, rect.bottom));
}
}
///////////////////////////////////////////////////////////////////////////////
// gamepad input
// gamepad internal variables
let gamepadData, gamepadStickData, gamepadDataValues;
// gamepads are updated by engine every frame automatically
function gamepadsUpdate()
{
const applyDeadZones = (v)=>
{
const min=.2, max=.8;
const deadZone = (v)=>
v > min ? percent( v, min, max) :
v < -min ? -percent(-v, min, max) : 0;
return vec3(deadZone(v.x), deadZone(-v.y)).clampLength();
}
// update touch gamepad if enabled
isTouchDevice && touchGamepadUpdate();
// return if gamepads are disabled or not supported
if (!navigator || !navigator.getGamepads)
return;
// only poll gamepads when focused or in debug mode (allow playing when not focused in debug)
if (!devMode && !document.hasFocus())
return;
// poll gamepads
const gamepads = navigator.getGamepads();
for (let i = gamepads.length; i--;)
{
// get or create gamepad data
const gamepad = gamepads[i];
const data = gamepadData[i] || (gamepadData[i] = []);
const dataValue = gamepadDataValues[i] || (gamepadDataValues[i] = []);
const sticks = gamepadStickData[i] || (gamepadStickData[i] = []);
if (gamepad)
{
// read analog sticks
for (let j = 0; j < gamepad.axes.length-1; j+=2)
sticks[j>>1] = applyDeadZones(vec3(gamepad.axes[j],gamepad.axes[j+1]));
// read buttons
for (let j = gamepad.buttons.length; j--;)
{
const button = gamepad.buttons[j];
const wasDown = gamepadIsDown(j,i);
data[j] = button.pressed ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
dataValue[j] = percent(button.value||0,.1,.9); // apply deadzone
isUsingGamepad ||= !i && button.pressed;
}
const gamepadDirectionEmulateStick = 1;
if (gamepadDirectionEmulateStick)
{
// copy dpad to left analog stick when pressed
const dpad = vec3(
(gamepadIsDown(15,i)&&1) - (gamepadIsDown(14,i)&&1),
(gamepadIsDown(12,i)&&1) - (gamepadIsDown(13,i)&&1));
if (dpad.lengthSquared())
sticks[0] = dpad.clampLength();
}
}
}
}
///////////////////////////////////////////////////////////////////////////////
// touch input
// try to enable touch mouse
function touchInputInit()
{
// add non passive touch event listeners
let handleTouch = handleTouchDefault;
if (touchGamepadEnable)
{
// touch input internal variables
handleTouch = handleTouchGamepad;
touchGamepadButtons = [];
touchGamepadStick = vec3();
}
document.addEventListener('touchstart', (e) => handleTouch(e), { passive: false });
document.addEventListener('touchmove', (e) => handleTouch(e), { passive: false });
document.addEventListener('touchend', (e) => handleTouch(e), { passive: false });
// override mouse events
onmousedown = onmouseup = ()=> 0;
// handle all touch events the same way
let wasTouching;
function handleTouchDefault(e)
{
// fix stalled audio requiring user interaction
if (soundEnable && !audioContext)
audioContext = new AudioContext; // create audio context
//if (soundEnable && audioContext && audioContext.state != 'running')
// sound_bump.play(); // play sound to fix audio
// check if touching and pass to mouse events
const touching = e.touches.length;
const button = 0; // all touches are left mouse button
if (touching)
{
// average all touch positions
const p = vec3();
for (let touch of e.touches)
{
p.x += touch.clientX/e.touches.length;
p.y += touch.clientY/e.touches.length;
}
mousePos = mouseToScreen(p);
wasTouching ? 0 : inputData[button] = 3;
}
else if (wasTouching)
inputData[button] = inputData[button] & 2 | 4;
// set was touching
wasTouching = touching;
// prevent default handling like copy and magnifier lens
if (document.hasFocus()) // allow document to get focus
e.preventDefault();
// must return true so the document will get focus
return true;
}
}
///////////////////////////////////////////////////////////////////////////////
// touch gamepad
// touch gamepad internal variables
let touchGamepadTimer = new Timer, touchGamepadButtons, touchGamepadStick, touchGamepadSize;
// special handling for virtual gamepad mode
function handleTouchGamepad(e)
{
if (soundEnable)
{
if (!audioContext)
audioContext = new AudioContext; // create audio context
// fix stalled audio
if (audioContext.state != 'running')
audioContext.resume();
}
// clear touch gamepad input
touchGamepadStick = vec3();
touchGamepadButtons = [];
isUsingGamepad = true;
const touching = e.touches.length;
if (touching)
{
touchGamepadTimer.set();
if (paused || titleScreenMode || gameOverTimer.isSet())
{
// touch anywhere to press start
touchGamepadButtons[9] = 1;
return;
}
}
// get center of left and right sides
const stickCenter = vec3(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
const buttonCenter = mainCanvasSize.subtract(vec3(touchGamepadSize, touchGamepadSize));
const startCenter = mainCanvasSize.scale(.5);
// check each touch point
for (const touch of e.touches)
{
let touchPos = mouseToScreen(vec3(touch.clientX, touch.clientY));
touchPos = touchPos.multiply(mainCanvasSize);
if (touchPos.distance(stickCenter) < touchGamepadSize)
{
// virtual analog stick
touchGamepadStick = touchPos.subtract(stickCenter).scale(2/touchGamepadSize);
//touchGamepadStick = touchGamepadStick.clampLength(); // circular clamp
touchGamepadStick.x = clamp(touchGamepadStick.x,-1,1);
touchGamepadStick.y = clamp(touchGamepadStick.y,-1,1);
}
else if (touchPos.distance(buttonCenter) < touchGamepadSize)
{
// virtual face buttons
const button = touchPos.y > buttonCenter.y ? 1 : 0;
touchGamepadButtons[button] = 1;
}
else if (touchPos.distance(startCenter) < touchGamepadSize)
{
// hidden virtual start button in center
touchGamepadButtons[9] = 1;
}
}
// call default touch handler so normal touch events still work
//handleTouchDefault(e);
// prevent default handling like copy and magnifier lens
if (document.hasFocus()) // allow document to get focus
e.preventDefault();
// must return true so the document will get focus
return true;
}
// update the touch gamepad, called automatically by the engine
function touchGamepadUpdate()
{
if (!touchGamepadEnable)
return;
// adjust for thin canvas
touchGamepadSize = clamp(mainCanvasSize.y/8, 99, mainCanvasSize.x/2);
ASSERT(touchGamepadButtons, 'set touchGamepadEnable before calling init!');
if (!touchGamepadTimer.isSet())
return;
// read virtual analog stick
const sticks = gamepadStickData[0] || (gamepadStickData[0] = []);
sticks[0] = touchGamepadStick.copy();
// read virtual gamepad buttons
const data = gamepadData[0];
for (let i=10; i--;)
{
const wasDown = gamepadIsDown(i,0);
data[i] = touchGamepadButtons[i] ? wasDown ? 1 : 3 : wasDown ? 4 : 0;
}
}
// render the touch gamepad, called automatically by the engine
function touchGamepadRender()
{
if (!touchGamepadEnable || !touchGamepadTimer.isSet())
return;
// fade off when not touching or paused
const alpha = percent(touchGamepadTimer.get(), 4, 3);
if (!alpha || paused)
return;
// setup the canvas
const context = mainContext;
context.save();
context.globalAlpha = alpha*touchGamepadAlpha;
context.strokeStyle = '#fff';
context.lineWidth = 3;
// draw left analog stick
context.fillStyle = touchGamepadStick.lengthSquared() > 0 ? '#fff' : '#000';
context.beginPath();
// draw circle shaped gamepad
const leftCenter = vec3(touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
context.arc(leftCenter.x, leftCenter.y, touchGamepadSize/2, 0, 9);
context.fill();
context.stroke();
// draw right face buttons
const rightCenter = vec3(mainCanvasSize.x-touchGamepadSize, mainCanvasSize.y-touchGamepadSize);
for (let i=2; i--;)
{
const pos = rightCenter.add(vec3(0,(i?1:-1)*touchGamepadSize/2));
context.fillStyle = touchGamepadButtons[i] ? '#fff' : '#000';
context.beginPath();
context.arc(pos.x, pos.y, touchGamepadSize/3, 0, 9);
context.fill();
context.stroke();
}
// set canvas back to normal
context.restore();
}

View File

@@ -1,447 +0,0 @@
'use strict';
let levelInfoList;
function initLevelInfos()
{
levelInfoList = [];
let LI, level=0;
// Level 1 - beach -
LI = new LevelInfo(level++, [
spriteList.grass_plain,
spriteList.tree_palm,
spriteList.rock_big,
], spriteList.tree_palm);
LI.horizonSpriteSize = .7;
LI.waterSide = -1;
//LI.tunnel = spriteList.tunnel2; // test tunnel
LI.billboardChance = .3 // more billboards at start
//LI.trafficDensity = .7; // less traffic start
// mostly straight with few well defined turns or bumps
LI.turnChance = .6;
LI.turnMin = .2;
//LI.turnMax = .6;
//LI.bumpChance = .5;
LI.bumpFreqMin = .2;
LI.bumpFreqMax = .4;
LI.bumpScaleMin = 10;
LI.bumpScaleMax = 20;
// Level 2 - forest -
LI = new LevelInfo(level++, [
spriteList.tree_oak,
spriteList.grass_plain,
spriteList.tree_bush,
spriteList.tree_stump,
spriteList.grass_flower1,
spriteList.grass_flower3,
spriteList.grass_flower2,
], spriteList.tree_bush, spriteList.horizon_smallMountains);
LI.horizonSpriteSize = 10;
LI.trackSideRate = 10;
LI.sceneryListBias = 9;
//LI.skyColorTop = WHITE;
LI.skyColorBottom = hsl(.5,.3,.5);
LI.roadColor = hsl(.05,.4,.2);
LI.groundColor = hsl(.2,.4,.4);
LI.cloudColor = hsl(0,0,1,.3);
LI.cloudHeight = .2;
LI.sunHeight = .7;
LI.billboardChance = .1 // less billboards in forest type areas
//LI.trafficDensity = .7; // less traffic in forest
// trail through forest
LI.turnChance = .7; // more small turns
//LI.turnMin = 0;
//LI.turnMax = .6;
LI.bumpChance = .8;
LI.bumpFreqMin = .4;
//LI.bumpFreqMax = .7;
//LI.bumpScaleMin = 50;
LI.bumpScaleMax = 140;
// Level 3 - desert -
// has long straight thin roads and tunnel
LI = new LevelInfo(level++, [
spriteList.grass_dead,
spriteList.tree_dead,
spriteList.rock_big,
spriteList.tree_stump,
], spriteList.telephonePole, spriteList.horizon_desert);
LI.trackSideRate = 50;
LI.trackSideChance = 1;
LI.skyColorTop = hsl(.15,1,.9);
LI.skyColorBottom = hsl(.5,.7,.6);
LI.roadColor = hsl(.1,.2,.2);
LI.lineColor = hsl(0,0,1,.5);
LI.groundColor = hsl(.1,.2,.5);
LI.trackSideForce = 1; // telephone poles on right side
LI.cloudHeight = .05;
LI.sunHeight = .9;
LI.sideStreets = 1;
LI.laneCount = 2;
LI.hazardType = spriteList.hazard_sand;
LI.hazardChance = .005;
LI.tunnel = spriteList.tunnel2;
LI.trafficDensity = .7; // less traffic in desert, only 2 lanes
LI.billboardRate = 87;
LI.billboardScale = 8;
// flat desert
//LI.turnChance = .5;
LI.turnMin = .2;
LI.turnMax = .6;
LI.bumpChance = 1;
//LI.bumpFreqMin = 0;
LI.bumpFreqMax = .2;
LI.bumpScaleMin = 30;
LI.bumpScaleMax = 60;
// Level 4 - snow area -
LI = new LevelInfo(level++, [
spriteList.grass_snow,
spriteList.tree_dead,
spriteList.tree_snow,
spriteList.rock_big,
spriteList.tree_stump,
], spriteList.tree_snow, spriteList.horizon_snow);
LI.sceneryListBias = 9;
LI.trackSideRate = 21;
LI.skyColorTop = hsl(.5,.2,.4);
LI.skyColorBottom = WHITE;
LI.roadColor = hsl(0,0,.5,.5);
LI.groundColor = hsl(.6,.3,.9);
LI.cloudColor = hsl(0,0,.8,.5);
LI.horizonSpriteSize = 2;
LI.lineColor = hsl(0,0,1,.5);
LI.sunHeight = .7;
LI.hazardType = spriteList.hazard_rocks;
LI.hazardChance = .002;
LI.trafficDensity = 1.2; // extra traffic through snow
// snowy mountains
//LI.turnChance = .5;
LI.turnMin = .4;
//LI.turnMax = .6;
LI.bumpChance = .8;
LI.bumpFreqMin = .2;
LI.bumpFreqMax = .6;
//LI.bumpFreqMax = .7;
LI.bumpScaleMin = 50;
LI.bumpScaleMax = 100;
// Level 5 - canyon -
// has winding roads, hills, and sand onground
LI = new LevelInfo(level++, [
spriteList.rock_huge,
spriteList.grass_dead,
spriteList.tree_fall,
spriteList.rock_huge2,
spriteList.grass_flower2,
spriteList.tree_dead,
spriteList.tree_stump,
spriteList.rock_big,
], spriteList.tree_fall,spriteList.horizon_brownMountains);
LI.sceneryListBias = 2;
LI.trackSideRate = 31;
LI.skyColorTop = hsl(.7,1,.7);
LI.skyColorBottom = hsl(.2,1,.9);
LI.roadColor = hsl(0,0,.15);
LI.groundColor = hsl(.1,.4,.5);
LI.cloudColor = hsl(0,0,1,.3);
LI.cloudHeight = .1;
LI.sunColor = hsl(0,1,.7);
//LI.laneCount = 3;
LI.billboardChance = .1 // less billboards in forest type areas
LI.trafficDensity = .7; // less traffic in canyon
// rocky canyon
LI.turnChance = 1; // must turn to block vision
LI.turnMin = .2;
LI.turnMax = .8;
LI.bumpChance = .9;
LI.bumpFreqMin = .4;
//LI.bumpFreqMax = .7;
//LI.bumpScaleMin = 50;
LI.bumpScaleMax = 120;
// Level 6 - red fields and city
LI = new LevelInfo(level++, [
spriteList.grass_red,
spriteList.tree_yellow,
spriteList.rock_big,
spriteList.tree_stump,
//spriteList.rock_wide,
], spriteList.tree_yellow,spriteList.horizon_city);
LI.trackSideRate = 31;
LI.skyColorTop = YELLOW;
LI.skyColorBottom = RED;
LI.roadColor = hsl(0,0,.1);
LI.lineColor = hsl(.15,1,.7);
LI.groundColor = hsl(.05,.5,.4);
LI.cloudColor = hsl(.15,1,.5,.5);
//LI.cloudHeight = .3;
LI.billboardRate = 23; // more billboards in city
LI.billboardChance = .5
LI.horizonSpriteSize = 1.5;
if (!js13kBuildLevel2)
LI.horizonFlipChance = .3;
LI.sunHeight = .5;
LI.sunColor = hsl(.15,1,.8);
LI.sideStreets = 1;
LI.laneCount = 5;
LI.trafficDensity = 2; // extra traffic in city
// in front of city
LI.turnChance = .3;
LI.turnMin = .5
LI.turnMax = .9; // bigger turns since lanes are wide
//LI.bumpChance = .5;
LI.bumpFreqMin = .3;
LI.bumpFreqMax = .6;
LI.bumpScaleMin = 80;
LI.bumpScaleMax = 200;
// Level 7 - graveyard -
LI = new LevelInfo(level++, [
spriteList.grass_dead,
spriteList.grass_plain,
spriteList.grave_stone,
spriteList.tree_dead,
spriteList.tree_stump,
], spriteList.tree_oak, spriteList.horizon_graveyard);
LI.sceneryListBias = 2;
LI.trackSideRate = 50;
LI.skyColorTop = hsl(.5,1,.5);
LI.skyColorBottom = hsl(0,1,.8);
LI.roadColor = hsl(.6,.3,.15);
LI.groundColor = hsl(.2,.3,.5);
LI.lineColor = hsl(0,0,1,.5);
LI.billboardChance = 0; // no ads in graveyard
LI.cloudColor = hsl(.15,1,.9,.3);
LI.horizonSpriteSize = 4;
LI.sunHeight = 1.5;
//LI.laneCount = 3;
//LI.trafficDensity = .7;
LI.trackSideChance = 1; // more trees
// thin road over hills in graveyard
//LI.turnChance = .5;
LI.turnMax = .6;
LI.bumpChance = .6;
LI.bumpFreqMin = LI.bumpFreqMax = .7;
LI.bumpScaleMin = 80;
//LI.bumpScaleMax = 150;
// Level 8 - jungle - dirt road, many trees
// has lots of physical hazards
LI = new LevelInfo(level++, [
spriteList.grass_large,
spriteList.tree_palm,
spriteList.grass_flower1,
spriteList.rock_tall,
spriteList.rock_big,
spriteList.rock_huge2,
], spriteList.rock_big, spriteList.horizon_redMountains);
LI.sceneryListBias = 5;
LI.trackSideRate = 25;
LI.skyColorTop = hsl(0,1,.8);
LI.skyColorBottom = hsl(.6,1,.6);
LI.lineColor = hsl(0,0,0,0);
LI.roadColor = hsl(0,.6,.2,.8);
LI.groundColor = hsl(.1,.5,.4);
LI.waterSide = 1;
LI.cloudColor = hsl(0,1,.96,.8);
LI.cloudWidth = .6;
//LI.cloudHeight = .3;
LI.sunHeight = .7;
LI.sunColor = hsl(.1,1,.7);
LI.hazardType = spriteList.rock_big;
LI.hazardChance = .2;
LI.trafficDensity = 0; // no other cars in jungle
// bumpy jungle road
LI.turnChance = .8;
//LI.turnMin = 0;
LI.turnMax = .3; // lots of slight turns
LI.bumpChance = 1;
LI.bumpFreqMin = .4;
LI.bumpFreqMax = .6;
LI.bumpScaleMin = 10;
LI.bumpScaleMax = 80;
// Level 9 - strange area
LI = new LevelInfo(level++, [
spriteList.grass_red,
spriteList.rock_weird,
spriteList.tree_huge,
], spriteList.rock_weird2, spriteList.horizon_weird);
LI.trackSideRate = 50;
LI.skyColorTop = hsl(.05,1,.8);
LI.skyColorBottom = hsl(.15,1,.7);
LI.lineColor = hsl(0,1,.9);
LI.roadColor = hsl(.6,1,.1);
LI.groundColor = hsl(.6,1,.6);
LI.cloudColor = hsl(.9,1,.5,.3);
LI.cloudHeight = .2;
LI.sunColor = BLACK;
LI.laneCount = 4;
LI.trafficDensity = 1.5; // extra traffic to increase difficulty here
// large strange hills
LI.turnChance = .7;
LI.turnMin = .3;
LI.turnMax = .8;
LI.bumpChance = 1;
LI.bumpFreqMin = .5;
LI.bumpFreqMax = .9;
LI.bumpScaleMin = 100;
LI.bumpScaleMax = 200;
// Level 10 - mountains - hilly, rocks on sides
LI = new LevelInfo(level++, [
spriteList.grass_plain,
spriteList.rock_huge3,
spriteList.grass_flower1,
spriteList.rock_huge2,
spriteList.rock_huge,
], spriteList.tree_pink);
LI.trackSideRate = 21;
LI.skyColorTop = hsl(.2,1,.9);
LI.skyColorBottom = hsl(.55,1,.5);
LI.roadColor = hsl(0,0,.1);
LI.groundColor = hsl(.1,.5,.7);
LI.cloudColor = hsl(0,0,1,.5);
LI.tunnel = spriteList.tunnel1;
if (js13kBuildLevel2)
LI.horizonSpriteSize = 0;
else
{
LI.sunHeight = .6;
LI.horizonSprite = spriteList.horizon_mountains
LI.horizonSpriteSize = 1;
}
// mountains, most difficult level
LI.turnChance = LI.turnMax = .8;
//LI.turnMin = 0;
LI.bumpChance = 1;
LI.bumpFreqMin = .3;
LI.bumpFreqMax = .9;
//LI.bumpScaleMin = 50;
LI.bumpScaleMax = 80;
// Level 11 - win area
LI = new LevelInfo(level++, [
spriteList.grass_flower1,
spriteList.grass_flower2,
spriteList.grass_flower3,
spriteList.grass_plain,
spriteList.tree_oak,
spriteList.tree_bush,
], spriteList.tree_oak);
LI.sceneryListBias = 1;
LI.groundColor = hsl(.2,.3,.5);
LI.trackSideRate = LI.billboardChance = 0;
LI.bumpScaleMin = 1e3; // hill in the distance
// match settings to previous level
if (js13kBuildLevel2)
LI.horizonSpriteSize = 0;
else
{
LI.sunHeight = .6;
LI.horizonSprite = spriteList.horizon_mountains
LI.horizonSpriteSize = 1;
}
}
const getLevelInfo = (level) => testLevelInfo || levelInfoList[level|0] || levelInfoList[0];
// info about how to build and draw each level
class LevelInfo
{
constructor(level, scenery, trackSideSprite,horizonSprite=spriteList.horizon_islands)
{
// add self to list
levelInfoList[level] = this;
if (debug)
{
for(const s of scenery)
ASSERT(s, 'missing scenery!');
}
this.level = level;
this.scenery = scenery;
this.trackSideSprite = trackSideSprite;
this.sceneryListBias = 29;
this.waterSide = 0;
this.billboardChance = .2;
this.billboardRate = 45;
this.billboardScale = 1;
this.trackSideRate = 5;
this.trackSideForce = 0;
this.trackSideChance = .5;
this.groundColor = hsl(.08,.2, .7);
this.skyColorTop = WHITE;
this.skyColorBottom = hsl(.57,1,.5);
this.lineColor = WHITE;
this.roadColor = hsl(0, 0, .5);
// horizon stuff
this.cloudColor = hsl(.15,1,.95,.7);
this.cloudWidth = 1;
this.cloudHeight = .3;
this.horizonSprite = horizonSprite;
this.horizonSpriteSize = 2;
this.sunHeight = .8;
this.sunColor = hsl(.15,1,.95);
// track generation
this.laneCount = 3;
this.trafficDensity = 1;
// default turns and bumps
this.turnChance = .5;
this.turnMin = 0;
this.turnMax = .6;
this.bumpChance = .5;
this.bumpFreqMin = 0; // no bumps
this.bumpFreqMax = .7; // more often bumps
this.bumpScaleMin = 50; // rapid bumps
this.bumpScaleMax = 150; // largest hills
}
randomize()
{
shuffle(this.scenery);
this.sceneryListBias = random.float(5,30);
this.groundColor = random.mutateColor(this.groundColor);
this.skyColorTop = random.mutateColor(this.skyColorTop);
this.skyColorBottom = random.mutateColor(this.skyColorBottom);
this.lineColor = random.mutateColor(this.lineColor);
this.roadColor = random.mutateColor(this.roadColor);
this.cloudColor = random.mutateColor(this.cloudColor);
this.sunColor = random.mutateColor(this.sunColor);
// track generation
this.laneCount = random.int(2,5);
this.trafficDensity = random.float(.5,1.5);
// default turns and bumps
this.turnChance = random.float();
this.turnMin = random.float();
this.turnMax = random.float();
this.bumpChance = random.float();
this.bumpFreqMin = random.float(.5); // no bumps
this.bumpFreqMax = random.float(); // more often bumps
this.bumpScaleMin = random.float(20,50); // rapid bumps
this.bumpScaleMax = random.float(50,150); // largest hills
this.hazardChance = 0;
}
}

View File

@@ -1,41 +0,0 @@
'use strict';
/*
Dr1v3n Wild by Frank Force
A 13k game for js13kGames 2024
Controls
- Arrows or Mouse = Drive
- Spacebar = Brake
- F = Free Ride Mode
- Escape = Title Screen
Features
- 10 stages with unique visuals
- Fast custom WebGL rendering
- Procedural art (trees, rocks, scenery)
- Track generator
- Arcade style driving physics
- 2 types of AI vehicles
- Parallax horizon and sky
- ZZFX sounds
- Persistent save data
- Keyboard or mouse input
- All written from scratch in vanilla JS
*/
///////////////////////////////////////////////////
// debug settings
//devMode = debugInfo = 1
//soundVolume = 0
//debugGenerativeCanvas = 1
//autoPause = 0
//quickStart = 1
//disableAiVehicles = 1
///////////////////////////////////////////////////
gameInit();

View File

@@ -1,16 +0,0 @@
'use strict';
const debug = 0;
const enhancedMode = 1;
let debugInfo, debugMesh, debugTile, debugGenerativeCanvas, devMode;
const js13kBuildLevel2 = 0; // more space is needed for js13k
// disable debug features
function ASSERT() {}
function debugInit() {}
function drawDebug() {}
function debugUpdate() {}
function debugSaveCanvas() {}
function debugSaveText() {}
function debugDraw() {}
function debugSaveDataURL() {}

Some files were not shown because too many files have changed in this diff Show More