From 464f5389a46b19d954559d455c5b0539f1620719 Mon Sep 17 00:00:00 2001 From: zhangxiaohua <827885272@qq.com> Date: Thu, 15 Jan 2026 16:35:00 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E6=94=B9=E8=B5=9B=E6=9E=9C=E5=8F=91?= =?UTF-8?q?=E5=B8=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .claude/skills/README.md | 109 +++ .claude/skills/backend-api.md | 261 ++++++ .claude/skills/contest-detail-page.md | 310 +++++++ .claude/skills/contest-list-page.md | 449 +++++++++ backend/src/ai-3d/ai-3d.service.ts | 59 +- .../src/ai-3d/providers/hunyuan.provider.ts | 52 +- .../src/contests/contests/contests.service.ts | 90 +- .../registrations/registrations.service.ts | 32 +- .../contests/results/dto/query-results.dto.ts | 24 + .../contests/results/results.controller.ts | 12 +- .../src/contests/results/results.service.ts | 102 ++- backend/src/oss/oss.service.ts | 200 +++- backend/src/upload/upload.module.ts | 2 + frontend/package-lock.json | 645 +++++++++++++ frontend/package.json | 2 + frontend/src/api/contests.ts | 14 +- frontend/src/api/judges-management.ts | 1 + frontend/src/components/RichTextEditor.vue | 122 +++ frontend/src/router/index.ts | 11 + frontend/src/views/contests/Activities.vue | 13 +- frontend/src/views/contests/Create.vue | 854 ++++++++++-------- frontend/src/views/contests/Detail.vue | 88 +- frontend/src/views/contests/Index.vue | 1 - .../views/contests/registrations/Index.vue | 35 + .../views/contests/registrations/Records.vue | 28 +- .../src/views/contests/results/Detail.vue | 311 +++++++ frontend/src/views/contests/results/Index.vue | 816 +++-------------- frontend/src/views/model/ModelViewer.vue | 28 +- .../src/views/workbench/ai-3d/Generate.vue | 12 +- .../src/views/workbench/ai-3d/History.vue | 109 ++- frontend/src/views/workbench/ai-3d/Index.vue | 12 +- 31 files changed, 3407 insertions(+), 1397 deletions(-) create mode 100644 .claude/skills/README.md create mode 100644 .claude/skills/backend-api.md create mode 100644 .claude/skills/contest-detail-page.md create mode 100644 .claude/skills/contest-list-page.md create mode 100644 backend/src/contests/results/dto/query-results.dto.ts create mode 100644 frontend/src/components/RichTextEditor.vue create mode 100644 frontend/src/views/contests/results/Detail.vue diff --git a/.claude/skills/README.md b/.claude/skills/README.md new file mode 100644 index 0000000..42e8585 --- /dev/null +++ b/.claude/skills/README.md @@ -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 | diff --git a/.claude/skills/backend-api.md b/.claude/skills/backend-api.md new file mode 100644 index 0000000..0e16673 --- /dev/null +++ b/.claude/skills/backend-api.md @@ -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 { + return request.get(`/api/xxx/${parentId}/list`, { params }) + }, +} +``` + +## Prisma 关联查询示例 + +### 常用关联模式 + +```typescript +// 用户信息 + 租户 + 学生班级年级 +user: { + include: { + tenant: { + select: { id: true, name: true }, + }, + student: { + include: { + class: { + include: { + grade: { + select: { id: true, name: true }, + }, + }, + }, + }, + }, + }, +} + +// 指导老师列表 +teachers: { + include: { + user: { + select: { id: true, username: true, nickname: true }, + }, + }, +} + +// 报名信息 + 用户 + 团队 +registration: { + include: { + user: { + select: { id: true, username: true, nickname: true }, + }, + team: { + select: { id: true, teamName: true }, + }, + }, +} +``` + +## 示例:赛果发布详情接口 + +``` +请生成后端接口: + +模块名称:results(已存在,需修改) +接口路径:GET /api/contests/:id/results/works +功能描述:获取赛事的作品列表(用于赛果发布) + +查询参数: +- page(number,选填,默认1) +- pageSize(number,选填,默认10) +- workNo(string,选填,作品编号模糊搜索) +- accountNo(string,选填,报名账号模糊搜索) + +返回字段: +- id, workNo, title, finalScore(作品表) +- registration.user.nickname, username(用户表) +- registration.user.tenant.name(租户表) +- registration.user.student.class.name, grade.name(班级年级) +- registration.teachers[].user.nickname(指导老师) + +关联查询: +- ContestWork -> ContestRegistration(registrationId) +- ContestRegistration -> User(userId) +- User -> Tenant(tenantId) +- User -> Student -> Class -> Grade +- ContestRegistration -> ContestRegistrationTeacher -> User + +排序:finalScore DESC +分页:是 +``` + +## 注意事项 + +1. **权限控制**:Controller 需要添加 `@UseGuards(JwtAuthGuard)` 和权限装饰器 +2. **参数校验**:DTO 使用 class-validator 进行校验 +3. **分页处理**:统一使用 page/pageSize 参数 +4. **空值处理**:查询条件为空时不添加到 where 条件 +5. **关联数据**:使用 Prisma 的 include 进行关联查询 +6. **性能优化**:只 select 需要的字段,避免查询过多数据 +7. **错误处理**:Service 层抛出 NestJS 内置异常 diff --git a/.claude/skills/contest-detail-page.md b/.claude/skills/contest-detail-page.md new file mode 100644 index 0000000..23c8275 --- /dev/null +++ b/.claude/skills/contest-detail-page.md @@ -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 + +``` + +## 特殊字段渲染规范 + +### 机构信息(org 类型) +```vue + +``` + +### 指导老师(数组拼接) +```vue + + +// 格式化函数 +const formatTeachers = (teachers: any[]) => { + if (!teachers || teachers.length === 0) return "-" + return teachers.map(t => t.user?.nickname || t.user?.username).join("、") +} +``` + +### 分数格式 +```vue + +``` + +## 样式规范 + +```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. **嵌套数据**:使用可选链操作符 `?.` 避免空指针错误 diff --git a/.claude/skills/contest-list-page.md b/.claude/skills/contest-list-page.md new file mode 100644 index 0000000..66ee68a --- /dev/null +++ b/.claude/skills/contest-list-page.md @@ -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 // 值到颜色的映射 + textMap?: Record // 值到文本的映射 + // 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 + + + + + +``` + +## 使用示例 + +### 示例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 diff --git a/backend/src/ai-3d/ai-3d.service.ts b/backend/src/ai-3d/ai-3d.service.ts index d42f1cb..7c6be29 100644 --- a/backend/src/ai-3d/ai-3d.service.ts +++ b/backend/src/ai-3d/ai-3d.service.ts @@ -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(), }, diff --git a/backend/src/ai-3d/providers/hunyuan.provider.ts b/backend/src/ai-3d/providers/hunyuan.provider.ts index 1a8f559..193de72 100644 --- a/backend/src/ai-3d/providers/hunyuan.provider.ts +++ b/backend/src/ai-3d/providers/hunyuan.provider.ts @@ -7,7 +7,6 @@ import { AI3DGenerateOptions, } from './ai-3d-provider.interface'; import { TencentCloudSigner } from '../utils/tencent-cloud-sign'; -import { ZipHandler } from '../utils/zip-handler'; /** * 腾讯混元 3D Provider @@ -202,6 +201,7 @@ export class HunyuanAI3DProvider implements AI3DProvider { // 如果任务完成,提取模型URL // 根据API文档,返回的是 ResultFile3Ds 数组 + // 注意:这里只返回原始URL,COS转存由AI3DService统一处理 if (status === 'completed' && result.ResultFile3Ds?.length > 0) { const file3Ds = result.ResultFile3Ds; // 提取所有模型URL和预览图URL @@ -210,54 +210,18 @@ export class HunyuanAI3DProvider implements AI3DProvider { .map((file: any) => file.PreviewImageUrl) .filter(Boolean); - // 处理.zip文件:下载并解压 if (urls.length > 0) { - const firstUrl = urls[0]; + generateResult.resultUrl = urls[0]; + generateResult.resultUrls = urls; + } - // 检查是否是.zip文件 - if (firstUrl.toLowerCase().endsWith('.zip')) { - this.logger.log(`检测到ZIP文件,开始下载并解压: ${firstUrl}`); - try { - const extracted = await ZipHandler.downloadAndExtract(firstUrl); - - // 使用解压后的文件URL - generateResult.resultUrl = extracted.modelUrl; - generateResult.resultUrls = [extracted.modelUrl]; - - if (extracted.previewUrl) { - generateResult.previewUrl = extracted.previewUrl; - generateResult.previewUrls = [extracted.previewUrl]; - } else if (previewUrls.length > 0) { - // 如果ZIP中没有预览图,使用API返回的预览图 - generateResult.previewUrl = previewUrls[0]; - generateResult.previewUrls = previewUrls; - } - - this.logger.log( - `ZIP文件处理完成,模型URL: ${extracted.modelUrl}`, - ); - } catch (error) { - this.logger.error(`处理ZIP文件失败: ${error.message}`); - // ZIP处理失败,尝试直接返回原始URL - generateResult.resultUrl = firstUrl; - generateResult.resultUrls = urls; - generateResult.previewUrl = previewUrls[0]; - generateResult.previewUrls = previewUrls; - } - } else { - // 不是ZIP文件,直接使用URL - generateResult.resultUrl = firstUrl; - generateResult.resultUrls = urls; - - if (previewUrls.length > 0) { - generateResult.previewUrl = previewUrls[0]; - generateResult.previewUrls = previewUrls; - } - } + if (previewUrls.length > 0) { + generateResult.previewUrl = previewUrls[0]; + generateResult.previewUrls = previewUrls; } this.logger.log( - `混元3D任务 ${taskId} 完成: ${generateResult.resultUrls?.length || 0} 个模型文件`, + `混元3D任务 ${taskId} 完成: ${urls.length} 个模型文件, ${previewUrls.length} 个预览图`, ); } else if (status === 'failed') { // 失败原因:根据文档,错误信息在 ErrorMessage 字段 diff --git a/backend/src/contests/contests/contests.service.ts b/backend/src/contests/contests/contests.service.ts index 2d8f03e..c242a32 100644 --- a/backend/src/contests/contests/contests.service.ts +++ b/backend/src/contests/contests/contests.service.ts @@ -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, + }, + }); }); } diff --git a/backend/src/contests/registrations/registrations.service.ts b/backend/src/contests/registrations/registrations.service.ts index bcd9ee8..313e3f7 100644 --- a/backend/src/contests/registrations/registrations.service.ts +++ b/backend/src/contests/registrations/registrations.service.ts @@ -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: { diff --git a/backend/src/contests/results/dto/query-results.dto.ts b/backend/src/contests/results/dto/query-results.dto.ts new file mode 100644 index 0000000..62582c0 --- /dev/null +++ b/backend/src/contests/results/dto/query-results.dto.ts @@ -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; +} diff --git a/backend/src/contests/results/results.controller.ts b/backend/src/contests/results/results.controller.ts index 6ec3020..1b514b0 100644 --- a/backend/src/contests/results/results.controller.ts +++ b/backend/src/contests/results/results.controller.ts @@ -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); } /** diff --git a/backend/src/contests/results/results.service.ts b/backend/src/contests/results/results.service.ts index 68fb267..0ef0c21 100644 --- a/backend/src/contests/results/results.service.ts +++ b/backend/src/contests/results/results.service.ts @@ -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 = {}; - 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, }; } diff --git a/backend/src/oss/oss.service.ts b/backend/src/oss/oss.service.ts index e2ff5d8..97c64f3 100644 --- a/backend/src/oss/oss.service.ts +++ b/backend/src/oss/oss.service.ts @@ -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 { + this.logger.log(`开始下载文件: ${url.substring(0, 100)}...`); + const response = await axios.get(url, { + responseType: 'arraybuffer', + timeout: 120000, // 2分钟超时 + }); + this.logger.log(`文件下载完成,大小: ${response.data.length} 字节`); + return Buffer.from(response.data); + } + + /** + * 上传AI 3D模型文件到COS + * 支持ZIP格式(会解压提取GLB)和直接的GLB格式 + * @param url 模型文件URL + * @param taskId 任务ID(用于目录隔离) + * @param index 文件索引(多个文件时区分) + * @returns 上传后的COS URL + */ + async uploadAI3DModel( + url: string, + taskId: number, + index: number = 0, + ): Promise<{ modelUrl: string; ossPath: string }> { + if (!this.enabled || !this.client) { + throw new BadRequestException('COS 服务未启用'); + } + + try { + // 下载文件 + const fileBuffer = await this.downloadFromUrl(url); + + // 检查是否是ZIP文件 + const isZip = url.toLowerCase().includes('.zip') || + (fileBuffer[0] === 0x50 && fileBuffer[1] === 0x4b); // ZIP magic number + + let modelBuffer: Buffer; + let modelExt = '.glb'; + + if (isZip) { + this.logger.log('检测到ZIP文件,开始解压...'); + // 解压ZIP文件 + const zip = new AdmZip(fileBuffer); + const zipEntries = zip.getEntries(); + + // 打印ZIP中的所有文件,便于调试 + const fileNames = zipEntries.map(e => e.entryName); + this.logger.log(`ZIP包含文件: ${fileNames.join(', ')}`); + + // 按优先级查找3D模型文件 + const modelFormats = ['.glb', '.gltf', '.obj', '.fbx', '.stl', '.usdz', '.usdc']; + let modelEntry: any = null; + + for (const format of modelFormats) { + modelEntry = zipEntries.find(entry => + entry.entryName.toLowerCase().endsWith(format) + ); + if (modelEntry) { + modelExt = format; + break; + } + } + + if (!modelEntry) { + throw new Error(`ZIP文件中未找到3D模型文件,包含: ${fileNames.join(', ')}`); + } + + this.logger.log(`在ZIP中找到模型文件: ${modelEntry.entryName}`); + modelBuffer = modelEntry.getData(); + } else { + // 直接是GLB/GLTF文件 + modelBuffer = fileBuffer; + if (url.toLowerCase().includes('.gltf')) { + modelExt = '.gltf'; + } + } + + // 生成COS路径(简化:3d/{taskId}.glb 或 3d/{taskId}_1.glb) + const cosPath = index === 0 ? `3d/${taskId}${modelExt}` : `3d/${taskId}_${index}${modelExt}`; + + // 上传到COS + await new Promise((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((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}`); + } + } } diff --git a/backend/src/upload/upload.module.ts b/backend/src/upload/upload.module.ts index b6e37cf..f48e572 100644 --- a/backend/src/upload/upload.module.ts +++ b/backend/src/upload/upload.module.ts @@ -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], diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 3e52da4..4139e45 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -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", diff --git a/frontend/package.json b/frontend/package.json index 4d01c1c..732eef9 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -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", diff --git a/frontend/src/api/contests.ts b/frontend/src/api/contests.ts index 9b6ffc7..676db5a 100644 --- a/frontend/src/api/contests.ts +++ b/frontend/src/api/contests.ts @@ -1310,11 +1310,19 @@ export const resultsApi = { return response; }, - // 获取比赛结果列表 - getResults: async (contestId: number, page = 1, pageSize = 20): Promise => { + // 获取比赛结果列表(作品列表) + getResults: async ( + contestId: number, + params: { + page?: number; + pageSize?: number; + workNo?: string; + accountNo?: string; + } = {} + ): Promise => { const response = await request.get( `/contests/results/${contestId}`, - { params: { page, pageSize } } + { params: { page: params.page || 1, pageSize: params.pageSize || 10, ...params } } ); return response; }, diff --git a/frontend/src/api/judges-management.ts b/frontend/src/api/judges-management.ts index b2aaf24..13a3996 100644 --- a/frontend/src/api/judges-management.ts +++ b/frontend/src/api/judges-management.ts @@ -153,3 +153,4 @@ export const judgesManagementApi = { + diff --git a/frontend/src/components/RichTextEditor.vue b/frontend/src/components/RichTextEditor.vue new file mode 100644 index 0000000..c9f2c0b --- /dev/null +++ b/frontend/src/components/RichTextEditor.vue @@ -0,0 +1,122 @@ + + + + + diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 26e0176..3cc7891 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -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", diff --git a/frontend/src/views/contests/Activities.vue b/frontend/src/views/contests/Activities.vue index 80fca00..47094fd 100644 --- a/frontend/src/views/contests/Activities.vue +++ b/frontend/src/views/contests/Activities.vue @@ -44,7 +44,8 @@
- 赛事时间:{{ formatDate(contest.startTime) }} ~ {{ formatDate(contest.endTime) }} + 赛事时间:{{ formatDate(contest.startTime) }} ~ + {{ formatDate(contest.endTime) }}
@@ -52,7 +53,10 @@ {{ contest.contestType === "individual" ? "个人赛" : "团队赛" }} - + {{ getStatusText(contest) }} @@ -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 { diff --git a/frontend/src/views/contests/Create.vue b/frontend/src/views/contests/Create.vue index af5ae49..e4fb1c7 100644 --- a/frontend/src/views/contests/Create.vue +++ b/frontend/src/views/contests/Create.vue @@ -1,302 +1,285 @@ diff --git a/frontend/src/views/contests/Detail.vue b/frontend/src/views/contests/Detail.vue index 6249aad..96709a0 100644 --- a/frontend/src/views/contests/Detail.vue +++ b/frontend/src/views/contests/Detail.vue @@ -66,7 +66,7 @@ }}
-
+
| 类型
@@ -379,7 +379,15 @@ const myRegistration = ref(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" diff --git a/frontend/src/views/contests/Index.vue b/frontend/src/views/contests/Index.vue index e5282b7..edfa6ca 100644 --- a/frontend/src/views/contests/Index.vue +++ b/frontend/src/views/contests/Index.vue @@ -143,7 +143,6 @@ v-permission="'contest:update'" type="link" size="small" - :disabled="record.contestState === 'published'" @click.stop="handleEdit(record.id)" > 编辑 diff --git a/frontend/src/views/contests/registrations/Index.vue b/frontend/src/views/contests/registrations/Index.vue index 871e58a..819fe79 100644 --- a/frontend/src/views/contests/registrations/Index.vue +++ b/frontend/src/views/contests/registrations/Index.vue @@ -54,6 +54,9 @@ + @@ -114,6 +117,9 @@ + @@ -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 diff --git a/frontend/src/views/contests/registrations/Records.vue b/frontend/src/views/contests/registrations/Records.vue index 858f7ca..ef50d28 100644 --- a/frontend/src/views/contests/registrations/Records.vue +++ b/frontend/src/views/contests/registrations/Records.vue @@ -169,7 +169,13 @@ {{ (pagination.current - 1) * pagination.pageSize + index + 1 }}