add backend
This commit is contained in:
16
.gemini/settings.json
Normal file
16
.gemini/settings.json
Normal 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
8
.gitignore
vendored
Normal 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
71
backend/README.md
Normal 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 数据先把前后端链路跑通。
|
||||
1
backend/dev-backend.err.log
Normal file
1
backend/dev-backend.err.log
Normal file
@@ -0,0 +1 @@
|
||||
^C
|
||||
1772
backend/dev-backend.log
Normal file
1772
backend/dev-backend.log
Normal file
File diff suppressed because it is too large
Load Diff
108
backend/pom.xml
Normal file
108
backend/pom.xml
Normal 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>
|
||||
54
backend/sql/mysql-init.sql
Normal file
54
backend/sql/mysql-init.sql
Normal 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);
|
||||
46
backend/sql/opengauss-init.sql
Normal file
46
backend/sql/opengauss-init.sql
Normal 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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
33
backend/src/main/java/com/yoyuzh/auth/AuthController.java
Normal file
33
backend/src/main/java/com/yoyuzh/auth/AuthController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
83
backend/src/main/java/com/yoyuzh/auth/AuthService.java
Normal file
83
backend/src/main/java/com/yoyuzh/auth/AuthService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
@@ -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, "用户不存在"));
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/auth/DevAuthController.java
Normal file
26
backend/src/main/java/com/yoyuzh/auth/DevAuthController.java
Normal 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));
|
||||
}
|
||||
}
|
||||
62
backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java
Normal file
62
backend/src/main/java/com/yoyuzh/auth/JwtTokenProvider.java
Normal 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();
|
||||
}
|
||||
}
|
||||
84
backend/src/main/java/com/yoyuzh/auth/User.java
Normal file
84
backend/src/main/java/com/yoyuzh/auth/User.java
Normal 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;
|
||||
}
|
||||
}
|
||||
24
backend/src/main/java/com/yoyuzh/auth/UserController.java
Normal file
24
backend/src/main/java/com/yoyuzh/auth/UserController.java
Normal 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()));
|
||||
}
|
||||
}
|
||||
13
backend/src/main/java/com/yoyuzh/auth/UserRepository.java
Normal file
13
backend/src/main/java/com/yoyuzh/auth/UserRepository.java
Normal 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);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
public record AuthResponse(String token, UserProfileResponse user) {
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record LoginRequest(
|
||||
@NotBlank String username,
|
||||
@NotBlank String password
|
||||
) {
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.auth.dto;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
public record UserProfileResponse(Long id, String username, String email, LocalDateTime createdAt) {
|
||||
}
|
||||
16
backend/src/main/java/com/yoyuzh/common/ApiResponse.java
Normal file
16
backend/src/main/java/com/yoyuzh/common/ApiResponse.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
18
backend/src/main/java/com/yoyuzh/common/ErrorCode.java
Normal file
18
backend/src/main/java/com/yoyuzh/common/ErrorCode.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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, "服务器内部错误"));
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/config/JwtProperties.java
Normal file
26
backend/src/main/java/com/yoyuzh/config/JwtProperties.java
Normal 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;
|
||||
}
|
||||
}
|
||||
26
backend/src/main/java/com/yoyuzh/config/OpenApiConfig.java
Normal file
26
backend/src/main/java/com/yoyuzh/config/OpenApiConfig.java
Normal 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")));
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
83
backend/src/main/java/com/yoyuzh/config/SecurityConfig.java
Normal file
83
backend/src/main/java/com/yoyuzh/config/SecurityConfig.java
Normal 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();
|
||||
}
|
||||
}
|
||||
154
backend/src/main/java/com/yoyuzh/cqu/Course.java
Normal file
154
backend/src/main/java/com/yoyuzh/cqu/Course.java
Normal 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;
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/CourseRepository.java
Normal 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);
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/CourseResponse.java
Normal file
@@ -0,0 +1,11 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record CourseResponse(
|
||||
String courseName,
|
||||
String teacher,
|
||||
String classroom,
|
||||
Integer dayOfWeek,
|
||||
Integer startTime,
|
||||
Integer endTime
|
||||
) {
|
||||
}
|
||||
40
backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java
Normal file
40
backend/src/main/java/com/yoyuzh/cqu/CquApiClient.java
Normal 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<>() {
|
||||
});
|
||||
}
|
||||
}
|
||||
44
backend/src/main/java/com/yoyuzh/cqu/CquController.java
Normal file
44
backend/src/main/java/com/yoyuzh/cqu/CquController.java
Normal 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());
|
||||
}
|
||||
}
|
||||
129
backend/src/main/java/com/yoyuzh/cqu/CquDataService.java
Normal file
129
backend/src/main/java/com/yoyuzh/cqu/CquDataService.java
Normal 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());
|
||||
}
|
||||
}
|
||||
68
backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java
Normal file
68
backend/src/main/java/com/yoyuzh/cqu/CquMockDataFactory.java
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
110
backend/src/main/java/com/yoyuzh/cqu/Grade.java
Normal file
110
backend/src/main/java/com/yoyuzh/cqu/Grade.java
Normal 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;
|
||||
}
|
||||
}
|
||||
11
backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java
Normal file
11
backend/src/main/java/com/yoyuzh/cqu/GradeRepository.java
Normal 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);
|
||||
}
|
||||
8
backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java
Normal file
8
backend/src/main/java/com/yoyuzh/cqu/GradeResponse.java
Normal file
@@ -0,0 +1,8 @@
|
||||
package com.yoyuzh.cqu;
|
||||
|
||||
public record GradeResponse(
|
||||
String courseName,
|
||||
Double grade,
|
||||
String semester
|
||||
) {
|
||||
}
|
||||
77
backend/src/main/java/com/yoyuzh/files/FileController.java
Normal file
77
backend/src/main/java/com/yoyuzh/files/FileController.java
Normal 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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
216
backend/src/main/java/com/yoyuzh/files/FileService.java
Normal file
216
backend/src/main/java/com/yoyuzh/files/FileService.java
Normal 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);
|
||||
}
|
||||
}
|
||||
6
backend/src/main/java/com/yoyuzh/files/MkdirRequest.java
Normal file
6
backend/src/main/java/com/yoyuzh/files/MkdirRequest.java
Normal file
@@ -0,0 +1,6 @@
|
||||
package com.yoyuzh.files;
|
||||
|
||||
import jakarta.validation.constraints.NotBlank;
|
||||
|
||||
public record MkdirRequest(@NotBlank String path) {
|
||||
}
|
||||
132
backend/src/main/java/com/yoyuzh/files/StoredFile.java
Normal file
132
backend/src/main/java/com/yoyuzh/files/StoredFile.java
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
17
backend/src/main/resources/application-dev.yml
Normal file
17
backend/src/main/resources/application-dev.yml
Normal 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
|
||||
41
backend/src/main/resources/application.yml
Normal file
41
backend/src/main/resources/application.yml
Normal 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
|
||||
14
backend/src/main/resources/logback.xml
Normal file
14
backend/src/main/resources/logback.xml
Normal 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>
|
||||
105
backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java
Normal file
105
backend/src/test/java/com/yoyuzh/auth/AuthServiceTest.java
Normal 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("用户名或密码错误");
|
||||
}
|
||||
}
|
||||
86
backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java
Normal file
86
backend/src/test/java/com/yoyuzh/cqu/CquDataServiceTest.java
Normal 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);
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
114
backend/src/test/java/com/yoyuzh/files/FileServiceTest.java
Normal file
114
backend/src/test/java/com/yoyuzh/files/FileServiceTest.java
Normal 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
9
front/.env.example
Normal 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
8
front/.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
dist/
|
||||
coverage/
|
||||
.DS_Store
|
||||
*.log
|
||||
.env*
|
||||
!.env.example
|
||||
8
front/.vite/deps/_metadata.json
Normal file
8
front/.vite/deps/_metadata.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"hash": "1eac4ae6",
|
||||
"configHash": "19e214db",
|
||||
"lockfileHash": "126cd023",
|
||||
"browserHash": "c5ddb224",
|
||||
"optimized": {},
|
||||
"chunks": {}
|
||||
}
|
||||
3
front/.vite/deps/package.json
Normal file
3
front/.vite/deps/package.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"type": "module"
|
||||
}
|
||||
20
front/README.md
Normal file
20
front/README.md
Normal 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`
|
||||
@@ -2,12 +2,12 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<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" />
|
||||
<title>test1</title>
|
||||
<title>My Google AI Studio App</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<script type="module" src="/src/main.ts"></script>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
5
front/metadata.json
Normal file
5
front/metadata.json
Normal 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
5281
front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
39
front/package.json
Normal file
39
front/package.json
Normal 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
25
front/src/App.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
front/src/components/layout/Layout.tsx
Normal file
91
front/src/components/layout/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
||||
36
front/src/components/ui/button.tsx
Normal file
36
front/src/components/ui/button.tsx
Normal 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 }
|
||||
78
front/src/components/ui/card.tsx
Normal file
78
front/src/components/ui/card.tsx
Normal 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 }
|
||||
24
front/src/components/ui/input.tsx
Normal file
24
front/src/components/ui/input.tsx
Normal 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
88
front/src/index.css
Normal 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
6
front/src/lib/utils.ts
Normal 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
10
front/src/main.tsx
Normal 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
275
front/src/pages/Files.tsx
Normal 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
109
front/src/pages/Games.tsx
Normal 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
130
front/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
208
front/src/pages/Overview.tsx
Normal file
208
front/src/pages/Overview.tsx
Normal 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
291
front/src/pages/School.tsx
Normal 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
26
front/tsconfig.json
Normal 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
24
front/vite.config.ts
Normal 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
130
scripts/local-smoke.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
37
scripts/start-backend-dev.ps1
Normal file
37
scripts/start-backend-dev.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
36
scripts/start-frontend-dev.ps1
Normal file
36
scripts/start-frontend-dev.ps1
Normal 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
|
||||
}
|
||||
}
|
||||
267
todo_list.md
267
todo_list.md
@@ -1,267 +0,0 @@
|
||||
下面这份是**工程级**的 TODO List(Markdown),按“能上线”的路径拆好了:里程碑 → 任务 → 验收点。你前端已经做了一部分,就从 **FE-Desktop / FE-Apps** 里把已完成的勾上即可。
|
||||
|
||||
---
|
||||
|
||||
# Web Desktop 项目工程 TODO(可上线版)
|
||||
|
||||
> 维护规则:
|
||||
>
|
||||
> * 每个任务尽量做到“可交付 + 可验收”。
|
||||
> * 任务粒度:1~4 小时能完成为宜。
|
||||
> * 每周至少推进一个 Milestone 到可演示状态。
|
||||
|
||||
---
|
||||
|
||||
## 0. 里程碑总览
|
||||
|
||||
* [ ] **M0:工程骨架就绪(能跑通 dev / staging)**
|
||||
* [ ] **M1:账号体系 + 桌面壳可用(基础可演示)**
|
||||
* [ ] **M2:网盘 MVP(OSS 直传闭环)**
|
||||
* [ ] **M3:分享/审计/配额/管理后台(上线门槛)**
|
||||
* [ ] **M4:Campus BFF 接 Rust API(课表/成绩缓存降级)**
|
||||
* [ ] **M5:论坛/地图完善 + 监控告警 + 上线演练**
|
||||
|
||||
---
|
||||
|
||||
## 1. M0 工程骨架就绪
|
||||
|
||||
### Repo / 工程结构
|
||||
|
||||
* [ ] 初始化 mono-repo 或多 repo 结构(建议:`frontend/` `backend/` `infra/`)
|
||||
* [ ] 统一 lint/format(ESLint/Prettier + 后端 formatter)
|
||||
* [ ] 统一 commit 规范(可选:commitlint)
|
||||
* [ ] 统一环境变量模板:`.env.example`(前后端分开)
|
||||
* [ ] 基础 README:本地启动、部署、配置项说明
|
||||
|
||||
### 本地开发环境
|
||||
|
||||
* [ ] docker-compose:db + 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 网盘 MVP(OSS 直传闭环)
|
||||
|
||||
### 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
24
vue/.gitignore
vendored
@@ -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?
|
||||
3
vue/.vscode/extensions.json
vendored
3
vue/.vscode/extensions.json
vendored
@@ -1,3 +0,0 @@
|
||||
{
|
||||
"recommendations": ["Vue.volar"]
|
||||
}
|
||||
@@ -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
1834
vue/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
@@ -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
Reference in New Issue
Block a user