feat: refresh innovation platform demo

This commit is contained in:
Codex
2026-03-18 18:47:19 +08:00
commit 64e61454ab
44 changed files with 3165 additions and 0 deletions

5
backend/Dockerfile Normal file
View File

@@ -0,0 +1,5 @@
FROM eclipse-temurin:21-jdk-alpine
WORKDIR /app
COPY target/*.jar app.jar
EXPOSE 8080
ENTRYPOINT ["java", "-jar", "app.jar"]

92
backend/pom.xml Normal file
View File

@@ -0,0 +1,92 @@
<?xml version="1.0" encoding="UTF-8"?>
<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.2.3</version>
<relativePath/>
</parent>
<groupId>com.innovation</groupId>
<artifactId>innovation-platform</artifactId>
<version>1.0.0</version>
<name>innovation-platform</name>
<description>University innovation and entrepreneurship project management demo</description>
<properties>
<java.version>17</java.version>
<mybatis-plus.version>3.5.5</mybatis-plus.version>
<sa-token.version>1.37.0</sa-token.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-validation</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<dependency>
<groupId>com.baomidou</groupId>
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
<version>${mybatis-plus.version}</version>
</dependency>
<dependency>
<groupId>cn.dev33</groupId>
<artifactId>sa-token-spring-boot3-starter</artifactId>
<version>${sa-token.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
<dependency>
<groupId>cn.hutool</groupId>
<artifactId>hutool-all</artifactId>
<version>5.8.25</version>
</dependency>
<dependency>
<groupId>com.github.xiaoymin</groupId>
<artifactId>knife4j-openapi3-jakarta-spring-boot-starter</artifactId>
<version>4.4.0</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<configuration>
<excludes>
<exclude>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</exclude>
</excludes>
</configuration>
</plugin>
</plugins>
</build>
</project>

View File

@@ -0,0 +1,16 @@
package com.innovation.platform;
import org.mybatis.spring.annotation.MapperScan;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
/**
* 高校创新创业项目孵化平台启动类
*/
@SpringBootApplication
@MapperScan("com.innovation.platform.mapper")
public class InnovationPlatformApplication {
public static void main(String[] args) {
SpringApplication.run(InnovationPlatformApplication.class, args);
}
}

View File

@@ -0,0 +1,28 @@
package com.innovation.platform.common;
import com.baomidou.mybatisplus.core.metadata.IPage;
import lombok.Data;
import java.io.Serializable;
import java.util.List;
/**
* 分页结果
*/
@Data
public class PageResult<T> implements Serializable {
private List<T> records;
private Long total;
private Long size;
private Long current;
private Long pages;
public static <T> PageResult<T> of(IPage<T> page) {
PageResult<T> result = new PageResult<>();
result.setRecords(page.getRecords());
result.setTotal(page.getTotal());
result.setSize(page.getSize());
result.setCurrent(page.getCurrent());
result.setPages(page.getPages());
return result;
}
}

View File

@@ -0,0 +1,51 @@
package com.innovation.platform.common;
import lombok.Data;
import java.io.Serializable;
@Data
public class Result<T> implements Serializable {
private Integer code;
private String message;
private T data;
private Long timestamp;
public Result() {
this.timestamp = System.currentTimeMillis();
}
public static <T> Result<T> success() {
Result<T> result = new Result<>();
result.setCode(200);
result.setMessage("Success");
return result;
}
public static <T> Result<T> success(T data) {
Result<T> result = success();
result.setData(data);
return result;
}
public static <T> Result<T> success(String message, T data) {
Result<T> result = success();
result.setMessage(message);
result.setData(data);
return result;
}
public static <T> Result<T> error(String message) {
Result<T> result = new Result<>();
result.setCode(500);
result.setMessage(message);
return result;
}
public static <T> Result<T> error(Integer code, String message) {
Result<T> result = new Result<>();
result.setCode(code);
result.setMessage(message);
return result;
}
}

View File

@@ -0,0 +1,25 @@
package com.innovation.platform.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
/**
* Sa-Token 配置类
*/
@Configuration
public class SaTokenConfig implements WebMvcConfigurer {
/**
* 跨域配置
*/
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOriginPatterns("*")
.allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
.allowedHeaders("*")
.allowCredentials(true)
.maxAge(3600);
}
}

View File

@@ -0,0 +1,44 @@
package com.innovation.platform.controller;
import cn.dev33.satoken.annotation.SaIgnore;
import com.innovation.platform.common.Result;
import com.innovation.platform.dto.LoginRequest;
import com.innovation.platform.dto.LoginResponse;
import com.innovation.platform.dto.RegisterRequest;
import com.innovation.platform.service.SysUserService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Authentication", description = "Login, registration, and logout endpoints")
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final SysUserService sysUserService;
@SaIgnore
@Operation(summary = "User login")
@PostMapping("/login")
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
return Result.success("Login succeeded.", sysUserService.login(request));
}
@SaIgnore
@Operation(summary = "User registration")
@PostMapping("/register")
public Result<Void> register(@Valid @RequestBody RegisterRequest request) {
sysUserService.register(request);
return Result.success("Registration succeeded.", null);
}
@Operation(summary = "User logout")
@PostMapping("/logout")
public Result<Void> logout() {
sysUserService.logout();
return Result.success("Logout succeeded.", null);
}
}

View File

@@ -0,0 +1,69 @@
package com.innovation.platform.controller;
import cn.dev33.satoken.annotation.SaCheckLogin;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.innovation.platform.common.PageResult;
import com.innovation.platform.common.Result;
import com.innovation.platform.dto.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse;
import com.innovation.platform.dto.ProjectStatsResponse;
import com.innovation.platform.service.ProjectService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
@Tag(name = "Project management", description = "Project CRUD, filtering, and dashboard statistics")
@RestController
@RequestMapping("/api/projects")
@RequiredArgsConstructor
@SaCheckLogin
public class ProjectController {
private final ProjectService projectService;
@Operation(summary = "List projects with pagination and keyword search")
@GetMapping
public Result<PageResult<ProjectResponse>> page(ProjectQueryRequest request) {
IPage<ProjectResponse> page = projectService.page(request);
return Result.success(PageResult.of(page));
}
@Operation(summary = "Get project dashboard statistics")
@GetMapping("/stats")
public Result<ProjectStatsResponse> stats() {
return Result.success(projectService.stats());
}
@Operation(summary = "Get project detail by id")
@GetMapping("/{id}")
public Result<ProjectResponse> getById(@Parameter(description = "Project id") @PathVariable Long id) {
return Result.success(projectService.getById(id));
}
@Operation(summary = "Create a project")
@PostMapping
public Result<Long> create(@Valid @RequestBody ProjectRequest request) {
return Result.success("Project created successfully.", projectService.create(request));
}
@Operation(summary = "Update a project")
@PutMapping("/{id}")
public Result<Void> update(
@Parameter(description = "Project id") @PathVariable Long id,
@Valid @RequestBody ProjectRequest request
) {
projectService.update(id, request);
return Result.success("Project updated successfully.", null);
}
@Operation(summary = "Delete a project")
@DeleteMapping("/{id}")
public Result<Void> delete(@Parameter(description = "Project id") @PathVariable Long id) {
projectService.delete(id);
return Result.success("Project deleted successfully.", null);
}
}

