修改赛果发布

This commit is contained in:
zhangxiaohua 2026-01-15 16:35:00 +08:00
parent 9d3537ce53
commit 464f5389a4
31 changed files with 3407 additions and 1397 deletions

109
.claude/skills/README.md Normal file
View 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 |

View 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
功能描述:获取赛事的作品列表(用于赛果发布)
查询参数:
- pagenumber选填默认1
- pageSizenumber选填默认10
- workNostring选填作品编号模糊搜索
- accountNostring选填报名账号模糊搜索
返回字段:
- id, workNo, title, finalScore作品表
- registration.user.nickname, username用户表
- registration.user.tenant.name租户表
- registration.user.student.class.name, grade.name班级年级
- registration.teachers[].user.nickname指导老师
关联查询:
- ContestWork -> ContestRegistrationregistrationId
- ContestRegistration -> UseruserId
- User -> TenanttenantId
- 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 内置异常

View 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. **嵌套数据**:使用可选链操作符 `?.` 避免空指针错误

View 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

View File

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

View File

@ -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 数组
// 注意这里只返回原始URLCOS转存由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 字段

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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格式GLBGLB格式
* @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}`);
}
}
}

View File

@ -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],

View File

@ -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",

View File

@ -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",

View File

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

View File

@ -153,3 +153,4 @@ export const judgesManagementApi = {

View File

@ -0,0 +1,122 @@
<template>
<div class="rich-text-editor">
<Toolbar
:editor="editorRef"
:defaultConfig="toolbarConfig"
:mode="mode"
class="toolbar"
/>
<Editor
:defaultConfig="editorConfig"
:mode="mode"
v-model="valueHtml"
class="editor"
@onCreated="handleCreated"
/>
</div>
</template>
<script setup lang="ts">
import { ref, shallowRef, watch, onBeforeUnmount } from "vue"
import { Editor, Toolbar } from "@wangeditor/editor-for-vue"
import type { IDomEditor, IEditorConfig, IToolbarConfig } from "@wangeditor/editor"
import { uploadFile } from "@/api/upload"
import "@wangeditor/editor/dist/css/style.css"
const props = defineProps<{
modelValue: string
placeholder?: string
height?: number
}>()
const emit = defineEmits<{
(e: "update:modelValue", value: string): void
}>()
//
const editorRef = shallowRef<IDomEditor | null>(null)
const valueHtml = ref(props.modelValue || "")
const mode = "default"
//
watch(
() => props.modelValue,
(newVal) => {
if (newVal !== valueHtml.value) {
valueHtml.value = newVal || ""
}
}
)
//
watch(valueHtml, (newVal) => {
emit("update:modelValue", newVal)
})
//
const toolbarConfig: Partial<IToolbarConfig> = {
excludeKeys: [
"group-video", //
"fullScreen", //
],
}
//
const editorConfig: Partial<IEditorConfig> = {
placeholder: props.placeholder || "请输入内容...",
MENU_CONF: {
//
uploadImage: {
async customUpload(file: File, insertFn: (url: string) => void) {
try {
const result: any = await uploadFile(file)
const url = result.data?.url || result.url
if (url) {
insertFn(url)
}
} catch (error) {
console.error("图片上传失败:", error)
}
},
},
},
}
//
const handleCreated = (editor: IDomEditor) => {
editorRef.value = editor
}
//
onBeforeUnmount(() => {
const editor = editorRef.value
if (editor) {
editor.destroy()
}
})
</script>
<style scoped>
.rich-text-editor {
border: 1px solid #d9d9d9;
border-radius: 6px;
overflow: hidden;
}
.toolbar {
border-bottom: 1px solid #d9d9d9;
}
.editor {
height: v-bind('(props.height || 300) + "px"');
overflow-y: auto;
}
.rich-text-editor :deep(.w-e-text-container) {
background-color: #fff;
}
.rich-text-editor :deep(.w-e-toolbar) {
background-color: #fafafa;
}
</style>

View File

@ -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",

View File

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

View File

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

View File

@ -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"

View File

@ -143,7 +143,6 @@
v-permission="'contest:update'"
type="link"
size="small"
:disabled="record.contestState === 'published'"
@click.stop="handleEdit(record.id)"
>
编辑

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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