修改赛果发布
This commit is contained in:
parent
9d3537ce53
commit
464f5389a4
109
.claude/skills/README.md
Normal file
109
.claude/skills/README.md
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# 页面生成规范索引
|
||||||
|
|
||||||
|
## 文档列表
|
||||||
|
|
||||||
|
| 文档 | 用途 | 路径 |
|
||||||
|
|------|------|------|
|
||||||
|
| 列表页规范 | 生成赛事列表页(Tab + 搜索 + 表格) | [contest-list-page.md](./contest-list-page.md) |
|
||||||
|
| 详情页规范 | 生成详情页(返回 + 标题 + 搜索 + 表格) | [contest-detail-page.md](./contest-detail-page.md) |
|
||||||
|
| 后端接口规范 | 生成 NestJS 接口(Controller + Service + DTO) | [backend-api.md](./backend-api.md) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 快速生成模板
|
||||||
|
|
||||||
|
### 一、列表页(如:赛果发布列表、报名管理列表)
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个赛事列表页面:
|
||||||
|
|
||||||
|
页面名称:xxx
|
||||||
|
文件路径:xxx
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- 字段1(类型)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 序号
|
||||||
|
- 列名(数据字段)
|
||||||
|
- 操作(按钮列表)
|
||||||
|
|
||||||
|
详情跳转:路由路径
|
||||||
|
```
|
||||||
|
|
||||||
|
### 二、详情页(如:赛果详情、报名记录)
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个详情页面:
|
||||||
|
|
||||||
|
页面名称:xxx
|
||||||
|
文件路径:xxx
|
||||||
|
|
||||||
|
标题:字段名
|
||||||
|
操作按钮:按钮名称 -> 处理函数
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- 字段1(类型)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 序号
|
||||||
|
- 列名(数据字段,渲染类型)
|
||||||
|
|
||||||
|
排序:字段名 + 排序方式
|
||||||
|
```
|
||||||
|
|
||||||
|
### 三、完整功能页面(前端 + 后端)
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成完整功能页面:
|
||||||
|
|
||||||
|
【页面信息】
|
||||||
|
页面名称:xxx
|
||||||
|
文件路径:xxx
|
||||||
|
|
||||||
|
【接口信息】
|
||||||
|
接口路径:GET/POST /api/xxx
|
||||||
|
数据表:xxx
|
||||||
|
|
||||||
|
【搜索条件】
|
||||||
|
- 字段(类型)
|
||||||
|
|
||||||
|
【表格列】
|
||||||
|
- 列名(数据字段,渲染类型)
|
||||||
|
|
||||||
|
【操作按钮】
|
||||||
|
- 按钮名称 -> 处理逻辑
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用渲染类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| index | 序号 | 自动计算 |
|
||||||
|
| text | 纯文本 | 直接显示 |
|
||||||
|
| link | 可点击 | 跳转详情 |
|
||||||
|
| count | 统计数 | _count.registrations |
|
||||||
|
| score | 分数 | 保留2位小数 |
|
||||||
|
| tag | 标签 | 状态显示 |
|
||||||
|
| date | 日期 | YYYY-MM-DD HH:mm |
|
||||||
|
| dateRange | 日期范围 | 开始-结束 |
|
||||||
|
| org | 机构信息 | 学校+年级+班级 |
|
||||||
|
| array | 数组拼接 | 用顿号连接 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 常用数据字段路径
|
||||||
|
|
||||||
|
| 数据 | 字段路径 |
|
||||||
|
|------|----------|
|
||||||
|
| 用户昵称 | user.nickname |
|
||||||
|
| 用户账号 | user.username |
|
||||||
|
| 学校名称 | user.tenant.name |
|
||||||
|
| 年级名称 | user.student.class.grade.name |
|
||||||
|
| 班级名称 | user.student.class.name |
|
||||||
|
| 指导老师 | teachers[].user.nickname |
|
||||||
|
| 报名人数 | _count.registrations |
|
||||||
|
| 作品数量 | _count.works |
|
||||||
|
| 团队名称 | team.teamName |
|
||||||
261
.claude/skills/backend-api.md
Normal file
261
.claude/skills/backend-api.md
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
# 后端接口生成规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本规范用于快速生成 NestJS 后端接口,包含:
|
||||||
|
- Controller(路由定义)
|
||||||
|
- Service(业务逻辑)
|
||||||
|
- DTO(参数校验)
|
||||||
|
- 前端 API 调用
|
||||||
|
|
||||||
|
## 快速生成指令格式
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成后端接口:
|
||||||
|
|
||||||
|
模块名称:xxx
|
||||||
|
接口路径:GET/POST /api/xxx
|
||||||
|
功能描述:xxx
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
- 参数1(类型,必填/选填)
|
||||||
|
- 参数2(类型,必填/选填)
|
||||||
|
|
||||||
|
返回字段:
|
||||||
|
- 字段1(来源表.字段)
|
||||||
|
- 字段2(关联表.字段)
|
||||||
|
|
||||||
|
关联查询:
|
||||||
|
- 表1 -> 表2(关联字段)
|
||||||
|
|
||||||
|
排序:字段名 + 排序方式
|
||||||
|
分页:是/否
|
||||||
|
```
|
||||||
|
|
||||||
|
## 文件结构
|
||||||
|
|
||||||
|
```
|
||||||
|
backend/src/模块名/
|
||||||
|
├── 模块名.module.ts # 模块定义
|
||||||
|
├── 模块名.controller.ts # 路由控制器
|
||||||
|
├── 模块名.service.ts # 业务逻辑
|
||||||
|
└── dto/
|
||||||
|
├── query-xxx.dto.ts # 查询参数 DTO
|
||||||
|
└── create-xxx.dto.ts # 创建参数 DTO
|
||||||
|
|
||||||
|
frontend/src/api/
|
||||||
|
└── 模块名.ts # 前端 API 调用
|
||||||
|
```
|
||||||
|
|
||||||
|
## Controller 模板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Controller, Get, Query, Param, UseGuards } from '@nestjs/common';
|
||||||
|
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
|
import { XxxService } from './xxx.service';
|
||||||
|
import { QueryXxxDto } from './dto/query-xxx.dto';
|
||||||
|
|
||||||
|
@Controller('api/xxx')
|
||||||
|
@UseGuards(JwtAuthGuard)
|
||||||
|
export class XxxController {
|
||||||
|
constructor(private readonly xxxService: XxxService) {}
|
||||||
|
|
||||||
|
@Get(':id/list')
|
||||||
|
async getList(
|
||||||
|
@Param('id') id: string,
|
||||||
|
@Query() queryDto: QueryXxxDto,
|
||||||
|
) {
|
||||||
|
return this.xxxService.findAll(+id, queryDto);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Service 模板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { QueryXxxDto } from './dto/query-xxx.dto';
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class XxxService {
|
||||||
|
constructor(private prisma: PrismaService) {}
|
||||||
|
|
||||||
|
async findAll(parentId: number, queryDto: QueryXxxDto) {
|
||||||
|
const { page = 1, pageSize = 10, ...filters } = queryDto;
|
||||||
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
const where: any = { parentId };
|
||||||
|
|
||||||
|
if (filters.field1) {
|
||||||
|
where.field1 = { contains: filters.field1 };
|
||||||
|
}
|
||||||
|
|
||||||
|
const [list, total] = await Promise.all([
|
||||||
|
this.prisma.table.findMany({
|
||||||
|
where,
|
||||||
|
skip,
|
||||||
|
take: pageSize,
|
||||||
|
orderBy: { sortField: 'desc' },
|
||||||
|
include: {
|
||||||
|
// 关联查询
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
this.prisma.table.count({ where }),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return { list, total, page, pageSize };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## DTO 模板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class QueryXxxDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
field1?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
field2?: string;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 前端 API 模板
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
import request from '@/utils/request'
|
||||||
|
|
||||||
|
export interface QueryXxxParams {
|
||||||
|
page?: number
|
||||||
|
pageSize?: number
|
||||||
|
field1?: string
|
||||||
|
field2?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XxxItem {
|
||||||
|
id: number
|
||||||
|
// ... 其他字段
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface XxxListResponse {
|
||||||
|
list: XxxItem[]
|
||||||
|
total: number
|
||||||
|
page: number
|
||||||
|
pageSize: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const xxxApi = {
|
||||||
|
getList(parentId: number, params: QueryXxxParams): Promise<XxxListResponse> {
|
||||||
|
return request.get(`/api/xxx/${parentId}/list`, { params })
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Prisma 关联查询示例
|
||||||
|
|
||||||
|
### 常用关联模式
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// 用户信息 + 租户 + 学生班级年级
|
||||||
|
user: {
|
||||||
|
include: {
|
||||||
|
tenant: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
grade: {
|
||||||
|
select: { id: true, name: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 指导老师列表
|
||||||
|
teachers: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, username: true, nickname: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 报名信息 + 用户 + 团队
|
||||||
|
registration: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: { id: true, username: true, nickname: true },
|
||||||
|
},
|
||||||
|
team: {
|
||||||
|
select: { id: true, teamName: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 示例:赛果发布详情接口
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成后端接口:
|
||||||
|
|
||||||
|
模块名称:results(已存在,需修改)
|
||||||
|
接口路径:GET /api/contests/:id/results/works
|
||||||
|
功能描述:获取赛事的作品列表(用于赛果发布)
|
||||||
|
|
||||||
|
查询参数:
|
||||||
|
- page(number,选填,默认1)
|
||||||
|
- pageSize(number,选填,默认10)
|
||||||
|
- workNo(string,选填,作品编号模糊搜索)
|
||||||
|
- accountNo(string,选填,报名账号模糊搜索)
|
||||||
|
|
||||||
|
返回字段:
|
||||||
|
- id, workNo, title, finalScore(作品表)
|
||||||
|
- registration.user.nickname, username(用户表)
|
||||||
|
- registration.user.tenant.name(租户表)
|
||||||
|
- registration.user.student.class.name, grade.name(班级年级)
|
||||||
|
- registration.teachers[].user.nickname(指导老师)
|
||||||
|
|
||||||
|
关联查询:
|
||||||
|
- ContestWork -> ContestRegistration(registrationId)
|
||||||
|
- ContestRegistration -> User(userId)
|
||||||
|
- User -> Tenant(tenantId)
|
||||||
|
- User -> Student -> Class -> Grade
|
||||||
|
- ContestRegistration -> ContestRegistrationTeacher -> User
|
||||||
|
|
||||||
|
排序:finalScore DESC
|
||||||
|
分页:是
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **权限控制**:Controller 需要添加 `@UseGuards(JwtAuthGuard)` 和权限装饰器
|
||||||
|
2. **参数校验**:DTO 使用 class-validator 进行校验
|
||||||
|
3. **分页处理**:统一使用 page/pageSize 参数
|
||||||
|
4. **空值处理**:查询条件为空时不添加到 where 条件
|
||||||
|
5. **关联数据**:使用 Prisma 的 include 进行关联查询
|
||||||
|
6. **性能优化**:只 select 需要的字段,避免查询过多数据
|
||||||
|
7. **错误处理**:Service 层抛出 NestJS 内置异常
|
||||||
310
.claude/skills/contest-detail-page.md
Normal file
310
.claude/skills/contest-detail-page.md
Normal file
@ -0,0 +1,310 @@
|
|||||||
|
# 赛事详情页面生成规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本规范用于快速生成赛事管理系统中的详情页面,这类页面具有统一的结构:
|
||||||
|
- 顶部导航栏(返回按钮 + 标题 + 操作按钮)
|
||||||
|
- 搜索表单
|
||||||
|
- 数据表格
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ [← 返回] 页面标题 [操作按钮] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 搜索表单: [搜索条件...] [搜索] [重置] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 数据表格 │
|
||||||
|
│ ┌───┬──────┬──────┬──────┬──────┐ │
|
||||||
|
│ │序号│ 列1 │ 列2 │ 列3 │ 操作 │ │
|
||||||
|
│ ├───┼──────┼──────┼──────┼──────┤ │
|
||||||
|
│ │ 1 │ ... │ ... │ ... │ ... │ │
|
||||||
|
│ └───┴──────┴──────┴──────┴──────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速生成指令格式
|
||||||
|
|
||||||
|
向 Claude 提供以下格式的指令即可快速生成详情页面:
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个详情页面:
|
||||||
|
|
||||||
|
页面名称:xxx
|
||||||
|
文件路径:xxx
|
||||||
|
数据来源:xxx API
|
||||||
|
|
||||||
|
标题:字段名(如 contestName)
|
||||||
|
操作按钮:按钮名称 -> 处理函数
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- 字段1(类型)
|
||||||
|
- 字段2(类型)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 列名1(数据字段)
|
||||||
|
- 列名2(数据字段)
|
||||||
|
- ...
|
||||||
|
|
||||||
|
排序:字段名 + 排序方式(升序/降序)
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置参数说明
|
||||||
|
|
||||||
|
### 1. 基础配置
|
||||||
|
|
||||||
|
| 参数 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| 页面名称 | 显示在标题旁 | 赛果发布详情 |
|
||||||
|
| 文件路径 | Vue 文件位置 | views/contests/results/Detail.vue |
|
||||||
|
| 数据来源 | API 模块 | resultsApi / worksApi |
|
||||||
|
| 标题字段 | 动态标题来源 | record.contestName |
|
||||||
|
| 返回路径 | 返回按钮跳转 | 上一页 / 指定路径 |
|
||||||
|
|
||||||
|
### 2. 操作按钮
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 名称 | 按钮显示文本 |
|
||||||
|
| 类型 | primary / default / danger |
|
||||||
|
| 处理函数 | 点击触发的方法 |
|
||||||
|
| 确认弹窗 | 是否需要二次确认 |
|
||||||
|
|
||||||
|
### 3. 搜索条件类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| input | 输入框 | 作品编号、账号 |
|
||||||
|
| select | 下拉选择 | 状态筛选 |
|
||||||
|
| date | 日期选择 | 提交日期 |
|
||||||
|
| dateRange | 日期范围 | 时间区间 |
|
||||||
|
|
||||||
|
### 4. 表格列渲染类型
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| index | 序号 | 自动计算 |
|
||||||
|
| text | 纯文本 | 直接显示字段值 |
|
||||||
|
| nested | 嵌套字段 | record.user?.nickname |
|
||||||
|
| array | 数组拼接 | teachers.map(t => t.name).join('、') |
|
||||||
|
| score | 分数格式 | 保留2位小数 |
|
||||||
|
| tag | 标签样式 | 状态标签 |
|
||||||
|
| org | 机构信息 | 学校 + 年级 + 班级 |
|
||||||
|
|
||||||
|
### 5. 排序配置
|
||||||
|
|
||||||
|
| 属性 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| 字段 | 排序依据的字段名 |
|
||||||
|
| 方式 | asc(升序)/ desc(降序)|
|
||||||
|
|
||||||
|
## 示例
|
||||||
|
|
||||||
|
### 赛果发布详情页
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个详情页面:
|
||||||
|
|
||||||
|
页面名称:赛果发布详情
|
||||||
|
文件路径:views/contests/results/Detail.vue
|
||||||
|
数据来源:resultsApi.getResults
|
||||||
|
|
||||||
|
标题:contestName(赛事名称)
|
||||||
|
操作按钮:发布赛果 -> handlePublish(需确认)
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- workNo 作品编号(input)
|
||||||
|
- accountNo 报名账号(input)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 序号(index)
|
||||||
|
- 作品编号(workNo)
|
||||||
|
- 评委评分(finalScore,分数格式)
|
||||||
|
- 姓名(registration.user.nickname)
|
||||||
|
- 账号(registration.user.username)
|
||||||
|
- 机构信息(registration.user,机构格式)
|
||||||
|
- 指导老师(registration.teachers,数组拼接)
|
||||||
|
|
||||||
|
排序:finalScore 降序
|
||||||
|
```
|
||||||
|
|
||||||
|
### 报名记录详情页
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个详情页面:
|
||||||
|
|
||||||
|
页面名称:报名记录
|
||||||
|
文件路径:views/contests/registrations/Records.vue
|
||||||
|
数据来源:registrationsApi.getList
|
||||||
|
|
||||||
|
标题:contestName(赛事名称)
|
||||||
|
操作按钮:无
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- accountNo 报名账号(input)
|
||||||
|
- registrationState 审核状态(select)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 序号(index)
|
||||||
|
- 报名账号(accountNo)
|
||||||
|
- 姓名(user.nickname)
|
||||||
|
- 机构信息(user,机构格式)
|
||||||
|
- 指导老师(teachers,数组拼接)
|
||||||
|
- 审核状态(registrationState,标签)
|
||||||
|
- 报名时间(registrationTime,日期)
|
||||||
|
- 操作(查看详情、审核)
|
||||||
|
|
||||||
|
排序:registrationTime 降序
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面模板代码
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="detail-page">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a-button type="text" @click="handleBack">
|
||||||
|
<template #icon><ArrowLeftOutlined /></template>
|
||||||
|
返回
|
||||||
|
</a-button>
|
||||||
|
<span class="page-title">{{ pageTitle }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<a-button type="primary" @click="handleAction">
|
||||||
|
{{ actionButtonText }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索表单 -->
|
||||||
|
<a-form
|
||||||
|
:model="searchParams"
|
||||||
|
layout="inline"
|
||||||
|
class="search-form"
|
||||||
|
@finish="handleSearch"
|
||||||
|
>
|
||||||
|
<!-- 根据配置生成搜索项 -->
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button style="margin-left: 8px" @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<!-- 根据配置生成列渲染 -->
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 特殊字段渲染规范
|
||||||
|
|
||||||
|
### 机构信息(org 类型)
|
||||||
|
```vue
|
||||||
|
<template v-else-if="column.key === 'org'">
|
||||||
|
<div>
|
||||||
|
<div>{{ record.user?.tenant?.name || "-" }}</div>
|
||||||
|
<div v-if="record.user?.student?.class" class="org-detail">
|
||||||
|
{{ record.user.student.class.grade?.name }}
|
||||||
|
{{ record.user.student.class.name }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
### 指导老师(数组拼接)
|
||||||
|
```vue
|
||||||
|
<template v-else-if="column.key === 'teachers'">
|
||||||
|
{{ formatTeachers(record.teachers || record.registration?.teachers) }}
|
||||||
|
</template>
|
||||||
|
|
||||||
|
// 格式化函数
|
||||||
|
const formatTeachers = (teachers: any[]) => {
|
||||||
|
if (!teachers || teachers.length === 0) return "-"
|
||||||
|
return teachers.map(t => t.user?.nickname || t.user?.username).join("、")
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 分数格式
|
||||||
|
```vue
|
||||||
|
<template v-else-if="column.key === 'score'">
|
||||||
|
<span v-if="record.finalScore !== null" class="score">
|
||||||
|
{{ Number(record.finalScore).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 样式规范
|
||||||
|
|
||||||
|
```css
|
||||||
|
.detail-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API 数据**:确保后端返回了所需的关联数据(如 user、teachers、tenant、student.class.grade)
|
||||||
|
2. **排序字段**:需要后端支持对应字段的排序
|
||||||
|
3. **权限控制**:操作按钮需要配置权限检查
|
||||||
|
4. **空值处理**:所有字段都需要处理空值情况,显示 "-"
|
||||||
|
5. **嵌套数据**:使用可选链操作符 `?.` 避免空指针错误
|
||||||
449
.claude/skills/contest-list-page.md
Normal file
449
.claude/skills/contest-list-page.md
Normal file
@ -0,0 +1,449 @@
|
|||||||
|
# 赛事列表页面生成规范
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
|
||||||
|
本规范用于快速生成赛事管理系统中的列表页面,这类页面具有统一的结构:
|
||||||
|
- Tab 切换(个人赛/团队赛)
|
||||||
|
- 搜索表单
|
||||||
|
- 数据表格
|
||||||
|
- 操作按钮
|
||||||
|
|
||||||
|
## 页面结构
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────┐
|
||||||
|
│ 标题卡片 (a-card) │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ Tab栏: [个人赛] [团队赛] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 搜索表单: [搜索条件...] [搜索] [重置] │
|
||||||
|
├─────────────────────────────────────────────────┤
|
||||||
|
│ 数据表格 │
|
||||||
|
│ ┌───┬──────┬──────┬──────┬──────┐ │
|
||||||
|
│ │序号│ 列1 │ 列2 │ 列3 │ 操作 │ │
|
||||||
|
│ ├───┼──────┼──────┼──────┼──────┤ │
|
||||||
|
│ │ 1 │ ... │ ... │ ... │ 详情 │ │
|
||||||
|
│ │ 2 │ ... │ ... │ ... │ 详情 │ │
|
||||||
|
│ └───┴──────┴──────┴──────┴──────┘ │
|
||||||
|
└─────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 配置参数
|
||||||
|
|
||||||
|
生成页面时需要提供以下配置:
|
||||||
|
|
||||||
|
### 1. 基础配置
|
||||||
|
|
||||||
|
| 参数 | 类型 | 必填 | 说明 |
|
||||||
|
|------|------|------|------|
|
||||||
|
| pageName | string | 是 | 页面名称,如"赛果发布"、"报名管理" |
|
||||||
|
| pageClass | string | 是 | CSS类名,如"results-page" |
|
||||||
|
| routePrefix | string | 是 | 路由前缀,如"results"、"registrations" |
|
||||||
|
| detailRouteName | string | 否 | 详情页路由名称 |
|
||||||
|
|
||||||
|
### 2. 搜索条件配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface SearchField {
|
||||||
|
field: string // 字段名
|
||||||
|
label: string // 标签文本
|
||||||
|
type: 'input' | 'select' | 'date' | 'dateRange' // 控件类型
|
||||||
|
placeholder?: string // 占位文本
|
||||||
|
width?: string // 宽度,默认 "200px"
|
||||||
|
options?: Array<{ label: string; value: string }> // select 类型的选项
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**示例**:
|
||||||
|
```typescript
|
||||||
|
const searchFields = [
|
||||||
|
{ field: 'contestName', label: '赛事名称', type: 'input', placeholder: '请输入赛事名称' },
|
||||||
|
{ field: 'status', label: '状态', type: 'select', options: [
|
||||||
|
{ label: '已发布', value: 'published' },
|
||||||
|
{ label: '未发布', value: 'unpublished' }
|
||||||
|
]}
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 表格列配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface TableColumn {
|
||||||
|
title: string // 列标题
|
||||||
|
key: string // 列标识
|
||||||
|
dataIndex?: string // 数据字段(简单字段直接映射)
|
||||||
|
width?: number // 列宽度
|
||||||
|
fixed?: 'left' | 'right' // 固定列
|
||||||
|
render?: 'index' | 'link' | 'count' | 'tag' | 'date' | 'dateRange' | 'custom' // 渲染类型
|
||||||
|
renderConfig?: {
|
||||||
|
// link 类型
|
||||||
|
clickHandler?: string // 点击处理函数名
|
||||||
|
// count 类型
|
||||||
|
countField?: string // _count 下的字段名
|
||||||
|
// tag 类型
|
||||||
|
colorMap?: Record<string, string> // 值到颜色的映射
|
||||||
|
textMap?: Record<string, string> // 值到文本的映射
|
||||||
|
// dateRange 类型
|
||||||
|
startField?: string // 开始时间字段
|
||||||
|
endField?: string // 结束时间字段
|
||||||
|
startLabel?: string // 开始标签
|
||||||
|
endLabel?: string // 结束标签
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
**常用渲染类型**:
|
||||||
|
|
||||||
|
| 类型 | 说明 | 示例 |
|
||||||
|
|------|------|------|
|
||||||
|
| index | 序号列 | 自动计算 (current-1)*pageSize+index+1 |
|
||||||
|
| link | 可点击链接 | 赛事名称点击跳转 |
|
||||||
|
| count | 统计数量 | record._count?.registrations |
|
||||||
|
| tag | 标签展示 | 状态标签(不同颜色) |
|
||||||
|
| date | 单个日期 | YYYY-MM-DD HH:mm |
|
||||||
|
| dateRange | 日期范围 | 开始:xxx / 结束:xxx |
|
||||||
|
| custom | 自定义渲染 | 需要单独写模板 |
|
||||||
|
|
||||||
|
### 4. 操作按钮配置
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
interface ActionButton {
|
||||||
|
text: string // 按钮文本
|
||||||
|
type?: 'link' | 'primary' | 'default' // 按钮类型
|
||||||
|
danger?: boolean // 是否危险按钮
|
||||||
|
permission?: string // 权限标识
|
||||||
|
handler: string // 处理函数名
|
||||||
|
disabled?: (record: any) => boolean // 禁用条件
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 页面模板
|
||||||
|
|
||||||
|
### 完整模板代码
|
||||||
|
|
||||||
|
```vue
|
||||||
|
<template>
|
||||||
|
<div class="{{pageClass}}">
|
||||||
|
<a-card class="mb-4">
|
||||||
|
<template #title>{{pageName}}</template>
|
||||||
|
</a-card>
|
||||||
|
|
||||||
|
<!-- Tab栏切换 -->
|
||||||
|
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||||
|
<a-tab-pane key="individual" tab="个人赛" />
|
||||||
|
<a-tab-pane key="team" tab="团队赛" />
|
||||||
|
</a-tabs>
|
||||||
|
|
||||||
|
<!-- 搜索表单 -->
|
||||||
|
<a-form
|
||||||
|
:model="searchParams"
|
||||||
|
layout="inline"
|
||||||
|
class="search-form"
|
||||||
|
@finish="handleSearch"
|
||||||
|
>
|
||||||
|
<!-- 根据 searchFields 配置生成 -->
|
||||||
|
<a-form-item v-for="field in searchFields" :key="field.field" :label="field.label">
|
||||||
|
<!-- input 类型 -->
|
||||||
|
<a-input
|
||||||
|
v-if="field.type === 'input'"
|
||||||
|
v-model:value="searchParams[field.field]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
allow-clear
|
||||||
|
:style="{ width: field.width || '200px' }"
|
||||||
|
/>
|
||||||
|
<!-- select 类型 -->
|
||||||
|
<a-select
|
||||||
|
v-else-if="field.type === 'select'"
|
||||||
|
v-model:value="searchParams[field.field]"
|
||||||
|
:placeholder="field.placeholder"
|
||||||
|
allow-clear
|
||||||
|
:style="{ width: field.width || '150px' }"
|
||||||
|
>
|
||||||
|
<a-select-option
|
||||||
|
v-for="opt in field.options"
|
||||||
|
:key="opt.value"
|
||||||
|
:value="opt.value"
|
||||||
|
>
|
||||||
|
{{ opt.label }}
|
||||||
|
</a-select-option>
|
||||||
|
</a-select>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button style="margin-left: 8px" @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<!-- 根据列配置渲染 -->
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from "vue"
|
||||||
|
import { useRouter, useRoute } from "vue-router"
|
||||||
|
import { message } from "ant-design-vue"
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||||
|
import { contestsApi, type Contest, type QueryContestParams } from "@/api/contests"
|
||||||
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const route = useRoute()
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
|
||||||
|
// Tab状态
|
||||||
|
const activeTab = ref<"individual" | "team">("individual")
|
||||||
|
|
||||||
|
// 列表状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const dataSource = ref<Contest[]>([])
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive<QueryContestParams>({
|
||||||
|
contestName: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
// 根据配置生成
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化日期
|
||||||
|
const formatDateTime = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return "-"
|
||||||
|
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取列表数据
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const params: QueryContestParams = {
|
||||||
|
...searchParams,
|
||||||
|
contestType: activeTab.value,
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
}
|
||||||
|
const response = await contestsApi.getList(params)
|
||||||
|
dataSource.value = response.list
|
||||||
|
pagination.total = response.total
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取列表失败")
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tab切换
|
||||||
|
const handleTabChange = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
// 重置所有搜索参数
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格变化
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.current = pag.current
|
||||||
|
pagination.pageSize = pag.pageSize
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查看详情
|
||||||
|
const handleViewDetail = (record: Contest) => {
|
||||||
|
router.push(`/${tenantCode}/contests/{{routePrefix}}/${record.id}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.{{pageClass}} {
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
```
|
||||||
|
|
||||||
|
## 使用示例
|
||||||
|
|
||||||
|
### 示例1:赛果发布列表
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```yaml
|
||||||
|
pageName: 赛果发布
|
||||||
|
pageClass: results-page
|
||||||
|
routePrefix: results
|
||||||
|
|
||||||
|
searchFields:
|
||||||
|
- field: contestName
|
||||||
|
label: 赛事名称
|
||||||
|
type: input
|
||||||
|
placeholder: 请输入赛事名称
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- title: 序号
|
||||||
|
key: index
|
||||||
|
width: 70
|
||||||
|
render: index
|
||||||
|
|
||||||
|
- title: 赛事名称
|
||||||
|
key: contestName
|
||||||
|
dataIndex: contestName
|
||||||
|
width: 200
|
||||||
|
|
||||||
|
- title: 报名人数
|
||||||
|
key: registrationCount
|
||||||
|
width: 100
|
||||||
|
render: count
|
||||||
|
renderConfig:
|
||||||
|
countField: registrations
|
||||||
|
|
||||||
|
- title: 提交作品数
|
||||||
|
key: worksCount
|
||||||
|
width: 100
|
||||||
|
render: count
|
||||||
|
renderConfig:
|
||||||
|
countField: works
|
||||||
|
|
||||||
|
- title: 操作
|
||||||
|
key: action
|
||||||
|
width: 100
|
||||||
|
fixed: right
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- text: 详情
|
||||||
|
handler: handleViewDetail
|
||||||
|
```
|
||||||
|
|
||||||
|
### 示例2:报名管理列表
|
||||||
|
|
||||||
|
**配置**:
|
||||||
|
```yaml
|
||||||
|
pageName: 报名管理
|
||||||
|
pageClass: registrations-page
|
||||||
|
routePrefix: registrations
|
||||||
|
|
||||||
|
searchFields:
|
||||||
|
- field: contestName
|
||||||
|
label: 赛事名称
|
||||||
|
type: input
|
||||||
|
placeholder: 请输入赛事名称
|
||||||
|
|
||||||
|
columns:
|
||||||
|
- title: 序号
|
||||||
|
key: index
|
||||||
|
width: 70
|
||||||
|
render: index
|
||||||
|
|
||||||
|
- title: 赛事名称
|
||||||
|
key: contestName
|
||||||
|
dataIndex: contestName
|
||||||
|
width: 250
|
||||||
|
|
||||||
|
- title: 主办单位
|
||||||
|
key: organizers
|
||||||
|
width: 200
|
||||||
|
render: custom # 需要格式化JSON数组
|
||||||
|
|
||||||
|
- title: 报名人数
|
||||||
|
key: registrationCount
|
||||||
|
width: 120
|
||||||
|
render: count
|
||||||
|
renderConfig:
|
||||||
|
countField: registrations
|
||||||
|
|
||||||
|
- title: 报名时间
|
||||||
|
key: registerTime
|
||||||
|
width: 200
|
||||||
|
render: dateRange
|
||||||
|
renderConfig:
|
||||||
|
startField: registerStartTime
|
||||||
|
endField: registerEndTime
|
||||||
|
startLabel: 开始
|
||||||
|
endLabel: 结束
|
||||||
|
|
||||||
|
- title: 操作
|
||||||
|
key: action
|
||||||
|
width: 220
|
||||||
|
fixed: right
|
||||||
|
|
||||||
|
actions:
|
||||||
|
- text: 报名记录
|
||||||
|
handler: handleViewRecords
|
||||||
|
- text: 启动报名
|
||||||
|
handler: handleStartRegistration
|
||||||
|
permission: contest:update
|
||||||
|
- text: 关闭报名
|
||||||
|
handler: handleStopRegistration
|
||||||
|
permission: contest:update
|
||||||
|
danger: true
|
||||||
|
```
|
||||||
|
|
||||||
|
## 快速生成指令
|
||||||
|
|
||||||
|
向 Claude 提供以下格式的指令即可快速生成页面:
|
||||||
|
|
||||||
|
```
|
||||||
|
请生成一个赛事列表页面:
|
||||||
|
|
||||||
|
页面名称:赛果发布
|
||||||
|
路由前缀:results
|
||||||
|
文件路径:frontend/src/views/contests/results/Index.vue
|
||||||
|
|
||||||
|
搜索条件:
|
||||||
|
- 赛事名称(输入框)
|
||||||
|
|
||||||
|
表格列:
|
||||||
|
- 序号
|
||||||
|
- 赛事名称
|
||||||
|
- 报名人数(_count.registrations)
|
||||||
|
- 提交作品数(_count.works)
|
||||||
|
- 操作(详情按钮)
|
||||||
|
|
||||||
|
详情跳转:/${tenantCode}/contests/results/${record.id}
|
||||||
|
```
|
||||||
|
|
||||||
|
## 注意事项
|
||||||
|
|
||||||
|
1. **API 数据**:确保后端 API 返回了所需的统计字段(如 `_count`)
|
||||||
|
2. **路由配置**:生成页面后需要在 `router/index.ts` 中配置路由
|
||||||
|
3. **权限控制**:操作按钮需要配置 `v-permission` 指令
|
||||||
|
4. **样式一致性**:使用 Ant Design Vue 组件,保持系统风格统一
|
||||||
|
5. **团队赛差异**:如果个人赛和团队赛的列不同,需要定义两套 columns
|
||||||
@ -6,6 +6,7 @@ import {
|
|||||||
Logger,
|
Logger,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
|
import { OssService } from '../oss/oss.service';
|
||||||
import { CreateTaskDto } from './dto/create-task.dto';
|
import { CreateTaskDto } from './dto/create-task.dto';
|
||||||
import { QueryTaskDto } from './dto/query-task.dto';
|
import { QueryTaskDto } from './dto/query-task.dto';
|
||||||
import { AI3DProvider, AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
|
import { AI3DProvider, AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
|
||||||
@ -21,6 +22,7 @@ export class AI3DService {
|
|||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private prisma: PrismaService,
|
private prisma: PrismaService,
|
||||||
|
private ossService: OssService,
|
||||||
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
|
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -270,14 +272,63 @@ export class AI3DService {
|
|||||||
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
||||||
|
|
||||||
if (result.status === 'completed' || result.status === 'failed') {
|
if (result.status === 'completed' || result.status === 'failed') {
|
||||||
|
let finalResultUrl = result.resultUrl;
|
||||||
|
let finalPreviewUrl = result.previewUrl;
|
||||||
|
let finalResultUrls = result.resultUrls || null;
|
||||||
|
let finalPreviewUrls = result.previewUrls || null;
|
||||||
|
|
||||||
|
// 3. 如果任务成功且COS已启用,转存文件到自己的COS
|
||||||
|
if (result.status === 'completed' && this.ossService.isEnabled()) {
|
||||||
|
try {
|
||||||
|
// 转存所有模型文件
|
||||||
|
if (result.resultUrls && result.resultUrls.length > 0) {
|
||||||
|
this.logger.log(`开始转存 ${result.resultUrls.length} 个模型文件到COS: ${taskId}`);
|
||||||
|
const uploadedModelUrls: string[] = [];
|
||||||
|
for (let i = 0; i < result.resultUrls.length; i++) {
|
||||||
|
const modelResult = await this.ossService.uploadAI3DModel(
|
||||||
|
result.resultUrls[i],
|
||||||
|
taskId,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
uploadedModelUrls.push(modelResult.modelUrl);
|
||||||
|
this.logger.log(`模型文件 ${i + 1} 转存完成: ${modelResult.modelUrl}`);
|
||||||
|
}
|
||||||
|
finalResultUrl = uploadedModelUrls[0];
|
||||||
|
finalResultUrls = uploadedModelUrls;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转存所有预览图
|
||||||
|
if (result.previewUrls && result.previewUrls.length > 0) {
|
||||||
|
this.logger.log(`开始转存 ${result.previewUrls.length} 个预览图到COS: ${taskId}`);
|
||||||
|
const uploadedPreviewUrls: string[] = [];
|
||||||
|
for (let i = 0; i < result.previewUrls.length; i++) {
|
||||||
|
const previewResult = await this.ossService.uploadAI3DPreview(
|
||||||
|
result.previewUrls[i],
|
||||||
|
taskId,
|
||||||
|
i,
|
||||||
|
);
|
||||||
|
uploadedPreviewUrls.push(previewResult.previewUrl);
|
||||||
|
this.logger.log(`预览图 ${i + 1} 转存完成: ${previewResult.previewUrl}`);
|
||||||
|
}
|
||||||
|
finalPreviewUrl = uploadedPreviewUrls[0];
|
||||||
|
finalPreviewUrls = uploadedPreviewUrls;
|
||||||
|
}
|
||||||
|
} catch (transferError) {
|
||||||
|
// 转存失败不影响任务完成,只记录日志
|
||||||
|
this.logger.error(
|
||||||
|
`文件转存失败,使用原始URL: ${transferError.message}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.prisma.aI3DTask.update({
|
await this.prisma.aI3DTask.update({
|
||||||
where: { id: taskId },
|
where: { id: taskId },
|
||||||
data: {
|
data: {
|
||||||
status: result.status,
|
status: result.status,
|
||||||
resultUrl: result.resultUrl,
|
resultUrl: finalResultUrl,
|
||||||
previewUrl: result.previewUrl,
|
previewUrl: finalPreviewUrl,
|
||||||
resultUrls: result.resultUrls || null,
|
resultUrls: finalResultUrls,
|
||||||
previewUrls: result.previewUrls || null,
|
previewUrls: finalPreviewUrls,
|
||||||
errorMessage: result.errorMessage,
|
errorMessage: result.errorMessage,
|
||||||
completeTime: new Date(),
|
completeTime: new Date(),
|
||||||
},
|
},
|
||||||
|
|||||||
@ -7,7 +7,6 @@ import {
|
|||||||
AI3DGenerateOptions,
|
AI3DGenerateOptions,
|
||||||
} from './ai-3d-provider.interface';
|
} from './ai-3d-provider.interface';
|
||||||
import { TencentCloudSigner } from '../utils/tencent-cloud-sign';
|
import { TencentCloudSigner } from '../utils/tencent-cloud-sign';
|
||||||
import { ZipHandler } from '../utils/zip-handler';
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 腾讯混元 3D Provider
|
* 腾讯混元 3D Provider
|
||||||
@ -202,6 +201,7 @@ export class HunyuanAI3DProvider implements AI3DProvider {
|
|||||||
|
|
||||||
// 如果任务完成,提取模型URL
|
// 如果任务完成,提取模型URL
|
||||||
// 根据API文档,返回的是 ResultFile3Ds 数组
|
// 根据API文档,返回的是 ResultFile3Ds 数组
|
||||||
|
// 注意:这里只返回原始URL,COS转存由AI3DService统一处理
|
||||||
if (status === 'completed' && result.ResultFile3Ds?.length > 0) {
|
if (status === 'completed' && result.ResultFile3Ds?.length > 0) {
|
||||||
const file3Ds = result.ResultFile3Ds;
|
const file3Ds = result.ResultFile3Ds;
|
||||||
// 提取所有模型URL和预览图URL
|
// 提取所有模型URL和预览图URL
|
||||||
@ -210,54 +210,18 @@ export class HunyuanAI3DProvider implements AI3DProvider {
|
|||||||
.map((file: any) => file.PreviewImageUrl)
|
.map((file: any) => file.PreviewImageUrl)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
// 处理.zip文件:下载并解压
|
|
||||||
if (urls.length > 0) {
|
if (urls.length > 0) {
|
||||||
const firstUrl = urls[0];
|
generateResult.resultUrl = urls[0];
|
||||||
|
|
||||||
// 检查是否是.zip文件
|
|
||||||
if (firstUrl.toLowerCase().endsWith('.zip')) {
|
|
||||||
this.logger.log(`检测到ZIP文件,开始下载并解压: ${firstUrl}`);
|
|
||||||
try {
|
|
||||||
const extracted = await ZipHandler.downloadAndExtract(firstUrl);
|
|
||||||
|
|
||||||
// 使用解压后的文件URL
|
|
||||||
generateResult.resultUrl = extracted.modelUrl;
|
|
||||||
generateResult.resultUrls = [extracted.modelUrl];
|
|
||||||
|
|
||||||
if (extracted.previewUrl) {
|
|
||||||
generateResult.previewUrl = extracted.previewUrl;
|
|
||||||
generateResult.previewUrls = [extracted.previewUrl];
|
|
||||||
} else if (previewUrls.length > 0) {
|
|
||||||
// 如果ZIP中没有预览图,使用API返回的预览图
|
|
||||||
generateResult.previewUrl = previewUrls[0];
|
|
||||||
generateResult.previewUrls = previewUrls;
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
|
||||||
`ZIP文件处理完成,模型URL: ${extracted.modelUrl}`,
|
|
||||||
);
|
|
||||||
} catch (error) {
|
|
||||||
this.logger.error(`处理ZIP文件失败: ${error.message}`);
|
|
||||||
// ZIP处理失败,尝试直接返回原始URL
|
|
||||||
generateResult.resultUrl = firstUrl;
|
|
||||||
generateResult.resultUrls = urls;
|
generateResult.resultUrls = urls;
|
||||||
generateResult.previewUrl = previewUrls[0];
|
|
||||||
generateResult.previewUrls = previewUrls;
|
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
// 不是ZIP文件,直接使用URL
|
|
||||||
generateResult.resultUrl = firstUrl;
|
|
||||||
generateResult.resultUrls = urls;
|
|
||||||
|
|
||||||
if (previewUrls.length > 0) {
|
if (previewUrls.length > 0) {
|
||||||
generateResult.previewUrl = previewUrls[0];
|
generateResult.previewUrl = previewUrls[0];
|
||||||
generateResult.previewUrls = previewUrls;
|
generateResult.previewUrls = previewUrls;
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
this.logger.log(
|
this.logger.log(
|
||||||
`混元3D任务 ${taskId} 完成: ${generateResult.resultUrls?.length || 0} 个模型文件`,
|
`混元3D任务 ${taskId} 完成: ${urls.length} 个模型文件, ${previewUrls.length} 个预览图`,
|
||||||
);
|
);
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
// 失败原因:根据文档,错误信息在 ErrorMessage 字段
|
// 失败原因:根据文档,错误信息在 ErrorMessage 字段
|
||||||
|
|||||||
@ -510,19 +510,7 @@ export class ContestsService {
|
|||||||
throw new NotFoundException('比赛不存在');
|
throw new NotFoundException('比赛不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果比赛已发布,检查是否有报名记录
|
// 允许取消发布,即使有报名记录也可以(保留报名和作品数据)
|
||||||
if (contest.contestState === 'published') {
|
|
||||||
const registrationCount = await this.prisma.contestRegistration.count({
|
|
||||||
where: { contestId: id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (
|
|
||||||
registrationCount > 0 &&
|
|
||||||
(updateContestDto as any).contestState === 'unpublished'
|
|
||||||
) {
|
|
||||||
throw new BadRequestException('比赛已有报名记录,无法撤回');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 如果更新了比赛名称,检查是否重复
|
// 如果更新了比赛名称,检查是否重复
|
||||||
if (
|
if (
|
||||||
@ -723,19 +711,7 @@ export class ContestsService {
|
|||||||
throw new NotFoundException('比赛不存在');
|
throw new NotFoundException('比赛不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 如果撤回比赛,检查是否有报名记录
|
// 允许取消发布,即使有报名记录也可以(保留报名和作品数据)
|
||||||
if (
|
|
||||||
contestState === 'unpublished' &&
|
|
||||||
contest.contestState === 'published'
|
|
||||||
) {
|
|
||||||
const registrationCount = await this.prisma.contestRegistration.count({
|
|
||||||
where: { contestId: id },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (registrationCount > 0) {
|
|
||||||
throw new BadRequestException('比赛已有报名记录,无法撤回');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const data: any = {
|
const data: any = {
|
||||||
contestState,
|
contestState,
|
||||||
@ -760,33 +736,57 @@ export class ContestsService {
|
|||||||
async remove(id: number) {
|
async remove(id: number) {
|
||||||
const contest = await this.prisma.contest.findUnique({
|
const contest = await this.prisma.contest.findUnique({
|
||||||
where: { id },
|
where: { id },
|
||||||
include: {
|
|
||||||
_count: {
|
|
||||||
select: {
|
|
||||||
registrations: true,
|
|
||||||
works: true,
|
|
||||||
teams: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!contest) {
|
if (!contest) {
|
||||||
throw new NotFoundException('比赛不存在');
|
throw new NotFoundException('比赛不存在');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 检查是否有报名记录
|
// 使用事务删除赛事及相关数据
|
||||||
if (contest._count.registrations > 0) {
|
// 注意:作品和报名记录保留(用户仍可在自己的数据中看到)
|
||||||
throw new BadRequestException('比赛已有报名记录,无法删除');
|
return this.prisma.$transaction(async (tx) => {
|
||||||
}
|
// 1. 删除团队成员记录
|
||||||
|
await tx.contestTeamMember.deleteMany({
|
||||||
|
where: {
|
||||||
|
team: {
|
||||||
|
contestId: id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
// 软删除
|
// 2. 删除团队记录
|
||||||
return this.prisma.contest.update({
|
await tx.contestTeam.deleteMany({
|
||||||
|
where: { contestId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 3. 删除评委分配记录
|
||||||
|
await tx.contestJudge.deleteMany({
|
||||||
|
where: { contestId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 4. 删除公告
|
||||||
|
await tx.contestNotice.deleteMany({
|
||||||
|
where: { contestId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 5. 删除作品评分记录
|
||||||
|
await tx.contestWorkScore.deleteMany({
|
||||||
|
where: { contestId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 6. 删除作品评委分配记录
|
||||||
|
await tx.contestWorkJudgeAssignment.deleteMany({
|
||||||
|
where: { contestId: id },
|
||||||
|
});
|
||||||
|
|
||||||
|
// 7. 软删除赛事(报名记录和作品保留,指向此软删除的赛事)
|
||||||
|
return tx.contest.update({
|
||||||
where: { id },
|
where: { id },
|
||||||
data: {
|
data: {
|
||||||
validState: 2,
|
validState: 2,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -326,6 +326,12 @@ export class RegistrationsService {
|
|||||||
},
|
},
|
||||||
user: {
|
user: {
|
||||||
include: {
|
include: {
|
||||||
|
tenant: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
student: {
|
student: {
|
||||||
include: {
|
include: {
|
||||||
class: {
|
class: {
|
||||||
@ -409,11 +415,27 @@ export class RegistrationsService {
|
|||||||
include: {
|
include: {
|
||||||
contest: true,
|
contest: true,
|
||||||
user: {
|
user: {
|
||||||
|
include: {
|
||||||
|
tenant: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
name: true,
|
||||||
nickname: true,
|
},
|
||||||
email: true,
|
},
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
grade: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
|
|||||||
24
backend/src/contests/results/dto/query-results.dto.ts
Normal file
24
backend/src/contests/results/dto/query-results.dto.ts
Normal file
@ -0,0 +1,24 @@
|
|||||||
|
import { IsOptional, IsString, IsInt, Min } from 'class-validator';
|
||||||
|
import { Type } from 'class-transformer';
|
||||||
|
|
||||||
|
export class QueryResultsDto {
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
page?: number = 1;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@Type(() => Number)
|
||||||
|
@IsInt()
|
||||||
|
@Min(1)
|
||||||
|
pageSize?: number = 10;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
workNo?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
accountNo?: string;
|
||||||
|
}
|
||||||
@ -13,6 +13,7 @@ import { ResultsService } from './results.service';
|
|||||||
import { SetAwardDto } from './dto/set-award.dto';
|
import { SetAwardDto } from './dto/set-award.dto';
|
||||||
import { BatchSetAwardsDto } from './dto/batch-set-awards.dto';
|
import { BatchSetAwardsDto } from './dto/batch-set-awards.dto';
|
||||||
import { AutoSetAwardsDto } from './dto/auto-set-awards.dto';
|
import { AutoSetAwardsDto } from './dto/auto-set-awards.dto';
|
||||||
|
import { QueryResultsDto } from './dto/query-results.dto';
|
||||||
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
import { JwtAuthGuard } from '../../auth/guards/jwt-auth.guard';
|
||||||
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
import { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||||
|
|
||||||
@ -94,20 +95,15 @@ export class ResultsController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取比赛结果列表
|
* 获取比赛结果列表(作品列表)
|
||||||
*/
|
*/
|
||||||
@Get(':contestId')
|
@Get(':contestId')
|
||||||
@RequirePermission('contest:read')
|
@RequirePermission('contest:read')
|
||||||
getResults(
|
getResults(
|
||||||
@Param('contestId', ParseIntPipe) contestId: number,
|
@Param('contestId', ParseIntPipe) contestId: number,
|
||||||
@Query('page') page?: string,
|
@Query() queryDto: QueryResultsDto,
|
||||||
@Query('pageSize') pageSize?: string,
|
|
||||||
) {
|
) {
|
||||||
return this.resultsService.getResults(
|
return this.resultsService.getResults(contestId, queryDto);
|
||||||
contestId,
|
|
||||||
page ? parseInt(page) : 1,
|
|
||||||
pageSize ? parseInt(pageSize) : 20,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -6,6 +6,7 @@ import {
|
|||||||
import { PrismaService } from '../../prisma/prisma.service';
|
import { PrismaService } from '../../prisma/prisma.service';
|
||||||
import { SetAwardDto } from './dto/set-award.dto';
|
import { SetAwardDto } from './dto/set-award.dto';
|
||||||
import { BatchSetAwardsDto } from './dto/batch-set-awards.dto';
|
import { BatchSetAwardsDto } from './dto/batch-set-awards.dto';
|
||||||
|
import { QueryResultsDto } from './dto/query-results.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ResultsService {
|
export class ResultsService {
|
||||||
@ -448,9 +449,11 @@ export class ResultsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 获取比赛结果列表
|
* 获取比赛结果列表(作品列表)
|
||||||
*/
|
*/
|
||||||
async getResults(contestId: number, page = 1, pageSize = 20) {
|
async getResults(contestId: number, queryDto: QueryResultsDto) {
|
||||||
|
const { page = 1, pageSize = 10, workNo, accountNo } = queryDto;
|
||||||
|
|
||||||
const contest = await this.prisma.contest.findUnique({
|
const contest = await this.prisma.contest.findUnique({
|
||||||
where: { id: contestId },
|
where: { id: contestId },
|
||||||
include: {
|
include: {
|
||||||
@ -464,21 +467,55 @@ export class ResultsService {
|
|||||||
|
|
||||||
const skip = (page - 1) * pageSize;
|
const skip = (page - 1) * pageSize;
|
||||||
|
|
||||||
const [works, total] = await Promise.all([
|
// 构建查询条件
|
||||||
this.prisma.contestWork.findMany({
|
const where: any = {
|
||||||
where: {
|
|
||||||
contestId,
|
contestId,
|
||||||
validState: 1,
|
validState: 1,
|
||||||
isLatest: true,
|
isLatest: true,
|
||||||
|
};
|
||||||
|
|
||||||
|
// 作品编号搜索
|
||||||
|
if (workNo) {
|
||||||
|
where.workNo = { contains: workNo };
|
||||||
|
}
|
||||||
|
|
||||||
|
// 报名账号搜索(需要关联查询)
|
||||||
|
if (accountNo) {
|
||||||
|
where.registration = {
|
||||||
|
user: {
|
||||||
|
username: { contains: accountNo },
|
||||||
},
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const [works, total] = await Promise.all([
|
||||||
|
this.prisma.contestWork.findMany({
|
||||||
|
where,
|
||||||
include: {
|
include: {
|
||||||
registration: {
|
registration: {
|
||||||
include: {
|
include: {
|
||||||
user: {
|
user: {
|
||||||
|
include: {
|
||||||
|
tenant: {
|
||||||
select: {
|
select: {
|
||||||
id: true,
|
id: true,
|
||||||
username: true,
|
name: true,
|
||||||
nickname: true,
|
},
|
||||||
|
},
|
||||||
|
student: {
|
||||||
|
include: {
|
||||||
|
class: {
|
||||||
|
include: {
|
||||||
|
grade: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
name: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
team: {
|
team: {
|
||||||
@ -487,59 +524,40 @@ export class ResultsService {
|
|||||||
teamName: true,
|
teamName: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
teachers: {
|
||||||
|
include: {
|
||||||
|
user: {
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
username: true,
|
||||||
|
nickname: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [
|
orderBy: [
|
||||||
{ rank: { sort: 'asc', nulls: 'last' } },
|
|
||||||
{ finalScore: 'desc' },
|
{ finalScore: 'desc' },
|
||||||
],
|
],
|
||||||
skip,
|
skip,
|
||||||
take: pageSize,
|
take: pageSize,
|
||||||
}),
|
}),
|
||||||
this.prisma.contestWork.count({
|
this.prisma.contestWork.count({ where }),
|
||||||
where: {
|
|
||||||
contestId,
|
|
||||||
validState: 1,
|
|
||||||
isLatest: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
// 统计奖项分布
|
|
||||||
const awardStats = await this.prisma.contestWork.groupBy({
|
|
||||||
by: ['awardLevel'],
|
|
||||||
where: {
|
|
||||||
contestId,
|
|
||||||
validState: 1,
|
|
||||||
isLatest: true,
|
|
||||||
awardLevel: { not: null },
|
|
||||||
},
|
|
||||||
_count: {
|
|
||||||
id: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const awardDistribution: Record<string, number> = {};
|
|
||||||
awardStats.forEach((stat) => {
|
|
||||||
if (stat.awardLevel) {
|
|
||||||
awardDistribution[stat.awardLevel] = stat._count.id;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contest: {
|
contest: {
|
||||||
id: contest.id,
|
id: contest.id,
|
||||||
contestName: contest.contestName,
|
contestName: contest.contestName,
|
||||||
resultState: contest.resultState,
|
resultState: contest.resultState,
|
||||||
resultPublishTime: contest.resultPublishTime,
|
resultPublishTime: contest.resultPublishTime,
|
||||||
reviewRule: contest.reviewRule,
|
|
||||||
},
|
},
|
||||||
list: works,
|
list: works,
|
||||||
total,
|
total,
|
||||||
page,
|
page,
|
||||||
pageSize,
|
pageSize,
|
||||||
awardDistribution,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
import { Injectable, BadRequestException, Logger } from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
import COS from 'cos-nodejs-sdk-v5';
|
import COS from 'cos-nodejs-sdk-v5';
|
||||||
import * as path from 'path';
|
import * as path from 'path';
|
||||||
|
import * as fs from 'fs';
|
||||||
|
import * as os from 'os';
|
||||||
import { randomBytes } from 'crypto';
|
import { randomBytes } from 'crypto';
|
||||||
|
import axios from 'axios';
|
||||||
|
import AdmZip from 'adm-zip';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class OssService {
|
export class OssService {
|
||||||
|
private readonly logger = new Logger(OssService.name);
|
||||||
private client: COS | null = null;
|
private client: COS | null = null;
|
||||||
private readonly bucket: string;
|
private readonly bucket: string;
|
||||||
private readonly region: string;
|
private readonly region: string;
|
||||||
@ -56,20 +61,16 @@ export class OssService {
|
|||||||
throw new BadRequestException('COS 服务未启用');
|
throw new BadRequestException('COS 服务未启用');
|
||||||
}
|
}
|
||||||
|
|
||||||
// 生成唯一文件名
|
// 生成唯一文件名,图片和文件分开存储
|
||||||
const fileExt = path.extname(originalName);
|
const fileExt = path.extname(originalName).toLowerCase();
|
||||||
const uniqueId = randomBytes(16).toString('hex');
|
const uniqueId = randomBytes(16).toString('hex');
|
||||||
const fileName = `${uniqueId}${fileExt}`;
|
|
||||||
|
|
||||||
// 构建 COS 存储路径:uploads/tenant_X/user_Y/filename
|
// 判断是否为图片
|
||||||
let cosPath = 'uploads';
|
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
|
||||||
if (tenantId) {
|
const isImage = imageExts.includes(fileExt);
|
||||||
cosPath += `/tenant_${tenantId}`;
|
|
||||||
if (userId) {
|
// 图片: img/{id}.ext, 文件: file/{id}.ext
|
||||||
cosPath += `/user_${userId}`;
|
const cosPath = isImage ? `img/${uniqueId}${fileExt}` : `file/${uniqueId}${fileExt}`;
|
||||||
}
|
|
||||||
}
|
|
||||||
cosPath += `/${fileName}`;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// 上传到 COS
|
// 上传到 COS
|
||||||
@ -199,4 +200,177 @@ export class OssService {
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 从URL下载文件到Buffer
|
||||||
|
* @param url 文件URL
|
||||||
|
* @returns 文件Buffer
|
||||||
|
*/
|
||||||
|
private async downloadFromUrl(url: string): Promise<Buffer> {
|
||||||
|
this.logger.log(`开始下载文件: ${url.substring(0, 100)}...`);
|
||||||
|
const response = await axios.get(url, {
|
||||||
|
responseType: 'arraybuffer',
|
||||||
|
timeout: 120000, // 2分钟超时
|
||||||
|
});
|
||||||
|
this.logger.log(`文件下载完成,大小: ${response.data.length} 字节`);
|
||||||
|
return Buffer.from(response.data);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传AI 3D模型文件到COS
|
||||||
|
* 支持ZIP格式(会解压提取GLB)和直接的GLB格式
|
||||||
|
* @param url 模型文件URL
|
||||||
|
* @param taskId 任务ID(用于目录隔离)
|
||||||
|
* @param index 文件索引(多个文件时区分)
|
||||||
|
* @returns 上传后的COS URL
|
||||||
|
*/
|
||||||
|
async uploadAI3DModel(
|
||||||
|
url: string,
|
||||||
|
taskId: number,
|
||||||
|
index: number = 0,
|
||||||
|
): Promise<{ modelUrl: string; ossPath: string }> {
|
||||||
|
if (!this.enabled || !this.client) {
|
||||||
|
throw new BadRequestException('COS 服务未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载文件
|
||||||
|
const fileBuffer = await this.downloadFromUrl(url);
|
||||||
|
|
||||||
|
// 检查是否是ZIP文件
|
||||||
|
const isZip = url.toLowerCase().includes('.zip') ||
|
||||||
|
(fileBuffer[0] === 0x50 && fileBuffer[1] === 0x4b); // ZIP magic number
|
||||||
|
|
||||||
|
let modelBuffer: Buffer;
|
||||||
|
let modelExt = '.glb';
|
||||||
|
|
||||||
|
if (isZip) {
|
||||||
|
this.logger.log('检测到ZIP文件,开始解压...');
|
||||||
|
// 解压ZIP文件
|
||||||
|
const zip = new AdmZip(fileBuffer);
|
||||||
|
const zipEntries = zip.getEntries();
|
||||||
|
|
||||||
|
// 打印ZIP中的所有文件,便于调试
|
||||||
|
const fileNames = zipEntries.map(e => e.entryName);
|
||||||
|
this.logger.log(`ZIP包含文件: ${fileNames.join(', ')}`);
|
||||||
|
|
||||||
|
// 按优先级查找3D模型文件
|
||||||
|
const modelFormats = ['.glb', '.gltf', '.obj', '.fbx', '.stl', '.usdz', '.usdc'];
|
||||||
|
let modelEntry: any = null;
|
||||||
|
|
||||||
|
for (const format of modelFormats) {
|
||||||
|
modelEntry = zipEntries.find(entry =>
|
||||||
|
entry.entryName.toLowerCase().endsWith(format)
|
||||||
|
);
|
||||||
|
if (modelEntry) {
|
||||||
|
modelExt = format;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!modelEntry) {
|
||||||
|
throw new Error(`ZIP文件中未找到3D模型文件,包含: ${fileNames.join(', ')}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`在ZIP中找到模型文件: ${modelEntry.entryName}`);
|
||||||
|
modelBuffer = modelEntry.getData();
|
||||||
|
} else {
|
||||||
|
// 直接是GLB/GLTF文件
|
||||||
|
modelBuffer = fileBuffer;
|
||||||
|
if (url.toLowerCase().includes('.gltf')) {
|
||||||
|
modelExt = '.gltf';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成COS路径(简化:3d/{taskId}.glb 或 3d/{taskId}_1.glb)
|
||||||
|
const cosPath = index === 0 ? `3d/${taskId}${modelExt}` : `3d/${taskId}_${index}${modelExt}`;
|
||||||
|
|
||||||
|
// 上传到COS
|
||||||
|
await new Promise<COS.PutObjectResult>((resolve, reject) => {
|
||||||
|
this.client!.putObject(
|
||||||
|
{
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Region: this.region,
|
||||||
|
Key: cosPath,
|
||||||
|
Body: modelBuffer,
|
||||||
|
},
|
||||||
|
(err, data) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const modelUrl = `https://${this.bucket}.cos.${this.region}.myqcloud.com/${cosPath}`;
|
||||||
|
this.logger.log(`模型文件上传成功: ${modelUrl}`);
|
||||||
|
|
||||||
|
return { modelUrl, ossPath: cosPath };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`上传AI 3D模型失败: ${error.message}`, error.stack);
|
||||||
|
throw new BadRequestException(`模型文件上传失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 上传AI 3D预览图到COS
|
||||||
|
* @param url 预览图URL
|
||||||
|
* @param taskId 任务ID(用于目录隔离)
|
||||||
|
* @param index 文件索引(多个文件时区分)
|
||||||
|
* @returns 上传后的COS URL
|
||||||
|
*/
|
||||||
|
async uploadAI3DPreview(
|
||||||
|
url: string,
|
||||||
|
taskId: number,
|
||||||
|
index: number = 0,
|
||||||
|
): Promise<{ previewUrl: string; ossPath: string }> {
|
||||||
|
if (!this.enabled || !this.client) {
|
||||||
|
throw new BadRequestException('COS 服务未启用');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 下载文件
|
||||||
|
const fileBuffer = await this.downloadFromUrl(url);
|
||||||
|
|
||||||
|
// 从URL中提取扩展名
|
||||||
|
let ext = '.png';
|
||||||
|
const urlPath = new URL(url).pathname;
|
||||||
|
const urlExt = path.extname(urlPath).toLowerCase();
|
||||||
|
if (['.jpg', '.jpeg', '.png', '.webp'].includes(urlExt)) {
|
||||||
|
ext = urlExt;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 生成COS路径(简化:3d/{taskId}_p.png 或 3d/{taskId}_p1.png)
|
||||||
|
const cosPath = index === 0 ? `3d/${taskId}_p${ext}` : `3d/${taskId}_p${index}${ext}`;
|
||||||
|
|
||||||
|
// 上传到COS
|
||||||
|
await new Promise<COS.PutObjectResult>((resolve, reject) => {
|
||||||
|
this.client!.putObject(
|
||||||
|
{
|
||||||
|
Bucket: this.bucket,
|
||||||
|
Region: this.region,
|
||||||
|
Key: cosPath,
|
||||||
|
Body: fileBuffer,
|
||||||
|
},
|
||||||
|
(err, data) => {
|
||||||
|
if (err) {
|
||||||
|
reject(err);
|
||||||
|
} else {
|
||||||
|
resolve(data);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
const previewUrl = `https://${this.bucket}.cos.${this.region}.myqcloud.com/${cosPath}`;
|
||||||
|
this.logger.log(`预览图上传成功: ${previewUrl}`);
|
||||||
|
|
||||||
|
return { previewUrl, ossPath: cosPath };
|
||||||
|
} catch (error: any) {
|
||||||
|
this.logger.error(`上传AI 3D预览图失败: ${error.message}`, error.stack);
|
||||||
|
throw new BadRequestException(`预览图上传失败: ${error.message}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { UploadController, UploadsController } from './upload.controller';
|
import { UploadController, UploadsController } from './upload.controller';
|
||||||
import { UploadService } from './upload.service';
|
import { UploadService } from './upload.service';
|
||||||
|
import { OssModule } from '../oss/oss.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [OssModule],
|
||||||
controllers: [UploadController, UploadsController],
|
controllers: [UploadController, UploadsController],
|
||||||
providers: [UploadService],
|
providers: [UploadService],
|
||||||
exports: [UploadService],
|
exports: [UploadService],
|
||||||
|
|||||||
645
frontend/package-lock.json
generated
645
frontend/package-lock.json
generated
@ -10,16 +10,20 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"@vee-validate/zod": "^4.12.4",
|
"@vee-validate/zod": "^4.12.4",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"ant-design-vue": "^4.1.1",
|
"ant-design-vue": "^4.1.1",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
"pinia": "^2.1.7",
|
"pinia": "^2.1.7",
|
||||||
|
"three": "^0.182.0",
|
||||||
"vee-validate": "^4.12.4",
|
"vee-validate": "^4.12.4",
|
||||||
"vue": "^3.4.21",
|
"vue": "^3.4.21",
|
||||||
"vue-router": "^4.3.0",
|
"vue-router": "^4.3.0",
|
||||||
"zod": "^3.22.4"
|
"zod": "^3.22.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/multer": "^2.0.0",
|
||||||
"@vitejs/plugin-vue": "^5.0.4",
|
"@vitejs/plugin-vue": "^5.0.4",
|
||||||
"@vue/eslint-config-typescript": "^13.0.0",
|
"@vue/eslint-config-typescript": "^13.0.0",
|
||||||
"autoprefixer": "^10.4.18",
|
"autoprefixer": "^10.4.18",
|
||||||
@ -1394,6 +1398,33 @@
|
|||||||
"nanopop": "^2.1.0"
|
"nanopop": "^2.1.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@transloadit/prettier-bytes": {
|
||||||
|
"version": "0.0.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@transloadit/prettier-bytes/-/prettier-bytes-0.0.7.tgz",
|
||||||
|
"integrity": "sha512-VeJbUb0wEKbcwaSlj5n+LscBl9IPgLPkHVGBkh00cztv6X4L/TJXK58LzFuBKX7/GAfiGhIwH67YTLTlzvIzBA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/body-parser": {
|
||||||
|
"version": "1.19.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
|
||||||
|
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/connect": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/connect": {
|
||||||
|
"version": "3.4.38",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
|
||||||
|
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@types/estree": {
|
"node_modules/@types/estree": {
|
||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||||
@ -1401,6 +1432,99 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/event-emitter": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-zx2/Gg0Eg7gwEiOIIh5w9TrhKKTeQh7CPCOPNc0el4pLSwzebA8SmnHwZs2dWlLONvyulykSwGSQxQHLhjGLvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/express": {
|
||||||
|
"version": "5.0.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
|
||||||
|
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/body-parser": "*",
|
||||||
|
"@types/express-serve-static-core": "^5.0.0",
|
||||||
|
"@types/serve-static": "^2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/express-serve-static-core": {
|
||||||
|
"version": "5.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
|
||||||
|
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*",
|
||||||
|
"@types/qs": "*",
|
||||||
|
"@types/range-parser": "*",
|
||||||
|
"@types/send": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/http-errors": {
|
||||||
|
"version": "2.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
|
||||||
|
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/multer": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-C3Z9v9Evij2yST3RSBktxP9STm6OdMc5uR1xF1SGr98uv8dUlAL2hqwrZ3GVB3uyMyiegnscEK6PGtYvNrjTjw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/express": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/node": {
|
||||||
|
"version": "25.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.0.8.tgz",
|
||||||
|
"integrity": "sha512-powIePYMmC3ibL0UJ2i2s0WIbq6cg6UyVFQxSCpaPxxzAaziRfimGivjdF943sSGV6RADVbk0Nvlm5P/FB44Zg==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"undici-types": "~7.16.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/qs": {
|
||||||
|
"version": "6.14.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
|
||||||
|
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/range-parser": {
|
||||||
|
"version": "1.2.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
|
||||||
|
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@types/send": {
|
||||||
|
"version": "1.2.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
|
||||||
|
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/serve-static": {
|
||||||
|
"version": "2.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
|
||||||
|
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/http-errors": "*",
|
||||||
|
"@types/node": "*"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@typescript-eslint/eslint-plugin": {
|
"node_modules/@typescript-eslint/eslint-plugin": {
|
||||||
"version": "7.18.0",
|
"version": "7.18.0",
|
||||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
|
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
|
||||||
@ -1601,6 +1725,61 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/@uppy/companion-client": {
|
||||||
|
"version": "2.2.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/companion-client/-/companion-client-2.2.2.tgz",
|
||||||
|
"integrity": "sha512-5mTp2iq97/mYSisMaBtFRry6PTgZA6SIL7LePteOV5x0/DxKfrZW3DEiQERJmYpHzy7k8johpm2gHnEKto56Og==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/utils": "^4.1.2",
|
||||||
|
"namespace-emitter": "^2.0.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/core": {
|
||||||
|
"version": "2.3.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/core/-/core-2.3.4.tgz",
|
||||||
|
"integrity": "sha512-iWAqppC8FD8mMVqewavCz+TNaet6HPXitmGXpGGREGrakZ4FeuWytVdrelydzTdXx6vVKkOmI2FLztGg73sENQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@transloadit/prettier-bytes": "0.0.7",
|
||||||
|
"@uppy/store-default": "^2.1.1",
|
||||||
|
"@uppy/utils": "^4.1.3",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"mime-match": "^1.0.2",
|
||||||
|
"namespace-emitter": "^2.0.1",
|
||||||
|
"nanoid": "^3.1.25",
|
||||||
|
"preact": "^10.5.13"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/store-default": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/store-default/-/store-default-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-xnpTxvot2SeAwGwbvmJ899ASk5tYXhmZzD/aCFsXePh/v8rNvR2pKlcQUH7cF/y4baUGq3FHO/daKCok/mpKqQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/utils": {
|
||||||
|
"version": "4.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/utils/-/utils-4.1.3.tgz",
|
||||||
|
"integrity": "sha512-nTuMvwWYobnJcytDO3t+D6IkVq/Qs4Xv3vyoEZ+Iaf8gegZP+rEyoaFT2CK5XLRMienPyqRqNbIfRuFaOWSIFw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"lodash.throttle": "^4.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@uppy/xhr-upload": {
|
||||||
|
"version": "2.1.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@uppy/xhr-upload/-/xhr-upload-2.1.3.tgz",
|
||||||
|
"integrity": "sha512-YWOQ6myBVPs+mhNjfdWsQyMRWUlrDLMoaG7nvf/G6Y3GKZf8AyjFDjvvJ49XWQ+DaZOftGkHmF1uh/DBeGivJQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/companion-client": "^2.2.2",
|
||||||
|
"@uppy/utils": "^4.1.2",
|
||||||
|
"nanoid": "^3.1.25"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.3.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@vee-validate/zod": {
|
"node_modules/@vee-validate/zod": {
|
||||||
"version": "4.15.1",
|
"version": "4.15.1",
|
||||||
"resolved": "https://registry.npmjs.org/@vee-validate/zod/-/zod-4.15.1.tgz",
|
"resolved": "https://registry.npmjs.org/@vee-validate/zod/-/zod-4.15.1.tgz",
|
||||||
@ -1840,6 +2019,165 @@
|
|||||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@wangeditor/basic-modules": {
|
||||||
|
"version": "1.1.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/basic-modules/-/basic-modules-1.1.7.tgz",
|
||||||
|
"integrity": "sha512-cY9CPkLJaqF05STqfpZKWG4LpxTMeGSIIF1fHvfm/mz+JXatCagjdkbxdikOuKYlxDdeqvOeBmsUBItufDLXZg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-url": "^1.2.4"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/code-highlight": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/code-highlight/-/code-highlight-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-iazHwO14XpCuIWJNTQTikqUhGKyqj+dUNWJ9288Oym9M2xMVHvnsOmDU2sgUDWVy+pOLojReMPgXCsvvNlOOhw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"prismjs": "^1.23.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/core": {
|
||||||
|
"version": "1.1.19",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/core/-/core-1.1.19.tgz",
|
||||||
|
"integrity": "sha512-KevkB47+7GhVszyYF2pKGKtCSj/YzmClsD03C3zTt+9SR2XWT5T0e3yQqg8baZpcMvkjs1D8Dv4fk8ok/UaS2Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/event-emitter": "^0.3.3",
|
||||||
|
"event-emitter": "^0.3.5",
|
||||||
|
"html-void-elements": "^2.0.0",
|
||||||
|
"i18next": "^20.4.0",
|
||||||
|
"scroll-into-view-if-needed": "^2.2.28",
|
||||||
|
"slate-history": "^0.66.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.1.1",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"is-hotkey": "^0.2.0",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lodash.toarray": "^4.4.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/editor": {
|
||||||
|
"version": "5.1.23",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/editor/-/editor-5.1.23.tgz",
|
||||||
|
"integrity": "sha512-0RxfeVTuK1tktUaPROnCoFfaHVJpRAIE2zdS0mpP+vq1axVQpLjM8+fCvKzqYIkH0Pg+C+44hJpe3VVroSkEuQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@uppy/core": "^2.1.1",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"@wangeditor/basic-modules": "^1.1.7",
|
||||||
|
"@wangeditor/code-highlight": "^1.0.3",
|
||||||
|
"@wangeditor/core": "^1.1.19",
|
||||||
|
"@wangeditor/list-module": "^1.0.5",
|
||||||
|
"@wangeditor/table-module": "^1.1.4",
|
||||||
|
"@wangeditor/upload-image-module": "^1.0.2",
|
||||||
|
"@wangeditor/video-module": "^1.1.4",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"is-hotkey": "^0.2.0",
|
||||||
|
"lodash.camelcase": "^4.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.debounce": "^4.0.8",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"lodash.toarray": "^4.4.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/editor-for-vue": {
|
||||||
|
"version": "5.1.12",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/editor-for-vue/-/editor-for-vue-5.1.12.tgz",
|
||||||
|
"integrity": "sha512-0Ds3D8I+xnpNWezAeO7HmPRgTfUxHLMd9JKcIw+QzvSmhC5xUHbpCcLU+KLmeBKTR/zffnS5GQo6qi3GhTMJWQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/editor": ">=5.1.0",
|
||||||
|
"vue": "^3.0.5"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/list-module": {
|
||||||
|
"version": "1.0.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/list-module/-/list-module-1.0.5.tgz",
|
||||||
|
"integrity": "sha512-uDuYTP6DVhcYf7mF1pTlmNn5jOb4QtcVhYwSSAkyg09zqxI1qBqsfUnveeDeDqIuptSJhkh81cyxi+MF8sEPOQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/table-module": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/table-module/-/table-module-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-5saanU9xuEocxaemGdNi9t8MCDSucnykEC6jtuiT72kt+/Hhh4nERYx1J20OPsTCCdVr7hIyQenFD1iSRkIQ6w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.isequal": "^4.5.0",
|
||||||
|
"lodash.throttle": "^4.1.1",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/upload-image-module": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/upload-image-module/-/upload-image-module-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-z81lk/v71OwPDYeQDxj6cVr81aDP90aFuywb8nPD6eQeECtOymrqRODjpO6VGvCVxVck8nUxBHtbxKtjgcwyiA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.0.3",
|
||||||
|
"@uppy/xhr-upload": "^2.0.3",
|
||||||
|
"@wangeditor/basic-modules": "1.x",
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"lodash.foreach": "^4.5.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@wangeditor/video-module": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@wangeditor/video-module/-/video-module-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-ZdodDPqKQrgx3IwWu4ZiQmXI8EXZ3hm2/fM6E3t5dB8tCaIGWQZhmqd6P5knfkRAd3z2+YRSRbxOGfoRSp/rLg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"peerDependencies": {
|
||||||
|
"@uppy/core": "^2.1.4",
|
||||||
|
"@uppy/xhr-upload": "^2.0.7",
|
||||||
|
"@wangeditor/core": "1.x",
|
||||||
|
"dom7": "^3.0.0",
|
||||||
|
"nanoid": "^3.2.0",
|
||||||
|
"slate": "^0.72.0",
|
||||||
|
"snabbdom": "^3.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/acorn": {
|
"node_modules/acorn": {
|
||||||
"version": "8.15.0",
|
"version": "8.15.0",
|
||||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||||
@ -2369,6 +2707,19 @@
|
|||||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/d": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"es5-ext": "^0.10.64",
|
||||||
|
"type": "^2.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dayjs": {
|
"node_modules/dayjs": {
|
||||||
"version": "1.11.19",
|
"version": "1.11.19",
|
||||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||||
@ -2482,6 +2833,15 @@
|
|||||||
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
|
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/dom7": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/dom7/-/dom7-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-oNlcUdHsC4zb7Msx7JN3K0Nro1dzJ48knvBOnDPKJ2GV9wl1i5vydJZUSyOfrkKFDZEud/jBsTk92S/VGSAe/g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"ssr-window": "^3.0.0-alpha.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/dunder-proto": {
|
"node_modules/dunder-proto": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
@ -2560,6 +2920,46 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/es5-ext": {
|
||||||
|
"version": "0.10.64",
|
||||||
|
"resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz",
|
||||||
|
"integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==",
|
||||||
|
"hasInstallScript": true,
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"es6-iterator": "^2.0.3",
|
||||||
|
"es6-symbol": "^3.1.3",
|
||||||
|
"esniff": "^2.0.1",
|
||||||
|
"next-tick": "^1.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es6-iterator": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "^0.10.35",
|
||||||
|
"es6-symbol": "^3.1.1"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/es6-symbol": {
|
||||||
|
"version": "3.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz",
|
||||||
|
"integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "^1.0.2",
|
||||||
|
"ext": "^1.7.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.12"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/esbuild": {
|
"node_modules/esbuild": {
|
||||||
"version": "0.21.5",
|
"version": "0.21.5",
|
||||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||||
@ -2756,6 +3156,21 @@
|
|||||||
"node": "*"
|
"node": "*"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/esniff": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "^1.0.1",
|
||||||
|
"es5-ext": "^0.10.62",
|
||||||
|
"event-emitter": "^0.3.5",
|
||||||
|
"type": "^2.7.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/espree": {
|
"node_modules/espree": {
|
||||||
"version": "9.6.1",
|
"version": "9.6.1",
|
||||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||||
@ -2826,6 +3241,25 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/event-emitter": {
|
||||||
|
"version": "0.3.5",
|
||||||
|
"resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz",
|
||||||
|
"integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"d": "1",
|
||||||
|
"es5-ext": "~0.10.14"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ext": {
|
||||||
|
"version": "1.7.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz",
|
||||||
|
"integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"type": "^2.7.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@ -3263,6 +3697,25 @@
|
|||||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/html-void-elements": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/html-void-elements/-/html-void-elements-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-0quDb7s97CfemeJAnW9wC0hw78MtW7NU3hqtCD75g2vFlDLt36llsYD7uB7SUzojLMP24N5IatXf7ylGXiGG9A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "github",
|
||||||
|
"url": "https://github.com/sponsors/wooorm"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/i18next": {
|
||||||
|
"version": "20.6.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-20.6.1.tgz",
|
||||||
|
"integrity": "sha512-yCMYTMEJ9ihCwEQQ3phLo7I/Pwycf8uAx+sRHwwk5U9Aui/IZYgQRyMqXafQOw5QQ7DM1Z+WyEXWIqSuJHhG2A==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/ignore": {
|
"node_modules/ignore": {
|
||||||
"version": "5.3.2",
|
"version": "5.3.2",
|
||||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||||
@ -3273,6 +3726,16 @@
|
|||||||
"node": ">= 4"
|
"node": ">= 4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/immer": {
|
||||||
|
"version": "9.0.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/immer/-/immer-9.0.21.tgz",
|
||||||
|
"integrity": "sha512-bc4NBHqOqSfRW7POMkHd51LvClaeMXpm8dx0e8oE2GORbq5aRK7Bxl4FyzVLdGtLmvLKL7BTDBG5ACQm4HWjTA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/immer"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/immutable": {
|
"node_modules/immutable": {
|
||||||
"version": "5.1.4",
|
"version": "5.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||||
@ -3378,6 +3841,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-hotkey": {
|
||||||
|
"version": "0.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-hotkey/-/is-hotkey-0.2.0.tgz",
|
||||||
|
"integrity": "sha512-UknnZK4RakDmTgz4PI1wIph5yxSs/mvChWs9ifnlXsKuXgWmOkY/hAE0H/k2MIqH0RlRye0i1oC07MCRSD28Mw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-number": {
|
"node_modules/is-number": {
|
||||||
"version": "7.0.0",
|
"version": "7.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||||
@ -3407,6 +3876,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-url": {
|
||||||
|
"version": "1.2.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz",
|
||||||
|
"integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/is-what": {
|
"node_modules/is-what": {
|
||||||
"version": "5.5.0",
|
"version": "5.5.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||||
@ -3548,6 +4023,37 @@
|
|||||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.camelcase": {
|
||||||
|
"version": "4.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz",
|
||||||
|
"integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.debounce": {
|
||||||
|
"version": "4.0.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||||
|
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.foreach": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.foreach/-/lodash.foreach-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-aEXTF4d+m05rVOAUG3z4vZZ4xVexLKZGF0lIxuHZ1Hplpk/3B6Z1+/ICICYRLm7c41Z2xiejbkCkJoTlypoXhQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@ -3555,6 +4061,18 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash.throttle": {
|
||||||
|
"version": "4.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.throttle/-/lodash.throttle-4.1.1.tgz",
|
||||||
|
"integrity": "sha512-wIkUCfVKpVsWo3JSZlc+8MB5it+2AN5W8J7YVMST30UrvcQNZ1Okbj+rbVniijTWE6FGYy4XJq/rHkas8qJMLQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.toarray": {
|
||||||
|
"version": "4.4.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.toarray/-/lodash.toarray-4.4.0.tgz",
|
||||||
|
"integrity": "sha512-QyffEA3i5dma5q2490+SgCvDN0pXLmRGSyAANuVi0HQ01Pkfr9fuoKQW8wm1wGBnJITs/mS7wQvS6VshUEBFCw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/loose-envify": {
|
"node_modules/loose-envify": {
|
||||||
"version": "1.4.0",
|
"version": "1.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||||
@ -3618,6 +4136,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/mime-match": {
|
||||||
|
"version": "1.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/mime-match/-/mime-match-1.0.2.tgz",
|
||||||
|
"integrity": "sha512-VXp/ugGDVh3eCLOBCiHZMYWQaTNUHv2IJrut+yXA6+JbLPXHglHwfS/5A5L0ll+jkCY7fIzRJcH6OIunF+c6Cg==",
|
||||||
|
"license": "ISC",
|
||||||
|
"dependencies": {
|
||||||
|
"wildcard": "^1.1.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/mime-types": {
|
"node_modules/mime-types": {
|
||||||
"version": "2.1.35",
|
"version": "2.1.35",
|
||||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||||
@ -3678,6 +4205,12 @@
|
|||||||
"thenify-all": "^1.0.0"
|
"thenify-all": "^1.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/namespace-emitter": {
|
||||||
|
"version": "2.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/namespace-emitter/-/namespace-emitter-2.0.1.tgz",
|
||||||
|
"integrity": "sha512-N/sMKHniSDJBjfrkbS/tpkPj4RAbvW3mr8UAzvlMHyun93XEm83IAvhWtJVHo+RHn/oO8Job5YN4b+wRjSVp5g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/nanoid": {
|
"node_modules/nanoid": {
|
||||||
"version": "3.3.11",
|
"version": "3.3.11",
|
||||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||||
@ -3709,6 +4242,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/next-tick": {
|
||||||
|
"version": "1.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz",
|
||||||
|
"integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/node-addon-api": {
|
"node_modules/node-addon-api": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||||
@ -4123,6 +4662,16 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/preact": {
|
||||||
|
"version": "10.28.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/preact/-/preact-10.28.2.tgz",
|
||||||
|
"integrity": "sha512-lbteaWGzGHdlIuiJ0l2Jq454m6kcpI1zNje6d8MlGAFlYvP2GO4ibnat7P74Esfz4sPTdM6UxtTwh/d3pwM9JA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"funding": {
|
||||||
|
"type": "opencollective",
|
||||||
|
"url": "https://opencollective.com/preact"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/prelude-ls": {
|
"node_modules/prelude-ls": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||||
@ -4133,6 +4682,15 @@
|
|||||||
"node": ">= 0.8.0"
|
"node": ">= 0.8.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/prismjs": {
|
||||||
|
"version": "1.30.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/prismjs/-/prismjs-1.30.0.tgz",
|
||||||
|
"integrity": "sha512-DEvV2ZF2r2/63V+tK8hQvrR2ZGn10srHbXviTlcv7Kpzw8jWiNTqbVgjO3IY8RxrrOUF8VPMQQFysYYYv0YZxw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/proxy-from-env": {
|
"node_modules/proxy-from-env": {
|
||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||||
@ -4413,6 +4971,56 @@
|
|||||||
"node": ">=8"
|
"node": ">=8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/slate": {
|
||||||
|
"version": "0.72.8",
|
||||||
|
"resolved": "https://registry.npmjs.org/slate/-/slate-0.72.8.tgz",
|
||||||
|
"integrity": "sha512-/nJwTswQgnRurpK+bGJFH1oM7naD5qDmHd89JyiKNT2oOKD8marW0QSBtuFnwEbL5aGCS8AmrhXQgNOsn4osAw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"immer": "^9.0.6",
|
||||||
|
"is-plain-object": "^5.0.0",
|
||||||
|
"tiny-warning": "^1.0.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slate-history": {
|
||||||
|
"version": "0.66.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/slate-history/-/slate-history-0.66.0.tgz",
|
||||||
|
"integrity": "sha512-6MWpxGQZiMvSINlCbMW43E2YBSVMCMCIwQfBzGssjWw4kb0qfvj0pIdblWNRQZD0hR6WHP+dHHgGSeVdMWzfng==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-plain-object": "^5.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"slate": ">=0.65.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slate-history/node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/slate/node_modules/is-plain-object": {
|
||||||
|
"version": "5.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-plain-object/-/is-plain-object-5.0.0.tgz",
|
||||||
|
"integrity": "sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/snabbdom": {
|
||||||
|
"version": "3.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/snabbdom/-/snabbdom-3.6.3.tgz",
|
||||||
|
"integrity": "sha512-W2lHLLw2qR2Vv0DcMmcxXqcfdBaIcoN+y/86SmHv8fn4DazEQSH6KN3TjZcWvwujW56OHiiirsbHWZb4vx/0fg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.17.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/source-map-js": {
|
"node_modules/source-map-js": {
|
||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||||
@ -4431,6 +5039,12 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/ssr-window": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/ssr-window/-/ssr-window-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-q+8UfWDg9Itrg0yWK7oe5p/XRCJpJF9OBtXfOPgSJl+u3Xd5KI328RUEvUqSMVM9CiQUEf1QdBzJMkYGErj9QA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/strip-ansi": {
|
"node_modules/strip-ansi": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||||
@ -4643,6 +5257,12 @@
|
|||||||
"node": ">=0.8"
|
"node": ">=0.8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/three": {
|
||||||
|
"version": "0.182.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/three/-/three-0.182.0.tgz",
|
||||||
|
"integrity": "sha512-GbHabT+Irv+ihI1/f5kIIsZ+Ef9Sl5A1Y7imvS5RQjWgtTPfPnZ43JmlYI7NtCRDK9zir20lQpfg8/9Yd02OvQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/throttle-debounce": {
|
"node_modules/throttle-debounce": {
|
||||||
"version": "5.0.2",
|
"version": "5.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||||
@ -4652,6 +5272,12 @@
|
|||||||
"node": ">=12.22"
|
"node": ">=12.22"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/tiny-warning": {
|
||||||
|
"version": "1.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
|
||||||
|
"integrity": "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/tinyglobby": {
|
"node_modules/tinyglobby": {
|
||||||
"version": "0.2.15",
|
"version": "0.2.15",
|
||||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||||
@ -4733,6 +5359,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0"
|
"license": "Apache-2.0"
|
||||||
},
|
},
|
||||||
|
"node_modules/type": {
|
||||||
|
"version": "2.7.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz",
|
||||||
|
"integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==",
|
||||||
|
"license": "ISC"
|
||||||
|
},
|
||||||
"node_modules/type-check": {
|
"node_modules/type-check": {
|
||||||
"version": "0.4.0",
|
"version": "0.4.0",
|
||||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||||
@ -4772,6 +5404,13 @@
|
|||||||
"node": ">=14.17"
|
"node": ">=14.17"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/undici-types": {
|
||||||
|
"version": "7.16.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
|
||||||
|
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/update-browserslist-db": {
|
"node_modules/update-browserslist-db": {
|
||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||||
@ -5058,6 +5697,12 @@
|
|||||||
"node": ">= 8"
|
"node": ">= 8"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/wildcard": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/wildcard/-/wildcard-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-DXukZJxpHA8LuotRwL0pP1+rS6CS7FF2qStDDE1C7DDg2rLud2PXRMuEDYIPhgEezwnlHNL4c+N6MfMTjCGTng==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/word-wrap": {
|
"node_modules/word-wrap": {
|
||||||
"version": "1.2.5",
|
"version": "1.2.5",
|
||||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||||
|
|||||||
@ -11,6 +11,8 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@ant-design/icons-vue": "^7.0.1",
|
"@ant-design/icons-vue": "^7.0.1",
|
||||||
"@vee-validate/zod": "^4.12.4",
|
"@vee-validate/zod": "^4.12.4",
|
||||||
|
"@wangeditor/editor": "^5.1.23",
|
||||||
|
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||||
"ant-design-vue": "^4.1.1",
|
"ant-design-vue": "^4.1.1",
|
||||||
"axios": "^1.6.7",
|
"axios": "^1.6.7",
|
||||||
"dayjs": "^1.11.10",
|
"dayjs": "^1.11.10",
|
||||||
|
|||||||
@ -1310,11 +1310,19 @@ export const resultsApi = {
|
|||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 获取比赛结果列表
|
// 获取比赛结果列表(作品列表)
|
||||||
getResults: async (contestId: number, page = 1, pageSize = 20): Promise<ResultsResponse> => {
|
getResults: async (
|
||||||
|
contestId: number,
|
||||||
|
params: {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
workNo?: string;
|
||||||
|
accountNo?: string;
|
||||||
|
} = {}
|
||||||
|
): Promise<ResultsResponse> => {
|
||||||
const response = await request.get<any, ResultsResponse>(
|
const response = await request.get<any, ResultsResponse>(
|
||||||
`/contests/results/${contestId}`,
|
`/contests/results/${contestId}`,
|
||||||
{ params: { page, pageSize } }
|
{ params: { page: params.page || 1, pageSize: params.pageSize || 10, ...params } }
|
||||||
);
|
);
|
||||||
return response;
|
return response;
|
||||||
},
|
},
|
||||||
|
|||||||
@ -153,3 +153,4 @@ export const judgesManagementApi = {
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
122
frontend/src/components/RichTextEditor.vue
Normal file
122
frontend/src/components/RichTextEditor.vue
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
<template>
|
||||||
|
<div class="rich-text-editor">
|
||||||
|
<Toolbar
|
||||||
|
:editor="editorRef"
|
||||||
|
:defaultConfig="toolbarConfig"
|
||||||
|
:mode="mode"
|
||||||
|
class="toolbar"
|
||||||
|
/>
|
||||||
|
<Editor
|
||||||
|
:defaultConfig="editorConfig"
|
||||||
|
:mode="mode"
|
||||||
|
v-model="valueHtml"
|
||||||
|
class="editor"
|
||||||
|
@onCreated="handleCreated"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, shallowRef, watch, onBeforeUnmount } from "vue"
|
||||||
|
import { Editor, Toolbar } from "@wangeditor/editor-for-vue"
|
||||||
|
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor/editor"
|
||||||
|
import { uploadFile } from "@/api/upload"
|
||||||
|
import "@wangeditor/editor/dist/css/style.css"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: string
|
||||||
|
placeholder?: string
|
||||||
|
height?: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", value: string): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
// 编辑器实例
|
||||||
|
const editorRef = shallowRef<IDomEditor | null>(null)
|
||||||
|
const valueHtml = ref(props.modelValue || "")
|
||||||
|
const mode = "default"
|
||||||
|
|
||||||
|
// 监听外部值变化
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(newVal) => {
|
||||||
|
if (newVal !== valueHtml.value) {
|
||||||
|
valueHtml.value = newVal || ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
// 监听内部值变化,触发更新
|
||||||
|
watch(valueHtml, (newVal) => {
|
||||||
|
emit("update:modelValue", newVal)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 工具栏配置
|
||||||
|
const toolbarConfig: Partial<IToolbarConfig> = {
|
||||||
|
excludeKeys: [
|
||||||
|
"group-video", // 排除视频
|
||||||
|
"fullScreen", // 排除全屏
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器配置
|
||||||
|
const editorConfig: Partial<IEditorConfig> = {
|
||||||
|
placeholder: props.placeholder || "请输入内容...",
|
||||||
|
MENU_CONF: {
|
||||||
|
// 上传图片配置
|
||||||
|
uploadImage: {
|
||||||
|
async customUpload(file: File, insertFn: (url: string) => void) {
|
||||||
|
try {
|
||||||
|
const result: any = await uploadFile(file)
|
||||||
|
const url = result.data?.url || result.url
|
||||||
|
if (url) {
|
||||||
|
insertFn(url)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("图片上传失败:", error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// 编辑器创建完成
|
||||||
|
const handleCreated = (editor: IDomEditor) => {
|
||||||
|
editorRef.value = editor
|
||||||
|
}
|
||||||
|
|
||||||
|
// 组件销毁时,销毁编辑器
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
const editor = editorRef.value
|
||||||
|
if (editor) {
|
||||||
|
editor.destroy()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rich-text-editor {
|
||||||
|
border: 1px solid #d9d9d9;
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar {
|
||||||
|
border-bottom: 1px solid #d9d9d9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor {
|
||||||
|
height: v-bind('(props.height || 300) + "px"');
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor :deep(.w-e-text-container) {
|
||||||
|
background-color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.rich-text-editor :deep(.w-e-toolbar) {
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -123,6 +123,17 @@ const baseRoutes: RouteRecordRaw[] = [
|
|||||||
permissions: ["review:read"],
|
permissions: ["review:read"],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
// 赛果发布详情路由
|
||||||
|
{
|
||||||
|
path: "contests/results/:id",
|
||||||
|
name: "ContestsResultsDetail",
|
||||||
|
component: () => import("@/views/contests/results/Detail.vue"),
|
||||||
|
meta: {
|
||||||
|
title: "赛果发布详情",
|
||||||
|
requiresAuth: true,
|
||||||
|
permissions: ["result:read"],
|
||||||
|
},
|
||||||
|
},
|
||||||
// 参赛作品详情列表路由
|
// 参赛作品详情列表路由
|
||||||
{
|
{
|
||||||
path: "contests/works/:id/list",
|
path: "contests/works/:id/list",
|
||||||
|
|||||||
@ -44,7 +44,8 @@
|
|||||||
|
|
||||||
<!-- 卡片描述 -->
|
<!-- 卡片描述 -->
|
||||||
<div class="card-desc">
|
<div class="card-desc">
|
||||||
赛事时间:{{ formatDate(contest.startTime) }} ~ {{ formatDate(contest.endTime) }}
|
赛事时间:{{ formatDate(contest.startTime) }} ~
|
||||||
|
{{ formatDate(contest.endTime) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- 卡片标签 -->
|
<!-- 卡片标签 -->
|
||||||
@ -52,7 +53,10 @@
|
|||||||
<span class="tag tag-type">
|
<span class="tag tag-type">
|
||||||
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
|
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
|
||||||
</span>
|
</span>
|
||||||
<span class="tag tag-status" :class="{ 'tag-ongoing': contest.status === 'ongoing' }">
|
<span
|
||||||
|
class="tag tag-status"
|
||||||
|
:class="{ 'tag-ongoing': contest.status === 'ongoing' }"
|
||||||
|
>
|
||||||
{{ getStatusText(contest) }}
|
{{ getStatusText(contest) }}
|
||||||
</span>
|
</span>
|
||||||
<span v-if="getStageText(contest)" class="tag tag-stage">
|
<span v-if="getStageText(contest)" class="tag tag-stage">
|
||||||
@ -452,11 +456,6 @@ $primary-light: #1677ff;
|
|||||||
&:hover {
|
&:hover {
|
||||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||||
transform: translateY(-2px);
|
transform: translateY(-2px);
|
||||||
|
|
||||||
.card-icon {
|
|
||||||
background: linear-gradient(135deg, #ff7a45 0%, #fa541c 100%);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-icon {
|
.card-icon {
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="create-contest-page">
|
<div class="create-contest-page">
|
||||||
<a-spin :spinning="loading">
|
<a-spin :spinning="pageLoading">
|
||||||
<a-card>
|
<a-card>
|
||||||
<template #title>
|
<template #title>
|
||||||
<a-space>
|
<a-space>
|
||||||
@ -17,9 +17,7 @@
|
|||||||
>赛事管理</router-link
|
>赛事管理</router-link
|
||||||
>
|
>
|
||||||
</a-breadcrumb-item>
|
</a-breadcrumb-item>
|
||||||
<a-breadcrumb-item>{{
|
<a-breadcrumb-item>{{ isEdit ? '编辑比赛' : '创建比赛' }}</a-breadcrumb-item>
|
||||||
isEditMode ? "编辑比赛" : "创建比赛"
|
|
||||||
}}</a-breadcrumb-item>
|
|
||||||
</a-breadcrumb>
|
</a-breadcrumb>
|
||||||
</a-space>
|
</a-space>
|
||||||
</template>
|
</template>
|
||||||
@ -90,13 +88,11 @@
|
|||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="赛事详情" name="content" required>
|
<a-form-item label="赛事详情" name="content" required>
|
||||||
<a-textarea
|
<RichTextEditor
|
||||||
v-model:value="form.content"
|
v-model="form.content"
|
||||||
placeholder="请输入赛事详细说明"
|
placeholder="请输入赛事详细说明"
|
||||||
:rows="8"
|
:height="300"
|
||||||
:maxlength="5000"
|
style="width: 800px"
|
||||||
show-count
|
|
||||||
style="width: 600px"
|
|
||||||
/>
|
/>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="赛事封面" name="coverUrl" required>
|
<a-form-item label="赛事封面" name="coverUrl" required>
|
||||||
@ -202,25 +198,8 @@
|
|||||||
placeholder="请选择评审规则(可选)"
|
placeholder="请选择评审规则(可选)"
|
||||||
style="width: 600px"
|
style="width: 600px"
|
||||||
:options="reviewRuleOptions"
|
:options="reviewRuleOptions"
|
||||||
:loading="reviewRuleLoading"
|
|
||||||
allow-clear
|
allow-clear
|
||||||
>
|
/>
|
||||||
<template
|
|
||||||
v-if="reviewRuleOptions.length === 0 && !reviewRuleLoading"
|
|
||||||
#notFoundContent
|
|
||||||
>
|
|
||||||
<div style="padding: 8px; text-align: center; color: #999">
|
|
||||||
<div>暂无评审规则</div>
|
|
||||||
<div style="margin-top: 4px; font-size: 12px">
|
|
||||||
请先前往
|
|
||||||
<a @click="goToReviewRules" style="color: #1890ff"
|
|
||||||
>评审规则列表</a
|
|
||||||
>
|
|
||||||
新增规则
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</template>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="评审时间" name="reviewTimeRange" required>
|
<a-form-item label="评审时间" name="reviewTimeRange" required>
|
||||||
<a-range-picker
|
<a-range-picker
|
||||||
@ -259,7 +238,7 @@
|
|||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
@click="handleSubmit"
|
@click="handleSubmit"
|
||||||
>
|
>
|
||||||
{{ isEditMode ? "更新" : "保存" }}
|
保存
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-space>
|
</a-space>
|
||||||
</div>
|
</div>
|
||||||
@ -269,34 +248,38 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from "vue"
|
import { ref, reactive, nextTick, onMounted, computed } from "vue"
|
||||||
import { useRouter, useRoute } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message } from "ant-design-vue"
|
import { message } from "ant-design-vue"
|
||||||
import type { FormInstance, UploadFile } from "ant-design-vue"
|
import type { FormInstance, UploadFile } from "ant-design-vue"
|
||||||
import type { Dayjs } from "dayjs"
|
import type { Dayjs } from "dayjs"
|
||||||
|
import dayjs from "dayjs"
|
||||||
import {
|
import {
|
||||||
PlusOutlined,
|
PlusOutlined,
|
||||||
UploadOutlined,
|
UploadOutlined,
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
} from "@ant-design/icons-vue"
|
} from "@ant-design/icons-vue"
|
||||||
|
import RichTextEditor from "@/components/RichTextEditor.vue"
|
||||||
import {
|
import {
|
||||||
contestsApi,
|
contestsApi,
|
||||||
|
attachmentsApi,
|
||||||
reviewRulesApi,
|
reviewRulesApi,
|
||||||
type CreateContestForm,
|
type CreateContestForm,
|
||||||
type UpdateContestForm,
|
type Contest,
|
||||||
type ReviewRule,
|
|
||||||
} from "@/api/contests"
|
} from "@/api/contests"
|
||||||
import dayjs from "dayjs"
|
import { uploadFile } from "@/api/upload"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const tenantCode = route.params.tenantCode as string
|
const tenantCode = route.params.tenantCode as string
|
||||||
const contestId = route.params.id ? parseInt(route.params.id as string) : null
|
|
||||||
const isEditMode = ref(!!contestId)
|
// 编辑模式
|
||||||
|
const contestId = computed(() => route.params.id ? Number(route.params.id) : null)
|
||||||
|
const isEdit = computed(() => !!contestId.value)
|
||||||
|
const pageLoading = ref(false)
|
||||||
|
|
||||||
const formRef = ref<FormInstance>()
|
const formRef = ref<FormInstance>()
|
||||||
const submitLoading = ref(false)
|
const submitLoading = ref(false)
|
||||||
const loading = ref(false)
|
|
||||||
|
|
||||||
// 表单数据
|
// 表单数据
|
||||||
const form = reactive<
|
const form = reactive<
|
||||||
@ -338,7 +321,19 @@ const attachmentFileList = ref<UploadFile[]>([])
|
|||||||
|
|
||||||
// 评审规则选项
|
// 评审规则选项
|
||||||
const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
|
const reviewRuleOptions = ref<{ value: number; label: string }[]>([])
|
||||||
const reviewRuleLoading = ref(false)
|
|
||||||
|
// 获取评审规则列表
|
||||||
|
const fetchReviewRules = async () => {
|
||||||
|
try {
|
||||||
|
const rules = await reviewRulesApi.getForSelect()
|
||||||
|
reviewRuleOptions.value = rules.map(rule => ({
|
||||||
|
value: rule.id,
|
||||||
|
label: rule.ruleName,
|
||||||
|
}))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('获取评审规则列表失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// 表单验证规则
|
// 表单验证规则
|
||||||
const rules = {
|
const rules = {
|
||||||
@ -463,20 +458,20 @@ const beforeFileUpload = (file: File) => {
|
|||||||
const handleCoverUpload = async (options: any) => {
|
const handleCoverUpload = async (options: any) => {
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
// TODO: 调用实际上传接口
|
const result: any = await uploadFile(file)
|
||||||
// 注意:这里需要实现实际上传逻辑,上传成功后返回文件URL
|
// 兼容不同的响应格式
|
||||||
// 示例:const response = await uploadFile(file)
|
const url = result.data?.url || result.url
|
||||||
// form.coverUrl = response.url
|
if (url) {
|
||||||
|
form.coverUrl = url
|
||||||
// 临时方案:使用本地URL(仅用于开发测试)
|
|
||||||
const fileUrl = URL.createObjectURL(file)
|
|
||||||
form.coverUrl = fileUrl
|
|
||||||
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
message.success("封面上传成功")
|
message.success("封面上传成功")
|
||||||
} catch (error) {
|
} else {
|
||||||
|
throw new Error("无法获取图片地址")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("封面上传失败:", error)
|
||||||
onError(error)
|
onError(error)
|
||||||
message.error("封面上传失败")
|
message.error(error?.response?.data?.message || "封面上传失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -484,36 +479,70 @@ const handleCoverUpload = async (options: any) => {
|
|||||||
const handlePosterUpload = async (options: any) => {
|
const handlePosterUpload = async (options: any) => {
|
||||||
const { file, onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
// TODO: 调用实际上传接口
|
const result: any = await uploadFile(file)
|
||||||
// 注意:这里需要实现实际上传逻辑,上传成功后返回文件URL
|
// 兼容不同的响应格式
|
||||||
// 示例:const response = await uploadFile(file)
|
const url = result.data?.url || result.url
|
||||||
// form.posterUrl = response.url
|
if (url) {
|
||||||
|
form.posterUrl = url
|
||||||
// 临时方案:使用本地URL(仅用于开发测试)
|
|
||||||
const fileUrl = URL.createObjectURL(file)
|
|
||||||
form.posterUrl = fileUrl
|
|
||||||
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
message.success("海报上传成功")
|
message.success("海报上传成功")
|
||||||
} catch (error) {
|
} else {
|
||||||
|
throw new Error("无法获取图片地址")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("海报上传失败:", error)
|
||||||
onError(error)
|
onError(error)
|
||||||
message.error("海报上传失败")
|
message.error(error?.response?.data?.message || "海报上传失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 附件上传
|
// 附件上传
|
||||||
const handleAttachmentUpload = async (options: any) => {
|
const handleAttachmentUpload = async (options: any) => {
|
||||||
const { onSuccess, onError } = options
|
const { file, onSuccess, onError } = options
|
||||||
try {
|
try {
|
||||||
// TODO: 调用实际上传接口
|
const result: any = await uploadFile(file)
|
||||||
// 注意:附件上传后需要保存文件信息,但此时比赛还未创建,所以需要在创建比赛后再关联附件
|
// 兼容不同的响应格式
|
||||||
// 临时方案:先保存文件信息到 attachmentFileList,创建比赛后再上传
|
const url = result.data?.url || result.url
|
||||||
|
if (url) {
|
||||||
|
// 更新文件列表中的文件信息,保存上传后的URL
|
||||||
|
// 使用 nextTick 确保文件已添加到列表中
|
||||||
|
await nextTick()
|
||||||
|
const fileIndex = attachmentFileList.value.findIndex(
|
||||||
|
(f) => f.uid === file.uid || f.name === file.name
|
||||||
|
)
|
||||||
|
if (fileIndex !== -1) {
|
||||||
|
attachmentFileList.value[fileIndex] = {
|
||||||
|
...attachmentFileList.value[fileIndex],
|
||||||
|
url,
|
||||||
|
response: result,
|
||||||
|
status: "done",
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// 如果找不到,手动添加到列表
|
||||||
|
attachmentFileList.value.push({
|
||||||
|
uid: file.uid,
|
||||||
|
name: file.name,
|
||||||
|
status: "done",
|
||||||
|
url,
|
||||||
|
response: result,
|
||||||
|
})
|
||||||
|
}
|
||||||
onSuccess()
|
onSuccess()
|
||||||
message.success("附件上传成功(将在创建比赛后保存)")
|
message.success("附件上传成功")
|
||||||
} catch (error) {
|
} else {
|
||||||
|
throw new Error("无法获取文件地址")
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error("附件上传失败:", error)
|
||||||
|
// 标记文件为错误状态
|
||||||
|
const fileIndex = attachmentFileList.value.findIndex(
|
||||||
|
(f) => f.uid === file.uid || f.name === file.name
|
||||||
|
)
|
||||||
|
if (fileIndex !== -1) {
|
||||||
|
attachmentFileList.value[fileIndex].status = "error"
|
||||||
|
}
|
||||||
onError(error)
|
onError(error)
|
||||||
message.error("附件上传失败")
|
message.error(error?.response?.data?.message || "附件上传失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -611,93 +640,93 @@ const disabledPublishDate = (current: Dayjs | null) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载比赛详情(编辑模式)
|
// 加载赛事数据(编辑模式)
|
||||||
const loadContestDetail = async () => {
|
const loadContestData = async () => {
|
||||||
if (!contestId) return
|
if (!contestId.value) return
|
||||||
|
|
||||||
|
pageLoading.value = true
|
||||||
try {
|
try {
|
||||||
loading.value = true
|
const contest = await contestsApi.getDetail(contestId.value)
|
||||||
const contest = await contestsApi.getDetail(contestId)
|
|
||||||
|
|
||||||
// 填充表单数据
|
// 填充表单数据
|
||||||
form.contestName = contest.contestName || ""
|
form.contestName = contest.contestName || ''
|
||||||
form.contestType = contest.contestType || "individual"
|
form.contestType = contest.contestType || 'individual'
|
||||||
form.startTime = contest.startTime || ""
|
form.startTime = contest.startTime || ''
|
||||||
form.endTime = contest.endTime || ""
|
form.endTime = contest.endTime || ''
|
||||||
form.content = contest.content || ""
|
form.content = contest.content || ''
|
||||||
form.coverUrl = contest.coverUrl || ""
|
form.coverUrl = contest.coverUrl || ''
|
||||||
form.posterUrl = contest.posterUrl || ""
|
form.posterUrl = contest.posterUrl || ''
|
||||||
|
// 处理主办/协办/赞助单位(后端返回数组,表单需要字符串)
|
||||||
form.organizers = Array.isArray(contest.organizers)
|
form.organizers = Array.isArray(contest.organizers)
|
||||||
? contest.organizers.join(",")
|
? contest.organizers.join('、')
|
||||||
: contest.organizers || ""
|
: (contest.organizers || '')
|
||||||
form.coOrganizers = Array.isArray(contest.coOrganizers)
|
form.coOrganizers = Array.isArray(contest.coOrganizers)
|
||||||
? contest.coOrganizers.join(",")
|
? contest.coOrganizers.join('、')
|
||||||
: contest.coOrganizers || ""
|
: (contest.coOrganizers || '')
|
||||||
form.sponsors = Array.isArray(contest.sponsors)
|
form.sponsors = Array.isArray(contest.sponsors)
|
||||||
? contest.sponsors.join(",")
|
? contest.sponsors.join('、')
|
||||||
: contest.sponsors || ""
|
: (contest.sponsors || '')
|
||||||
form.registerStartTime = contest.registerStartTime || ""
|
form.registerStartTime = contest.registerStartTime || ''
|
||||||
form.registerEndTime = contest.registerEndTime || ""
|
form.registerEndTime = contest.registerEndTime || ''
|
||||||
form.submitRule = contest.submitRule || "once"
|
form.submitRule = contest.submitRule || 'once'
|
||||||
form.submitStartTime = contest.submitStartTime || ""
|
form.submitStartTime = contest.submitStartTime || ''
|
||||||
form.submitEndTime = contest.submitEndTime || ""
|
form.submitEndTime = contest.submitEndTime || ''
|
||||||
form.reviewStartTime = contest.reviewStartTime || ""
|
form.reviewRuleId = contest.reviewRuleId || undefined
|
||||||
form.reviewEndTime = contest.reviewEndTime || ""
|
form.reviewStartTime = contest.reviewStartTime || ''
|
||||||
form.resultPublishTime = contest.resultPublishTime || ""
|
form.reviewEndTime = contest.reviewEndTime || ''
|
||||||
form.reviewRuleId = contest.reviewRule?.id
|
form.resultPublishTime = contest.resultPublishTime || ''
|
||||||
|
|
||||||
// 设置时间范围
|
// 设置时间范围
|
||||||
if (contest.startTime && contest.endTime) {
|
if (contest.startTime && contest.endTime) {
|
||||||
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
|
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
|
||||||
}
|
}
|
||||||
if (contest.registerStartTime && contest.registerEndTime) {
|
if (contest.registerStartTime && contest.registerEndTime) {
|
||||||
registerTimeRange.value = [
|
registerTimeRange.value = [dayjs(contest.registerStartTime), dayjs(contest.registerEndTime)]
|
||||||
dayjs(contest.registerStartTime),
|
|
||||||
dayjs(contest.registerEndTime),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (contest.submitStartTime && contest.submitEndTime) {
|
if (contest.submitStartTime && contest.submitEndTime) {
|
||||||
submitTimeRange.value = [
|
submitTimeRange.value = [dayjs(contest.submitStartTime), dayjs(contest.submitEndTime)]
|
||||||
dayjs(contest.submitStartTime),
|
|
||||||
dayjs(contest.submitEndTime),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (contest.reviewStartTime && contest.reviewEndTime) {
|
if (contest.reviewStartTime && contest.reviewEndTime) {
|
||||||
reviewTimeRange.value = [
|
reviewTimeRange.value = [dayjs(contest.reviewStartTime), dayjs(contest.reviewEndTime)]
|
||||||
dayjs(contest.reviewStartTime),
|
|
||||||
dayjs(contest.reviewEndTime),
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
if (contest.resultPublishTime) {
|
if (contest.resultPublishTime) {
|
||||||
resultPublishTime.value = dayjs(contest.resultPublishTime)
|
resultPublishTime.value = dayjs(contest.resultPublishTime)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 设置文件列表(如果有)
|
// 设置封面图片
|
||||||
if (contest.coverUrl) {
|
if (contest.coverUrl) {
|
||||||
coverFileList.value = [
|
coverFileList.value = [{
|
||||||
{
|
uid: '-1',
|
||||||
uid: "-1",
|
name: 'cover',
|
||||||
name: "cover.jpg",
|
status: 'done',
|
||||||
status: "done",
|
|
||||||
url: contest.coverUrl,
|
url: contest.coverUrl,
|
||||||
},
|
}]
|
||||||
]
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 设置海报图片
|
||||||
if (contest.posterUrl) {
|
if (contest.posterUrl) {
|
||||||
posterFileList.value = [
|
posterFileList.value = [{
|
||||||
{
|
uid: '-2',
|
||||||
uid: "-2",
|
name: 'poster',
|
||||||
name: "poster.jpg",
|
status: 'done',
|
||||||
status: "done",
|
|
||||||
url: contest.posterUrl,
|
url: contest.posterUrl,
|
||||||
},
|
}]
|
||||||
]
|
}
|
||||||
|
|
||||||
|
// 加载附件
|
||||||
|
if (contest.attachments && contest.attachments.length > 0) {
|
||||||
|
attachmentFileList.value = contest.attachments.map((att: any, index: number) => ({
|
||||||
|
uid: `-${index + 3}`,
|
||||||
|
name: att.fileName,
|
||||||
|
status: 'done',
|
||||||
|
url: att.fileUrl,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error?.response?.data?.message || "加载比赛详情失败")
|
message.error(error?.response?.data?.message || '加载赛事数据失败')
|
||||||
router.push(`/${tenantCode}/contests`)
|
router.back()
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
pageLoading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -707,13 +736,61 @@ const handleSubmit = async () => {
|
|||||||
await formRef.value?.validate()
|
await formRef.value?.validate()
|
||||||
submitLoading.value = true
|
submitLoading.value = true
|
||||||
|
|
||||||
if (isEditMode.value && contestId) {
|
// 构建提交数据,确保所有字符串字段不为 null/undefined
|
||||||
// 更新比赛
|
const submitData: CreateContestForm = {
|
||||||
await contestsApi.update(contestId, form as UpdateContestForm)
|
contestName: form.contestName || '',
|
||||||
message.success("更新成功")
|
contestType: form.contestType || 'individual',
|
||||||
|
startTime: form.startTime || '',
|
||||||
|
endTime: form.endTime || '',
|
||||||
|
content: form.content || '',
|
||||||
|
coverUrl: form.coverUrl || '',
|
||||||
|
posterUrl: form.posterUrl || '',
|
||||||
|
organizers: form.organizers || '',
|
||||||
|
coOrganizers: form.coOrganizers || '',
|
||||||
|
sponsors: form.sponsors || '',
|
||||||
|
registerStartTime: form.registerStartTime || '',
|
||||||
|
registerEndTime: form.registerEndTime || '',
|
||||||
|
submitRule: form.submitRule || 'once',
|
||||||
|
submitStartTime: form.submitStartTime || '',
|
||||||
|
submitEndTime: form.submitEndTime || '',
|
||||||
|
reviewRuleId: form.reviewRuleId || undefined,
|
||||||
|
reviewStartTime: form.reviewStartTime || '',
|
||||||
|
reviewEndTime: form.reviewEndTime || '',
|
||||||
|
resultPublishTime: form.resultPublishTime || undefined,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isEdit.value && contestId.value) {
|
||||||
|
// 编辑模式 - 更新比赛
|
||||||
|
await contestsApi.update(contestId.value, submitData)
|
||||||
|
message.success("保存成功")
|
||||||
} else {
|
} else {
|
||||||
// 创建比赛
|
// 创建模式
|
||||||
await contestsApi.create(form as CreateContestForm)
|
const contest = await contestsApi.create(submitData)
|
||||||
|
const newContestId = contest.id
|
||||||
|
|
||||||
|
// 如果有附件,创建附件记录
|
||||||
|
if (attachmentFileList.value.length > 0) {
|
||||||
|
try {
|
||||||
|
const attachmentPromises = attachmentFileList.value.map((file) => {
|
||||||
|
const fileUrl =
|
||||||
|
file.url || file.response?.url || file.response?.data?.url
|
||||||
|
if (fileUrl && file.name) {
|
||||||
|
return attachmentsApi.create({
|
||||||
|
contestId: newContestId,
|
||||||
|
fileName: file.name,
|
||||||
|
fileUrl,
|
||||||
|
format: file.name.split(".").pop()?.toLowerCase(),
|
||||||
|
size: file.size?.toString(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Promise.resolve()
|
||||||
|
})
|
||||||
|
await Promise.all(attachmentPromises)
|
||||||
|
} catch (attachmentError) {
|
||||||
|
console.error("创建附件记录失败:", attachmentError)
|
||||||
|
message.warning("比赛创建成功,但部分附件记录创建失败")
|
||||||
|
}
|
||||||
|
}
|
||||||
message.success("创建成功")
|
message.success("创建成功")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -724,10 +801,7 @@ const handleSubmit = async () => {
|
|||||||
// 表单验证错误
|
// 表单验证错误
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
message.error(
|
message.error(error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败"))
|
||||||
error?.response?.data?.message ||
|
|
||||||
(isEditMode.value ? "更新失败" : "创建失败")
|
|
||||||
)
|
|
||||||
} finally {
|
} finally {
|
||||||
submitLoading.value = false
|
submitLoading.value = false
|
||||||
}
|
}
|
||||||
@ -738,33 +812,13 @@ const handleCancel = () => {
|
|||||||
router.back()
|
router.back()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载评审规则列表
|
// 页面加载
|
||||||
const loadReviewRules = async () => {
|
onMounted(() => {
|
||||||
try {
|
// 获取评审规则列表
|
||||||
reviewRuleLoading.value = true
|
fetchReviewRules()
|
||||||
const result = await reviewRulesApi.getList({ page: 1, pageSize: 100 })
|
|
||||||
reviewRuleOptions.value = (result.list || []).map((rule: ReviewRule) => ({
|
|
||||||
value: rule.id,
|
|
||||||
label: rule.ruleName,
|
|
||||||
}))
|
|
||||||
} catch (error) {
|
|
||||||
console.error("加载评审规则列表失败:", error)
|
|
||||||
// 静默失败,不影响表单提交
|
|
||||||
} finally {
|
|
||||||
reviewRuleLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 跳转到评审规则列表
|
if (isEdit.value) {
|
||||||
const goToReviewRules = () => {
|
loadContestData()
|
||||||
router.push(`/${tenantCode}/contests/reviews`)
|
|
||||||
}
|
|
||||||
|
|
||||||
// 组件挂载时加载数据
|
|
||||||
onMounted(async () => {
|
|
||||||
await loadReviewRules()
|
|
||||||
if (isEditMode.value) {
|
|
||||||
await loadContestDetail()
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@ -66,7 +66,7 @@
|
|||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
<a-button
|
<a-button
|
||||||
v-if="isRegistering && !hasRegistered"
|
v-if="isTeacher && isRegistering && !hasRegistered"
|
||||||
type="primary"
|
type="primary"
|
||||||
size="large"
|
size="large"
|
||||||
class="register-button"
|
class="register-button"
|
||||||
@ -75,7 +75,7 @@
|
|||||||
立即报名
|
立即报名
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-else-if="hasRegistered && canViewRegistration"
|
v-else-if="isTeacher && hasRegistered && canViewRegistration"
|
||||||
type="default"
|
type="default"
|
||||||
size="large"
|
size="large"
|
||||||
class="register-button"
|
class="register-button"
|
||||||
@ -84,7 +84,7 @@
|
|||||||
查看报名
|
查看报名
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button
|
<a-button
|
||||||
v-else
|
v-else-if="isTeacher"
|
||||||
type="default"
|
type="default"
|
||||||
size="large"
|
size="large"
|
||||||
class="register-button"
|
class="register-button"
|
||||||
@ -242,7 +242,7 @@
|
|||||||
<!-- 右侧:组织信息 -->
|
<!-- 右侧:组织信息 -->
|
||||||
<a-col :xs="24" :lg="8">
|
<a-col :xs="24" :lg="8">
|
||||||
<div class="org-info-card">
|
<div class="org-info-card">
|
||||||
<div class="info-item">
|
<!-- <div class="info-item">
|
||||||
<div class="info-label">| 发布者</div>
|
<div class="info-label">| 发布者</div>
|
||||||
<div class="info-value">
|
<div class="info-value">
|
||||||
<div v-if="(contest as any).publisher">
|
<div v-if="(contest as any).publisher">
|
||||||
@ -250,7 +250,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else>-</div>
|
<div v-else>-</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> -->
|
||||||
|
|
||||||
<div class="info-item">
|
<div class="info-item">
|
||||||
<div class="info-label">| 类型</div>
|
<div class="info-label">| 类型</div>
|
||||||
@ -379,7 +379,15 @@ const myRegistration = ref<any>(null)
|
|||||||
// 检查是否有查看报名的权限
|
// 检查是否有查看报名的权限
|
||||||
const canViewRegistration = computed(() => {
|
const canViewRegistration = computed(() => {
|
||||||
const permissions = authStore.user?.permissions || []
|
const permissions = authStore.user?.permissions || []
|
||||||
return permissions.includes('registration:read') || permissions.includes('registration:create')
|
return (
|
||||||
|
permissions.includes("registration:read") ||
|
||||||
|
permissions.includes("registration:create")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 检查是否是教师角色
|
||||||
|
const isTeacher = computed(() => {
|
||||||
|
return authStore.hasRole("teacher")
|
||||||
})
|
})
|
||||||
|
|
||||||
const contestId = Number(route.params.id)
|
const contestId = Number(route.params.id)
|
||||||
@ -449,14 +457,6 @@ const isRegistering = computed(() => {
|
|||||||
return now.isAfter(start) && now.isBefore(end)
|
return now.isAfter(start) && now.isBefore(end)
|
||||||
})
|
})
|
||||||
|
|
||||||
// 判断报名是否已结束
|
|
||||||
const isRegisterEnded = computed(() => {
|
|
||||||
if (!contest.value) return false
|
|
||||||
const now = dayjs()
|
|
||||||
const end = dayjs(contest.value.registerEndTime)
|
|
||||||
return now.isAfter(end)
|
|
||||||
})
|
|
||||||
|
|
||||||
// 计算距离报名截止还有几天
|
// 计算距离报名截止还有几天
|
||||||
const daysRemaining = computed(() => {
|
const daysRemaining = computed(() => {
|
||||||
if (!contest.value || !isRegistering.value) return 0
|
if (!contest.value || !isRegistering.value) return 0
|
||||||
@ -466,24 +466,6 @@ const daysRemaining = computed(() => {
|
|||||||
return diff > 0 ? diff : 0
|
return diff > 0 ? diff : 0
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取作品类型文本
|
|
||||||
const getWorkTypeText = (type?: string) => {
|
|
||||||
switch (type) {
|
|
||||||
case "image":
|
|
||||||
return "图片"
|
|
||||||
case "video":
|
|
||||||
return "视频"
|
|
||||||
case "document":
|
|
||||||
return "文档"
|
|
||||||
case "code":
|
|
||||||
return "代码"
|
|
||||||
case "other":
|
|
||||||
return "其他"
|
|
||||||
default:
|
|
||||||
return type || "-"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取公告类型颜色
|
// 获取公告类型颜色
|
||||||
const getNoticeTypeColor = (type?: string) => {
|
const getNoticeTypeColor = (type?: string) => {
|
||||||
switch (type) {
|
switch (type) {
|
||||||
@ -508,48 +490,6 @@ const getNoticeTypeText = (type?: string) => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算报名阶段状态
|
|
||||||
const getRegisterStateColor = () => {
|
|
||||||
if (!contest.value) return "default"
|
|
||||||
const now = dayjs()
|
|
||||||
const start = dayjs(contest.value.registerStartTime)
|
|
||||||
const end = dayjs(contest.value.registerEndTime)
|
|
||||||
if (now.isBefore(start)) return "default"
|
|
||||||
if (now.isAfter(end)) return "orange"
|
|
||||||
return "processing"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getRegisterStateText = () => {
|
|
||||||
if (!contest.value) return "-"
|
|
||||||
const now = dayjs()
|
|
||||||
const start = dayjs(contest.value.registerStartTime)
|
|
||||||
const end = dayjs(contest.value.registerEndTime)
|
|
||||||
if (now.isBefore(start)) return "未开始"
|
|
||||||
if (now.isAfter(end)) return "已结束"
|
|
||||||
return "进行中"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算作品提交阶段状态
|
|
||||||
const getSubmitStateColor = () => {
|
|
||||||
if (!contest.value) return "default"
|
|
||||||
const now = dayjs()
|
|
||||||
const start = dayjs(contest.value.submitStartTime)
|
|
||||||
const end = dayjs(contest.value.submitEndTime)
|
|
||||||
if (now.isBefore(start)) return "default"
|
|
||||||
if (now.isAfter(end)) return "orange"
|
|
||||||
return "processing"
|
|
||||||
}
|
|
||||||
|
|
||||||
const getSubmitStateText = () => {
|
|
||||||
if (!contest.value) return "-"
|
|
||||||
const now = dayjs()
|
|
||||||
const start = dayjs(contest.value.submitStartTime)
|
|
||||||
const end = dayjs(contest.value.submitEndTime)
|
|
||||||
if (now.isBefore(start)) return "未开始"
|
|
||||||
if (now.isAfter(end)) return "已结束"
|
|
||||||
return "进行中"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取排名颜色
|
// 获取排名颜色
|
||||||
const getRankColor = (rank?: number) => {
|
const getRankColor = (rank?: number) => {
|
||||||
if (!rank) return "default"
|
if (!rank) return "default"
|
||||||
|
|||||||
@ -143,7 +143,6 @@
|
|||||||
v-permission="'contest:update'"
|
v-permission="'contest:update'"
|
||||||
type="link"
|
type="link"
|
||||||
size="small"
|
size="small"
|
||||||
:disabled="record.contestState === 'published'"
|
|
||||||
@click.stop="handleEdit(record.id)"
|
@click.stop="handleEdit(record.id)"
|
||||||
>
|
>
|
||||||
编辑
|
编辑
|
||||||
|
|||||||
@ -54,6 +54,9 @@
|
|||||||
<template v-else-if="column.key === 'contestName'">
|
<template v-else-if="column.key === 'contestName'">
|
||||||
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'organizers'">
|
||||||
|
{{ formatOrganizers(record.organizers) }}
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'registrationCount'">
|
<template v-else-if="column.key === 'registrationCount'">
|
||||||
{{ record._count?.registrations || 0 }}
|
{{ record._count?.registrations || 0 }}
|
||||||
</template>
|
</template>
|
||||||
@ -114,6 +117,9 @@
|
|||||||
<template v-else-if="column.key === 'contestName'">
|
<template v-else-if="column.key === 'contestName'">
|
||||||
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
||||||
</template>
|
</template>
|
||||||
|
<template v-else-if="column.key === 'organizers'">
|
||||||
|
{{ formatOrganizers(record.organizers) }}
|
||||||
|
</template>
|
||||||
<template v-else-if="column.key === 'teamCount'">
|
<template v-else-if="column.key === 'teamCount'">
|
||||||
{{ record._count?.teams || 0 }}
|
{{ record._count?.teams || 0 }}
|
||||||
</template>
|
</template>
|
||||||
@ -211,6 +217,11 @@ const individualColumns = [
|
|||||||
dataIndex: "contestName",
|
dataIndex: "contestName",
|
||||||
width: 250,
|
width: 250,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "主办单位",
|
||||||
|
key: "organizers",
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "报名人数",
|
title: "报名人数",
|
||||||
key: "registrationCount",
|
key: "registrationCount",
|
||||||
@ -242,6 +253,11 @@ const teamColumns = [
|
|||||||
dataIndex: "contestName",
|
dataIndex: "contestName",
|
||||||
width: 250,
|
width: 250,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: "主办单位",
|
||||||
|
key: "organizers",
|
||||||
|
width: 200,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: "报名队伍数",
|
title: "报名队伍数",
|
||||||
key: "teamCount",
|
key: "teamCount",
|
||||||
@ -271,6 +287,25 @@ const formatDateTime = (dateStr?: string) => {
|
|||||||
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
|
return dayjs(dateStr).format("YYYY-MM-DD HH:mm")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 格式化主办单位
|
||||||
|
const formatOrganizers = (organizers: any) => {
|
||||||
|
if (!organizers) return "-"
|
||||||
|
if (Array.isArray(organizers)) {
|
||||||
|
return organizers.join("、") || "-"
|
||||||
|
}
|
||||||
|
if (typeof organizers === "string") {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(organizers)
|
||||||
|
if (Array.isArray(parsed)) {
|
||||||
|
return parsed.join("、") || "-"
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return organizers || "-"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "-"
|
||||||
|
}
|
||||||
|
|
||||||
// 获取列表数据
|
// 获取列表数据
|
||||||
const fetchList = async () => {
|
const fetchList = async () => {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
|
|||||||
@ -169,7 +169,13 @@
|
|||||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'tenant'">
|
<template v-else-if="column.key === 'tenant'">
|
||||||
{{ record.user?.tenant?.name || record.tenant?.name || "-" }}
|
<div>
|
||||||
|
<div>{{ record.user?.tenant?.name || record.tenant?.name || "-" }}</div>
|
||||||
|
<div v-if="record.user?.student?.class" class="org-detail">
|
||||||
|
{{ record.user.student.class.grade?.name || "" }}
|
||||||
|
{{ record.user.student.class.name || "" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'nickname'">
|
<template v-else-if="column.key === 'nickname'">
|
||||||
{{ record.user?.nickname || record.accountName || "-" }}
|
{{ record.user?.nickname || record.accountName || "-" }}
|
||||||
@ -240,7 +246,13 @@
|
|||||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'tenant'">
|
<template v-else-if="column.key === 'tenant'">
|
||||||
{{ record.user?.tenant?.name || record.tenant?.name || "-" }}
|
<div>
|
||||||
|
<div>{{ record.user?.tenant?.name || record.tenant?.name || "-" }}</div>
|
||||||
|
<div v-if="record.user?.student?.class" class="org-detail">
|
||||||
|
{{ record.user.student.class.grade?.name || "" }}
|
||||||
|
{{ record.user.student.class.name || "" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'teamName'">
|
<template v-else-if="column.key === 'teamName'">
|
||||||
{{ record.team?.teamName || "-" }}
|
{{ record.team?.teamName || "-" }}
|
||||||
@ -311,7 +323,11 @@
|
|||||||
{{ currentDetail.accountNo || currentDetail.user?.username || "-" }}
|
{{ currentDetail.accountNo || currentDetail.user?.username || "-" }}
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="机构">
|
<a-descriptions-item label="机构">
|
||||||
{{ currentDetail.user?.tenant?.name || "-" }}
|
<div>{{ currentDetail.user?.tenant?.name || "-" }}</div>
|
||||||
|
<div v-if="currentDetail.user?.student?.class" class="org-detail">
|
||||||
|
{{ currentDetail.user.student.class.grade?.name || "" }}
|
||||||
|
{{ currentDetail.user.student.class.name || "" }}
|
||||||
|
</div>
|
||||||
</a-descriptions-item>
|
</a-descriptions-item>
|
||||||
<a-descriptions-item label="审核状态">
|
<a-descriptions-item label="审核状态">
|
||||||
<a-tag :color="getStateColor(currentDetail.registrationState)">
|
<a-tag :color="getStateColor(currentDetail.registrationState)">
|
||||||
@ -936,4 +952,10 @@ onMounted(async () => {
|
|||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.org-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
311
frontend/src/views/contests/results/Detail.vue
Normal file
311
frontend/src/views/contests/results/Detail.vue
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
<template>
|
||||||
|
<div class="results-detail-page">
|
||||||
|
<!-- 顶部导航 -->
|
||||||
|
<div class="page-header">
|
||||||
|
<div class="header-left">
|
||||||
|
<a-button type="text" @click="handleBack">
|
||||||
|
<template #icon><ArrowLeftOutlined /></template>
|
||||||
|
返回
|
||||||
|
</a-button>
|
||||||
|
<span class="page-title">{{ contestInfo?.contestName || "赛果发布" }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-right">
|
||||||
|
<a-button
|
||||||
|
type="primary"
|
||||||
|
:loading="publishLoading"
|
||||||
|
@click="handlePublish"
|
||||||
|
>
|
||||||
|
{{ contestInfo?.resultState === "published" ? "撤回发布" : "发布赛果" }}
|
||||||
|
</a-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 搜索表单 -->
|
||||||
|
<a-form
|
||||||
|
:model="searchParams"
|
||||||
|
layout="inline"
|
||||||
|
class="search-form"
|
||||||
|
@finish="handleSearch"
|
||||||
|
>
|
||||||
|
<a-form-item label="作品编号">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchParams.workNo"
|
||||||
|
placeholder="请输入作品编号"
|
||||||
|
allow-clear
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item label="报名账号">
|
||||||
|
<a-input
|
||||||
|
v-model:value="searchParams.accountNo"
|
||||||
|
placeholder="请输入报名账号"
|
||||||
|
allow-clear
|
||||||
|
style="width: 180px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button style="margin-left: 8px" @click="handleReset">
|
||||||
|
<template #icon><ReloadOutlined /></template>
|
||||||
|
重置
|
||||||
|
</a-button>
|
||||||
|
</a-form-item>
|
||||||
|
</a-form>
|
||||||
|
|
||||||
|
<!-- 数据表格 -->
|
||||||
|
<a-table
|
||||||
|
:columns="columns"
|
||||||
|
:data-source="dataSource"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="pagination"
|
||||||
|
row-key="id"
|
||||||
|
@change="handleTableChange"
|
||||||
|
>
|
||||||
|
<template #bodyCell="{ column, record, index }">
|
||||||
|
<template v-if="column.key === 'index'">
|
||||||
|
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'finalScore'">
|
||||||
|
<span v-if="record.finalScore !== null" class="score">
|
||||||
|
{{ Number(record.finalScore).toFixed(2) }}
|
||||||
|
</span>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'nickname'">
|
||||||
|
{{ record.registration?.user?.nickname || "-" }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'username'">
|
||||||
|
{{ record.registration?.user?.username || "-" }}
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'org'">
|
||||||
|
<div>
|
||||||
|
<div>{{ record.registration?.user?.tenant?.name || "-" }}</div>
|
||||||
|
<div v-if="record.registration?.user?.student?.class" class="org-detail">
|
||||||
|
{{ record.registration.user.student.class.grade?.name || "" }}
|
||||||
|
{{ record.registration.user.student.class.name || "" }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
<template v-else-if="column.key === 'teachers'">
|
||||||
|
{{ formatTeachers(record.registration?.teachers) }}
|
||||||
|
</template>
|
||||||
|
</template>
|
||||||
|
</a-table>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, reactive, onMounted } from "vue"
|
||||||
|
import { useRoute, useRouter } from "vue-router"
|
||||||
|
import { message, Modal } from "ant-design-vue"
|
||||||
|
import {
|
||||||
|
ArrowLeftOutlined,
|
||||||
|
SearchOutlined,
|
||||||
|
ReloadOutlined,
|
||||||
|
} from "@ant-design/icons-vue"
|
||||||
|
import { resultsApi } from "@/api/contests"
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
const contestId = Number(route.params.id)
|
||||||
|
|
||||||
|
// 赛事信息
|
||||||
|
const contestInfo = ref<{
|
||||||
|
id: number
|
||||||
|
contestName: string
|
||||||
|
resultState: string
|
||||||
|
} | null>(null)
|
||||||
|
|
||||||
|
// 列表状态
|
||||||
|
const loading = ref(false)
|
||||||
|
const publishLoading = ref(false)
|
||||||
|
const dataSource = ref<any[]>([])
|
||||||
|
const pagination = reactive({
|
||||||
|
current: 1,
|
||||||
|
pageSize: 10,
|
||||||
|
total: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 搜索参数
|
||||||
|
const searchParams = reactive({
|
||||||
|
workNo: "",
|
||||||
|
accountNo: "",
|
||||||
|
})
|
||||||
|
|
||||||
|
// 表格列定义
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: "序号",
|
||||||
|
key: "index",
|
||||||
|
width: 70,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "作品编号",
|
||||||
|
dataIndex: "workNo",
|
||||||
|
key: "workNo",
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "评委评分",
|
||||||
|
key: "finalScore",
|
||||||
|
width: 100,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "姓名",
|
||||||
|
key: "nickname",
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "账号",
|
||||||
|
key: "username",
|
||||||
|
width: 120,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "机构信息",
|
||||||
|
key: "org",
|
||||||
|
width: 180,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "指导老师",
|
||||||
|
key: "teachers",
|
||||||
|
width: 150,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
// 格式化指导老师
|
||||||
|
const formatTeachers = (teachers: any[] | undefined) => {
|
||||||
|
if (!teachers || teachers.length === 0) return "-"
|
||||||
|
return teachers
|
||||||
|
.map((t) => t.user?.nickname || t.user?.username)
|
||||||
|
.filter(Boolean)
|
||||||
|
.join("、") || "-"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取列表数据
|
||||||
|
const fetchList = async () => {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const response = await resultsApi.getResults(contestId, {
|
||||||
|
page: pagination.current,
|
||||||
|
pageSize: pagination.pageSize,
|
||||||
|
workNo: searchParams.workNo || undefined,
|
||||||
|
accountNo: searchParams.accountNo || undefined,
|
||||||
|
})
|
||||||
|
contestInfo.value = response.contest
|
||||||
|
dataSource.value = response.list
|
||||||
|
pagination.total = response.total
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "获取列表失败")
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.workNo = ""
|
||||||
|
searchParams.accountNo = ""
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 表格变化
|
||||||
|
const handleTableChange = (pag: any) => {
|
||||||
|
pagination.current = pag.current
|
||||||
|
pagination.pageSize = pag.pageSize
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 返回
|
||||||
|
const handleBack = () => {
|
||||||
|
router.push(`/${tenantCode}/contests/results`)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 发布/撤回赛果
|
||||||
|
const handlePublish = () => {
|
||||||
|
const isPublished = contestInfo.value?.resultState === "published"
|
||||||
|
|
||||||
|
Modal.confirm({
|
||||||
|
title: isPublished ? "确定撤回赛果发布吗?" : "确定发布赛果吗?",
|
||||||
|
content: isPublished
|
||||||
|
? "撤回后,赛果将不再对外公开显示"
|
||||||
|
: "发布后,比赛结果将公开显示",
|
||||||
|
onOk: async () => {
|
||||||
|
publishLoading.value = true
|
||||||
|
try {
|
||||||
|
if (isPublished) {
|
||||||
|
await resultsApi.unpublish(contestId)
|
||||||
|
message.success("已撤回发布")
|
||||||
|
} else {
|
||||||
|
await resultsApi.publish(contestId)
|
||||||
|
message.success("发布成功")
|
||||||
|
}
|
||||||
|
fetchList()
|
||||||
|
} catch (error: any) {
|
||||||
|
message.error(error?.response?.data?.message || "操作失败")
|
||||||
|
} finally {
|
||||||
|
publishLoading.value = false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchList()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.results-detail-page {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-form {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
padding: 16px;
|
||||||
|
background: #fff;
|
||||||
|
border-radius: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.org-detail {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #666;
|
||||||
|
margin-top: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.score {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #52c41a;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -1,440 +1,127 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="results-page">
|
<div class="results-page">
|
||||||
<a-card>
|
<a-card class="mb-4">
|
||||||
<template #title>
|
<template #title>赛果发布</template>
|
||||||
<a-breadcrumb>
|
</a-card>
|
||||||
<a-breadcrumb-item>
|
|
||||||
<router-link :to="`/contests`">赛事管理</router-link>
|
<!-- Tab栏切换 -->
|
||||||
</a-breadcrumb-item>
|
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||||
<a-breadcrumb-item>
|
<a-tab-pane key="individual" tab="个人赛" />
|
||||||
<router-link :to="`/contests/${contestId}`">{{
|
<a-tab-pane key="team" tab="团队赛" />
|
||||||
summaryData?.contest?.contestName || "赛事详情"
|
</a-tabs>
|
||||||
}}</router-link>
|
|
||||||
</a-breadcrumb-item>
|
<!-- 搜索表单 -->
|
||||||
<a-breadcrumb-item>赛果发布</a-breadcrumb-item>
|
<a-form
|
||||||
</a-breadcrumb>
|
:model="searchParams"
|
||||||
</template>
|
layout="inline"
|
||||||
<template #extra>
|
class="search-form"
|
||||||
<a-space>
|
@finish="handleSearch"
|
||||||
<a-tag
|
|
||||||
v-if="summaryData?.contest?.resultState === 'published'"
|
|
||||||
color="green"
|
|
||||||
>已发布</a-tag
|
|
||||||
>
|
>
|
||||||
<a-tag v-else color="orange">未发布</a-tag>
|
<a-form-item label="赛事名称">
|
||||||
<a-button @click="fetchData" :loading="loading">
|
<a-input
|
||||||
|
v-model:value="searchParams.contestName"
|
||||||
|
placeholder="请输入赛事名称"
|
||||||
|
allow-clear
|
||||||
|
style="width: 200px"
|
||||||
|
/>
|
||||||
|
</a-form-item>
|
||||||
|
<a-form-item>
|
||||||
|
<a-button type="primary" html-type="submit">
|
||||||
|
<template #icon><SearchOutlined /></template>
|
||||||
|
搜索
|
||||||
|
</a-button>
|
||||||
|
<a-button style="margin-left: 8px" @click="handleReset">
|
||||||
<template #icon><ReloadOutlined /></template>
|
<template #icon><ReloadOutlined /></template>
|
||||||
刷新
|
重置
|
||||||
</a-button>
|
</a-button>
|
||||||
<a-button @click="$router.back()">返回</a-button>
|
</a-form-item>
|
||||||
</a-space>
|
</a-form>
|
||||||
</template>
|
|
||||||
|
|
||||||
<a-spin :spinning="loading">
|
<!-- 数据表格 -->
|
||||||
<!-- 统计概览 -->
|
|
||||||
<a-row :gutter="16" class="stats-row">
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="作品总数"
|
|
||||||
:value="summaryData?.summary?.totalWorks || 0"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="已计分"
|
|
||||||
:value="summaryData?.summary?.scoredWorks || 0"
|
|
||||||
:value-style="{
|
|
||||||
color:
|
|
||||||
(summaryData?.summary?.unscoredWorks || 0) > 0
|
|
||||||
? '#faad14'
|
|
||||||
: '#3f8600',
|
|
||||||
}"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="已排名"
|
|
||||||
:value="summaryData?.summary?.rankedWorks || 0"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="已设奖"
|
|
||||||
:value="summaryData?.summary?.awardedWorks || 0"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="平均分"
|
|
||||||
:value="summaryData?.scoreStats?.avgScore || '-'"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="4">
|
|
||||||
<a-statistic
|
|
||||||
title="最高分"
|
|
||||||
:value="summaryData?.scoreStats?.maxScore || '-'"
|
|
||||||
/>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
|
|
||||||
<!-- 操作区域 -->
|
|
||||||
<a-card title="操作" size="small" class="action-card">
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-card
|
|
||||||
size="small"
|
|
||||||
hoverable
|
|
||||||
@click="handleCalculateScores"
|
|
||||||
:loading="calculateScoresLoading"
|
|
||||||
>
|
|
||||||
<template #title>
|
|
||||||
<CalculatorOutlined /> 计算最终得分
|
|
||||||
</template>
|
|
||||||
<p class="action-desc">根据评审规则计算所有作品的最终得分</p>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-card
|
|
||||||
size="small"
|
|
||||||
hoverable
|
|
||||||
@click="handleCalculateRankings"
|
|
||||||
:loading="calculateRankingsLoading"
|
|
||||||
>
|
|
||||||
<template #title> <OrderedListOutlined /> 计算排名 </template>
|
|
||||||
<p class="action-desc">
|
|
||||||
根据最终得分计算作品排名(同分同名次)
|
|
||||||
</p>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-card size="small" hoverable @click="showAutoAwardModal = true">
|
|
||||||
<template #title> <TrophyOutlined /> 自动设置奖项 </template>
|
|
||||||
<p class="action-desc">根据排名自动分配一等奖、二等奖等奖项</p>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-card
|
|
||||||
size="small"
|
|
||||||
hoverable
|
|
||||||
@click="handlePublish"
|
|
||||||
:loading="publishLoading"
|
|
||||||
:class="{
|
|
||||||
'published-card':
|
|
||||||
summaryData?.contest?.resultState === 'published',
|
|
||||||
}"
|
|
||||||
>
|
|
||||||
<template #title>
|
|
||||||
<SendOutlined />
|
|
||||||
{{
|
|
||||||
summaryData?.contest?.resultState === "published"
|
|
||||||
? "撤回发布"
|
|
||||||
: "发布赛果"
|
|
||||||
}}
|
|
||||||
</template>
|
|
||||||
<p class="action-desc">
|
|
||||||
{{
|
|
||||||
summaryData?.contest?.resultState === "published"
|
|
||||||
? "撤回已发布的赛果"
|
|
||||||
: "公开发布比赛结果"
|
|
||||||
}}
|
|
||||||
</p>
|
|
||||||
</a-card>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 奖项分布 -->
|
|
||||||
<a-card title="奖项分布" size="small" class="award-card">
|
|
||||||
<a-row :gutter="16">
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-statistic
|
|
||||||
title="一等奖"
|
|
||||||
:value="summaryData?.awardDistribution?.first || 0"
|
|
||||||
>
|
|
||||||
<template #prefix
|
|
||||||
><TrophyOutlined style="color: #ffd700"
|
|
||||||
/></template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-statistic
|
|
||||||
title="二等奖"
|
|
||||||
:value="summaryData?.awardDistribution?.second || 0"
|
|
||||||
>
|
|
||||||
<template #prefix
|
|
||||||
><TrophyOutlined style="color: #c0c0c0"
|
|
||||||
/></template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-statistic
|
|
||||||
title="三等奖"
|
|
||||||
:value="summaryData?.awardDistribution?.third || 0"
|
|
||||||
>
|
|
||||||
<template #prefix
|
|
||||||
><TrophyOutlined style="color: #cd7f32"
|
|
||||||
/></template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-col>
|
|
||||||
<a-col :span="6">
|
|
||||||
<a-statistic
|
|
||||||
title="优秀奖"
|
|
||||||
:value="summaryData?.awardDistribution?.excellent || 0"
|
|
||||||
>
|
|
||||||
<template #prefix
|
|
||||||
><StarOutlined style="color: #1890ff"
|
|
||||||
/></template>
|
|
||||||
</a-statistic>
|
|
||||||
</a-col>
|
|
||||||
</a-row>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 结果列表 -->
|
|
||||||
<a-card title="排名列表" size="small" class="list-card">
|
|
||||||
<template #extra>
|
|
||||||
<a-button type="link" @click="handleExport">
|
|
||||||
<template #icon><ExportOutlined /></template>
|
|
||||||
导出
|
|
||||||
</a-button>
|
|
||||||
</template>
|
|
||||||
<a-table
|
<a-table
|
||||||
:columns="columns"
|
:columns="columns"
|
||||||
:data-source="resultsData?.list || []"
|
:data-source="dataSource"
|
||||||
:loading="listLoading"
|
:loading="loading"
|
||||||
:pagination="pagination"
|
:pagination="pagination"
|
||||||
row-key="id"
|
row-key="id"
|
||||||
@change="handleTableChange"
|
@change="handleTableChange"
|
||||||
>
|
>
|
||||||
<template #bodyCell="{ column, record }">
|
<template #bodyCell="{ column, record, index }">
|
||||||
<template v-if="column.key === 'rank'">
|
<template v-if="column.key === 'index'">
|
||||||
<span v-if="record.rank" :class="getRankClass(record.rank)">
|
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||||
{{ record.rank }}
|
|
||||||
</span>
|
|
||||||
<span v-else class="no-rank">-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'participant'">
|
<template v-else-if="column.key === 'contestName'">
|
||||||
<span v-if="record.registration?.team">
|
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||||
{{ record.registration.team.teamName }}
|
|
||||||
</span>
|
|
||||||
<span v-else-if="record.registration?.user">
|
|
||||||
{{
|
|
||||||
record.registration.user.nickname ||
|
|
||||||
record.registration.user.username
|
|
||||||
}}
|
|
||||||
</span>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'finalScore'">
|
<template v-else-if="column.key === 'registrationCount'">
|
||||||
<span v-if="record.finalScore !== null" class="score">{{
|
{{ record._count?.registrations || 0 }}
|
||||||
Number(record.finalScore).toFixed(2)
|
|
||||||
}}</span>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'awardLevel'">
|
<template v-else-if="column.key === 'worksCount'">
|
||||||
<a-tag
|
{{ record._count?.works || 0 }}
|
||||||
v-if="record.awardLevel"
|
|
||||||
:color="getAwardColor(record.awardLevel)"
|
|
||||||
>
|
|
||||||
{{ record.awardName || getAwardText(record.awardLevel) }}
|
|
||||||
</a-tag>
|
|
||||||
<span v-else>-</span>
|
|
||||||
</template>
|
</template>
|
||||||
<template v-else-if="column.key === 'action'">
|
<template v-else-if="column.key === 'action'">
|
||||||
<a-button
|
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||||
type="link"
|
详情
|
||||||
size="small"
|
|
||||||
@click="handleSetAward(record)"
|
|
||||||
>
|
|
||||||
设置奖项
|
|
||||||
</a-button>
|
</a-button>
|
||||||
</template>
|
</template>
|
||||||
</template>
|
</template>
|
||||||
</a-table>
|
</a-table>
|
||||||
</a-card>
|
|
||||||
</a-spin>
|
|
||||||
</a-card>
|
|
||||||
|
|
||||||
<!-- 自动设置奖项弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:open="showAutoAwardModal"
|
|
||||||
title="自动设置奖项"
|
|
||||||
:confirm-loading="autoAwardLoading"
|
|
||||||
@ok="handleAutoSetAwards"
|
|
||||||
@cancel="showAutoAwardModal = false"
|
|
||||||
>
|
|
||||||
<a-form :label-col="{ span: 8 }" :wrapper-col="{ span: 16 }">
|
|
||||||
<a-form-item label="一等奖数量">
|
|
||||||
<a-input-number
|
|
||||||
v-model:value="autoAwardForm.first"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="二等奖数量">
|
|
||||||
<a-input-number
|
|
||||||
v-model:value="autoAwardForm.second"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="三等奖数量">
|
|
||||||
<a-input-number
|
|
||||||
v-model:value="autoAwardForm.third"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="优秀奖数量">
|
|
||||||
<a-input-number
|
|
||||||
v-model:value="autoAwardForm.excellent"
|
|
||||||
:min="0"
|
|
||||||
style="width: 100%"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
<a-alert type="info" show-icon>
|
|
||||||
<template #message>根据排名从前到后依次分配奖项</template>
|
|
||||||
</a-alert>
|
|
||||||
</a-modal>
|
|
||||||
|
|
||||||
<!-- 设置单个奖项弹窗 -->
|
|
||||||
<a-modal
|
|
||||||
v-model:open="showSetAwardModal"
|
|
||||||
title="设置奖项"
|
|
||||||
:confirm-loading="setAwardLoading"
|
|
||||||
@ok="handleSetAwardSubmit"
|
|
||||||
@cancel="showSetAwardModal = false"
|
|
||||||
>
|
|
||||||
<a-form :label-col="{ span: 6 }" :wrapper-col="{ span: 18 }">
|
|
||||||
<a-form-item label="作品">
|
|
||||||
<span>{{ currentWork?.title }}</span>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="当前排名">
|
|
||||||
<span>{{ currentWork?.rank || "-" }}</span>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="最终得分">
|
|
||||||
<span>{{
|
|
||||||
currentWork?.finalScore
|
|
||||||
? Number(currentWork.finalScore).toFixed(2)
|
|
||||||
: "-"
|
|
||||||
}}</span>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="奖项等级" required>
|
|
||||||
<a-select v-model:value="setAwardForm.awardLevel" style="width: 100%">
|
|
||||||
<a-select-option value="first">一等奖</a-select-option>
|
|
||||||
<a-select-option value="second">二等奖</a-select-option>
|
|
||||||
<a-select-option value="third">三等奖</a-select-option>
|
|
||||||
<a-select-option value="excellent">优秀奖</a-select-option>
|
|
||||||
<a-select-option value="none">无奖项</a-select-option>
|
|
||||||
</a-select>
|
|
||||||
</a-form-item>
|
|
||||||
<a-form-item label="奖项名称">
|
|
||||||
<a-input
|
|
||||||
v-model:value="setAwardForm.awardName"
|
|
||||||
placeholder="可自定义奖项名称"
|
|
||||||
/>
|
|
||||||
</a-form-item>
|
|
||||||
</a-form>
|
|
||||||
</a-modal>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, reactive, onMounted } from "vue"
|
import { ref, reactive, onMounted } from "vue"
|
||||||
import { useRoute } from "vue-router"
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message, Modal } from "ant-design-vue"
|
import { message } from "ant-design-vue"
|
||||||
|
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||||
import {
|
import {
|
||||||
ReloadOutlined,
|
contestsApi,
|
||||||
CalculatorOutlined,
|
type Contest,
|
||||||
OrderedListOutlined,
|
type QueryContestParams,
|
||||||
TrophyOutlined,
|
|
||||||
SendOutlined,
|
|
||||||
StarOutlined,
|
|
||||||
ExportOutlined,
|
|
||||||
} from "@ant-design/icons-vue"
|
|
||||||
import dayjs from "dayjs"
|
|
||||||
import {
|
|
||||||
resultsApi,
|
|
||||||
type ResultsSummary,
|
|
||||||
type ResultsResponse,
|
|
||||||
type ContestResult,
|
|
||||||
type SetAwardForm,
|
|
||||||
type AutoSetAwardsForm,
|
|
||||||
} from "@/api/contests"
|
} from "@/api/contests"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const contestId = Number(route.params.id)
|
const tenantCode = route.params.tenantCode as string
|
||||||
|
|
||||||
|
// Tab状态
|
||||||
|
const activeTab = ref<"individual" | "team">("individual")
|
||||||
|
|
||||||
|
// 列表状态
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const listLoading = ref(false)
|
const dataSource = ref<Contest[]>([])
|
||||||
const summaryData = ref<ResultsSummary | null>(null)
|
|
||||||
const resultsData = ref<ResultsResponse | null>(null)
|
|
||||||
|
|
||||||
// 操作加载状态
|
|
||||||
const calculateScoresLoading = ref(false)
|
|
||||||
const calculateRankingsLoading = ref(false)
|
|
||||||
const publishLoading = ref(false)
|
|
||||||
const autoAwardLoading = ref(false)
|
|
||||||
const setAwardLoading = ref(false)
|
|
||||||
|
|
||||||
// 分页
|
|
||||||
const pagination = reactive({
|
const pagination = reactive({
|
||||||
current: 1,
|
current: 1,
|
||||||
pageSize: 20,
|
pageSize: 10,
|
||||||
total: 0,
|
total: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
// 自动设置奖项
|
// 搜索参数
|
||||||
const showAutoAwardModal = ref(false)
|
const searchParams = reactive<QueryContestParams>({
|
||||||
const autoAwardForm = reactive<AutoSetAwardsForm>({
|
contestName: "",
|
||||||
first: 1,
|
|
||||||
second: 2,
|
|
||||||
third: 3,
|
|
||||||
excellent: 5,
|
|
||||||
})
|
})
|
||||||
|
|
||||||
// 设置单个奖项
|
// 表格列定义
|
||||||
const showSetAwardModal = ref(false)
|
|
||||||
const currentWork = ref<ContestResult | null>(null)
|
|
||||||
const setAwardForm = reactive<SetAwardForm>({
|
|
||||||
awardLevel: "first",
|
|
||||||
awardName: "",
|
|
||||||
})
|
|
||||||
|
|
||||||
// 表格列
|
|
||||||
const columns = [
|
const columns = [
|
||||||
{
|
{
|
||||||
title: "排名",
|
title: "序号",
|
||||||
key: "rank",
|
key: "index",
|
||||||
width: 80,
|
width: 70,
|
||||||
align: "center" as const,
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "作品编号",
|
title: "赛事名称",
|
||||||
dataIndex: "workNo",
|
key: "contestName",
|
||||||
key: "workNo",
|
dataIndex: "contestName",
|
||||||
|
width: 250,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "报名人数",
|
||||||
|
key: "registrationCount",
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "作品标题",
|
title: "提交作品数",
|
||||||
dataIndex: "title",
|
key: "worksCount",
|
||||||
key: "title",
|
|
||||||
ellipsis: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "参赛者/团队",
|
|
||||||
key: "participant",
|
|
||||||
width: 150,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "最终得分",
|
|
||||||
key: "finalScore",
|
|
||||||
width: 100,
|
|
||||||
align: "center" as const,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "奖项",
|
|
||||||
key: "awardLevel",
|
|
||||||
width: 120,
|
width: 120,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@ -445,82 +132,43 @@ const columns = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
// 获取排名样式
|
// 获取列表数据
|
||||||
const getRankClass = (rank: number) => {
|
const fetchList = async () => {
|
||||||
if (rank === 1) return "rank rank-1"
|
|
||||||
if (rank === 2) return "rank rank-2"
|
|
||||||
if (rank === 3) return "rank rank-3"
|
|
||||||
return "rank"
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取奖项颜色
|
|
||||||
const getAwardColor = (level: string) => {
|
|
||||||
switch (level) {
|
|
||||||
case "first":
|
|
||||||
return "gold"
|
|
||||||
case "second":
|
|
||||||
return "silver"
|
|
||||||
case "third":
|
|
||||||
return "orange"
|
|
||||||
case "excellent":
|
|
||||||
return "blue"
|
|
||||||
default:
|
|
||||||
return "default"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 获取奖项文本
|
|
||||||
const getAwardText = (level: string) => {
|
|
||||||
switch (level) {
|
|
||||||
case "first":
|
|
||||||
return "一等奖"
|
|
||||||
case "second":
|
|
||||||
return "二等奖"
|
|
||||||
case "third":
|
|
||||||
return "三等奖"
|
|
||||||
case "excellent":
|
|
||||||
return "优秀奖"
|
|
||||||
case "none":
|
|
||||||
return "无奖项"
|
|
||||||
default:
|
|
||||||
return level
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 加载数据
|
|
||||||
const fetchData = async () => {
|
|
||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const [summary, results] = await Promise.all([
|
const params: QueryContestParams = {
|
||||||
resultsApi.getSummary(contestId),
|
...searchParams,
|
||||||
resultsApi.getResults(contestId, pagination.current, pagination.pageSize),
|
contestType: activeTab.value,
|
||||||
])
|
page: pagination.current,
|
||||||
summaryData.value = summary
|
pageSize: pagination.pageSize,
|
||||||
resultsData.value = results
|
}
|
||||||
pagination.total = results.total
|
const response = await contestsApi.getList(params)
|
||||||
} catch (error: any) {
|
dataSource.value = response.list
|
||||||
message.error(error?.response?.data?.message || "加载数据失败")
|
pagination.total = response.total
|
||||||
|
} catch (error) {
|
||||||
|
message.error("获取列表失败")
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 加载列表
|
// Tab切换
|
||||||
const fetchList = async () => {
|
const handleTabChange = () => {
|
||||||
listLoading.value = true
|
pagination.current = 1
|
||||||
try {
|
fetchList()
|
||||||
const results = await resultsApi.getResults(
|
|
||||||
contestId,
|
|
||||||
pagination.current,
|
|
||||||
pagination.pageSize
|
|
||||||
)
|
|
||||||
resultsData.value = results
|
|
||||||
pagination.total = results.total
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "加载列表失败")
|
|
||||||
} finally {
|
|
||||||
listLoading.value = false
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 搜索
|
||||||
|
const handleSearch = () => {
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 重置
|
||||||
|
const handleReset = () => {
|
||||||
|
searchParams.contestName = ""
|
||||||
|
pagination.current = 1
|
||||||
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 表格变化
|
// 表格变化
|
||||||
@ -530,224 +178,24 @@ const handleTableChange = (pag: any) => {
|
|||||||
fetchList()
|
fetchList()
|
||||||
}
|
}
|
||||||
|
|
||||||
// 计算最终得分
|
// 查看详情
|
||||||
const handleCalculateScores = async () => {
|
const handleViewDetail = (record: Contest) => {
|
||||||
calculateScoresLoading.value = true
|
router.push(`/${tenantCode}/contests/results/${record.id}`)
|
||||||
try {
|
|
||||||
const result = await resultsApi.calculateScores(contestId)
|
|
||||||
message.success(result.message)
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "计算失败")
|
|
||||||
} finally {
|
|
||||||
calculateScoresLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 计算排名
|
|
||||||
const handleCalculateRankings = async () => {
|
|
||||||
calculateRankingsLoading.value = true
|
|
||||||
try {
|
|
||||||
const result = await resultsApi.calculateRankings(contestId)
|
|
||||||
message.success(result.message)
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "计算失败")
|
|
||||||
} finally {
|
|
||||||
calculateRankingsLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 自动设置奖项
|
|
||||||
const handleAutoSetAwards = async () => {
|
|
||||||
autoAwardLoading.value = true
|
|
||||||
try {
|
|
||||||
const result = await resultsApi.autoSetAwards(contestId, autoAwardForm)
|
|
||||||
message.success(result.message)
|
|
||||||
showAutoAwardModal.value = false
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "设置失败")
|
|
||||||
} finally {
|
|
||||||
autoAwardLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 发布/撤回赛果
|
|
||||||
const handlePublish = () => {
|
|
||||||
const isPublished = summaryData.value?.contest?.resultState === "published"
|
|
||||||
|
|
||||||
Modal.confirm({
|
|
||||||
title: isPublished ? "确定撤回赛果发布吗?" : "确定发布赛果吗?",
|
|
||||||
content: isPublished
|
|
||||||
? "撤回后,赛果将不再对外公开显示"
|
|
||||||
: "发布后,比赛结果将公开显示",
|
|
||||||
onOk: async () => {
|
|
||||||
publishLoading.value = true
|
|
||||||
try {
|
|
||||||
if (isPublished) {
|
|
||||||
await resultsApi.unpublish(contestId)
|
|
||||||
message.success("已撤回发布")
|
|
||||||
} else {
|
|
||||||
await resultsApi.publish(contestId)
|
|
||||||
message.success("发布成功")
|
|
||||||
}
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "操作失败")
|
|
||||||
} finally {
|
|
||||||
publishLoading.value = false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
// 设置单个奖项
|
|
||||||
const handleSetAward = (record: ContestResult) => {
|
|
||||||
currentWork.value = record
|
|
||||||
setAwardForm.awardLevel = (record.awardLevel as any) || "first"
|
|
||||||
setAwardForm.awardName = record.awardName || ""
|
|
||||||
showSetAwardModal.value = true
|
|
||||||
}
|
|
||||||
|
|
||||||
// 提交设置奖项
|
|
||||||
const handleSetAwardSubmit = async () => {
|
|
||||||
if (!currentWork.value) return
|
|
||||||
|
|
||||||
setAwardLoading.value = true
|
|
||||||
try {
|
|
||||||
await resultsApi.setAward(currentWork.value.id, setAwardForm)
|
|
||||||
message.success("设置成功")
|
|
||||||
showSetAwardModal.value = false
|
|
||||||
fetchData()
|
|
||||||
} catch (error: any) {
|
|
||||||
message.error(error?.response?.data?.message || "设置失败")
|
|
||||||
} finally {
|
|
||||||
setAwardLoading.value = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 导出结果
|
|
||||||
const handleExport = () => {
|
|
||||||
if (!resultsData.value?.list || resultsData.value.list.length === 0) {
|
|
||||||
message.warning("没有可导出的数据")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const headers = [
|
|
||||||
"排名",
|
|
||||||
"作品编号",
|
|
||||||
"作品标题",
|
|
||||||
"参赛者/团队",
|
|
||||||
"最终得分",
|
|
||||||
"奖项",
|
|
||||||
]
|
|
||||||
const rows = resultsData.value.list.map((item) => {
|
|
||||||
const participant =
|
|
||||||
item.registration?.team?.teamName ||
|
|
||||||
item.registration?.user?.nickname ||
|
|
||||||
item.registration?.user?.username ||
|
|
||||||
"-"
|
|
||||||
return [
|
|
||||||
item.rank || "-",
|
|
||||||
item.workNo || "-",
|
|
||||||
item.title,
|
|
||||||
participant,
|
|
||||||
item.finalScore ? Number(item.finalScore).toFixed(2) : "-",
|
|
||||||
item.awardName || getAwardText(item.awardLevel || "") || "-",
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
const csvContent = [headers, ...rows].map((row) => row.join(",")).join("\n")
|
|
||||||
const blob = new Blob(["\ufeff" + csvContent], {
|
|
||||||
type: "text/csv;charset=utf-8",
|
|
||||||
})
|
|
||||||
const url = window.URL.createObjectURL(blob)
|
|
||||||
const link = document.createElement("a")
|
|
||||||
link.href = url
|
|
||||||
link.download = `赛果_${
|
|
||||||
summaryData.value?.contest?.contestName || contestId
|
|
||||||
}_${dayjs().format("YYYYMMDD")}.csv`
|
|
||||||
document.body.appendChild(link)
|
|
||||||
link.click()
|
|
||||||
document.body.removeChild(link)
|
|
||||||
window.URL.revokeObjectURL(url)
|
|
||||||
|
|
||||||
message.success("导出成功")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
fetchData()
|
fetchList()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.results-page {
|
||||||
.stats-row {
|
.search-form {
|
||||||
margin-bottom: 24px;
|
|
||||||
padding: 16px;
|
|
||||||
background: #fafafa;
|
|
||||||
border-radius: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.action-card {
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-card :deep(.ant-card-body) {
|
|
||||||
cursor: pointer;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.action-desc {
|
.mb-4 {
|
||||||
font-size: 12px;
|
|
||||||
color: #999;
|
|
||||||
margin: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.published-card {
|
|
||||||
border-color: #52c41a;
|
|
||||||
}
|
|
||||||
|
|
||||||
.award-card {
|
|
||||||
margin-bottom: 16px;
|
margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.list-card {
|
|
||||||
margin-bottom: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank {
|
|
||||||
display: inline-block;
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
line-height: 28px;
|
|
||||||
text-align: center;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #f0f0f0;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank-1 {
|
|
||||||
background: linear-gradient(135deg, #ffd700, #ffed4a);
|
|
||||||
color: #8b6914;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank-2 {
|
|
||||||
background: linear-gradient(135deg, #c0c0c0, #e8e8e8);
|
|
||||||
color: #666;
|
|
||||||
}
|
|
||||||
|
|
||||||
.rank-3 {
|
|
||||||
background: linear-gradient(135deg, #cd7f32, #daa06d);
|
|
||||||
color: #5c3317;
|
|
||||||
}
|
|
||||||
|
|
||||||
.no-rank {
|
|
||||||
color: #999;
|
|
||||||
}
|
|
||||||
|
|
||||||
.score {
|
|
||||||
font-weight: bold;
|
|
||||||
color: #52c41a; /* 使用绿色主题 */
|
|
||||||
}
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@ -570,7 +570,10 @@ const updateRenderSettings = () => {
|
|||||||
const resetSettings = () => {
|
const resetSettings = () => {
|
||||||
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
|
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
|
||||||
|
|
||||||
updateBackgroundColor()
|
// 重置为透明背景
|
||||||
|
if (scene) {
|
||||||
|
scene.background = null
|
||||||
|
}
|
||||||
updateGridVisibility()
|
updateGridVisibility()
|
||||||
updateAmbientLight()
|
updateAmbientLight()
|
||||||
updateMainLight()
|
updateMainLight()
|
||||||
@ -584,9 +587,9 @@ const resetSettings = () => {
|
|||||||
const initScene = () => {
|
const initScene = () => {
|
||||||
if (!containerRef.value) return
|
if (!containerRef.value) return
|
||||||
|
|
||||||
// 创建场景
|
// 创建场景 - 使用透明背景,让页面渐变效果显示
|
||||||
scene = new THREE.Scene()
|
scene = new THREE.Scene()
|
||||||
scene.background = new THREE.Color(sceneSettings.backgroundColor)
|
scene.background = null
|
||||||
|
|
||||||
// 创建相机
|
// 创建相机
|
||||||
const width = containerRef.value.clientWidth
|
const width = containerRef.value.clientWidth
|
||||||
@ -1172,14 +1175,17 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
// Header
|
// Header
|
||||||
// ==========================================
|
// ==========================================
|
||||||
.page-header {
|
.page-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@ -1273,12 +1279,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
// Content Area
|
// Content Area
|
||||||
// ==========================================
|
// ==========================================
|
||||||
.viewer-content {
|
.viewer-content {
|
||||||
flex: 1;
|
position: absolute;
|
||||||
position: relative;
|
inset: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba($surface, 0.3);
|
|
||||||
backdrop-filter: blur(10px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.model-canvas {
|
.model-canvas {
|
||||||
@ -1436,7 +1440,7 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
.model-info {
|
.model-info {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 84px;
|
||||||
left: 20px;
|
left: 20px;
|
||||||
background: rgba($surface, 0.8);
|
background: rgba($surface, 0.8);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
@ -1502,10 +1506,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
// ==========================================
|
// ==========================================
|
||||||
.scene-settings {
|
.scene-settings {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 20px;
|
top: 84px;
|
||||||
right: 20px;
|
right: 20px;
|
||||||
width: 280px;
|
width: 280px;
|
||||||
max-height: calc(100% - 40px);
|
max-height: calc(100% - 104px);
|
||||||
background: rgba($surface, 0.85);
|
background: rgba($surface, 0.85);
|
||||||
backdrop-filter: blur(20px);
|
backdrop-filter: blur(20px);
|
||||||
border: 1px solid rgba($primary, 0.3);
|
border: 1px solid rgba($primary, 0.3);
|
||||||
|
|||||||
@ -118,8 +118,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Input Display -->
|
<!-- Input Display - 仅文生3D显示提示词 -->
|
||||||
<div class="input-display">
|
<div class="input-display" v-if="task?.inputType === 'text'">
|
||||||
<div class="input-card">
|
<div class="input-card">
|
||||||
<div class="input-icon">
|
<div class="input-icon">
|
||||||
<ThunderboltOutlined />
|
<ThunderboltOutlined />
|
||||||
@ -187,10 +187,14 @@ const pageTitle = computed(() => {
|
|||||||
return "3D生成"
|
return "3D生成"
|
||||||
})
|
})
|
||||||
|
|
||||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
// 获取预览图URL
|
||||||
const getPreviewUrl = (url: string) => {
|
const getPreviewUrl = (url: string) => {
|
||||||
if (!url) return ""
|
if (!url) return ""
|
||||||
// 如果是腾讯云COS链接,通过代理访问
|
// 自己的COS桶直接访问(已配置公有读)
|
||||||
|
if (url.includes("competition-ms-1325825530.cos")) {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||||
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
|
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
|
||||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
|
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -75,7 +75,9 @@
|
|||||||
class="preview-image"
|
class="preview-image"
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
v-else-if="task.status === 'processing' || task.status === 'pending'"
|
v-else-if="
|
||||||
|
task.status === 'processing' || task.status === 'pending'
|
||||||
|
"
|
||||||
class="preview-loading"
|
class="preview-loading"
|
||||||
>
|
>
|
||||||
<div class="loading-dots">
|
<div class="loading-dots">
|
||||||
@ -141,7 +143,7 @@
|
|||||||
{{ formatTime(task.createTime) }}
|
{{ formatTime(task.createTime) }}
|
||||||
</span>
|
</span>
|
||||||
<span class="card-type">
|
<span class="card-type">
|
||||||
{{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
|
{{ task.inputType === "text" ? "文生3D" : "图生3D" }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -164,9 +166,9 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, onMounted, onUnmounted } from 'vue'
|
import { ref, onMounted, onUnmounted } from "vue"
|
||||||
import { useRouter, useRoute } from 'vue-router'
|
import { useRouter, useRoute } from "vue-router"
|
||||||
import { message, Modal } from 'ant-design-vue'
|
import { message, Modal } from "ant-design-vue"
|
||||||
import {
|
import {
|
||||||
ArrowLeftOutlined,
|
ArrowLeftOutlined,
|
||||||
FileImageOutlined,
|
FileImageOutlined,
|
||||||
@ -176,15 +178,15 @@ import {
|
|||||||
ReloadOutlined,
|
ReloadOutlined,
|
||||||
DeleteOutlined,
|
DeleteOutlined,
|
||||||
ThunderboltOutlined,
|
ThunderboltOutlined,
|
||||||
} from '@ant-design/icons-vue'
|
} from "@ant-design/icons-vue"
|
||||||
import {
|
import {
|
||||||
getAI3DTasks,
|
getAI3DTasks,
|
||||||
retryAI3DTask,
|
retryAI3DTask,
|
||||||
deleteAI3DTask,
|
deleteAI3DTask,
|
||||||
type AI3DTask,
|
type AI3DTask,
|
||||||
type AI3DTaskStatus,
|
type AI3DTaskStatus,
|
||||||
} from '@/api/ai-3d'
|
} from "@/api/ai-3d"
|
||||||
import dayjs from 'dayjs'
|
import dayjs from "dayjs"
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
@ -195,7 +197,7 @@ const list = ref<AI3DTask[]>([])
|
|||||||
const total = ref(0)
|
const total = ref(0)
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
const pageSize = ref(12)
|
const pageSize = ref(12)
|
||||||
const statusFilter = ref<AI3DTaskStatus | ''>('')
|
const statusFilter = ref<AI3DTaskStatus | "">("")
|
||||||
|
|
||||||
// 轮询定时器
|
// 轮询定时器
|
||||||
let pollingTimer: number | null = null
|
let pollingTimer: number | null = null
|
||||||
@ -227,8 +229,8 @@ const fetchList = async () => {
|
|||||||
list.value = data.list || []
|
list.value = data.list || []
|
||||||
total.value = data.total || 0
|
total.value = data.total || 0
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('获取历史记录失败:', error)
|
console.error("获取历史记录失败:", error)
|
||||||
message.error('获取历史记录失败')
|
message.error("获取历史记录失败")
|
||||||
} finally {
|
} finally {
|
||||||
loading.value = false
|
loading.value = false
|
||||||
}
|
}
|
||||||
@ -249,38 +251,43 @@ const handlePageChange = (page: number) => {
|
|||||||
// 获取预览图URL
|
// 获取预览图URL
|
||||||
const getPreviewUrl = (task: AI3DTask) => {
|
const getPreviewUrl = (task: AI3DTask) => {
|
||||||
if (task.previewUrl) {
|
if (task.previewUrl) {
|
||||||
|
// 自己的COS桶直接访问(已配置公有读)
|
||||||
|
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
|
||||||
|
return task.previewUrl
|
||||||
|
}
|
||||||
|
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||||
if (
|
if (
|
||||||
task.previewUrl.includes('tencentcos.cn') ||
|
task.previewUrl.includes("tencentcos.cn") ||
|
||||||
task.previewUrl.includes('qcloud.com')
|
task.previewUrl.includes("qcloud.com")
|
||||||
) {
|
) {
|
||||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||||
}
|
}
|
||||||
return task.previewUrl
|
return task.previewUrl
|
||||||
}
|
}
|
||||||
return ''
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取状态文本
|
// 获取状态文本
|
||||||
const getStatusText = (status: string) => {
|
const getStatusText = (status: string) => {
|
||||||
const texts: Record<string, string> = {
|
const texts: Record<string, string> = {
|
||||||
pending: '等待中',
|
pending: "等待中",
|
||||||
processing: '生成中',
|
processing: "生成中",
|
||||||
completed: '已完成',
|
completed: "已完成",
|
||||||
failed: '失败',
|
failed: "失败",
|
||||||
timeout: '超时',
|
timeout: "超时",
|
||||||
}
|
}
|
||||||
return texts[status] || status
|
return texts[status] || status
|
||||||
}
|
}
|
||||||
|
|
||||||
// 格式化时间
|
// 格式化时间
|
||||||
const formatTime = (time: string) => {
|
const formatTime = (time: string) => {
|
||||||
return dayjs(time).format('YYYY-MM-DD HH:mm')
|
return dayjs(time).format("YYYY-MM-DD HH:mm")
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查看任务详情
|
// 查看任务详情
|
||||||
const handleViewTask = (task: AI3DTask) => {
|
const handleViewTask = (task: AI3DTask) => {
|
||||||
router.push({
|
router.push({
|
||||||
name: 'AI3DGenerate',
|
name: "AI3DGenerate",
|
||||||
params: { taskId: task.id },
|
params: { taskId: task.id },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -299,34 +306,34 @@ const handlePreview = (task: AI3DTask) => {
|
|||||||
// 重试任务
|
// 重试任务
|
||||||
const handleRetry = async (task: AI3DTask) => {
|
const handleRetry = async (task: AI3DTask) => {
|
||||||
if (task.retryCount >= 3) {
|
if (task.retryCount >= 3) {
|
||||||
message.warning('已达到最大重试次数,请创建新任务')
|
message.warning("已达到最大重试次数,请创建新任务")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
await retryAI3DTask(task.id)
|
await retryAI3DTask(task.id)
|
||||||
message.success('重试已提交')
|
message.success("重试已提交")
|
||||||
fetchList()
|
fetchList()
|
||||||
startPolling()
|
startPolling()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
message.error(error.response?.data?.message || '重试失败')
|
message.error(error.response?.data?.message || "重试失败")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 删除任务
|
// 删除任务
|
||||||
const handleDelete = (task: AI3DTask) => {
|
const handleDelete = (task: AI3DTask) => {
|
||||||
Modal.confirm({
|
Modal.confirm({
|
||||||
title: '确认删除',
|
title: "确认删除",
|
||||||
content: '确定要删除这条创作记录吗?',
|
content: "确定要删除这条创作记录吗?",
|
||||||
okText: '删除',
|
okText: "删除",
|
||||||
okType: 'danger',
|
okType: "danger",
|
||||||
cancelText: '取消',
|
cancelText: "取消",
|
||||||
async onOk() {
|
async onOk() {
|
||||||
try {
|
try {
|
||||||
await deleteAI3DTask(task.id)
|
await deleteAI3DTask(task.id)
|
||||||
message.success('删除成功')
|
message.success("删除成功")
|
||||||
fetchList()
|
fetchList()
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
message.error('删除失败')
|
message.error("删除失败")
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@ -337,7 +344,7 @@ const startPolling = () => {
|
|||||||
if (pollingTimer) return
|
if (pollingTimer) return
|
||||||
pollingTimer = window.setInterval(async () => {
|
pollingTimer = window.setInterval(async () => {
|
||||||
const hasProcessing = list.value.some(
|
const hasProcessing = list.value.some(
|
||||||
(t) => t.status === 'pending' || t.status === 'processing'
|
(t) => t.status === "pending" || t.status === "processing"
|
||||||
)
|
)
|
||||||
if (!hasProcessing) {
|
if (!hasProcessing) {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
@ -358,7 +365,7 @@ const stopPolling = () => {
|
|||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await fetchList()
|
await fetchList()
|
||||||
const hasProcessing = list.value.some(
|
const hasProcessing = list.value.some(
|
||||||
(t) => t.status === 'pending' || t.status === 'processing'
|
(t) => t.status === "pending" || t.status === "processing"
|
||||||
)
|
)
|
||||||
if (hasProcessing) {
|
if (hasProcessing) {
|
||||||
startPolling()
|
startPolling()
|
||||||
@ -468,14 +475,17 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
// Header
|
// Header
|
||||||
// ==========================================
|
// ==========================================
|
||||||
.page-header {
|
.page-header {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
height: 64px;
|
height: 64px;
|
||||||
padding: 0 24px;
|
padding: 0 24px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
flex-shrink: 0;
|
|
||||||
position: relative;
|
|
||||||
z-index: 10;
|
z-index: 10;
|
||||||
|
background: transparent;
|
||||||
}
|
}
|
||||||
|
|
||||||
.header-left {
|
.header-left {
|
||||||
@ -536,11 +546,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
.page-content {
|
.page-content {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
padding-top: 88px;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
position: relative;
|
position: relative;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: rgba($surface, 0.3);
|
|
||||||
backdrop-filter: blur(20px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
@ -725,7 +734,11 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
.failed-icon {
|
.failed-icon {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: linear-gradient(135deg, rgba($error, 0.15) 0%, rgba($error, 0.25) 100%);
|
background: linear-gradient(
|
||||||
|
135deg,
|
||||||
|
rgba($error, 0.15) 0%,
|
||||||
|
rgba($error, 0.25) 100%
|
||||||
|
);
|
||||||
border: 2px solid rgba($error, 0.3);
|
border: 2px solid rgba($error, 0.3);
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
display: flex;
|
display: flex;
|
||||||
@ -795,7 +808,8 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
}
|
}
|
||||||
|
|
||||||
@keyframes pulse-error {
|
@keyframes pulse-error {
|
||||||
0%, 100% {
|
0%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
box-shadow: 0 0 0 0 rgba($error, 0.3);
|
||||||
}
|
}
|
||||||
@ -816,14 +830,22 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
animation: dotPulse 1.4s ease-in-out infinite;
|
animation: dotPulse 1.4s ease-in-out infinite;
|
||||||
|
|
||||||
&:nth-child(1) { animation-delay: 0s; }
|
&:nth-child(1) {
|
||||||
&:nth-child(2) { animation-delay: 0.2s; }
|
animation-delay: 0s;
|
||||||
&:nth-child(3) { animation-delay: 0.4s; }
|
}
|
||||||
|
&:nth-child(2) {
|
||||||
|
animation-delay: 0.2s;
|
||||||
|
}
|
||||||
|
&:nth-child(3) {
|
||||||
|
animation-delay: 0.4s;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes dotPulse {
|
@keyframes dotPulse {
|
||||||
0%, 60%, 100% {
|
0%,
|
||||||
|
60%,
|
||||||
|
100% {
|
||||||
transform: scale(1);
|
transform: scale(1);
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
@ -944,6 +966,7 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
|||||||
|
|
||||||
.page-content {
|
.page-content {
|
||||||
padding: 16px;
|
padding: 16px;
|
||||||
|
padding-top: 80px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.history-grid {
|
.history-grid {
|
||||||
|
|||||||
@ -667,17 +667,19 @@ const handleDelete = (task: AI3DTask) => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
// 获取预览图URL
|
||||||
const getPreviewUrl = (task: AI3DTask) => {
|
const getPreviewUrl = (task: AI3DTask) => {
|
||||||
if (task.previewUrl) {
|
if (task.previewUrl) {
|
||||||
// 如果是腾讯云COS链接,通过代理访问
|
// 自己的COS桶直接访问(已配置公有读)
|
||||||
|
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
|
||||||
|
return task.previewUrl
|
||||||
|
}
|
||||||
|
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||||
if (
|
if (
|
||||||
task.previewUrl.includes("tencentcos.cn") ||
|
task.previewUrl.includes("tencentcos.cn") ||
|
||||||
task.previewUrl.includes("qcloud.com")
|
task.previewUrl.includes("qcloud.com")
|
||||||
) {
|
) {
|
||||||
// 确保URL正确编码
|
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||||
const encodedUrl = encodeURIComponent(task.previewUrl)
|
|
||||||
return `/api/ai-3d/proxy-preview?url=${encodedUrl}`
|
|
||||||
}
|
}
|
||||||
// 其他URL直接返回
|
// 其他URL直接返回
|
||||||
return task.previewUrl
|
return task.previewUrl
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user