View File

@@ -0,0 +1,13 @@
package com.innovation.platform.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
@Data
public class LoginRequest {
@NotBlank(message = "Username is required.")
private String username;
@NotBlank(message = "Password is required.")
private String password;
}

View File

@@ -0,0 +1,22 @@
package com.innovation.platform.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
/**
* 登录响应 DTO
*/
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class LoginResponse {
private String token;
private Long userId;
private String username;
private String realName;
private String avatar;
private Integer roleType;
}

View File

@@ -0,0 +1,28 @@
package com.innovation.platform.dto;
import lombok.Data;
@Data
public class ProjectQueryRequest {
private String keyword;
private String projectName;
private Integer projectType;
private Integer projectLevel;
private Integer status;
private Long leaderId;
private Long advisorId;
private String college;
private Integer current = 1;
private Integer size = 10;
public Integer getCurrent() {
return current == null || current < 1 ? 1 : current;
}
public Integer getSize() {
if (size == null || size < 1) {
return 10;
}
return Math.min(size, 100);
}
}

View File

@@ -0,0 +1,90 @@
package com.innovation.platform.dto;
import com.innovation.platform.entity.Project;
import jakarta.validation.constraints.AssertTrue;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
@Data
public class ProjectRequest {
@NotBlank(message = "Project number is required.")
@Size(max = 30, message = "Project number must stay within 30 characters.")
private String projectNo;
@NotBlank(message = "Project name is required.")
@Size(max = 200, message = "Project name must stay within 200 characters.")
private String projectName;
@NotNull(message = "Project type is required.")
private Integer projectType;
@NotNull(message = "Project level is required.")
private Integer projectLevel;
@NotNull(message = "Leader id is required.")
private Long leaderId;
@NotNull(message = "Advisor id is required.")
private Long advisorId;
@Size(max = 4000, message = "Description must stay within 4000 characters.")
private String description;
@Size(max = 4000, message = "Research plan must stay within 4000 characters.")
private String researchPlan;
@Size(max = 4000, message = "Expected result must stay within 4000 characters.")
private String expectedResult;
@Pattern(
regexp = "^$|^\\d+(\\.\\d{1,2})?$",
message = "Budget must be a number with up to two decimal places."
)
private String budget;
private Integer status;
private LocalDate startTime;
private LocalDate endTime;
@Size(max = 100, message = "College must stay within 100 characters.")
private String college;
@AssertTrue(message = "End date must be on or after the start date.")
public boolean isDateRangeValid() {
if (startTime == null || endTime == null) {
return true;
}
return !endTime.isBefore(startTime);
}
public Project toEntity() {
Project project = new Project();
project.setProjectNo(clean(projectNo));
project.setProjectName(clean(projectName));
project.setProjectType(projectType);
project.setProjectLevel(projectLevel);
project.setLeaderId(leaderId);
project.setAdvisorId(advisorId);
project.setDescription(clean(description));
project.setResearchPlan(clean(researchPlan));
project.setExpectedResult(clean(expectedResult));
if (budget != null && !budget.isBlank()) {
project.setBudget(new BigDecimal(budget.trim()));
}
project.setStatus(status);
project.setStartTime(startTime);
project.setEndTime(endTime);
project.setCollege(clean(college));
return project;
}
private String clean(String value) {
return value == null ? null : value.trim();
}
}

View File

@@ -0,0 +1,116 @@
package com.innovation.platform.dto;
import com.innovation.platform.entity.Project;
import lombok.Builder;
import lombok.Data;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
@Data
@Builder
public class ProjectResponse {
private Long id;
private String projectNo;
private String projectName;
private Integer projectType;
private String projectTypeLabel;
private Integer projectLevel;
private String projectLevelLabel;
private Long leaderId;
private String leaderName;
private Long advisorId;
private String advisorName;
private String description;
private String researchPlan;
private String expectedResult;
private BigDecimal budget;
private Integer status;
private String statusLabel;
private LocalDate startTime;
private LocalDate endTime;
private Long durationDays;
private String college;
private LocalDateTime createTime;
private LocalDateTime updateTime;
public static ProjectResponse fromEntity(Project project) {
return fromEntity(project, Map.of());
}
public static ProjectResponse fromEntity(Project project, Map<Long, String> userNames) {
return ProjectResponse.builder()
.id(project.getId())
.projectNo(project.getProjectNo())
.projectName(project.getProjectName())
.projectType(project.getProjectType())
.projectTypeLabel(projectTypeLabel(project.getProjectType()))
.projectLevel(project.getProjectLevel())
.projectLevelLabel(projectLevelLabel(project.getProjectLevel()))
.leaderId(project.getLeaderId())
.leaderName(userNames.get(project.getLeaderId()))
.advisorId(project.getAdvisorId())
.advisorName(userNames.get(project.getAdvisorId()))
.description(project.getDescription())
.researchPlan(project.getResearchPlan())
.expectedResult(project.getExpectedResult())
.budget(project.getBudget())
.status(project.getStatus())
.statusLabel(statusLabel(project.getStatus()))
.startTime(project.getStartTime())
.endTime(project.getEndTime())
.durationDays(durationDays(project.getStartTime(), project.getEndTime()))
.college(project.getCollege())
.createTime(project.getCreateTime())
.updateTime(project.getUpdateTime())
.build();
}
private static String projectTypeLabel(Integer value) {
if (value == null) {
return "Unknown";
}
return switch (value) {
case 1 -> "Research";
case 2 -> "Startup";
case 3 -> "Competition";
default -> "Other";
};
}
private static String projectLevelLabel(Integer value) {
if (value == null) {
return "Unknown";
}
return switch (value) {
case 1 -> "School";
case 2 -> "Provincial";
case 3 -> "National";
default -> "Other";
};
}
private static String statusLabel(Integer value) {
if (value == null) {
return "Unknown";
}
return switch (value) {
case 0 -> "Draft";
case 1 -> "Pending review";
case 2 -> "In progress";
case 3 -> "Completed";
case 4 -> "Rejected";
default -> "Other";
};
}
private static Long durationDays(LocalDate start, LocalDate end) {
if (start == null || end == null || end.isBefore(start)) {
return null;
}
return ChronoUnit.DAYS.between(start, end) + 1;
}
}

View File

@@ -0,0 +1,37 @@
package com.innovation.platform.dto;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Data;
import lombok.NoArgsConstructor;
import java.math.BigDecimal;
import java.util.List;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public class ProjectStatsResponse {
private long totalProjects;
private long draftProjects;
private long pendingProjects;
private long activeProjects;
private long completedProjects;
private long rejectedProjects;
private long nationalProjects;
private BigDecimal totalBudget;
private BigDecimal averageBudget;
private List<Bucket> statusBreakdown;
private List<Bucket> levelBreakdown;
@Data
@Builder
@NoArgsConstructor
@AllArgsConstructor
public static class Bucket {
private String key;
private String label;
private long value;
}
}

View File

@@ -0,0 +1,29 @@
package com.innovation.platform.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import lombok.Data;
@Data
public class RegisterRequest {
@NotBlank(message = "Username is required.")
private String username;
@NotBlank(message = "Password is required.")
private String password;
@NotBlank(message = "Real name is required.")
private String realName;
@Pattern(regexp = "^1[3-9]\\d{9}$", message = "Phone number format is invalid.")
private String phone;
@Pattern(
regexp = "^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$",
message = "Email format is invalid."
)
private String email;
private Integer gender;
private Integer roleType;
}

