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:
En 2026-03-28 18:53:24 +08:00
parent 418aa57ea8
commit 48fc71b41d
195 changed files with 60904 additions and 0 deletions

View 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
View 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. **状态管理**:使用 Piniastore 命名 `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
View 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
View 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
View 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
View 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>

View File

@ -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("============================================");
}
}

View File

@ -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;
}

View File

@ -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());
}
};
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -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);
}
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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;
}
}

View File

@ -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);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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;
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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> {
}

View File

@ -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);
}

View File

@ -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> {
}

View File

@ -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();
}
}

View File

@ -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());
}
}

View File

@ -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
);
}
}

View File

@ -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);
}
}

View File

@ -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;
}

View File

@ -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;
}

View 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

View 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

View 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

View 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

View File

@ -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='系统日志表';
-- ============================================
-- 初始化数据
-- ============================================
-- 初始化管理员账号密码admin123BCrypt 加密)
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`;

View 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>

View 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>

View 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>

View 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
```

View File

@ -0,0 +1,2 @@
# 开发环境
VITE_API_BASE_URL=/api

View 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
View File

@ -0,0 +1,3 @@
# 测试环境
VITE_BASE_URL=/web-test/
VITE_API_BASE_URL=/api-test

25
java-frontend/.gitignore vendored Normal file
View 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
View File

@ -0,0 +1,4 @@
# 前端 pnpm 配置
shamefully-hoist=true
strict-peer-dependencies=false

View 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 类型定义完善
可以开始使用权限控制功能了!

View 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
View 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;
}
}

Binary file not shown.

View 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` | 主布局、侧边栏菜单渲染 |

View 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-5Ant 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
View 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

File diff suppressed because it is too large Load Diff

View 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"
}
}

View File

@ -0,0 +1,7 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View 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
View 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>

View 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}`);
}

View 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 };
},
};

View 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,
};

View 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,
};

File diff suppressed because it is too large Load Diff

View 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,
};

View 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}`);
}

View 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,
};

View 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`);
},
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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,
};

View 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);
}

View 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,
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

View 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>

View 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>

View 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,
};
}

View 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,
};
}

View 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;

View 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: key3D
// /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>

View File

@ -0,0 +1,3 @@
<template>
<router-view />
</template>

View 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
View 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")
})

View 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

View 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