修改赛果发布
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,
|
||||
} from '@nestjs/common';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { OssService } from '../oss/oss.service';
|
||||
import { CreateTaskDto } from './dto/create-task.dto';
|
||||
import { QueryTaskDto } from './dto/query-task.dto';
|
||||
import { AI3DProvider, AI3D_PROVIDER } from './providers/ai-3d-provider.interface';
|
||||
@ -21,6 +22,7 @@ export class AI3DService {
|
||||
|
||||
constructor(
|
||||
private prisma: PrismaService,
|
||||
private ossService: OssService,
|
||||
@Inject(AI3D_PROVIDER) private ai3dProvider: AI3DProvider,
|
||||
) {}
|
||||
|
||||
@ -270,14 +272,63 @@ export class AI3DService {
|
||||
const result = await this.ai3dProvider.queryTask(externalTaskId);
|
||||
|
||||
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({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: result.status,
|
||||
resultUrl: result.resultUrl,
|
||||
previewUrl: result.previewUrl,
|
||||
resultUrls: result.resultUrls || null,
|
||||
previewUrls: result.previewUrls || null,
|
||||
resultUrl: finalResultUrl,
|
||||
previewUrl: finalPreviewUrl,
|
||||
resultUrls: finalResultUrls,
|
||||
previewUrls: finalPreviewUrls,
|
||||
errorMessage: result.errorMessage,
|
||||
completeTime: new Date(),
|
||||
},
|
||||
|
||||
@ -7,7 +7,6 @@ import {
|
||||
AI3DGenerateOptions,
|
||||
} from './ai-3d-provider.interface';
|
||||
import { TencentCloudSigner } from '../utils/tencent-cloud-sign';
|
||||
import { ZipHandler } from '../utils/zip-handler';
|
||||
|
||||
/**
|
||||
* 腾讯混元 3D Provider
|
||||
@ -202,6 +201,7 @@ export class HunyuanAI3DProvider implements AI3DProvider {
|
||||
|
||||
// 如果任务完成,提取模型URL
|
||||
// 根据API文档,返回的是 ResultFile3Ds 数组
|
||||
// 注意:这里只返回原始URL,COS转存由AI3DService统一处理
|
||||
if (status === 'completed' && result.ResultFile3Ds?.length > 0) {
|
||||
const file3Ds = result.ResultFile3Ds;
|
||||
// 提取所有模型URL和预览图URL
|
||||
@ -210,54 +210,18 @@ export class HunyuanAI3DProvider implements AI3DProvider {
|
||||
.map((file: any) => file.PreviewImageUrl)
|
||||
.filter(Boolean);
|
||||
|
||||
// 处理.zip文件:下载并解压
|
||||
if (urls.length > 0) {
|
||||
const firstUrl = urls[0];
|
||||
generateResult.resultUrl = urls[0];
|
||||
generateResult.resultUrls = urls;
|
||||
}
|
||||
|
||||
// 检查是否是.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.previewUrl = previewUrls[0];
|
||||
generateResult.previewUrls = previewUrls;
|
||||
}
|
||||
} else {
|
||||
// 不是ZIP文件,直接使用URL
|
||||
generateResult.resultUrl = firstUrl;
|
||||
generateResult.resultUrls = urls;
|
||||
|
||||
if (previewUrls.length > 0) {
|
||||
generateResult.previewUrl = previewUrls[0];
|
||||
generateResult.previewUrls = previewUrls;
|
||||
}
|
||||
}
|
||||
if (previewUrls.length > 0) {
|
||||
generateResult.previewUrl = previewUrls[0];
|
||||
generateResult.previewUrls = previewUrls;
|
||||
}
|
||||
|
||||
this.logger.log(
|
||||
`混元3D任务 ${taskId} 完成: ${generateResult.resultUrls?.length || 0} 个模型文件`,
|
||||
`混元3D任务 ${taskId} 完成: ${urls.length} 个模型文件, ${previewUrls.length} 个预览图`,
|
||||
);
|
||||
} else if (status === 'failed') {
|
||||
// 失败原因:根据文档,错误信息在 ErrorMessage 字段
|
||||
|
||||
@ -510,19 +510,7 @@ export class ContestsService {
|
||||
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 (
|
||||
@ -723,19 +711,7 @@ export class ContestsService {
|
||||
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 = {
|
||||
contestState,
|
||||
@ -760,32 +736,56 @@ export class ContestsService {
|
||||
async remove(id: number) {
|
||||
const contest = await this.prisma.contest.findUnique({
|
||||
where: { id },
|
||||
include: {
|
||||
_count: {
|
||||
select: {
|
||||
registrations: true,
|
||||
works: true,
|
||||
teams: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (!contest) {
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// 软删除
|
||||
return this.prisma.contest.update({
|
||||
where: { id },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
// 2. 删除团队记录
|
||||
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 },
|
||||
data: {
|
||||
validState: 2,
|
||||
},
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@ -326,6 +326,12 @@ export class RegistrationsService {
|
||||
},
|
||||
user: {
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
include: {
|
||||
class: {
|
||||
@ -409,11 +415,27 @@ export class RegistrationsService {
|
||||
include: {
|
||||
contest: true,
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
email: true,
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
include: {
|
||||
class: {
|
||||
include: {
|
||||
grade: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
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 { BatchSetAwardsDto } from './dto/batch-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 { RequirePermission } from '../../auth/decorators/require-permission.decorator';
|
||||
|
||||
@ -94,20 +95,15 @@ export class ResultsController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取比赛结果列表
|
||||
* 获取比赛结果列表(作品列表)
|
||||
*/
|
||||
@Get(':contestId')
|
||||
@RequirePermission('contest:read')
|
||||
getResults(
|
||||
@Param('contestId', ParseIntPipe) contestId: number,
|
||||
@Query('page') page?: string,
|
||||
@Query('pageSize') pageSize?: string,
|
||||
@Query() queryDto: QueryResultsDto,
|
||||
) {
|
||||
return this.resultsService.getResults(
|
||||
contestId,
|
||||
page ? parseInt(page) : 1,
|
||||
pageSize ? parseInt(pageSize) : 20,
|
||||
);
|
||||
return this.resultsService.getResults(contestId, queryDto);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -6,6 +6,7 @@ import {
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { SetAwardDto } from './dto/set-award.dto';
|
||||
import { BatchSetAwardsDto } from './dto/batch-set-awards.dto';
|
||||
import { QueryResultsDto } from './dto/query-results.dto';
|
||||
|
||||
@Injectable()
|
||||
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({
|
||||
where: { id: contestId },
|
||||
include: {
|
||||
@ -464,21 +467,55 @@ export class ResultsService {
|
||||
|
||||
const skip = (page - 1) * pageSize;
|
||||
|
||||
// 构建查询条件
|
||||
const where: any = {
|
||||
contestId,
|
||||
validState: 1,
|
||||
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: {
|
||||
contestId,
|
||||
validState: 1,
|
||||
isLatest: true,
|
||||
},
|
||||
where,
|
||||
include: {
|
||||
registration: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
include: {
|
||||
tenant: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
student: {
|
||||
include: {
|
||||
class: {
|
||||
include: {
|
||||
grade: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
team: {
|
||||
@ -487,59 +524,40 @@ export class ResultsService {
|
||||
teamName: true,
|
||||
},
|
||||
},
|
||||
teachers: {
|
||||
include: {
|
||||
user: {
|
||||
select: {
|
||||
id: true,
|
||||
username: true,
|
||||
nickname: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [
|
||||
{ rank: { sort: 'asc', nulls: 'last' } },
|
||||
{ finalScore: 'desc' },
|
||||
],
|
||||
skip,
|
||||
take: pageSize,
|
||||
}),
|
||||
this.prisma.contestWork.count({
|
||||
where: {
|
||||
contestId,
|
||||
validState: 1,
|
||||
isLatest: true,
|
||||
},
|
||||
}),
|
||||
this.prisma.contestWork.count({ where }),
|
||||
]);
|
||||
|
||||
// 统计奖项分布
|
||||
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 {
|
||||
contest: {
|
||||
id: contest.id,
|
||||
contestName: contest.contestName,
|
||||
resultState: contest.resultState,
|
||||
resultPublishTime: contest.resultPublishTime,
|
||||
reviewRule: contest.reviewRule,
|
||||
},
|
||||
list: works,
|
||||
total,
|
||||
page,
|
||||
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 COS from 'cos-nodejs-sdk-v5';
|
||||
import * as path from 'path';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import { randomBytes } from 'crypto';
|
||||
import axios from 'axios';
|
||||
import AdmZip from 'adm-zip';
|
||||
|
||||
@Injectable()
|
||||
export class OssService {
|
||||
private readonly logger = new Logger(OssService.name);
|
||||
private client: COS | null = null;
|
||||
private readonly bucket: string;
|
||||
private readonly region: string;
|
||||
@ -56,20 +61,16 @@ export class OssService {
|
||||
throw new BadRequestException('COS 服务未启用');
|
||||
}
|
||||
|
||||
// 生成唯一文件名
|
||||
const fileExt = path.extname(originalName);
|
||||
// 生成唯一文件名,图片和文件分开存储
|
||||
const fileExt = path.extname(originalName).toLowerCase();
|
||||
const uniqueId = randomBytes(16).toString('hex');
|
||||
const fileName = `${uniqueId}${fileExt}`;
|
||||
|
||||
// 构建 COS 存储路径:uploads/tenant_X/user_Y/filename
|
||||
let cosPath = 'uploads';
|
||||
if (tenantId) {
|
||||
cosPath += `/tenant_${tenantId}`;
|
||||
if (userId) {
|
||||
cosPath += `/user_${userId}`;
|
||||
}
|
||||
}
|
||||
cosPath += `/${fileName}`;
|
||||
// 判断是否为图片
|
||||
const imageExts = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.bmp', '.svg'];
|
||||
const isImage = imageExts.includes(fileExt);
|
||||
|
||||
// 图片: img/{id}.ext, 文件: file/{id}.ext
|
||||
const cosPath = isImage ? `img/${uniqueId}${fileExt}` : `file/${uniqueId}${fileExt}`;
|
||||
|
||||
try {
|
||||
// 上传到 COS
|
||||
@ -199,4 +200,177 @@ export class OssService {
|
||||
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 { UploadController, UploadsController } from './upload.controller';
|
||||
import { UploadService } from './upload.service';
|
||||
import { OssModule } from '../oss/oss.module';
|
||||
|
||||
@Module({
|
||||
imports: [OssModule],
|
||||
controllers: [UploadController, UploadsController],
|
||||
providers: [UploadService],
|
||||
exports: [UploadService],
|
||||
|
||||
645
frontend/package-lock.json
generated
645
frontend/package-lock.json
generated
@ -10,16 +10,20 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@vee-validate/zod": "^4.12.4",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
"pinia": "^2.1.7",
|
||||
"three": "^0.182.0",
|
||||
"vee-validate": "^4.12.4",
|
||||
"vue": "^3.4.21",
|
||||
"vue-router": "^4.3.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/multer": "^2.0.0",
|
||||
"@vitejs/plugin-vue": "^5.0.4",
|
||||
"@vue/eslint-config-typescript": "^13.0.0",
|
||||
"autoprefixer": "^10.4.18",
|
||||
@ -1394,6 +1398,33 @@
|
||||
"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": {
|
||||
"version": "1.0.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
|
||||
@ -1401,6 +1432,99 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.18.0",
|
||||
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-7.18.0.tgz",
|
||||
@ -1601,6 +1725,61 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "4.15.1",
|
||||
"resolved": "https://registry.npmjs.org/@vee-validate/zod/-/zod-4.15.1.tgz",
|
||||
@ -1840,6 +2019,165 @@
|
||||
"integrity": "sha512-7Z6/y3uFI5PRoKeorTOSXKcDj0MSasfNNltcslbFrPpcw6aXRUALq4IfJlaTRspiWIUOEZbrpM+iQGmCOiWe4A==",
|
||||
"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": {
|
||||
"version": "8.15.0",
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
|
||||
@ -2369,6 +2707,19 @@
|
||||
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
|
||||
"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": {
|
||||
"version": "1.11.19",
|
||||
"resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz",
|
||||
@ -2482,6 +2833,15 @@
|
||||
"integrity": "sha512-bvVTQe1lfaUr1oFzZX80ce9KLDlZ3iU+XGNE/bz9HnGdklTieqsbmsLHe+rT2XWqopvL0PckkYqN7ksmm5pe3w==",
|
||||
"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": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||
@ -2560,6 +2920,46 @@
|
||||
"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": {
|
||||
"version": "0.21.5",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz",
|
||||
@ -2756,6 +3156,21 @@
|
||||
"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": {
|
||||
"version": "9.6.1",
|
||||
"resolved": "https://registry.npmjs.org/espree/-/espree-9.6.1.tgz",
|
||||
@ -2826,6 +3241,25 @@
|
||||
"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": {
|
||||
"version": "3.1.3",
|
||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||
@ -3263,6 +3697,25 @@
|
||||
"integrity": "sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==",
|
||||
"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": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz",
|
||||
@ -3273,6 +3726,16 @@
|
||||
"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": {
|
||||
"version": "5.1.4",
|
||||
"resolved": "https://registry.npmjs.org/immutable/-/immutable-5.1.4.tgz",
|
||||
@ -3378,6 +3841,12 @@
|
||||
"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": {
|
||||
"version": "7.0.0",
|
||||
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
|
||||
@ -3407,6 +3876,12 @@
|
||||
"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": {
|
||||
"version": "5.5.0",
|
||||
"resolved": "https://registry.npmjs.org/is-what/-/is-what-5.5.0.tgz",
|
||||
@ -3548,6 +4023,37 @@
|
||||
"integrity": "sha512-XEawp1t0gxSi9x01glktRZ5HDy0HXqrM0x5pXQM98EaI0NxO6jVM7omDOxsuEo5UIASAnm2bRp1Jt/e0a2XU8Q==",
|
||||
"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": {
|
||||
"version": "4.6.2",
|
||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||
@ -3555,6 +4061,18 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz",
|
||||
@ -3618,6 +4136,15 @@
|
||||
"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": {
|
||||
"version": "2.1.35",
|
||||
"resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz",
|
||||
@ -3678,6 +4205,12 @@
|
||||
"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": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
@ -3709,6 +4242,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-7.1.1.tgz",
|
||||
@ -4123,6 +4662,16 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz",
|
||||
@ -4133,6 +4682,15 @@
|
||||
"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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz",
|
||||
@ -4413,6 +4971,56 @@
|
||||
"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": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
@ -4431,6 +5039,12 @@
|
||||
"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": {
|
||||
"version": "6.0.1",
|
||||
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
|
||||
@ -4643,6 +5257,12 @@
|
||||
"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": {
|
||||
"version": "5.0.2",
|
||||
"resolved": "https://registry.npmjs.org/throttle-debounce/-/throttle-debounce-5.0.2.tgz",
|
||||
@ -4652,6 +5272,12 @@
|
||||
"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": {
|
||||
"version": "0.2.15",
|
||||
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz",
|
||||
@ -4733,6 +5359,12 @@
|
||||
"dev": true,
|
||||
"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": {
|
||||
"version": "0.4.0",
|
||||
"resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz",
|
||||
@ -4772,6 +5404,13 @@
|
||||
"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": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
@ -5058,6 +5697,12 @@
|
||||
"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": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
|
||||
|
||||
@ -11,6 +11,8 @@
|
||||
"dependencies": {
|
||||
"@ant-design/icons-vue": "^7.0.1",
|
||||
"@vee-validate/zod": "^4.12.4",
|
||||
"@wangeditor/editor": "^5.1.23",
|
||||
"@wangeditor/editor-for-vue": "^5.1.12",
|
||||
"ant-design-vue": "^4.1.1",
|
||||
"axios": "^1.6.7",
|
||||
"dayjs": "^1.11.10",
|
||||
|
||||
@ -1310,11 +1310,19 @@ export const resultsApi = {
|
||||
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>(
|
||||
`/contests/results/${contestId}`,
|
||||
{ params: { page, pageSize } }
|
||||
{ params: { page: params.page || 1, pageSize: params.pageSize || 10, ...params } }
|
||||
);
|
||||
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"],
|
||||
},
|
||||
},
|
||||
// 赛果发布详情路由
|
||||
{
|
||||
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",
|
||||
|
||||
@ -44,7 +44,8 @@
|
||||
|
||||
<!-- 卡片描述 -->
|
||||
<div class="card-desc">
|
||||
赛事时间:{{ formatDate(contest.startTime) }} ~ {{ formatDate(contest.endTime) }}
|
||||
赛事时间:{{ formatDate(contest.startTime) }} ~
|
||||
{{ formatDate(contest.endTime) }}
|
||||
</div>
|
||||
|
||||
<!-- 卡片标签 -->
|
||||
@ -52,7 +53,10 @@
|
||||
<span class="tag tag-type">
|
||||
{{ contest.contestType === "individual" ? "个人赛" : "团队赛" }}
|
||||
</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) }}
|
||||
</span>
|
||||
<span v-if="getStageText(contest)" class="tag tag-stage">
|
||||
@ -452,11 +456,6 @@ $primary-light: #1677ff;
|
||||
&:hover {
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.08);
|
||||
transform: translateY(-2px);
|
||||
|
||||
.card-icon {
|
||||
background: linear-gradient(135deg, #ff7a45 0%, #fa541c 100%);
|
||||
color: #fff;
|
||||
}
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
|
||||
@ -1,302 +1,285 @@
|
||||
<template>
|
||||
<div class="create-contest-page">
|
||||
<a-spin :spinning="loading">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="text"
|
||||
@click="handleCancel"
|
||||
style="padding: 0; margin-right: 8px"
|
||||
<a-spin :spinning="pageLoading">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<a-space>
|
||||
<a-button
|
||||
type="text"
|
||||
@click="handleCancel"
|
||||
style="padding: 0; margin-right: 8px"
|
||||
>
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests`"
|
||||
>赛事管理</router-link
|
||||
>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{ isEdit ? '编辑比赛' : '创建比赛' }}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 5 }"
|
||||
:wrapper-col="{ span: 19 }"
|
||||
layout="vertical"
|
||||
>
|
||||
<!-- 主办信息 -->
|
||||
<a-card title="主办信息" size="small" class="section-card">
|
||||
<a-form-item label="主办单位" name="organizers" required>
|
||||
<a-input
|
||||
v-model:value="form.organizers"
|
||||
placeholder="请输入主办单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="协办单位" name="coOrganizers">
|
||||
<a-input
|
||||
v-model:value="form.coOrganizers"
|
||||
placeholder="请输入协办单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赞助单位" name="sponsors">
|
||||
<a-input
|
||||
v-model:value="form.sponsors"
|
||||
placeholder="请输入赞助单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 赛事信息 -->
|
||||
<a-card title="赛事信息" size="small" class="section-card">
|
||||
<a-form-item label="赛事名称" name="contestName" required>
|
||||
<a-input
|
||||
v-model:value="form.contestName"
|
||||
placeholder="请输入赛事名称"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事时间" name="timeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="timeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
@change="handleTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事类型" name="contestType" required>
|
||||
<a-select
|
||||
v-model:value="form.contestType"
|
||||
placeholder="请选择赛事类型"
|
||||
style="width: 200px"
|
||||
>
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
</a-button>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/${tenantCode}/contests`"
|
||||
>赛事管理</router-link
|
||||
>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>{{
|
||||
isEditMode ? "编辑比赛" : "创建比赛"
|
||||
}}</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ span: 5 }"
|
||||
:wrapper-col="{ span: 19 }"
|
||||
layout="vertical"
|
||||
>
|
||||
<!-- 主办信息 -->
|
||||
<a-card title="主办信息" size="small" class="section-card">
|
||||
<a-form-item label="主办单位" name="organizers" required>
|
||||
<a-input
|
||||
v-model:value="form.organizers"
|
||||
placeholder="请输入主办单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="协办单位" name="coOrganizers">
|
||||
<a-input
|
||||
v-model:value="form.coOrganizers"
|
||||
placeholder="请输入协办单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赞助单位" name="sponsors">
|
||||
<a-input
|
||||
v-model:value="form.sponsors"
|
||||
placeholder="请输入赞助单位"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 赛事信息 -->
|
||||
<a-card title="赛事信息" size="small" class="section-card">
|
||||
<a-form-item label="赛事名称" name="contestName" required>
|
||||
<a-input
|
||||
v-model:value="form.contestName"
|
||||
placeholder="请输入赛事名称"
|
||||
:maxlength="200"
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事时间" name="timeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="timeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
@change="handleTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事类型" name="contestType" required>
|
||||
<a-select
|
||||
v-model:value="form.contestType"
|
||||
placeholder="请选择赛事类型"
|
||||
style="width: 200px"
|
||||
>
|
||||
<a-select-option value="individual">个人赛</a-select-option>
|
||||
<a-select-option value="team">团队赛</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事详情" name="content" required>
|
||||
<a-textarea
|
||||
v-model:value="form.content"
|
||||
placeholder="请输入赛事详细说明"
|
||||
:rows="8"
|
||||
:maxlength="5000"
|
||||
show-count
|
||||
style="width: 600px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事封面" name="coverUrl" required>
|
||||
<a-upload
|
||||
v-model:file-list="coverFileList"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handleCoverUpload"
|
||||
accept="image/*"
|
||||
:max-count="1"
|
||||
>
|
||||
<div v-if="coverFileList.length < 1">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-alert
|
||||
message="图片要求:尺寸16:9,文件大小小于30M"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-top: 8px; width: fit-content"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事海报" name="posterUrl" required>
|
||||
<a-upload
|
||||
v-model:file-list="posterFileList"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handlePosterUpload"
|
||||
accept="image/*"
|
||||
:max-count="1"
|
||||
>
|
||||
<div v-if="posterFileList.length < 1">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传海报</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-alert
|
||||
message="图片要求:尺寸16:9,文件大小小于30M"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-top: 8px; width: fit-content"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事附件" name="attachments">
|
||||
<a-upload
|
||||
v-model:file-list="attachmentFileList"
|
||||
:before-upload="beforeFileUpload"
|
||||
:custom-request="handleAttachmentUpload"
|
||||
:max-count="10"
|
||||
>
|
||||
<a-button>
|
||||
<upload-outlined />
|
||||
上传附件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 报名设置 -->
|
||||
<a-card title="报名设置" size="small" class="section-card">
|
||||
<a-form-item label="报名时间" name="registerTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="registerTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledRegisterDate"
|
||||
@change="handleRegisterTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 作品提交设置 -->
|
||||
<a-card title="作品提交设置" size="small" class="section-card">
|
||||
<a-form-item label="提交规则" name="submitRule" required>
|
||||
<a-radio-group v-model:value="form.submitRule">
|
||||
<a-radio value="once">仅允许提交一次</a-radio>
|
||||
<a-radio value="resubmit">时间范围内允许重复提交</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="提交时间" name="submitTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="submitTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledSubmitDate"
|
||||
@change="handleSubmitTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 评审规则 -->
|
||||
<a-card title="评审规则" size="small" class="section-card">
|
||||
<a-form-item label="评审规则" name="reviewRuleId">
|
||||
<a-select
|
||||
v-model:value="form.reviewRuleId"
|
||||
placeholder="请选择评审规则(可选)"
|
||||
style="width: 600px"
|
||||
:options="reviewRuleOptions"
|
||||
:loading="reviewRuleLoading"
|
||||
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 label="评审时间" name="reviewTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="reviewTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledReviewDate"
|
||||
@change="handleReviewTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 结果公布 -->
|
||||
<a-card title="结果公布" size="small" class="section-card">
|
||||
<a-form-item label="公布时间" name="resultPublishTime">
|
||||
<a-date-picker
|
||||
v-model:value="resultPublishTime"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledPublishDate"
|
||||
placeholder="请选择结果公布时间"
|
||||
@change="handleResultPublishTimeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
</a-form>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
<a-select-option value="individual">个人赛</a-select-option>
|
||||
<a-select-option value="team">团队赛</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事详情" name="content" required>
|
||||
<RichTextEditor
|
||||
v-model="form.content"
|
||||
placeholder="请输入赛事详细说明"
|
||||
:height="300"
|
||||
style="width: 800px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事封面" name="coverUrl" required>
|
||||
<a-upload
|
||||
v-model:file-list="coverFileList"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handleCoverUpload"
|
||||
accept="image/*"
|
||||
:max-count="1"
|
||||
>
|
||||
{{ isEditMode ? "更新" : "保存" }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
<div v-if="coverFileList.length < 1">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传封面</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-alert
|
||||
message="图片要求:尺寸16:9,文件大小小于30M"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-top: 8px; width: fit-content"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事海报" name="posterUrl" required>
|
||||
<a-upload
|
||||
v-model:file-list="posterFileList"
|
||||
list-type="picture-card"
|
||||
:before-upload="beforeUpload"
|
||||
:custom-request="handlePosterUpload"
|
||||
accept="image/*"
|
||||
:max-count="1"
|
||||
>
|
||||
<div v-if="posterFileList.length < 1">
|
||||
<plus-outlined />
|
||||
<div style="margin-top: 8px">上传海报</div>
|
||||
</div>
|
||||
</a-upload>
|
||||
<div class="upload-tip">
|
||||
<a-alert
|
||||
message="图片要求:尺寸16:9,文件大小小于30M"
|
||||
type="info"
|
||||
show-icon
|
||||
style="margin-top: 8px; width: fit-content"
|
||||
/>
|
||||
</div>
|
||||
</a-form-item>
|
||||
<a-form-item label="赛事附件" name="attachments">
|
||||
<a-upload
|
||||
v-model:file-list="attachmentFileList"
|
||||
:before-upload="beforeFileUpload"
|
||||
:custom-request="handleAttachmentUpload"
|
||||
:max-count="10"
|
||||
>
|
||||
<a-button>
|
||||
<upload-outlined />
|
||||
上传附件
|
||||
</a-button>
|
||||
</a-upload>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 报名设置 -->
|
||||
<a-card title="报名设置" size="small" class="section-card">
|
||||
<a-form-item label="报名时间" name="registerTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="registerTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledRegisterDate"
|
||||
@change="handleRegisterTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 作品提交设置 -->
|
||||
<a-card title="作品提交设置" size="small" class="section-card">
|
||||
<a-form-item label="提交规则" name="submitRule" required>
|
||||
<a-radio-group v-model:value="form.submitRule">
|
||||
<a-radio value="once">仅允许提交一次</a-radio>
|
||||
<a-radio value="resubmit">时间范围内允许重复提交</a-radio>
|
||||
</a-radio-group>
|
||||
</a-form-item>
|
||||
<a-form-item label="提交时间" name="submitTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="submitTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledSubmitDate"
|
||||
@change="handleSubmitTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 评审规则 -->
|
||||
<a-card title="评审规则" size="small" class="section-card">
|
||||
<a-form-item label="评审规则" name="reviewRuleId">
|
||||
<a-select
|
||||
v-model:value="form.reviewRuleId"
|
||||
placeholder="请选择评审规则(可选)"
|
||||
style="width: 600px"
|
||||
:options="reviewRuleOptions"
|
||||
allow-clear
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="评审时间" name="reviewTimeRange" required>
|
||||
<a-range-picker
|
||||
v-model:value="reviewTimeRange"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledReviewDate"
|
||||
@change="handleReviewTimeRangeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
|
||||
<!-- 结果公布 -->
|
||||
<a-card title="结果公布" size="small" class="section-card">
|
||||
<a-form-item label="公布时间" name="resultPublishTime">
|
||||
<a-date-picker
|
||||
v-model:value="resultPublishTime"
|
||||
show-time
|
||||
format="YYYY-MM-DD HH:mm"
|
||||
style="width: 600px"
|
||||
:disabled-date="disabledPublishDate"
|
||||
placeholder="请选择结果公布时间"
|
||||
@change="handleResultPublishTimeChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-card>
|
||||
</a-form>
|
||||
|
||||
<!-- 底部操作按钮 -->
|
||||
<div class="form-actions">
|
||||
<a-space>
|
||||
<a-button @click="handleCancel">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:loading="submitLoading"
|
||||
@click="handleSubmit"
|
||||
>
|
||||
保存
|
||||
</a-button>
|
||||
</a-space>
|
||||
</div>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<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 { message } from "ant-design-vue"
|
||||
import type { FormInstance, UploadFile } from "ant-design-vue"
|
||||
import type { Dayjs } from "dayjs"
|
||||
import dayjs from "dayjs"
|
||||
import {
|
||||
PlusOutlined,
|
||||
UploadOutlined,
|
||||
ArrowLeftOutlined,
|
||||
} from "@ant-design/icons-vue"
|
||||
import RichTextEditor from "@/components/RichTextEditor.vue"
|
||||
import {
|
||||
contestsApi,
|
||||
attachmentsApi,
|
||||
reviewRulesApi,
|
||||
type CreateContestForm,
|
||||
type UpdateContestForm,
|
||||
type ReviewRule,
|
||||
type Contest,
|
||||
} from "@/api/contests"
|
||||
import dayjs from "dayjs"
|
||||
import { uploadFile } from "@/api/upload"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
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 submitLoading = ref(false)
|
||||
const loading = ref(false)
|
||||
|
||||
// 表单数据
|
||||
const form = reactive<
|
||||
@ -338,7 +321,19 @@ const attachmentFileList = ref<UploadFile[]>([])
|
||||
|
||||
// 评审规则选项
|
||||
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 = {
|
||||
@ -463,20 +458,20 @@ const beforeFileUpload = (file: File) => {
|
||||
const handleCoverUpload = async (options: any) => {
|
||||
const { file, onSuccess, onError } = options
|
||||
try {
|
||||
// TODO: 调用实际上传接口
|
||||
// 注意:这里需要实现实际上传逻辑,上传成功后返回文件URL
|
||||
// 示例:const response = await uploadFile(file)
|
||||
// form.coverUrl = response.url
|
||||
|
||||
// 临时方案:使用本地URL(仅用于开发测试)
|
||||
const fileUrl = URL.createObjectURL(file)
|
||||
form.coverUrl = fileUrl
|
||||
|
||||
onSuccess()
|
||||
message.success("封面上传成功")
|
||||
} catch (error) {
|
||||
const result: any = await uploadFile(file)
|
||||
// 兼容不同的响应格式
|
||||
const url = result.data?.url || result.url
|
||||
if (url) {
|
||||
form.coverUrl = url
|
||||
onSuccess()
|
||||
message.success("封面上传成功")
|
||||
} else {
|
||||
throw new Error("无法获取图片地址")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("封面上传失败:", 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 { file, onSuccess, onError } = options
|
||||
try {
|
||||
// TODO: 调用实际上传接口
|
||||
// 注意:这里需要实现实际上传逻辑,上传成功后返回文件URL
|
||||
// 示例:const response = await uploadFile(file)
|
||||
// form.posterUrl = response.url
|
||||
|
||||
// 临时方案:使用本地URL(仅用于开发测试)
|
||||
const fileUrl = URL.createObjectURL(file)
|
||||
form.posterUrl = fileUrl
|
||||
|
||||
onSuccess()
|
||||
message.success("海报上传成功")
|
||||
} catch (error) {
|
||||
const result: any = await uploadFile(file)
|
||||
// 兼容不同的响应格式
|
||||
const url = result.data?.url || result.url
|
||||
if (url) {
|
||||
form.posterUrl = url
|
||||
onSuccess()
|
||||
message.success("海报上传成功")
|
||||
} else {
|
||||
throw new Error("无法获取图片地址")
|
||||
}
|
||||
} catch (error: any) {
|
||||
console.error("海报上传失败:", error)
|
||||
onError(error)
|
||||
message.error("海报上传失败")
|
||||
message.error(error?.response?.data?.message || "海报上传失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 附件上传
|
||||
const handleAttachmentUpload = async (options: any) => {
|
||||
const { onSuccess, onError } = options
|
||||
const { file, onSuccess, onError } = options
|
||||
try {
|
||||
// TODO: 调用实际上传接口
|
||||
// 注意:附件上传后需要保存文件信息,但此时比赛还未创建,所以需要在创建比赛后再关联附件
|
||||
// 临时方案:先保存文件信息到 attachmentFileList,创建比赛后再上传
|
||||
|
||||
onSuccess()
|
||||
message.success("附件上传成功(将在创建比赛后保存)")
|
||||
} catch (error) {
|
||||
const result: any = await uploadFile(file)
|
||||
// 兼容不同的响应格式
|
||||
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()
|
||||
message.success("附件上传成功")
|
||||
} 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)
|
||||
message.error("附件上传失败")
|
||||
message.error(error?.response?.data?.message || "附件上传失败")
|
||||
}
|
||||
}
|
||||
|
||||
@ -611,93 +640,93 @@ const disabledPublishDate = (current: Dayjs | null) => {
|
||||
)
|
||||
}
|
||||
|
||||
// 加载比赛详情(编辑模式)
|
||||
const loadContestDetail = async () => {
|
||||
if (!contestId) return
|
||||
// 加载赛事数据(编辑模式)
|
||||
const loadContestData = async () => {
|
||||
if (!contestId.value) return
|
||||
|
||||
pageLoading.value = true
|
||||
try {
|
||||
loading.value = true
|
||||
const contest = await contestsApi.getDetail(contestId)
|
||||
const contest = await contestsApi.getDetail(contestId.value)
|
||||
|
||||
// 填充表单数据
|
||||
form.contestName = contest.contestName || ""
|
||||
form.contestType = contest.contestType || "individual"
|
||||
form.startTime = contest.startTime || ""
|
||||
form.endTime = contest.endTime || ""
|
||||
form.content = contest.content || ""
|
||||
form.coverUrl = contest.coverUrl || ""
|
||||
form.posterUrl = contest.posterUrl || ""
|
||||
form.contestName = contest.contestName || ''
|
||||
form.contestType = contest.contestType || 'individual'
|
||||
form.startTime = contest.startTime || ''
|
||||
form.endTime = contest.endTime || ''
|
||||
form.content = contest.content || ''
|
||||
form.coverUrl = contest.coverUrl || ''
|
||||
form.posterUrl = contest.posterUrl || ''
|
||||
// 处理主办/协办/赞助单位(后端返回数组,表单需要字符串)
|
||||
form.organizers = Array.isArray(contest.organizers)
|
||||
? contest.organizers.join(",")
|
||||
: contest.organizers || ""
|
||||
? contest.organizers.join('、')
|
||||
: (contest.organizers || '')
|
||||
form.coOrganizers = Array.isArray(contest.coOrganizers)
|
||||
? contest.coOrganizers.join(",")
|
||||
: contest.coOrganizers || ""
|
||||
? contest.coOrganizers.join('、')
|
||||
: (contest.coOrganizers || '')
|
||||
form.sponsors = Array.isArray(contest.sponsors)
|
||||
? contest.sponsors.join(",")
|
||||
: contest.sponsors || ""
|
||||
form.registerStartTime = contest.registerStartTime || ""
|
||||
form.registerEndTime = contest.registerEndTime || ""
|
||||
form.submitRule = contest.submitRule || "once"
|
||||
form.submitStartTime = contest.submitStartTime || ""
|
||||
form.submitEndTime = contest.submitEndTime || ""
|
||||
form.reviewStartTime = contest.reviewStartTime || ""
|
||||
form.reviewEndTime = contest.reviewEndTime || ""
|
||||
form.resultPublishTime = contest.resultPublishTime || ""
|
||||
form.reviewRuleId = contest.reviewRule?.id
|
||||
? contest.sponsors.join('、')
|
||||
: (contest.sponsors || '')
|
||||
form.registerStartTime = contest.registerStartTime || ''
|
||||
form.registerEndTime = contest.registerEndTime || ''
|
||||
form.submitRule = contest.submitRule || 'once'
|
||||
form.submitStartTime = contest.submitStartTime || ''
|
||||
form.submitEndTime = contest.submitEndTime || ''
|
||||
form.reviewRuleId = contest.reviewRuleId || undefined
|
||||
form.reviewStartTime = contest.reviewStartTime || ''
|
||||
form.reviewEndTime = contest.reviewEndTime || ''
|
||||
form.resultPublishTime = contest.resultPublishTime || ''
|
||||
|
||||
// 设置时间范围
|
||||
if (contest.startTime && contest.endTime) {
|
||||
timeRange.value = [dayjs(contest.startTime), dayjs(contest.endTime)]
|
||||
}
|
||||
if (contest.registerStartTime && contest.registerEndTime) {
|
||||
registerTimeRange.value = [
|
||||
dayjs(contest.registerStartTime),
|
||||
dayjs(contest.registerEndTime),
|
||||
]
|
||||
registerTimeRange.value = [dayjs(contest.registerStartTime), dayjs(contest.registerEndTime)]
|
||||
}
|
||||
if (contest.submitStartTime && contest.submitEndTime) {
|
||||
submitTimeRange.value = [
|
||||
dayjs(contest.submitStartTime),
|
||||
dayjs(contest.submitEndTime),
|
||||
]
|
||||
submitTimeRange.value = [dayjs(contest.submitStartTime), dayjs(contest.submitEndTime)]
|
||||
}
|
||||
if (contest.reviewStartTime && contest.reviewEndTime) {
|
||||
reviewTimeRange.value = [
|
||||
dayjs(contest.reviewStartTime),
|
||||
dayjs(contest.reviewEndTime),
|
||||
]
|
||||
reviewTimeRange.value = [dayjs(contest.reviewStartTime), dayjs(contest.reviewEndTime)]
|
||||
}
|
||||
if (contest.resultPublishTime) {
|
||||
resultPublishTime.value = dayjs(contest.resultPublishTime)
|
||||
}
|
||||
|
||||
// 设置文件列表(如果有)
|
||||
// 设置封面图片
|
||||
if (contest.coverUrl) {
|
||||
coverFileList.value = [
|
||||
{
|
||||
uid: "-1",
|
||||
name: "cover.jpg",
|
||||
status: "done",
|
||||
url: contest.coverUrl,
|
||||
},
|
||||
]
|
||||
coverFileList.value = [{
|
||||
uid: '-1',
|
||||
name: 'cover',
|
||||
status: 'done',
|
||||
url: contest.coverUrl,
|
||||
}]
|
||||
}
|
||||
|
||||
// 设置海报图片
|
||||
if (contest.posterUrl) {
|
||||
posterFileList.value = [
|
||||
{
|
||||
uid: "-2",
|
||||
name: "poster.jpg",
|
||||
status: "done",
|
||||
url: contest.posterUrl,
|
||||
},
|
||||
]
|
||||
posterFileList.value = [{
|
||||
uid: '-2',
|
||||
name: 'poster',
|
||||
status: 'done',
|
||||
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) {
|
||||
message.error(error?.response?.data?.message || "加载比赛详情失败")
|
||||
router.push(`/${tenantCode}/contests`)
|
||||
message.error(error?.response?.data?.message || '加载赛事数据失败')
|
||||
router.back()
|
||||
} finally {
|
||||
loading.value = false
|
||||
pageLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@ -707,13 +736,61 @@ const handleSubmit = async () => {
|
||||
await formRef.value?.validate()
|
||||
submitLoading.value = true
|
||||
|
||||
if (isEditMode.value && contestId) {
|
||||
// 更新比赛
|
||||
await contestsApi.update(contestId, form as UpdateContestForm)
|
||||
message.success("更新成功")
|
||||
// 构建提交数据,确保所有字符串字段不为 null/undefined
|
||||
const submitData: CreateContestForm = {
|
||||
contestName: form.contestName || '',
|
||||
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 {
|
||||
// 创建比赛
|
||||
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("创建成功")
|
||||
}
|
||||
|
||||
@ -724,10 +801,7 @@ const handleSubmit = async () => {
|
||||
// 表单验证错误
|
||||
return
|
||||
}
|
||||
message.error(
|
||||
error?.response?.data?.message ||
|
||||
(isEditMode.value ? "更新失败" : "创建失败")
|
||||
)
|
||||
message.error(error?.response?.data?.message || (isEdit.value ? "保存失败" : "创建失败"))
|
||||
} finally {
|
||||
submitLoading.value = false
|
||||
}
|
||||
@ -738,33 +812,13 @@ const handleCancel = () => {
|
||||
router.back()
|
||||
}
|
||||
|
||||
// 加载评审规则列表
|
||||
const loadReviewRules = async () => {
|
||||
try {
|
||||
reviewRuleLoading.value = true
|
||||
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
|
||||
}
|
||||
}
|
||||
// 页面加载
|
||||
onMounted(() => {
|
||||
// 获取评审规则列表
|
||||
fetchReviewRules()
|
||||
|
||||
// 跳转到评审规则列表
|
||||
const goToReviewRules = () => {
|
||||
router.push(`/${tenantCode}/contests/reviews`)
|
||||
}
|
||||
|
||||
// 组件挂载时加载数据
|
||||
onMounted(async () => {
|
||||
await loadReviewRules()
|
||||
if (isEditMode.value) {
|
||||
await loadContestDetail()
|
||||
if (isEdit.value) {
|
||||
loadContestData()
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
@ -66,7 +66,7 @@
|
||||
}}
|
||||
</div>
|
||||
<a-button
|
||||
v-if="isRegistering && !hasRegistered"
|
||||
v-if="isTeacher && isRegistering && !hasRegistered"
|
||||
type="primary"
|
||||
size="large"
|
||||
class="register-button"
|
||||
@ -75,7 +75,7 @@
|
||||
立即报名
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else-if="hasRegistered && canViewRegistration"
|
||||
v-else-if="isTeacher && hasRegistered && canViewRegistration"
|
||||
type="default"
|
||||
size="large"
|
||||
class="register-button"
|
||||
@ -84,7 +84,7 @@
|
||||
查看报名
|
||||
</a-button>
|
||||
<a-button
|
||||
v-else
|
||||
v-else-if="isTeacher"
|
||||
type="default"
|
||||
size="large"
|
||||
class="register-button"
|
||||
@ -242,7 +242,7 @@
|
||||
<!-- 右侧:组织信息 -->
|
||||
<a-col :xs="24" :lg="8">
|
||||
<div class="org-info-card">
|
||||
<div class="info-item">
|
||||
<!-- <div class="info-item">
|
||||
<div class="info-label">| 发布者</div>
|
||||
<div class="info-value">
|
||||
<div v-if="(contest as any).publisher">
|
||||
@ -250,7 +250,7 @@
|
||||
</div>
|
||||
<div v-else>-</div>
|
||||
</div>
|
||||
</div>
|
||||
</div> -->
|
||||
|
||||
<div class="info-item">
|
||||
<div class="info-label">| 类型</div>
|
||||
@ -379,7 +379,15 @@ const myRegistration = ref<any>(null)
|
||||
// 检查是否有查看报名的权限
|
||||
const canViewRegistration = computed(() => {
|
||||
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)
|
||||
@ -449,14 +457,6 @@ const isRegistering = computed(() => {
|
||||
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(() => {
|
||||
if (!contest.value || !isRegistering.value) return 0
|
||||
@ -466,24 +466,6 @@ const daysRemaining = computed(() => {
|
||||
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) => {
|
||||
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) => {
|
||||
if (!rank) return "default"
|
||||
|
||||
@ -143,7 +143,6 @@
|
||||
v-permission="'contest:update'"
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="record.contestState === 'published'"
|
||||
@click.stop="handleEdit(record.id)"
|
||||
>
|
||||
编辑
|
||||
|
||||
@ -54,6 +54,9 @@
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ formatOrganizers(record.organizers) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
{{ record._count?.registrations || 0 }}
|
||||
</template>
|
||||
@ -114,6 +117,9 @@
|
||||
<template v-else-if="column.key === 'contestName'">
|
||||
<a @click="handleViewContest(record.id)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'organizers'">
|
||||
{{ formatOrganizers(record.organizers) }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'teamCount'">
|
||||
{{ record._count?.teams || 0 }}
|
||||
</template>
|
||||
@ -211,6 +217,11 @@ const individualColumns = [
|
||||
dataIndex: "contestName",
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "主办单位",
|
||||
key: "organizers",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "报名人数",
|
||||
key: "registrationCount",
|
||||
@ -242,6 +253,11 @@ const teamColumns = [
|
||||
dataIndex: "contestName",
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "主办单位",
|
||||
key: "organizers",
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
title: "报名队伍数",
|
||||
key: "teamCount",
|
||||
@ -271,6 +287,25 @@ const formatDateTime = (dateStr?: string) => {
|
||||
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 () => {
|
||||
loading.value = true
|
||||
|
||||
@ -169,7 +169,13 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<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 v-else-if="column.key === 'nickname'">
|
||||
{{ record.user?.nickname || record.accountName || "-" }}
|
||||
@ -240,7 +246,13 @@
|
||||
{{ (pagination.current - 1) * pagination.pageSize + index + 1 }}
|
||||
</template>
|
||||
<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 v-else-if="column.key === 'teamName'">
|
||||
{{ record.team?.teamName || "-" }}
|
||||
@ -311,7 +323,11 @@
|
||||
{{ currentDetail.accountNo || currentDetail.user?.username || "-" }}
|
||||
</a-descriptions-item>
|
||||
<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 label="审核状态">
|
||||
<a-tag :color="getStateColor(currentDetail.registrationState)">
|
||||
@ -936,4 +952,10 @@ onMounted(async () => {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.org-detail {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 2px;
|
||||
}
|
||||
</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>
|
||||
<div class="results-page">
|
||||
<a-card>
|
||||
<template #title>
|
||||
<a-breadcrumb>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/contests`">赛事管理</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>
|
||||
<router-link :to="`/contests/${contestId}`">{{
|
||||
summaryData?.contest?.contestName || "赛事详情"
|
||||
}}</router-link>
|
||||
</a-breadcrumb-item>
|
||||
<a-breadcrumb-item>赛果发布</a-breadcrumb-item>
|
||||
</a-breadcrumb>
|
||||
</template>
|
||||
<template #extra>
|
||||
<a-space>
|
||||
<a-tag
|
||||
v-if="summaryData?.contest?.resultState === 'published'"
|
||||
color="green"
|
||||
>已发布</a-tag
|
||||
>
|
||||
<a-tag v-else color="orange">未发布</a-tag>
|
||||
<a-button @click="fetchData" :loading="loading">
|
||||
<template #icon><ReloadOutlined /></template>
|
||||
刷新
|
||||
</a-button>
|
||||
<a-button @click="$router.back()">返回</a-button>
|
||||
</a-space>
|
||||
</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
|
||||
:columns="columns"
|
||||
:data-source="resultsData?.list || []"
|
||||
:loading="listLoading"
|
||||
:pagination="pagination"
|
||||
row-key="id"
|
||||
@change="handleTableChange"
|
||||
>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.key === 'rank'">
|
||||
<span v-if="record.rank" :class="getRankClass(record.rank)">
|
||||
{{ record.rank }}
|
||||
</span>
|
||||
<span v-else class="no-rank">-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'participant'">
|
||||
<span v-if="record.registration?.team">
|
||||
{{ 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 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 === 'awardLevel'">
|
||||
<a-tag
|
||||
v-if="record.awardLevel"
|
||||
:color="getAwardColor(record.awardLevel)"
|
||||
>
|
||||
{{ record.awardName || getAwardText(record.awardLevel) }}
|
||||
</a-tag>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
@click="handleSetAward(record)"
|
||||
>
|
||||
设置奖项
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
<a-card class="mb-4">
|
||||
<template #title>赛果发布</template>
|
||||
</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>
|
||||
<!-- Tab栏切换 -->
|
||||
<a-tabs v-model:activeKey="activeTab" @change="handleTabChange">
|
||||
<a-tab-pane key="individual" tab="个人赛" />
|
||||
<a-tab-pane key="team" tab="团队赛" />
|
||||
</a-tabs>
|
||||
|
||||
<!-- 设置单个奖项弹窗 -->
|
||||
<a-modal
|
||||
v-model:open="showSetAwardModal"
|
||||
title="设置奖项"
|
||||
:confirm-loading="setAwardLoading"
|
||||
@ok="handleSetAwardSubmit"
|
||||
@cancel="showSetAwardModal = false"
|
||||
<!-- 搜索表单 -->
|
||||
<a-form
|
||||
:model="searchParams"
|
||||
layout="inline"
|
||||
class="search-form"
|
||||
@finish="handleSearch"
|
||||
>
|
||||
<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>
|
||||
<a-form-item label="赛事名称">
|
||||
<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>
|
||||
重置
|
||||
</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 === 'contestName'">
|
||||
<a @click="handleViewDetail(record)">{{ record.contestName }}</a>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'registrationCount'">
|
||||
{{ record._count?.registrations || 0 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'worksCount'">
|
||||
{{ record._count?.works || 0 }}
|
||||
</template>
|
||||
<template v-else-if="column.key === 'action'">
|
||||
<a-button type="link" size="small" @click="handleViewDetail(record)">
|
||||
详情
|
||||
</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, reactive, onMounted } from "vue"
|
||||
import { useRoute } from "vue-router"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message } from "ant-design-vue"
|
||||
import { SearchOutlined, ReloadOutlined } from "@ant-design/icons-vue"
|
||||
import {
|
||||
ReloadOutlined,
|
||||
CalculatorOutlined,
|
||||
OrderedListOutlined,
|
||||
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,
|
||||
contestsApi,
|
||||
type Contest,
|
||||
type QueryContestParams,
|
||||
} from "@/api/contests"
|
||||
|
||||
const router = useRouter()
|
||||
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 listLoading = ref(false)
|
||||
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 dataSource = ref<Contest[]>([])
|
||||
const pagination = reactive({
|
||||
current: 1,
|
||||
pageSize: 20,
|
||||
pageSize: 10,
|
||||
total: 0,
|
||||
})
|
||||
|
||||
// 自动设置奖项
|
||||
const showAutoAwardModal = ref(false)
|
||||
const autoAwardForm = reactive<AutoSetAwardsForm>({
|
||||
first: 1,
|
||||
second: 2,
|
||||
third: 3,
|
||||
excellent: 5,
|
||||
// 搜索参数
|
||||
const searchParams = reactive<QueryContestParams>({
|
||||
contestName: "",
|
||||
})
|
||||
|
||||
// 设置单个奖项
|
||||
const showSetAwardModal = ref(false)
|
||||
const currentWork = ref<ContestResult | null>(null)
|
||||
const setAwardForm = reactive<SetAwardForm>({
|
||||
awardLevel: "first",
|
||||
awardName: "",
|
||||
})
|
||||
|
||||
// 表格列
|
||||
// 表格列定义
|
||||
const columns = [
|
||||
{
|
||||
title: "排名",
|
||||
key: "rank",
|
||||
width: 80,
|
||||
align: "center" as const,
|
||||
title: "序号",
|
||||
key: "index",
|
||||
width: 70,
|
||||
},
|
||||
{
|
||||
title: "作品编号",
|
||||
dataIndex: "workNo",
|
||||
key: "workNo",
|
||||
title: "赛事名称",
|
||||
key: "contestName",
|
||||
dataIndex: "contestName",
|
||||
width: 250,
|
||||
},
|
||||
{
|
||||
title: "报名人数",
|
||||
key: "registrationCount",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
title: "作品标题",
|
||||
dataIndex: "title",
|
||||
key: "title",
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: "参赛者/团队",
|
||||
key: "participant",
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
title: "最终得分",
|
||||
key: "finalScore",
|
||||
width: 100,
|
||||
align: "center" as const,
|
||||
},
|
||||
{
|
||||
title: "奖项",
|
||||
key: "awardLevel",
|
||||
title: "提交作品数",
|
||||
key: "worksCount",
|
||||
width: 120,
|
||||
},
|
||||
{
|
||||
@ -445,82 +132,43 @@ const columns = [
|
||||
},
|
||||
]
|
||||
|
||||
// 获取排名样式
|
||||
const getRankClass = (rank: number) => {
|
||||
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 () => {
|
||||
// 获取列表数据
|
||||
const fetchList = async () => {
|
||||
loading.value = true
|
||||
try {
|
||||
const [summary, results] = await Promise.all([
|
||||
resultsApi.getSummary(contestId),
|
||||
resultsApi.getResults(contestId, pagination.current, pagination.pageSize),
|
||||
])
|
||||
summaryData.value = summary
|
||||
resultsData.value = results
|
||||
pagination.total = results.total
|
||||
} catch (error: any) {
|
||||
message.error(error?.response?.data?.message || "加载数据失败")
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
// 加载列表
|
||||
const fetchList = async () => {
|
||||
listLoading.value = true
|
||||
try {
|
||||
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
|
||||
}
|
||||
// Tab切换
|
||||
const handleTabChange = () => {
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 搜索
|
||||
const handleSearch = () => {
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 重置
|
||||
const handleReset = () => {
|
||||
searchParams.contestName = ""
|
||||
pagination.current = 1
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 表格变化
|
||||
@ -530,224 +178,24 @@ const handleTableChange = (pag: any) => {
|
||||
fetchList()
|
||||
}
|
||||
|
||||
// 计算最终得分
|
||||
const handleCalculateScores = async () => {
|
||||
calculateScoresLoading.value = true
|
||||
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("导出成功")
|
||||
// 查看详情
|
||||
const handleViewDetail = (record: Contest) => {
|
||||
router.push(`/${tenantCode}/contests/results/${record.id}`)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
fetchList()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 24px;
|
||||
padding: 16px;
|
||||
background: #fafafa;
|
||||
border-radius: 8px;
|
||||
.results-page {
|
||||
.search-form {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
.action-card {
|
||||
.mb-4 {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.action-card :deep(.ant-card-body) {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.action-desc {
|
||||
font-size: 12px;
|
||||
color: #999;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.published-card {
|
||||
border-color: #52c41a;
|
||||
}
|
||||
|
||||
.award-card {
|
||||
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>
|
||||
|
||||
@ -570,7 +570,10 @@ const updateRenderSettings = () => {
|
||||
const resetSettings = () => {
|
||||
Object.assign(sceneSettings, JSON.parse(JSON.stringify(defaultSettings)))
|
||||
|
||||
updateBackgroundColor()
|
||||
// 重置为透明背景
|
||||
if (scene) {
|
||||
scene.background = null
|
||||
}
|
||||
updateGridVisibility()
|
||||
updateAmbientLight()
|
||||
updateMainLight()
|
||||
@ -584,9 +587,9 @@ const resetSettings = () => {
|
||||
const initScene = () => {
|
||||
if (!containerRef.value) return
|
||||
|
||||
// 创建场景
|
||||
// 创建场景 - 使用透明背景,让页面渐变效果显示
|
||||
scene = new THREE.Scene()
|
||||
scene.background = new THREE.Color(sceneSettings.backgroundColor)
|
||||
scene.background = null
|
||||
|
||||
// 创建相机
|
||||
const width = containerRef.value.clientWidth
|
||||
@ -1172,14 +1175,17 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
// Header
|
||||
// ==========================================
|
||||
.page-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@ -1273,12 +1279,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
// Content Area
|
||||
// ==========================================
|
||||
.viewer-content {
|
||||
flex: 1;
|
||||
position: relative;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
z-index: 1;
|
||||
background: rgba($surface, 0.3);
|
||||
backdrop-filter: blur(10px);
|
||||
}
|
||||
|
||||
.model-canvas {
|
||||
@ -1436,7 +1440,7 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
// ==========================================
|
||||
.model-info {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: 84px;
|
||||
left: 20px;
|
||||
background: rgba($surface, 0.8);
|
||||
backdrop-filter: blur(20px);
|
||||
@ -1502,10 +1506,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
// ==========================================
|
||||
.scene-settings {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
top: 84px;
|
||||
right: 20px;
|
||||
width: 280px;
|
||||
max-height: calc(100% - 40px);
|
||||
max-height: calc(100% - 104px);
|
||||
background: rgba($surface, 0.85);
|
||||
backdrop-filter: blur(20px);
|
||||
border: 1px solid rgba($primary, 0.3);
|
||||
|
||||
@ -118,8 +118,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Input Display -->
|
||||
<div class="input-display">
|
||||
<!-- Input Display - 仅文生3D显示提示词 -->
|
||||
<div class="input-display" v-if="task?.inputType === 'text'">
|
||||
<div class="input-card">
|
||||
<div class="input-icon">
|
||||
<ThunderboltOutlined />
|
||||
@ -187,10 +187,14 @@ const pageTitle = computed(() => {
|
||||
return "3D生成"
|
||||
})
|
||||
|
||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (url: string) => {
|
||||
if (!url) return ""
|
||||
// 如果是腾讯云COS链接,通过代理访问
|
||||
// 自己的COS桶直接访问(已配置公有读)
|
||||
if (url.includes("competition-ms-1325825530.cos")) {
|
||||
return url
|
||||
}
|
||||
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||
if (url.includes("tencentcos.cn") || url.includes("qcloud.com")) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(url)}`
|
||||
}
|
||||
|
||||
@ -75,7 +75,9 @@
|
||||
class="preview-image"
|
||||
/>
|
||||
<div
|
||||
v-else-if="task.status === 'processing' || task.status === 'pending'"
|
||||
v-else-if="
|
||||
task.status === 'processing' || task.status === 'pending'
|
||||
"
|
||||
class="preview-loading"
|
||||
>
|
||||
<div class="loading-dots">
|
||||
@ -141,7 +143,7 @@
|
||||
{{ formatTime(task.createTime) }}
|
||||
</span>
|
||||
<span class="card-type">
|
||||
{{ task.inputType === 'text' ? '文生3D' : '图生3D' }}
|
||||
{{ task.inputType === "text" ? "文生3D" : "图生3D" }}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -164,9 +166,9 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, onUnmounted } from 'vue'
|
||||
import { useRouter, useRoute } from 'vue-router'
|
||||
import { message, Modal } from 'ant-design-vue'
|
||||
import { ref, onMounted, onUnmounted } from "vue"
|
||||
import { useRouter, useRoute } from "vue-router"
|
||||
import { message, Modal } from "ant-design-vue"
|
||||
import {
|
||||
ArrowLeftOutlined,
|
||||
FileImageOutlined,
|
||||
@ -176,15 +178,15 @@ import {
|
||||
ReloadOutlined,
|
||||
DeleteOutlined,
|
||||
ThunderboltOutlined,
|
||||
} from '@ant-design/icons-vue'
|
||||
} from "@ant-design/icons-vue"
|
||||
import {
|
||||
getAI3DTasks,
|
||||
retryAI3DTask,
|
||||
deleteAI3DTask,
|
||||
type AI3DTask,
|
||||
type AI3DTaskStatus,
|
||||
} from '@/api/ai-3d'
|
||||
import dayjs from 'dayjs'
|
||||
} from "@/api/ai-3d"
|
||||
import dayjs from "dayjs"
|
||||
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
@ -195,7 +197,7 @@ const list = ref<AI3DTask[]>([])
|
||||
const total = ref(0)
|
||||
const currentPage = ref(1)
|
||||
const pageSize = ref(12)
|
||||
const statusFilter = ref<AI3DTaskStatus | ''>('')
|
||||
const statusFilter = ref<AI3DTaskStatus | "">("")
|
||||
|
||||
// 轮询定时器
|
||||
let pollingTimer: number | null = null
|
||||
@ -227,8 +229,8 @@ const fetchList = async () => {
|
||||
list.value = data.list || []
|
||||
total.value = data.total || 0
|
||||
} catch (error) {
|
||||
console.error('获取历史记录失败:', error)
|
||||
message.error('获取历史记录失败')
|
||||
console.error("获取历史记录失败:", error)
|
||||
message.error("获取历史记录失败")
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
@ -249,38 +251,43 @@ const handlePageChange = (page: number) => {
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (task: AI3DTask) => {
|
||||
if (task.previewUrl) {
|
||||
// 自己的COS桶直接访问(已配置公有读)
|
||||
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
|
||||
return task.previewUrl
|
||||
}
|
||||
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||
if (
|
||||
task.previewUrl.includes('tencentcos.cn') ||
|
||||
task.previewUrl.includes('qcloud.com')
|
||||
task.previewUrl.includes("tencentcos.cn") ||
|
||||
task.previewUrl.includes("qcloud.com")
|
||||
) {
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||
}
|
||||
return task.previewUrl
|
||||
}
|
||||
return ''
|
||||
return ""
|
||||
}
|
||||
|
||||
// 获取状态文本
|
||||
const getStatusText = (status: string) => {
|
||||
const texts: Record<string, string> = {
|
||||
pending: '等待中',
|
||||
processing: '生成中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
timeout: '超时',
|
||||
pending: "等待中",
|
||||
processing: "生成中",
|
||||
completed: "已完成",
|
||||
failed: "失败",
|
||||
timeout: "超时",
|
||||
}
|
||||
return texts[status] || status
|
||||
}
|
||||
|
||||
// 格式化时间
|
||||
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) => {
|
||||
router.push({
|
||||
name: 'AI3DGenerate',
|
||||
name: "AI3DGenerate",
|
||||
params: { taskId: task.id },
|
||||
})
|
||||
}
|
||||
@ -299,34 +306,34 @@ const handlePreview = (task: AI3DTask) => {
|
||||
// 重试任务
|
||||
const handleRetry = async (task: AI3DTask) => {
|
||||
if (task.retryCount >= 3) {
|
||||
message.warning('已达到最大重试次数,请创建新任务')
|
||||
message.warning("已达到最大重试次数,请创建新任务")
|
||||
return
|
||||
}
|
||||
try {
|
||||
await retryAI3DTask(task.id)
|
||||
message.success('重试已提交')
|
||||
message.success("重试已提交")
|
||||
fetchList()
|
||||
startPolling()
|
||||
} catch (error: any) {
|
||||
message.error(error.response?.data?.message || '重试失败')
|
||||
message.error(error.response?.data?.message || "重试失败")
|
||||
}
|
||||
}
|
||||
|
||||
// 删除任务
|
||||
const handleDelete = (task: AI3DTask) => {
|
||||
Modal.confirm({
|
||||
title: '确认删除',
|
||||
content: '确定要删除这条创作记录吗?',
|
||||
okText: '删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
title: "确认删除",
|
||||
content: "确定要删除这条创作记录吗?",
|
||||
okText: "删除",
|
||||
okType: "danger",
|
||||
cancelText: "取消",
|
||||
async onOk() {
|
||||
try {
|
||||
await deleteAI3DTask(task.id)
|
||||
message.success('删除成功')
|
||||
message.success("删除成功")
|
||||
fetchList()
|
||||
} catch (error) {
|
||||
message.error('删除失败')
|
||||
message.error("删除失败")
|
||||
}
|
||||
},
|
||||
})
|
||||
@ -337,7 +344,7 @@ const startPolling = () => {
|
||||
if (pollingTimer) return
|
||||
pollingTimer = window.setInterval(async () => {
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === 'pending' || t.status === 'processing'
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
)
|
||||
if (!hasProcessing) {
|
||||
stopPolling()
|
||||
@ -358,7 +365,7 @@ const stopPolling = () => {
|
||||
onMounted(async () => {
|
||||
await fetchList()
|
||||
const hasProcessing = list.value.some(
|
||||
(t) => t.status === 'pending' || t.status === 'processing'
|
||||
(t) => t.status === "pending" || t.status === "processing"
|
||||
)
|
||||
if (hasProcessing) {
|
||||
startPolling()
|
||||
@ -468,14 +475,17 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
// Header
|
||||
// ==========================================
|
||||
.page-header {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 64px;
|
||||
padding: 0 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
flex-shrink: 0;
|
||||
position: relative;
|
||||
z-index: 10;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
@ -536,11 +546,10 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
.page-content {
|
||||
flex: 1;
|
||||
padding: 24px;
|
||||
padding-top: 88px;
|
||||
overflow-y: auto;
|
||||
position: relative;
|
||||
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 {
|
||||
width: 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-radius: 50%;
|
||||
display: flex;
|
||||
@ -795,7 +808,8 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
}
|
||||
|
||||
@keyframes pulse-error {
|
||||
0%, 100% {
|
||||
0%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
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%;
|
||||
animation: dotPulse 1.4s ease-in-out infinite;
|
||||
|
||||
&:nth-child(1) { animation-delay: 0s; }
|
||||
&:nth-child(2) { animation-delay: 0.2s; }
|
||||
&:nth-child(3) { animation-delay: 0.4s; }
|
||||
&:nth-child(1) {
|
||||
animation-delay: 0s;
|
||||
}
|
||||
&:nth-child(2) {
|
||||
animation-delay: 0.2s;
|
||||
}
|
||||
&:nth-child(3) {
|
||||
animation-delay: 0.4s;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes dotPulse {
|
||||
0%, 60%, 100% {
|
||||
0%,
|
||||
60%,
|
||||
100% {
|
||||
transform: scale(1);
|
||||
opacity: 1;
|
||||
}
|
||||
@ -944,6 +966,7 @@ $gradient-primary: linear-gradient(135deg, $primary 0%, $primary-light 100%);
|
||||
|
||||
.page-content {
|
||||
padding: 16px;
|
||||
padding-top: 80px;
|
||||
}
|
||||
|
||||
.history-grid {
|
||||
|
||||
@ -667,17 +667,19 @@ const handleDelete = (task: AI3DTask) => {
|
||||
})
|
||||
}
|
||||
|
||||
// 获取预览图URL(通过代理访问,解决CORS问题)
|
||||
// 获取预览图URL
|
||||
const getPreviewUrl = (task: AI3DTask) => {
|
||||
if (task.previewUrl) {
|
||||
// 如果是腾讯云COS链接,通过代理访问
|
||||
// 自己的COS桶直接访问(已配置公有读)
|
||||
if (task.previewUrl.includes("competition-ms-1325825530.cos")) {
|
||||
return task.previewUrl
|
||||
}
|
||||
// 混元返回的临时链接通过代理访问(解决CORS问题)
|
||||
if (
|
||||
task.previewUrl.includes("tencentcos.cn") ||
|
||||
task.previewUrl.includes("qcloud.com")
|
||||
) {
|
||||
// 确保URL正确编码
|
||||
const encodedUrl = encodeURIComponent(task.previewUrl)
|
||||
return `/api/ai-3d/proxy-preview?url=${encodedUrl}`
|
||||
return `/api/ai-3d/proxy-preview?url=${encodeURIComponent(task.previewUrl)}`
|
||||
}
|
||||
// 其他URL直接返回
|
||||
return task.previewUrl
|
||||
|
||||
Loading…
Reference in New Issue
Block a user