View File

@@ -0,0 +1,30 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.time.LocalDateTime;
/**
* 成果实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("achievement")
public class Achievement extends BaseEntity {
private Long projectId;
private Integer achievementType;
private String achievementName;
private Integer achievementLevel;
private String authorNames;
private LocalDate publishTime;
private String publishOrg;
private String description;
private BigDecimal credit;
private Integer status;
private Long auditorId;
private LocalDateTime auditTime;
private String auditOpinion;
}

View File

@@ -0,0 +1,23 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 成果附件实体
*/
@Data
@TableName("achievement_attachment")
public class AchievementAttachment implements Serializable {
private Long id;
private Long achievementId;
private String fileName;
private String filePath;
private Long fileSize;
private String fileType;
private LocalDateTime createTime;
private Long createBy;
private Integer deleted;
}

View File

@@ -0,0 +1,30 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.*;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 基础实体类
*/
@Data
public class BaseEntity implements Serializable {
@TableId(type = IdType.AUTO)
private Long id;
@TableField(fill = FieldFill.INSERT)
private LocalDateTime createTime;
@TableField(fill = FieldFill.INSERT_UPDATE)
private LocalDateTime updateTime;
@TableField(fill = FieldFill.INSERT)
private Long createBy;
@TableField(fill = FieldFill.UPDATE)
private Long updateBy;
@TableLogic
private Integer deleted;
}

View File

@@ -0,0 +1,30 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDate;
/**
* 项目实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("project")
public class Project extends BaseEntity {
private String projectNo;
private String projectName;
private Integer projectType;
private Integer projectLevel;
private Long leaderId;
private Long advisorId;
private String description;
private String researchPlan;
private String expectedResult;
private BigDecimal budget;
private Integer status;
private LocalDate startTime;
private LocalDate endTime;
private String college;
}

View File

@@ -0,0 +1,24 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 项目附件实体
*/
@Data
@TableName("project_attachment")
public class ProjectAttachment implements Serializable {
private Long id;
private Long projectId;
private String fileName;
private String filePath;
private Long fileSize;
private String fileType;
private Integer attachmentType;
private LocalDateTime createTime;
private Long createBy;
private Integer deleted;
}

View File

@@ -0,0 +1,22 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 项目成员实体
*/
@Data
@TableName("project_member")
public class ProjectMember implements Serializable {
private Long id;
private Long projectId;
private Long userId;
private Integer memberOrder;
private Integer role;
private LocalDateTime joinTime;
private LocalDateTime createTime;
private Integer deleted;
}

View File

@@ -0,0 +1,24 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 评审实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("review")
public class Review extends BaseEntity {
private Long projectId;
private Long reviewerId;
private Integer reviewType;
private BigDecimal score;
private String opinion;
private Integer result;
private Integer status;
private LocalDateTime reviewTime;
}

View File

