fix: 前后端接口对齐修复
- 修复 sys-config 接口参数对齐(configKey/configValue) - 添加 dict 字典项管理 API - 修复 logs 接口参数格式(批量删除/清理日志) - 添加 request.ts postForm/putForm 方法支持 Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
418aa57ea8
commit
48fc71b41d
148
.claude/memory/java-backend.md
Normal file
148
.claude/memory/java-backend.md
Normal file
@ -0,0 +1,148 @@
|
|||||||
|
# Java 后端开发规范
|
||||||
|
|
||||||
|
本项目 Java 后端开发规范,基于 Spring Boot + MyBatis-Plus 技术栈。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
1. **OpenAPI 规范驱动** - 前后端通过接口规范对齐
|
||||||
|
2. **类型安全优先** - 强制类型校验
|
||||||
|
3. **约定大于配置** - 统一代码风格
|
||||||
|
4. **自动化优先** - 能自动化的绝不手动
|
||||||
|
5. **三层架构分离** - Controller、Service、Mapper 职责清晰
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术选型 | 版本 |
|
||||||
|
|------|---------|------|
|
||||||
|
| 框架 | Spring Boot | 3.2+ |
|
||||||
|
| 持久层 | MyBatis-Plus | 3.5+ |
|
||||||
|
| 对象映射 | MapStruct | 1.5+ |
|
||||||
|
| 数据库 | MySQL | 8.0+ |
|
||||||
|
| 缓存 | Redis | - |
|
||||||
|
| 安全 | Spring Security + JWT | - |
|
||||||
|
| API 文档 | Knife4j | 4.x |
|
||||||
|
|
||||||
|
## 三层架构
|
||||||
|
|
||||||
|
| 层级 | 职责 | 数据接收 | 数据返回 |
|
||||||
|
|------|------|---------|---------|
|
||||||
|
| Controller | 接收请求、参数校验、返回响应 | DTO/Request | VO/Response |
|
||||||
|
| Service | 处理业务逻辑、事务控制 | DTO/Entity | Entity |
|
||||||
|
| Mapper | 数据库 CRUD 操作 | Entity/条件 | Entity |
|
||||||
|
|
||||||
|
**核心规范:**
|
||||||
|
- Service↔Mapper 之间只用 Entity,禁止 DTO/VO 转换
|
||||||
|
- 转换只在 Controller 层发生
|
||||||
|
- Service 继承 `IService<T>`
|
||||||
|
- 查询接口默认分页
|
||||||
|
|
||||||
|
## 消除魔法值规范
|
||||||
|
|
||||||
|
**禁止在代码中使用魔法值,所有状态、类型、常量必须使用枚举定义**
|
||||||
|
|
||||||
|
- 枚举类存放在 `enums` 包下
|
||||||
|
- 枚举包含 `code` 和 `desc` 字段
|
||||||
|
- 提供 `getCode()`、`getDesc()`、`valueOfCode()` 方法
|
||||||
|
- 数据库存储 `code`,代码中使用枚举
|
||||||
|
|
||||||
|
## ORM 实体类规范
|
||||||
|
|
||||||
|
### 表名命名
|
||||||
|
|
||||||
|
- `t_user_*` - 用户模块
|
||||||
|
- `t_sys_*` - 系统模块
|
||||||
|
- `t_biz_*` - 业务模块
|
||||||
|
- `t_auth_*` - 权限模块
|
||||||
|
|
||||||
|
### 审计字段(必填)
|
||||||
|
|
||||||
|
所有表必须包含审计字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | BIGINT | 主键(自增) |
|
||||||
|
| create_by | VARCHAR(50) | 创建人 |
|
||||||
|
| create_time | DATETIME | 创建时间 |
|
||||||
|
| update_by | VARCHAR(50) | 更新人 |
|
||||||
|
| update_time | DATETIME | 更新时间 |
|
||||||
|
| deleted | TINYINT | 逻辑删除(0-未删除,1-已删除) |
|
||||||
|
|
||||||
|
- 审计字段通过 MyBatis-Plus `FieldFill` 自动填充
|
||||||
|
- 禁止手动设置审计字段
|
||||||
|
|
||||||
|
## 日志规范
|
||||||
|
|
||||||
|
### 日志语言
|
||||||
|
|
||||||
|
**所有日志必须使用中文**
|
||||||
|
|
||||||
|
### TraceId 链路追踪
|
||||||
|
|
||||||
|
- 使用 MDC 实现 TraceId 链路追踪
|
||||||
|
- AOP 切面在请求入口生成 TraceId
|
||||||
|
- 日志格式包含 `[%X{traceId}]`
|
||||||
|
|
||||||
|
### 环境差异化配置
|
||||||
|
|
||||||
|
| 环境 | 日志策略 |
|
||||||
|
|------|---------|
|
||||||
|
| 开发/测试 | 全量记录(DEBUG 级别) |
|
||||||
|
| 生产 | 精简记录(INFO/WARN 级别) |
|
||||||
|
|
||||||
|
### AOP 日志实现
|
||||||
|
|
||||||
|
- 拦截 Controller 层所有方法
|
||||||
|
- 请求日志:TraceId、请求方法、路径、IP、耗时
|
||||||
|
- 异常日志:TraceId、异常类型、消息、堆栈
|
||||||
|
|
||||||
|
## 统一响应格式
|
||||||
|
|
||||||
|
```java
|
||||||
|
Result<T> {
|
||||||
|
code: Integer; // 状态码
|
||||||
|
message: String; // 消息
|
||||||
|
data: T; // 数据
|
||||||
|
timestamp: Long; // 时间戳
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**错误码:** 200-成功、400-参数错误、401-未授权、403-无权限、404-不存在、500-系统错误
|
||||||
|
|
||||||
|
## MapStruct 对象映射
|
||||||
|
|
||||||
|
- 使用 `@Mapper` 注解定义 Converter 接口
|
||||||
|
- Entity ↔ VO 转换在 Controller 层调用
|
||||||
|
- Service 层和 Mapper 层禁止 DTO/VO 转换
|
||||||
|
- 命名规范:`XxxConverter` 或 `XxxMapStruct`
|
||||||
|
|
||||||
|
## 工具类规范
|
||||||
|
|
||||||
|
- 工具函数集中管理,禁止在 Controller 层编写工具方法
|
||||||
|
- 工具类私有构造、静态方法、无状态、线程安全
|
||||||
|
- 命名规范:`XxxUtil`(通用)、`XxxHelper`(业务)、`XxxConverter`(转换)
|
||||||
|
|
||||||
|
## 多环境配置
|
||||||
|
|
||||||
|
| 配置项 | dev | test | prod |
|
||||||
|
|--------|-----|------|------|
|
||||||
|
| SQL 日志 | 开启 | 开启 | 关闭 |
|
||||||
|
| Swagger | 开启 | 开启 | 关闭 |
|
||||||
|
| Flyway Clean | 允许 | 禁止 | 禁止 |
|
||||||
|
|
||||||
|
## 快速参考
|
||||||
|
|
||||||
|
### Redis Key 命名
|
||||||
|
|
||||||
|
- `auth:token:{token}` - 用户 Token
|
||||||
|
- `user:info:{userId}` - 用户信息缓存
|
||||||
|
- `dict:{type}` - 数据字典
|
||||||
|
- `lock:{resource}:{id}` - 分布式锁
|
||||||
|
- `rate_limit:{key}` - 限流计数器
|
||||||
|
|
||||||
|
### 核心环境变量
|
||||||
|
|
||||||
|
- `SPRING_PROFILES_ACTIVE` - 活跃环境
|
||||||
|
- `DB_HOST`、`DB_PASSWORD` - 数据库配置
|
||||||
|
- `REDIS_HOST`、`REDIS_PASSWORD` - Redis 配置
|
||||||
|
- `JWT_SECRET` - JWT 密钥
|
||||||
|
- `OSS_ACCESS_KEY_ID`、`OSS_ACCESS_KEY_SECRET` - OSS 配置
|
||||||
202
CLAUDE.md
Normal file
202
CLAUDE.md
Normal file
@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
# CLAUDE.md
|
||||||
|
|
||||||
|
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装所有依赖(前端 + 后端)
|
||||||
|
pnpm install
|
||||||
|
|
||||||
|
# 同时启动前后端开发服务器
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 或分别启动
|
||||||
|
pnpm dev:frontend # 前端 http://localhost:3000
|
||||||
|
pnpm dev:backend # 后端 http://localhost:3001
|
||||||
|
```
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
### 后端
|
||||||
|
- **框架**: NestJS + TypeScript
|
||||||
|
- **数据库**: MySQL 8.0 + Prisma ORM
|
||||||
|
- **认证**: JWT + RBAC (基于角色的访问控制)
|
||||||
|
- **多租户**: 数据隔离架构(每个租户独立 tenantId)
|
||||||
|
|
||||||
|
### 前端
|
||||||
|
- **框架**: Vue 3 + TypeScript + Vite
|
||||||
|
- **UI 组件**: Ant Design Vue
|
||||||
|
- **状态管理**: Pinia
|
||||||
|
- **样式**: Tailwind CSS + SCSS
|
||||||
|
|
||||||
|
## 核心命令
|
||||||
|
|
||||||
|
### 开发
|
||||||
|
```bash
|
||||||
|
# 根目录
|
||||||
|
pnpm dev # 同时启动前后端
|
||||||
|
pnpm dev:frontend # 只启动前端
|
||||||
|
pnpm dev:backend # 只启动后端
|
||||||
|
|
||||||
|
# 前端目录
|
||||||
|
pnpm dev # 启动前端
|
||||||
|
|
||||||
|
# 后端目录
|
||||||
|
pnpm start:dev # 启动后端
|
||||||
|
pnpm prisma:studio # Prisma 数据库可视化
|
||||||
|
```
|
||||||
|
|
||||||
|
### 数据库迁移
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pnpm prisma:generate # 生成 Prisma Client
|
||||||
|
pnpm prisma:migrate # 开发环境迁移
|
||||||
|
pnpm prisma:migrate:deploy # 生产环境部署
|
||||||
|
```
|
||||||
|
|
||||||
|
### 构建
|
||||||
|
```bash
|
||||||
|
pnpm build # 构建前后端
|
||||||
|
pnpm build:frontend # 只构建前端
|
||||||
|
pnpm build:backend # 只构建后端
|
||||||
|
```
|
||||||
|
|
||||||
|
### 测试
|
||||||
|
```bash
|
||||||
|
cd backend
|
||||||
|
pnpm test # 运行单元测试
|
||||||
|
pnpm test:cov # 测试覆盖率
|
||||||
|
```
|
||||||
|
|
||||||
|
## 架构概览
|
||||||
|
|
||||||
|
### 目录结构
|
||||||
|
```
|
||||||
|
library-picturebook-activity/
|
||||||
|
├── backend/ # NestJS 后端
|
||||||
|
│ ├── prisma/ # Prisma schema 和 migrations
|
||||||
|
│ ├── src/
|
||||||
|
│ │ ├── auth/ # 认证模块 (JWT)
|
||||||
|
│ │ ├── users/ # 用户管理
|
||||||
|
│ │ ├── roles/ # 角色权限
|
||||||
|
│ │ ├── menus/ # 菜单管理
|
||||||
|
│ │ ├── tenants/ # 租户管理
|
||||||
|
│ │ ├── contests/ # 竞赛模块
|
||||||
|
│ │ │ ├── contests/ # 竞赛管理
|
||||||
|
│ │ │ ├── works/ # 作品管理
|
||||||
|
│ │ │ ├── teams/ # 团队管理
|
||||||
|
│ │ │ ├── registrations/ # 报名管理
|
||||||
|
│ │ │ └── reviews/ # 评审管理
|
||||||
|
│ │ ├── school/ # 学校模块
|
||||||
|
│ │ │ ├── schools/
|
||||||
|
│ │ │ ├── grades/
|
||||||
|
│ │ │ ├── classes/
|
||||||
|
│ │ │ ├── teachers/
|
||||||
|
│ │ │ └── students/
|
||||||
|
│ │ └── prisma/ # Prisma 服务
|
||||||
|
│ └── package.json
|
||||||
|
│
|
||||||
|
└── frontend/ # Vue 3 前端
|
||||||
|
├── src/
|
||||||
|
│ ├── api/ # API 接口
|
||||||
|
│ ├── views/ # 页面组件
|
||||||
|
│ ├── components/ # 公共组件
|
||||||
|
│ ├── stores/ # Pinia 状态
|
||||||
|
│ ├── router/ # 路由配置
|
||||||
|
│ └── composables/ # 组合式函数
|
||||||
|
└── package.json
|
||||||
|
```
|
||||||
|
|
||||||
|
### 模块结构(后端)
|
||||||
|
每个功能模块包含:
|
||||||
|
- `module.ts` - 模块定义
|
||||||
|
- `controller.ts` - REST 控制器
|
||||||
|
- `service.ts` - 业务逻辑
|
||||||
|
- `dto/` - 数据传输对象
|
||||||
|
|
||||||
|
### 多租户架构
|
||||||
|
- 所有业务数据必须包含 `tenantId` 字段
|
||||||
|
- 查询必须包含租户隔离条件
|
||||||
|
- 超级租户(`isSuper = 1`)可访问所有数据
|
||||||
|
|
||||||
|
## 关键开发规范
|
||||||
|
|
||||||
|
### 后端规范
|
||||||
|
|
||||||
|
1. **租户隔离(强制)**:所有数据库查询必须包含 `tenantId`
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确
|
||||||
|
const data = await prisma.model.findMany({ where: { tenantId } });
|
||||||
|
|
||||||
|
// ❌ 错误 - 缺少 tenantId
|
||||||
|
const data = await prisma.model.findMany({});
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **审计字段**:所有表必须包含
|
||||||
|
- `tenantId` - 租户 ID
|
||||||
|
- `creator`/`modifier` - 创建/修改人
|
||||||
|
- `createTime`/`modifyTime` - 时间戳
|
||||||
|
- `validState` - 有效状态(1-有效,2-失效)
|
||||||
|
|
||||||
|
3. **权限控制**:使用 `@RequirePermission('module:action')` 装饰器
|
||||||
|
|
||||||
|
4. **DTO 验证**:使用 `class-validator` 装饰器
|
||||||
|
|
||||||
|
### 前端规范
|
||||||
|
|
||||||
|
1. **路由包含租户编码**:`/:tenantCode/xxx`
|
||||||
|
|
||||||
|
2. **API 调用**:放在 `api/` 目录,按模块组织
|
||||||
|
|
||||||
|
3. **状态管理**:使用 Pinia,store 命名 `xxxStore`
|
||||||
|
|
||||||
|
4. **组件语法**:使用 `<script setup lang="ts">`
|
||||||
|
|
||||||
|
## 环境变量
|
||||||
|
|
||||||
|
### 后端 (.env)
|
||||||
|
```env
|
||||||
|
DATABASE_URL="mysql://user:password@localhost:3306/db_name"
|
||||||
|
JWT_SECRET="your-secret-key"
|
||||||
|
PORT=3001
|
||||||
|
```
|
||||||
|
|
||||||
|
### 前端 (.env.development)
|
||||||
|
```env
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
```
|
||||||
|
|
||||||
|
## 初始化脚本(后端)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm init:admin # 初始化管理员账户
|
||||||
|
pnpm init:menus # 初始化菜单
|
||||||
|
pnpm init:super-tenant # 初始化超级租户
|
||||||
|
pnpm init:tenant-admin # 初始化租户管理员
|
||||||
|
pnpm init:roles:all # 初始化所有角色权限
|
||||||
|
```
|
||||||
|
|
||||||
|
## Cursor Rules
|
||||||
|
|
||||||
|
项目使用 `.cursor/rules/` 目录定义开发规范:
|
||||||
|
- `project-overview.mdc` - 项目概述
|
||||||
|
- `backend-architecture.mdc` - 后端架构规范
|
||||||
|
- `frontend-architecture.mdc` - 前端架构规范
|
||||||
|
- `database-design.mdc` - 数据库设计规范
|
||||||
|
- `multi-tenant.mdc` - 多租户数据隔离规范
|
||||||
|
- `code-review-checklist.mdc` - 代码审查清单
|
||||||
|
|
||||||
|
## Git 提交规范
|
||||||
|
|
||||||
|
格式:`类型: 描述`
|
||||||
|
|
||||||
|
类型:
|
||||||
|
- `feat` - 新功能
|
||||||
|
- `fix` - 修复 bug
|
||||||
|
- `docs` - 文档更新
|
||||||
|
- `style` - 代码格式调整
|
||||||
|
- `refactor` - 代码重构
|
||||||
|
- `test` - 测试相关
|
||||||
|
- `chore` - 构建/工具相关
|
||||||
439
java 后端开发规范.md
Normal file
439
java 后端开发规范.md
Normal file
@ -0,0 +1,439 @@
|
|||||||
|
# Java 后端开发规范
|
||||||
|
|
||||||
|
## 制定背景
|
||||||
|
|
||||||
|
提供一套可复用的标准化开发规范,适用于 Spring Boot 企业级应用开发。
|
||||||
|
|
||||||
|
## 核心原则
|
||||||
|
|
||||||
|
1. **OpenAPI 规范驱动** - 前后端通过接口规范对齐,零沟通成本
|
||||||
|
2. **类型安全优先** - TypeScript 强制类型校验,早发现早修复
|
||||||
|
3. **约定大于配置** - 统一代码风格和目录结构,降低认知负担
|
||||||
|
4. **自动化优先** - 能自动化的绝不手动(代码生成、部署、测试)
|
||||||
|
5. **三层架构分离** - Controller、Service、Mapper 职责清晰
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 一、技术栈规范
|
||||||
|
|
||||||
|
### 后端技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术选型 | 版本 | 说明 |
|
||||||
|
|:------|:------|:------|:------|
|
||||||
|
| 框架 | Spring Boot | 3.2+ | 基于 Java 17 |
|
||||||
|
| 持久层 | MyBatis-Plus | 3.5+ | 简化 CRUD |
|
||||||
|
| 对象映射 | MapStruct | 1.5+ | VO/Entity 转换 |
|
||||||
|
| 数据库连接池 | Alibaba Druid | 1.2+ | 数据库连接池 + 监控 |
|
||||||
|
| 安全 | Spring Security + JWT | - | 无状态认证 |
|
||||||
|
| API 文档 | Knife4j (SpringDoc) | 4.x | OpenAPI 3.0 |
|
||||||
|
| 数据库 | MySQL | 8.0+ | 关系型数据库 |
|
||||||
|
| 迁移 | Flyway | - | 版本化数据库变更 |
|
||||||
|
| 校验 | Hibernate Validator | - | JSR-303 参数校验 |
|
||||||
|
| 缓存 | Redis + Spring Data Redis | - | 缓存、会话存储 |
|
||||||
|
| 日志 | Logback | - | 结构化日志 |
|
||||||
|
| JSON | FastJSON | 2.x | JSON 序列化 |
|
||||||
|
| 工具类 | Hutool | 5.x | 常用工具集合 |
|
||||||
|
| 文件存储 | 阿里云 OSS | - | 对象存储 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 二、项目结构规范
|
||||||
|
|
||||||
|
### 后端目录结构
|
||||||
|
|
||||||
|
主要模块包括 common(公共模块)、controller(控制器层)、service(服务层)、mapper(数据访问层)、entity(实体类)、dto(数据传输对象)、enums(枚举类)。资源配置包括 application 配置文件、Flyway 迁移脚本、日志配置、MyBatis XML。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 三、三层架构规范
|
||||||
|
|
||||||
|
### 各层职责
|
||||||
|
|
||||||
|
| 层级 | 职责 | 数据接收 | 数据返回 |
|
||||||
|
|:------|:------|:---------|:---------|
|
||||||
|
| **Controller** | 接收请求、参数校验、调用 Service、返回响应 | DTO/Request | VO/Response |
|
||||||
|
| **Service** | 处理业务逻辑、事务控制 | DTO/Entity | Entity |
|
||||||
|
| **Mapper** | 数据库 CRUD 操作 | Entity/条件 | Entity |
|
||||||
|
|
||||||
|
### 核心规范
|
||||||
|
|
||||||
|
1. **Service/Mapper 层必须使用实体类** - Service↔Mapper 之间只用 Entity,禁止 DTO/VO 转换
|
||||||
|
2. **转换只在 Controller 层发生** - Entity ↔ VO 转换统一在 Controller 层处理
|
||||||
|
3. **Service 继承 IService<T>** - 所有 Service 接口继承 MyBatis-Plus 的 IService
|
||||||
|
4. **查询接口默认分页** - 所有返回列表的查询接口,默认进行分页处理
|
||||||
|
|
||||||
|
### 查询方式选择
|
||||||
|
|
||||||
|
| 场景 | 推荐方式 |
|
||||||
|
|:------|:---------|
|
||||||
|
| 单表按 ID 查询 | 通用方法 `getById(id)` |
|
||||||
|
| 单表条件查询 | QueryWrapper `list(wrapper)` / `getOne(wrapper)` |
|
||||||
|
| 单表分页查询 | QueryWrapper + Page `page(new Page<>(p, s), wrapper)` |
|
||||||
|
| 单表统计 | QueryWrapper `count(wrapper)` |
|
||||||
|
| 两表联查 | 自定义 SQL `mapper.selectWithXxx()` |
|
||||||
|
| 三表及以上 | 自定义 SQL |
|
||||||
|
| 聚合统计 | 自定义 SQL |
|
||||||
|
| 动态复杂条件 | 自定义 SQL(XML) |
|
||||||
|
|
||||||
|
### 消除魔法值规范
|
||||||
|
|
||||||
|
**核心原则:禁止在代码中使用魔法值,所有状态、类型、常量必须使用枚举定义**
|
||||||
|
|
||||||
|
| 场景 | 规范 |
|
||||||
|
|:------|:------|
|
||||||
|
| 状态字段 | 使用枚举定义(如 `CommonStatusEnum`) |
|
||||||
|
| 类型字段 | 使用枚举定义(如 `UserTypeEnum`) |
|
||||||
|
| 常量值 | 使用常量类或接口定义 |
|
||||||
|
| 错误码 | 使用枚举定义(如 `ErrorCode`) |
|
||||||
|
|
||||||
|
**枚举设计规范:**
|
||||||
|
- 枚举类统一存放在 `enums` 包下
|
||||||
|
- 枚举包含 `code` 和 `desc` 字段,便于前端展示
|
||||||
|
- 提供 `getCode()`、`getDesc()`、`valueOfCode()` 方法
|
||||||
|
- 数据库存储 `code`,代码中使用枚举
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 四、ORM 实体类规范
|
||||||
|
|
||||||
|
### 1. 表名命名规范
|
||||||
|
|
||||||
|
所有数据库表必须添加业务模块前缀,格式为 `t_{模块}_{表名}`:
|
||||||
|
|
||||||
|
| 模块分类 | 前缀 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 用户模块 | t_user_ | t_user, t_user_role |
|
||||||
|
| 系统模块 | t_sys_ | t_sys_config, t_sys_log |
|
||||||
|
| 业务模块 | t_biz_ | t_biz_order, t_biz_product |
|
||||||
|
| 权限模块 | t_auth_ | t_auth_role, t_auth_menu |
|
||||||
|
|
||||||
|
### 2. 表名与实体类名对应规范
|
||||||
|
|
||||||
|
**表名与实体类名必须保持一致**,仅允许下划线与驼峰的区别:
|
||||||
|
|
||||||
|
| 表名(下划线命名) | 实体类名(驼峰命名) |
|
||||||
|
|:------|:------|
|
||||||
|
| `t_auth_role` | `AuthRole` |
|
||||||
|
| `t_user_role` | `UserRole` |
|
||||||
|
| `t_sys_config` | `SysConfig` |
|
||||||
|
|
||||||
|
### 3. 不使用外键约束规范
|
||||||
|
|
||||||
|
- 数据库表之间**不使用 FOREIGN KEY 外键约束**
|
||||||
|
- 表与表之间的关联关系通过**代码逻辑控制**
|
||||||
|
- 关联查询通过 `JOIN` 或应用层组装实现
|
||||||
|
|
||||||
|
### 4. 多对多关系中间表命名规范
|
||||||
|
|
||||||
|
**核心原则:中间表名必须清晰表达两个关联实体的关系**
|
||||||
|
|
||||||
|
- 标准命名格式:`t_{实体 A}_{实体 B}`
|
||||||
|
- 按字母顺序或业务逻辑顺序排列
|
||||||
|
- 不使用 `relation`、`rel`、`link` 等冗余词
|
||||||
|
- 实体类名直接使用两个实体名的组合(如 `ParentStudent`)
|
||||||
|
|
||||||
|
### 5. 基础实体类
|
||||||
|
|
||||||
|
所有实体类继承 `BaseEntity`,包含公共字段:
|
||||||
|
|
||||||
|
| 字段 | 类型 | 说明 | 自动填充 |
|
||||||
|
|:------|:------|:------|:------|
|
||||||
|
| id | Long | 主键 ID(自增) | 数据库自增 |
|
||||||
|
| createBy | String | 创建人 | 插入时 |
|
||||||
|
| createTime | LocalDateTime | 创建时间 | 插入时 |
|
||||||
|
| updateBy | String | 更新人 | 更新时 |
|
||||||
|
| updateTime | LocalDateTime | 更新时间 | 更新时 |
|
||||||
|
| deleted | Integer | 逻辑删除标识 | - |
|
||||||
|
|
||||||
|
### 6. 审计字段规范
|
||||||
|
|
||||||
|
**所有数据库表必须包含审计字段**,用于追踪数据变更历史:
|
||||||
|
|
||||||
|
| 审计字段 | 类型 | 说明 | 必填 |
|
||||||
|
|:------|:------|:------|:------|
|
||||||
|
| create_by | VARCHAR(50) | 创建人账号 | 是 |
|
||||||
|
| create_time | DATETIME | 创建时间 | 是 |
|
||||||
|
| update_by | VARCHAR(50) | 更新人账号 | 是 |
|
||||||
|
| update_time | DATETIME | 更新时间 | 是 |
|
||||||
|
| deleted | TINYINT | 逻辑删除标识(0-未删除,1-已删除) | 是 |
|
||||||
|
|
||||||
|
**规范要求:**
|
||||||
|
- 审计字段通过 MyBatis-Plus `FieldFill` 自动填充
|
||||||
|
- 禁止在业务代码中手动设置审计字段
|
||||||
|
- 生产环境审计字段不得为空
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 五、日志规范
|
||||||
|
|
||||||
|
### 1. 日志语言规范(重要)
|
||||||
|
|
||||||
|
**所有日志打印内容必须使用中文**
|
||||||
|
|
||||||
|
| 场景 | 推荐格式 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 操作开始 | "开始{操作},{关键参数}" | `开始创建用户,用户名:zhangsan` |
|
||||||
|
| 操作成功 | "{操作}成功,{关键结果}" | `用户创建成功,ID: 123` |
|
||||||
|
| 操作失败 | "{操作}失败,{关键参数}" | `用户删除失败,ID: 123` |
|
||||||
|
| 查询操作 | "{动作}{对象},{关键参数}" | `查询用户,ID: 123` |
|
||||||
|
| 状态检查 | "{对象}不存在/已存在,{关键参数}" | `用户不存在,ID: 123` |
|
||||||
|
| 异常日志 | "{操作}异常,{关键参数}" + e | `创建用户异常,ID: 123` + e |
|
||||||
|
|
||||||
|
### 2. 日志级别使用场景
|
||||||
|
|
||||||
|
| 级别 | 使用场景 |
|
||||||
|
|:------|:------|
|
||||||
|
| ERROR | 系统异常,需要人工介入(数据库连接失败、第三方服务调用失败) |
|
||||||
|
| WARN | 业务异常,可预期的错误(参数校验失败、资源不存在) |
|
||||||
|
| INFO | 重要的业务操作记录(用户登录、订单创建、支付完成) |
|
||||||
|
| DEBUG | 调试信息,开发时使用(方法入参、SQL 执行结果) |
|
||||||
|
|
||||||
|
### 3. TraceId 链路追踪规范
|
||||||
|
|
||||||
|
- 使用 `MDC`(Mapped Diagnostic Context)实现 TraceId 链路追踪
|
||||||
|
- 通过 AOP 切面在请求入口自动生成 TraceId 并放入 MDC
|
||||||
|
- 请求结束时清除 MDC,防止内存泄漏
|
||||||
|
- 日志格式包含 `[%X{traceId}]` 实现全链路追踪
|
||||||
|
|
||||||
|
### 4. 环境差异化日志配置
|
||||||
|
|
||||||
|
| 环境 | 日志策略 |
|
||||||
|
|:------|:------|
|
||||||
|
| **开发/测试环境** | 全量记录(DEBUG 级别),包含所有业务日志、SQL 日志、调试信息 |
|
||||||
|
| **生产环境** | 精简记录(INFO/WARN 级别),仅记录:请求日志、错误堆栈、错误 SQL、关键业务操作 |
|
||||||
|
|
||||||
|
### 5. AOP 日志实现规范
|
||||||
|
|
||||||
|
- 使用 `@Aspect` 定义日志切面,拦截 Controller 层所有方法
|
||||||
|
- 请求日志包含:TraceId、请求方法、请求路径、IP、耗时
|
||||||
|
- 异常日志包含:TraceId、异常类型、异常消息、堆栈信息
|
||||||
|
- 生产环境关闭 DEBUG 日志,避免性能损耗和日志膨胀
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 六、统一响应格式
|
||||||
|
|
||||||
|
### 1. 响应类定义
|
||||||
|
|
||||||
|
Result 统一响应类包含 code(状态码)、message(消息)、data(数据)、timestamp(时间戳)字段。
|
||||||
|
|
||||||
|
### 2. 错误码枚举
|
||||||
|
|
||||||
|
| 错误码 | 说明 |
|
||||||
|
|:------|:------|
|
||||||
|
| 200 | 成功 |
|
||||||
|
| 400 | 请求参数错误 |
|
||||||
|
| 401 | 未登录或 Token 已过期 |
|
||||||
|
| 403 | 没有访问权限 |
|
||||||
|
| 404 | 资源不存在 |
|
||||||
|
| 500 | 系统内部错误 |
|
||||||
|
|
||||||
|
### 3. 全局异常处理器
|
||||||
|
|
||||||
|
- 使用 `@RestControllerAdvice` 定义全局异常处理器
|
||||||
|
- 业务异常返回对应错误码
|
||||||
|
- 系统异常统一返回 500
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 七、多环境配置规范
|
||||||
|
|
||||||
|
### 1. 配置文件目录结构
|
||||||
|
|
||||||
|
配置文件包括 application.yml(主配置)、application-dev.yml(开发环境)、application-test.yml(测试环境)、application-prod.yml(生产环境)。
|
||||||
|
|
||||||
|
### 2. 环境配置对比
|
||||||
|
|
||||||
|
| 配置项 | 开发环境 (dev) | 测试环境 (test) | 生产环境 (prod) |
|
||||||
|
|:------|:------|:------|:------|
|
||||||
|
| 数据库 | 本地 MySQL | 测试服务器 | 生产服务器 |
|
||||||
|
| SQL 日志 | 开启 | 开启 | 关闭 |
|
||||||
|
| Swagger | 开启 | 开启 | 关闭 |
|
||||||
|
| Flyway Clean | 允许 | 禁止 | 禁止 |
|
||||||
|
| JWT 密钥 | 默认值 | 默认值 | 必须环境变量 |
|
||||||
|
| Redis 连接池 | 默认 | 默认 | 优化配置 |
|
||||||
|
|
||||||
|
### 3. 环境切换方式
|
||||||
|
|
||||||
|
- 方式一:环境变量 `export SPRING_PROFILES_ACTIVE=prod`
|
||||||
|
- 方式二:命令行参数 `java -jar project.jar --spring.profiles.active=prod`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 八、工具类使用规范
|
||||||
|
|
||||||
|
### 1. 工具函数存放位置
|
||||||
|
|
||||||
|
| 场景 | 存放位置 |
|
||||||
|
|:------|:------|
|
||||||
|
| 多个地方调用(≥2 处) | 统一工具类(如 `CommonUtil`) |
|
||||||
|
| 仅在一个地方调用 | Service 层内部私有方法 |
|
||||||
|
| 业务无关的通用工具 | 独立工具类(如 `DateUtil`, `FileUtil`) |
|
||||||
|
|
||||||
|
### 2. 工具类设计原则
|
||||||
|
|
||||||
|
- **私有构造** - 防止实例化
|
||||||
|
- **静态方法** - 所有方法都是 `static` 的
|
||||||
|
- **无状态** - 工具类不应持有状态
|
||||||
|
- **线程安全** - 工具方法必须是线程安全的
|
||||||
|
- **充分注释** - 每个方法都要有 JavaDoc 注释
|
||||||
|
|
||||||
|
### 3. 工具类命名规范
|
||||||
|
|
||||||
|
| 类型 | 命名格式 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 通用工具类 | `XxxUtil` | `CommonUtil`, `DateUtil`, `FileUtil` |
|
||||||
|
| 业务工具类 | `XxxHelper` | `UserHelper`, `OrderHelper` |
|
||||||
|
| 转换工具类 | `XxxConverter` | `EntityConverter`, `DtoConverter` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 九、MapStruct 对象映射规范
|
||||||
|
|
||||||
|
### 1. Maven 依赖
|
||||||
|
|
||||||
|
```xml
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>1.5.5.Final</version>
|
||||||
|
</dependency>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 使用规范
|
||||||
|
|
||||||
|
- 定义 Converter 接口,使用 `@Mapper` 注解
|
||||||
|
- 接口方法用于 Entity ↔ VO 之间的转换
|
||||||
|
- 在 Controller 层调用 Converter 进行对象转换
|
||||||
|
- 禁止在 Service 层和 Mapper 层进行 DTO/VO 转换
|
||||||
|
|
||||||
|
### 3. 命名规范
|
||||||
|
|
||||||
|
| 类型 | 命名格式 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 对象映射接口 | `XxxConverter` 或 `XxxMapStruct` | `UserConverter`, `OrderConverter` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、Hutool 工具类使用
|
||||||
|
|
||||||
|
### 常用工具方法
|
||||||
|
|
||||||
|
- 生成 UUID(不带横杠):`IdUtil.fastSimpleUUID()`
|
||||||
|
- 密码加密(BCrypt):`SecureUtil.bcrypt(password)`
|
||||||
|
- 密码校验:`SecureUtil.bcryptVerify(password, encrypted)`
|
||||||
|
- 手机号验证:`Validator.isMatchRegex("^1[3-9]\\d{9}$", phone)`
|
||||||
|
- 手机号脱敏:`DesensitizedUtil.mobilePhone(phone)`
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十、Swagger/Knife4j 配置规范
|
||||||
|
|
||||||
|
### 1. 接口文档访问
|
||||||
|
|
||||||
|
- Knife4j UI: `http://localhost:8080/doc.html`
|
||||||
|
- OpenAPI JSON: `http://localhost:8080/v3/api-docs`
|
||||||
|
|
||||||
|
### 2. Controller 层注解
|
||||||
|
|
||||||
|
- 使用 `@Tag` 注解标注控制器
|
||||||
|
- 使用 `@Operation` 注解标注接口
|
||||||
|
- 使用 `@Schema` 注解标注 DTO 字段
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十一、工具函数使用规范
|
||||||
|
|
||||||
|
**核心原则:工具函数必须集中管理,禁止在 Controller 层直接编写工具方法**
|
||||||
|
|
||||||
|
### 工具函数存放位置
|
||||||
|
|
||||||
|
| 场景 | 存放位置 |
|
||||||
|
|:------|:------|
|
||||||
|
| 多个地方调用(≥2 处) | 统一工具类(如 `CommonUtil`) |
|
||||||
|
| 仅在一个地方调用 | Service 层内部私有方法 |
|
||||||
|
| 业务无关的通用工具 | 独立工具类(如 `DateUtil`, `FileUtil`) |
|
||||||
|
|
||||||
|
### 工具类命名规范
|
||||||
|
|
||||||
|
| 类型 | 命名格式 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 通用工具类 | `XxxUtil` | `CommonUtil`, `DateUtil`, `FileUtil` |
|
||||||
|
| 业务工具类 | `XxxHelper` | `UserHelper`, `OrderHelper` |
|
||||||
|
| 转换工具类 | `XxxConverter` | `EntityConverter`, `DtoConverter` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十二、限流规范
|
||||||
|
|
||||||
|
### 技术选型
|
||||||
|
|
||||||
|
| 方案 | 适用场景 |
|
||||||
|
|:------|:------|
|
||||||
|
| **Redis 滑动窗口** | 推荐:全局限流 |
|
||||||
|
| **自定义注解 + AOP** | 推荐:特殊接口限流 |
|
||||||
|
|
||||||
|
### 常见限流场景建议值
|
||||||
|
|
||||||
|
| 接口类型 | 时间窗口 | 最大请求数 | 说明 |
|
||||||
|
|:------|:------|:------|:------|
|
||||||
|
| 登录接口 | 60 秒 | 10 | 防止暴力破解 |
|
||||||
|
| 验证码/短信 | 60 秒 | 3-5 | 防止短信轰炸 |
|
||||||
|
| 文件上传 | 60 秒 | 30 | 防止资源滥用 |
|
||||||
|
| 普通业务接口 | 60 秒 | 100-1000 | 根据业务调整 |
|
||||||
|
| 导出接口 | 60 秒 | 5-10 | 防止服务器过载 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十三、FastJSON 配置规范
|
||||||
|
|
||||||
|
### JSON 工具类
|
||||||
|
|
||||||
|
- `toJson(Object)` - 对象转 JSON 字符串
|
||||||
|
- `parseObject(String, Class)` - JSON 字符串转对象
|
||||||
|
- `parseArray(String, Class)` - JSON 字符串转列表
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 十四、阿里云 OSS 文件上传规范
|
||||||
|
|
||||||
|
### OSS 配置
|
||||||
|
|
||||||
|
配置项包括 endpoint、access-key-id、access-key-secret、bucket-name、url-prefix,均通过环境变量注入。
|
||||||
|
|
||||||
|
### 文件命名规范
|
||||||
|
|
||||||
|
- 使用日期目录:`yyyy/MM/dd/`
|
||||||
|
- 使用 UUID 文件名,避免重名冲突
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 附录:快速参考
|
||||||
|
|
||||||
|
### 表名命名规范
|
||||||
|
|
||||||
|
- `t_user_*` - 用户模块
|
||||||
|
- `t_sys_*` - 系统模块
|
||||||
|
- `t_biz_*` - 业务模块
|
||||||
|
- `t_auth_*` - 权限模块
|
||||||
|
|
||||||
|
### Redis Key 命名规范
|
||||||
|
|
||||||
|
- `auth:token:{token}` - 用户 Token
|
||||||
|
- `user:info:{userId}` - 用户信息缓存
|
||||||
|
- `dict:{type}` - 数据字典
|
||||||
|
- `lock:{resource}:{id}` - 分布式锁
|
||||||
|
- `rate_limit:{key}` - 限流计数器
|
||||||
|
|
||||||
|
### 核心配置速查
|
||||||
|
|
||||||
|
| 配置项 | 环境变量 |
|
||||||
|
|:------|:------|
|
||||||
|
| 活跃环境 | `SPRING_PROFILES_ACTIVE` |
|
||||||
|
| 数据库地址 | `DB_HOST` |
|
||||||
|
| 数据库密码 | `DB_PASSWORD` |
|
||||||
|
| Redis 地址 | `REDIS_HOST` |
|
||||||
|
| Redis 密码 | `REDIS_PASSWORD` |
|
||||||
|
| JWT 密钥 | `JWT_SECRET` |
|
||||||
|
| OSS AccessKey | `OSS_ACCESS_KEY_ID` |
|
||||||
|
| OSS Secret | `OSS_ACCESS_KEY_SECRET` |
|
||||||
44
java-backend/.gitignore
vendored
Normal file
44
java-backend/.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
# Maven
|
||||||
|
target/
|
||||||
|
*.class
|
||||||
|
*.log
|
||||||
|
.mvn/
|
||||||
|
mvnw*
|
||||||
|
pom.xml.tag
|
||||||
|
pom.xml.releaseBackup
|
||||||
|
pom.xml.versionsBackup
|
||||||
|
pom.xml.next
|
||||||
|
release.properties
|
||||||
|
dependency-reduced-pom.xml
|
||||||
|
buildNumber.properties
|
||||||
|
.mvn/timing.properties
|
||||||
|
|
||||||
|
# IDE
|
||||||
|
.idea/
|
||||||
|
*.iml
|
||||||
|
*.ipr
|
||||||
|
*.iws
|
||||||
|
.project
|
||||||
|
.classpath
|
||||||
|
.settings/
|
||||||
|
.vscode/
|
||||||
|
|
||||||
|
# OS
|
||||||
|
.DS_Store
|
||||||
|
Thumbs.db
|
||||||
|
|
||||||
|
# 配置文件(敏感信息)
|
||||||
|
src/main/resources/application-local.yml
|
||||||
|
src/main/resources/application-prod.yml.local
|
||||||
|
|
||||||
|
# 日志
|
||||||
|
logs/
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# 临时文件
|
||||||
|
tmp/
|
||||||
|
temp/
|
||||||
|
*.tmp
|
||||||
|
*.bak
|
||||||
|
*.swp
|
||||||
|
*~
|
||||||
177
java-backend/README.md
Normal file
177
java-backend/README.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# Creation Java Backend
|
||||||
|
|
||||||
|
Spring Boot 后端基础框架
|
||||||
|
|
||||||
|
## 技术栈
|
||||||
|
|
||||||
|
| 组件 | 技术 | 版本 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| 框架 | Spring Boot | 3.2.4 |
|
||||||
|
| 持久层 | MyBatis-Plus | 3.5.5 |
|
||||||
|
| 数据库 | MySQL 8.0 | 8.0.33 |
|
||||||
|
| 迁移 | Flyway | 10.10.0 |
|
||||||
|
| 认证 | Spring Security + JWT | 0.12.3 |
|
||||||
|
| 缓存 | Redis | - |
|
||||||
|
| 连接池 | Alibaba Druid | 1.2.20 |
|
||||||
|
| 对象映射 | MapStruct | 1.5.5.Final |
|
||||||
|
| API 文档 | Knife4j | 4.4.0 |
|
||||||
|
| 日志 | Logback | - |
|
||||||
|
| JSON | FastJSON2 | 2.0.43 |
|
||||||
|
| 工具类 | Hutool | 5.8.26 |
|
||||||
|
|
||||||
|
## 快速开始
|
||||||
|
|
||||||
|
### 环境要求
|
||||||
|
- Java 17+
|
||||||
|
- Maven 3.8+
|
||||||
|
- MySQL 8.0+
|
||||||
|
- Redis 6.0+
|
||||||
|
|
||||||
|
### 配置数据库
|
||||||
|
|
||||||
|
1. 创建数据库
|
||||||
|
```sql
|
||||||
|
CREATE DATABASE creation_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
|
||||||
|
```
|
||||||
|
|
||||||
|
2. 修改 `src/main/resources/application-dev.yml` 中的数据库连接信息
|
||||||
|
|
||||||
|
### 启动应用
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd java-backend
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
```
|
||||||
|
|
||||||
|
启动成功后访问:
|
||||||
|
- 应用地址:http://localhost:8580
|
||||||
|
- API 文档:http://localhost:8580/swagger-ui.html
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
java-backend/
|
||||||
|
├── src/main/java/com/lesingle/creation/
|
||||||
|
│ ├── CreationApplication.java # 启动类
|
||||||
|
│ ├── common/ # 公共模块
|
||||||
|
│ │ ├── config/ # 配置类
|
||||||
|
│ │ │ ├── MybatisPlusConfig.java
|
||||||
|
│ │ │ ├── SecurityConfig.java
|
||||||
|
│ │ │ └── JwtProperties.java
|
||||||
|
│ │ ├── constant/ # 常量定义
|
||||||
|
│ │ │ └── ErrorCode.java
|
||||||
|
│ │ ├── core/ # 核心类
|
||||||
|
│ │ │ └── Result.java
|
||||||
|
│ │ ├── exception/ # 异常处理
|
||||||
|
│ │ │ ├── BusinessException.java
|
||||||
|
│ │ │ └── GlobalExceptionHandler.java
|
||||||
|
│ │ ├── filter/ # 过滤器
|
||||||
|
│ │ │ └── JwtAuthenticationFilter.java
|
||||||
|
│ │ └── util/ # 工具类
|
||||||
|
│ │ └── JwtTokenUtil.java
|
||||||
|
│ ├── controller/ # 控制器
|
||||||
|
│ ├── service/ # 服务层
|
||||||
|
│ ├── mapper/ # Mapper 接口
|
||||||
|
│ ├── entity/ # 实体类
|
||||||
|
│ ├── dto/ # 数据传输对象
|
||||||
|
│ └── vo/ # 视图对象
|
||||||
|
├── src/main/resources/
|
||||||
|
│ ├── application.yml # 主配置
|
||||||
|
│ ├── application-dev.yml # 开发环境
|
||||||
|
│ ├── application-test.yml # 测试环境
|
||||||
|
│ ├── application-prod.yml # 生产环境
|
||||||
|
│ ├── db/migration/ # Flyway 迁移脚本
|
||||||
|
│ │ └── V1.0__init_schema.sql
|
||||||
|
│ └── logback-spring.xml # 日志配置
|
||||||
|
└── pom.xml # Maven 配置
|
||||||
|
```
|
||||||
|
|
||||||
|
## 默认账户
|
||||||
|
|
||||||
|
Flyway 初始迁移后会自动创建以下账户:
|
||||||
|
|
||||||
|
| 用户名 | 密码 | 角色 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| admin | admin123 | 超级管理员 |
|
||||||
|
|
||||||
|
## 开发规范
|
||||||
|
|
||||||
|
### 三层架构
|
||||||
|
|
||||||
|
| 层级 | 职责 | 数据流 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| **Controller** | 接收请求、参数校验 | DTO ↔ VO |
|
||||||
|
| **Service** | 业务逻辑、事务控制 | 使用 Entity |
|
||||||
|
| **Mapper** | 数据库 CRUD | 使用 Entity |
|
||||||
|
|
||||||
|
### 代码规范
|
||||||
|
|
||||||
|
- Java 17 严格模式
|
||||||
|
- 注释和日志使用中文
|
||||||
|
- 使用 Lombok 简化代码
|
||||||
|
- 使用 MapStruct 进行对象映射
|
||||||
|
|
||||||
|
### 日志规范
|
||||||
|
|
||||||
|
- 所有日志使用中文
|
||||||
|
- 使用 MDC 实现 TraceId 链路追踪
|
||||||
|
- 开发环境:DEBUG 级别
|
||||||
|
- 生产环境:INFO/WARN 级别
|
||||||
|
|
||||||
|
## 常用命令
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 编译
|
||||||
|
mvn clean compile
|
||||||
|
|
||||||
|
# 打包
|
||||||
|
mvn clean package
|
||||||
|
|
||||||
|
# 运行(开发环境)
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=dev
|
||||||
|
|
||||||
|
# 运行(测试环境)
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=test
|
||||||
|
|
||||||
|
# 运行(生产环境)
|
||||||
|
mvn spring-boot:run -Dspring-boot.run.profiles=prod
|
||||||
|
|
||||||
|
# 跳过测试打包
|
||||||
|
mvn clean package -DskipTests
|
||||||
|
|
||||||
|
# 查看依赖
|
||||||
|
mvn dependency:tree
|
||||||
|
```
|
||||||
|
|
||||||
|
## API 接口
|
||||||
|
|
||||||
|
### 认证相关
|
||||||
|
- `POST /api/auth/login` - 用户登录
|
||||||
|
- `POST /api/auth/logout` - 用户登出
|
||||||
|
- `POST /api/auth/register` - 用户注册
|
||||||
|
- `POST /api/auth/refresh` - 刷新 Token
|
||||||
|
|
||||||
|
### 用户相关
|
||||||
|
- `GET /api/users` - 用户列表
|
||||||
|
- `GET /api/users/{id}` - 获取用户详情
|
||||||
|
- `POST /api/users` - 创建用户
|
||||||
|
- `PUT /api/users/{id}` - 更新用户
|
||||||
|
- `DELETE /api/users/{id}` - 删除用户
|
||||||
|
|
||||||
|
## 环境变量(生产环境)
|
||||||
|
|
||||||
|
生产环境部署时需设置以下环境变量:
|
||||||
|
|
||||||
|
| 变量名 | 说明 | 示例 |
|
||||||
|
|:------|:------|:------|
|
||||||
|
| `DATABASE_URL` | 数据库连接 URL | `jdbc:mysql://host:3306/db` |
|
||||||
|
| `DB_USERNAME` | 数据库用户名 | `root` |
|
||||||
|
| `DB_PASSWORD` | 数据库密码 | `password` |
|
||||||
|
| `REDIS_HOST` | Redis 主机 | `localhost` |
|
||||||
|
| `REDIS_PORT` | Redis 端口 | `6379` |
|
||||||
|
| `REDIS_PASSWORD` | Redis 密码 | `password` |
|
||||||
|
| `JWT_SECRET` | JWT 密钥 | `your-secret-key` |
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT
|
||||||
223
java-backend/pom.xml
Normal file
223
java-backend/pom.xml
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
<?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.4</version>
|
||||||
|
<relativePath/>
|
||||||
|
</parent>
|
||||||
|
|
||||||
|
<groupId>com.lesingle.creation</groupId>
|
||||||
|
<artifactId>creation</artifactId>
|
||||||
|
<version>1.0.0-SNAPSHOT</version>
|
||||||
|
<name>creation</name>
|
||||||
|
<description>Lesingle Creation - Spring Boot 后端服务</description>
|
||||||
|
|
||||||
|
<properties>
|
||||||
|
<java.version>17</java.version>
|
||||||
|
<mybatis-plus.version>3.5.5</mybatis-plus.version>
|
||||||
|
<druid.version>1.2.20</druid.version>
|
||||||
|
<mysql.version>8.0.33</mysql.version>
|
||||||
|
<mapstruct.version>1.5.5.Final</mapstruct.version>
|
||||||
|
<lombok.version>1.18.30</lombok.version>
|
||||||
|
<hutool.version>5.8.26</hutool.version>
|
||||||
|
<fastjson2.version>2.0.43</fastjson2.version>
|
||||||
|
<jjwt.version>0.12.3</jjwt.version>
|
||||||
|
<flyway.version>10.10.0</flyway.version>
|
||||||
|
</properties>
|
||||||
|
|
||||||
|
<!-- 添加 Maven 仓库 -->
|
||||||
|
<repositories>
|
||||||
|
<repository>
|
||||||
|
<id>aliyun</id>
|
||||||
|
<name>Aliyun Maven</name>
|
||||||
|
<url>https://maven.aliyun.com/repository/public</url>
|
||||||
|
</repository>
|
||||||
|
</repositories>
|
||||||
|
|
||||||
|
<dependencies>
|
||||||
|
<!-- Spring Boot 核心依赖 -->
|
||||||
|
<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-data-redis</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>org.springframework.boot</groupId>
|
||||||
|
<artifactId>spring-boot-configuration-processor</artifactId>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MyBatis-Plus -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.baomidou</groupId>
|
||||||
|
<artifactId>mybatis-plus-spring-boot3-starter</artifactId>
|
||||||
|
<version>${mybatis-plus.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MySQL 驱动 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.mysql</groupId>
|
||||||
|
<artifactId>mysql-connector-j</artifactId>
|
||||||
|
<version>${mysql.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Druid 连接池 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba</groupId>
|
||||||
|
<artifactId>druid-spring-boot-3-starter</artifactId>
|
||||||
|
<version>${druid.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Flyway 数据库迁移 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.flywaydb</groupId>
|
||||||
|
<artifactId>flyway-core</artifactId>
|
||||||
|
<version>${flyway.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- SpringDoc OpenAPI (API 文档) -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.springdoc</groupId>
|
||||||
|
<artifactId>springdoc-openapi-starter-webmvc-ui</artifactId>
|
||||||
|
<version>2.3.0</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- MapStruct 对象映射 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Lombok -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
<optional>true</optional>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- Hutool 工具类 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>cn.hutool</groupId>
|
||||||
|
<artifactId>hutool-all</artifactId>
|
||||||
|
<version>${hutool.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- FastJSON2 -->
|
||||||
|
<dependency>
|
||||||
|
<groupId>com.alibaba.fastjson2</groupId>
|
||||||
|
<artifactId>fastjson2</artifactId>
|
||||||
|
<version>${fastjson2.version}</version>
|
||||||
|
</dependency>
|
||||||
|
|
||||||
|
<!-- JWT -->
|
||||||
|
<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>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>
|
||||||
|
</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>
|
||||||
|
|
||||||
|
<plugin>
|
||||||
|
<groupId>org.apache.maven.plugins</groupId>
|
||||||
|
<artifactId>maven-compiler-plugin</artifactId>
|
||||||
|
<configuration>
|
||||||
|
<source>${java.version}</source>
|
||||||
|
<target>${java.version}</target>
|
||||||
|
<annotationProcessorPaths>
|
||||||
|
<path>
|
||||||
|
<groupId>org.mapstruct</groupId>
|
||||||
|
<artifactId>mapstruct-processor</artifactId>
|
||||||
|
<version>${mapstruct.version}</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok</artifactId>
|
||||||
|
<version>${lombok.version}</version>
|
||||||
|
</path>
|
||||||
|
<path>
|
||||||
|
<groupId>org.projectlombok</groupId>
|
||||||
|
<artifactId>lombok-mapstruct-binding</artifactId>
|
||||||
|
<version>0.2.0</version>
|
||||||
|
</path>
|
||||||
|
</annotationProcessorPaths>
|
||||||
|
</configuration>
|
||||||
|
</plugin>
|
||||||
|
</plugins>
|
||||||
|
</build>
|
||||||
|
|
||||||
|
</project>
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.lesingle.creation;
|
||||||
|
|
||||||
|
import org.mybatis.spring.annotation.MapperScan;
|
||||||
|
import org.springframework.boot.SpringApplication;
|
||||||
|
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Boot 启动类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@SpringBootApplication
|
||||||
|
@MapperScan("com.lesingle.creation.mapper")
|
||||||
|
public class CreationApplication {
|
||||||
|
|
||||||
|
public static void main(String[] args) {
|
||||||
|
SpringApplication.run(CreationApplication.class, args);
|
||||||
|
System.out.println("============================================");
|
||||||
|
System.out.println(" Creation 应用启动成功!");
|
||||||
|
System.out.println(" 端口:8580");
|
||||||
|
System.out.println(" API 文档:http://localhost:8580/doc.html");
|
||||||
|
System.out.println("============================================");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,38 @@
|
|||||||
|
package com.lesingle.creation.common.config;
|
||||||
|
|
||||||
|
import lombok.Data;
|
||||||
|
import org.springframework.boot.context.properties.ConfigurationProperties;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 配置属性
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Component
|
||||||
|
@ConfigurationProperties(prefix = "jwt")
|
||||||
|
public class JwtProperties {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 密钥
|
||||||
|
*/
|
||||||
|
private String secret;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 过期时间(毫秒)
|
||||||
|
*/
|
||||||
|
private Long expiration;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 前缀
|
||||||
|
*/
|
||||||
|
private String tokenPrefix;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 请求头名称
|
||||||
|
*/
|
||||||
|
private String header;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.lesingle.creation.common.config;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.DbType;
|
||||||
|
import com.baomidou.mybatisplus.core.handlers.MetaObjectHandler;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.MybatisPlusInterceptor;
|
||||||
|
import com.baomidou.mybatisplus.extension.plugins.inner.PaginationInnerInterceptor;
|
||||||
|
import org.apache.ibatis.reflection.MetaObject;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* MyBatis-Plus 配置类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
public class MybatisPlusConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 分页插件配置
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public MybatisPlusInterceptor mybatisPlusInterceptor() {
|
||||||
|
MybatisPlusInterceptor interceptor = new MybatisPlusInterceptor();
|
||||||
|
// 添加分页插件
|
||||||
|
interceptor.addInnerInterceptor(new PaginationInnerInterceptor(DbType.MYSQL));
|
||||||
|
return interceptor;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 自动填充处理器
|
||||||
|
* 用于自动填充创建时间、更新时间等字段
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public MetaObjectHandler metaObjectHandler() {
|
||||||
|
return new MetaObjectHandler() {
|
||||||
|
@Override
|
||||||
|
public void insertFill(MetaObject metaObject) {
|
||||||
|
// 插入时自动填充 createTime 和 updateTime
|
||||||
|
this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now());
|
||||||
|
this.strictInsertFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void updateFill(MetaObject metaObject) {
|
||||||
|
// 更新时自动填充 updateTime
|
||||||
|
this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,109 @@
|
|||||||
|
package com.lesingle.creation.common.config;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.filter.JwtAuthenticationFilter;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.context.annotation.Bean;
|
||||||
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.authentication.AuthenticationManager;
|
||||||
|
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.annotation.web.configurers.AbstractHttpConfigurer;
|
||||||
|
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;
|
||||||
|
import org.springframework.web.cors.CorsConfiguration;
|
||||||
|
import org.springframework.web.cors.CorsConfigurationSource;
|
||||||
|
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
|
||||||
|
|
||||||
|
import java.util.Arrays;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spring Security 配置类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
|
@EnableMethodSecurity
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class SecurityConfig {
|
||||||
|
|
||||||
|
private final JwtAuthenticationFilter jwtAuthenticationFilter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码编码器
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public PasswordEncoder passwordEncoder() {
|
||||||
|
return new BCryptPasswordEncoder();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证管理器
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
|
||||||
|
return config.getAuthenticationManager();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 安全过滤链配置
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
|
||||||
|
http
|
||||||
|
// 关闭 CSRF
|
||||||
|
.csrf(AbstractHttpConfigurer::disable)
|
||||||
|
// 关闭表单登录
|
||||||
|
.formLogin(AbstractHttpConfigurer::disable)
|
||||||
|
// 关闭 HTTP Basic
|
||||||
|
.httpBasic(AbstractHttpConfigurer::disable)
|
||||||
|
// 关闭会话管理(使用 JWT)
|
||||||
|
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
// 配置授权规则
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
// 放行静态资源
|
||||||
|
.requestMatchers("/favicon.ico", "/error").permitAll()
|
||||||
|
// 放行 API 文档
|
||||||
|
.requestMatchers("/doc.html", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**").permitAll()
|
||||||
|
// 放行登录、注册接口
|
||||||
|
.requestMatchers("/api/auth/**").permitAll()
|
||||||
|
// 其他请求需要认证
|
||||||
|
.anyRequest().authenticated()
|
||||||
|
)
|
||||||
|
// 配置跨域
|
||||||
|
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
|
||||||
|
// 添加 JWT 过滤器
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 跨域配置
|
||||||
|
*/
|
||||||
|
@Bean
|
||||||
|
public CorsConfigurationSource corsConfigurationSource() {
|
||||||
|
CorsConfiguration config = new CorsConfiguration();
|
||||||
|
// 允许所有来源
|
||||||
|
config.setAllowedOriginPatterns(List.of("*"));
|
||||||
|
// 允许的请求方法
|
||||||
|
config.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
|
||||||
|
// 允许的请求头
|
||||||
|
config.setAllowedHeaders(List.of("*"));
|
||||||
|
// 允许携带凭证
|
||||||
|
config.setAllowCredentials(true);
|
||||||
|
// 预检请求缓存时间
|
||||||
|
config.setMaxAge(3600L);
|
||||||
|
|
||||||
|
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
|
||||||
|
source.registerCorsConfiguration("/**", config);
|
||||||
|
return source;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,73 @@
|
|||||||
|
package com.lesingle.creation.common.constant;
|
||||||
|
|
||||||
|
import lombok.AllArgsConstructor;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一错误码枚举
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
@AllArgsConstructor
|
||||||
|
public enum ErrorCode {
|
||||||
|
|
||||||
|
// ========== 成功 ==========
|
||||||
|
SUCCESS(200, "操作成功"),
|
||||||
|
|
||||||
|
// ========== 客户端错误(4xx) ==========
|
||||||
|
BAD_REQUEST(400, "请求参数错误"),
|
||||||
|
UNAUTHORIZED(401, "未登录或 Token 已过期"),
|
||||||
|
FORBIDDEN(403, "没有访问权限"),
|
||||||
|
NOT_FOUND(404, "资源不存在"),
|
||||||
|
METHOD_NOT_ALLOWED(405, "请求方法不支持"),
|
||||||
|
CONFLICT(409, "资源冲突"),
|
||||||
|
|
||||||
|
// ========== 服务端错误(5xx) ==========
|
||||||
|
INTERNAL_ERROR(500, "系统内部错误"),
|
||||||
|
SERVICE_UNAVAILABLE(503, "服务不可用"),
|
||||||
|
|
||||||
|
// ========== 业务错误(1xxx) ==========
|
||||||
|
USER_NOT_FOUND(1001, "用户不存在"),
|
||||||
|
USER_ACCOUNT_LOCKED(1002, "用户账号已被锁定"),
|
||||||
|
USER_PASSWORD_ERROR(1003, "用户名或密码错误"),
|
||||||
|
USER_ALREADY_EXISTS(1004, "用户已存在"),
|
||||||
|
|
||||||
|
ROLE_NOT_FOUND(1101, "角色不存在"),
|
||||||
|
ROLE_ALREADY_EXISTS(1102, "角色已存在"),
|
||||||
|
|
||||||
|
PERMISSION_DENIED(1201, "权限不足"),
|
||||||
|
PERMISSION_NOT_FOUND(1202, "权限不存在"),
|
||||||
|
|
||||||
|
TOKEN_INVALID(1301, "Token 无效"),
|
||||||
|
TOKEN_EXPIRED(1302, "Token 已过期"),
|
||||||
|
|
||||||
|
DATA_NOT_FOUND(1401, "数据不存在"),
|
||||||
|
DATA_ALREADY_EXISTS(1402, "数据已存在"),
|
||||||
|
DATA_VERSION_ERROR(1403, "数据版本冲突"),
|
||||||
|
|
||||||
|
PARAMS_ERROR(1501, "参数校验失败"),
|
||||||
|
FILE_UPLOAD_ERROR(1502, "文件上传失败"),
|
||||||
|
FILE_DOWNLOAD_ERROR(1503, "文件下载失败"),
|
||||||
|
FILE_NOT_FOUND(1504, "文件不存在"),
|
||||||
|
|
||||||
|
// ========== 数据库错误(2xxx) ==========
|
||||||
|
DB_ERROR(2001, "数据库操作失败"),
|
||||||
|
DB_CONNECTION_ERROR(2002, "数据库连接失败"),
|
||||||
|
DB_QUERY_ERROR(2003, "数据库查询失败"),
|
||||||
|
DB_UPDATE_ERROR(2004, "数据库更新失败"),
|
||||||
|
DB_DELETE_ERROR(2005, "数据库删除失败"),
|
||||||
|
DB_INSERT_ERROR(2006, "数据库插入失败");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误码
|
||||||
|
*/
|
||||||
|
private final Integer code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误消息
|
||||||
|
*/
|
||||||
|
private final String message;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,112 @@
|
|||||||
|
package com.lesingle.creation.common.core;
|
||||||
|
|
||||||
|
import com.alibaba.fastjson2.JSON;
|
||||||
|
import com.lesingle.creation.common.constant.ErrorCode;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.io.Serializable;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 统一响应结果类
|
||||||
|
*
|
||||||
|
* @param <T> 数据类型
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class Result<T> implements Serializable {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应码
|
||||||
|
*/
|
||||||
|
private Integer code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应消息
|
||||||
|
*/
|
||||||
|
private String message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应数据
|
||||||
|
*/
|
||||||
|
private T data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 时间戳
|
||||||
|
*/
|
||||||
|
private Long timestamp;
|
||||||
|
|
||||||
|
public Result() {
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
public Result(Integer code, String message, T data) {
|
||||||
|
this.code = code;
|
||||||
|
this.message = message;
|
||||||
|
this.data = data;
|
||||||
|
this.timestamp = System.currentTimeMillis();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功响应(无数据)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> success() {
|
||||||
|
return new Result<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功响应(带数据)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> success(T data) {
|
||||||
|
return new Result<>(ErrorCode.SUCCESS.getCode(), ErrorCode.SUCCESS.getMessage(), data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 成功响应(自定义消息)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> success(String message, T data) {
|
||||||
|
return new Result<>(ErrorCode.SUCCESS.getCode(), message, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败响应
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> fail(String message) {
|
||||||
|
return new Result<>(ErrorCode.INTERNAL_ERROR.getCode(), message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败响应(指定错误码)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> fail(ErrorCode errorCode) {
|
||||||
|
return new Result<>(errorCode.getCode(), errorCode.getMessage(), null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败响应(指定错误码和消息)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> fail(ErrorCode errorCode, String message) {
|
||||||
|
return new Result<>(errorCode.getCode(), message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 失败响应(指定错误码和数据)
|
||||||
|
*/
|
||||||
|
public static <T> Result<T> fail(Integer code, String message) {
|
||||||
|
return new Result<>(code, message, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断是否成功
|
||||||
|
*/
|
||||||
|
public boolean isSuccess() {
|
||||||
|
return ErrorCode.SUCCESS.getCode().equals(this.code);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public String toString() {
|
||||||
|
return JSON.toJSONString(this);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,51 @@
|
|||||||
|
package com.lesingle.creation.common.exception;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.constant.ErrorCode;
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 业务异常类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Getter
|
||||||
|
public class BusinessException extends RuntimeException {
|
||||||
|
|
||||||
|
private static final long serialVersionUID = 1L;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误码
|
||||||
|
*/
|
||||||
|
private final Integer code;
|
||||||
|
|
||||||
|
public BusinessException(String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = ErrorCode.INTERNAL_ERROR.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(Integer code, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(ErrorCode errorCode) {
|
||||||
|
super(errorCode.getMessage());
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(ErrorCode errorCode, String message) {
|
||||||
|
super(message);
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(String message, Throwable cause) {
|
||||||
|
super(message, cause);
|
||||||
|
this.code = ErrorCode.INTERNAL_ERROR.getCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
public BusinessException(ErrorCode errorCode, Throwable cause) {
|
||||||
|
super(errorCode.getMessage(), cause);
|
||||||
|
this.code = errorCode.getCode();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,199 @@
|
|||||||
|
package com.lesingle.creation.common.exception;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.constant.ErrorCode;
|
||||||
|
import com.lesingle.creation.common.core.Result;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.http.HttpStatus;
|
||||||
|
import org.springframework.security.access.AccessDeniedException;
|
||||||
|
import org.springframework.security.authentication.AuthenticationCredentialsNotFoundException;
|
||||||
|
import org.springframework.security.authentication.BadCredentialsException;
|
||||||
|
import org.springframework.security.core.AuthenticationException;
|
||||||
|
import org.springframework.validation.BindException;
|
||||||
|
import org.springframework.validation.FieldError;
|
||||||
|
import org.springframework.web.HttpRequestMethodNotSupportedException;
|
||||||
|
import org.springframework.web.bind.MethodArgumentNotValidException;
|
||||||
|
import org.springframework.web.bind.MissingPathVariableException;
|
||||||
|
import org.springframework.web.bind.MissingServletRequestParameterException;
|
||||||
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
|
import org.springframework.web.bind.annotation.ResponseStatus;
|
||||||
|
import org.springframework.web.bind.annotation.RestControllerAdvice;
|
||||||
|
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;
|
||||||
|
import org.springframework.web.multipart.MaxUploadSizeExceededException;
|
||||||
|
import org.springframework.web.servlet.NoHandlerFoundException;
|
||||||
|
|
||||||
|
import java.sql.SQLIntegrityConstraintViolationException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 全局异常处理器
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@RestControllerAdvice
|
||||||
|
public class GlobalExceptionHandler {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理业务异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BusinessException.class)
|
||||||
|
@ResponseStatus(HttpStatus.OK)
|
||||||
|
public Result<?> handleBusinessException(BusinessException e) {
|
||||||
|
log.warn("业务异常:{}", e.getMessage());
|
||||||
|
return Result.fail(e.getCode(), e.getMessage());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数校验异常(@Validated)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentNotValidException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleMethodArgumentNotValidException(MethodArgumentNotValidException e) {
|
||||||
|
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||||
|
String message = fieldError != null ? fieldError.getDefaultMessage() : "参数校验失败";
|
||||||
|
log.warn("参数校验异常:{}", message);
|
||||||
|
return Result.fail(ErrorCode.PARAMS_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数绑定异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BindException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleBindException(BindException e) {
|
||||||
|
FieldError fieldError = e.getBindingResult().getFieldError();
|
||||||
|
String message = fieldError != null ? fieldError.getDefaultMessage() : "参数绑定失败";
|
||||||
|
log.warn("参数绑定异常:{}", message);
|
||||||
|
return Result.fail(ErrorCode.PARAMS_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理参数类型不匹配异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleMethodArgumentTypeMismatchException(MethodArgumentTypeMismatchException e) {
|
||||||
|
String message = String.format("参数 [%s] 类型错误,应为:%s", e.getName(), e.getRequiredType().getSimpleName());
|
||||||
|
log.warn("参数类型错误:{}", message);
|
||||||
|
return Result.fail(ErrorCode.PARAMS_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理缺少请求参数异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MissingServletRequestParameterException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleMissingServletRequestParameterException(MissingServletRequestParameterException e) {
|
||||||
|
String message = String.format("缺少必填参数:%s", e.getParameterName());
|
||||||
|
log.warn("缺少请求参数:{}", message);
|
||||||
|
return Result.fail(ErrorCode.PARAMS_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理缺少路径参数异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MissingPathVariableException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleMissingPathVariableException(MissingPathVariableException e) {
|
||||||
|
String message = String.format("缺少路径参数:%s", e.getVariableName());
|
||||||
|
log.warn("缺少路径参数:{}", message);
|
||||||
|
return Result.fail(ErrorCode.PARAMS_ERROR, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthenticationException.class)
|
||||||
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
|
public Result<?> handleAuthenticationException(AuthenticationException e) {
|
||||||
|
String message = "认证失败:" + e.getMessage();
|
||||||
|
log.warn("认证异常:{}", message);
|
||||||
|
return Result.fail(ErrorCode.UNAUTHORIZED, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理凭证错误(如密码错误)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(BadCredentialsException.class)
|
||||||
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
|
public Result<?> handleBadCredentialsException(BadCredentialsException e) {
|
||||||
|
log.warn("凭证错误:{}", e.getMessage());
|
||||||
|
return Result.fail(ErrorCode.UNAUTHORIZED, "用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理认证凭据未找到异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AuthenticationCredentialsNotFoundException.class)
|
||||||
|
@ResponseStatus(HttpStatus.UNAUTHORIZED)
|
||||||
|
public Result<?> handleAuthenticationCredentialsNotFoundException(AuthenticationCredentialsNotFoundException e) {
|
||||||
|
log.warn("未提供认证凭据:{}", e.getMessage());
|
||||||
|
return Result.fail(ErrorCode.UNAUTHORIZED, "未登录或 Token 已过期");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理授权异常(权限不足)
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(AccessDeniedException.class)
|
||||||
|
@ResponseStatus(HttpStatus.FORBIDDEN)
|
||||||
|
public Result<?> handleAccessDeniedException(AccessDeniedException e) {
|
||||||
|
log.warn("权限不足:{}", e.getMessage());
|
||||||
|
return Result.fail(ErrorCode.FORBIDDEN, "没有访问权限");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理资源不存在异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(NoHandlerFoundException.class)
|
||||||
|
@ResponseStatus(HttpStatus.NOT_FOUND)
|
||||||
|
public Result<?> handleNoHandlerFoundException(NoHandlerFoundException e) {
|
||||||
|
String message = String.format("资源不存在:%s %s", e.getHttpMethod(), e.getRequestURL());
|
||||||
|
log.warn("资源不存在:{}", message);
|
||||||
|
return Result.fail(ErrorCode.NOT_FOUND, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理请求方法不支持异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(HttpRequestMethodNotSupportedException.class)
|
||||||
|
@ResponseStatus(HttpStatus.METHOD_NOT_ALLOWED)
|
||||||
|
public Result<?> handleHttpRequestMethodNotSupportedException(HttpRequestMethodNotSupportedException e) {
|
||||||
|
String message = String.format("不支持的请求方法:%s", e.getMethod());
|
||||||
|
log.warn("请求方法不支持:{}", message);
|
||||||
|
return Result.fail(ErrorCode.METHOD_NOT_ALLOWED, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理文件上传大小超限异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(MaxUploadSizeExceededException.class)
|
||||||
|
@ResponseStatus(HttpStatus.BAD_REQUEST)
|
||||||
|
public Result<?> handleMaxUploadSizeExceededException(MaxUploadSizeExceededException e) {
|
||||||
|
log.warn("文件大小超限:{}", e.getMessage());
|
||||||
|
return Result.fail(ErrorCode.FILE_UPLOAD_ERROR, "上传文件大小超过限制");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理数据库唯一约束冲突异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(SQLIntegrityConstraintViolationException.class)
|
||||||
|
@ResponseStatus(HttpStatus.CONFLICT)
|
||||||
|
public Result<?> handleSQLIntegrityConstraintViolationException(SQLIntegrityConstraintViolationException e) {
|
||||||
|
String message = e.getMessage();
|
||||||
|
if (message != null && message.contains("Duplicate entry")) {
|
||||||
|
message = "数据已存在,请勿重复添加";
|
||||||
|
}
|
||||||
|
log.warn("数据库约束冲突:{}", message);
|
||||||
|
return Result.fail(ErrorCode.DATA_ALREADY_EXISTS, message);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理其他未知异常
|
||||||
|
*/
|
||||||
|
@ExceptionHandler(Exception.class)
|
||||||
|
@ResponseStatus(HttpStatus.INTERNAL_SERVER_ERROR)
|
||||||
|
public Result<?> handleException(Exception e) {
|
||||||
|
log.error("系统内部错误:", e);
|
||||||
|
return Result.fail(ErrorCode.INTERNAL_ERROR, "系统内部错误:" + e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,93 @@
|
|||||||
|
package com.lesingle.creation.common.filter;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.config.JwtProperties;
|
||||||
|
import com.lesingle.creation.common.util.JwtTokenUtil;
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.util.StringUtils;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.IOException;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT 认证过滤器
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtTokenUtil jwtTokenUtil;
|
||||||
|
private final UserDetailsService userDetailsService;
|
||||||
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request,
|
||||||
|
HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
// 从请求头中获取 Token
|
||||||
|
String token = getTokenFromRequest(request);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(token)) {
|
||||||
|
try {
|
||||||
|
// 验证 Token
|
||||||
|
String username = jwtTokenUtil.getUsernameFromToken(token);
|
||||||
|
|
||||||
|
if (StringUtils.hasText(username) && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
// 从数据库加载用户信息
|
||||||
|
UserDetails userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
// 验证 Token 是否有效
|
||||||
|
if (jwtTokenUtil.isTokenValid(token, userDetails)) {
|
||||||
|
// 创建认证对象
|
||||||
|
UsernamePasswordAuthenticationToken authentication =
|
||||||
|
new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails,
|
||||||
|
null,
|
||||||
|
userDetails.getAuthorities()
|
||||||
|
);
|
||||||
|
authentication.setDetails(
|
||||||
|
new WebAuthenticationDetailsSource().buildDetails(request)
|
||||||
|
);
|
||||||
|
// 设置到 SecurityContext
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authentication);
|
||||||
|
log.debug("用户认证成功:{}", username);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (Exception e) {
|
||||||
|
log.warn("Token 验证失败:{}", e.getMessage());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从请求头中获取 Token
|
||||||
|
*/
|
||||||
|
private String getTokenFromRequest(HttpServletRequest request) {
|
||||||
|
String bearerToken = request.getHeader(jwtProperties.getHeader());
|
||||||
|
if (StringUtils.hasText(bearerToken) && bearerToken.startsWith(jwtProperties.getTokenPrefix())) {
|
||||||
|
return bearerToken.substring(jwtProperties.getTokenPrefix().length()).trim();
|
||||||
|
}
|
||||||
|
// 也支持 token 作为查询参数
|
||||||
|
String token = request.getParameter("token");
|
||||||
|
if (StringUtils.hasText(token)) {
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.lesingle.creation.common.util;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.config.JwtProperties;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Token 工具类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtTokenUtil {
|
||||||
|
|
||||||
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取密钥
|
||||||
|
*/
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Token 中获取用户名
|
||||||
|
*/
|
||||||
|
public String getUsernameFromToken(String token) {
|
||||||
|
return getClaimFromToken(token, Claims::getSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Token 中获取指定声明
|
||||||
|
*/
|
||||||
|
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
|
||||||
|
final Claims claims = getAllClaimsFromToken(token);
|
||||||
|
return claimsResolver.apply(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有声明
|
||||||
|
*/
|
||||||
|
private Claims getAllClaimsFromToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Token
|
||||||
|
*/
|
||||||
|
public String generateToken(String username) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
return createToken(claims, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成带额外信息的 Token
|
||||||
|
*/
|
||||||
|
public String generateToken(String username, Map<String, Object> claims) {
|
||||||
|
return createToken(claims, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Token
|
||||||
|
*/
|
||||||
|
private String createToken(Map<String, Object> claims, String subject) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expirationDate = new Date(now.getTime() + jwtProperties.getExpiration());
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(claims)
|
||||||
|
.subject(subject)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(expirationDate)
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 是否有效
|
||||||
|
*/
|
||||||
|
public boolean isTokenValid(String token, UserDetails userDetails) {
|
||||||
|
final String username = getUsernameFromToken(token);
|
||||||
|
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Token 是否过期
|
||||||
|
*/
|
||||||
|
private boolean isTokenExpired(String token) {
|
||||||
|
return extractExpiration(token).before(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 过期时间
|
||||||
|
*/
|
||||||
|
public Date extractExpiration(String token) {
|
||||||
|
return getClaimFromToken(token, Claims::getExpiration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,101 @@
|
|||||||
|
package com.lesingle.creation.controller;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.core.Result;
|
||||||
|
import com.lesingle.creation.dto.LoginRequest;
|
||||||
|
import com.lesingle.creation.dto.RegisterRequest;
|
||||||
|
import com.lesingle.creation.entity.User;
|
||||||
|
import com.lesingle.creation.service.AuthService;
|
||||||
|
import com.lesingle.creation.service.RoleService;
|
||||||
|
import com.lesingle.creation.service.UserDetailsServiceImpl;
|
||||||
|
import com.lesingle.creation.vo.LoginResponse;
|
||||||
|
import com.lesingle.creation.vo.UserVO;
|
||||||
|
import io.swagger.v3.oas.annotations.Operation;
|
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证控制器
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Tag(name = "认证管理")
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/auth")
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthController {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final UserDetailsServiceImpl userDetailsService;
|
||||||
|
private final RoleService roleService;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
@Operation(summary = "用户登录")
|
||||||
|
@PostMapping("/login")
|
||||||
|
public Result<LoginResponse> login(@Valid @RequestBody LoginRequest request) {
|
||||||
|
String token = authService.login(request);
|
||||||
|
|
||||||
|
// 查询用户信息
|
||||||
|
User user = ((User) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
|
||||||
|
|
||||||
|
// 获取用户角色
|
||||||
|
List<String> roles = roleService.getRoleCodesByUserId(user.getId());
|
||||||
|
|
||||||
|
LoginResponse response = LoginResponse.builder()
|
||||||
|
.token(token)
|
||||||
|
.tokenType("Bearer")
|
||||||
|
.expiresIn(86400000L)
|
||||||
|
.userId(user.getId())
|
||||||
|
.username(user.getUsername())
|
||||||
|
.nickname(user.getNickname())
|
||||||
|
.avatar(user.getAvatar())
|
||||||
|
.roles(roles)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return Result.success(response);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*/
|
||||||
|
@Operation(summary = "用户注册")
|
||||||
|
@PostMapping("/register")
|
||||||
|
public Result<Long> register(@Valid @RequestBody RegisterRequest request) {
|
||||||
|
Long userId = authService.register(request);
|
||||||
|
return Result.success(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取当前用户信息
|
||||||
|
*/
|
||||||
|
@Operation(summary = "获取当前用户信息")
|
||||||
|
@GetMapping("/me")
|
||||||
|
public Result<UserVO> getCurrentUser() {
|
||||||
|
String username = SecurityContextHolder.getContext().getAuthentication().getName();
|
||||||
|
User user = ((User) SecurityContextHolder.getContext().getAuthentication().getPrincipal());
|
||||||
|
|
||||||
|
List<String> roles = roleService.getRoleCodesByUserId(user.getId());
|
||||||
|
|
||||||
|
UserVO userVO = UserVO.builder()
|
||||||
|
.id(user.getId())
|
||||||
|
.username(user.getUsername())
|
||||||
|
.nickname(user.getNickname())
|
||||||
|
.email(user.getEmail())
|
||||||
|
.phone(user.getPhone())
|
||||||
|
.avatar(user.getAvatar())
|
||||||
|
.status(user.getStatus())
|
||||||
|
.roles(roles)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
return Result.success(userVO);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,27 @@
|
|||||||
|
package com.lesingle.creation.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录请求 DTO
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class LoginRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,47 @@
|
|||||||
|
package com.lesingle.creation.dto;
|
||||||
|
|
||||||
|
import jakarta.validation.constraints.Email;
|
||||||
|
import jakarta.validation.constraints.NotBlank;
|
||||||
|
import jakarta.validation.constraints.Size;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册请求 DTO
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
public class RegisterRequest {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "用户名不能为空")
|
||||||
|
@Size(min = 3, max = 50, message = "用户名长度必须在 3-50 个字符之间")
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码
|
||||||
|
*/
|
||||||
|
@NotBlank(message = "密码不能为空")
|
||||||
|
@Size(min = 6, max = 100, message = "密码长度必须在 6-100 个字符之间")
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱
|
||||||
|
*/
|
||||||
|
@Email(message = "邮箱格式不正确")
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号
|
||||||
|
*/
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,92 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_permission")
|
||||||
|
public class Permission {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限名称
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限编码
|
||||||
|
*/
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限类型:1-菜单,2-按钮,3-接口
|
||||||
|
*/
|
||||||
|
private Integer type;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 父级 ID
|
||||||
|
*/
|
||||||
|
private Long parentId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 路由路径
|
||||||
|
*/
|
||||||
|
private String path;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 图标
|
||||||
|
*/
|
||||||
|
private String icon;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 排序
|
||||||
|
*/
|
||||||
|
private Integer sort;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用,1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除:0-未删除,1-已删除
|
||||||
|
*/
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人
|
||||||
|
*/
|
||||||
|
private Long createBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人
|
||||||
|
*/
|
||||||
|
private Long updateBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_role")
|
||||||
|
public class Role {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色名称
|
||||||
|
*/
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色编码
|
||||||
|
*/
|
||||||
|
private String code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色描述
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用,1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除:0-未删除,1-已删除
|
||||||
|
*/
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人
|
||||||
|
*/
|
||||||
|
private Long createBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人
|
||||||
|
*/
|
||||||
|
private Long updateBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色权限关联实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_role_permission")
|
||||||
|
public class RolePermission {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色 ID
|
||||||
|
*/
|
||||||
|
private Long roleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限 ID
|
||||||
|
*/
|
||||||
|
private Long permissionId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,72 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_sys_config")
|
||||||
|
public class SysConfig {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置键
|
||||||
|
*/
|
||||||
|
private String configKey;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置值
|
||||||
|
*/
|
||||||
|
private String configValue;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 配置描述
|
||||||
|
*/
|
||||||
|
private String description;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用,1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除:0-未删除,1-已删除
|
||||||
|
*/
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人
|
||||||
|
*/
|
||||||
|
private Long createBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人
|
||||||
|
*/
|
||||||
|
private Long updateBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,94 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统日志实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_sys_log")
|
||||||
|
public class SysLog {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 链路追踪 ID
|
||||||
|
*/
|
||||||
|
private String traceId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模块
|
||||||
|
*/
|
||||||
|
private String module;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 操作
|
||||||
|
*/
|
||||||
|
private String operation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求方法
|
||||||
|
*/
|
||||||
|
private String method;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求 URL
|
||||||
|
*/
|
||||||
|
private String url;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 请求参数
|
||||||
|
*/
|
||||||
|
private String params;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 响应结果
|
||||||
|
*/
|
||||||
|
private String result;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* IP 地址
|
||||||
|
*/
|
||||||
|
private String ip;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 浏览器
|
||||||
|
*/
|
||||||
|
private String userAgent;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 执行时间(ms)
|
||||||
|
*/
|
||||||
|
private Integer executeTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-失败,1-成功
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 错误信息
|
||||||
|
*/
|
||||||
|
private String errorMsg;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人
|
||||||
|
*/
|
||||||
|
private Long createBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,87 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_user")
|
||||||
|
public class User {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 密码(加密)
|
||||||
|
*/
|
||||||
|
private String password;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱
|
||||||
|
*/
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号
|
||||||
|
*/
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像 URL
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态:0-禁用,1-启用
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 逻辑删除:0-未删除,1-已删除
|
||||||
|
*/
|
||||||
|
@TableLogic
|
||||||
|
private Integer deleted;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建人
|
||||||
|
*/
|
||||||
|
private Long createBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT)
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新人
|
||||||
|
*/
|
||||||
|
private Long updateBy;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 更新时间
|
||||||
|
*/
|
||||||
|
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||||
|
private LocalDateTime updateTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,39 @@
|
|||||||
|
package com.lesingle.creation.entity;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.annotation.*;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户角色关联实体类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@TableName("t_user_role")
|
||||||
|
public class UserRole {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 主键 ID
|
||||||
|
*/
|
||||||
|
@TableId(type = IdType.AUTO)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色 ID
|
||||||
|
*/
|
||||||
|
private Long roleId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.Permission;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface PermissionMapper extends BaseMapper<Permission> {
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.Role;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface RoleMapper extends BaseMapper<Role> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据角色编码查询角色
|
||||||
|
*
|
||||||
|
* @param code 角色编码
|
||||||
|
* @return 角色信息
|
||||||
|
*/
|
||||||
|
Role selectByCode(@Param("code") String code);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.RolePermission;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色权限 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface RolePermissionMapper extends BaseMapper<RolePermission> {
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.SysConfig;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统配置 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysConfigMapper extends BaseMapper<SysConfig> {
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.SysLog;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 系统日志 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface SysLogMapper extends BaseMapper<SysLog> {
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,25 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.User;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
import org.apache.ibatis.annotations.Param;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface UserMapper extends BaseMapper<User> {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户名查询用户
|
||||||
|
*
|
||||||
|
* @param username 用户名
|
||||||
|
* @return 用户信息
|
||||||
|
*/
|
||||||
|
User selectByUsername(@Param("username") String username);
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,16 @@
|
|||||||
|
package com.lesingle.creation.mapper;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
|
||||||
|
import com.lesingle.creation.entity.UserRole;
|
||||||
|
import org.apache.ibatis.annotations.Mapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户角色 Mapper 接口
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Mapper
|
||||||
|
public interface UserRoleMapper extends BaseMapper<UserRole> {
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,103 @@
|
|||||||
|
package com.lesingle.creation.service;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.exception.BusinessException;
|
||||||
|
import com.lesingle.creation.dto.LoginRequest;
|
||||||
|
import com.lesingle.creation.dto.RegisterRequest;
|
||||||
|
import com.lesingle.creation.entity.Role;
|
||||||
|
import com.lesingle.creation.entity.User;
|
||||||
|
import com.lesingle.creation.entity.UserRole;
|
||||||
|
import com.lesingle.creation.mapper.RoleMapper;
|
||||||
|
import com.lesingle.creation.mapper.UserMapper;
|
||||||
|
import com.lesingle.creation.mapper.UserRoleMapper;
|
||||||
|
import com.lesingle.creation.util.JwtTokenUtil;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 认证服务实现类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class AuthService {
|
||||||
|
|
||||||
|
private final UserMapper userMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
private final UserRoleMapper userRoleMapper;
|
||||||
|
private final PasswordEncoder passwordEncoder;
|
||||||
|
private final JwtTokenUtil jwtTokenUtil;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户登录
|
||||||
|
*/
|
||||||
|
public String login(LoginRequest request) {
|
||||||
|
// 查询用户
|
||||||
|
User user = userMapper.selectByUsername(request.getUsername());
|
||||||
|
|
||||||
|
if (user == null) {
|
||||||
|
log.warn("用户不存在:{}", request.getUsername());
|
||||||
|
throw new BusinessException("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密码
|
||||||
|
if (!passwordEncoder.matches(request.getPassword(), user.getPassword())) {
|
||||||
|
log.warn("密码错误:{}", request.getUsername());
|
||||||
|
throw new BusinessException("用户名或密码错误");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查用户状态
|
||||||
|
if (user.getStatus() == 0) {
|
||||||
|
log.warn("用户账号已被禁用:{}", request.getUsername());
|
||||||
|
throw new BusinessException("用户账号已被禁用");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成 Token
|
||||||
|
String token = jwtTokenUtil.generateToken(user.getUsername());
|
||||||
|
log.info("用户登录成功:{}", request.getUsername());
|
||||||
|
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户注册
|
||||||
|
*/
|
||||||
|
@Transactional(rollbackFor = Exception.class)
|
||||||
|
public Long register(RegisterRequest request) {
|
||||||
|
// 检查用户名是否已存在
|
||||||
|
User existingUser = userMapper.selectByUsername(request.getUsername());
|
||||||
|
if (existingUser != null) {
|
||||||
|
throw new BusinessException("用户名已存在");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
User user = new User();
|
||||||
|
user.setUsername(request.getUsername());
|
||||||
|
user.setPassword(passwordEncoder.encode(request.getPassword()));
|
||||||
|
user.setNickname(request.getNickname());
|
||||||
|
user.setEmail(request.getEmail());
|
||||||
|
user.setPhone(request.getPhone());
|
||||||
|
user.setStatus(1);
|
||||||
|
|
||||||
|
userMapper.insert(user);
|
||||||
|
log.info("用户注册成功:{}", request.getUsername());
|
||||||
|
|
||||||
|
// 分配普通用户角色
|
||||||
|
Role userRole = roleMapper.selectByCode("USER");
|
||||||
|
if (userRole != null) {
|
||||||
|
UserRole userRoleRel = new UserRole();
|
||||||
|
userRoleRel.setUserId(user.getId());
|
||||||
|
userRoleRel.setRoleId(userRole.getId());
|
||||||
|
userRoleMapper.insert(userRoleRel);
|
||||||
|
}
|
||||||
|
|
||||||
|
return user.getId();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,53 @@
|
|||||||
|
package com.lesingle.creation.service;
|
||||||
|
|
||||||
|
import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper;
|
||||||
|
import com.lesingle.creation.entity.Role;
|
||||||
|
import com.lesingle.creation.entity.UserRole;
|
||||||
|
import com.lesingle.creation.mapper.RoleMapper;
|
||||||
|
import com.lesingle.creation.mapper.UserRoleMapper;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.stream.Collectors;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色服务实现类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class RoleService {
|
||||||
|
|
||||||
|
private final UserRoleMapper userRoleMapper;
|
||||||
|
private final RoleMapper roleMapper;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 根据用户 ID 获取角色编码列表
|
||||||
|
*/
|
||||||
|
public List<String> getRoleCodesByUserId(Long userId) {
|
||||||
|
// 查询用户角色关联
|
||||||
|
List<UserRole> userRoles = userRoleMapper.selectList(new LambdaQueryWrapper<UserRole>()
|
||||||
|
.eq(UserRole::getUserId, userId));
|
||||||
|
|
||||||
|
if (userRoles == null || userRoles.isEmpty()) {
|
||||||
|
return List.of();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取角色 ID 列表
|
||||||
|
List<Long> roleIds = userRoles.stream()
|
||||||
|
.map(UserRole::getRoleId)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
|
||||||
|
// 查询角色信息
|
||||||
|
List<Role> roles = roleMapper.selectList(new LambdaQueryWrapper<Role>()
|
||||||
|
.in(Role::getId, roleIds)
|
||||||
|
.eq(Role::getStatus, 1));
|
||||||
|
|
||||||
|
return roles.stream()
|
||||||
|
.map(Role::getCode)
|
||||||
|
.collect(Collectors.toList());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,54 @@
|
|||||||
|
package com.lesingle.creation.service;
|
||||||
|
|
||||||
|
import com.lesingle.creation.entity.User;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import lombok.extern.slf4j.Slf4j;
|
||||||
|
import org.springframework.security.core.authority.SimpleGrantedAuthority;
|
||||||
|
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;
|
||||||
|
|
||||||
|
import java.util.ArrayList;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户详情服务实现类
|
||||||
|
* <p>
|
||||||
|
* 实现 Spring Security 的 UserDetailsService 接口,用于加载用户信息
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Slf4j
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||||
|
|
||||||
|
private final AuthService authService;
|
||||||
|
private final RoleService roleService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
|
||||||
|
// 调用 Service 层查询用户
|
||||||
|
User user = authService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
// 查询用户角色
|
||||||
|
List<String> roleCodes = roleService.getRoleCodesByUserId(user.getId());
|
||||||
|
List<SimpleGrantedAuthority> authorities = new ArrayList<>();
|
||||||
|
for (String roleCode : roleCodes) {
|
||||||
|
authorities.add(new SimpleGrantedAuthority("ROLE_" + roleCode));
|
||||||
|
}
|
||||||
|
|
||||||
|
log.debug("加载用户成功:{}, 角色:{}", username, roleCodes);
|
||||||
|
|
||||||
|
// 使用自定义的 UserPrincipal 存储用户 ID
|
||||||
|
return new UserPrincipal(
|
||||||
|
String.valueOf(user.getId()), // username 字段存储用户 ID
|
||||||
|
user.getUsername(), // 额外存储用户名
|
||||||
|
user.getPassword(),
|
||||||
|
authorities,
|
||||||
|
user.getTenantId() // 租户 ID
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,116 @@
|
|||||||
|
package com.lesingle.creation.util;
|
||||||
|
|
||||||
|
import com.lesingle.creation.common.config.JwtProperties;
|
||||||
|
import io.jsonwebtoken.Claims;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import io.jsonwebtoken.security.Keys;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import java.util.HashMap;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.function.Function;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* JWT Token 工具类
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtTokenUtil {
|
||||||
|
|
||||||
|
private final JwtProperties jwtProperties;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取密钥
|
||||||
|
*/
|
||||||
|
private SecretKey getSigningKey() {
|
||||||
|
byte[] keyBytes = jwtProperties.getSecret().getBytes(StandardCharsets.UTF_8);
|
||||||
|
return Keys.hmacShaKeyFor(keyBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Token 中获取用户名
|
||||||
|
*/
|
||||||
|
public String getUsernameFromToken(String token) {
|
||||||
|
return getClaimFromToken(token, Claims::getSubject);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从 Token 中获取指定声明
|
||||||
|
*/
|
||||||
|
public <T> T getClaimFromToken(String token, Function<Claims, T> claimsResolver) {
|
||||||
|
final Claims claims = getAllClaimsFromToken(token);
|
||||||
|
return claimsResolver.apply(claims);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取所有声明
|
||||||
|
*/
|
||||||
|
private Claims getAllClaimsFromToken(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(getSigningKey())
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成 Token
|
||||||
|
*/
|
||||||
|
public String generateToken(String username) {
|
||||||
|
Map<String, Object> claims = new HashMap<>();
|
||||||
|
return createToken(claims, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 生成带额外信息的 Token
|
||||||
|
*/
|
||||||
|
public String generateToken(String username, Map<String, Object> claims) {
|
||||||
|
return createToken(claims, username);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建 Token
|
||||||
|
*/
|
||||||
|
private String createToken(Map<String, Object> claims, String subject) {
|
||||||
|
Date now = new Date();
|
||||||
|
Date expirationDate = new Date(now.getTime() + jwtProperties.getExpiration());
|
||||||
|
|
||||||
|
return Jwts.builder()
|
||||||
|
.claims(claims)
|
||||||
|
.subject(subject)
|
||||||
|
.issuedAt(now)
|
||||||
|
.expiration(expirationDate)
|
||||||
|
.signWith(getSigningKey())
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 验证 Token 是否有效
|
||||||
|
*/
|
||||||
|
public boolean isTokenValid(String token, UserDetails userDetails) {
|
||||||
|
final String username = getUsernameFromToken(token);
|
||||||
|
return (username.equals(userDetails.getUsername()) && !isTokenExpired(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 判断 Token 是否过期
|
||||||
|
*/
|
||||||
|
private boolean isTokenExpired(String token) {
|
||||||
|
return extractExpiration(token).before(new Date());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取 Token 过期时间
|
||||||
|
*/
|
||||||
|
public Date extractExpiration(String token) {
|
||||||
|
return getClaimFromToken(token, Claims::getExpiration);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,58 @@
|
|||||||
|
package com.lesingle.creation.vo;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 登录响应 VO
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class LoginResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token
|
||||||
|
*/
|
||||||
|
private String token;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Token 类型
|
||||||
|
*/
|
||||||
|
private String tokenType;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 过期时间
|
||||||
|
*/
|
||||||
|
private Long expiresIn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 ID
|
||||||
|
*/
|
||||||
|
private Long userId;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像 URL
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色列表
|
||||||
|
*/
|
||||||
|
private List<String> roles;
|
||||||
|
|
||||||
|
}
|
||||||
@ -0,0 +1,64 @@
|
|||||||
|
package com.lesingle.creation.vo;
|
||||||
|
|
||||||
|
import lombok.Builder;
|
||||||
|
import lombok.Data;
|
||||||
|
|
||||||
|
import java.time.LocalDateTime;
|
||||||
|
import java.util.List;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户信息 VO
|
||||||
|
*
|
||||||
|
* @author lesingle
|
||||||
|
* @since 1.0.0
|
||||||
|
*/
|
||||||
|
@Data
|
||||||
|
@Builder
|
||||||
|
public class UserVO {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户 ID
|
||||||
|
*/
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 用户名
|
||||||
|
*/
|
||||||
|
private String username;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 昵称
|
||||||
|
*/
|
||||||
|
private String nickname;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 邮箱
|
||||||
|
*/
|
||||||
|
private String email;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 手机号
|
||||||
|
*/
|
||||||
|
private String phone;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 头像 URL
|
||||||
|
*/
|
||||||
|
private String avatar;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 状态
|
||||||
|
*/
|
||||||
|
private Integer status;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 角色列表
|
||||||
|
*/
|
||||||
|
private List<String> roles;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建时间
|
||||||
|
*/
|
||||||
|
private LocalDateTime createTime;
|
||||||
|
|
||||||
|
}
|
||||||
89
java-backend/src/main/resources/application-dev.yml
Normal file
89
java-backend/src/main/resources/application-dev.yml
Normal file
@ -0,0 +1,89 @@
|
|||||||
|
# 开发环境配置
|
||||||
|
spring:
|
||||||
|
# 数据源配置
|
||||||
|
datasource:
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: jdbc:mysql://localhost:3306/creation_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||||
|
username: root
|
||||||
|
password: root
|
||||||
|
druid:
|
||||||
|
# 初始连接数
|
||||||
|
initial-size: 5
|
||||||
|
# 最小空闲连接数
|
||||||
|
min-idle: 5
|
||||||
|
# 最大活跃连接数
|
||||||
|
max-active: 20
|
||||||
|
# 获取连接等待超时时间
|
||||||
|
max-wait: 60000
|
||||||
|
# 检测间隔
|
||||||
|
time-between-eviction-runs-millis: 60000
|
||||||
|
# 连接最小生存时间
|
||||||
|
min-evictable-idle-time-millis: 300000
|
||||||
|
# 检测连接是否有效
|
||||||
|
validation-query: SELECT 1
|
||||||
|
# 检测时机
|
||||||
|
test-while-idle: true
|
||||||
|
# 申请连接时检测
|
||||||
|
test-on-borrow: false
|
||||||
|
# 归还连接时检测
|
||||||
|
test-on-return: false
|
||||||
|
# 开启 P6Spy SQL 性能分析(开发环境)
|
||||||
|
# filter:
|
||||||
|
# slf4j:
|
||||||
|
# enabled: true
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: localhost
|
||||||
|
port: 6379
|
||||||
|
password:
|
||||||
|
database: 0
|
||||||
|
timeout: 5000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
max-wait: -1ms
|
||||||
|
|
||||||
|
# Flyway 配置
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true
|
||||||
|
baseline-version: 0
|
||||||
|
# 开发环境允许 Clean 操作
|
||||||
|
clean-disabled: false
|
||||||
|
# SQL 执行前是否校验
|
||||||
|
validate-on-migrate: true
|
||||||
|
# 编码
|
||||||
|
encoding: UTF-8
|
||||||
|
|
||||||
|
# JWT 配置(开发环境默认密钥)
|
||||||
|
jwt:
|
||||||
|
secret: creation-development-secret-key-please-change-in-production
|
||||||
|
expiration: 86400000 # 24 小时
|
||||||
|
token-prefix: "Bearer "
|
||||||
|
header: Authorization
|
||||||
|
|
||||||
|
# MyBatis-Plus 开发配置
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
# 开启 SQL 日志
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
|
# SpringDoc 开发环境开启
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# 日志级别(开发环境 DEBUG)
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
com.lesingle.creation: DEBUG
|
||||||
|
com.lesingle.creation.mapper: DEBUG
|
||||||
78
java-backend/src/main/resources/application-prod.yml
Normal file
78
java-backend/src/main/resources/application-prod.yml
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
# 生产环境配置
|
||||||
|
spring:
|
||||||
|
# 数据源配置
|
||||||
|
datasource:
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: ${DATABASE_URL:jdbc:mysql://localhost:3306/creation_db?useUnicode=true&characterEncoding=utf8&useSSL=true&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true}
|
||||||
|
username: ${DB_USERNAME}
|
||||||
|
password: ${DB_PASSWORD}
|
||||||
|
druid:
|
||||||
|
initial-size: 10
|
||||||
|
min-idle: 10
|
||||||
|
max-active: 50
|
||||||
|
max-wait: 60000
|
||||||
|
time-between-eviction-runs-millis: 60000
|
||||||
|
min-evictable-idle-time-millis: 300000
|
||||||
|
validation-query: SELECT 1
|
||||||
|
test-while-idle: true
|
||||||
|
test-on-borrow: false
|
||||||
|
test-on-return: false
|
||||||
|
# 开启防火墙模式
|
||||||
|
wall:
|
||||||
|
config:
|
||||||
|
delete-where-none-check: true
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: ${REDIS_HOST}
|
||||||
|
port: ${REDIS_PORT:6379}
|
||||||
|
password: ${REDIS_PASSWORD}
|
||||||
|
database: ${REDIS_DB:0}
|
||||||
|
timeout: 5000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 20
|
||||||
|
max-idle: 20
|
||||||
|
min-idle: 5
|
||||||
|
max-wait: -1ms
|
||||||
|
|
||||||
|
# Flyway 配置
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true
|
||||||
|
baseline-version: 0
|
||||||
|
# 生产环境严格禁止 Clean 操作
|
||||||
|
clean-disabled: true
|
||||||
|
validate-on-migrate: true
|
||||||
|
encoding: UTF-8
|
||||||
|
|
||||||
|
# JWT 配置(生产环境必须使用环境变量)
|
||||||
|
jwt:
|
||||||
|
# 生产环境必须设置强密钥
|
||||||
|
secret: ${JWT_SECRET}
|
||||||
|
expiration: 86400000
|
||||||
|
token-prefix: "Bearer "
|
||||||
|
header: Authorization
|
||||||
|
|
||||||
|
# MyBatis-Plus 生产配置
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
# 关闭 SQL 日志提升性能
|
||||||
|
log-impl: org.slf4j.impl.SimpleLogger
|
||||||
|
|
||||||
|
# SpringDoc 生产环境关闭
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: false
|
||||||
|
swagger-ui:
|
||||||
|
enabled: false
|
||||||
|
|
||||||
|
# 日志级别(生产环境 INFO)
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: WARN
|
||||||
|
com.lesingle.creation: INFO
|
||||||
|
com.lesingle.creation.mapper: WARN
|
||||||
73
java-backend/src/main/resources/application-test.yml
Normal file
73
java-backend/src/main/resources/application-test.yml
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
# 测试环境配置
|
||||||
|
spring:
|
||||||
|
# 数据源配置
|
||||||
|
datasource:
|
||||||
|
type: com.alibaba.druid.pool.DruidDataSource
|
||||||
|
driver-class-name: com.mysql.cj.jdbc.Driver
|
||||||
|
url: jdbc:mysql://test-server:3306/creation_db?useUnicode=true&characterEncoding=utf8&useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true
|
||||||
|
username: ${DB_USERNAME:root}
|
||||||
|
password: ${DB_PASSWORD:root}
|
||||||
|
druid:
|
||||||
|
initial-size: 5
|
||||||
|
min-idle: 5
|
||||||
|
max-active: 20
|
||||||
|
max-wait: 60000
|
||||||
|
time-between-eviction-runs-millis: 60000
|
||||||
|
min-evictable-idle-time-millis: 300000
|
||||||
|
validation-query: SELECT 1
|
||||||
|
test-while-idle: true
|
||||||
|
test-on-borrow: false
|
||||||
|
test-on-return: false
|
||||||
|
|
||||||
|
# Redis 配置
|
||||||
|
data:
|
||||||
|
redis:
|
||||||
|
host: test-server
|
||||||
|
port: 6379
|
||||||
|
password: ${REDIS_PASSWORD:}
|
||||||
|
database: 0
|
||||||
|
timeout: 5000ms
|
||||||
|
lettuce:
|
||||||
|
pool:
|
||||||
|
max-active: 8
|
||||||
|
max-idle: 8
|
||||||
|
min-idle: 0
|
||||||
|
max-wait: -1ms
|
||||||
|
|
||||||
|
# Flyway 配置
|
||||||
|
flyway:
|
||||||
|
enabled: true
|
||||||
|
locations: classpath:db/migration
|
||||||
|
baseline-on-migrate: true
|
||||||
|
baseline-version: 0
|
||||||
|
# 测试环境禁止 Clean 操作
|
||||||
|
clean-disabled: true
|
||||||
|
validate-on-migrate: true
|
||||||
|
encoding: UTF-8
|
||||||
|
|
||||||
|
# JWT 配置
|
||||||
|
jwt:
|
||||||
|
secret: ${JWT_SECRET:creation-test-secret-key}
|
||||||
|
expiration: 86400000
|
||||||
|
token-prefix: "Bearer "
|
||||||
|
header: Authorization
|
||||||
|
|
||||||
|
# MyBatis-Plus 测试配置
|
||||||
|
mybatis-plus:
|
||||||
|
configuration:
|
||||||
|
# 开启 SQL 日志便于调试
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
|
||||||
|
# SpringDoc 测试环境开启
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true
|
||||||
|
|
||||||
|
# 日志级别(测试环境 DEBUG)
|
||||||
|
logging:
|
||||||
|
level:
|
||||||
|
root: INFO
|
||||||
|
com.lesingle.creation: DEBUG
|
||||||
|
com.lesingle.creation.mapper: DEBUG
|
||||||
55
java-backend/src/main/resources/application.yml
Normal file
55
java-backend/src/main/resources/application.yml
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# 应用配置
|
||||||
|
spring:
|
||||||
|
application:
|
||||||
|
name: creation
|
||||||
|
# 激活的环境配置
|
||||||
|
profiles:
|
||||||
|
active: dev
|
||||||
|
# 文件上传配置
|
||||||
|
servlet:
|
||||||
|
multipart:
|
||||||
|
max-file-size: 100MB
|
||||||
|
max-request-size: 100MB
|
||||||
|
|
||||||
|
# 服务器配置
|
||||||
|
server:
|
||||||
|
port: 8580
|
||||||
|
servlet:
|
||||||
|
context-path: /
|
||||||
|
|
||||||
|
# MyBatis-Plus 配置
|
||||||
|
mybatis-plus:
|
||||||
|
# Mapper XML 文件位置
|
||||||
|
mapper-locations: classpath*:/mapper/**/*.xml
|
||||||
|
# 实体类包路径
|
||||||
|
type-aliases-package: com.lesingle.creation.entity
|
||||||
|
configuration:
|
||||||
|
# 开启驼峰命名转换
|
||||||
|
map-underscore-to-camel-case: true
|
||||||
|
# 开启日志
|
||||||
|
log-impl: org.apache.ibatis.logging.stdout.StdOutImpl
|
||||||
|
global-config:
|
||||||
|
db-config:
|
||||||
|
# 主键类型(自增)
|
||||||
|
id-type: auto
|
||||||
|
# 逻辑删除字段
|
||||||
|
logic-delete-field: deleted
|
||||||
|
logic-delete-value: 1
|
||||||
|
logic-not-delete-value: 0
|
||||||
|
# 关闭 Banner
|
||||||
|
banner: false
|
||||||
|
|
||||||
|
# SpringDoc OpenAPI 配置(API 文档)
|
||||||
|
springdoc:
|
||||||
|
api-docs:
|
||||||
|
enabled: true
|
||||||
|
path: /v3/api-docs
|
||||||
|
swagger-ui:
|
||||||
|
enabled: true
|
||||||
|
path: /swagger-ui.html
|
||||||
|
# 设置中文语言
|
||||||
|
default-locale: zh-CN
|
||||||
|
|
||||||
|
# 日志配置
|
||||||
|
logging:
|
||||||
|
config: classpath:logback-spring.xml
|
||||||
@ -0,0 +1,174 @@
|
|||||||
|
-- V1.0__init_schema.sql
|
||||||
|
-- 初始数据库迁移脚本
|
||||||
|
-- 创建基础系统表
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 用户表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_user`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`username` VARCHAR(50) NOT NULL COMMENT '用户名',
|
||||||
|
`password` VARCHAR(255) NOT NULL COMMENT '密码(加密)',
|
||||||
|
`nickname` VARCHAR(50) DEFAULT NULL COMMENT '昵称',
|
||||||
|
`email` VARCHAR(100) DEFAULT NULL COMMENT '邮箱',
|
||||||
|
`phone` VARCHAR(20) DEFAULT NULL COMMENT '手机号',
|
||||||
|
`avatar` VARCHAR(255) DEFAULT NULL COMMENT '头像 URL',
|
||||||
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
|
||||||
|
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
|
||||||
|
`create_by` BIGINT DEFAULT NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_by` BIGINT DEFAULT NULL COMMENT '更新人',
|
||||||
|
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_username` (`username`),
|
||||||
|
UNIQUE KEY `uk_email` (`email`),
|
||||||
|
UNIQUE KEY `uk_phone` (`phone`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 角色表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_role`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`name` VARCHAR(50) NOT NULL COMMENT '角色名称',
|
||||||
|
`code` VARCHAR(50) NOT NULL COMMENT '角色编码',
|
||||||
|
`description` VARCHAR(255) DEFAULT NULL COMMENT '角色描述',
|
||||||
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
|
||||||
|
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
|
||||||
|
`create_by` BIGINT DEFAULT NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_by` BIGINT DEFAULT NULL COMMENT '更新人',
|
||||||
|
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_code` (`code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 权限表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_permission`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`name` VARCHAR(50) NOT NULL COMMENT '权限名称',
|
||||||
|
`code` VARCHAR(100) NOT NULL COMMENT '权限编码',
|
||||||
|
`type` TINYINT NOT NULL COMMENT '权限类型:1-菜单,2-按钮,3-接口',
|
||||||
|
`parent_id` BIGINT DEFAULT 0 COMMENT '父级 ID',
|
||||||
|
`path` VARCHAR(255) DEFAULT NULL COMMENT '路由路径',
|
||||||
|
`icon` VARCHAR(50) DEFAULT NULL COMMENT '图标',
|
||||||
|
`sort` INT DEFAULT 0 COMMENT '排序',
|
||||||
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
|
||||||
|
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
|
||||||
|
`create_by` BIGINT DEFAULT NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_by` BIGINT DEFAULT NULL COMMENT '更新人',
|
||||||
|
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_code` (`code`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='权限表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 用户角色关联表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_user_role`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`user_id` BIGINT NOT NULL COMMENT '用户 ID',
|
||||||
|
`role_id` BIGINT NOT NULL COMMENT '角色 ID',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_user_role` (`user_id`, `role_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户角色关联表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 角色权限关联表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_role_permission`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`role_id` BIGINT NOT NULL COMMENT '角色 ID',
|
||||||
|
`permission_id` BIGINT NOT NULL COMMENT '权限 ID',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_role_permission` (`role_id`, `permission_id`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='角色权限关联表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 系统配置表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_sys_config`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`config_key` VARCHAR(100) NOT NULL COMMENT '配置键',
|
||||||
|
`config_value` VARCHAR(255) DEFAULT NULL COMMENT '配置值',
|
||||||
|
`description` VARCHAR(255) DEFAULT NULL COMMENT '配置描述',
|
||||||
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
|
||||||
|
`deleted` TINYINT DEFAULT 0 COMMENT '逻辑删除:0-未删除,1-已删除',
|
||||||
|
`create_by` BIGINT DEFAULT NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
`update_by` BIGINT DEFAULT NULL COMMENT '更新人',
|
||||||
|
`update_time` DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '更新时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
UNIQUE KEY `uk_config_key` (`config_key`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统配置表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 系统日志表
|
||||||
|
-- ============================================
|
||||||
|
CREATE TABLE IF NOT EXISTS `t_sys_log`
|
||||||
|
(
|
||||||
|
`id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '主键 ID',
|
||||||
|
`trace_id` VARCHAR(50) DEFAULT NULL COMMENT '链路追踪 ID',
|
||||||
|
`module` VARCHAR(50) DEFAULT NULL COMMENT '模块',
|
||||||
|
`operation` VARCHAR(100) DEFAULT NULL COMMENT '操作',
|
||||||
|
`method` VARCHAR(100) DEFAULT NULL COMMENT '请求方法',
|
||||||
|
`url` VARCHAR(255) DEFAULT NULL COMMENT '请求 URL',
|
||||||
|
`params` TEXT COMMENT '请求参数',
|
||||||
|
`result` TEXT COMMENT '响应结果',
|
||||||
|
`ip` VARCHAR(50) DEFAULT NULL COMMENT 'IP 地址',
|
||||||
|
`user_agent` VARCHAR(255) DEFAULT NULL COMMENT '浏览器',
|
||||||
|
`execute_time` INT DEFAULT 0 COMMENT '执行时间(ms)',
|
||||||
|
`status` TINYINT DEFAULT 1 COMMENT '状态:0-失败,1-成功',
|
||||||
|
`error_msg` TEXT COMMENT '错误信息',
|
||||||
|
`create_by` BIGINT DEFAULT NULL COMMENT '创建人',
|
||||||
|
`create_time` DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间',
|
||||||
|
PRIMARY KEY (`id`),
|
||||||
|
KEY `idx_create_time` (`create_time`),
|
||||||
|
KEY `idx_module` (`module`),
|
||||||
|
KEY `idx_user_id` (`create_by`)
|
||||||
|
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='系统日志表';
|
||||||
|
|
||||||
|
-- ============================================
|
||||||
|
-- 初始化数据
|
||||||
|
-- ============================================
|
||||||
|
|
||||||
|
-- 初始化管理员账号(密码:admin123,BCrypt 加密)
|
||||||
|
INSERT INTO `t_user` (`username`, `password`, `nickname`, `status`)
|
||||||
|
VALUES ('admin', '$2a$10$N.zmdr9k7uOCQb376NoUnuTJ8iAt6Z5EHsM8lE9lBOsl7iKTVKIUi', '系统管理员', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE `username` = `username`;
|
||||||
|
|
||||||
|
-- 初始化超级管理员角色
|
||||||
|
INSERT INTO `t_role` (`name`, `code`, `description`, `status`)
|
||||||
|
VALUES ('超级管理员', 'SUPER_ADMIN', '拥有系统所有权限', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE `code` = `code`;
|
||||||
|
|
||||||
|
-- 初始化普通用户角色
|
||||||
|
INSERT INTO `t_role` (`name`, `code`, `description`, `status`)
|
||||||
|
VALUES ('普通用户', 'USER', '普通用户角色', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE `code` = `code`;
|
||||||
|
|
||||||
|
-- 关联管理员与超级管理员角色
|
||||||
|
INSERT INTO `t_user_role` (`user_id`, `role_id`)
|
||||||
|
SELECT u.id, r.id
|
||||||
|
FROM `t_user` u, `t_role` r
|
||||||
|
WHERE u.username = 'admin' AND r.code = 'SUPER_ADMIN'
|
||||||
|
ON DUPLICATE KEY UPDATE `user_id` = `user_id`;
|
||||||
|
|
||||||
|
-- 初始化系统配置
|
||||||
|
INSERT INTO `t_sys_config` (`config_key`, `config_value`, `description`, `status`)
|
||||||
|
VALUES
|
||||||
|
('system.name', 'Creation 系统', '系统名称', 1),
|
||||||
|
('system.version', '1.0.0', '系统版本', 1),
|
||||||
|
('user.register.enabled', 'true', '是否允许用户注册', 1)
|
||||||
|
ON DUPLICATE KEY UPDATE `config_key` = `config_key`;
|
||||||
97
java-backend/src/main/resources/logback-spring.xml
Normal file
97
java-backend/src/main/resources/logback-spring.xml
Normal file
@ -0,0 +1,97 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<configuration>
|
||||||
|
<!-- 日志输出格式 -->
|
||||||
|
<property name="LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n"/>
|
||||||
|
<!-- 控制台彩色输出 -->
|
||||||
|
<property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %boldGreen(%-5level) %boldMagenta(%logger{50}) - %msg%n"/>
|
||||||
|
|
||||||
|
<!-- 控制台输出 -->
|
||||||
|
<appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
|
||||||
|
<encoder>
|
||||||
|
<pattern>${CONSOLE_LOG_PATTERN}</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 文件输出 - INFO 级别 -->
|
||||||
|
<appender name="FILE_INFO" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>logs/creation-info.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>INFO</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>logs/creation-info.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>1GB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 文件输出 - ERROR 级别 -->
|
||||||
|
<appender name="FILE_ERROR" class="ch.qos.logback.core.rolling.RollingFileAppender">
|
||||||
|
<file>logs/creation-error.log</file>
|
||||||
|
<encoder>
|
||||||
|
<pattern>${LOG_PATTERN}</pattern>
|
||||||
|
<charset>UTF-8</charset>
|
||||||
|
</encoder>
|
||||||
|
<filter class="ch.qos.logback.classic.filter.LevelFilter">
|
||||||
|
<level>ERROR</level>
|
||||||
|
<onMatch>ACCEPT</onMatch>
|
||||||
|
<onMismatch>DENY</onMismatch>
|
||||||
|
</filter>
|
||||||
|
<rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
|
||||||
|
<fileNamePattern>logs/creation-error.%d{yyyy-MM-dd}.log</fileNamePattern>
|
||||||
|
<maxHistory>30</maxHistory>
|
||||||
|
<totalSizeCap>500MB</totalSizeCap>
|
||||||
|
</rollingPolicy>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 异步输出 -->
|
||||||
|
<appender name="ASYNC_FILE_INFO" class="ch.qos.logback.classic.AsyncAppender">
|
||||||
|
<appender-ref ref="FILE_INFO"/>
|
||||||
|
<queueSize>512</queueSize>
|
||||||
|
<discardingThreshold>0</discardingThreshold>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<appender name="ASYNC_FILE_ERROR" class="ch.qos.logback.classic.AsyncAppender">
|
||||||
|
<appender-ref ref="FILE_ERROR"/>
|
||||||
|
<queueSize>512</queueSize>
|
||||||
|
<discardingThreshold>0</discardingThreshold>
|
||||||
|
</appender>
|
||||||
|
|
||||||
|
<!-- 开发环境配置 -->
|
||||||
|
<springProfile name="dev">
|
||||||
|
<root level="DEBUG">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
</root>
|
||||||
|
<logger name="com.lesingle.creation" level="DEBUG"/>
|
||||||
|
<logger name="com.lesingle.creation.mapper" level="DEBUG"/>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<!-- 测试环境配置 -->
|
||||||
|
<springProfile name="test">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="ASYNC_FILE_INFO"/>
|
||||||
|
<appender-ref ref="ASYNC_FILE_ERROR"/>
|
||||||
|
</root>
|
||||||
|
<logger name="com.lesingle.creation" level="DEBUG"/>
|
||||||
|
<logger name="com.lesingle.creation.mapper" level="DEBUG"/>
|
||||||
|
</springProfile>
|
||||||
|
|
||||||
|
<!-- 生产环境配置 -->
|
||||||
|
<springProfile name="prod">
|
||||||
|
<root level="INFO">
|
||||||
|
<appender-ref ref="CONSOLE"/>
|
||||||
|
<appender-ref ref="ASYNC_FILE_INFO"/>
|
||||||
|
<appender-ref ref="ASYNC_FILE_ERROR"/>
|
||||||
|
</root>
|
||||||
|
<logger name="com.lesingle.creation" level="INFO"/>
|
||||||
|
<logger name="com.lesingle.creation.mapper" level="WARN"/>
|
||||||
|
</springProfile>
|
||||||
|
</configuration>
|
||||||
13
java-backend/src/main/resources/mapper/RoleMapper.xml
Normal file
13
java-backend/src/main/resources/mapper/RoleMapper.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.lesingle.creation.mapper.RoleMapper">
|
||||||
|
|
||||||
|
<!-- 根据角色编码查询角色 -->
|
||||||
|
<select id="selectByCode" resultType="com.lesingle.creation.entity.Role">
|
||||||
|
SELECT *
|
||||||
|
FROM t_role
|
||||||
|
WHERE code = #{code}
|
||||||
|
AND deleted = 0
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
13
java-backend/src/main/resources/mapper/UserMapper.xml
Normal file
13
java-backend/src/main/resources/mapper/UserMapper.xml
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE mapper PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN" "http://mybatis.org/dtd/mybatis-3-mapper.dtd">
|
||||||
|
<mapper namespace="com.lesingle.creation.mapper.UserMapper">
|
||||||
|
|
||||||
|
<!-- 根据用户名查询用户 -->
|
||||||
|
<select id="selectByUsername" resultType="com.lesingle.creation.entity.User">
|
||||||
|
SELECT *
|
||||||
|
FROM t_user
|
||||||
|
WHERE username = #{username}
|
||||||
|
AND deleted = 0
|
||||||
|
</select>
|
||||||
|
|
||||||
|
</mapper>
|
||||||
237
java-frontend/.cursor/rules/frontend-specific.mdc
Normal file
237
java-frontend/.cursor/rules/frontend-specific.mdc
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
---
|
||||||
|
description: 前端特定的开发规范(仅作用于 frontend 目录)
|
||||||
|
globs:
|
||||||
|
alwaysApply: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# 前端特定规范
|
||||||
|
|
||||||
|
本规则仅作用于 `frontend/` 目录。
|
||||||
|
|
||||||
|
## Ant Design Vue 组件使用
|
||||||
|
|
||||||
|
### 表格组件
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record }">
|
||||||
|
<template v-if="column.key === 'action'">
|
||||||
|
<a-space>
|
||||||
|
<a-button type="link" @click="handleEdit(record)">编辑</a-button>
|
||||||
|
<a-popconfirm
|
||||||
|
title="确定要删除吗?"
|
||||||
|
@confirm="handleDelete(record.id)"
|
||||||
|
>
|
||||||
|
<a-button type="link" danger>删除</a-button>
|
||||||
|
</a-popconfirm>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 表单组件
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-form
|
||||||
|
:model="formState"
|
||||||
|
:rules="rules"
|
||||||
|
layout="vertical"
|
||||||
|
@finish="onFinish"
|
||||||
|
>
|
||||||
|
<a-form-item label="用户名" name="username">
|
||||||
|
<a-input v-model:value="formState.username" />
|
||||||
|
</a-form-item>
|
||||||
|
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">提交</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Modal 弹窗
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-modal
|
||||||
|
v-model:open="visible"
|
||||||
|
title="编辑用户"
|
||||||
|
@ok="handleOk"
|
||||||
|
>
|
||||||
|
<a-form :model="formState">
|
||||||
|
<!-- 表单内容 -->
|
||||||
|
</a-form>
|
||||||
|
</a-modal>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 消息提示
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { message, notification } from 'ant-design-vue';
|
||||||
|
|
||||||
|
// 成功提示
|
||||||
|
message.success('操作成功');
|
||||||
|
|
||||||
|
// 错误提示
|
||||||
|
message.error('操作失败');
|
||||||
|
|
||||||
|
// 通知
|
||||||
|
notification.success({
|
||||||
|
message: '成功',
|
||||||
|
description: '用户创建成功',
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tailwind CSS 常用类
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- 布局 -->
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="grid grid-cols-3 gap-4">
|
||||||
|
<div class="p-4 m-2">
|
||||||
|
|
||||||
|
<!-- 响应式 -->
|
||||||
|
<div class="w-full md:w-1/2 lg:w-1/3">
|
||||||
|
|
||||||
|
<!-- 文本 -->
|
||||||
|
<p class="text-lg font-bold text-gray-800">
|
||||||
|
|
||||||
|
<!-- 状态 -->
|
||||||
|
<button class="hover:bg-blue-600 active:bg-blue-700">
|
||||||
|
```
|
||||||
|
|
||||||
|
## 权限指令
|
||||||
|
|
||||||
|
使用自定义指令 `v-permission`:
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-button v-permission="'user:create'" type="primary">
|
||||||
|
创建用户
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<a-button v-permission="['user:update', 'user:delete']" type="link">
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 租户路由
|
||||||
|
|
||||||
|
所有路由必须包含租户编码:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// ✅ 正确
|
||||||
|
router.push(`/${tenantCode}/users`);
|
||||||
|
|
||||||
|
// ❌ 错误
|
||||||
|
router.push('/users');
|
||||||
|
```
|
||||||
|
|
||||||
|
获取租户编码:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useRoute } from 'vue-router';
|
||||||
|
|
||||||
|
const route = useRoute();
|
||||||
|
const tenantCode = route.params.tenantCode as string;
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用组合式函数
|
||||||
|
|
||||||
|
### 使用权限检查
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuthStore } from '@/stores/auth';
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const hasPermission = authStore.hasPermission('user:create');
|
||||||
|
|
||||||
|
// 检查多个权限(任一)
|
||||||
|
const hasAnyPermission = authStore.hasAnyPermission(['user:create', 'user:update']);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 表格分页
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { ref, reactive } from 'vue';
|
||||||
|
|
||||||
|
const loading = ref(false);
|
||||||
|
const dataSource = ref([]);
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.current = pag.current;
|
||||||
|
pagination.pageSize = pag.pageSize;
|
||||||
|
fetchData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const fetchData = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const { data, total } = await getUsers({
|
||||||
|
skip: (pagination.current - 1) * pagination.pageSize,
|
||||||
|
take: pagination.pageSize,
|
||||||
|
});
|
||||||
|
dataSource.value = data;
|
||||||
|
pagination.total = total;
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
## 项目结构
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
├── api/ # API 接口
|
||||||
|
├── assets/ # 静态资源
|
||||||
|
├── components/ # 公共组件
|
||||||
|
├── composables/ # 组合式函数
|
||||||
|
├── directives/ # 自定义指令
|
||||||
|
├── layouts/ # 布局组件
|
||||||
|
├── router/ # 路由配置
|
||||||
|
├── stores/ # Pinia 状态
|
||||||
|
├── styles/ # 全局样式
|
||||||
|
├── types/ # TypeScript 类型
|
||||||
|
├── utils/ # 工具函数
|
||||||
|
└── views/ # 页面组件
|
||||||
|
├── auth/ # 认证相关
|
||||||
|
├── users/ # 用户管理
|
||||||
|
├── school/ # 学校管理
|
||||||
|
└── contests/ # 竞赛管理
|
||||||
|
```
|
||||||
|
|
||||||
|
## 常用脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 开发
|
||||||
|
pnpm dev
|
||||||
|
|
||||||
|
# 构建
|
||||||
|
pnpm build
|
||||||
|
|
||||||
|
# 预览
|
||||||
|
pnpm preview
|
||||||
|
|
||||||
|
# 代码检查
|
||||||
|
pnpm lint
|
||||||
|
```
|
||||||
2
java-frontend/.env.development
Normal file
2
java-frontend/.env.development
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# 开发环境
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
5
java-frontend/.env.production
Normal file
5
java-frontend/.env.production
Normal file
@ -0,0 +1,5 @@
|
|||||||
|
# 生产环境
|
||||||
|
VITE_API_BASE_URL=/api
|
||||||
|
# 如果后端部署在不同域名,可以改成完整地址:
|
||||||
|
# VITE_API_BASE_URL=https://api.your-domain.com
|
||||||
|
|
||||||
3
java-frontend/.env.test
Normal file
3
java-frontend/.env.test
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
# 测试环境
|
||||||
|
VITE_BASE_URL=/web-test/
|
||||||
|
VITE_API_BASE_URL=/api-test
|
||||||
25
java-frontend/.gitignore
vendored
Normal file
25
java-frontend/.gitignore
vendored
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
# 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?
|
||||||
|
|
||||||
4
java-frontend/.npmrc
Normal file
4
java-frontend/.npmrc
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
# 前端 pnpm 配置
|
||||||
|
shamefully-hoist=true
|
||||||
|
strict-peer-dependencies=false
|
||||||
|
|
||||||
378
java-frontend/PERMISSION_USAGE.md
Normal file
378
java-frontend/PERMISSION_USAGE.md
Normal file
@ -0,0 +1,378 @@
|
|||||||
|
# 前端权限控制使用指南
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
前端权限控制系统已经完善,支持:
|
||||||
|
|
||||||
|
- ✅ 自动获取用户信息(刷新页面时)
|
||||||
|
- ✅ 角色权限检查
|
||||||
|
- ✅ 权限码检查
|
||||||
|
- ✅ 路由守卫自动验证
|
||||||
|
|
||||||
|
## 🔧 已修复的问题
|
||||||
|
|
||||||
|
### 1. **认证状态判断**
|
||||||
|
|
||||||
|
- **之前**: `isAuthenticated = !!token && !!user`(刷新页面时 user 为 null 导致判断失败)
|
||||||
|
- **现在**: `isAuthenticated = !!token`(只要有 token 就认为已认证)
|
||||||
|
|
||||||
|
### 2. **自动获取用户信息**
|
||||||
|
|
||||||
|
- **之前**: 刷新页面后用户信息丢失
|
||||||
|
- **现在**:
|
||||||
|
- 应用启动时自动获取(`main.ts`)
|
||||||
|
- 路由守卫中自动获取(如果 token 存在但 user 不存在)
|
||||||
|
|
||||||
|
### 3. **权限检查**
|
||||||
|
|
||||||
|
- **之前**: 只检查 `requiresAuth`,没有角色和权限检查
|
||||||
|
- **现在**: 支持角色和权限检查
|
||||||
|
|
||||||
|
## 🎯 使用方法
|
||||||
|
|
||||||
|
### 1. 在路由中配置权限
|
||||||
|
|
||||||
|
#### 使用角色控制
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
name: "SystemUsers",
|
||||||
|
component: () => import("@/views/system/users/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "用户管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["super_admin", "admin"], // 需要 super_admin 或 admin 角色
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 使用权限控制
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
name: "SystemUsers",
|
||||||
|
component: () => import("@/views/system/users/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "用户管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["user:read"], // 需要 user:read 权限
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 同时使用角色和权限
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
{
|
||||||
|
path: "users",
|
||||||
|
name: "SystemUsers",
|
||||||
|
component: () => import("@/views/system/users/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "用户管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["admin"], // 需要 admin 角色
|
||||||
|
permissions: ["user:read"], // 并且需要 user:read 权限
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**: 如果同时设置了 `roles` 和 `permissions`,需要**同时满足**两者。
|
||||||
|
|
||||||
|
### 2. 在组件中使用权限
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<!-- 根据角色显示 -->
|
||||||
|
<a-button v-if="authStore.hasRole('super_admin')" @click="deleteAll">
|
||||||
|
删除所有
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 根据权限显示 -->
|
||||||
|
<a-button v-if="authStore.hasPermission('user:create')" @click="createUser">
|
||||||
|
创建用户
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 检查多个角色 -->
|
||||||
|
<a-button
|
||||||
|
v-if="authStore.hasAnyRole(['admin', 'editor'])"
|
||||||
|
@click="editUser"
|
||||||
|
>
|
||||||
|
编辑用户
|
||||||
|
</a-button>
|
||||||
|
|
||||||
|
<!-- 检查多个权限 -->
|
||||||
|
<a-button
|
||||||
|
v-if="authStore.hasAnyPermission(['user:update', 'user:delete'])"
|
||||||
|
@click="manageUser"
|
||||||
|
>
|
||||||
|
管理用户
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Store 方法说明
|
||||||
|
|
||||||
|
#### `hasRole(role: string): boolean`
|
||||||
|
|
||||||
|
检查用户是否有指定角色
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (authStore.hasRole("super_admin")) {
|
||||||
|
// 用户是超级管理员
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `hasPermission(permission: string): boolean`
|
||||||
|
|
||||||
|
检查用户是否有指定权限
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (authStore.hasPermission("user:create")) {
|
||||||
|
// 用户可以创建用户
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `hasAnyRole(roles: string[]): boolean`
|
||||||
|
|
||||||
|
检查用户是否有任一指定角色
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (authStore.hasAnyRole(["admin", "editor"])) {
|
||||||
|
// 用户是 admin 或 editor
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `hasAnyPermission(permissions: string[]): boolean`
|
||||||
|
|
||||||
|
检查用户是否有任一指定权限
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
if (authStore.hasAnyPermission(["user:create", "user:update"])) {
|
||||||
|
// 用户可以创建或更新用户
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔄 工作流程
|
||||||
|
|
||||||
|
### 1. 应用启动流程
|
||||||
|
|
||||||
|
```
|
||||||
|
应用启动
|
||||||
|
↓
|
||||||
|
检查 localStorage 中是否有 token
|
||||||
|
↓
|
||||||
|
如果有 token,调用 fetchUserInfo() 获取用户信息
|
||||||
|
↓
|
||||||
|
用户信息包含 roles 和 permissions
|
||||||
|
↓
|
||||||
|
应用挂载完成
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 路由导航流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户访问路由
|
||||||
|
↓
|
||||||
|
路由守卫 beforeEach
|
||||||
|
↓
|
||||||
|
检查 token 是否存在
|
||||||
|
↓
|
||||||
|
如果 token 存在但 user 不存在 → 自动获取用户信息
|
||||||
|
↓
|
||||||
|
检查 requiresAuth → 是否需要登录
|
||||||
|
↓
|
||||||
|
检查 roles → 是否有指定角色
|
||||||
|
↓
|
||||||
|
检查 permissions → 是否有指定权限
|
||||||
|
↓
|
||||||
|
允许/拒绝访问
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 登录流程
|
||||||
|
|
||||||
|
```
|
||||||
|
用户登录
|
||||||
|
↓
|
||||||
|
调用 authApi.login()
|
||||||
|
↓
|
||||||
|
返回 token 和 user 信息(包含 roles 和 permissions)
|
||||||
|
↓
|
||||||
|
保存 token 到 localStorage
|
||||||
|
↓
|
||||||
|
保存 user 到 store
|
||||||
|
↓
|
||||||
|
跳转到目标页面
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📝 路由配置示例
|
||||||
|
|
||||||
|
### 完整的路由配置示例
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const routes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "Login",
|
||||||
|
component: () => import("@/views/auth/Login.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/",
|
||||||
|
component: () => import("@/layouts/BasicLayout.vue"),
|
||||||
|
redirect: "/workbench",
|
||||||
|
meta: { requiresAuth: true },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "workbench",
|
||||||
|
name: "workbench",
|
||||||
|
component: () => import("@/views/workbench/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "仪表盘",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "system/users",
|
||||||
|
name: "SystemUsers",
|
||||||
|
component: () => import("@/views/system/users/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "用户管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["user:read"], // 需要查看用户权限
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "system/roles",
|
||||||
|
name: "SystemRoles",
|
||||||
|
component: () => import("@/views/system/roles/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "角色管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
roles: ["super_admin", "admin"], // 需要管理员角色
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 实际应用场景
|
||||||
|
|
||||||
|
### 场景 1: 根据权限显示菜单
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item v-if="authStore.hasPermission('user:read')">
|
||||||
|
<router-link to="/system/users">用户管理</router-link>
|
||||||
|
</a-menu-item>
|
||||||
|
<a-menu-item v-if="authStore.hasRole('admin')">
|
||||||
|
<router-link to="/system/roles">角色管理</router-link>
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 2: 根据权限显示按钮
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<a-table>
|
||||||
|
<template #action="{ record }">
|
||||||
|
<a-space>
|
||||||
|
<a-button
|
||||||
|
v-if="authStore.hasPermission('user:update')"
|
||||||
|
@click="edit(record)"
|
||||||
|
>
|
||||||
|
编辑
|
||||||
|
</a-button>
|
||||||
|
<a-button
|
||||||
|
v-if="authStore.hasPermission('user:delete')"
|
||||||
|
danger
|
||||||
|
@click="remove(record)"
|
||||||
|
>
|
||||||
|
删除
|
||||||
|
</a-button>
|
||||||
|
</a-space>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 场景 3: 组合权限检查
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<!-- 需要同时满足角色和权限 -->
|
||||||
|
<a-button
|
||||||
|
v-if="authStore.hasRole('admin') && authStore.hasPermission('user:delete')"
|
||||||
|
@click="deleteAll"
|
||||||
|
>
|
||||||
|
批量删除
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
### 1. 路由守卫是异步的
|
||||||
|
|
||||||
|
路由守卫使用了 `async/await`,确保在检查权限前用户信息已加载。
|
||||||
|
|
||||||
|
### 2. 权限检查顺序
|
||||||
|
|
||||||
|
1. 认证检查(`requiresAuth`)
|
||||||
|
2. 角色检查(`roles`)
|
||||||
|
3. 权限检查(`permissions`)
|
||||||
|
|
||||||
|
### 3. 403 页面
|
||||||
|
|
||||||
|
如果没有权限,会跳转到 `/403` 页面。你可以自定义这个页面。
|
||||||
|
|
||||||
|
### 4. 权限更新
|
||||||
|
|
||||||
|
如果用户权限发生变化,需要:
|
||||||
|
|
||||||
|
- 重新登录,或
|
||||||
|
- 调用 `authStore.fetchUserInfo()` 刷新用户信息
|
||||||
|
|
||||||
|
## 🔍 调试技巧
|
||||||
|
|
||||||
|
### 1. 查看当前用户信息
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
console.log("用户信息:", authStore.user);
|
||||||
|
console.log("角色:", authStore.user?.roles);
|
||||||
|
console.log("权限:", authStore.user?.permissions);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 检查权限
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
console.log("是否有 admin 角色:", authStore.hasRole("admin"));
|
||||||
|
console.log("是否有 user:create 权限:", authStore.hasPermission("user:create"));
|
||||||
|
```
|
||||||
|
|
||||||
|
## 📚 总结
|
||||||
|
|
||||||
|
现在权限控制系统已经完善:
|
||||||
|
|
||||||
|
1. ✅ **自动获取用户信息** - 刷新页面不会丢失
|
||||||
|
2. ✅ **路由权限检查** - 支持角色和权限控制
|
||||||
|
3. ✅ **组件权限检查** - 可以在组件中使用权限方法
|
||||||
|
4. ✅ **类型安全** - TypeScript 类型定义完善
|
||||||
|
|
||||||
|
可以开始使用权限控制功能了!
|
||||||
219
java-frontend/TENANT_MANAGEMENT_GUIDE.md
Normal file
219
java-frontend/TENANT_MANAGEMENT_GUIDE.md
Normal file
@ -0,0 +1,219 @@
|
|||||||
|
# 租户管理页面使用指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
租户管理页面已创建完成,只有超级租户才能访问和使用。该页面提供了完整的租户管理功能,包括租户的创建、编辑、删除和菜单分配。
|
||||||
|
|
||||||
|
## 功能特性
|
||||||
|
|
||||||
|
### 1. 租户列表
|
||||||
|
|
||||||
|
- 显示所有租户的基本信息
|
||||||
|
- 显示租户类型(超级租户/普通租户)
|
||||||
|
- 显示租户状态(有效/失效)
|
||||||
|
- 显示租户统计信息(用户数、角色数)
|
||||||
|
- 显示租户已分配的菜单
|
||||||
|
|
||||||
|
### 2. 创建租户
|
||||||
|
|
||||||
|
- 租户名称
|
||||||
|
- 租户编码(用于访问链接,必须唯一)
|
||||||
|
- 租户域名(可选,用于子域名访问)
|
||||||
|
- 租户描述
|
||||||
|
|
||||||
|
### 3. 编辑租户
|
||||||
|
|
||||||
|
- 修改租户基本信息
|
||||||
|
- 修改租户状态(有效/失效)
|
||||||
|
- 注意:租户编码创建后不可修改
|
||||||
|
|
||||||
|
### 4. 分配菜单
|
||||||
|
|
||||||
|
- 以树形结构展示所有可用菜单
|
||||||
|
- 支持多选
|
||||||
|
- 显示租户当前已分配的菜单
|
||||||
|
- 可以批量分配或取消分配菜单
|
||||||
|
|
||||||
|
### 5. 删除租户
|
||||||
|
|
||||||
|
- 删除租户及其所有关联数据
|
||||||
|
- 超级租户不能被删除
|
||||||
|
- 删除操作不可恢复
|
||||||
|
|
||||||
|
## 权限要求
|
||||||
|
|
||||||
|
所有操作都需要相应的权限:
|
||||||
|
|
||||||
|
- `tenant:create` - 创建租户
|
||||||
|
- `tenant:read` - 查看租户列表和详情
|
||||||
|
- `tenant:update` - 编辑租户和分配菜单
|
||||||
|
- `tenant:delete` - 删除租户
|
||||||
|
|
||||||
|
**注意**:只有超级租户的用户才拥有这些权限。
|
||||||
|
|
||||||
|
## 添加租户管理菜单
|
||||||
|
|
||||||
|
### 方式一:通过数据库直接添加
|
||||||
|
|
||||||
|
在数据库中执行以下SQL,为超级租户添加租户管理菜单:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 假设系统管理菜单的ID为某个值,需要根据实际情况调整 parent_id
|
||||||
|
-- 假设系统管理菜单的ID为 2(需要根据实际情况查询)
|
||||||
|
|
||||||
|
INSERT INTO menus (name, path, icon, component, parent_id, permission, sort, valid_state, create_time, modify_time)
|
||||||
|
VALUES (
|
||||||
|
'租户管理',
|
||||||
|
'/system/tenants',
|
||||||
|
'TeamOutlined',
|
||||||
|
'system/tenants/Index',
|
||||||
|
2, -- 系统管理菜单的ID,需要根据实际情况调整
|
||||||
|
'tenant:read',
|
||||||
|
7, -- 排序,放在其他系统管理菜单之后
|
||||||
|
1,
|
||||||
|
NOW(),
|
||||||
|
NOW()
|
||||||
|
);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式二:通过后端API添加
|
||||||
|
|
||||||
|
使用超级管理员账号登录后,通过菜单管理接口添加:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
POST /api/menus
|
||||||
|
Authorization: Bearer <token>
|
||||||
|
X-Tenant-Code: super
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "租户管理",
|
||||||
|
"path": "/system/tenants",
|
||||||
|
"icon": "TeamOutlined",
|
||||||
|
"component": "system/tenants/Index",
|
||||||
|
"parentId": 2, // 系统管理菜单的ID
|
||||||
|
"permission": "tenant:read",
|
||||||
|
"sort": 7
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 方式三:更新初始化脚本
|
||||||
|
|
||||||
|
修改 `backend/scripts/init-menus.ts` 或 `backend/scripts/init-super-tenant.ts`,在菜单初始化时添加租户管理菜单。
|
||||||
|
|
||||||
|
## 页面访问
|
||||||
|
|
||||||
|
添加菜单后,超级租户的用户登录后可以在"系统管理"菜单下看到"租户管理"选项。
|
||||||
|
|
||||||
|
访问路径:`/system/tenants`
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 创建新租户
|
||||||
|
|
||||||
|
1. 点击"新增租户"按钮
|
||||||
|
2. 填写租户信息:
|
||||||
|
- 租户名称:例如 "租户A"
|
||||||
|
- 租户编码:例如 "tenant-a"(必须唯一,只能包含小写字母、数字、下划线和连字符)
|
||||||
|
- 租户域名:例如 "tenant-a.example.com"(可选)
|
||||||
|
- 描述:租户的描述信息
|
||||||
|
3. 点击"确定"创建
|
||||||
|
|
||||||
|
### 为租户分配菜单
|
||||||
|
|
||||||
|
1. 在租户列表中,点击某个租户的"分配菜单"按钮
|
||||||
|
2. 在弹出的菜单树中,勾选要分配给该租户的菜单
|
||||||
|
3. 点击"确定"保存
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
|
||||||
|
- 只有被分配的菜单才会在该租户的用户登录后显示
|
||||||
|
- 父菜单如果被分配,其子菜单也会自动显示(但需要单独分配才能访问)
|
||||||
|
|
||||||
|
### 编辑租户
|
||||||
|
|
||||||
|
1. 点击租户列表中的"编辑"按钮
|
||||||
|
2. 修改租户信息(租户编码不可修改)
|
||||||
|
3. 可以修改租户状态(有效/失效)
|
||||||
|
4. 点击"确定"保存
|
||||||
|
|
||||||
|
### 删除租户
|
||||||
|
|
||||||
|
1. 点击租户列表中的"删除"按钮
|
||||||
|
2. 确认删除操作
|
||||||
|
3. **警告**:删除租户会同时删除该租户的所有数据(用户、角色、权限等),此操作不可恢复
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限控制**:只有超级租户的用户才能看到和使用租户管理功能
|
||||||
|
2. **租户编码唯一性**:租户编码必须全局唯一,创建后不可修改
|
||||||
|
3. **超级租户保护**:超级租户不能被删除
|
||||||
|
4. **菜单分配**:菜单分配后,租户的用户登录后才能看到相应的菜单
|
||||||
|
5. **数据隔离**:每个租户的数据完全隔离,互不影响
|
||||||
|
|
||||||
|
## 故障排查
|
||||||
|
|
||||||
|
### 问题1:看不到租户管理菜单
|
||||||
|
|
||||||
|
**原因**:菜单未添加到数据库,或当前用户不是超级租户
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
|
||||||
|
- 确认菜单已添加到数据库
|
||||||
|
- 确认当前用户属于超级租户
|
||||||
|
- 确认用户有 `tenant:read` 权限
|
||||||
|
- 刷新页面或重新登录
|
||||||
|
|
||||||
|
### 问题2:无法创建租户
|
||||||
|
|
||||||
|
**原因**:缺少 `tenant:create` 权限
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
|
||||||
|
- 确认当前用户有创建租户的权限
|
||||||
|
- 联系超级管理员分配权限
|
||||||
|
|
||||||
|
### 问题3:菜单分配不生效
|
||||||
|
|
||||||
|
**原因**:菜单分配后,用户需要重新登录才能看到新菜单
|
||||||
|
|
||||||
|
**解决**:
|
||||||
|
|
||||||
|
- 让租户的用户重新登录
|
||||||
|
- 或者清除浏览器缓存后重新登录
|
||||||
|
|
||||||
|
## 技术实现
|
||||||
|
|
||||||
|
### 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
frontend/src/
|
||||||
|
├── api/
|
||||||
|
│ └── tenants.ts # 租户API接口
|
||||||
|
├── views/
|
||||||
|
│ └── system/
|
||||||
|
│ └── tenants/
|
||||||
|
│ └── Index.vue # 租户管理页面
|
||||||
|
└── utils/
|
||||||
|
└── menu.ts # 菜单工具(已添加租户管理组件映射)
|
||||||
|
```
|
||||||
|
|
||||||
|
### API接口
|
||||||
|
|
||||||
|
所有API接口都在 `frontend/src/api/tenants.ts` 中定义:
|
||||||
|
|
||||||
|
- `getTenantsList()` - 获取租户列表
|
||||||
|
- `getTenantDetail()` - 获取租户详情
|
||||||
|
- `createTenant()` - 创建租户
|
||||||
|
- `updateTenant()` - 更新租户
|
||||||
|
- `deleteTenant()` - 删除租户
|
||||||
|
- `getTenantMenus()` - 获取租户菜单树
|
||||||
|
|
||||||
|
### 组件映射
|
||||||
|
|
||||||
|
在 `frontend/src/utils/menu.ts` 中添加了组件映射:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
"system/tenants/Index": () => import("@/views/system/tenants/Index.vue")
|
||||||
|
```
|
||||||
|
|
||||||
|
这样当菜单的 `component` 字段为 `system/tenants/Index` 时,系统会自动加载租户管理页面。
|
||||||
53
java-frontend/cmp-3d.conf
Normal file
53
java-frontend/cmp-3d.conf
Normal file
@ -0,0 +1,53 @@
|
|||||||
|
# ========== 活动管理系统 - cmp-3d.linkseaai.com ==========
|
||||||
|
|
||||||
|
server {
|
||||||
|
listen 443 ssl;
|
||||||
|
server_name cmp-3d.linkseaai.com;
|
||||||
|
include /usr/local/nginx/conf/conf.d/linkseaai.ssl.conf;
|
||||||
|
root /data/apps/cmp-3d/;
|
||||||
|
include /usr/local/nginx/conf/conf.d/error.conf;
|
||||||
|
include /usr/local/nginx/conf/conf.d/static.conf;
|
||||||
|
|
||||||
|
# ========== 超时配置 ==========
|
||||||
|
keepalive_timeout 300s;
|
||||||
|
send_timeout 180s;
|
||||||
|
proxy_connect_timeout 10s;
|
||||||
|
proxy_send_timeout 10s;
|
||||||
|
proxy_read_timeout 300s;
|
||||||
|
|
||||||
|
# ========== 测试环境 - 前端 ==========
|
||||||
|
location /web-test/ {
|
||||||
|
root /data/apps/cmp-3d/;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /web-test/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 测试环境 - API 代理 ==========
|
||||||
|
location /api-test/ {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://119.29.229.174:3234/api;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 生产环境 - 前端 ==========
|
||||||
|
location /web/ {
|
||||||
|
root /data/apps/cmp-3d/;
|
||||||
|
index index.html index.htm;
|
||||||
|
try_files $uri $uri/ /web/index.html;
|
||||||
|
}
|
||||||
|
|
||||||
|
# ========== 生产环境 - API 代理 ==========
|
||||||
|
location /api/ {
|
||||||
|
proxy_redirect off;
|
||||||
|
proxy_pass http://119.29.229.174:3234/api/;
|
||||||
|
proxy_http_version 1.1;
|
||||||
|
proxy_set_header Host $host;
|
||||||
|
proxy_set_header X-Real-IP $remote_addr;
|
||||||
|
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||||
|
proxy_set_header X-Forwarded-Proto $scheme;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
java-frontend/competition-web-test-v1.0.0.tgz
Normal file
BIN
java-frontend/competition-web-test-v1.0.0.tgz
Normal file
Binary file not shown.
262
java-frontend/docs/add-new-route.md
Normal file
262
java-frontend/docs/add-new-route.md
Normal file
@ -0,0 +1,262 @@
|
|||||||
|
# 新增前端页面路由指南
|
||||||
|
|
||||||
|
本文档介绍如何在活动管理系统中新增前端页面路由。
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
系统采用**动态路由**机制,路由配置存储在数据库中,前端根据用户权限动态加载。新增页面需要完成以下 4 个步骤:
|
||||||
|
|
||||||
|
```
|
||||||
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
|
│ Step 1: 创建 Vue 组件文件 │
|
||||||
|
│ frontend/src/views/xxx/Index.vue │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Step 2: 在 componentMap 中注册组件 │
|
||||||
|
│ frontend/src/utils/menu.ts │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Step 3: 在数据库 menus 表中添加菜单记录 │
|
||||||
|
├──────────────────────────────────────────────────────────────┤
|
||||||
|
│ Step 4: 在数据库 tenant_menus 表中关联租户 │
|
||||||
|
└──────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 详细步骤
|
||||||
|
|
||||||
|
### Step 1: 创建 Vue 组件文件
|
||||||
|
|
||||||
|
在 `frontend/src/views/` 目录下创建对应的 Vue 组件。
|
||||||
|
|
||||||
|
**目录结构规范:**
|
||||||
|
```
|
||||||
|
frontend/src/views/
|
||||||
|
├── workbench/ # 工作台模块
|
||||||
|
│ └── Index.vue
|
||||||
|
├── contests/ # 活动管理模块
|
||||||
|
│ ├── Index.vue # 活动列表
|
||||||
|
│ ├── Create.vue # 创建活动
|
||||||
|
│ ├── Detail.vue # 活动详情
|
||||||
|
│ ├── registrations/ # 报名管理
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ ├── works/ # 作品管理
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ └── ...
|
||||||
|
├── system/ # 系统管理模块
|
||||||
|
│ ├── users/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ ├── roles/
|
||||||
|
│ │ └── Index.vue
|
||||||
|
│ └── ...
|
||||||
|
└── your-module/ # 你的新模块
|
||||||
|
└── Index.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
**组件模板示例:**
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="page-container">
|
||||||
|
<h1>页面标题</h1>
|
||||||
|
<!-- 页面内容 -->
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
// 组件逻辑
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.page-container {
|
||||||
|
// 样式
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
### Step 2: 注册组件映射
|
||||||
|
|
||||||
|
在 `frontend/src/utils/menu.ts` 文件中的 `componentMap` 对象中添加组件映射。
|
||||||
|
|
||||||
|
**文件位置:** `frontend/src/utils/menu.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const componentMap: Record<string, () => Promise<any>> = {
|
||||||
|
// 工作台
|
||||||
|
"workbench/Index": () => import("@/views/workbench/Index.vue"),
|
||||||
|
|
||||||
|
// 活动管理模块
|
||||||
|
"contests/Index": () => import("@/views/contests/Index.vue"),
|
||||||
|
"contests/Create": () => import("@/views/contests/Create.vue"),
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// 系统管理模块
|
||||||
|
"system/users/Index": () => import("@/views/system/users/Index.vue"),
|
||||||
|
// ...
|
||||||
|
|
||||||
|
// ========== 新增组件映射 ==========
|
||||||
|
"your-module/Index": () => import("@/views/your-module/Index.vue"),
|
||||||
|
"your-module/Detail": () => import("@/views/your-module/Detail.vue"),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意事项:**
|
||||||
|
- Key 格式:`模块名/组件名`,不需要 `@/views/` 前缀和 `.vue` 后缀
|
||||||
|
- Value 格式:使用动态 import 函数
|
||||||
|
- 如果未注册,控制台会输出警告信息
|
||||||
|
|
||||||
|
### Step 3: 添加数据库菜单记录
|
||||||
|
|
||||||
|
在数据库的 `menus` 表中插入菜单记录。
|
||||||
|
|
||||||
|
**menus 表结构:**
|
||||||
|
| 字段 | 类型 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| id | int | 主键,自增 |
|
||||||
|
| name | varchar | 菜单名称(显示文本) |
|
||||||
|
| path | varchar | 路由路径,如 `/contests` |
|
||||||
|
| component | varchar | 组件路径,对应 componentMap 的 key |
|
||||||
|
| icon | varchar | 图标名称,使用 Ant Design 图标 |
|
||||||
|
| parent_id | int | 父菜单ID,顶级菜单为 NULL |
|
||||||
|
| sort | int | 排序序号 |
|
||||||
|
| permission | varchar | 权限标识,如 `contest:read` |
|
||||||
|
| tenant_id | int | 租户ID |
|
||||||
|
|
||||||
|
**SQL 示例:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 添加顶级菜单
|
||||||
|
INSERT INTO menus (name, path, component, icon, parent_id, sort, tenant_id)
|
||||||
|
VALUES ('新模块', '/your-module', 'your-module/Index', 'AppstoreOutlined', NULL, 10, 1);
|
||||||
|
|
||||||
|
-- 获取刚插入的菜单ID
|
||||||
|
SET @parent_id = LAST_INSERT_ID();
|
||||||
|
|
||||||
|
-- 添加子菜单
|
||||||
|
INSERT INTO menus (name, path, component, icon, parent_id, sort, tenant_id)
|
||||||
|
VALUES ('子页面1', '/your-module/sub1', 'your-module/Sub1', NULL, @parent_id, 1, 1);
|
||||||
|
|
||||||
|
INSERT INTO menus (name, path, component, icon, parent_id, sort, tenant_id)
|
||||||
|
VALUES ('子页面2', '/your-module/sub2', 'your-module/Sub2', NULL, @parent_id, 2, 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用图标名称:**
|
||||||
|
- `HomeOutlined` - 首页
|
||||||
|
- `AppstoreOutlined` - 应用
|
||||||
|
- `TrophyOutlined` - 奖杯/活动
|
||||||
|
- `TeamOutlined` - 团队
|
||||||
|
- `UserOutlined` - 用户
|
||||||
|
- `SettingOutlined` - 设置
|
||||||
|
- `FileOutlined` - 文件
|
||||||
|
- `FolderOutlined` - 文件夹
|
||||||
|
|
||||||
|
更多图标请参考:[Ant Design Icons](https://ant.design/components/icon-cn)
|
||||||
|
|
||||||
|
### Step 4: 关联租户菜单
|
||||||
|
|
||||||
|
在 `tenant_menus` 表中添加租户与菜单的关联关系。
|
||||||
|
|
||||||
|
**SQL 示例:**
|
||||||
|
|
||||||
|
```sql
|
||||||
|
-- 假设菜单ID为 30,租户ID为 1
|
||||||
|
INSERT INTO tenant_menus (tenant_id, menu_id) VALUES (1, 30);
|
||||||
|
|
||||||
|
-- 如果刚插入菜单,可以使用 LAST_INSERT_ID()
|
||||||
|
INSERT INTO tenant_menus (tenant_id, menu_id) VALUES (1, LAST_INSERT_ID());
|
||||||
|
|
||||||
|
-- 批量关联(关联多个租户)
|
||||||
|
INSERT INTO tenant_menus (tenant_id, menu_id) VALUES
|
||||||
|
(1, 30),
|
||||||
|
(2, 30),
|
||||||
|
(3, 30);
|
||||||
|
```
|
||||||
|
|
||||||
|
## 完整示例
|
||||||
|
|
||||||
|
假设要新增一个「公告管理」页面:
|
||||||
|
|
||||||
|
### 1. 创建组件文件
|
||||||
|
|
||||||
|
**文件:** `frontend/src/views/announcements/Index.vue`
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div>
|
||||||
|
<a-card title="公告管理">
|
||||||
|
<a-table :columns="columns" :data-source="data" />
|
||||||
|
</a-card>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ title: '标题', dataIndex: 'title' },
|
||||||
|
{ title: '发布时间', dataIndex: 'createdAt' },
|
||||||
|
{ title: '操作', key: 'action' },
|
||||||
|
]
|
||||||
|
|
||||||
|
const data = ref([])
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 注册组件
|
||||||
|
|
||||||
|
**文件:** `frontend/src/utils/menu.ts`
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const componentMap: Record<string, () => Promise<any>> = {
|
||||||
|
// ... 其他组件
|
||||||
|
"announcements/Index": () => import("@/views/announcements/Index.vue"),
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 添加菜单记录
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO menus (name, path, component, icon, parent_id, sort, permission, tenant_id)
|
||||||
|
VALUES ('公告管理', '/announcements', 'announcements/Index', 'NotificationOutlined', NULL, 5, 'announcement:read', 1);
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 关联租户
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO tenant_menus (tenant_id, menu_id) VALUES (1, LAST_INSERT_ID());
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 验证
|
||||||
|
|
||||||
|
1. 重新登录系统(清除缓存)
|
||||||
|
2. 检查侧边栏是否显示新菜单
|
||||||
|
3. 点击菜单验证页面是否正常加载
|
||||||
|
|
||||||
|
## 常见问题
|
||||||
|
|
||||||
|
### Q1: 菜单不显示?
|
||||||
|
|
||||||
|
检查以下几点:
|
||||||
|
1. `tenant_menus` 表是否已关联当前租户
|
||||||
|
2. 用户角色是否有该菜单的权限
|
||||||
|
3. 清除浏览器缓存后重新登录
|
||||||
|
|
||||||
|
### Q2: 点击菜单报 404?
|
||||||
|
|
||||||
|
检查以下几点:
|
||||||
|
1. `componentMap` 是否已注册该组件
|
||||||
|
2. 组件文件路径是否正确
|
||||||
|
3. 查看控制台是否有警告信息
|
||||||
|
|
||||||
|
### Q3: 控制台警告「组件路径未在 componentMap 中定义」?
|
||||||
|
|
||||||
|
在 `menu.ts` 的 `componentMap` 中添加对应的组件映射。
|
||||||
|
|
||||||
|
### Q4: 如何添加需要权限的页面?
|
||||||
|
|
||||||
|
在 `menus` 表的 `permission` 字段设置权限标识,如 `announcement:read`,然后确保用户角色拥有该权限。
|
||||||
|
|
||||||
|
## 相关文件
|
||||||
|
|
||||||
|
| 文件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| `frontend/src/utils/menu.ts` | 组件映射、菜单转换工具 |
|
||||||
|
| `frontend/src/router/index.ts` | 路由配置、动态路由加载 |
|
||||||
|
| `frontend/src/stores/auth.ts` | 用户认证、菜单数据存储 |
|
||||||
|
| `frontend/src/layouts/BasicLayout.vue` | 主布局、侧边栏菜单渲染 |
|
||||||
226
java-frontend/docs/theme-customization.md
Normal file
226
java-frontend/docs/theme-customization.md
Normal file
@ -0,0 +1,226 @@
|
|||||||
|
# 主题色修改指南
|
||||||
|
|
||||||
|
本文档说明如何修改 Ant Design Vue 的主题色。
|
||||||
|
|
||||||
|
## 📋 概述
|
||||||
|
|
||||||
|
项目使用 Ant Design Vue 4.x 作为 UI 组件库,主题色配置主要通过两个文件实现:
|
||||||
|
|
||||||
|
1. **`src/styles/theme.scss`** - CSS 变量定义,用于全局样式和侧边栏等自定义组件
|
||||||
|
2. **`src/App.vue`** - Ant Design Vue 的 ConfigProvider 配置,用于组件库的主题色
|
||||||
|
|
||||||
|
## 📁 需要修改的文件
|
||||||
|
|
||||||
|
### 1. `src/styles/theme.scss`
|
||||||
|
|
||||||
|
该文件定义了 CSS 变量,用于:
|
||||||
|
|
||||||
|
- 全局主题色变量
|
||||||
|
- 侧边栏菜单样式
|
||||||
|
- 自定义组件的主题色
|
||||||
|
|
||||||
|
### 2. `src/App.vue`
|
||||||
|
|
||||||
|
该文件通过 `a-config-provider` 组件配置 Ant Design Vue 的主题色,影响所有 Ant Design Vue 组件的默认颜色。
|
||||||
|
|
||||||
|
## 🔧 修改步骤
|
||||||
|
|
||||||
|
### 步骤 1: 修改 `src/styles/theme.scss`
|
||||||
|
|
||||||
|
在 `:root` 选择器中修改以下 CSS 变量:
|
||||||
|
|
||||||
|
```scss
|
||||||
|
:root {
|
||||||
|
// 主色调
|
||||||
|
--ant-color-primary: #1890ff; // 主色
|
||||||
|
--ant-color-primary-hover: #40a9ff; // 悬停色(通常比主色浅)
|
||||||
|
--ant-color-primary-active: #096dd9; // 激活色(通常比主色深)
|
||||||
|
--ant-color-primary-bg: #e6f7ff; // 主色背景(浅色背景)
|
||||||
|
--ant-color-primary-bg-hover: #bae7ff; // 主色背景悬停
|
||||||
|
|
||||||
|
// 信息色(通常与主色一致)
|
||||||
|
--ant-color-info: #1890ff;
|
||||||
|
--ant-color-info-bg: #e6f7ff;
|
||||||
|
|
||||||
|
// 链接色(通常与主色一致)
|
||||||
|
--ant-color-link: #1890ff;
|
||||||
|
--ant-color-link-hover: #40a9ff;
|
||||||
|
--ant-color-link-active: #096dd9;
|
||||||
|
|
||||||
|
// 侧边栏相关颜色
|
||||||
|
--sidebar-menu-item-hover: #e6f7ff; // 菜单项悬停背景
|
||||||
|
--sidebar-menu-item-selected-bg: #e6f7ff; // 菜单项选中背景色
|
||||||
|
--sidebar-menu-text-selected: #1890ff; // 选中菜单文字颜色
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2: 修改 `src/App.vue`
|
||||||
|
|
||||||
|
在 `themeConfig` 对象中修改主题色配置:
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const themeConfig: ConfigProviderProps["theme"] = {
|
||||||
|
token: {
|
||||||
|
colorPrimary: "#1890ff", // 主色调
|
||||||
|
colorInfo: "#1890ff", // 信息色(通常与主色一致)
|
||||||
|
colorLink: "#1890ff", // 链接色(通常与主色一致)
|
||||||
|
borderRadius: 6, // 圆角(可选)
|
||||||
|
},
|
||||||
|
algorithm: undefined, // 使用默认算法
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🎨 颜色值说明
|
||||||
|
|
||||||
|
### 主色调(Primary)
|
||||||
|
|
||||||
|
- **主色(colorPrimary)**: 按钮、链接、选中状态等的主要颜色
|
||||||
|
- **悬停色(hover)**: 鼠标悬停时的颜色,通常比主色浅 10-20%
|
||||||
|
- **激活色(active)**: 点击时的颜色,通常比主色深 10-20%
|
||||||
|
- **背景色(bg)**: 选中项的背景色,通常是主色的 5-10% 透明度
|
||||||
|
|
||||||
|
### 颜色搭配建议
|
||||||
|
|
||||||
|
1. **悬停色**: 在主色的基础上增加亮度(HSL 的 L 值增加 10-15%)
|
||||||
|
2. **激活色**: 在主色的基础上降低亮度(HSL 的 L 值减少 10-15%)
|
||||||
|
3. **背景色**: 使用主色的浅色版本(透明度约 5-10%)
|
||||||
|
|
||||||
|
## 📝 常见主题色示例
|
||||||
|
|
||||||
|
### 拂晓蓝主题(Daybreak Blue)- 官方推荐
|
||||||
|
|
||||||
|
**拂晓蓝**是 Ant Design 官方的基础色板中的蓝色,代表"包容、科技、普惠"。这是 Ant Design Vue 的默认主题色。
|
||||||
|
|
||||||
|
参考文档:[Ant Design 色彩规范](https://ant.design/docs/spec/colors-cn#%E7%B3%BB%E7%BB%9F%E7%BA%A7%E8%89%B2%E5%BD%A9%E4%BD%93%E7%B3%BB)
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// theme.scss
|
||||||
|
// 拂晓蓝色板:blue-0 (#E6F4FF) 到 blue-9 (#001D66)
|
||||||
|
--ant-color-primary: #1890ff; // 主色 - blue-5(Ant Design Vue 4.x)
|
||||||
|
--ant-color-primary-hover: #40a9ff; // 悬停色 - blue-4
|
||||||
|
--ant-color-primary-active: #096dd9; // 激活色 - blue-6
|
||||||
|
--ant-color-primary-bg: #e6f7ff; // 主色背景 - blue-0(最浅)
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.vue
|
||||||
|
colorPrimary: "#1890ff" // 拂晓蓝主色
|
||||||
|
```
|
||||||
|
|
||||||
|
**完整的拂晓蓝色板**:
|
||||||
|
|
||||||
|
- blue-0: `#E6F4FF` (最浅)
|
||||||
|
- blue-1: `#BAE0FF`
|
||||||
|
- blue-2: `#91CAFF`
|
||||||
|
- blue-3: `#69B1FF`
|
||||||
|
- blue-4: `#4096FF`
|
||||||
|
- blue-5: `#1890ff` (主色,Ant Design Vue 4.x)
|
||||||
|
- blue-6: `#0958D9`
|
||||||
|
- blue-7: `#003EB3`
|
||||||
|
- blue-8: `#002C8C`
|
||||||
|
- blue-9: `#001D66` (最深)
|
||||||
|
|
||||||
|
### 橙色主题
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// theme.scss
|
||||||
|
--ant-color-primary: #ff7a00;
|
||||||
|
--ant-color-primary-hover: #ff9a2e;
|
||||||
|
--ant-color-primary-active: #d46b08;
|
||||||
|
--ant-color-primary-bg: #fff7e6;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.vue
|
||||||
|
colorPrimary: "#ff7a00"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 墨绿色主题
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// theme.scss
|
||||||
|
--ant-color-primary: #01412b;
|
||||||
|
--ant-color-primary-hover: #026b47;
|
||||||
|
--ant-color-primary-active: #013320;
|
||||||
|
--ant-color-primary-bg: #e2f0ed;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.vue
|
||||||
|
colorPrimary: "#01412b"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 紫色主题
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// theme.scss
|
||||||
|
--ant-color-primary: #722ed1;
|
||||||
|
--ant-color-primary-hover: #9254de;
|
||||||
|
--ant-color-primary-active: #531dab;
|
||||||
|
--ant-color-primary-bg: #f9f0ff;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.vue
|
||||||
|
colorPrimary: "#722ed1"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 红色主题
|
||||||
|
|
||||||
|
```scss
|
||||||
|
// theme.scss
|
||||||
|
--ant-color-primary: #f5222d;
|
||||||
|
--ant-color-primary-hover: #ff4d4f;
|
||||||
|
--ant-color-primary-active: #cf1322;
|
||||||
|
--ant-color-primary-bg: #fff1f0;
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// App.vue
|
||||||
|
colorPrimary: "#f5222d"
|
||||||
|
```
|
||||||
|
|
||||||
|
## ⚠️ 注意事项
|
||||||
|
|
||||||
|
1. **同步修改**: 必须同时修改 `theme.scss` 和 `App.vue` 两个文件,确保主题色一致
|
||||||
|
|
||||||
|
2. **颜色对比度**: 确保文字颜色与背景色有足够的对比度,符合无障碍访问标准(WCAG AA 级别)
|
||||||
|
|
||||||
|
3. **侧边栏颜色**: 如果修改了主色调,记得同步更新侧边栏相关的 CSS 变量:
|
||||||
|
- `--sidebar-menu-item-hover`
|
||||||
|
- `--sidebar-menu-item-selected-bg`
|
||||||
|
- `--sidebar-menu-text-selected`
|
||||||
|
|
||||||
|
4. **深色模式**: 如果项目支持深色模式,需要在 `[data-theme="dark"]` 选择器中定义深色模式的主题色
|
||||||
|
|
||||||
|
5. **浏览器缓存**: 修改后可能需要清除浏览器缓存或强制刷新(Ctrl+F5)才能看到效果
|
||||||
|
|
||||||
|
6. **颜色格式**: 使用十六进制颜色值(如 `#1890ff`),也可以使用 RGB/RGBA 格式
|
||||||
|
|
||||||
|
## 🔍 颜色工具推荐
|
||||||
|
|
||||||
|
- **Ant Design 色彩工具**: https://ant.design/docs/spec/colors-cn
|
||||||
|
- **Coolors**: https://coolors.co/ - 配色方案生成器
|
||||||
|
- **Adobe Color**: https://color.adobe.com/ - 颜色搭配工具
|
||||||
|
- **Material Design Color Tool**: https://material.io/resources/color/ - Material Design 配色工具
|
||||||
|
|
||||||
|
## 📚 相关文档
|
||||||
|
|
||||||
|
- [Ant Design Vue 主题定制](https://antdv.com/docs/vue/customize-theme-cn)
|
||||||
|
- [CSS 变量](https://developer.mozilla.org/zh-CN/docs/Web/CSS/Using_CSS_custom_properties)
|
||||||
|
|
||||||
|
## 🎯 快速修改模板
|
||||||
|
|
||||||
|
如果需要快速修改主题色,可以按照以下模板操作:
|
||||||
|
|
||||||
|
1. 选择主色调(例如:`#ff7a00`)
|
||||||
|
2. 计算悬停色(主色 + 亮度)
|
||||||
|
3. 计算激活色(主色 - 亮度)
|
||||||
|
4. 选择背景色(主色的浅色版本)
|
||||||
|
|
||||||
|
然后替换以下位置的值:
|
||||||
|
|
||||||
|
- `src/styles/theme.scss` 中的 `--ant-color-primary*` 变量
|
||||||
|
- `src/App.vue` 中的 `colorPrimary`、`colorInfo`、`colorLink` 值
|
||||||
|
|
||||||
|
修改完成后,重启开发服务器或刷新页面即可看到效果。
|
||||||
16
java-frontend/index.html
Normal file
16
java-frontend/index.html
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="zh-CN">
|
||||||
|
<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" />
|
||||||
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Nunito:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
|
||||||
|
<title>乐绘世界创想活动乐园</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5756
java-frontend/package-lock.json
generated
Normal file
5756
java-frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
44
java-frontend/package.json
Normal file
44
java-frontend/package.json
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
{
|
||||||
|
"name": "competition-management-frontend",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"compress:test": "node scripts/compress.cjs test",
|
||||||
|
"compress:prod": "node scripts/compress.cjs production",
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vue-tsc -b && vite build",
|
||||||
|
"build:test": "vite build --mode test",
|
||||||
|
"build:prod": "vite build --mode production",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
|
"@vee-validate/zod": "^4.12.4",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
|
"ant-design-vue": "^4.1.1",
|
||||||
|
"axios": "^1.6.7",
|
||||||
|
"dayjs": "^1.11.10",
|
||||||
|
"pinia": "^2.1.7",
|
||||||
|
"three": "^0.182.0",
|
||||||
|
"vee-validate": "^4.12.4",
|
||||||
|
"vue": "^3.4.21",
|
||||||
|
"vue-router": "^4.3.0",
|
||||||
|
"zod": "^3.22.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
|
"autoprefixer": "^10.4.18",
|
||||||
|
"eslint": "^8.57.0",
|
||||||
|
"eslint-plugin-vue": "^9.22.0",
|
||||||
|
"postcss": "^8.4.35",
|
||||||
|
"sass": "^1.71.1",
|
||||||
|
"tailwindcss": "^3.4.1",
|
||||||
|
"typescript": "^5.4.3",
|
||||||
|
"vite": "^5.1.6",
|
||||||
|
"vue-tsc": "^3.2.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
java-frontend/postcss.config.js
Normal file
7
java-frontend/postcss.config.js
Normal file
@ -0,0 +1,7 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
116
java-frontend/scripts/compress.cjs
Normal file
116
java-frontend/scripts/compress.cjs
Normal file
@ -0,0 +1,116 @@
|
|||||||
|
const { execSync } = require("child_process")
|
||||||
|
const path = require("path")
|
||||||
|
const fs = require("fs")
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 压缩前端打包文件
|
||||||
|
* 压缩包放在根目录下,文件名格式: competition-web-{env}-v{version}.tgz
|
||||||
|
*
|
||||||
|
* 用法:
|
||||||
|
* node scripts/compress.cjs test - 测试环境
|
||||||
|
* node scripts/compress.cjs production - 生产环境
|
||||||
|
*/
|
||||||
|
function compressFrontend() {
|
||||||
|
const rootDir = path.join(__dirname, "..")
|
||||||
|
const sourceDir = path.join(rootDir, "dist")
|
||||||
|
const outputDir = rootDir
|
||||||
|
|
||||||
|
// 获取环境参数
|
||||||
|
const env = process.argv[2]
|
||||||
|
if (!env || !["test", "production"].includes(env)) {
|
||||||
|
console.error("❌ 错误: 请指定环境参数")
|
||||||
|
console.error(" 用法: node scripts/compress.cjs <test|production>")
|
||||||
|
console.error(" 示例: node scripts/compress.cjs test")
|
||||||
|
console.error(" 示例: node scripts/compress.cjs production")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从 package.json 读取版本号
|
||||||
|
const packageJsonPath = path.join(rootDir, "package.json")
|
||||||
|
let version = "1.0.0"
|
||||||
|
try {
|
||||||
|
const packageJson = JSON.parse(fs.readFileSync(packageJsonPath, "utf-8"))
|
||||||
|
version = packageJson.version || "1.0.0"
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(`⚠️ 无法读取 package.json 版本号,使用默认版本: ${version}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查源目录是否存在
|
||||||
|
if (!fs.existsSync(sourceDir)) {
|
||||||
|
console.error("❌ 错误: 前端打包文件不存在")
|
||||||
|
console.error(` 路径: ${sourceDir}`)
|
||||||
|
console.error(
|
||||||
|
` 请先运行 pnpm build:${env === "test" ? "test" : ""} 构建前端项目`,
|
||||||
|
)
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除之前的所有压缩包
|
||||||
|
console.log("🧹 清理旧的压缩包...\n")
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(outputDir)
|
||||||
|
const oldZipFiles = files.filter(
|
||||||
|
(file) => file.startsWith("competition-web-") && file.endsWith(".tgz"),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (oldZipFiles.length > 0) {
|
||||||
|
oldZipFiles.forEach((file) => {
|
||||||
|
const filePath = path.join(outputDir, file)
|
||||||
|
try {
|
||||||
|
fs.unlinkSync(filePath)
|
||||||
|
console.log(` ✅ 已删除: ${file}`)
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(` ⚠️ 删除失败: ${file} - ${error.message}`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
console.log("")
|
||||||
|
} else {
|
||||||
|
console.log(" ℹ️ 没有找到旧的压缩包\n")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn(` ⚠️ 清理旧压缩包时出错: ${error.message}\n`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成文件名: competition-web-{env}-v{version}.tgz
|
||||||
|
const zipFileName = `competition-web-${env}-v${version}.tgz`
|
||||||
|
const zipFilePath = path.join(outputDir, zipFileName)
|
||||||
|
|
||||||
|
console.log("📦 开始压缩前端打包文件...\n")
|
||||||
|
console.log(` 环境: ${env}`)
|
||||||
|
console.log(` 版本: v${version}`)
|
||||||
|
console.log(` 源目录: ${sourceDir}`)
|
||||||
|
console.log(` 输出文件: ${zipFilePath}\n`)
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 使用相对路径,避免 Windows tar 路径问题
|
||||||
|
const tarCommand = `tar -czf "${zipFileName}" -C dist .`
|
||||||
|
|
||||||
|
execSync(tarCommand, {
|
||||||
|
cwd: rootDir,
|
||||||
|
stdio: "inherit",
|
||||||
|
shell: true,
|
||||||
|
env: { ...process.env },
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查文件是否创建成功
|
||||||
|
if (fs.existsSync(zipFilePath)) {
|
||||||
|
const stats = fs.statSync(zipFilePath)
|
||||||
|
const fileSizeMB = (stats.size / (1024 * 1024)).toFixed(2)
|
||||||
|
console.log(`\n✅ 压缩完成!`)
|
||||||
|
console.log(` 文件: ${zipFileName}`)
|
||||||
|
console.log(` 大小: ${fileSizeMB} MB`)
|
||||||
|
console.log(` 路径: ${zipFilePath}`)
|
||||||
|
} else {
|
||||||
|
throw new Error("压缩文件未生成")
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("\n❌ 压缩失败:", error.message)
|
||||||
|
console.error("\n提示:")
|
||||||
|
console.error(" 1. 确保已安装tar命令 (Windows 10+内置支持)")
|
||||||
|
console.error(" 2. 确保有足够的磁盘空间")
|
||||||
|
console.error(" 3. 确保输出目录有写入权限")
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compressFrontend()
|
||||||
98
java-frontend/src/App.vue
Normal file
98
java-frontend/src/App.vue
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<template>
|
||||||
|
<a-config-provider :theme="themeConfig">
|
||||||
|
<router-view />
|
||||||
|
</a-config-provider>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ConfigProviderProps } from "ant-design-vue"
|
||||||
|
|
||||||
|
// 乐绘世界创想活动乐园 — 主题配置
|
||||||
|
// 风格:活泼、艺术、少儿绘本创作
|
||||||
|
const themeConfig: ConfigProviderProps["theme"] = {
|
||||||
|
token: {
|
||||||
|
// 色彩
|
||||||
|
colorPrimary: "#6366F1",
|
||||||
|
colorSuccess: "#10B981",
|
||||||
|
colorError: "#F43F5E",
|
||||||
|
colorWarning: "#F59E0B",
|
||||||
|
colorInfo: "#6366F1",
|
||||||
|
colorLink: "#6366F1",
|
||||||
|
|
||||||
|
// 字体
|
||||||
|
fontFamily:
|
||||||
|
"'Nunito', -apple-system, BlinkMacSystemFont, 'PingFang SC', 'Hiragino Sans GB', 'Microsoft YaHei', sans-serif",
|
||||||
|
fontSize: 14,
|
||||||
|
|
||||||
|
// 圆角
|
||||||
|
borderRadius: 10,
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
borderRadiusSM: 8,
|
||||||
|
|
||||||
|
// 阴影
|
||||||
|
boxShadow:
|
||||||
|
"0 1px 3px rgba(99,102,241,0.06), 0 4px 12px rgba(99,102,241,0.04)",
|
||||||
|
boxShadowSecondary:
|
||||||
|
"0 4px 16px rgba(99,102,241,0.10), 0 8px 32px rgba(99,102,241,0.06)",
|
||||||
|
|
||||||
|
// 背景与边框
|
||||||
|
colorBgLayout: "#F8F7FC",
|
||||||
|
colorBgContainer: "#FFFFFF",
|
||||||
|
colorBorder: "#E5E1F5",
|
||||||
|
colorBorderSecondary: "#F0ECF9",
|
||||||
|
|
||||||
|
// 文字
|
||||||
|
colorText: "#1E1B4B",
|
||||||
|
colorTextSecondary: "#6B7280",
|
||||||
|
colorTextTertiary: "#9CA3AF",
|
||||||
|
|
||||||
|
// 控件尺寸
|
||||||
|
controlHeight: 38,
|
||||||
|
controlHeightLG: 44,
|
||||||
|
controlHeightSM: 32,
|
||||||
|
},
|
||||||
|
components: {
|
||||||
|
Button: {
|
||||||
|
borderRadius: 10,
|
||||||
|
controlHeight: 38,
|
||||||
|
fontWeight: 600,
|
||||||
|
},
|
||||||
|
Card: {
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
},
|
||||||
|
Table: {
|
||||||
|
headerBg: "#F5F3FF",
|
||||||
|
headerColor: "#6B7280",
|
||||||
|
rowHoverBg: "#FAF9FE",
|
||||||
|
borderColor: "#F0ECF9",
|
||||||
|
},
|
||||||
|
Input: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Select: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Tag: {
|
||||||
|
borderRadiusSM: 20,
|
||||||
|
},
|
||||||
|
Menu: {
|
||||||
|
itemBorderRadius: 12,
|
||||||
|
itemHeight: 44,
|
||||||
|
subMenuItemBg: "transparent",
|
||||||
|
itemSelectedBg: "rgba(99, 102, 241, 0.10)",
|
||||||
|
itemSelectedColor: "#6366F1",
|
||||||
|
itemHoverBg: "rgba(99, 102, 241, 0.06)",
|
||||||
|
itemHoverColor: "#6366F1",
|
||||||
|
},
|
||||||
|
DatePicker: {
|
||||||
|
borderRadius: 10,
|
||||||
|
},
|
||||||
|
Modal: {
|
||||||
|
borderRadiusLG: 16,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
algorithm: undefined,
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss"></style>
|
||||||
119
java-frontend/src/api/ai-3d.ts
Normal file
119
java-frontend/src/api/ai-3d.ts
Normal file
@ -0,0 +1,119 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams } from "@/types/api";
|
||||||
|
|
||||||
|
// ==================== AI 3D 任务相关类型 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务状态
|
||||||
|
*/
|
||||||
|
export type AI3DTaskStatus =
|
||||||
|
| "pending"
|
||||||
|
| "processing"
|
||||||
|
| "completed"
|
||||||
|
| "failed"
|
||||||
|
| "timeout";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务输入类型
|
||||||
|
*/
|
||||||
|
export type AI3DInputType = "text" | "image";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* AI 3D 任务
|
||||||
|
*/
|
||||||
|
export interface AI3DTask {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
userId: number;
|
||||||
|
inputType: AI3DInputType;
|
||||||
|
inputContent: string;
|
||||||
|
status: AI3DTaskStatus;
|
||||||
|
resultUrl?: string;
|
||||||
|
previewUrl?: string;
|
||||||
|
// 多结果支持(文生3D会生成4个不同角度的模型)
|
||||||
|
resultUrls?: string[];
|
||||||
|
previewUrls?: string[];
|
||||||
|
errorMessage?: string;
|
||||||
|
externalTaskId?: string;
|
||||||
|
retryCount: number;
|
||||||
|
createTime: string;
|
||||||
|
completeTime?: string;
|
||||||
|
// 队列位置(仅 pending 状态时返回)
|
||||||
|
queuePosition?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 模型生成类型
|
||||||
|
*/
|
||||||
|
export type AI3DGenerateType = "Normal" | "LowPoly" | "Geometry" | "Sketch";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建任务参数
|
||||||
|
*/
|
||||||
|
export interface CreateAI3DTaskParams {
|
||||||
|
inputType: AI3DInputType;
|
||||||
|
inputContent: string;
|
||||||
|
/** 模型生成类型:Normal-带纹理, LowPoly-低多边形, Geometry-白模, Sketch-草图 */
|
||||||
|
generateType?: AI3DGenerateType;
|
||||||
|
/** 模型面数:10000-1500000,默认500000 */
|
||||||
|
faceCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 查询任务参数
|
||||||
|
*/
|
||||||
|
export interface QueryAI3DTaskParams extends PaginationParams {
|
||||||
|
status?: AI3DTaskStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 任务列表响应
|
||||||
|
*/
|
||||||
|
export interface AI3DTaskListResponse {
|
||||||
|
list: AI3DTask[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 接口 ====================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 创建生成任务
|
||||||
|
* POST /api/ai-3d/generate
|
||||||
|
*/
|
||||||
|
export function createAI3DTask(data: CreateAI3DTaskParams) {
|
||||||
|
return request.post<AI3DTask>("/ai-3d/generate", data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务列表
|
||||||
|
* GET /api/ai-3d/tasks
|
||||||
|
*/
|
||||||
|
export function getAI3DTasks(params?: QueryAI3DTaskParams) {
|
||||||
|
return request.get<AI3DTaskListResponse>("/ai-3d/tasks", { params });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 获取任务详情
|
||||||
|
* GET /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
export function getAI3DTask(id: number) {
|
||||||
|
return request.get<AI3DTask>(`/ai-3d/tasks/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重试任务
|
||||||
|
* POST /api/ai-3d/tasks/:id/retry
|
||||||
|
*/
|
||||||
|
export function retryAI3DTask(id: number) {
|
||||||
|
return request.post<AI3DTask>(`/ai-3d/tasks/${id}/retry`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 删除任务
|
||||||
|
* DELETE /api/ai-3d/tasks/:id
|
||||||
|
*/
|
||||||
|
export function deleteAI3DTask(id: number) {
|
||||||
|
return request.delete(`/ai-3d/tasks/${id}`);
|
||||||
|
}
|
||||||
23
java-frontend/src/api/auth.ts
Normal file
23
java-frontend/src/api/auth.ts
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { LoginForm, LoginResponse, User } from "@/types/auth";
|
||||||
|
|
||||||
|
export const authApi = {
|
||||||
|
login: async (data: LoginForm): Promise<LoginResponse> => {
|
||||||
|
const response = await request.post("/auth/login", data);
|
||||||
|
return response as unknown as LoginResponse;
|
||||||
|
},
|
||||||
|
|
||||||
|
logout: async (): Promise<void> => {
|
||||||
|
await request.post("/auth/logout");
|
||||||
|
},
|
||||||
|
|
||||||
|
getUserInfo: async (): Promise<User> => {
|
||||||
|
const response = await request.get("/auth/user-info");
|
||||||
|
return response as unknown as User;
|
||||||
|
},
|
||||||
|
|
||||||
|
refreshToken: async (): Promise<{ token: string }> => {
|
||||||
|
const response = await request.post("/auth/refresh-token");
|
||||||
|
return response as unknown as { token: string };
|
||||||
|
},
|
||||||
|
};
|
||||||
91
java-frontend/src/api/classes.ts
Normal file
91
java-frontend/src/api/classes.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Class {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
gradeId: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: number; // 1-行政班级,2-兴趣班
|
||||||
|
capacity?: number;
|
||||||
|
description?: string;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
grade?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
_count?: {
|
||||||
|
students: number;
|
||||||
|
studentInterestClasses: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateClassForm {
|
||||||
|
gradeId: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: number;
|
||||||
|
capacity?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateClassForm {
|
||||||
|
gradeId?: number;
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
type?: number;
|
||||||
|
capacity?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取班级列表
|
||||||
|
export async function getClassesList(
|
||||||
|
params: PaginationParams & { gradeId?: number; type?: number }
|
||||||
|
): Promise<PaginationResponse<Class>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Class>>("/classes", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个班级详情
|
||||||
|
export async function getClassDetail(id: number): Promise<Class> {
|
||||||
|
const response = await request.get<any, Class>(`/classes/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建班级
|
||||||
|
export async function createClass(data: CreateClassForm): Promise<Class> {
|
||||||
|
const response = await request.post<any, Class>("/classes", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新班级
|
||||||
|
export async function updateClass(
|
||||||
|
id: number,
|
||||||
|
data: UpdateClassForm
|
||||||
|
): Promise<Class> {
|
||||||
|
const response = await request.patch<any, Class>(`/classes/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除班级
|
||||||
|
export async function deleteClass(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/classes/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const classesApi = {
|
||||||
|
getList: getClassesList,
|
||||||
|
getDetail: getClassDetail,
|
||||||
|
create: createClass,
|
||||||
|
update: updateClass,
|
||||||
|
delete: deleteClass,
|
||||||
|
};
|
||||||
|
|
||||||
88
java-frontend/src/api/config.ts
Normal file
88
java-frontend/src/api/config.ts
Normal file
@ -0,0 +1,88 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Config {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateConfigForm {
|
||||||
|
key: string;
|
||||||
|
value: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateConfigForm {
|
||||||
|
key?: string;
|
||||||
|
value?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取配置列表
|
||||||
|
export async function getConfigsList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Config>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Config>>(
|
||||||
|
"/sys-config/page",
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个配置详情
|
||||||
|
export async function getConfigDetail(id: number): Promise<Config> {
|
||||||
|
const response = await request.get<any, Config>(`/sys-config/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据key获取配置
|
||||||
|
export async function getConfigByKey(key: string): Promise<Config> {
|
||||||
|
const response = await request.get<any, Config>(`/sys-config/key/${key}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建配置
|
||||||
|
export async function createConfig(data: CreateConfigForm): Promise<Config> {
|
||||||
|
const response = await request.postForm<any, Config>("/sys-config", {
|
||||||
|
configKey: data.key,
|
||||||
|
configValue: data.value,
|
||||||
|
description: data.description,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新配置
|
||||||
|
export async function updateConfig(
|
||||||
|
id: number,
|
||||||
|
data: UpdateConfigForm
|
||||||
|
): Promise<Config> {
|
||||||
|
const response = await request.putForm<any, Config>(`/sys-config/${id}`, {
|
||||||
|
configValue: data.value,
|
||||||
|
description: data.description,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除配置
|
||||||
|
export async function deleteConfig(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/sys-config/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 configApi 对象
|
||||||
|
export const configApi = {
|
||||||
|
getList: getConfigsList,
|
||||||
|
getDetail: getConfigDetail,
|
||||||
|
getByKey: getConfigByKey,
|
||||||
|
create: createConfig,
|
||||||
|
update: updateConfig,
|
||||||
|
delete: deleteConfig,
|
||||||
|
};
|
||||||
|
|
||||||
1486
java-frontend/src/api/contests.ts
Normal file
1486
java-frontend/src/api/contests.ts
Normal file
File diff suppressed because it is too large
Load Diff
91
java-frontend/src/api/departments.ts
Normal file
91
java-frontend/src/api/departments.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Department {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
description?: string;
|
||||||
|
sort: number;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
parent?: Department;
|
||||||
|
children?: Department[];
|
||||||
|
_count?: {
|
||||||
|
teachers: number;
|
||||||
|
children: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDepartmentForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
parentId?: number;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDepartmentForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
parentId?: number | null;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取部门列表
|
||||||
|
export async function getDepartmentsList(
|
||||||
|
params: PaginationParams & { parentId?: number }
|
||||||
|
): Promise<PaginationResponse<Department>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Department>>("/departments", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取部门树
|
||||||
|
export async function getDepartmentsTree(): Promise<Department[]> {
|
||||||
|
const response = await request.get<any, Department[]>("/departments/tree");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个部门详情
|
||||||
|
export async function getDepartmentDetail(id: number): Promise<Department> {
|
||||||
|
const response = await request.get<any, Department>(`/departments/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建部门
|
||||||
|
export async function createDepartment(data: CreateDepartmentForm): Promise<Department> {
|
||||||
|
const response = await request.post<any, Department>("/departments", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新部门
|
||||||
|
export async function updateDepartment(
|
||||||
|
id: number,
|
||||||
|
data: UpdateDepartmentForm
|
||||||
|
): Promise<Department> {
|
||||||
|
const response = await request.patch<any, Department>(`/departments/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除部门
|
||||||
|
export async function deleteDepartment(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/departments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const departmentsApi = {
|
||||||
|
getList: getDepartmentsList,
|
||||||
|
getTree: getDepartmentsTree,
|
||||||
|
getDetail: getDepartmentDetail,
|
||||||
|
create: createDepartment,
|
||||||
|
update: updateDepartment,
|
||||||
|
delete: deleteDepartment,
|
||||||
|
};
|
||||||
|
|
||||||
130
java-frontend/src/api/dict.ts
Normal file
130
java-frontend/src/api/dict.ts
Normal file
@ -0,0 +1,130 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Dict {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
items?: DictItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DictItem {
|
||||||
|
id: number;
|
||||||
|
dictId: number;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sort: number;
|
||||||
|
validState: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateDictForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDictForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取字典列表
|
||||||
|
export async function getDictsList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Dict>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Dict>>("/dict/page", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个字典详情
|
||||||
|
export async function getDictDetail(id: number): Promise<Dict> {
|
||||||
|
const response = await request.get<any, Dict>(`/dict/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据编码获取字典
|
||||||
|
export async function getDictByCode(code: string): Promise<Dict> {
|
||||||
|
const response = await request.get<any, Dict>(`/dict/code/${code}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建字典
|
||||||
|
export async function createDict(data: CreateDictForm): Promise<Dict> {
|
||||||
|
const response = await request.post<any, Dict>("/dict", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字典
|
||||||
|
export async function updateDict(
|
||||||
|
id: number,
|
||||||
|
data: UpdateDictForm
|
||||||
|
): Promise<Dict> {
|
||||||
|
const response = await request.put<any, Dict>(`/dict/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除字典
|
||||||
|
export async function deleteDict(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/dict/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 dictApi 对象
|
||||||
|
export const dictApi = {
|
||||||
|
getList: getDictsList,
|
||||||
|
getDetail: getDictDetail,
|
||||||
|
getByCode: getDictByCode,
|
||||||
|
create: createDict,
|
||||||
|
update: updateDict,
|
||||||
|
delete: deleteDict,
|
||||||
|
};
|
||||||
|
|
||||||
|
// ==================== 字典项管理 ====================
|
||||||
|
|
||||||
|
export interface CreateDictItemForm {
|
||||||
|
dictId: number;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateDictItemForm {
|
||||||
|
label?: string;
|
||||||
|
value?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建字典项
|
||||||
|
export async function createDictItem(data: CreateDictItemForm): Promise<DictItem> {
|
||||||
|
const response = await request.post<any, DictItem>("/dict/item", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取字典项列表
|
||||||
|
export async function getDictItems(dictId: number): Promise<DictItem[]> {
|
||||||
|
const response = await request.get<any, DictItem[]>(`/dict/item/${dictId}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新字典项
|
||||||
|
export async function updateDictItem(
|
||||||
|
id: number,
|
||||||
|
data: UpdateDictItemForm
|
||||||
|
): Promise<DictItem> {
|
||||||
|
const response = await request.put<any, DictItem>(`/dict/item/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除字典项
|
||||||
|
export async function deleteDictItem(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/dict/item/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
78
java-frontend/src/api/grades.ts
Normal file
78
java-frontend/src/api/grades.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Grade {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
level: number;
|
||||||
|
description?: string;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
_count?: {
|
||||||
|
classes: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateGradeForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
level: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateGradeForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
level?: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取年级列表
|
||||||
|
export async function getGradesList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Grade>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Grade>>("/grades", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个年级详情
|
||||||
|
export async function getGradeDetail(id: number): Promise<Grade> {
|
||||||
|
const response = await request.get<any, Grade>(`/grades/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建年级
|
||||||
|
export async function createGrade(data: CreateGradeForm): Promise<Grade> {
|
||||||
|
const response = await request.post<any, Grade>("/grades", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新年级
|
||||||
|
export async function updateGrade(
|
||||||
|
id: number,
|
||||||
|
data: UpdateGradeForm
|
||||||
|
): Promise<Grade> {
|
||||||
|
const response = await request.patch<any, Grade>(`/grades/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除年级
|
||||||
|
export async function deleteGrade(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/grades/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const gradesApi = {
|
||||||
|
getList: getGradesList,
|
||||||
|
getDetail: getGradeDetail,
|
||||||
|
create: createGrade,
|
||||||
|
update: updateGrade,
|
||||||
|
delete: deleteGrade,
|
||||||
|
};
|
||||||
|
|
||||||
415
java-frontend/src/api/homework.ts
Normal file
415
java-frontend/src/api/homework.ts
Normal file
@ -0,0 +1,415 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
// ==================== 作业相关类型 ====================
|
||||||
|
export interface Homework {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
name: string;
|
||||||
|
content?: string;
|
||||||
|
status: "unpublished" | "published";
|
||||||
|
publishTime?: string;
|
||||||
|
submitStartTime: string;
|
||||||
|
submitEndTime: string;
|
||||||
|
attachments?: HomeworkAttachment[];
|
||||||
|
publishScope?: number[];
|
||||||
|
publishScopeNames?: string[];
|
||||||
|
reviewRuleId?: number;
|
||||||
|
reviewRule?: HomeworkReviewRule;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
validState?: number;
|
||||||
|
_count?: {
|
||||||
|
submissions: number;
|
||||||
|
};
|
||||||
|
// 学生端:我的提交记录
|
||||||
|
submission?: {
|
||||||
|
id: number;
|
||||||
|
workName: string;
|
||||||
|
submitTime: string;
|
||||||
|
totalScore?: number;
|
||||||
|
} | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface HomeworkAttachment {
|
||||||
|
fileName: string;
|
||||||
|
fileUrl: string;
|
||||||
|
size?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateHomeworkForm {
|
||||||
|
name: string;
|
||||||
|
content?: string;
|
||||||
|
submitStartTime: string;
|
||||||
|
submitEndTime: string;
|
||||||
|
attachments?: HomeworkAttachment[];
|
||||||
|
publishScope?: number[];
|
||||||
|
reviewRuleId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateHomeworkForm extends Partial<CreateHomeworkForm> {}
|
||||||
|
|
||||||
|
export interface QueryHomeworkParams extends PaginationParams {
|
||||||
|
name?: string;
|
||||||
|
status?: string;
|
||||||
|
submitStartTime?: string;
|
||||||
|
submitEndTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 提交记录相关类型 ====================
|
||||||
|
export interface HomeworkSubmission {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
homeworkId: number;
|
||||||
|
studentId: number;
|
||||||
|
workNo?: string;
|
||||||
|
workName: string;
|
||||||
|
workDescription?: string;
|
||||||
|
files?: any[];
|
||||||
|
attachments?: any[];
|
||||||
|
submitTime: string;
|
||||||
|
status: "pending" | "reviewed" | "rejected";
|
||||||
|
totalScore?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
validState?: number;
|
||||||
|
homework?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
reviewRuleId?: number;
|
||||||
|
reviewRule?: HomeworkReviewRule;
|
||||||
|
};
|
||||||
|
student?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
student?: {
|
||||||
|
studentNo?: string;
|
||||||
|
class?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
grade?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
};
|
||||||
|
scores?: HomeworkScore[];
|
||||||
|
_count?: {
|
||||||
|
scores: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QuerySubmissionParams extends PaginationParams {
|
||||||
|
homeworkId?: number;
|
||||||
|
workNo?: string;
|
||||||
|
workName?: string;
|
||||||
|
studentAccount?: string;
|
||||||
|
studentName?: string;
|
||||||
|
status?: string;
|
||||||
|
classIds?: number[];
|
||||||
|
gradeId?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评审规则相关类型 ====================
|
||||||
|
export interface HomeworkReviewRule {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
criteria: ReviewCriterion[];
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
validState?: number;
|
||||||
|
homeworks?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ReviewCriterion {
|
||||||
|
name: string;
|
||||||
|
maxScore: number;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateReviewRuleForm {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
criteria: ReviewCriterion[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 评分相关类型 ====================
|
||||||
|
export interface HomeworkScore {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
submissionId: number;
|
||||||
|
reviewerId: number;
|
||||||
|
dimensionScores: DimensionScore[];
|
||||||
|
totalScore: number;
|
||||||
|
comments?: string;
|
||||||
|
scoreTime: string;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
validState?: number;
|
||||||
|
reviewer?: {
|
||||||
|
id: number;
|
||||||
|
nickname: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DimensionScore {
|
||||||
|
name: string;
|
||||||
|
score: number;
|
||||||
|
maxScore: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateScoreForm {
|
||||||
|
submissionId: number;
|
||||||
|
dimensionScores: DimensionScore[];
|
||||||
|
comments?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 班级树相关类型 ====================
|
||||||
|
export interface ClassTreeNode {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
type: "grade" | "class";
|
||||||
|
gradeId?: number;
|
||||||
|
classId?: number;
|
||||||
|
children?: ClassTreeNode[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== API 函数 ====================
|
||||||
|
|
||||||
|
// 作业管理
|
||||||
|
export const homeworksApi = {
|
||||||
|
// 获取作业列表(教师端)
|
||||||
|
getList: async (
|
||||||
|
params: QueryHomeworkParams
|
||||||
|
): Promise<PaginationResponse<Homework>> => {
|
||||||
|
const response = await request.get<any, PaginationResponse<Homework>>(
|
||||||
|
"/homework/homeworks",
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取我的作业列表(学生端)
|
||||||
|
getMyList: async (
|
||||||
|
params: QueryHomeworkParams
|
||||||
|
): Promise<PaginationResponse<Homework>> => {
|
||||||
|
const response = await request.get<any, PaginationResponse<Homework>>(
|
||||||
|
"/homework/homeworks/my",
|
||||||
|
{ params }
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取作业详情
|
||||||
|
getDetail: async (id: number): Promise<Homework> => {
|
||||||
|
const response = await request.get<any, Homework>(
|
||||||
|
`/homework/homeworks/${id}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建作业
|
||||||
|
create: async (data: CreateHomeworkForm): Promise<Homework> => {
|
||||||
|
const response = await request.post<any, Homework>(
|
||||||
|
"/homework/homeworks",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新作业
|
||||||
|
update: async (id: number, data: UpdateHomeworkForm): Promise<Homework> => {
|
||||||
|
const response = await request.patch<any, Homework>(
|
||||||
|
`/homework/homeworks/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 发布作业
|
||||||
|
publish: async (id: number, publishScope: number[]): Promise<Homework> => {
|
||||||
|
const response = await request.post<any, Homework>(
|
||||||
|
`/homework/homeworks/${id}/publish`,
|
||||||
|
{ publishScope }
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 取消发布作业
|
||||||
|
unpublish: async (id: number): Promise<Homework> => {
|
||||||
|
const response = await request.post<any, Homework>(
|
||||||
|
`/homework/homeworks/${id}/unpublish`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除作业
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
return await request.delete<any, void>(`/homework/homeworks/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 学生提交作业表单
|
||||||
|
export interface SubmitHomeworkForm {
|
||||||
|
homeworkId: number;
|
||||||
|
workName: string;
|
||||||
|
workDescription?: string;
|
||||||
|
files?: HomeworkAttachment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交记录管理
|
||||||
|
export const submissionsApi = {
|
||||||
|
// 获取提交记录列表
|
||||||
|
getList: async (
|
||||||
|
params: QuerySubmissionParams
|
||||||
|
): Promise<PaginationResponse<HomeworkSubmission>> => {
|
||||||
|
const response = await request.get<
|
||||||
|
any,
|
||||||
|
PaginationResponse<HomeworkSubmission>
|
||||||
|
>("/homework/submissions", { params });
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取提交记录详情
|
||||||
|
getDetail: async (id: number): Promise<HomeworkSubmission> => {
|
||||||
|
const response = await request.get<any, HomeworkSubmission>(
|
||||||
|
`/homework/submissions/${id}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取班级树结构
|
||||||
|
getClassTree: async (): Promise<ClassTreeNode[]> => {
|
||||||
|
const response = await request.get<any, ClassTreeNode[]>(
|
||||||
|
"/homework/submissions/class-tree"
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取当前用户对某作业的提交记录
|
||||||
|
getMySubmission: async (homeworkId: number): Promise<HomeworkSubmission> => {
|
||||||
|
const response = await request.get<any, HomeworkSubmission>(
|
||||||
|
`/homework/submissions/my/${homeworkId}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 提交作业
|
||||||
|
submit: async (data: SubmitHomeworkForm): Promise<HomeworkSubmission> => {
|
||||||
|
const response = await request.post<any, HomeworkSubmission>(
|
||||||
|
"/homework/submissions",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 评审规则管理
|
||||||
|
export const reviewRulesApi = {
|
||||||
|
// 获取评审规则列表
|
||||||
|
getList: async (params?: {
|
||||||
|
name?: string;
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
}): Promise<{
|
||||||
|
list: HomeworkReviewRule[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}> => {
|
||||||
|
const response = await request.get<
|
||||||
|
any,
|
||||||
|
{
|
||||||
|
list: HomeworkReviewRule[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
>("/homework/review-rules", { params });
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取所有可用的评审规则(用于选择)
|
||||||
|
getForSelect: async (): Promise<HomeworkReviewRule[]> => {
|
||||||
|
const response = await request.get<any, HomeworkReviewRule[]>(
|
||||||
|
"/homework/review-rules/select"
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 获取评审规则详情
|
||||||
|
getDetail: async (id: number): Promise<HomeworkReviewRule> => {
|
||||||
|
const response = await request.get<any, HomeworkReviewRule>(
|
||||||
|
`/homework/review-rules/${id}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 创建评审规则
|
||||||
|
create: async (data: CreateReviewRuleForm): Promise<HomeworkReviewRule> => {
|
||||||
|
const response = await request.post<any, HomeworkReviewRule>(
|
||||||
|
"/homework/review-rules",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 更新评审规则
|
||||||
|
update: async (
|
||||||
|
id: number,
|
||||||
|
data: Partial<CreateReviewRuleForm>
|
||||||
|
): Promise<HomeworkReviewRule> => {
|
||||||
|
const response = await request.patch<any, HomeworkReviewRule>(
|
||||||
|
`/homework/review-rules/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 删除评审规则
|
||||||
|
delete: async (id: number): Promise<void> => {
|
||||||
|
await request.delete(`/homework/review-rules/${id}`);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
// 评分管理
|
||||||
|
export const scoresApi = {
|
||||||
|
// 提交评分
|
||||||
|
create: async (data: CreateScoreForm): Promise<HomeworkScore> => {
|
||||||
|
const response = await request.post<any, HomeworkScore>(
|
||||||
|
"/homework/scores",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 标记作品违规
|
||||||
|
markViolation: async (
|
||||||
|
submissionId: number,
|
||||||
|
reason?: string
|
||||||
|
): Promise<void> => {
|
||||||
|
await request.post(`/homework/scores/${submissionId}/violation`, {
|
||||||
|
reason,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
|
||||||
|
// 重置评分
|
||||||
|
resetScore: async (submissionId: number): Promise<void> => {
|
||||||
|
await request.post(`/homework/scores/${submissionId}/reset`);
|
||||||
|
},
|
||||||
|
};
|
||||||
156
java-frontend/src/api/judges-management.ts
Normal file
156
java-frontend/src/api/judges-management.ts
Normal file
@ -0,0 +1,156 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Judge {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
gender?: 'male' | 'female';
|
||||||
|
avatar?: string;
|
||||||
|
status?: 'enabled' | 'disabled';
|
||||||
|
organization?: string;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
roles?: Array<{
|
||||||
|
id: number;
|
||||||
|
role: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
contestJudges?: Array<{
|
||||||
|
contest: {
|
||||||
|
id: number;
|
||||||
|
contestName: string;
|
||||||
|
status: string;
|
||||||
|
contestState?: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface QueryJudgeParams extends PaginationParams {
|
||||||
|
organization?: string;
|
||||||
|
nickname?: string;
|
||||||
|
username?: string;
|
||||||
|
status?: 'enabled' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateJudgeForm {
|
||||||
|
nickname: string;
|
||||||
|
gender: 'male' | 'female';
|
||||||
|
organization: string;
|
||||||
|
phone: string;
|
||||||
|
password: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
status?: 'enabled' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateJudgeForm {
|
||||||
|
nickname?: string;
|
||||||
|
gender?: 'male' | 'female';
|
||||||
|
organization?: string;
|
||||||
|
phone?: string;
|
||||||
|
password?: string;
|
||||||
|
username?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
status?: 'enabled' | 'disabled';
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JudgeListResponse {
|
||||||
|
list: Judge[];
|
||||||
|
total: number;
|
||||||
|
page: number;
|
||||||
|
pageSize: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取评委列表
|
||||||
|
export async function getJudgesList(
|
||||||
|
params: QueryJudgeParams
|
||||||
|
): Promise<JudgeListResponse> {
|
||||||
|
const response = await request.get<any, JudgeListResponse>(
|
||||||
|
"/judges-management",
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个评委详情
|
||||||
|
export async function getJudgeDetail(id: number): Promise<Judge> {
|
||||||
|
const response = await request.get<any, Judge>(`/judges-management/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建评委
|
||||||
|
export async function createJudge(data: CreateJudgeForm): Promise<Judge> {
|
||||||
|
const response = await request.post<any, Judge>("/judges-management", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新评委
|
||||||
|
export async function updateJudge(
|
||||||
|
id: number,
|
||||||
|
data: UpdateJudgeForm
|
||||||
|
): Promise<Judge> {
|
||||||
|
const response = await request.patch<any, Judge>(
|
||||||
|
`/judges-management/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除评委
|
||||||
|
export async function deleteJudge(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/judges-management/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 冻结评委
|
||||||
|
export async function freezeJudge(id: number): Promise<Judge> {
|
||||||
|
const response = await request.patch<any, Judge>(
|
||||||
|
`/judges-management/${id}/freeze`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解冻评委
|
||||||
|
export async function unfreezeJudge(id: number): Promise<Judge> {
|
||||||
|
const response = await request.patch<any, Judge>(
|
||||||
|
`/judges-management/${id}/unfreeze`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除评委
|
||||||
|
export async function batchDeleteJudges(ids: number[]): Promise<void> {
|
||||||
|
return await request.post<any, void>("/judges-management/batch-delete", {
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 judgesManagementApi 对象
|
||||||
|
export const judgesManagementApi = {
|
||||||
|
getList: getJudgesList,
|
||||||
|
getDetail: getJudgeDetail,
|
||||||
|
create: createJudge,
|
||||||
|
update: updateJudge,
|
||||||
|
delete: deleteJudge,
|
||||||
|
freeze: freezeJudge,
|
||||||
|
unfreeze: unfreezeJudge,
|
||||||
|
batchDelete: batchDeleteJudges,
|
||||||
|
};
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
90
java-frontend/src/api/logs.ts
Normal file
90
java-frontend/src/api/logs.ts
Normal file
@ -0,0 +1,90 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Log {
|
||||||
|
id: number;
|
||||||
|
userId?: number;
|
||||||
|
action: string;
|
||||||
|
content?: string;
|
||||||
|
ip?: string;
|
||||||
|
userAgent?: string;
|
||||||
|
createTime?: string;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogQueryParams extends PaginationParams {
|
||||||
|
userId?: number;
|
||||||
|
action?: string;
|
||||||
|
keyword?: string;
|
||||||
|
ip?: string;
|
||||||
|
startTime?: string;
|
||||||
|
endTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LogStatistics {
|
||||||
|
totalCount: number;
|
||||||
|
recentCount: number;
|
||||||
|
days: number;
|
||||||
|
actionStats: Array<{
|
||||||
|
action: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
dailyStats: Array<{
|
||||||
|
date: string;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取日志列表
|
||||||
|
export async function getLogsList(
|
||||||
|
params: LogQueryParams
|
||||||
|
): Promise<PaginationResponse<Log>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Log>>("/sys-log/page", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个日志详情
|
||||||
|
export async function getLogDetail(id: number): Promise<Log> {
|
||||||
|
const response = await request.get<any, Log>(`/sys-log/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取日志统计信息
|
||||||
|
export async function getLogStatistics(days: number = 7): Promise<LogStatistics> {
|
||||||
|
const response = await request.get<any, LogStatistics>("/sys-log/statistics", {
|
||||||
|
params: { days },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除日志
|
||||||
|
export async function deleteLogs(ids: number[]): Promise<number> {
|
||||||
|
const response = await request.delete<any, number>("/sys-log", {
|
||||||
|
params: { ids },
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 清理过期日志
|
||||||
|
export async function cleanOldLogs(daysToKeep?: number): Promise<number> {
|
||||||
|
const response = await request.post<any, number>("/sys-log/clean", null, {
|
||||||
|
params: daysToKeep ? { days: daysToKeep } : {},
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 logsApi 对象
|
||||||
|
export const logsApi = {
|
||||||
|
getList: getLogsList,
|
||||||
|
getDetail: getLogDetail,
|
||||||
|
getStatistics: getLogStatistics,
|
||||||
|
delete: deleteLogs,
|
||||||
|
clean: cleanOldLogs,
|
||||||
|
};
|
||||||
|
|
||||||
87
java-frontend/src/api/menus.ts
Normal file
87
java-frontend/src/api/menus.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export interface Menu {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
icon?: string;
|
||||||
|
component?: string;
|
||||||
|
parentId?: number;
|
||||||
|
permission?: string;
|
||||||
|
sort: number;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
children?: Menu[];
|
||||||
|
parent?: Menu;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateMenuForm {
|
||||||
|
name: string;
|
||||||
|
path?: string;
|
||||||
|
icon?: string;
|
||||||
|
component?: string;
|
||||||
|
parentId?: number;
|
||||||
|
permission?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateMenuForm {
|
||||||
|
name?: string;
|
||||||
|
path?: string;
|
||||||
|
icon?: string;
|
||||||
|
component?: string;
|
||||||
|
parentId?: number;
|
||||||
|
permission?: string;
|
||||||
|
sort?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取菜单列表(树形结构)
|
||||||
|
export async function getMenusList(): Promise<Menu[]> {
|
||||||
|
const response = await request.get<any, Menu[]>("/menus");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个菜单详情
|
||||||
|
export async function getMenuDetail(id: number): Promise<Menu> {
|
||||||
|
const response = await request.get<any, Menu>(`/menus/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建菜单
|
||||||
|
export async function createMenu(data: CreateMenuForm): Promise<Menu> {
|
||||||
|
const response = await request.post<any, Menu>("/menus", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新菜单
|
||||||
|
export async function updateMenu(
|
||||||
|
id: number,
|
||||||
|
data: UpdateMenuForm
|
||||||
|
): Promise<Menu> {
|
||||||
|
const response = await request.patch<any, Menu>(`/menus/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除菜单
|
||||||
|
export async function deleteMenu(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/menus/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取当前用户的菜单(根据权限过滤)
|
||||||
|
export async function getUserMenus(): Promise<Menu[]> {
|
||||||
|
const response = await request.get<any, Menu[]>("/menus/user-menus");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 menusApi 对象
|
||||||
|
export const menusApi = {
|
||||||
|
getList: getMenusList,
|
||||||
|
getDetail: getMenuDetail,
|
||||||
|
create: createMenu,
|
||||||
|
update: updateMenu,
|
||||||
|
delete: deleteMenu,
|
||||||
|
getUserMenus: getUserMenus,
|
||||||
|
};
|
||||||
78
java-frontend/src/api/permissions.ts
Normal file
78
java-frontend/src/api/permissions.ts
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Permission {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePermissionForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePermissionForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
resource?: string;
|
||||||
|
action?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取权限列表
|
||||||
|
export async function getPermissionsList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Permission>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Permission>>("/permissions", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个权限详情
|
||||||
|
export async function getPermissionDetail(id: number): Promise<Permission> {
|
||||||
|
const response = await request.get<any, Permission>(`/permissions/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建权限
|
||||||
|
export async function createPermission(data: CreatePermissionForm): Promise<Permission> {
|
||||||
|
const response = await request.post<any, Permission>("/permissions", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新权限
|
||||||
|
export async function updatePermission(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePermissionForm
|
||||||
|
): Promise<Permission> {
|
||||||
|
const response = await request.patch<any, Permission>(`/permissions/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除权限
|
||||||
|
export async function deletePermission(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/permissions/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 permissionsApi 对象
|
||||||
|
export const permissionsApi = {
|
||||||
|
getList: getPermissionsList,
|
||||||
|
getDetail: getPermissionDetail,
|
||||||
|
create: createPermission,
|
||||||
|
update: updatePermission,
|
||||||
|
delete: deletePermission,
|
||||||
|
};
|
||||||
|
|
||||||
139
java-frontend/src/api/preset-comments.ts
Normal file
139
java-frontend/src/api/preset-comments.ts
Normal file
@ -0,0 +1,139 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export interface PresetComment {
|
||||||
|
id: number;
|
||||||
|
contestId: number;
|
||||||
|
judgeId: number;
|
||||||
|
content: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder: number;
|
||||||
|
useCount: number;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime: string;
|
||||||
|
modifyTime: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePresetCommentParams {
|
||||||
|
contestId: number;
|
||||||
|
content: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePresetCommentParams {
|
||||||
|
content?: string;
|
||||||
|
score?: number;
|
||||||
|
sortOrder?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SyncPresetCommentsParams {
|
||||||
|
sourceContestId: number;
|
||||||
|
targetContestIds: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JudgeContest {
|
||||||
|
id: number;
|
||||||
|
contestName: string;
|
||||||
|
contestState: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取预设评语列表
|
||||||
|
export async function getPresetCommentsList(
|
||||||
|
contestId: number
|
||||||
|
): Promise<PresetComment[]> {
|
||||||
|
const response = await request.get<any, PresetComment[]>(
|
||||||
|
"/contests/preset-comments",
|
||||||
|
{
|
||||||
|
params: { contestId },
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个预设评语详情
|
||||||
|
export async function getPresetCommentDetail(
|
||||||
|
id: number
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.get<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建预设评语
|
||||||
|
export async function createPresetComment(
|
||||||
|
data: CreatePresetCommentParams
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.post<any, PresetComment>(
|
||||||
|
"/contests/preset-comments",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新预设评语
|
||||||
|
export async function updatePresetComment(
|
||||||
|
id: number,
|
||||||
|
data: UpdatePresetCommentParams
|
||||||
|
): Promise<PresetComment> {
|
||||||
|
const response = await request.patch<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}`,
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除预设评语
|
||||||
|
export async function deletePresetComment(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/contests/preset-comments/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 批量删除预设评语
|
||||||
|
export async function batchDeletePresetComments(ids: number[]): Promise<void> {
|
||||||
|
return await request.post<any, void>("/contests/preset-comments/batch-delete", {
|
||||||
|
ids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 同步预设评语到其他活动
|
||||||
|
export async function syncPresetComments(
|
||||||
|
data: SyncPresetCommentsParams
|
||||||
|
): Promise<{ message: string; count: number }> {
|
||||||
|
const response = await request.post<any, { message: string; count: number }>(
|
||||||
|
"/contests/preset-comments/sync",
|
||||||
|
data
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取评委的活动列表
|
||||||
|
export async function getJudgeContests(): Promise<JudgeContest[]> {
|
||||||
|
const response = await request.get<any, JudgeContest[]>(
|
||||||
|
"/contests/preset-comments/judge/contests"
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 增加使用次数
|
||||||
|
export async function incrementUseCount(id: number): Promise<PresetComment> {
|
||||||
|
const response = await request.post<any, PresetComment>(
|
||||||
|
`/contests/preset-comments/${id}/use`
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 presetCommentsApi 对象
|
||||||
|
export const presetCommentsApi = {
|
||||||
|
getList: getPresetCommentsList,
|
||||||
|
getDetail: getPresetCommentDetail,
|
||||||
|
create: createPresetComment,
|
||||||
|
update: updatePresetComment,
|
||||||
|
delete: deletePresetComment,
|
||||||
|
batchDelete: batchDeletePresetComments,
|
||||||
|
sync: syncPresetComments,
|
||||||
|
getJudgeContests: getJudgeContests,
|
||||||
|
incrementUseCount: incrementUseCount,
|
||||||
|
};
|
||||||
404
java-frontend/src/api/public.ts
Normal file
404
java-frontend/src/api/public.ts
Normal file
@ -0,0 +1,404 @@
|
|||||||
|
import axios from "axios"
|
||||||
|
|
||||||
|
// 公众端专用 axios 实例
|
||||||
|
const publicApi = axios.create({
|
||||||
|
baseURL: import.meta.env.VITE_API_BASE_URL || "/api",
|
||||||
|
timeout: 15000,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 请求拦截器
|
||||||
|
publicApi.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem("public_token")
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`
|
||||||
|
}
|
||||||
|
return config
|
||||||
|
})
|
||||||
|
|
||||||
|
// 响应拦截器
|
||||||
|
publicApi.interceptors.response.use(
|
||||||
|
(response) => response.data?.data ?? response.data,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
localStorage.removeItem("public_token")
|
||||||
|
localStorage.removeItem("public_user")
|
||||||
|
// 如果在公众端页面,跳转到公众端登录
|
||||||
|
if (window.location.pathname.startsWith("/p/")) {
|
||||||
|
window.location.href = "/p/login"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Promise.reject(error)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
// ==================== 认证 ====================
|
||||||
|
|
||||||
|
export interface PublicRegisterParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname: string
|
||||||
|
phone?: string
|
||||||
|
city?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicLoginParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PublicUser {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
phone: string | null
|
||||||
|
city: string | null
|
||||||
|
avatar: string | null
|
||||||
|
tenantId: number
|
||||||
|
tenantCode: string
|
||||||
|
userSource: string
|
||||||
|
userType: "adult" | "child"
|
||||||
|
parentUserId: number | null
|
||||||
|
roles: string[]
|
||||||
|
permissions: string[]
|
||||||
|
children?: any[]
|
||||||
|
childrenCount?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface LoginResponse {
|
||||||
|
token: string
|
||||||
|
user: PublicUser
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicAuthApi = {
|
||||||
|
register: (data: PublicRegisterParams): Promise<LoginResponse> =>
|
||||||
|
publicApi.post("/public/auth/register", data),
|
||||||
|
|
||||||
|
login: (data: PublicLoginParams): Promise<LoginResponse> =>
|
||||||
|
publicApi.post("/public/auth/login", data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 个人信息 ====================
|
||||||
|
|
||||||
|
export const publicProfileApi = {
|
||||||
|
getProfile: (): Promise<PublicUser> => publicApi.get("/public/mine/profile"),
|
||||||
|
|
||||||
|
updateProfile: (data: {
|
||||||
|
nickname?: string
|
||||||
|
city?: string
|
||||||
|
avatar?: string
|
||||||
|
gender?: string
|
||||||
|
}) => publicApi.put("/public/mine/profile", data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 子女管理 ====================
|
||||||
|
|
||||||
|
export interface Child {
|
||||||
|
id: number
|
||||||
|
parentId: number
|
||||||
|
name: string
|
||||||
|
gender: string | null
|
||||||
|
birthday: string | null
|
||||||
|
grade: string | null
|
||||||
|
city: string | null
|
||||||
|
schoolName: string | null
|
||||||
|
avatar: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateChildParams {
|
||||||
|
name: string
|
||||||
|
gender?: string
|
||||||
|
birthday?: string
|
||||||
|
grade?: string
|
||||||
|
city?: string
|
||||||
|
schoolName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicChildrenApi = {
|
||||||
|
list: (): Promise<Child[]> => publicApi.get("/public/mine/children"),
|
||||||
|
|
||||||
|
create: (data: CreateChildParams): Promise<Child> =>
|
||||||
|
publicApi.post("/public/mine/children", data),
|
||||||
|
|
||||||
|
get: (id: number): Promise<Child> =>
|
||||||
|
publicApi.get(`/public/mine/children/${id}`),
|
||||||
|
|
||||||
|
update: (id: number, data: Partial<CreateChildParams>): Promise<Child> =>
|
||||||
|
publicApi.put(`/public/mine/children/${id}`, data),
|
||||||
|
|
||||||
|
delete: (id: number) => publicApi.delete(`/public/mine/children/${id}`),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 子女独立账号管理 ====================
|
||||||
|
|
||||||
|
export interface CreateChildAccountParams {
|
||||||
|
username: string
|
||||||
|
password: string
|
||||||
|
nickname: string
|
||||||
|
gender?: string
|
||||||
|
birthday?: string
|
||||||
|
city?: string
|
||||||
|
avatar?: string
|
||||||
|
relationship?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ChildAccount {
|
||||||
|
id: number
|
||||||
|
username: string
|
||||||
|
nickname: string
|
||||||
|
avatar: string | null
|
||||||
|
gender: string | null
|
||||||
|
birthday: string | null
|
||||||
|
city: string | null
|
||||||
|
status: string
|
||||||
|
userType: string
|
||||||
|
createTime: string
|
||||||
|
relationship: string | null
|
||||||
|
controlMode: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicChildAccountApi = {
|
||||||
|
// 家长为子女创建独立账号
|
||||||
|
create: (data: CreateChildAccountParams): Promise<any> =>
|
||||||
|
publicApi.post("/public/children/create-account", data),
|
||||||
|
|
||||||
|
// 获取子女账号列表
|
||||||
|
list: (): Promise<ChildAccount[]> =>
|
||||||
|
publicApi.get("/public/children/accounts"),
|
||||||
|
|
||||||
|
// 家长切换到子女身份
|
||||||
|
switchToChild: (childUserId: number): Promise<LoginResponse> =>
|
||||||
|
publicApi.post("/public/auth/switch-child", { childUserId }),
|
||||||
|
|
||||||
|
// 更新子女账号信息
|
||||||
|
update: (id: number, data: {
|
||||||
|
nickname?: string
|
||||||
|
password?: string
|
||||||
|
gender?: string
|
||||||
|
birthday?: string
|
||||||
|
city?: string
|
||||||
|
avatar?: string
|
||||||
|
controlMode?: string
|
||||||
|
}): Promise<any> =>
|
||||||
|
publicApi.put(`/public/children/accounts/${id}`, data),
|
||||||
|
|
||||||
|
// 子女查看家长信息
|
||||||
|
getParentInfo: (): Promise<{
|
||||||
|
parentId: number
|
||||||
|
nickname: string
|
||||||
|
avatar: string | null
|
||||||
|
relationship: string | null
|
||||||
|
} | null> =>
|
||||||
|
publicApi.get("/public/mine/parent-info"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 活动 ====================
|
||||||
|
|
||||||
|
export interface PublicActivity {
|
||||||
|
id: number
|
||||||
|
contestName: string
|
||||||
|
contestType: string
|
||||||
|
contestState: string
|
||||||
|
status: string
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
coverUrl: string | null
|
||||||
|
posterUrl: string | null
|
||||||
|
registerStartTime: string
|
||||||
|
registerEndTime: string
|
||||||
|
submitStartTime: string
|
||||||
|
submitEndTime: string
|
||||||
|
organizers: any
|
||||||
|
visibility: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicActivitiesApi = {
|
||||||
|
list: (params?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
keyword?: string
|
||||||
|
contestType?: string
|
||||||
|
}): Promise<{ list: PublicActivity[]; total: number }> =>
|
||||||
|
publicApi.get("/public/activities", { params }),
|
||||||
|
|
||||||
|
detail: (id: number) => publicApi.get(`/public/activities/${id}`),
|
||||||
|
|
||||||
|
register: (
|
||||||
|
id: number,
|
||||||
|
data: { participantType: "self" | "child"; childId?: number },
|
||||||
|
) => publicApi.post(`/public/activities/${id}/register`, data),
|
||||||
|
|
||||||
|
getMyRegistration: (id: number) =>
|
||||||
|
publicApi.get(`/public/activities/${id}/my-registration`),
|
||||||
|
|
||||||
|
submitWork: (
|
||||||
|
id: number,
|
||||||
|
data: {
|
||||||
|
registrationId: number
|
||||||
|
title: string
|
||||||
|
description?: string
|
||||||
|
files?: string[]
|
||||||
|
previewUrl?: string
|
||||||
|
attachments?: { fileName: string; fileUrl: string; fileType?: string; size?: string }[]
|
||||||
|
},
|
||||||
|
) => publicApi.post(`/public/activities/${id}/submit-work`, data),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 我的报名 & 作品 ====================
|
||||||
|
|
||||||
|
export const publicMineApi = {
|
||||||
|
registrations: (params?: { page?: number; pageSize?: number }) =>
|
||||||
|
publicApi.get("/public/mine/registrations", { params }),
|
||||||
|
|
||||||
|
works: (params?: { page?: number; pageSize?: number }) =>
|
||||||
|
publicApi.get("/public/mine/works", { params }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 用户作品库 ====================
|
||||||
|
|
||||||
|
export interface UserWork {
|
||||||
|
id: number
|
||||||
|
userId: number
|
||||||
|
title: string
|
||||||
|
coverUrl: string | null
|
||||||
|
description: string | null
|
||||||
|
visibility: string
|
||||||
|
status: string
|
||||||
|
reviewNote: string | null
|
||||||
|
originalImageUrl: string | null
|
||||||
|
voiceInputUrl: string | null
|
||||||
|
textInput: string | null
|
||||||
|
aiMeta: any
|
||||||
|
viewCount: number
|
||||||
|
likeCount: number
|
||||||
|
favoriteCount: number
|
||||||
|
commentCount: number
|
||||||
|
shareCount: number
|
||||||
|
publishTime: string | null
|
||||||
|
createTime: string
|
||||||
|
modifyTime: string
|
||||||
|
pages?: UserWorkPage[]
|
||||||
|
tags?: Array<{ tag: { id: number; name: string; category: string } }>
|
||||||
|
creator?: { id: number; nickname: string; avatar: string | null; username: string }
|
||||||
|
_count?: { pages: number; likes: number; favorites: number; comments: number }
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserWorkPage {
|
||||||
|
id: number
|
||||||
|
workId: number
|
||||||
|
pageNo: number
|
||||||
|
imageUrl: string | null
|
||||||
|
text: string | null
|
||||||
|
audioUrl: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicUserWorksApi = {
|
||||||
|
// 创建作品
|
||||||
|
create: (data: {
|
||||||
|
title: string
|
||||||
|
coverUrl?: string
|
||||||
|
description?: string
|
||||||
|
visibility?: string
|
||||||
|
originalImageUrl?: string
|
||||||
|
voiceInputUrl?: string
|
||||||
|
textInput?: string
|
||||||
|
aiMeta?: any
|
||||||
|
pages?: Array<{ pageNo: number; imageUrl?: string; text?: string; audioUrl?: string }>
|
||||||
|
tagIds?: number[]
|
||||||
|
}): Promise<UserWork> => publicApi.post("/public/works", data),
|
||||||
|
|
||||||
|
// 我的作品列表
|
||||||
|
list: (params?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
status?: string
|
||||||
|
keyword?: string
|
||||||
|
}): Promise<{ list: UserWork[]; total: number }> =>
|
||||||
|
publicApi.get("/public/works", { params }),
|
||||||
|
|
||||||
|
// 作品详情
|
||||||
|
detail: (id: number): Promise<UserWork> =>
|
||||||
|
publicApi.get(`/public/works/${id}`),
|
||||||
|
|
||||||
|
// 更新作品
|
||||||
|
update: (id: number, data: {
|
||||||
|
title?: string
|
||||||
|
description?: string
|
||||||
|
coverUrl?: string
|
||||||
|
visibility?: string
|
||||||
|
tagIds?: number[]
|
||||||
|
}): Promise<UserWork> => publicApi.put(`/public/works/${id}`, data),
|
||||||
|
|
||||||
|
// 删除作品
|
||||||
|
delete: (id: number) => publicApi.delete(`/public/works/${id}`),
|
||||||
|
|
||||||
|
// 发布作品(进入审核)
|
||||||
|
publish: (id: number) => publicApi.post(`/public/works/${id}/publish`),
|
||||||
|
|
||||||
|
// 获取绘本分页
|
||||||
|
getPages: (id: number): Promise<UserWorkPage[]> =>
|
||||||
|
publicApi.get(`/public/works/${id}/pages`),
|
||||||
|
|
||||||
|
// 保存绘本分页
|
||||||
|
savePages: (id: number, pages: Array<{ pageNo: number; imageUrl?: string; text?: string; audioUrl?: string }>) =>
|
||||||
|
publicApi.post(`/public/works/${id}/pages`, { pages }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== AI 创作流程 ====================
|
||||||
|
|
||||||
|
export const publicCreationApi = {
|
||||||
|
// 提交创作请求
|
||||||
|
submit: (data: {
|
||||||
|
originalImageUrl: string
|
||||||
|
voiceInputUrl?: string
|
||||||
|
textInput?: string
|
||||||
|
}): Promise<{ id: number; status: string; message: string }> =>
|
||||||
|
publicApi.post("/public/creation/submit", data),
|
||||||
|
|
||||||
|
// 查询生成进度
|
||||||
|
getStatus: (id: number): Promise<{ id: number; status: string; title: string; createdAt: string }> =>
|
||||||
|
publicApi.get(`/public/creation/${id}/status`),
|
||||||
|
|
||||||
|
// 获取生成结果
|
||||||
|
getResult: (id: number): Promise<UserWork> =>
|
||||||
|
publicApi.get(`/public/creation/${id}/result`),
|
||||||
|
|
||||||
|
// 创作历史
|
||||||
|
history: (params?: { page?: number; pageSize?: number }): Promise<{ list: any[]; total: number }> =>
|
||||||
|
publicApi.get("/public/creation/history", { params }),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 标签 ====================
|
||||||
|
|
||||||
|
export interface WorkTag {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
category: string | null
|
||||||
|
usageCount: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const publicTagsApi = {
|
||||||
|
list: (): Promise<WorkTag[]> => publicApi.get("/public/tags"),
|
||||||
|
hot: (): Promise<WorkTag[]> => publicApi.get("/public/tags/hot"),
|
||||||
|
}
|
||||||
|
|
||||||
|
// ==================== 作品广场 ====================
|
||||||
|
|
||||||
|
export const publicGalleryApi = {
|
||||||
|
list: (params?: {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
tagId?: number
|
||||||
|
category?: string
|
||||||
|
sortBy?: string
|
||||||
|
keyword?: string
|
||||||
|
}): Promise<{ list: UserWork[]; total: number }> =>
|
||||||
|
publicApi.get("/public/gallery", { params }),
|
||||||
|
|
||||||
|
detail: (id: number): Promise<UserWork> =>
|
||||||
|
publicApi.get(`/public/gallery/${id}`),
|
||||||
|
|
||||||
|
userWorks: (userId: number, params?: { page?: number; pageSize?: number }): Promise<{ list: UserWork[]; total: number }> =>
|
||||||
|
publicApi.get(`/public/users/${userId}/works`, { params }),
|
||||||
|
}
|
||||||
|
|
||||||
|
export default publicApi
|
||||||
83
java-frontend/src/api/roles.ts
Normal file
83
java-frontend/src/api/roles.ts
Normal file
@ -0,0 +1,83 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Role {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
permissions?: Array<{
|
||||||
|
id: number;
|
||||||
|
permission: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
resource: string;
|
||||||
|
action: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateRoleForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateRoleForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
description?: string;
|
||||||
|
permissionIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取角色列表
|
||||||
|
export async function getRolesList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Role>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Role>>("/roles", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个角色详情
|
||||||
|
export async function getRoleDetail(id: number): Promise<Role> {
|
||||||
|
const response = await request.get<any, Role>(`/roles/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建角色
|
||||||
|
export async function createRole(data: CreateRoleForm): Promise<Role> {
|
||||||
|
const response = await request.post<any, Role>("/roles", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新角色
|
||||||
|
export async function updateRole(
|
||||||
|
id: number,
|
||||||
|
data: UpdateRoleForm
|
||||||
|
): Promise<Role> {
|
||||||
|
const response = await request.patch<any, Role>(`/roles/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除角色
|
||||||
|
export async function deleteRole(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/roles/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 rolesApi 对象
|
||||||
|
export const rolesApi = {
|
||||||
|
getList: getRolesList,
|
||||||
|
getDetail: getRoleDetail,
|
||||||
|
create: createRole,
|
||||||
|
update: updateRole,
|
||||||
|
delete: deleteRole,
|
||||||
|
};
|
||||||
73
java-frontend/src/api/schools.ts
Normal file
73
java-frontend/src/api/schools.ts
Normal file
@ -0,0 +1,73 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export interface School {
|
||||||
|
id: number;
|
||||||
|
tenantId: number;
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
principal?: string;
|
||||||
|
established?: string;
|
||||||
|
description?: string;
|
||||||
|
logo?: string;
|
||||||
|
website?: string;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
tenant?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateSchoolForm {
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
principal?: string;
|
||||||
|
established?: string;
|
||||||
|
description?: string;
|
||||||
|
logo?: string;
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateSchoolForm {
|
||||||
|
address?: string;
|
||||||
|
phone?: string;
|
||||||
|
principal?: string;
|
||||||
|
established?: string;
|
||||||
|
description?: string;
|
||||||
|
logo?: string;
|
||||||
|
website?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取学校信息
|
||||||
|
export async function getSchool(): Promise<School> {
|
||||||
|
const response = await request.get<any, School>("/schools");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建学校信息
|
||||||
|
export async function createSchool(data: CreateSchoolForm): Promise<School> {
|
||||||
|
const response = await request.post<any, School>("/schools", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新学校信息
|
||||||
|
export async function updateSchool(data: UpdateSchoolForm): Promise<School> {
|
||||||
|
const response = await request.patch<any, School>("/schools", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除学校信息
|
||||||
|
export async function deleteSchool(): Promise<void> {
|
||||||
|
return await request.delete<any, void>("/schools");
|
||||||
|
}
|
||||||
|
|
||||||
|
export const schoolsApi = {
|
||||||
|
get: getSchool,
|
||||||
|
create: createSchool,
|
||||||
|
update: updateSchool,
|
||||||
|
delete: deleteSchool,
|
||||||
|
};
|
||||||
|
|
||||||
150
java-frontend/src/api/students.ts
Normal file
150
java-frontend/src/api/students.ts
Normal file
@ -0,0 +1,150 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Student {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
tenantId: number;
|
||||||
|
classId: number;
|
||||||
|
studentNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number; // 1-男,2-女
|
||||||
|
birthDate?: string;
|
||||||
|
enrollmentDate?: string;
|
||||||
|
parentName?: string;
|
||||||
|
parentPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
description?: string;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
validState: number;
|
||||||
|
};
|
||||||
|
class?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: number;
|
||||||
|
grade?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
interestClasses?: Array<{
|
||||||
|
id: number;
|
||||||
|
class: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
type: number;
|
||||||
|
grade?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
level: number;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateStudentForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
classId: number;
|
||||||
|
studentNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number;
|
||||||
|
birthDate?: string;
|
||||||
|
enrollmentDate?: string;
|
||||||
|
parentName?: string;
|
||||||
|
parentPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
description?: string;
|
||||||
|
interestClassIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateStudentForm {
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
classId?: number;
|
||||||
|
studentNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number;
|
||||||
|
birthDate?: string;
|
||||||
|
enrollmentDate?: string;
|
||||||
|
parentName?: string;
|
||||||
|
parentPhone?: string;
|
||||||
|
address?: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
interestClassIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取学生列表
|
||||||
|
export async function getStudentsList(
|
||||||
|
params: PaginationParams & { classId?: number }
|
||||||
|
): Promise<PaginationResponse<Student>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Student>>("/students", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个学生详情
|
||||||
|
export async function getStudentDetail(id: number): Promise<Student> {
|
||||||
|
const response = await request.get<any, Student>(`/students/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户ID获取学生信息
|
||||||
|
export async function getStudentByUserId(userId: number): Promise<Student> {
|
||||||
|
const response = await request.get<any, Student>(`/students/user/${userId}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建学生
|
||||||
|
export async function createStudent(data: CreateStudentForm): Promise<Student> {
|
||||||
|
const response = await request.post<any, Student>("/students", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新学生
|
||||||
|
export async function updateStudent(
|
||||||
|
id: number,
|
||||||
|
data: UpdateStudentForm
|
||||||
|
): Promise<Student> {
|
||||||
|
const response = await request.patch<any, Student>(`/students/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除学生
|
||||||
|
export async function deleteStudent(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/students/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const studentsApi = {
|
||||||
|
getList: getStudentsList,
|
||||||
|
getDetail: getStudentDetail,
|
||||||
|
getByUserId: getStudentByUserId,
|
||||||
|
create: createStudent,
|
||||||
|
update: updateStudent,
|
||||||
|
delete: deleteStudent,
|
||||||
|
};
|
||||||
|
|
||||||
123
java-frontend/src/api/teachers.ts
Normal file
123
java-frontend/src/api/teachers.ts
Normal file
@ -0,0 +1,123 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface Teacher {
|
||||||
|
id: number;
|
||||||
|
userId: number;
|
||||||
|
tenantId: number;
|
||||||
|
departmentId: number;
|
||||||
|
employeeNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number; // 1-男,2-女
|
||||||
|
birthDate?: string;
|
||||||
|
hireDate?: string;
|
||||||
|
subject?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
user?: {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
validState: number;
|
||||||
|
};
|
||||||
|
department?: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTeacherForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
departmentId: number;
|
||||||
|
employeeNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number;
|
||||||
|
birthDate?: string;
|
||||||
|
hireDate?: string;
|
||||||
|
subject?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTeacherForm {
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
avatar?: string;
|
||||||
|
departmentId?: number;
|
||||||
|
employeeNo?: string;
|
||||||
|
phone?: string;
|
||||||
|
idCard?: string;
|
||||||
|
gender?: number;
|
||||||
|
birthDate?: string;
|
||||||
|
hireDate?: string;
|
||||||
|
subject?: string;
|
||||||
|
title?: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取教师列表
|
||||||
|
export async function getTeachersList(
|
||||||
|
params: PaginationParams & { departmentId?: number; nickname?: string; username?: string }
|
||||||
|
): Promise<PaginationResponse<Teacher>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Teacher>>("/teachers", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个教师详情
|
||||||
|
export async function getTeacherDetail(id: number): Promise<Teacher> {
|
||||||
|
const response = await request.get<any, Teacher>(`/teachers/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据用户ID获取教师信息
|
||||||
|
export async function getTeacherByUserId(userId: number): Promise<Teacher> {
|
||||||
|
const response = await request.get<any, Teacher>(`/teachers/user/${userId}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建教师
|
||||||
|
export async function createTeacher(data: CreateTeacherForm): Promise<Teacher> {
|
||||||
|
const response = await request.post<any, Teacher>("/teachers", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新教师
|
||||||
|
export async function updateTeacher(
|
||||||
|
id: number,
|
||||||
|
data: UpdateTeacherForm
|
||||||
|
): Promise<Teacher> {
|
||||||
|
const response = await request.patch<any, Teacher>(`/teachers/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除教师
|
||||||
|
export async function deleteTeacher(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/teachers/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const teachersApi = {
|
||||||
|
getList: getTeachersList,
|
||||||
|
getDetail: getTeacherDetail,
|
||||||
|
getByUserId: getTeacherByUserId,
|
||||||
|
create: createTeacher,
|
||||||
|
update: updateTeacher,
|
||||||
|
delete: deleteTeacher,
|
||||||
|
};
|
||||||
|
|
||||||
98
java-frontend/src/api/tenants.ts
Normal file
98
java-frontend/src/api/tenants.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
import type { Menu } from "./menus";
|
||||||
|
|
||||||
|
export interface Tenant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
domain?: string;
|
||||||
|
description?: string;
|
||||||
|
isSuper: number;
|
||||||
|
tenantType?: string;
|
||||||
|
validState: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
menus?: Array<{
|
||||||
|
id: number;
|
||||||
|
menu: Menu;
|
||||||
|
}>;
|
||||||
|
_count?: {
|
||||||
|
users: number;
|
||||||
|
roles: number;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateTenantForm {
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
domain?: string;
|
||||||
|
description?: string;
|
||||||
|
menuIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateTenantForm {
|
||||||
|
name?: string;
|
||||||
|
code?: string;
|
||||||
|
domain?: string;
|
||||||
|
description?: string;
|
||||||
|
validState?: number;
|
||||||
|
menuIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取租户列表
|
||||||
|
export async function getTenantsList(
|
||||||
|
params: PaginationParams
|
||||||
|
): Promise<PaginationResponse<Tenant>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<Tenant>>(
|
||||||
|
"/tenants",
|
||||||
|
{
|
||||||
|
params,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个租户详情
|
||||||
|
export async function getTenantDetail(id: number): Promise<Tenant> {
|
||||||
|
const response = await request.get<any, Tenant>(`/tenants/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建租户
|
||||||
|
export async function createTenant(data: CreateTenantForm): Promise<Tenant> {
|
||||||
|
const response = await request.post<any, Tenant>("/tenants", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新租户
|
||||||
|
export async function updateTenant(
|
||||||
|
id: number,
|
||||||
|
data: UpdateTenantForm
|
||||||
|
): Promise<Tenant> {
|
||||||
|
const response = await request.patch<any, Tenant>(`/tenants/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除租户
|
||||||
|
export async function deleteTenant(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/tenants/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取租户的菜单树
|
||||||
|
export async function getTenantMenus(id: number): Promise<Menu[]> {
|
||||||
|
const response = await request.get<any, Menu[]>(`/tenants/${id}/menus`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 tenantsApi 对象
|
||||||
|
export const tenantsApi = {
|
||||||
|
getList: getTenantsList,
|
||||||
|
getDetail: getTenantDetail,
|
||||||
|
create: createTenant,
|
||||||
|
update: updateTenant,
|
||||||
|
delete: deleteTenant,
|
||||||
|
getTenantMenus: getTenantMenus,
|
||||||
|
};
|
||||||
33
java-frontend/src/api/upload.ts
Normal file
33
java-frontend/src/api/upload.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
|
||||||
|
export interface UploadResponse {
|
||||||
|
url: string;
|
||||||
|
filename: string;
|
||||||
|
originalname: string;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const uploadApi = {
|
||||||
|
// 上传文件
|
||||||
|
upload: async (formData: FormData): Promise<UploadResponse> => {
|
||||||
|
const response = await request.post<any, UploadResponse>(
|
||||||
|
"/upload",
|
||||||
|
formData,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "multipart/form-data",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return response;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传单个文件
|
||||||
|
*/
|
||||||
|
export async function uploadFile(file: File): Promise<UploadResponse> {
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append("file", file);
|
||||||
|
return uploadApi.upload(formData);
|
||||||
|
}
|
||||||
172
java-frontend/src/api/users.ts
Normal file
172
java-frontend/src/api/users.ts
Normal file
@ -0,0 +1,172 @@
|
|||||||
|
import request from "@/utils/request";
|
||||||
|
import type { PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface UserQueryParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
keyword?: string;
|
||||||
|
userType?: "platform" | "org" | "judge" | "public";
|
||||||
|
filterTenantId?: number;
|
||||||
|
userSource?: "admin_created" | "self_registered";
|
||||||
|
status?: "enabled" | "disabled";
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserTenant {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
tenantType: string;
|
||||||
|
isSuper: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
avatar?: string;
|
||||||
|
status?: "enabled" | "disabled";
|
||||||
|
userSource?: string;
|
||||||
|
city?: string;
|
||||||
|
birthday?: string;
|
||||||
|
organization?: string;
|
||||||
|
tenantId?: number;
|
||||||
|
validState?: number;
|
||||||
|
creator?: number;
|
||||||
|
modifier?: number;
|
||||||
|
createTime?: string;
|
||||||
|
modifyTime?: string;
|
||||||
|
tenant?: UserTenant;
|
||||||
|
roles?: Array<{
|
||||||
|
id: number;
|
||||||
|
role: {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
code: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
_count?: {
|
||||||
|
children: number;
|
||||||
|
contestRegistrations: number;
|
||||||
|
};
|
||||||
|
// 详情接口返回
|
||||||
|
children?: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
gender?: string;
|
||||||
|
birthday?: string;
|
||||||
|
grade?: string;
|
||||||
|
city?: string;
|
||||||
|
schoolName?: string;
|
||||||
|
}>;
|
||||||
|
contestRegistrations?: Array<{
|
||||||
|
id: number;
|
||||||
|
registrationState: string;
|
||||||
|
participantType: string;
|
||||||
|
createTime: string;
|
||||||
|
contest: { id: number; contestName: string; contestState: string };
|
||||||
|
child?: { id: number; name: string };
|
||||||
|
}>;
|
||||||
|
contestJudges?: Array<{
|
||||||
|
contest: {
|
||||||
|
id: number;
|
||||||
|
contestName: string;
|
||||||
|
status: string;
|
||||||
|
};
|
||||||
|
}>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UserStats {
|
||||||
|
total: number;
|
||||||
|
platform: number;
|
||||||
|
org: number;
|
||||||
|
judge: number;
|
||||||
|
public: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreateUserForm {
|
||||||
|
username: string;
|
||||||
|
password: string;
|
||||||
|
nickname: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
avatar?: string;
|
||||||
|
status?: "enabled" | "disabled";
|
||||||
|
roleIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateUserForm {
|
||||||
|
username?: string;
|
||||||
|
password?: string;
|
||||||
|
nickname?: string;
|
||||||
|
email?: string;
|
||||||
|
phone?: string;
|
||||||
|
gender?: "male" | "female";
|
||||||
|
avatar?: string;
|
||||||
|
status?: "enabled" | "disabled";
|
||||||
|
roleIds?: number[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户列表
|
||||||
|
export async function getUsersList(
|
||||||
|
params: UserQueryParams
|
||||||
|
): Promise<PaginationResponse<User>> {
|
||||||
|
const response = await request.get<any, PaginationResponse<User>>("/users", {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户统计
|
||||||
|
export async function getUserStats(): Promise<UserStats> {
|
||||||
|
const response = await request.get<any, UserStats>("/users/stats");
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取单个用户详情
|
||||||
|
export async function getUserDetail(id: number): Promise<User> {
|
||||||
|
const response = await request.get<any, User>(`/users/${id}`);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建用户
|
||||||
|
export async function createUser(data: CreateUserForm): Promise<User> {
|
||||||
|
const response = await request.post<any, User>("/users", data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新用户
|
||||||
|
export async function updateUser(
|
||||||
|
id: number,
|
||||||
|
data: UpdateUserForm
|
||||||
|
): Promise<User> {
|
||||||
|
const response = await request.patch<any, User>(`/users/${id}`, data);
|
||||||
|
return response;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 切换用户状态
|
||||||
|
export async function updateUserStatus(
|
||||||
|
id: number,
|
||||||
|
status: "enabled" | "disabled"
|
||||||
|
): Promise<void> {
|
||||||
|
await request.patch(`/users/${id}/status`, { status });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 删除用户
|
||||||
|
export async function deleteUser(id: number): Promise<void> {
|
||||||
|
return await request.delete<any, void>(`/users/${id}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 兼容性导出:保留 usersApi 对象
|
||||||
|
export const usersApi = {
|
||||||
|
getList: getUsersList,
|
||||||
|
getStats: getUserStats,
|
||||||
|
getDetail: getUserDetail,
|
||||||
|
create: createUser,
|
||||||
|
update: updateUser,
|
||||||
|
updateStatus: updateUserStatus,
|
||||||
|
delete: deleteUser,
|
||||||
|
};
|
||||||
BIN
java-frontend/src/assets/images/logo-icon.png
Normal file
BIN
java-frontend/src/assets/images/logo-icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
BIN
java-frontend/src/assets/images/logo.png
Normal file
BIN
java-frontend/src/assets/images/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 76 KiB |
45
java-frontend/src/components/ModelViewer.vue
Normal file
45
java-frontend/src/components/ModelViewer.vue
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
<template>
|
||||||
|
<!-- 这个组件作为跳转器使用,实际查看器在 model-viewer 页面 -->
|
||||||
|
<span></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { watch } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
open: boolean
|
||||||
|
modelUrl: string
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Emits {
|
||||||
|
(e: "update:open", value: boolean): void
|
||||||
|
}
|
||||||
|
|
||||||
|
const props = defineProps<Props>()
|
||||||
|
const emit = defineEmits<Emits>()
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
// 监听打开状态,跳转到全屏页面
|
||||||
|
watch(
|
||||||
|
() => props.open,
|
||||||
|
(newOpen) => {
|
||||||
|
console.log("ModelViewer watch triggered:", { open: newOpen, url: props.modelUrl })
|
||||||
|
if (newOpen && props.modelUrl) {
|
||||||
|
console.log("正在跳转到模型查看页面:", props.modelUrl)
|
||||||
|
// 先关闭状态
|
||||||
|
emit("update:open", false)
|
||||||
|
// 跳转到模型查看页面
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
router.push({
|
||||||
|
path: `/${tenantCode}/workbench/model-viewer`,
|
||||||
|
query: { url: props.modelUrl }
|
||||||
|
})
|
||||||
|
} else if (newOpen && !props.modelUrl) {
|
||||||
|
console.error("模型URL为空,无法跳转")
|
||||||
|
emit("update:open", false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
122
java-frontend/src/components/RichTextEditor.vue
Normal file
122
java-frontend/src/components/RichTextEditor.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-editor">
|
||||||
|
<Toolbar
|
||||||
|
:editor="editorRef"
|
||||||
|
:defaultConfig="toolbarConfig"
|
||||||
|
:mode="mode"
|
||||||
|
class="toolbar"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
:defaultConfig="editorConfig"
|
||||||
|
:mode="mode"
|
||||||
|
v-model="valueHtml"
|
||||||
|
class="editor"
|
||||||
|
@onCreated="handleCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef, watch, onBeforeUnmount } from "vue"
|
||||||
|
import { Editor, Toolbar } from "@wangeditor/editor-for-vue"
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor/editor"
|
||||||
|
import { uploadFile } from "@/api/upload"
|
||||||
|
import "@wangeditor/editor/dist/css/style.css"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
height?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 编辑器实例
|
||||||
|
const editorRef = shallowRef<IDomEditor | null>(null)
|
||||||
|
const valueHtml = ref(props.modelValue || "")
|
||||||
|
const mode = "default"
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal !== valueHtml.value) {
|
||||||
|
valueHtml.value = newVal || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听内部值变化,触发更新
|
||||||
|
watch(valueHtml, (newVal) => {
|
||||||
|
emit("update:modelValue", newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具栏配置
|
||||||
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
|
excludeKeys: [
|
||||||
|
"group-video", // 排除视频
|
||||||
|
"fullScreen", // 排除全屏
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器配置
|
||||||
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
|
placeholder: props.placeholder || "请输入内容...",
|
||||||
|
MENU_CONF: {
|
||||||
|
// 上传图片配置
|
||||||
|
uploadImage: {
|
||||||
|
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||||
|
try {
|
||||||
|
const result: any = await uploadFile(file)
|
||||||
|
const url = result.data?.url || result.url
|
||||||
|
if (url) {
|
||||||
|
insertFn(url)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("图片上传失败:", error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器创建完成
|
||||||
|
const handleCreated = (editor: IDomEditor) => {
|
||||||
|
editorRef.value = editor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件销毁时,销毁编辑器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor) {
|
||||||
|
editor.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-text-editor {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
border-bottom: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: v-bind('(props.height || 300) + "px"');
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor :deep(.w-e-text-container) {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor :deep(.w-e-toolbar) {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
118
java-frontend/src/composables/useListRequest.ts
Normal file
118
java-frontend/src/composables/useListRequest.ts
Normal file
@ -0,0 +1,118 @@
|
|||||||
|
import { ref, reactive, type Ref } from "vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
import type { TableProps } from "ant-design-vue";
|
||||||
|
import type { PaginationParams, PaginationResponse } from "@/types/api";
|
||||||
|
|
||||||
|
export interface UseListRequestOptions<
|
||||||
|
T,
|
||||||
|
P extends Record<string, any> = Record<string, any>,
|
||||||
|
> {
|
||||||
|
// 请求函数
|
||||||
|
requestFn: (params: PaginationParams & P) => Promise<PaginationResponse<T>>;
|
||||||
|
// 默认搜索参数
|
||||||
|
defaultSearchParams?: P;
|
||||||
|
// 默认分页大小
|
||||||
|
defaultPageSize?: number;
|
||||||
|
// 错误提示信息
|
||||||
|
errorMessage?: string;
|
||||||
|
// 是否在挂载时自动加载
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useListRequest<
|
||||||
|
T,
|
||||||
|
P extends Record<string, any> = Record<string, any>,
|
||||||
|
>(options: UseListRequestOptions<T, P>) {
|
||||||
|
const {
|
||||||
|
requestFn,
|
||||||
|
defaultSearchParams = {} as P,
|
||||||
|
defaultPageSize = 10,
|
||||||
|
errorMessage = "获取列表失败",
|
||||||
|
immediate = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
const dataSource = ref<T[]>([]) as Ref<T[]>;
|
||||||
|
|
||||||
|
// 分页信息
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: defaultPageSize,
|
||||||
|
total: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive<P>({ ...defaultSearchParams } as P);
|
||||||
|
|
||||||
|
// 获取列表数据
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const params = {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
...searchParams,
|
||||||
|
} as PaginationParams & P;
|
||||||
|
|
||||||
|
const response = await requestFn(params);
|
||||||
|
dataSource.value = response.list;
|
||||||
|
pagination.total = response.total;
|
||||||
|
} catch (error) {
|
||||||
|
message.error(errorMessage);
|
||||||
|
console.error("List request error:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 重置搜索并刷新
|
||||||
|
const resetSearch = () => {
|
||||||
|
Object.assign(searchParams, defaultSearchParams);
|
||||||
|
pagination.current = 1;
|
||||||
|
fetchList();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const search = (params?: Partial<P>) => {
|
||||||
|
if (params) {
|
||||||
|
Object.assign(searchParams, params);
|
||||||
|
}
|
||||||
|
pagination.current = 1;
|
||||||
|
fetchList();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新当前页
|
||||||
|
const refresh = () => {
|
||||||
|
fetchList();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 表格分页变化处理
|
||||||
|
const handleTableChange: TableProps["onChange"] = (pag) => {
|
||||||
|
pagination.current = pag.current || 1;
|
||||||
|
pagination.pageSize = pag.pageSize || defaultPageSize;
|
||||||
|
fetchList();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果 immediate 为 true,在组合函数被调用时自动加载
|
||||||
|
if (immediate) {
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
pagination,
|
||||||
|
searchParams,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
fetchList,
|
||||||
|
resetSearch,
|
||||||
|
search,
|
||||||
|
refresh,
|
||||||
|
handleTableChange,
|
||||||
|
};
|
||||||
|
}
|
||||||
62
java-frontend/src/composables/useSimpleListRequest.ts
Normal file
62
java-frontend/src/composables/useSimpleListRequest.ts
Normal file
@ -0,0 +1,62 @@
|
|||||||
|
import { ref, type Ref } from "vue";
|
||||||
|
import { message } from "ant-design-vue";
|
||||||
|
|
||||||
|
export interface UseSimpleListRequestOptions<T> {
|
||||||
|
// 请求函数(返回数组,不是分页响应)
|
||||||
|
requestFn: () => Promise<T[]>;
|
||||||
|
// 错误提示信息
|
||||||
|
errorMessage?: string;
|
||||||
|
// 是否在挂载时自动加载
|
||||||
|
immediate?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSimpleListRequest<T>(
|
||||||
|
options: UseSimpleListRequestOptions<T>
|
||||||
|
) {
|
||||||
|
const {
|
||||||
|
requestFn,
|
||||||
|
errorMessage = "获取列表失败",
|
||||||
|
immediate = true,
|
||||||
|
} = options;
|
||||||
|
|
||||||
|
// 加载状态
|
||||||
|
const loading = ref(false);
|
||||||
|
|
||||||
|
// 数据源
|
||||||
|
const dataSource = ref<T[]>([]) as Ref<T[]>;
|
||||||
|
|
||||||
|
// 获取列表数据
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true;
|
||||||
|
try {
|
||||||
|
const response = await requestFn();
|
||||||
|
dataSource.value = response;
|
||||||
|
} catch (error) {
|
||||||
|
message.error(errorMessage);
|
||||||
|
console.error("List request error:", error);
|
||||||
|
} finally {
|
||||||
|
loading.value = false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 刷新列表
|
||||||
|
const refresh = () => {
|
||||||
|
fetchList();
|
||||||
|
};
|
||||||
|
|
||||||
|
// 如果 immediate 为 true,在组合函数被调用时自动加载
|
||||||
|
if (immediate) {
|
||||||
|
fetchList();
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
// 状态
|
||||||
|
loading,
|
||||||
|
dataSource,
|
||||||
|
|
||||||
|
// 方法
|
||||||
|
fetchList,
|
||||||
|
refresh,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
146
java-frontend/src/directives/permission.ts
Normal file
146
java-frontend/src/directives/permission.ts
Normal file
@ -0,0 +1,146 @@
|
|||||||
|
import type { App, DirectiveBinding } from "vue";
|
||||||
|
import { useAuthStore } from "@/stores/auth";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限指令配置
|
||||||
|
*/
|
||||||
|
interface PermissionDirectiveValue {
|
||||||
|
// 权限码或权限码数组
|
||||||
|
permission: string | string[];
|
||||||
|
// 是否隐藏元素(默认 false,即禁用)
|
||||||
|
hide?: boolean;
|
||||||
|
// 是否需要所有权限(默认 false,即任一权限即可)
|
||||||
|
all?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 检查权限
|
||||||
|
*/
|
||||||
|
function checkPermission(
|
||||||
|
value: string | string[] | PermissionDirectiveValue,
|
||||||
|
all: boolean = false
|
||||||
|
): boolean {
|
||||||
|
// 在指令中,我们需要通过 getCurrentInstance 获取 store
|
||||||
|
// 但更好的方式是直接导入 store,因为 Pinia store 是全局的
|
||||||
|
const authStore = useAuthStore();
|
||||||
|
|
||||||
|
// 如果值是字符串或数组,直接检查权限
|
||||||
|
if (typeof value === "string") {
|
||||||
|
return authStore.hasPermission(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(value)) {
|
||||||
|
return all
|
||||||
|
? value.every((perm) => authStore.hasPermission(perm))
|
||||||
|
: authStore.hasAnyPermission(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果是对象配置
|
||||||
|
if (typeof value === "object" && value !== null) {
|
||||||
|
const { permission, all: needAll = false } = value as PermissionDirectiveValue;
|
||||||
|
if (typeof permission === "string") {
|
||||||
|
return authStore.hasPermission(permission);
|
||||||
|
}
|
||||||
|
if (Array.isArray(permission)) {
|
||||||
|
return needAll
|
||||||
|
? permission.every((perm) => authStore.hasPermission(perm))
|
||||||
|
: authStore.hasAnyPermission(permission);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 处理元素权限
|
||||||
|
*/
|
||||||
|
function handlePermission(
|
||||||
|
el: HTMLElement,
|
||||||
|
binding: DirectiveBinding<string | string[] | PermissionDirectiveValue>
|
||||||
|
) {
|
||||||
|
const { value, modifiers } = binding;
|
||||||
|
|
||||||
|
// 如果没有值,默认允许
|
||||||
|
if (!value) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要所有权限
|
||||||
|
const needAll = modifiers.all || false;
|
||||||
|
|
||||||
|
// 解析配置
|
||||||
|
let config: PermissionDirectiveValue;
|
||||||
|
if (typeof value === "string" || Array.isArray(value)) {
|
||||||
|
config = {
|
||||||
|
permission: value,
|
||||||
|
hide: modifiers.hide || false,
|
||||||
|
all: needAll,
|
||||||
|
};
|
||||||
|
} else {
|
||||||
|
config = {
|
||||||
|
permission: value.permission,
|
||||||
|
hide: value.hide ?? modifiers.hide ?? false,
|
||||||
|
all: value.all ?? needAll,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasPermission = checkPermission(config.permission, config.all);
|
||||||
|
|
||||||
|
if (config.hide) {
|
||||||
|
// 隐藏元素
|
||||||
|
if (hasPermission) {
|
||||||
|
el.style.display = "";
|
||||||
|
el.removeAttribute("data-permission-hidden");
|
||||||
|
} else {
|
||||||
|
el.style.display = "none";
|
||||||
|
el.setAttribute("data-permission-hidden", "true");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 禁用元素(默认行为)
|
||||||
|
if (hasPermission) {
|
||||||
|
// 恢复元素状态
|
||||||
|
if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) {
|
||||||
|
el.disabled = false;
|
||||||
|
el.classList.remove("permission-disabled");
|
||||||
|
} else {
|
||||||
|
el.style.pointerEvents = "";
|
||||||
|
el.style.opacity = "";
|
||||||
|
el.classList.remove("permission-disabled");
|
||||||
|
}
|
||||||
|
el.removeAttribute("data-permission-disabled");
|
||||||
|
} else {
|
||||||
|
// 禁用元素
|
||||||
|
if (el instanceof HTMLButtonElement || el instanceof HTMLInputElement) {
|
||||||
|
el.disabled = true;
|
||||||
|
el.classList.add("permission-disabled");
|
||||||
|
} else {
|
||||||
|
el.style.pointerEvents = "none";
|
||||||
|
el.style.opacity = "0.6";
|
||||||
|
el.classList.add("permission-disabled");
|
||||||
|
}
|
||||||
|
el.setAttribute("data-permission-disabled", "true");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 权限指令
|
||||||
|
*/
|
||||||
|
const permissionDirective = {
|
||||||
|
mounted(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
handlePermission(el, binding);
|
||||||
|
},
|
||||||
|
updated(el: HTMLElement, binding: DirectiveBinding) {
|
||||||
|
handlePermission(el, binding);
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 注册权限指令
|
||||||
|
*/
|
||||||
|
export function setupPermissionDirective(app: App) {
|
||||||
|
app.directive("permission", permissionDirective);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default permissionDirective;
|
||||||
|
|
||||||
681
java-frontend/src/layouts/BasicLayout.vue
Normal file
681
java-frontend/src/layouts/BasicLayout.vue
Normal file
@ -0,0 +1,681 @@
|
|||||||
|
<template>
|
||||||
|
<a-layout class="layout">
|
||||||
|
<a-layout-sider
|
||||||
|
v-if="!hideSidebar"
|
||||||
|
v-model:collapsed="collapsed"
|
||||||
|
:width="210"
|
||||||
|
class="custom-sider"
|
||||||
|
>
|
||||||
|
<div class="sider-content">
|
||||||
|
<div class="sider-top">
|
||||||
|
<div class="logo" :class="{ 'logo-collapsed': collapsed }">
|
||||||
|
<img src="../assets/images/logo-icon.png" alt="乐绘世界" class="logo-img" />
|
||||||
|
<div v-if="!collapsed" class="logo-text">
|
||||||
|
<span class="logo-title-main">乐绘世界</span>
|
||||||
|
<span class="logo-title-sub">创想活动乐园</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<!-- 3D建模实验室快捷入口(仅学生和教师可见) -->
|
||||||
|
<div
|
||||||
|
v-if="canAccess3DLab"
|
||||||
|
class="lab-entry"
|
||||||
|
:class="{ 'lab-entry-collapsed': collapsed }"
|
||||||
|
@click="open3DLab"
|
||||||
|
>
|
||||||
|
<div class="lab-entry-icon">
|
||||||
|
<experiment-outlined />
|
||||||
|
</div>
|
||||||
|
<div v-if="!collapsed" class="lab-entry-content">
|
||||||
|
<span class="lab-entry-title">3D建模实验室</span>
|
||||||
|
<span class="lab-entry-desc">AI智能建模</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="!collapsed" class="lab-entry-arrow">
|
||||||
|
<right-outlined />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<a-menu
|
||||||
|
v-model:selectedKeys="selectedKeys"
|
||||||
|
v-model:openKeys="openKeys"
|
||||||
|
mode="inline"
|
||||||
|
class="custom-menu"
|
||||||
|
:items="filteredMenuItems"
|
||||||
|
@click="handleMenuClick"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="sider-bottom"
|
||||||
|
:class="{ 'sider-bottom-collapsed': collapsed }"
|
||||||
|
>
|
||||||
|
<a-dropdown placement="topRight">
|
||||||
|
<div
|
||||||
|
class="user-info"
|
||||||
|
:class="{ 'user-info-collapsed': collapsed }"
|
||||||
|
>
|
||||||
|
<a-avatar :size="32" :src="userAvatar" />
|
||||||
|
<span v-if="!collapsed" class="username">{{
|
||||||
|
authStore.user?.nickname
|
||||||
|
}}</span>
|
||||||
|
</div>
|
||||||
|
<template #overlay>
|
||||||
|
<a-menu>
|
||||||
|
<a-menu-item @click="handleLogout">
|
||||||
|
<logout-outlined />
|
||||||
|
退出登录
|
||||||
|
</a-menu-item>
|
||||||
|
</a-menu>
|
||||||
|
</template>
|
||||||
|
</a-dropdown>
|
||||||
|
<div class="collapse-trigger" @click="collapsed = !collapsed">
|
||||||
|
<menu-unfold-outlined v-if="collapsed" />
|
||||||
|
<menu-fold-outlined v-else />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</a-layout-sider>
|
||||||
|
<a-layout class="main-layout">
|
||||||
|
<a-layout-content
|
||||||
|
class="content"
|
||||||
|
:class="{ 'content-fullscreen': hideSidebar }"
|
||||||
|
>
|
||||||
|
<router-view />
|
||||||
|
</a-layout-content>
|
||||||
|
</a-layout>
|
||||||
|
</a-layout>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, computed, watch } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
import {
|
||||||
|
MenuFoldOutlined,
|
||||||
|
MenuUnfoldOutlined,
|
||||||
|
LogoutOutlined,
|
||||||
|
ExperimentOutlined,
|
||||||
|
RightOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
import { useAuthStore } from "@/stores/auth"
|
||||||
|
import { convertMenusToMenuItems } from "@/utils/menu"
|
||||||
|
import { getUserAvatar } from "@/utils/avatar"
|
||||||
|
import type { MenuProps } from "ant-design-vue"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
const collapsed = ref(false)
|
||||||
|
const selectedKeys = ref<string[]>([])
|
||||||
|
const openKeys = ref<string[]>([])
|
||||||
|
|
||||||
|
// 生成用户头像URL(使用DiceBear API自动生成默认头像)
|
||||||
|
const userAvatar = computed(() => getUserAvatar(authStore.user))
|
||||||
|
|
||||||
|
// 根据路由 meta 判断是否隐藏侧边栏
|
||||||
|
const hideSidebar = computed(() => {
|
||||||
|
return route.meta?.hideSidebar === true
|
||||||
|
})
|
||||||
|
|
||||||
|
// 判断是否可以访问3D建模实验室(仅学生和教师角色可见)
|
||||||
|
const canAccess3DLab = computed(() => {
|
||||||
|
return authStore.hasAnyRole(["student", "teacher"])
|
||||||
|
})
|
||||||
|
|
||||||
|
// 使用动态菜单
|
||||||
|
const menuItems = computed<MenuProps["items"]>(() => {
|
||||||
|
if (authStore.menus && authStore.menus.length > 0) {
|
||||||
|
return convertMenusToMenuItems(authStore.menus)
|
||||||
|
}
|
||||||
|
// 如果没有菜单数据,返回空数组(或者可以返回默认菜单)
|
||||||
|
return []
|
||||||
|
})
|
||||||
|
|
||||||
|
// 过滤掉3D建模实验室的菜单项(仅当用户能看到快捷入口时才过滤,避免重复显示)
|
||||||
|
const filteredMenuItems = computed<MenuProps["items"]>(() => {
|
||||||
|
const items = menuItems.value || []
|
||||||
|
// 如果用户能看到快捷入口,则过滤掉菜单中的3D实验室
|
||||||
|
if (canAccess3DLab.value) {
|
||||||
|
return items.filter((item: any) => {
|
||||||
|
// 检查是否是3D建模实验室菜单
|
||||||
|
const is3DLab =
|
||||||
|
item?.key?.toLowerCase().includes("3dlab") ||
|
||||||
|
item?.key?.toLowerCase().includes("3d-lab") ||
|
||||||
|
item?.label?.includes("3D建模") ||
|
||||||
|
item?.title?.includes("3D建模")
|
||||||
|
return !is3DLab
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
// 打开3D建模实验室(当前窗口跳转,更快)
|
||||||
|
const open3DLab = () => {
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
router.push(`/${tenantCode}/workbench/3d-lab`)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => route.name,
|
||||||
|
(routeName) => {
|
||||||
|
if (routeName) {
|
||||||
|
selectedKeys.value = [routeName as string]
|
||||||
|
// 自动展开包含当前路由的父菜单
|
||||||
|
const findParentKeys = (
|
||||||
|
menus: any[],
|
||||||
|
targetName: string,
|
||||||
|
parentKeys: string[] = [],
|
||||||
|
): string[] => {
|
||||||
|
for (const menu of menus) {
|
||||||
|
const menuKey = menu.key
|
||||||
|
if (menuKey === targetName) {
|
||||||
|
return parentKeys
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const found = findParentKeys(menu.children, targetName, [
|
||||||
|
...parentKeys,
|
||||||
|
menuKey,
|
||||||
|
])
|
||||||
|
if (found.length > 0) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
const parentKeys = findParentKeys(
|
||||||
|
menuItems.value || [],
|
||||||
|
routeName as string,
|
||||||
|
)
|
||||||
|
if (parentKeys.length > 0) {
|
||||||
|
openKeys.value = parentKeys
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleMenuClick = ({ key }: { key: string }) => {
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
console.log("点击菜单,key:", key)
|
||||||
|
|
||||||
|
// 检查是否是3D建模实验室菜单
|
||||||
|
// 方法1: 检查key是否包含3D相关字符(考虑到路由名称的生成规则)
|
||||||
|
// 路径 /workbench/3d-lab 会生成类似 Workbench3dLab 的key
|
||||||
|
const is3DLab =
|
||||||
|
key.toLowerCase().includes("3dlab") ||
|
||||||
|
key.toLowerCase().includes("3d-lab") ||
|
||||||
|
(key.toLowerCase().includes("workbench") &&
|
||||||
|
key.toLowerCase().includes("3d"))
|
||||||
|
|
||||||
|
// 方法2: 从菜单数据中查找对应的菜单项,检查path
|
||||||
|
const findMenuByKey = (menus: any[], targetKey: string): any => {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.key === targetKey) {
|
||||||
|
return menu
|
||||||
|
}
|
||||||
|
if (menu.children) {
|
||||||
|
const found = findMenuByKey(menu.children, targetKey)
|
||||||
|
if (found) return found
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const menuItem = findMenuByKey(menuItems.value || [], key)
|
||||||
|
const is3DLabByPath =
|
||||||
|
menuItem?.label?.includes("3D建模") || menuItem?.title?.includes("3D建模")
|
||||||
|
|
||||||
|
// 调试日志
|
||||||
|
console.log(
|
||||||
|
"is3DLab:",
|
||||||
|
is3DLab,
|
||||||
|
"is3DLabByPath:",
|
||||||
|
is3DLabByPath,
|
||||||
|
"menuItem:",
|
||||||
|
menuItem,
|
||||||
|
)
|
||||||
|
|
||||||
|
if (is3DLab || is3DLabByPath) {
|
||||||
|
// 打开3D建模实验室页面(新窗口,路由配置了 hideSidebar 会自动隐藏侧边栏)
|
||||||
|
console.log("检测到3D建模实验室,打开新窗口")
|
||||||
|
const base = import.meta.env.BASE_URL || "/"
|
||||||
|
const basePath = base.endsWith("/") ? base.slice(0, -1) : base
|
||||||
|
const fullUrl = `${window.location.origin}${basePath}/${tenantCode}/workbench/3d-lab`
|
||||||
|
window.open(fullUrl, "_blank")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 其他菜单项正常跳转
|
||||||
|
if (tenantCode) {
|
||||||
|
router.push({ name: key, params: { tenantCode } })
|
||||||
|
} else {
|
||||||
|
router.push({ name: key })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLogout = async () => {
|
||||||
|
await authStore.logout()
|
||||||
|
// 获取当前路由的租户编码,跳转到对应的登录页
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
if (tenantCode) {
|
||||||
|
router.push(`/${tenantCode}/login`)
|
||||||
|
} else {
|
||||||
|
router.push("/login")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary: #6366f1;
|
||||||
|
$primary-dark: #4f46e5;
|
||||||
|
$primary-light: #818cf8;
|
||||||
|
$coral: #f97066;
|
||||||
|
$rose: #ec4899;
|
||||||
|
|
||||||
|
.layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 侧边栏 ==========
|
||||||
|
.custom-sider {
|
||||||
|
background: linear-gradient(180deg, #fefcfb 0%, #f8f5ff 100%) !important;
|
||||||
|
border-right: 1px solid rgba(99, 102, 241, 0.06) !important;
|
||||||
|
box-shadow: 2px 0 16px rgba(99, 102, 241, 0.06);
|
||||||
|
|
||||||
|
:deep(.ant-layout-sider-children) {
|
||||||
|
background: transparent;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-content {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-top {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
padding: 0 10px;
|
||||||
|
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 3px;
|
||||||
|
}
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== Logo 区域 ==========
|
||||||
|
.logo {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 14px;
|
||||||
|
padding: 26px 16px 22px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
cursor: default;
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
width: 54px;
|
||||||
|
height: 54px;
|
||||||
|
object-fit: contain;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
filter: drop-shadow(0 2px 6px rgba(99, 102, 241, 0.15));
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 3px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title-main {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, #6366f1 0%, #ec4899 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
letter-spacing: 2px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-title-sub {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #9ca3af;
|
||||||
|
letter-spacing: 3px;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.logo-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 26px 8px 22px;
|
||||||
|
|
||||||
|
.logo-img {
|
||||||
|
width: 42px;
|
||||||
|
height: 42px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 3D建模实验室快捷入口 ==========
|
||||||
|
.lab-entry {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
margin: 4px 0 14px 0;
|
||||||
|
padding: 12px 14px;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba($primary, 0.06) 0%,
|
||||||
|
rgba($rose, 0.06) 100%
|
||||||
|
);
|
||||||
|
border: 1px solid rgba($primary, 0.12);
|
||||||
|
border-radius: 14px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba($primary, 0.10) 0%,
|
||||||
|
rgba($rose, 0.10) 100%
|
||||||
|
);
|
||||||
|
border-color: rgba($primary, 0.25);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 4px 14px rgba($primary, 0.12);
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-entry-icon {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, $rose 100%);
|
||||||
|
border-radius: 10px;
|
||||||
|
color: #fff;
|
||||||
|
font-size: 17px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-entry-content {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
|
||||||
|
.lab-entry-title {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1e1b4b;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-entry-desc {
|
||||||
|
font-size: 11px;
|
||||||
|
color: #9ca3af;
|
||||||
|
line-height: 1.2;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-entry-arrow {
|
||||||
|
color: #9ca3af;
|
||||||
|
font-size: 11px;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .lab-entry-arrow {
|
||||||
|
color: $primary;
|
||||||
|
transform: translateX(3px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.lab-entry-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 10px;
|
||||||
|
margin: 4px 4px 14px 4px;
|
||||||
|
|
||||||
|
.lab-entry-icon {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 底部用户区域 ==========
|
||||||
|
.sider-bottom {
|
||||||
|
padding: 14px 14px;
|
||||||
|
border-top: 1px solid rgba(99, 102, 241, 0.08);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
|
||||||
|
.user-info {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 6px 10px;
|
||||||
|
border-radius: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($primary, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-avatar) {
|
||||||
|
border: 2px solid rgba($primary, 0.15);
|
||||||
|
box-shadow: 0 2px 6px rgba($primary, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.username {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-info-collapsed {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 6px;
|
||||||
|
flex: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapse-trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.25s;
|
||||||
|
color: #9ca3af;
|
||||||
|
flex-shrink: 0;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($primary, 0.06);
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sider-bottom-collapsed {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
padding: 14px 8px;
|
||||||
|
|
||||||
|
.collapse-trigger {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 覆盖 Ant Design 默认边框 ==========
|
||||||
|
:deep(.ant-menu-light.ant-menu-root.ant-menu-inline),
|
||||||
|
:deep(.ant-menu-light.ant-menu-root.ant-menu-vertical) {
|
||||||
|
border-inline-end: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 菜单样式 ==========
|
||||||
|
.custom-menu {
|
||||||
|
background: transparent !important;
|
||||||
|
border-right: none !important;
|
||||||
|
border-inline-end: none !important;
|
||||||
|
padding: 4px 0;
|
||||||
|
|
||||||
|
:deep(.ant-menu-item) {
|
||||||
|
color: #374151;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent !important;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary !important;
|
||||||
|
background: rgba($primary, 0.06) !important;
|
||||||
|
transform: translateX(2px);
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-item-selected {
|
||||||
|
color: $primary !important;
|
||||||
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba($primary, 0.10) 0%,
|
||||||
|
rgba($rose, 0.05) 100%
|
||||||
|
) !important;
|
||||||
|
font-weight: 600;
|
||||||
|
box-shadow: 0 2px 8px rgba($primary, 0.08);
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu) {
|
||||||
|
.ant-menu-submenu-title {
|
||||||
|
color: #374151;
|
||||||
|
margin: 3px 0;
|
||||||
|
border-radius: 12px;
|
||||||
|
transition: all 0.25s cubic-bezier(0.4, 0, 0.2, 1);
|
||||||
|
background: transparent !important;
|
||||||
|
height: 44px;
|
||||||
|
line-height: 44px;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
font-size: 16px;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: $primary !important;
|
||||||
|
background: rgba($primary, 0.06) !important;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-submenu-open > .ant-menu-submenu-title {
|
||||||
|
color: $primary;
|
||||||
|
background: rgba($primary, 0.06) !important;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.ant-menu-submenu-selected > .ant-menu-submenu-title {
|
||||||
|
color: $primary;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-sub) {
|
||||||
|
background: transparent !important;
|
||||||
|
|
||||||
|
.ant-menu-item {
|
||||||
|
padding-left: 48px !important;
|
||||||
|
height: 40px;
|
||||||
|
line-height: 40px;
|
||||||
|
margin: 2px 0;
|
||||||
|
|
||||||
|
&.ant-menu-item-selected {
|
||||||
|
color: $primary !important;
|
||||||
|
background: rgba($primary, 0.10) !important;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
.anticon {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.ant-menu-submenu-arrow) {
|
||||||
|
color: #9ca3af;
|
||||||
|
transition: all 0.25s;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(
|
||||||
|
.ant-menu-submenu-open > .ant-menu-submenu-title > .ant-menu-submenu-arrow
|
||||||
|
),
|
||||||
|
:deep(
|
||||||
|
.ant-menu-submenu:hover > .ant-menu-submenu-title > .ant-menu-submenu-arrow
|
||||||
|
) {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 主内容区 ==========
|
||||||
|
.main-layout {
|
||||||
|
height: 100vh;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content {
|
||||||
|
padding: 24px;
|
||||||
|
background: #f8f7fc;
|
||||||
|
height: 100vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
overflow-x: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-fullscreen {
|
||||||
|
padding: 0;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
3
java-frontend/src/layouts/EmptyLayout.vue
Normal file
3
java-frontend/src/layouts/EmptyLayout.vue
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
<template>
|
||||||
|
<router-view />
|
||||||
|
</template>
|
||||||
272
java-frontend/src/layouts/PublicLayout.vue
Normal file
272
java-frontend/src/layouts/PublicLayout.vue
Normal file
@ -0,0 +1,272 @@
|
|||||||
|
<template>
|
||||||
|
<div class="public-layout">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<header class="public-header">
|
||||||
|
<div class="header-inner">
|
||||||
|
<div class="header-brand" @click="goHome">
|
||||||
|
<img src="@/assets/images/logo-icon.png" alt="乐绘世界" class="header-logo" />
|
||||||
|
<span class="header-title">乐绘世界</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-actions">
|
||||||
|
<template v-if="isLoggedIn">
|
||||||
|
<div class="user-menu" @click="goMine">
|
||||||
|
<a-avatar :size="28" :src="userAvatar">
|
||||||
|
{{ user?.nickname?.charAt(0) }}
|
||||||
|
</a-avatar>
|
||||||
|
<span class="user-name hidden-mobile">{{ user?.nickname }}</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else>
|
||||||
|
<a-button type="primary" size="small" shape="round" @click="goLogin">
|
||||||
|
登录
|
||||||
|
</a-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<!-- 主内容 -->
|
||||||
|
<main class="public-main">
|
||||||
|
<router-view />
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- 移动端底部导航 -->
|
||||||
|
<nav class="public-tabbar">
|
||||||
|
<div
|
||||||
|
class="tabbar-item"
|
||||||
|
:class="{ active: currentTab === 'home' }"
|
||||||
|
@click="goHome"
|
||||||
|
>
|
||||||
|
<home-outlined />
|
||||||
|
<span>发现</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tabbar-item"
|
||||||
|
:class="{ active: currentTab === 'create' }"
|
||||||
|
@click="goCreate"
|
||||||
|
>
|
||||||
|
<plus-circle-outlined />
|
||||||
|
<span>创作</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tabbar-item"
|
||||||
|
:class="{ active: currentTab === 'activity' }"
|
||||||
|
@click="goActivity"
|
||||||
|
>
|
||||||
|
<trophy-outlined />
|
||||||
|
<span>活动</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tabbar-item"
|
||||||
|
:class="{ active: currentTab === 'works' }"
|
||||||
|
@click="goWorks"
|
||||||
|
>
|
||||||
|
<appstore-outlined />
|
||||||
|
<span>作品库</span>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="tabbar-item"
|
||||||
|
:class="{ active: currentTab === 'mine' }"
|
||||||
|
@click="goMine"
|
||||||
|
>
|
||||||
|
<user-outlined />
|
||||||
|
<span>我的</span>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
import { HomeOutlined, UserOutlined, PlusCircleOutlined, AppstoreOutlined, TrophyOutlined } from "@ant-design/icons-vue"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
|
||||||
|
const isLoggedIn = computed(() => !!localStorage.getItem("public_token"))
|
||||||
|
const user = computed(() => {
|
||||||
|
const data = localStorage.getItem("public_user")
|
||||||
|
return data ? JSON.parse(data) : null
|
||||||
|
})
|
||||||
|
const userAvatar = computed(() => user.value?.avatar || undefined)
|
||||||
|
|
||||||
|
const currentTab = computed(() => {
|
||||||
|
const path = route.path
|
||||||
|
if (path.includes("/mine")) return "mine"
|
||||||
|
if (path.includes("/create")) return "create"
|
||||||
|
if (path.startsWith("/p/works")) return "works"
|
||||||
|
if (path.includes("/activities")) return "activity"
|
||||||
|
return "home"
|
||||||
|
})
|
||||||
|
|
||||||
|
const goHome = () => router.push("/p/gallery")
|
||||||
|
const goActivity = () => router.push("/p/activities")
|
||||||
|
const goCreate = () => {
|
||||||
|
if (!isLoggedIn.value) { router.push("/p/login"); return }
|
||||||
|
router.push("/p/create")
|
||||||
|
}
|
||||||
|
const goWorks = () => {
|
||||||
|
if (!isLoggedIn.value) { router.push("/p/login"); return }
|
||||||
|
router.push("/p/works")
|
||||||
|
}
|
||||||
|
const goMine = () => {
|
||||||
|
if (!isLoggedIn.value) { router.push("/p/login"); return }
|
||||||
|
router.push("/p/mine")
|
||||||
|
}
|
||||||
|
const goLogin = () => router.push("/p/login")
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
$primary: #6366f1;
|
||||||
|
|
||||||
|
.public-layout {
|
||||||
|
min-height: 100vh;
|
||||||
|
background: #f8f7fc;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 顶部导航 ==========
|
||||||
|
.public-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.92);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-bottom: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
|
box-shadow: 0 1px 8px rgba(99, 102, 241, 0.04);
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 0 20px;
|
||||||
|
height: 56px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-brand {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
.header-logo {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
object-fit: contain;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 800;
|
||||||
|
background: linear-gradient(135deg, $primary 0%, #ec4899 100%);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-menu {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 4px 12px 4px 4px;
|
||||||
|
border-radius: 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.2s;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba($primary, 0.06);
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-name {
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #374151;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 主内容 ==========
|
||||||
|
.public-main {
|
||||||
|
flex: 1;
|
||||||
|
max-width: 1200px;
|
||||||
|
width: 100%;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
padding-bottom: 80px; // 为底部 tabbar 留空间
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 底部导航(移动端) ==========
|
||||||
|
.public-tabbar {
|
||||||
|
position: fixed;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
z-index: 100;
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
backdrop-filter: blur(16px);
|
||||||
|
border-top: 1px solid rgba(99, 102, 241, 0.06);
|
||||||
|
display: flex;
|
||||||
|
padding: 6px 0;
|
||||||
|
padding-bottom: calc(6px + env(safe-area-inset-bottom));
|
||||||
|
|
||||||
|
.tabbar-item {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 4px 0;
|
||||||
|
font-size: 20px;
|
||||||
|
color: #9ca3af;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s;
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 11px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
color: $primary;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========== 响应式 ==========
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.public-tabbar {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-main {
|
||||||
|
padding-bottom: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.hidden-mobile {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.public-main {
|
||||||
|
padding: 16px;
|
||||||
|
padding-bottom: 80px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-inner {
|
||||||
|
padding: 0 16px;
|
||||||
|
height: 50px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
27
java-frontend/src/main.ts
Normal file
27
java-frontend/src/main.ts
Normal file
@ -0,0 +1,27 @@
|
|||||||
|
import { createApp } from "vue"
|
||||||
|
import { createPinia } from "pinia"
|
||||||
|
import Antd from "ant-design-vue"
|
||||||
|
import "ant-design-vue/dist/reset.css"
|
||||||
|
import "./styles/global.scss"
|
||||||
|
import "./styles/theme.scss"
|
||||||
|
import App from "./App.vue"
|
||||||
|
import router from "./router"
|
||||||
|
import { useAuthStore } from "./stores/auth"
|
||||||
|
import { setupPermissionDirective } from "./directives/permission"
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
const pinia = createPinia()
|
||||||
|
|
||||||
|
app.use(pinia)
|
||||||
|
app.use(router)
|
||||||
|
app.use(Antd)
|
||||||
|
|
||||||
|
// 注册权限指令
|
||||||
|
setupPermissionDirective(app)
|
||||||
|
|
||||||
|
// 应用启动时初始化认证状态
|
||||||
|
// 如果有 token,自动获取用户信息
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
authStore.initAuth().finally(() => {
|
||||||
|
app.mount("#app")
|
||||||
|
})
|
||||||
890
java-frontend/src/router/index.ts
Normal file
890
java-frontend/src/router/index.ts
Normal file
@ -0,0 +1,890 @@
|
|||||||
|
import { createRouter, createWebHistory } from "vue-router"
|
||||||
|
import type { RouteRecordRaw } from "vue-router"
|
||||||
|
import { nextTick } from "vue"
|
||||||
|
import { useAuthStore } from "@/stores/auth"
|
||||||
|
import { convertMenusToRoutes } from "@/utils/menu"
|
||||||
|
import "@/types/router"
|
||||||
|
|
||||||
|
// 基础路由(不需要动态加载的)
|
||||||
|
const baseRoutes: RouteRecordRaw[] = [
|
||||||
|
{
|
||||||
|
path: "/:tenantCode/login",
|
||||||
|
name: "Login",
|
||||||
|
component: () => import("@/views/auth/Login.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/login",
|
||||||
|
name: "LoginFallback",
|
||||||
|
component: () => import("@/views/auth/Login.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
// ========== 公众端路由 ==========
|
||||||
|
{
|
||||||
|
path: "/p/login",
|
||||||
|
name: "PublicLogin",
|
||||||
|
component: () => import("@/views/public/Login.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/p",
|
||||||
|
name: "PublicMain",
|
||||||
|
component: () => import("@/layouts/PublicLayout.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: "",
|
||||||
|
redirect: "/p/gallery",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "gallery",
|
||||||
|
name: "PublicGallery",
|
||||||
|
component: () => import("@/views/public/Gallery.vue"),
|
||||||
|
meta: { title: "作品广场" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "activities",
|
||||||
|
name: "PublicActivities",
|
||||||
|
component: () => import("@/views/public/Activities.vue"),
|
||||||
|
meta: { title: "活动大厅" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "activities/:id",
|
||||||
|
name: "PublicActivityDetail",
|
||||||
|
component: () => import("@/views/public/ActivityDetail.vue"),
|
||||||
|
meta: { title: "活动详情" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "mine",
|
||||||
|
name: "PublicMine",
|
||||||
|
component: () => import("@/views/public/mine/Index.vue"),
|
||||||
|
meta: { title: "个人中心" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "mine/registrations",
|
||||||
|
name: "PublicMyRegistrations",
|
||||||
|
component: () => import("@/views/public/mine/Registrations.vue"),
|
||||||
|
meta: { title: "我的报名" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "mine/works",
|
||||||
|
name: "PublicMyWorks",
|
||||||
|
component: () => import("@/views/public/mine/Works.vue"),
|
||||||
|
meta: { title: "我的作品" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "mine/children",
|
||||||
|
name: "PublicMyChildren",
|
||||||
|
component: () => import("@/views/public/mine/Children.vue"),
|
||||||
|
meta: { title: "子女账号" },
|
||||||
|
},
|
||||||
|
// ========== 创作与作品库 ==========
|
||||||
|
{
|
||||||
|
path: "create",
|
||||||
|
name: "PublicCreate",
|
||||||
|
component: () => import("@/views/public/create/Index.vue"),
|
||||||
|
meta: { title: "绘本创作" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "create/generating/:id",
|
||||||
|
name: "PublicCreating",
|
||||||
|
component: () => import("@/views/public/create/Generating.vue"),
|
||||||
|
meta: { title: "生成中" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "works",
|
||||||
|
name: "PublicWorksList",
|
||||||
|
component: () => import("@/views/public/works/Index.vue"),
|
||||||
|
meta: { title: "我的作品库" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "works/:id",
|
||||||
|
name: "PublicWorkDetail",
|
||||||
|
component: () => import("@/views/public/works/Detail.vue"),
|
||||||
|
meta: { title: "作品详情" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "works/:id/publish",
|
||||||
|
name: "PublicWorkPublish",
|
||||||
|
component: () => import("@/views/public/works/Publish.vue"),
|
||||||
|
meta: { title: "发布作品" },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
// ========== 管理端路由 ==========
|
||||||
|
{
|
||||||
|
path: "/:tenantCode",
|
||||||
|
name: "Main",
|
||||||
|
component: () => import("@/layouts/BasicLayout.vue"),
|
||||||
|
// 不设置固定redirect,由路由守卫根据用户菜单动态跳转到第一个可见菜单
|
||||||
|
meta: {},
|
||||||
|
children: [
|
||||||
|
// 创建活动路由(不需要在菜单中显示)
|
||||||
|
{
|
||||||
|
path: "contests/create",
|
||||||
|
name: "ContestsCreate",
|
||||||
|
component: () => import("@/views/contests/Create.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "创建活动",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["contest:create"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 超管活动详情路由
|
||||||
|
{
|
||||||
|
path: "contests/:id/overview",
|
||||||
|
name: "SuperContestOverview",
|
||||||
|
component: () => import("@/views/contests/SuperDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "活动详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 活动详情路由(不需要在菜单中显示)
|
||||||
|
{
|
||||||
|
path: "contests/:id",
|
||||||
|
name: "ContestsDetail",
|
||||||
|
component: () => import("@/views/contests/Detail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "活动详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["contest:read", "activity:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 编辑活动路由(不需要在菜单中显示)
|
||||||
|
{
|
||||||
|
path: "contests/:id/edit",
|
||||||
|
name: "ContestsEdit",
|
||||||
|
component: () => import("@/views/contests/Create.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "编辑活动",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["contest:update"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 活动评委管理路由(不需要在菜单中显示)
|
||||||
|
{
|
||||||
|
path: "contests/:id/judges",
|
||||||
|
name: "ContestsJudges",
|
||||||
|
component: () => import("@/views/contests/judges/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "评委管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["contest:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 个人参与报名路由
|
||||||
|
{
|
||||||
|
path: "contests/:id/register/individual",
|
||||||
|
name: "ContestsRegisterIndividual",
|
||||||
|
component: () => import("@/views/contests/RegisterIndividual.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "活动报名(个人参与)",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 团队参与报名路由
|
||||||
|
{
|
||||||
|
path: "contests/:id/register/team",
|
||||||
|
name: "ContestsRegisterTeam",
|
||||||
|
component: () => import("@/views/contests/RegisterTeam.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "活动报名(团队参与)",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 报名管理列表路由
|
||||||
|
{
|
||||||
|
path: "contests/registrations",
|
||||||
|
name: "ContestsRegistrations",
|
||||||
|
component: () => import("@/views/contests/registrations/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "报名管理",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["registration:read", "contest:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 报名记录路由
|
||||||
|
{
|
||||||
|
path: "contests/registrations/:id/records",
|
||||||
|
name: "RegistrationRecords",
|
||||||
|
component: () => import("@/views/contests/registrations/Records.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "报名记录",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["registration:read", "contest:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 评委评审详情路由(从评审任务列表进入)
|
||||||
|
{
|
||||||
|
path: "activities/review/:id",
|
||||||
|
name: "ActivitiesReviewDetail",
|
||||||
|
component: () => import("@/views/activities/ReviewDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "评审工作台",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["review:score"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 评审进度详情路由
|
||||||
|
{
|
||||||
|
path: "contests/reviews/:id/progress",
|
||||||
|
name: "ReviewProgressDetail",
|
||||||
|
component: () => import("@/views/contests/reviews/ProgressDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "评审进度详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 成果发布详情路由
|
||||||
|
{
|
||||||
|
path: "contests/results/:id",
|
||||||
|
name: "ContestsResultsDetail",
|
||||||
|
component: () => import("@/views/contests/results/Detail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "成果发布详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 参赛作品详情列表路由
|
||||||
|
{
|
||||||
|
path: "contests/works/:id/list",
|
||||||
|
name: "WorksDetail",
|
||||||
|
component: () => import("@/views/contests/works/WorksDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "参赛作品详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["work:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 作业提交记录路由
|
||||||
|
{
|
||||||
|
path: "homework/submissions",
|
||||||
|
name: "HomeworkSubmissions",
|
||||||
|
component: () => import("@/views/homework/Submissions.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "作业提交记录",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["homework:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 学生作业详情路由
|
||||||
|
{
|
||||||
|
path: "homework/detail/:id",
|
||||||
|
name: "HomeworkDetail",
|
||||||
|
component: () => import("@/views/homework/StudentDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "作业详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["homework:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 教师我的指导路由
|
||||||
|
{
|
||||||
|
path: "student-activities/guidance",
|
||||||
|
name: "TeacherGuidance",
|
||||||
|
component: () => import("@/views/contests/Guidance.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "我的指导",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["activity:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 评委评审详情页
|
||||||
|
{
|
||||||
|
path: "activities/review/:id",
|
||||||
|
name: "ReviewDetail",
|
||||||
|
component: () => import("@/views/activities/ReviewDetail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "作品评审",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 预设评语页面
|
||||||
|
{
|
||||||
|
path: "activities/preset-comments",
|
||||||
|
name: "PresetComments",
|
||||||
|
component: () => import("@/views/activities/PresetComments.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "预设评语",
|
||||||
|
requiresAuth: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3D建模实验室路由(工作台模块下)
|
||||||
|
{
|
||||||
|
path: "workbench/3d-lab",
|
||||||
|
name: "3DModelingLab",
|
||||||
|
component: () => import("@/views/workbench/ai-3d/Index.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "3D建模实验室",
|
||||||
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3D模型生成页面
|
||||||
|
{
|
||||||
|
path: "workbench/3d-lab/generate/:taskId",
|
||||||
|
name: "AI3DGenerate",
|
||||||
|
component: () => import("@/views/workbench/ai-3d/Generate.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "3D模型生成",
|
||||||
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3D创作历史页面
|
||||||
|
{
|
||||||
|
path: "workbench/3d-lab/history",
|
||||||
|
name: "AI3DHistory",
|
||||||
|
component: () => import("@/views/workbench/ai-3d/History.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "创作历史",
|
||||||
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 3D模型预览页面
|
||||||
|
{
|
||||||
|
path: "workbench/model-viewer",
|
||||||
|
name: "ModelViewer",
|
||||||
|
component: () => import("@/views/model/ModelViewer.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "3D模型预览",
|
||||||
|
requiresAuth: true,
|
||||||
|
hideSidebar: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// 动态路由将在这里添加
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/403",
|
||||||
|
name: "Forbidden",
|
||||||
|
component: () => import("@/views/error/403.vue"),
|
||||||
|
meta: { requiresAuth: false },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/:pathMatch(.*)*",
|
||||||
|
name: "NotFound",
|
||||||
|
component: () => import("@/views/error/404.vue"),
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: baseRoutes,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 标记是否已经添加了动态路由
|
||||||
|
let dynamicRoutesAdded = false
|
||||||
|
// 保存已添加的路由名称,用于清理
|
||||||
|
let addedRouteNames: string[] = []
|
||||||
|
// 保存上次的菜单数据,用于检测变化
|
||||||
|
let lastMenusHash: string = ""
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 重置动态路由标记(用于登出或切换用户时)
|
||||||
|
*/
|
||||||
|
export function resetDynamicRoutes(): void {
|
||||||
|
dynamicRoutesAdded = false
|
||||||
|
addedRouteNames = []
|
||||||
|
lastMenusHash = ""
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 添加动态路由
|
||||||
|
* @returns Promise,当路由添加完成并生效后 resolve
|
||||||
|
*/
|
||||||
|
export async function addDynamicRoutes(): Promise<void> {
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
if (!authStore.menus || authStore.menus.length === 0) {
|
||||||
|
// 如果没有菜单,重置标记
|
||||||
|
if (dynamicRoutesAdded) {
|
||||||
|
resetDynamicRoutes()
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算菜单数据的哈希值,用于检测变化
|
||||||
|
const menusHash = JSON.stringify(
|
||||||
|
authStore.menus.map((m) => ({ id: m.id, path: m.path }))
|
||||||
|
)
|
||||||
|
|
||||||
|
// 如果菜单数据没有变化且已经添加过路由,直接返回
|
||||||
|
if (dynamicRoutesAdded && menusHash === lastMenusHash) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已经添加过路由,先移除旧路由
|
||||||
|
if (dynamicRoutesAdded && addedRouteNames.length > 0) {
|
||||||
|
addedRouteNames.forEach((routeName) => {
|
||||||
|
if (router.hasRoute(routeName)) {
|
||||||
|
router.removeRoute(routeName)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
addedRouteNames = []
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将菜单转换为路由
|
||||||
|
const dynamicRoutes = convertMenusToRoutes(authStore.menus)
|
||||||
|
|
||||||
|
if (dynamicRoutes.length === 0) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加动态路由到根路由下
|
||||||
|
dynamicRoutes.forEach((route) => {
|
||||||
|
router.addRoute("Main", route)
|
||||||
|
if (route.name) {
|
||||||
|
addedRouteNames.push(route.name as string)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
dynamicRoutesAdded = true
|
||||||
|
lastMenusHash = menusHash
|
||||||
|
|
||||||
|
// 等待多个 tick,确保路由已完全注册
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 额外等待一小段时间,确保路由系统完全更新
|
||||||
|
await new Promise((resolve) => setTimeout(resolve, 50))
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从路径中提取租户编码
|
||||||
|
*/
|
||||||
|
function extractTenantCodeFromPath(path: string): string | null {
|
||||||
|
const match = path.match(/^\/([^/]+)/)
|
||||||
|
return match ? match[1] : null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 构建带租户编码的路径
|
||||||
|
*/
|
||||||
|
function buildPathWithTenantCode(tenantCode: string, path: string): string {
|
||||||
|
// 如果路径已经包含租户编码,直接返回
|
||||||
|
if (path.startsWith(`/${tenantCode}/`)) {
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
// 移除开头的斜杠(如果有)
|
||||||
|
const cleanPath = path.startsWith("/") ? path.slice(1) : path
|
||||||
|
// 如果路径是根路径,返回租户编码根路径(路由守卫会处理跳转到第一个菜单)
|
||||||
|
if (cleanPath === "" || cleanPath === tenantCode) {
|
||||||
|
return `/${tenantCode}`
|
||||||
|
}
|
||||||
|
return `/${tenantCode}/${cleanPath}`
|
||||||
|
}
|
||||||
|
|
||||||
|
router.beforeEach(async (to, _from, next) => {
|
||||||
|
// 公众端路由不走管理端认证逻辑,直接放行
|
||||||
|
if (to.path.startsWith("/p/") || to.path === "/p") {
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const authStore = useAuthStore()
|
||||||
|
|
||||||
|
// 从URL中提取租户编码
|
||||||
|
const tenantCodeFromUrl = extractTenantCodeFromPath(to.path)
|
||||||
|
|
||||||
|
// 如果 token 存在但用户信息不存在,先获取用户信息
|
||||||
|
if (authStore.token && !authStore.user) {
|
||||||
|
try {
|
||||||
|
const userInfo = await authStore.fetchUserInfo()
|
||||||
|
|
||||||
|
// 如果获取用户信息失败或用户信息为空,跳转到登录页
|
||||||
|
if (!userInfo) {
|
||||||
|
authStore.logout()
|
||||||
|
const tenantCode =
|
||||||
|
tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取用户信息后,检查租户编码一致性
|
||||||
|
const userTenantCode = userInfo?.tenantCode
|
||||||
|
if (userTenantCode) {
|
||||||
|
// 如果URL中的租户编码与用户信息不一致,更正URL
|
||||||
|
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
|
||||||
|
const correctedPath = buildPathWithTenantCode(
|
||||||
|
userTenantCode,
|
||||||
|
to.path.replace(`/${tenantCodeFromUrl}`, "")
|
||||||
|
)
|
||||||
|
next({ path: correctedPath, query: to.query, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果URL中没有租户编码,添加租户编码
|
||||||
|
if (!tenantCodeFromUrl) {
|
||||||
|
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||||
|
next({ path: correctedPath, query: to.query, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// 获取用户信息后,添加动态路由并等待生效
|
||||||
|
await addDynamicRoutes()
|
||||||
|
// 保存原始目标路径
|
||||||
|
const targetPath = to.fullPath
|
||||||
|
// 路由已生效,重新解析目标路由
|
||||||
|
const resolved = router.resolve(targetPath)
|
||||||
|
|
||||||
|
// 如果目标是租户根路径(如 /judge、/super),直接跳转到第一个菜单
|
||||||
|
const isRootPath = to.matched.length === 1 && to.matched[0].name === "Main"
|
||||||
|
if (isRootPath && authStore.menus?.length) {
|
||||||
|
const findFirst = (menus: any[]): string | null => {
|
||||||
|
for (const m of menus) {
|
||||||
|
if (m.path && m.component) return m.path.startsWith("/") ? m.path.slice(1) : m.path
|
||||||
|
if (m.children?.length) { const c = findFirst(m.children); if (c) return c }
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
const first = findFirst(authStore.menus)
|
||||||
|
if (first) {
|
||||||
|
const tc = tenantCodeFromUrl || authStore.user?.tenantCode
|
||||||
|
if (tc) { next({ path: `/${tc}/${first}`, replace: true }); return }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果解析后的路由不是404,说明路由存在,重新导航
|
||||||
|
if (resolved.name !== "NotFound") {
|
||||||
|
next({ path: targetPath, replace: true })
|
||||||
|
} else {
|
||||||
|
// 如果路由不存在,尝试重定向到用户第一个菜单
|
||||||
|
if (authStore.menus && authStore.menus.length > 0) {
|
||||||
|
const findFirstMenuPath = (menus: any[]): string | null => {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.path && menu.component) {
|
||||||
|
// 移除开头的斜杠
|
||||||
|
return menu.path.startsWith("/")
|
||||||
|
? menu.path.slice(1)
|
||||||
|
: menu.path
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const childPath = findFirstMenuPath(menu.children)
|
||||||
|
if (childPath) return childPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMenuPath = findFirstMenuPath(authStore.menus)
|
||||||
|
if (firstMenuPath) {
|
||||||
|
const user = authStore.user as { tenantCode?: string } | null
|
||||||
|
const userTenantCode = user?.tenantCode
|
||||||
|
const tenantCode =
|
||||||
|
tenantCodeFromUrl ||
|
||||||
|
extractTenantCodeFromPath(to.path) ||
|
||||||
|
userTenantCode
|
||||||
|
if (tenantCode) {
|
||||||
|
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果路由不存在,但需要认证,跳转到登录页(而不是404)
|
||||||
|
if (to.meta.requiresAuth === false) {
|
||||||
|
// 路由确实不存在,允许继续(会显示404页面)
|
||||||
|
next()
|
||||||
|
} else {
|
||||||
|
const tenantCode =
|
||||||
|
tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
} catch (error) {
|
||||||
|
// 获取失败,清除 token 并跳转到登录页
|
||||||
|
console.error("获取用户信息失败:", error)
|
||||||
|
authStore.logout()
|
||||||
|
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果 token 不存在,但需要认证,跳转到登录页
|
||||||
|
if (!authStore.token && to.meta.requiresAuth !== false) {
|
||||||
|
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已登录,检查租户编码一致性
|
||||||
|
if (authStore.isAuthenticated && authStore.user) {
|
||||||
|
const userTenantCode = authStore.user.tenantCode
|
||||||
|
if (userTenantCode) {
|
||||||
|
// 如果URL中的租户编码与用户信息不一致,更正URL
|
||||||
|
if (tenantCodeFromUrl && tenantCodeFromUrl !== userTenantCode) {
|
||||||
|
const correctedPath = buildPathWithTenantCode(
|
||||||
|
userTenantCode,
|
||||||
|
to.path.replace(`/${tenantCodeFromUrl}`, "")
|
||||||
|
)
|
||||||
|
next({ path: correctedPath, query: to.query, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果URL中没有租户编码,添加租户编码(排除不需要认证的特殊路由)
|
||||||
|
const skipTenantCodePaths = ["/login", "/403"]
|
||||||
|
const shouldSkipTenantCode = skipTenantCodePaths.some(p => to.path.startsWith(p))
|
||||||
|
if (!tenantCodeFromUrl && !shouldSkipTenantCode) {
|
||||||
|
const correctedPath = buildPathWithTenantCode(userTenantCode, to.path)
|
||||||
|
next({ path: correctedPath, query: to.query, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已登录且有菜单数据,添加或更新动态路由
|
||||||
|
if (authStore.isAuthenticated && authStore.menus.length > 0) {
|
||||||
|
// 保存添加路由前的状态
|
||||||
|
const wasRoutesAdded = dynamicRoutesAdded
|
||||||
|
|
||||||
|
// 添加或更新动态路由
|
||||||
|
await addDynamicRoutes()
|
||||||
|
|
||||||
|
// 如果这是第一次添加路由,需要重新导航
|
||||||
|
if (!wasRoutesAdded && dynamicRoutesAdded) {
|
||||||
|
// 等待路由完全生效
|
||||||
|
await nextTick()
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
// 保存原始目标路径
|
||||||
|
const targetPath = to.fullPath
|
||||||
|
// 路由已生效,重新解析目标路由
|
||||||
|
const resolved = router.resolve(targetPath)
|
||||||
|
|
||||||
|
// 如果访问的是主路由,重定向到第一个菜单
|
||||||
|
const isMainRoute = to.name === "Main"
|
||||||
|
|
||||||
|
console.log('Route guard debug:', {
|
||||||
|
targetPath,
|
||||||
|
resolvedName: resolved.name,
|
||||||
|
resolvedPath: resolved.path,
|
||||||
|
isMainRoute,
|
||||||
|
toName: to.name,
|
||||||
|
toPath: to.path,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 如果解析后的路由不是404,说明路由存在,重新导航
|
||||||
|
if (resolved.name !== "NotFound" && !isMainRoute) {
|
||||||
|
next({ path: targetPath, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果路由不存在或是主路由,尝试重定向到用户第一个菜单
|
||||||
|
if (authStore.menus && authStore.menus.length > 0) {
|
||||||
|
const findFirstMenuPath = (menus: any[]): string | null => {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.path && menu.component) {
|
||||||
|
// 移除开头的斜杠
|
||||||
|
return menu.path.startsWith("/") ? menu.path.slice(1) : menu.path
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const childPath = findFirstMenuPath(menu.children)
|
||||||
|
if (childPath) return childPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMenuPath = findFirstMenuPath(authStore.menus)
|
||||||
|
if (firstMenuPath) {
|
||||||
|
const userTenantCode = authStore.user
|
||||||
|
? (authStore.user.tenantCode as string | undefined)
|
||||||
|
: undefined
|
||||||
|
const tenantCode =
|
||||||
|
tenantCodeFromUrl ||
|
||||||
|
extractTenantCodeFromPath(to.path) ||
|
||||||
|
userTenantCode
|
||||||
|
if (tenantCode) {
|
||||||
|
// 再次等待,确保路由完全注册
|
||||||
|
await nextTick()
|
||||||
|
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有任何菜单,跳转到404页面
|
||||||
|
const tenantCodeFor404 =
|
||||||
|
tenantCodeFromUrl ||
|
||||||
|
extractTenantCodeFromPath(to.path) ||
|
||||||
|
(authStore.user
|
||||||
|
? (authStore.user.tenantCode as string | undefined)
|
||||||
|
: undefined)
|
||||||
|
if (tenantCodeFor404) {
|
||||||
|
next({ path: `/${tenantCodeFor404}/404`, replace: true })
|
||||||
|
} else {
|
||||||
|
next({ name: "NotFound" })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已登录且有菜单,但路由已添加,检查当前路由是否存在
|
||||||
|
if (
|
||||||
|
authStore.isAuthenticated &&
|
||||||
|
authStore.menus.length > 0 &&
|
||||||
|
dynamicRoutesAdded
|
||||||
|
) {
|
||||||
|
const resolved = router.resolve(to.fullPath)
|
||||||
|
// 如果访问的是 Main 路由(无具体子路径)或路由不存在,重定向到用户第一个菜单
|
||||||
|
const isMainRouteWithoutChild = to.name === "Main" || to.matched.length === 1 && to.matched[0].name === "Main"
|
||||||
|
if (
|
||||||
|
(resolved.name === "NotFound" || isMainRouteWithoutChild) &&
|
||||||
|
to.name !== "Login" &&
|
||||||
|
to.name !== "LoginFallback"
|
||||||
|
) {
|
||||||
|
const findFirstMenuPath = (menus: any[]): string | null => {
|
||||||
|
for (const menu of menus) {
|
||||||
|
if (menu.path && menu.component) {
|
||||||
|
return menu.path.startsWith("/") ? menu.path.slice(1) : menu.path
|
||||||
|
}
|
||||||
|
if (menu.children && menu.children.length > 0) {
|
||||||
|
const childPath = findFirstMenuPath(menu.children)
|
||||||
|
if (childPath) return childPath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const firstMenuPath = findFirstMenuPath(authStore.menus)
|
||||||
|
if (firstMenuPath) {
|
||||||
|
const userTenantCode = authStore.user
|
||||||
|
? (authStore.user.tenantCode as string | undefined)
|
||||||
|
: undefined
|
||||||
|
const tenantCode =
|
||||||
|
tenantCodeFromUrl ||
|
||||||
|
extractTenantCodeFromPath(to.path) ||
|
||||||
|
userTenantCode
|
||||||
|
if (tenantCode) {
|
||||||
|
next({ path: `/${tenantCode}/${firstMenuPath}`, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有任何菜单,跳转到404页面
|
||||||
|
const tenantCodeFor404 =
|
||||||
|
tenantCodeFromUrl ||
|
||||||
|
extractTenantCodeFromPath(to.path) ||
|
||||||
|
(authStore.user
|
||||||
|
? (authStore.user.tenantCode as string | undefined)
|
||||||
|
: undefined)
|
||||||
|
if (tenantCodeFor404) {
|
||||||
|
next({ path: `/${tenantCodeFor404}/404`, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否需要认证
|
||||||
|
if (to.meta.requiresAuth !== false) {
|
||||||
|
// 如果没有 token,跳转到登录页
|
||||||
|
if (!authStore.token) {
|
||||||
|
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果有 token 但没有用户信息,跳转到登录页
|
||||||
|
if (!authStore.user) {
|
||||||
|
const tenantCode = tenantCodeFromUrl || extractTenantCodeFromPath(to.path)
|
||||||
|
if (tenantCode) {
|
||||||
|
next({
|
||||||
|
path: `/${tenantCode}/login`,
|
||||||
|
query: { redirect: to.fullPath },
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
next({ name: "LoginFallback", query: { redirect: to.fullPath } })
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果已登录,访问登录页则重定向到首页
|
||||||
|
if (
|
||||||
|
(to.name === "Login" || to.name === "LoginFallback") &&
|
||||||
|
authStore.isAuthenticated
|
||||||
|
) {
|
||||||
|
// 确保动态路由已添加并等待生效
|
||||||
|
if (!dynamicRoutesAdded && authStore.menus.length > 0) {
|
||||||
|
await addDynamicRoutes()
|
||||||
|
}
|
||||||
|
// 重定向到带租户编码的根路径(路由守卫会处理跳转到第一个菜单)
|
||||||
|
const userTenantCode = authStore.user?.tenantCode || "default"
|
||||||
|
next({ path: `/${userTenantCode}` })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理登录页面的租户编码
|
||||||
|
if (to.name === "LoginFallback" && !tenantCodeFromUrl) {
|
||||||
|
// 如果访问的是 /login,但没有租户编码,检查是否有用户信息中的租户编码
|
||||||
|
if (authStore.isAuthenticated && authStore.user?.tenantCode) {
|
||||||
|
const userTenantCode = authStore.user.tenantCode
|
||||||
|
next({ path: `/${userTenantCode}/login`, replace: true })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
// 如果没有租户编码,允许访问(会显示租户输入框)
|
||||||
|
next()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查角色权限
|
||||||
|
const requiredRoles = to.meta.roles
|
||||||
|
if (requiredRoles && requiredRoles.length > 0) {
|
||||||
|
if (!authStore.hasAnyRole(requiredRoles)) {
|
||||||
|
// 没有所需角色,跳转到 403 页面
|
||||||
|
next({ name: "Forbidden" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查权限
|
||||||
|
const requiredPermissions = to.meta.permissions
|
||||||
|
if (requiredPermissions && requiredPermissions.length > 0) {
|
||||||
|
if (!authStore.hasAnyPermission(requiredPermissions)) {
|
||||||
|
// 没有所需权限,跳转到 403 页面
|
||||||
|
next({ name: "Forbidden" })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
next()
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
163
java-frontend/src/stores/auth.ts
Normal file
163
java-frontend/src/stores/auth.ts
Normal file
@ -0,0 +1,163 @@
|
|||||||
|
import { defineStore } from "pinia"
|
||||||
|
import { ref, computed } from "vue"
|
||||||
|
import type { User, LoginForm } from "@/types/auth"
|
||||||
|
import { authApi } from "@/api/auth"
|
||||||
|
import { menusApi, type Menu } from "@/api/menus"
|
||||||
|
import { getToken, setToken, removeToken, getTenantCode } from "@/utils/auth"
|
||||||
|
|
||||||
|
export const useAuthStore = defineStore("auth", () => {
|
||||||
|
const user = ref<User | null>(null)
|
||||||
|
const token = ref<string>(getToken() || "")
|
||||||
|
const loading = ref<boolean>(false)
|
||||||
|
const menus = ref<Menu[]>([])
|
||||||
|
|
||||||
|
const isAuthenticated = computed(() => !!token.value)
|
||||||
|
|
||||||
|
// 获取当前用户的租户编码(优先从用户信息获取,其次从 URL 获取)
|
||||||
|
const tenantCode = computed(() => {
|
||||||
|
return user.value?.tenantCode || getTenantCode()
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否有指定角色
|
||||||
|
const hasRole = (role: string): boolean => {
|
||||||
|
return user.value?.roles?.includes(role) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否为超级管理员
|
||||||
|
const isSuperAdmin = (): boolean => {
|
||||||
|
return user.value?.roles?.includes('super_admin') ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有指定权限
|
||||||
|
const hasPermission = (permission: string): boolean => {
|
||||||
|
// 超级管理员拥有所有权限
|
||||||
|
if (isSuperAdmin()) return true
|
||||||
|
return user.value?.permissions?.includes(permission) ?? false
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有任一角色
|
||||||
|
const hasAnyRole = (roles: string[]): boolean => {
|
||||||
|
if (!roles || roles.length === 0) return true
|
||||||
|
return roles.some((role) => hasRole(role))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否有任一权限
|
||||||
|
const hasAnyPermission = (permissions: string[]): boolean => {
|
||||||
|
if (!permissions || permissions.length === 0) return true
|
||||||
|
// 超级管理员拥有所有权限
|
||||||
|
if (isSuperAdmin()) return true
|
||||||
|
return permissions.some((perm) => hasPermission(perm))
|
||||||
|
}
|
||||||
|
|
||||||
|
const login = async (form: LoginForm) => {
|
||||||
|
const response = await authApi.login(form)
|
||||||
|
token.value = response.token
|
||||||
|
user.value = response.user
|
||||||
|
|
||||||
|
// 使用租户编码作为 cookie path(不再存储到 localStorage)
|
||||||
|
if (response.user.tenantCode) {
|
||||||
|
setToken(response.token, response.user.tenantCode)
|
||||||
|
} else {
|
||||||
|
setToken(response.token)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 登录后获取用户菜单
|
||||||
|
await fetchUserMenus()
|
||||||
|
return response
|
||||||
|
}
|
||||||
|
|
||||||
|
const logout = async () => {
|
||||||
|
try {
|
||||||
|
await authApi.logout()
|
||||||
|
} finally {
|
||||||
|
token.value = ""
|
||||||
|
// 删除 token cookie,使用当前用户的租户编码或 URL 中的租户编码
|
||||||
|
const tenantCode = user.value?.tenantCode || getTenantCode()
|
||||||
|
removeToken(tenantCode || undefined)
|
||||||
|
user.value = null
|
||||||
|
menus.value = []
|
||||||
|
// 重置动态路由标记
|
||||||
|
if (typeof window !== "undefined") {
|
||||||
|
const { resetDynamicRoutes } = await import("@/router")
|
||||||
|
resetDynamicRoutes()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUserInfo = async () => {
|
||||||
|
if (!token.value) {
|
||||||
|
throw new Error("未登录")
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
loading.value = true
|
||||||
|
const response = await authApi.getUserInfo()
|
||||||
|
user.value = response
|
||||||
|
// 获取用户菜单
|
||||||
|
await fetchUserMenus()
|
||||||
|
return response
|
||||||
|
} catch (error) {
|
||||||
|
// 如果获取用户信息失败,清除 token
|
||||||
|
token.value = ""
|
||||||
|
user.value = null
|
||||||
|
menus.value = []
|
||||||
|
removeToken()
|
||||||
|
throw error
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchUserMenus = async () => {
|
||||||
|
if (!token.value) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const userMenus = await menusApi.getUserMenus()
|
||||||
|
menus.value = userMenus
|
||||||
|
return userMenus
|
||||||
|
} catch (error) {
|
||||||
|
console.error("获取用户菜单失败:", error)
|
||||||
|
menus.value = []
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateToken = (newToken: string) => {
|
||||||
|
token.value = newToken
|
||||||
|
// 使用当前用户的租户编码更新 token cookie
|
||||||
|
const tenantCode = user.value?.tenantCode || getTenantCode()
|
||||||
|
setToken(newToken, tenantCode || undefined)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化:如果有 token 但没有用户信息,自动获取
|
||||||
|
const initAuth = async () => {
|
||||||
|
if (token.value && !user.value) {
|
||||||
|
try {
|
||||||
|
await fetchUserInfo()
|
||||||
|
} catch (error) {
|
||||||
|
console.error("自动获取用户信息失败:", error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
user,
|
||||||
|
token,
|
||||||
|
loading,
|
||||||
|
menus,
|
||||||
|
isAuthenticated,
|
||||||
|
tenantCode,
|
||||||
|
hasRole,
|
||||||
|
hasPermission,
|
||||||
|
hasAnyRole,
|
||||||
|
hasAnyPermission,
|
||||||
|
login,
|
||||||
|
logout,
|
||||||
|
fetchUserInfo,
|
||||||
|
fetchUserMenus,
|
||||||
|
updateToken,
|
||||||
|
initAuth,
|
||||||
|
}
|
||||||
|
})
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user