@@ -0,0 +1,22 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.math.BigDecimal;
import java.time.LocalDateTime;
/**
* 评审评分项实体
*/
@Data
@TableName("review_score_item")
public class ReviewScoreItem implements Serializable {
private Long id;
private Long reviewId;
private String itemName;
private BigDecimal itemScore;
private BigDecimal maxScore;
private String itemComment;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,23 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import java.math.BigDecimal;
/**
* 学生信息实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("stu_info")
public class StuInfo extends BaseEntity {
private Long userId;
private String studentNo;
private String college;
private String major;
private String grade;
private String className;
private Long advisorId;
private BigDecimal totalCredit;
}

View File

@@ -0,0 +1,18 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 系统配置实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_config")
public class SysConfig extends BaseEntity {
private String configKey;
private String configValue;
private String configName;
private String description;
}

View File

@@ -0,0 +1,25 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import java.io.Serializable;
import java.time.LocalDateTime;
/**
* 系统日志实体
*/
@Data
@TableName("sys_log")
public class SysLog implements Serializable {
private Long id;
private Long userId;
private String username;
private String operation;
private String method;
private String params;
private String ip;
private Long time;
private Integer result;
private String errorMsg;
private LocalDateTime createTime;
}

View File

@@ -0,0 +1,23 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 用户实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("sys_user")
public class SysUser extends BaseEntity {
private String username;
private String password;
private String realName;
private Integer gender;
private String phone;
private String email;
private String avatar;
private Integer status;
private Integer roleType;
}

View File

@@ -0,0 +1,19 @@
package com.innovation.platform.entity;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
/**
* 教师信息实体
*/
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("teacher_info")
public class TeacherInfo extends BaseEntity {
private Long userId;
private String teacherNo;
private String college;
private String title;
private String researchField;
}

View File

@@ -0,0 +1,46 @@
package com.innovation.platform.exception;
import com.innovation.platform.common.Result;
import lombok.extern.slf4j.Slf4j;
import org.springframework.validation.BindException;
import org.springframework.validation.FieldError;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import java.util.stream.Collectors;
@Slf4j
@RestControllerAdvice
public class GlobalExceptionHandler {
@ExceptionHandler(RuntimeException.class)
public Result<Void> handleRuntimeException(RuntimeException e) {
log.error("Business exception: {}", e.getMessage(), e);
return Result.error(400, e.getMessage());
}
@ExceptionHandler(MethodArgumentNotValidException.class)
public Result<Void> handleValidationException(MethodArgumentNotValidException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.error("Validation failed: {}", message);
return Result.error(400, message);
}
@ExceptionHandler(BindException.class)
public Result<Void> handleBindException(BindException e) {
String message = e.getBindingResult().getFieldErrors().stream()
.map(FieldError::getDefaultMessage)
.collect(Collectors.joining(", "));
log.error("Binding failed: {}", message);
return Result.error(400, message);
}
@ExceptionHandler(Exception.class)
public Result<Void> handleException(Exception e) {
log.error("Unexpected system exception: {}", e.getMessage(), e);
return Result.error(500, "Unexpected system error. Please try again later.");
}
}

View File

@@ -0,0 +1,12 @@
package com.innovation.platform.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.innovation.platform.entity.Project;
import org.apache.ibatis.annotations.Mapper;
/**
* 项目 Mapper 接口
*/
@Mapper
public interface ProjectMapper extends BaseMapper<Project> {
}

View File

@@ -0,0 +1,12 @@
package com.innovation.platform.mapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.innovation.platform.entity.SysUser;
import org.apache.ibatis.annotations.Mapper;
/**
* 用户 Mapper 接口
*/
@Mapper
public interface SysUserMapper extends BaseMapper<SysUser> {
}

View File

@@ -0,0 +1,25 @@
package com.innovation.platform.service;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.innovation.platform.dto.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse;
import com.innovation.platform.dto.ProjectStatsResponse;
import com.innovation.platform.entity.Project;
public interface ProjectService {
IPage<ProjectResponse> page(ProjectQueryRequest request);
ProjectResponse getById(Long id);
Long create(ProjectRequest request);
void update(Long id, ProjectRequest request);
void delete(Long id);
Project getEntityById(Long id);
ProjectStatsResponse stats();
}

View File

@@ -0,0 +1,37 @@
package com.innovation.platform.service;
import com.innovation.platform.dto.LoginRequest;
import com.innovation.platform.dto.LoginResponse;
import com.innovation.platform.dto.RegisterRequest;
import com.innovation.platform.entity.SysUser;
/**
* 用户服务接口
*/
public interface SysUserService {
/**
* 用户登录
*/
LoginResponse login(LoginRequest request);
/**
* 用户注册
*/
void register(RegisterRequest request);
/**
* 用户登出
*/
void logout();
/**
* 根据用户名查询用户
*/
SysUser getByUsername(String username);
/**
* 根据ID查询用户
*/
SysUser getById(Long id);
}

View File

@@ -0,0 +1,194 @@
package com.innovation.platform.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.baomidou.mybatisplus.core.metadata.IPage;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import com.innovation.platform.dto.ProjectQueryRequest;
import com.innovation.platform.dto.ProjectRequest;
import com.innovation.platform.dto.ProjectResponse;
import com.innovation.platform.dto.ProjectStatsResponse;
import com.innovation.platform.entity.Project;
import com.innovation.platform.entity.SysUser;
import com.innovation.platform.mapper.ProjectMapper;
import com.innovation.platform.mapper.SysUserMapper;
import com.innovation.platform.service.ProjectService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.util.StringUtils;
import java.math.BigDecimal;
import java.math.RoundingMode;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Objects;
import java.util.stream.Collectors;
import java.util.stream.Stream;
@Service
@RequiredArgsConstructor
public class ProjectServiceImpl implements ProjectService {
private final ProjectMapper projectMapper;
private final SysUserMapper sysUserMapper;
@Override
public IPage<ProjectResponse> page(ProjectQueryRequest request) {
Page<Project> page = new Page<>(request.getCurrent(), request.getSize());
LambdaQueryWrapper<Project> wrapper = new LambdaQueryWrapper<>();
String keyword = clean(request.getKeyword());
wrapper.and(StringUtils.hasText(keyword), nested -> nested
.like(Project::getProjectNo, keyword)
.or()
.like(Project::getProjectName, keyword)
.or()
.like(Project::getDescription, keyword)
.or()
.like(Project::getCollege, keyword))
.like(StringUtils.hasText(request.getProjectName()), Project::getProjectName, clean(request.getProjectName()))
.eq(request.getProjectType() != null, Project::getProjectType, request.getProjectType())
.eq(request.getProjectLevel() != null, Project::getProjectLevel, request.getProjectLevel())
.eq(request.getStatus() != null, Project::getStatus, request.getStatus())
.eq(request.getLeaderId() != null, Project::getLeaderId, request.getLeaderId())
.eq(request.getAdvisorId() != null, Project::getAdvisorId, request.getAdvisorId())
.eq(StringUtils.hasText(request.getCollege()), Project::getCollege, clean(request.getCollege()))
.orderByDesc(Project::getCreateTime);
IPage<Project> projectPage = projectMapper.selectPage(page, wrapper);
Map<Long, String> userNames = loadUserNames(projectPage.getRecords());
return projectPage.convert(project -> ProjectResponse.fromEntity(project, userNames));
}
@Override
public ProjectResponse getById(Long id) {
Project project = projectMapper.selectById(id);
if (project == null) {
throw new RuntimeException("Project does not exist.");
}
return ProjectResponse.fromEntity(project, loadUserNames(List.of(project)));
}
@Override
@Transactional(rollbackFor = Exception.class)
public Long create(ProjectRequest request) {
Project project = request.toEntity();
project.setStatus(project.getStatus() != null ? project.getStatus() : 0);
Long userId = StpUtil.getLoginIdAsLong();
project.setCreateBy(userId);
project.setUpdateBy(userId);
projectMapper.insert(project);
return project.getId();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void update(Long id, ProjectRequest request) {
Project existingProject = projectMapper.selectById(id);
if (existingProject == null) {
throw new RuntimeException("Project does not exist.");
}
Project project = request.toEntity();
project.setId(id);
Long userId = StpUtil.getLoginIdAsLong();
project.setUpdateBy(userId);
project.setCreateBy(existingProject.getCreateBy());
project.setCreateTime(existingProject.getCreateTime());
projectMapper.updateById(project);
}
@Override
@Transactional(rollbackFor = Exception.class)
public void delete(Long id) {
Project project = projectMapper.selectById(id);
if (project == null) {
throw new RuntimeException("Project does not exist.");
}
projectMapper.deleteById(id);
}
@Override
public Project getEntityById(Long id) {
return projectMapper.selectById(id);
}
@Override
public ProjectStatsResponse stats() {
List<Project> projects = projectMapper.selectList(new LambdaQueryWrapper<Project>().orderByDesc(Project::getCreateTime));
long total = projects.size();
long draft = projects.stream().filter(item -> statusEquals(item, 0)).count();
long pending = projects.stream().filter(item -> statusEquals(item, 1)).count();
long active = projects.stream().filter(item -> statusEquals(item, 2)).count();
long completed = projects.stream().filter(item -> statusEquals(item, 3)).count();
long rejected = projects.stream().filter(item -> statusEquals(item, 4)).count();
long national = projects.stream().filter(item -> levelEquals(item, 3)).count();
BigDecimal totalBudget = projects.stream()
.map(Project::getBudget)
.filter(value -> value != null)
.reduce(BigDecimal.ZERO, BigDecimal::add);
BigDecimal averageBudget = total == 0
? BigDecimal.ZERO
: totalBudget.divide(BigDecimal.valueOf(total), 2, RoundingMode.HALF_UP);
return ProjectStatsResponse.builder()
.totalProjects(total)
.draftProjects(draft)
.pendingProjects(pending)
.activeProjects(active)
.completedProjects(completed)
.rejectedProjects(rejected)
.nationalProjects(national)
.totalBudget(totalBudget)
.averageBudget(averageBudget)
.statusBreakdown(List.of(
ProjectStatsResponse.Bucket.builder().key("draft").label("Draft").value(draft).build(),
ProjectStatsResponse.Bucket.builder().key("pending").label("Pending review").value(pending).build(),
ProjectStatsResponse.Bucket.builder().key("active").label("In progress").value(active).build(),
ProjectStatsResponse.Bucket.builder().key("completed").label("Completed").value(completed).build(),
ProjectStatsResponse.Bucket.builder().key("rejected").label("Rejected").value(rejected).build()
))
.levelBreakdown(List.of(
ProjectStatsResponse.Bucket.builder().key("school").label("School").value(projects.stream().filter(item -> levelEquals(item, 1)).count()).build(),
ProjectStatsResponse.Bucket.builder().key("provincial").label("Provincial").value(projects.stream().filter(item -> levelEquals(item, 2)).count()).build(),
ProjectStatsResponse.Bucket.builder().key("national").label("National").value(national).build()
))
.build();
}
private boolean statusEquals(Project project, int status) {
return project.getStatus() != null && project.getStatus() == status;
}
private boolean levelEquals(Project project, int level) {
return project.getProjectLevel() != null && project.getProjectLevel() == level;
}
private String clean(String value) {
return value == null ? null : value.trim();
}
private Map<Long, String> loadUserNames(List<Project> projects) {
List<Long> userIds = projects.stream()
.flatMap(project -> Stream.of(project.getLeaderId(), project.getAdvisorId()))
.filter(Objects::nonNull)
.distinct()
.toList();
if (userIds.isEmpty()) {
return Collections.emptyMap();
}
return sysUserMapper.selectBatchIds(userIds).stream()
.collect(Collectors.toMap(SysUser::getId, SysUser::getRealName));
}
}

View File

@@ -0,0 +1,86 @@
package com.innovation.platform.service.impl;
import cn.dev33.satoken.stp.StpUtil;
import cn.hutool.crypto.digest.BCrypt;
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
import com.innovation.platform.dto.LoginRequest;
import com.innovation.platform.dto.LoginResponse;
import com.innovation.platform.dto.RegisterRequest;
import com.innovation.platform.entity.SysUser;
import com.innovation.platform.mapper.SysUserMapper;
import com.innovation.platform.service.SysUserService;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
@RequiredArgsConstructor
public class SysUserServiceImpl implements SysUserService {
private final SysUserMapper sysUserMapper;
@Override
public LoginResponse login(LoginRequest request) {
SysUser user = getByUsername(request.getUsername());
if (user == null) {
throw new RuntimeException("Invalid username or password.");
}
if (!BCrypt.checkpw(request.getPassword(), user.getPassword())) {
throw new RuntimeException("Invalid username or password.");
}
if (user.getStatus() != null && user.getStatus() == 0) {
throw new RuntimeException("This account is disabled.");
}
StpUtil.login(user.getId());
return LoginResponse.builder()
.token(StpUtil.getTokenValue())
.userId(user.getId())
.username(user.getUsername())
.realName(user.getRealName())
.avatar(user.getAvatar())
.roleType(user.getRoleType())
.build();
}
@Override
@Transactional(rollbackFor = Exception.class)
public void register(RegisterRequest request) {
if (getByUsername(request.getUsername()) != null) {
throw new RuntimeException("Username already exists.");
}
SysUser user = new SysUser();
user.setUsername(request.getUsername());
user.setPassword(BCrypt.hashpw(request.getPassword()));
user.setRealName(request.getRealName());
user.setPhone(request.getPhone());
user.setEmail(request.getEmail());
user.setGender(request.getGender());
user.setRoleType(request.getRoleType() != null ? request.getRoleType() : 0);
user.setStatus(1);
sysUserMapper.insert(user);
}
@Override
public void logout() {
StpUtil.logout();
}
@Override
public SysUser getByUsername(String username) {
return sysUserMapper.selectOne(
new LambdaQueryWrapper<SysUser>()
.eq(SysUser::getUsername, username)
);
}
@Override
public SysUser getById(Long id) {
return sysUserMapper.selectById(id);
}
}

View File

@@ -0,0 +1,28 @@
server:
port: 8080
spring:
datasource:
url: jdbc:mysql://mysql:3306/innovation_platform?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai
username: innovation
password: innovation123
driver-class-name: com.mysql.cj.jdbc.Driver
sql:
init:
mode: always
schema-locations: classpath:schema.sql
data-locations: classpath:data.sql
mybatis-plus:
mapper-locations: classpath*:/mapper/**/*.xml
configuration:
map-underscore-to-camel-case: true
sa-token:
token-name: satoken
timeout: 86400
active-timeout: -1
is-concurrent: true
is-share: true
token-style: uuid
is-log: false

View File

@@ -0,0 +1,201 @@
-- Seed users
INSERT INTO sys_user (id, username, password, real_name, gender, role_type, status) VALUES
(1, 'admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'System Admin', 1, 3, 1),
(2, 'teacher001', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'Alice Chen', 2, 2, 1),
(3, 'teacher002', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'Michael Zhao', 1, 2, 1),
(4, 'student001', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'Olivia Lin', 2, 1, 1),
(5, 'student002', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'Ethan Wu', 1, 1, 1),
(6, 'student003', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iAt9hQIu', 'Sophia Xu', 2, 1, 1)
ON DUPLICATE KEY UPDATE
password = VALUES(password),
real_name = VALUES(real_name),
gender = VALUES(gender),
role_type = VALUES(role_type),
status = VALUES(status);
-- Teacher profiles
INSERT INTO teacher_info (id, user_id, teacher_no, college, title, research_field) VALUES
(1, 2, 'T2024001', 'School of Computer Science', 'Associate Professor', 'Artificial Intelligence'),
(2, 3, 'T2024002', 'School of Management', 'Professor', 'Digital Entrepreneurship')
ON DUPLICATE KEY UPDATE
college = VALUES(college),
title = VALUES(title),
research_field = VALUES(research_field);
-- Student profiles
INSERT INTO stu_info (id, user_id, student_no, college, major, grade, class_name, advisor_id) VALUES
(1, 4, 'S2021001', 'School of Computer Science', 'Software Engineering', '2021', 'SE2101', 2),
(2, 5, 'S2021002', 'School of Management', 'Business Analytics', '2021', 'BA2101', 3),
(3, 6, 'S2022001', 'School of Information Engineering', 'Data Science', '2022', 'DS2201', 2)
ON DUPLICATE KEY UPDATE
college = VALUES(college),
major = VALUES(major),
grade = VALUES(grade),
class_name = VALUES(class_name),
advisor_id = VALUES(advisor_id);
-- Demo projects
INSERT INTO project (
id,
project_no,
project_name,
project_type,
project_level,
leader_id,
advisor_id,
description,
research_plan,
expected_result,
budget,
status,
start_time,
end_time,
college,
create_by,
update_by
) VALUES
(
1,
'PRJ2024-001',
'Smart Campus Assistant',
1,
2,
4,
2,
'Build a multimodal assistant for campus services and policy guidance.',
'Deliver a knowledge base, chat workflow, and pilot deployment in the service center.',
'Working prototype, evaluation report, and student operation handbook.',
18000.00,
2,
'2024-03-01',
'2024-12-31',
'School of Computer Science',
4,
4
),
(
2,
'PRJ2024-002',
'Green Logistics Lab',
2,
1,
5,
3,
'Design a campus scale reverse logistics service for reusable packaging.',
'Run a semester pilot with collection points, route optimization, and merchant onboarding.',
'Service blueprint, mini-program prototype, and impact dashboard.',
9500.00,
1,
'2024-04-15',
'2024-11-30',
'School of Management',
5,
5
),
(
3,
'PRJ2024-003',
'Medical Image Triage Toolkit',
1,
3,
6,
2,
'Explore lightweight triage assistance for common medical imaging scenarios.',
'Prepare datasets, benchmark candidate models, and validate interpretability outputs.',
'Research paper draft, benchmark scripts, and explainability report.',
32000.00,
3,
'2024-01-10',
'2025-01-15',
'School of Information Engineering',
6,
6
),
(
4,
'PRJ2024-004',
'AR Heritage Guide',
3,
1,
4,
3,
'Create an augmented reality campus heritage route for new students and visitors.',
'Complete scene design, oral history collection, and route testing before launch.',
'Interactive AR route, narration scripts, and event showcase materials.',
12000.00,
0,
'2024-09-01',
'2025-03-01',
'School of Design',
4,
4
),
(
5,
'PRJ2024-005',
'Agri IoT Monitoring Network',
1,
2,
5,
2,
'Prototype low-cost monitoring stations for greenhouse temperature and soil conditions.',
'Build device kits, integrate dashboards, and verify long-run data stability.',
'Sensor kit, field report, and maintenance SOP.',
21000.00,
4,
'2024-02-20',
'2024-10-20',
'School of Agronomy',
5,
5
),
(
6,
'PRJ2024-006',
'Inclusive Career Coach',
2,
3,
6,
3,
'Build an inclusive career guidance service for first-generation college students.',
'Interview student cohorts, refine service flows, and launch a recommendation prototype.',
'Coaching toolkit, matching engine prototype, and adoption report.',
28500.00,
2,
'2024-05-01',
'2025-02-28',
'School of Public Affairs',
6,
6
)
ON DUPLICATE KEY UPDATE
project_name = VALUES(project_name),
project_type = VALUES(project_type),
project_level = VALUES(project_level),
leader_id = VALUES(leader_id),
advisor_id = VALUES(advisor_id),
description = VALUES(description),
research_plan = VALUES(research_plan),
expected_result = VALUES(expected_result),
budget = VALUES(budget),
status = VALUES(status),
start_time = VALUES(start_time),
end_time = VALUES(end_time),
college = VALUES(college),
update_by = VALUES(update_by);
-- Project members
INSERT INTO project_member (id, project_id, user_id, member_order, role) VALUES
(1, 1, 4, 1, 2),
(2, 1, 5, 2, 1),
(3, 2, 5, 1, 2),
(4, 2, 6, 2, 1),
(5, 3, 6, 1, 2),
(6, 3, 4, 2, 1),
(7, 4, 4, 1, 2),
(8, 5, 5, 1, 2),
(9, 6, 6, 1, 2),
(10, 6, 5, 2, 1)
ON DUPLICATE KEY UPDATE
member_order = VALUES(member_order),
role = VALUES(role);

View File

@@ -0,0 +1,133 @@
-- Core user table
CREATE TABLE IF NOT EXISTS sys_user (
id BIGINT NOT NULL AUTO_INCREMENT,
username VARCHAR(50) NOT NULL,
password VARCHAR(100) NOT NULL,
real_name VARCHAR(50) NOT NULL,
gender TINYINT DEFAULT 0,
phone VARCHAR(20) DEFAULT NULL,
email VARCHAR(100) DEFAULT NULL,
avatar VARCHAR(255) DEFAULT NULL,
status TINYINT DEFAULT 1,
role_type TINYINT NOT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
create_by BIGINT DEFAULT NULL,
update_by BIGINT DEFAULT NULL,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_username (username)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Student profile table
CREATE TABLE IF NOT EXISTS stu_info (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
student_no VARCHAR(20) NOT NULL,
college VARCHAR(100) NOT NULL,
major VARCHAR(100) NOT NULL,
grade VARCHAR(10) NOT NULL,
class_name VARCHAR(50) DEFAULT NULL,
advisor_id BIGINT DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_student_no (student_no),
UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Teacher profile table
CREATE TABLE IF NOT EXISTS teacher_info (
id BIGINT NOT NULL AUTO_INCREMENT,
user_id BIGINT NOT NULL,
teacher_no VARCHAR(20) NOT NULL,
college VARCHAR(100) NOT NULL,
title VARCHAR(50) DEFAULT NULL,
research_field VARCHAR(255) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_teacher_no (teacher_no),
UNIQUE KEY uk_user_id (user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Project table
CREATE TABLE IF NOT EXISTS project (
id BIGINT NOT NULL AUTO_INCREMENT,
project_no VARCHAR(30) NOT NULL,
project_name VARCHAR(200) NOT NULL,
project_type TINYINT NOT NULL,
project_level TINYINT NOT NULL,
leader_id BIGINT NOT NULL,
advisor_id BIGINT NOT NULL,
description TEXT,
research_plan TEXT,
expected_result TEXT,
budget DECIMAL(10,2) DEFAULT 0.00,
status TINYINT DEFAULT 1,
start_time DATE DEFAULT NULL,
end_time DATE DEFAULT NULL,
college VARCHAR(100) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
create_by BIGINT DEFAULT NULL,
update_by BIGINT DEFAULT NULL,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_project_no (project_no)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Project member table
CREATE TABLE IF NOT EXISTS project_member (
id BIGINT NOT NULL AUTO_INCREMENT,
project_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
member_order INT DEFAULT 1,
role TINYINT DEFAULT 1,
join_time DATETIME DEFAULT CURRENT_TIMESTAMP,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id),
UNIQUE KEY uk_project_user (project_id, user_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Achievement table
CREATE TABLE IF NOT EXISTS achievement (
id BIGINT NOT NULL AUTO_INCREMENT,
project_id BIGINT NOT NULL,
achievement_type TINYINT NOT NULL,
achievement_name VARCHAR(200) NOT NULL,
achievement_level TINYINT DEFAULT NULL,
author_names VARCHAR(500) DEFAULT NULL,
publish_time DATE DEFAULT NULL,
publish_org VARCHAR(200) DEFAULT NULL,
description TEXT,
status TINYINT DEFAULT 1,
auditor_id BIGINT DEFAULT NULL,
audit_time DATETIME DEFAULT NULL,
audit_opinion VARCHAR(500) DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
create_by BIGINT DEFAULT NULL,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- Review table
CREATE TABLE IF NOT EXISTS review (
id BIGINT NOT NULL AUTO_INCREMENT,
project_id BIGINT NOT NULL,
reviewer_id BIGINT NOT NULL,
review_type TINYINT NOT NULL,
score DECIMAL(5,1) DEFAULT NULL,
opinion TEXT,
result TINYINT DEFAULT NULL,
status TINYINT DEFAULT 1,
review_time DATETIME DEFAULT NULL,
create_time DATETIME DEFAULT CURRENT_TIMESTAMP,
update_time DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
deleted TINYINT DEFAULT 0,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

View File

@@ -0,0 +1,562 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Innovation Platform Dashboard</title>
<style>
:root {
--bg: #f3f6fb;
--panel: rgba(255,255,255,0.94);
--line: #d9e2ec;
--text: #102033;
--muted: #5a6c81;
--brand: #1768c8;
--accent: #0f9d77;
--warn: #f08c2b;
--shadow: 0 18px 40px rgba(16, 32, 51, 0.12);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
color: var(--text);
font-family: "Aptos", "Segoe UI", "Microsoft YaHei", sans-serif;
background:
radial-gradient(circle at top right, rgba(23, 104, 200, 0.14), transparent 28%),
radial-gradient(circle at bottom left, rgba(15, 157, 119, 0.12), transparent 24%),
var(--bg);
}
button, input, select { font: inherit; }
.shell { max-width: 1460px; margin: 0 auto; padding: 24px; }
.hero, .card {
background: var(--panel);
border: 1px solid var(--line);
border-radius: 28px;
box-shadow: var(--shadow);
backdrop-filter: blur(10px);
}
.hero { padding: 28px; margin-bottom: 18px; }
.eyebrow {
font-size: 12px;
letter-spacing: 0.12em;
text-transform: uppercase;
font-weight: 800;
color: var(--brand);
}
h1, h2, h3 { margin: 10px 0 12px; }
p { margin: 0; color: var(--muted); line-height: 1.85; }
.hero-grid {
display: grid;
grid-template-columns: 1.2fr 0.8fr;
gap: 18px;
align-items: start;
}
.hero-actions, .inline-actions, .chip-list {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.btn, .btn-soft {
border: 0;
border-radius: 999px;
padding: 12px 18px;
cursor: pointer;
font-weight: 700;
}
.btn { background: linear-gradient(135deg, var(--brand), #3a8dff); color: #fff; }
.btn-soft { background: #eaf3ff; color: var(--brand); }
.layout {
display: grid;
grid-template-columns: 360px minmax(0, 1fr);
gap: 18px;
align-items: start;
}
.sidebar, .content { display: flex; flex-direction: column; gap: 18px; }
.card { padding: 22px; }
.field-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
margin-top: 14px;
}
.field {
display: flex;
flex-direction: column;
gap: 8px;
}
label { font-size: 13px; font-weight: 700; color: #21364d; }
input, select {
border: 1px solid var(--line);
border-radius: 14px;
padding: 12px 14px;
background: transparent;
color: var(--text);
outline: none;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 12px;
margin-top: 16px;
}
.stat {
padding: 16px;
border-radius: 18px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.5);
}
.stat span { display: block; font-size: 12px; color: var(--muted); margin-bottom: 8px; }
.stat strong { font-size: 26px; }
.sub-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
}
.list, .table-wrap {
border: 1px solid var(--line);
border-radius: 18px;
overflow: hidden;
background: rgba(255,255,255,0.5);
}
.list-item {
display: flex;
justify-content: space-between;
gap: 12px;
padding: 14px 16px;
border-bottom: 1px solid var(--line);
}
.list-item:last-child { border-bottom: 0; }
table { width: 100%; border-collapse: collapse; }
th, td {
padding: 14px 16px;
text-align: left;
border-bottom: 1px solid var(--line);
vertical-align: top;
}
th { background: #f7fbff; color: #29425a; font-size: 13px; }
td { color: var(--muted); }
.pill {
display: inline-flex;
padding: 6px 10px;
border-radius: 999px;
background: #edf5ff;
color: var(--brand);
font-size: 12px;
font-weight: 700;
}
.notice {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.55);
color: var(--muted);
line-height: 1.8;
}
.status {
margin-top: 14px;
padding: 14px;
border-radius: 16px;
border: 1px solid #d8e5f4;
background: #f7fbff;
color: #35506b;
line-height: 1.8;
min-height: 58px;
}
.status.error {
background: #fff3f3;
border-color: #f0d0d0;
color: #b94242;
}
.status.success {
background: #eefbf6;
border-color: #d3f0e5;
color: #177658;
}
@media (max-width: 1180px) {
.layout, .hero-grid, .sub-grid { grid-template-columns: 1fr; }
.stats { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}
@media (max-width: 720px) {
.shell { padding: 14px; }
.field-grid, .stats { grid-template-columns: 1fr; }
}
</style>
</head>
<body>
<div class="shell">
<section class="hero">
<div class="hero-grid">
<div>
<div class="eyebrow">Innovation Platform</div>
<h1>Project dashboard for university innovation programs</h1>
<p>This static page is served by Spring Boot and talks directly to the project APIs. It helps demo authentication, project search, and the new statistics endpoint without requiring a separate frontend build.</p>
</div>
<div>
<div class="chip-list">
<span class="pill">Use satoken header</span>
<span class="pill">Supports quick login</span>
<span class="pill">Reads stats and project pages</span>
</div>
<div class="notice">
Seed accounts share the bundled demo password:
<br />admin / admin123
<br />teacher001 / admin123
<br />student001 / admin123
</div>
</div>
</div>
</section>
<div class="layout">
<aside class="sidebar">
<section class="card">
<div class="eyebrow">Connection</div>
<h2>Login and token helper</h2>
<div class="field-grid">
<div class="field">
<label for="baseUrl">API base URL</label>
<input id="baseUrl" value="" placeholder="http://localhost:8080" />
</div>
<div class="field">
<label for="token">satoken header value</label>
<input id="token" value="" placeholder="Paste token here" />
</div>
<div class="field">
<label for="accountPreset">Demo account</label>
<select id="accountPreset">
<option value="admin">Admin</option>
<option value="teacher001">Teacher</option>
<option value="student001">Student</option>
</select>
</div>
<div class="field">
<label for="username">Username</label>
<input id="username" value="admin" />
</div>
<div class="field">
<label for="password">Password</label>
<input id="password" type="password" value="admin123" />
</div>
</div>
<div class="inline-actions" style="margin-top: 14px;">
<button class="btn" type="button" id="loginBtn">Request token</button>
<button class="btn-soft" type="button" id="statsBtn">Load stats</button>
</div>
<div class="status" id="statusBox">Ready. Request a token or paste one manually, then load dashboard data.</div>
</section>
<section class="card">
<div class="eyebrow">Filters</div>
<h2>Project query</h2>
<div class="field-grid">
<div class="field">
<label for="keyword">Keyword</label>
<input id="keyword" placeholder="AI, campus, PRJ2024..." />
</div>
<div class="field">
<label for="projectType">Project type</label>
<select id="projectType">
<option value="">All</option>
<option value="1">Research</option>
<option value="2">Startup</option>
<option value="3">Competition</option>
</select>
</div>
<div class="field">
<label for="projectLevel">Project level</label>
<select id="projectLevel">
<option value="">All</option>
<option value="1">School</option>
<option value="2">Provincial</option>
<option value="3">National</option>
</select>
</div>
<div class="field">
<label for="status">Status</label>
<select id="status">
<option value="">All</option>
<option value="0">Draft</option>
<option value="1">Pending review</option>
<option value="2">In progress</option>
<option value="3">Completed</option>
<option value="4">Rejected</option>
</select>
</div>
</div>
<div class="inline-actions" style="margin-top: 14px;">
<button class="btn" type="button" id="projectsBtn">Load projects</button>
<button class="btn-soft" type="button" id="resetBtn">Reset filters</button>
</div>
</section>
</aside>
<main class="content">
<section class="card">
<div class="eyebrow">Overview</div>
<h2>Program health snapshot</h2>
<div class="stats">
<div class="stat"><span>Total projects</span><strong id="totalProjects">-</strong></div>
<div class="stat"><span>Draft</span><strong id="draftProjects">-</strong></div>
<div class="stat"><span>Pending review</span><strong id="pendingProjects">-</strong></div>
<div class="stat"><span>In progress</span><strong id="activeProjects">-</strong></div>
<div class="stat"><span>Completed</span><strong id="completedProjects">-</strong></div>
<div class="stat"><span>Rejected</span><strong id="rejectedProjects">-</strong></div>
</div>
<div class="stats">
<div class="stat"><span>National level</span><strong id="nationalProjects">-</strong></div>
<div class="stat"><span>Total budget</span><strong id="totalBudget">-</strong></div>
<div class="stat"><span>Average budget</span><strong id="averageBudget">-</strong></div>
</div>
</section>
<section class="card">
<div class="eyebrow">Breakdown</div>
<h2>Status and level distribution</h2>
<div class="sub-grid">
<div class="list" id="statusList"></div>
<div class="list" id="levelList"></div>
</div>
</section>
<section class="card">
<div class="eyebrow">Projects</div>
<h2>Recent project records</h2>
<div class="table-wrap">
<table>
<thead>
<tr>
<th>Project</th>
<th>Lead</th>
<th>Type</th>
<th>Status</th>
<th>Level</th>
<th>Timeline</th>
<th>College</th>
<th>Budget</th>
</tr>
</thead>
<tbody id="projectTable">
<tr><td colspan="8">No data loaded yet.</td></tr>
</tbody>
</table>
</div>
</section>
</main>
</div>
</div>
<script>
const refs = {
baseUrl: document.getElementById('baseUrl'),
token: document.getElementById('token'),
accountPreset: document.getElementById('accountPreset'),
username: document.getElementById('username'),
password: document.getElementById('password'),
keyword: document.getElementById('keyword'),
projectType: document.getElementById('projectType'),
projectLevel: document.getElementById('projectLevel'),
status: document.getElementById('status'),
statusBox: document.getElementById('statusBox'),
projectTable: document.getElementById('projectTable'),
statusList: document.getElementById('statusList'),
levelList: document.getElementById('levelList')
};
const statRefs = {
totalProjects: document.getElementById('totalProjects'),
draftProjects: document.getElementById('draftProjects'),
pendingProjects: document.getElementById('pendingProjects'),
activeProjects: document.getElementById('activeProjects'),
completedProjects: document.getElementById('completedProjects'),
rejectedProjects: document.getElementById('rejectedProjects'),
nationalProjects: document.getElementById('nationalProjects'),
totalBudget: document.getElementById('totalBudget'),
averageBudget: document.getElementById('averageBudget')
};
const accountPresets = {
admin: { username: 'admin', password: 'admin123' },
teacher001: { username: 'teacher001', password: 'admin123' },
student001: { username: 'student001', password: 'admin123' }
};
refs.baseUrl.value = window.location.origin || 'http://localhost:8080';
applyPresetAccount();
document.getElementById('loginBtn').addEventListener('click', login);
document.getElementById('statsBtn').addEventListener('click', loadStats);
document.getElementById('projectsBtn').addEventListener('click', loadProjects);
refs.accountPreset.addEventListener('change', applyPresetAccount);
document.getElementById('resetBtn').addEventListener('click', () => {
refs.keyword.value = '';
refs.projectType.value = '';
refs.projectLevel.value = '';
refs.status.value = '';
loadProjects();
});
async function login() {
setStatus('Requesting login token...');
try {
const response = await fetch(buildUrl('/api/auth/login'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
username: refs.username.value.trim(),
password: refs.password.value
})
});
const payload = await response.json();
if (payload.code !== 200) {
throw new Error(payload.message || 'Login failed.');
}
refs.token.value = payload.data.token || '';
setStatus('Login succeeded. Token stored in the satoken field.', 'success');
await loadStats();
await loadProjects();
} catch (error) {
setStatus(error.message, 'error');
}
}
async function loadStats() {
setStatus('Loading project statistics...');
try {
const payload = await apiFetch('/api/projects/stats');
const stats = payload.data;
statRefs.totalProjects.textContent = stats.totalProjects ?? '-';
statRefs.draftProjects.textContent = stats.draftProjects ?? '-';
statRefs.pendingProjects.textContent = stats.pendingProjects ?? '-';
statRefs.activeProjects.textContent = stats.activeProjects ?? '-';
statRefs.completedProjects.textContent = stats.completedProjects ?? '-';
statRefs.rejectedProjects.textContent = stats.rejectedProjects ?? '-';
statRefs.nationalProjects.textContent = stats.nationalProjects ?? '-';
statRefs.totalBudget.textContent = formatMoney(stats.totalBudget);
statRefs.averageBudget.textContent = formatMoney(stats.averageBudget);
renderBucketList(refs.statusList, stats.statusBreakdown || []);
renderBucketList(refs.levelList, stats.levelBreakdown || []);
setStatus('Statistics loaded successfully.', 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function loadProjects() {
setStatus('Loading project records...');
const params = new URLSearchParams();
if (refs.keyword.value.trim()) params.set('keyword', refs.keyword.value.trim());
if (refs.projectType.value) params.set('projectType', refs.projectType.value);
if (refs.projectLevel.value) params.set('projectLevel', refs.projectLevel.value);
if (refs.status.value) params.set('status', refs.status.value);
params.set('size', '8');
try {
const payload = await apiFetch('/api/projects?' + params.toString());
const page = payload.data || {};
const records = page.records || [];
if (!records.length) {
refs.projectTable.innerHTML = '<tr><td colspan="8">No matching projects found.</td></tr>';
} else {
refs.projectTable.innerHTML = records.map((item) => `
<tr>
<td>
<strong>${escapeHtml(item.projectName || 'Unnamed project')}</strong><br />
<span>${escapeHtml(item.projectNo || '-')}</span>
</td>
<td>
<strong>${escapeHtml(item.leaderName || 'Unassigned')}</strong><br />
<span>Advisor: ${escapeHtml(item.advisorName || '-')}</span>
</td>
<td>${escapeHtml(item.projectTypeLabel || '-')}</td>
<td>${escapeHtml(item.statusLabel || '-')}</td>
<td>${escapeHtml(item.projectLevelLabel || '-')}</td>
<td>${formatDuration(item.startTime, item.endTime, item.durationDays)}</td>
<td>${escapeHtml(item.college || '-')}</td>
<td>${formatMoney(item.budget)}</td>
</tr>
`).join('');
}
setStatus(`Loaded ${records.length} project record(s).`, 'success');
} catch (error) {
setStatus(error.message, 'error');
}
}
async function apiFetch(path) {
const headers = {};
const token = refs.token.value.trim();
if (token) {
headers.satoken = token;
}
const response = await fetch(buildUrl(path), { headers });
const payload = await response.json();
if (payload.code !== 200) {
throw new Error(payload.message || 'Request failed.');
}
return payload;
}
function renderBucketList(target, items) {
if (!items.length) {
target.innerHTML = '<div class="list-item"><span>No data</span><strong>-</strong></div>';
return;
}
target.innerHTML = items.map((item) => `
<div class="list-item">
<span>${escapeHtml(item.label || item.key || 'Item')}</span>
<strong>${item.value ?? 0}</strong>
</div>
`).join('');
}
function buildUrl(path) {
const base = refs.baseUrl.value.trim().replace(/\/$/, '');
return (base || 'http://localhost:8080') + path;
}
function formatMoney(value) {
if (value === null || value === undefined || value === '') {
return '-';
}
const num = Number(value);
if (Number.isNaN(num)) {
return escapeHtml(String(value));
}
return num.toLocaleString(undefined, { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}
function formatDuration(start, end, durationDays) {
const parts = [];
if (start || end) {
parts.push(`${escapeHtml(start || '?')} to ${escapeHtml(end || '?')}`);
}
if (durationDays) {
parts.push(`${durationDays} day(s)`);
}
return parts.length ? parts.join('<br />') : '-';
}
function applyPresetAccount() {
const preset = accountPresets[refs.accountPreset.value] || accountPresets.admin;
refs.username.value = preset.username;
refs.password.value = preset.password;
}
function setStatus(message, tone) {
refs.statusBox.textContent = message;
refs.statusBox.className = 'status' + (tone ? ' ' + tone : '');
}
function escapeHtml(value) {
return String(value)
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&#39;');
}
</script>
</body>
</html>