fix bugs
All checks were successful
continuous-integration/drone/push Build is passing

This commit is contained in:
2025-12-12 17:54:40 +08:00
parent 5b806e29b7
commit 669f29878b
27 changed files with 2781 additions and 4256 deletions

View File

@@ -15,7 +15,23 @@
"Bash(mvn clean compile:*)", "Bash(mvn clean compile:*)",
"Bash(mvn clean package:*)", "Bash(mvn clean package:*)",
"Bash(mvn package:*)", "Bash(mvn package:*)",
"Bash(findstr:*)" "Bash(findstr:*)",
"Bash(python:*)",
"Bash(mysql:*)",
"Bash(netstat:*)",
"Bash(taskkill:*)",
"Bash(powershell -Command:*)",
"Bash(mvn spring-boot:run:*)",
"Bash(timeout /t 2)",
"Bash(ping:*)",
"Bash(mvn compile:*)",
"Bash(timeout /t 25)",
"Bash(git checkout:*)",
"Bash(timeout /t 5)",
"Bash(paste:*)",
"Bash(cp:*)",
"Bash(tasklist:*)",
"Bash(node:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

View File

@@ -0,0 +1,581 @@
# 评委邀请码功能实现指南
> **实施日期**: 2025-12-12
> **页面路径**: `src/views/martial/judgeInvite/index.vue`
> **与赛事绑定**: ✅ 已通过 `competitionId` 实现
---
## 📋 实现方案
### 一、需求分析
根据文档,评委邀请码功能需要实现:
1. **单个生成**为单个评委生成6位邀请码
2. **批量生成**:为多个评委批量生成邀请码
3. **重新生成**:已有邀请码时可重新生成
4. **复制功能**:点击邀请码可复制
5. **赛事绑定**:所有操作都与选中的赛事绑定
### 二、后端接口(已完成)
后端接口已在 `src/api/martial/judgeInvite.js` 中添加:
```javascript
// 1. 生成邀请码
export const generateInviteCode = (data) => {
return request({
url: '/api/blade-martial/judgeInvite/generate',
method: 'post',
data
})
}
// 2. 批量生成邀请码
export const batchGenerateInviteCode = (data) => {
return request({
url: '/api/blade-martial/judgeInvite/generate/batch',
method: 'post',
data
})
}
// 3. 重新生成邀请码
export const regenerateInviteCode = (inviteId) => {
return request({
url: `/api/blade-martial/judgeInvite/regenerate/${inviteId}`,
method: 'put'
})
}
// 4. 查询评委邀请码
export const getInviteByJudge = (competitionId, judgeId) => {
return request({
url: '/api/blade-martial/judgeInvite/byJudge',
method: 'get',
params: { competitionId, judgeId }
})
}
```
---
## 三、前端实现步骤
### 步骤1导入新增的API
`src/views/martial/judgeInvite/index.vue` 第281-292行修改导入语句
```javascript
import {
getJudgeInviteList,
sendInvite,
batchSendInvites,
resendInvite,
cancelInvite,
confirmInvite,
getInviteStatistics,
importFromJudgePool,
exportInvites,
sendReminder,
generateInviteCode, // 新增
batchGenerateInviteCode, // 新增
regenerateInviteCode // 新增
} from '@/api/martial/judgeInvite'
```
### 步骤2修改邀请码列显示第165-179行
将现有的邀请码列替换为:
```vue
<el-table-column prop="inviteCode" label="邀请码" width="200" align="center">
<template #default="{ row }">
<!-- 已有邀请码显示邀请码 + 重新生成按钮 -->
<div v-if="row.inviteCode" style="display: flex; align-items: center; justify-content: center; gap: 8px;">
<el-tag
type="warning"
effect="dark"
size="default"
style="font-family: monospace; font-weight: bold; cursor: pointer;"
@click="copyToClipboard(row.inviteCode, '邀请码')"
title="点击复制"
>
{{ row.inviteCode }}
</el-tag>
<el-button
link
type="primary"
size="small"
@click="handleRegenerateCode(row)"
title="重新生成邀请码"
>
<el-icon><Refresh /></el-icon>
</el-button>
</div>
<!-- 未生成邀请码显示生成按钮 -->
<el-button
v-else
type="primary"
size="small"
@click="handleGenerateCode(row)"
>
生成邀请码
</el-button>
</template>
</el-table-column>
```
### 步骤3添加批量生成按钮第129-131行
修改工具栏的"批量邀请"按钮功能:
```vue
<el-button type="success" :icon="DocumentCopy" @click="handleBatchGenerateCode">
批量生成邀请码
</el-button>
```
### 步骤4添加方法实现
`<script setup>` 部分在第456行之后添加以下方法
```javascript
// ==================== 邀请码生成功能 ====================
/**
* 生成单个邀请码
*/
const handleGenerateCode = async (row) => {
if (!queryParams.competitionId) {
ElMessage.warning('请先选择赛事')
return
}
try {
loading.value = true
const res = await generateInviteCode({
competitionId: queryParams.competitionId,
judgeId: row.judgeId,
role: row.refereeType === 1 ? 'chief_judge' : 'judge', // 根据评委类型设置角色
venueId: row.venueId || null,
projects: row.projects ? JSON.stringify(row.projects) : null,
expireDays: 30
})
if (res.data && res.data.inviteCode) {
ElMessage.success(`邀请码生成成功:${res.data.inviteCode}`)
// 自动复制到剪贴板
copyToClipboard(res.data.inviteCode, '邀请码')
// 刷新列表
await fetchData()
await loadStatistics()
} else {
ElMessage.error(res.msg || '生成失败')
}
} catch (error) {
console.error('生成邀请码失败:', error)
ElMessage.error(error.response?.data?.msg || error.message || '生成邀请码失败')
} finally {
loading.value = false
}
}
/**
* 重新生成邀请码
*/
const handleRegenerateCode = async (row) => {
try {
await ElMessageBox.confirm(
'重新生成后,旧邀请码将失效。确定继续吗?',
'重新生成邀请码',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
}
)
loading.value = true
const res = await regenerateInviteCode(row.id)
if (res.data && res.data.inviteCode) {
ElMessage.success(`邀请码已重新生成:${res.data.inviteCode}`)
// 自动复制到剪贴板
copyToClipboard(res.data.inviteCode, '邀请码')
// 刷新列表
await fetchData()
await loadStatistics()
} else {
ElMessage.error(res.msg || '重新生成失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('重新生成邀请码失败:', error)
ElMessage.error(error.response?.data?.msg || error.message || '重新生成邀请码失败')
}
} finally {
loading.value = false
}
}
/**
* 批量生成邀请码
*/
const handleBatchGenerateCode = async () => {
if (!queryParams.competitionId) {
ElMessage.warning('请先选择赛事')
return
}
if (selection.value.length === 0) {
ElMessage.warning('请先选择评委')
return
}
try {
await ElMessageBox.confirm(
`确定为选中的 ${selection.value.length} 位评委批量生成邀请码吗?`,
'批量生成邀请码',
{
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'info'
}
)
loading.value = true
const judgeIds = selection.value.map(item => item.judgeId)
const res = await batchGenerateInviteCode({
competitionId: queryParams.competitionId,
judgeIds: judgeIds,
role: 'judge',
expireDays: 30
})
if (res.data && Array.isArray(res.data)) {
ElMessage.success(`成功生成 ${res.data.length} 个邀请码`)
// 刷新列表
await fetchData()
await loadStatistics()
} else {
ElMessage.error(res.msg || '批量生成失败')
}
} catch (error) {
if (error !== 'cancel') {
console.error('批量生成邀请码失败:', error)
ElMessage.error(error.response?.data?.msg || error.message || '批量生成邀请码失败')
}
} finally {
loading.value = false
}
}
```
---
## 四、数据流转说明
### 1. 赛事绑定流程
```
用户操作:
1. 选择赛事(下拉框)
2. queryParams.competitionId 更新
3. 触发 handleCompetitionChange
4. 加载该赛事的评委列表
5. 显示评委及其邀请码状态
```
### 2. 生成邀请码流程
```
单个生成:
1. 点击"生成邀请码"按钮
2. 调用 generateInviteCode API
3. 传入:
- competitionId: 当前选中的赛事ID
- judgeId: 评委ID
- role: 评委角色
- venueId: 场地ID可选
- projects: 项目列表(可选)
- expireDays: 30天
4. 后端生成6位随机码
5. 返回邀请码
6. 前端自动复制到剪贴板
7. 刷新列表显示
```
### 3. 批量生成流程
```
批量生成:
1. 选择多个评委(勾选)
2. 点击"批量生成邀请码"按钮
3. 确认操作
4. 调用 batchGenerateInviteCode API
5. 传入:
- competitionId: 当前选中的赛事ID
- judgeIds: 评委ID数组
- role: 'judge'
- expireDays: 30天
6. 后端循环为每个评委生成邀请码
7. 返回生成的邀请码列表
8. 刷新列表显示
```
---
## 五、关键字段说明
### 1. 数据表字段martial_judge_invite
| 字段 | 类型 | 说明 | 示例值 |
|------|------|------|--------|
| `id` | bigint | 主键ID | 1001 |
| `competition_id` | bigint | **赛事ID绑定** | 1 |
| `judge_id` | bigint | 评委ID | 5 |
| `invite_code` | varchar(50) | 邀请码 | ABC123 |
| `role` | varchar(20) | 角色 | judge/chief_judge |
| `venue_id` | bigint | 场地ID | 2 |
| `projects` | varchar(500) | 项目列表 | ["太极拳","长拳"] |
| `expire_time` | datetime | 过期时间 | 2026-01-11 |
| `is_used` | int | 是否已使用 | 0/1 |
| `status` | int | 状态 | 1-启用0-禁用 |
### 2. 前端查询参数
```javascript
queryParams = {
current: 1, // 当前页
size: 10, // 每页条数
competitionId: '', // ⭐ 赛事ID核心绑定字段
judgeName: '', // 评委姓名
judgeLevel: '', // 评委等级
inviteStatus: '' // 邀请状态
}
```
---
## 六、测试验证
### 测试场景1单个生成邀请码
**前置条件**
- 已选择赛事
- 列表中有评委记录
- 评委未生成邀请码
**操作步骤**
1. 在评委列表中找到一个未生成邀请码的评委
2. 点击"生成邀请码"按钮
3. 等待生成完成
**预期结果**
- ✅ 显示成功提示:`邀请码生成成功ABC123`
- ✅ 邀请码自动复制到剪贴板
- ✅ 列表刷新,显示生成的邀请码
- ✅ 邀请码列变为:邀请码 + 重新生成按钮
### 测试场景2重新生成邀请码
**前置条件**
- 已选择赛事
- 评委已有邀请码
**操作步骤**
1. 点击邀请码旁边的重新生成按钮
2. 确认操作
3. 等待生成完成
**预期结果**
- ✅ 显示警告提示框
- ✅ 确认后生成新邀请码
- ✅ 旧邀请码失效
- ✅ 新邀请码自动复制到剪贴板
### 测试场景3批量生成邀请码
**前置条件**
- 已选择赛事
- 列表中有多个未生成邀请码的评委
**操作步骤**
1. 勾选多个评委如3个
2. 点击"批量生成邀请码"按钮
3. 确认操作
4. 等待生成完成
**预期结果**
- ✅ 显示确认提示框:`确定为选中的 3 位评委批量生成邀请码吗?`
- ✅ 确认后批量生成
- ✅ 显示成功提示:`成功生成 3 个邀请码`
- ✅ 列表刷新,所有评委都显示邀请码
### 测试场景4复制邀请码
**前置条件**
- 评委已有邀请码
**操作步骤**
1. 点击邀请码标签
2. 粘贴到其他地方验证
**预期结果**
- ✅ 显示成功提示:`邀请码已复制ABC123`
- ✅ 剪贴板中有邀请码内容
### 测试场景5赛事切换
**操作步骤**
1. 在赛事A生成邀请码
2. 切换到赛事B
3. 查看列表
**预期结果**
- ✅ 只显示赛事B的评委列表
- ✅ 赛事A的邀请码不显示
- ✅ 每个赛事的邀请码独立管理
---
## 七、错误处理
### 错误1未选择赛事
```javascript
if (!queryParams.competitionId) {
ElMessage.warning('请先选择赛事')
return
}
```
### 错误2评委已有有效邀请码
后端会返回错误信息:
```json
{
"success": false,
"msg": "该评委已有有效邀请码,请使用重新生成功能"
}
```
前端显示:
```javascript
ElMessage.error(error.response?.data?.msg || '生成邀请码失败')
```
### 错误3邀请码重复
后端会自动重试最多10次前端无需处理
---
## 八、UI/UX优化建议
### 1. 视觉优化
- ✅ 邀请码使用等宽字体(`monospace`
- ✅ 使用警告色标签(`type="warning"`
- ✅ 添加复制提示(`title="点击复制"`
- ✅ 按钮使用图标(`<Refresh />`
### 2. 交互优化
- ✅ 点击邀请码自动复制
- ✅ 重新生成前确认提示
- ✅ 批量生成前确认提示
- ✅ 生成成功后自动复制到剪贴板
- ✅ 操作成功后自动刷新列表
### 3. 加载状态
- ✅ 生成时显示 loading
- ✅ 防止重复点击
- ✅ 异常时显示错误提示
---
## 九、实施检查清单
### 后端准备
- [x] DTO类创建完成
- [x] Service方法实现完成
- [x] Controller接口添加完成
- [x] 数据库表结构正确
- [x] 唯一索引配置正确
### 前端准备
- [x] API接口添加完成`judgeInvite.js`
- [ ] 导入新增API
- [ ] 修改邀请码列UI
- [ ] 添加生成邀请码方法
- [ ] 添加重新生成方法
- [ ] 添加批量生成方法
- [ ] 修改批量邀请按钮功能
### 测试验证
- [ ] 单个生成测试通过
- [ ] 重新生成测试通过
- [ ] 批量生成测试通过
- [ ] 复制功能测试通过
- [ ] 赛事切换测试通过
- [ ] 错误处理测试通过
---
## 十、总结
### 核心要点
1. **赛事绑定**:所有操作都基于 `queryParams.competitionId`
2. **数据隔离**:不同赛事的邀请码完全独立
3. **用户友好**:自动复制、确认提示、状态反馈
4. **错误处理**:完善的错误提示和异常处理
5. **性能优化**:操作后自动刷新,保持数据同步
### 实施时间
- 前端修改30分钟
- 测试验证20分钟
- **总计**50分钟
### 下一步
1. 按照本文档修改前端代码
2. 启动项目测试各功能
3. 根据测试结果调整优化
4. 上线部署
---
**祝您实施顺利!** 🚀
如有问题,请参考:
- 后端实施文档:`评委邀请码生成方案实施指南.md`
- API文档`src/api/martial/judgeInvite.js`
- 页面代码:`src/views/martial/judgeInvite/index.vue`

View File

@@ -0,0 +1,554 @@
# 评委邀请码生成方案 - 实施指南
> **实施日期**: 2025-12-12
> **实施方式**: 管理员生成 → 复制发送 → 评委使用
> **状态**: ✅ 代码已完成,可立即测试
---
## 📋 方案概述
### 核心流程
```
管理员操作:
1. 进入评委管理页面
2. 选择评委,点击"生成邀请码"
3. 系统生成6位随机码ABC123
4. 复制邀请码
5. 通过微信/短信发送给评委
评委使用:
1. 收到邀请码
2. 打开小程序登录页
3. 输入比赛编码 + 邀请码
4. 登录成功,开始评分
```
### 技术特点
-**无需改表** - 使用现有字段
-**6位随机码** - 大写字母+数字组合
-**唯一性保证** - 数据库唯一索引
-**有效期管理** - 默认30天
-**状态管理** - 待使用/已使用/已禁用
---
## 🚀 已完成的代码
### 1. DTO 类
#### GenerateInviteDTO.java
**路径**: `src/main/java/org/springblade/modules/martial/pojo/dto/GenerateInviteDTO.java`
```java
@Data
@ApiModel("生成邀请码DTO")
public class GenerateInviteDTO {
@NotNull(message = "赛事ID不能为空")
private Long competitionId;
@NotNull(message = "评委ID不能为空")
private Long judgeId;
@NotBlank(message = "角色不能为空")
private String role; // judge 或 chief_judge
private Long venueId; // 场地ID普通评委必填
private String projects; // 项目列表JSON
private Integer expireDays = 30; // 过期天数
}
```
#### BatchGenerateInviteDTO.java
**路径**: `src/main/java/org/springblade/modules/martial/pojo/dto/BatchGenerateInviteDTO.java`
```java
@Data
@ApiModel("批量生成邀请码DTO")
public class BatchGenerateInviteDTO {
@NotNull(message = "赛事ID不能为空")
private Long competitionId;
@NotEmpty(message = "评委列表不能为空")
private List<Long> judgeIds;
private String role = "judge";
private Integer expireDays = 30;
}
```
---
### 2. Service 层
#### IMartialJudgeInviteService.java
**新增方法**:
```java
// 生成邀请码
MartialJudgeInvite generateInviteCode(GenerateInviteDTO dto);
// 批量生成邀请码
List<MartialJudgeInvite> batchGenerateInviteCode(BatchGenerateInviteDTO dto);
// 重新生成邀请码
MartialJudgeInvite regenerateInviteCode(Long inviteId);
// 生成唯一邀请码
String generateUniqueInviteCode();
```
#### MartialJudgeInviteServiceImpl.java
**核心实现**:
1. **生成唯一邀请码**:
```java
// 6位随机字符串大写字母+数字)
String inviteCode = UUID.randomUUID().toString()
.replaceAll("-", "")
.substring(0, 6)
.toUpperCase();
```
2. **检查重复**:
```java
// 检查邀请码是否已存在
long count = this.count(
Wrappers.<MartialJudgeInvite>lambdaQuery()
.eq(MartialJudgeInvite::getInviteCode, inviteCode)
.eq(MartialJudgeInvite::getIsDeleted, 0)
);
```
3. **防止重复生成**:
```java
// 检查评委是否已有有效邀请码
MartialJudgeInvite existInvite = this.getOne(
Wrappers.<MartialJudgeInvite>lambdaQuery()
.eq(MartialJudgeInvite::getCompetitionId, competitionId)
.eq(MartialJudgeInvite::getJudgeId, judgeId)
.eq(MartialJudgeInvite::getStatus, 1)
.gt(MartialJudgeInvite::getExpireTime, LocalDateTime.now())
);
```
---
### 3. Controller 层
#### MartialJudgeInviteController.java
**新增接口**:
| 接口 | 方法 | 路径 | 说明 |
|------|------|------|------|
| 生成邀请码 | POST | `/martial/judgeInvite/generate` | 为单个评委生成 |
| 批量生成 | POST | `/martial/judgeInvite/generate/batch` | 批量生成 |
| 重新生成 | PUT | `/martial/judgeInvite/regenerate/{id}` | 重新生成(旧码失效) |
| 查询邀请码 | GET | `/martial/judgeInvite/byJudge` | 查询评委的邀请码 |
---
## 🧪 测试指南
### 1. 使用 Postman 测试
#### 测试1生成邀请码
```http
POST http://localhost:8080/martial/judgeInvite/generate
Content-Type: application/json
Blade-Auth: Bearer {token}
{
"competitionId": 1,
"judgeId": 1,
"role": "judge",
"venueId": 1,
"projects": "[\"女子组长拳\",\"男子组陈氏太极拳\"]",
"expireDays": 30
}
```
**预期响应**:
```json
{
"code": 200,
"success": true,
"data": {
"id": 1001,
"competitionId": 1,
"judgeId": 1,
"inviteCode": "ABC123",
"role": "judge",
"venueId": 1,
"projects": "[\"女子组长拳\",\"男子组陈氏太极拳\"]",
"expireTime": "2026-01-11 10:00:00",
"isUsed": 0,
"status": 1
}
}
```
#### 测试2批量生成邀请码
```http
POST http://localhost:8080/martial/judgeInvite/generate/batch
Content-Type: application/json
Blade-Auth: Bearer {token}
{
"competitionId": 1,
"judgeIds": [1, 2, 3, 4, 5],
"role": "judge",
"expireDays": 30
}
```
#### 测试3查询评委邀请码
```http
GET http://localhost:8080/martial/judgeInvite/byJudge?competitionId=1&judgeId=1
Blade-Auth: Bearer {token}
```
#### 测试4重新生成邀请码
```http
PUT http://localhost:8080/martial/judgeInvite/regenerate/1001
Blade-Auth: Bearer {token}
```
---
### 2. 使用 SQL 测试
#### 执行测试脚本
```bash
# 进入数据库
mysql -u root -p blade
# 执行测试脚本
source database/martial-db/test_invite_code_generation.sql
```
#### 查询有效邀请码
```sql
SELECT
ji.id,
ji.invite_code,
ji.role,
j.name AS judge_name,
ji.expire_time,
ji.is_used,
CASE
WHEN ji.is_used = 1 THEN '已使用'
WHEN ji.expire_time < NOW() THEN '已过期'
WHEN ji.status = 0 THEN '已禁用'
ELSE '待使用'
END AS status_text
FROM martial_judge_invite ji
LEFT JOIN martial_judge j ON ji.judge_id = j.id
WHERE ji.competition_id = 1
AND ji.is_deleted = 0
ORDER BY ji.create_time DESC;
```
---
## 📊 数据库字段说明
### martial_judge_invite 表
| 字段 | 类型 | 说明 | 使用方式 |
|------|------|------|----------|
| `invite_code` | varchar(50) | 邀请码 | 6位随机码 |
| `status` | int | 状态 | 1-启用0-禁用 |
| `is_used` | int | 是否已使用 | 0-未使用1-已使用 |
| `expire_time` | datetime | 过期时间 | 默认30天后 |
| `use_time` | datetime | 使用时间 | 登录时记录 |
| `role` | varchar(20) | 角色 | judge/chief_judge |
| `venue_id` | bigint | 场地ID | 普通评委必填 |
| `projects` | varchar(500) | 项目列表 | JSON数组 |
### 状态判断逻辑
```
有效邀请码status=1 AND is_used=0 AND expire_time>NOW()
已使用is_used=1
已过期expire_time<=NOW()
已禁用status=0
```
---
## 🎯 前端集成建议
### 1. 在评委管理页面添加按钮
```vue
<template>
<el-table :data="judgeList">
<el-table-column label="操作">
<template #default="{ row }">
<!-- 生成邀请码按钮 -->
<el-button
v-if="!row.inviteCode"
type="primary"
size="small"
@click="generateInviteCode(row)"
>
生成邀请码
</el-button>
<!-- 显示邀请码 -->
<div v-else>
<el-tag>{{ row.inviteCode }}</el-tag>
<el-button
type="text"
size="small"
@click="copyInviteCode(row.inviteCode)"
>
复制
</el-button>
<el-button
type="text"
size="small"
@click="regenerateInviteCode(row)"
>
重新生成
</el-button>
</div>
</template>
</el-table-column>
</el-table>
</template>
```
### 2. 生成邀请码方法
```javascript
async generateInviteCode(judge) {
try {
const res = await this.$http.post('/martial/judgeInvite/generate', {
competitionId: this.competitionId,
judgeId: judge.id,
role: judge.refereeType === 1 ? 'chief_judge' : 'judge',
venueId: judge.venueId,
projects: JSON.stringify(judge.projects),
expireDays: 30
});
if (res.success) {
this.$message.success('邀请码生成成功:' + res.data.inviteCode);
// 复制到剪贴板
this.copyToClipboard(res.data.inviteCode);
// 刷新列表
this.loadJudgeList();
}
} catch (error) {
this.$message.error(error.message || '生成失败');
}
}
// 复制到剪贴板
copyToClipboard(text) {
const input = document.createElement('input');
input.value = text;
document.body.appendChild(input);
input.select();
document.execCommand('copy');
document.body.removeChild(input);
this.$message.success('已复制到剪贴板');
}
```
### 3. 批量生成
```javascript
async batchGenerate() {
const selectedJudges = this.$refs.table.selection;
if (selectedJudges.length === 0) {
this.$message.warning('请选择评委');
return;
}
const judgeIds = selectedJudges.map(j => j.id);
try {
const res = await this.$http.post('/martial/judgeInvite/generate/batch', {
competitionId: this.competitionId,
judgeIds: judgeIds,
role: 'judge',
expireDays: 30
});
if (res.success) {
this.$message.success(`成功生成${res.data.length}个邀请码`);
this.loadJudgeList();
}
} catch (error) {
this.$message.error(error.message || '批量生成失败');
}
}
```
---
## ✅ 验证清单
### 后端验证
- [ ] DTO类创建成功
- [ ] Service方法实现完成
- [ ] Controller接口添加完成
- [ ] 编译无错误
- [ ] Swagger文档生成正常
### 功能验证
- [ ] 单个生成邀请码成功
- [ ] 邀请码格式正确6位大写字母+数字)
- [ ] 邀请码唯一性验证通过
- [ ] 批量生成成功
- [ ] 重新生成成功(旧码失效)
- [ ] 查询邀请码成功
- [ ] 防止重复生成(已有有效邀请码时报错)
### 数据库验证
- [ ] 邀请码保存成功
- [ ] 过期时间设置正确
- [ ] 状态字段正确
- [ ] 唯一索引生效
### 小程序验证
- [ ] 使用邀请码登录成功
- [ ] 登录后权限正确
- [ ] 场地和项目信息正确
---
## 🔧 常见问题
### 问题1邀请码重复
**现象**: 生成的邀请码已存在
**原因**: 随机生成时碰撞
**解决**: 代码已实现重试机制最多10次
---
### 问题2评委已有邀请码
**现象**: 提示"该评委已有有效邀请码"
**原因**: 防止重复生成
**解决**:
- 使用"重新生成"功能
- 或等待旧邀请码过期
---
### 问题3邀请码过期
**现象**: 登录时提示邀请码已过期
**原因**: 超过30天有效期
**解决**: 使用"重新生成"功能
---
## 📈 后续优化建议
### 短期优化(可选)
1. **邀请码格式优化**
- 添加前缀WS-ABC123
- 区分角色J-评委C-裁判长)
2. **批量导出**
- 导出Excel评委信息+邀请码
- 生成PDF邀请函
3. **统计报表**
- 邀请码使用率
- 过期邀请码数量
### 长期优化(可选)
1. **短信/邮件发送**
- 集成短信服务
- 自动发送邀请码
2. **二维码生成**
- 生成邀请二维码
- 扫码直接登录
3. **邀请码管理**
- 批量禁用
- 批量延期
---
## 📞 技术支持
### 代码位置
| 文件 | 路径 |
|------|------|
| DTO类 | `src/main/java/org/springblade/modules/martial/pojo/dto/` |
| Service接口 | `src/main/java/org/springblade/modules/martial/service/IMartialJudgeInviteService.java` |
| Service实现 | `src/main/java/org/springblade/modules/martial/service/impl/MartialJudgeInviteServiceImpl.java` |
| Controller | `src/main/java/org/springblade/modules/martial/controller/MartialJudgeInviteController.java` |
| 测试SQL | `database/martial-db/test_invite_code_generation.sql` |
### Swagger 文档
启动后端服务后访问:
```
http://localhost:8080/doc.html
```
搜索"裁判邀请码管理"查看所有接口。
---
## 🎉 总结
### 已完成
✅ DTO类创建
✅ Service层实现
✅ Controller接口
✅ 测试SQL脚本
✅ 实施文档
### 工作量
- 后端开发2小时
- 测试验证1小时
- 文档编写1小时
- **总计**4小时
### 下一步
1. 启动后端服务
2. 使用Postman测试接口
3. 前端集成(如需要)
4. 联调测试
5. 上线部署
---
**祝您实施顺利!** 🚀
如有问题,请查看代码注释或联系技术支持。

View File

@@ -88,7 +88,8 @@ export const saveAndLockSchedule = (competitionId) => {
return request({ return request({
url: '/martial/schedule/save-and-lock', url: '/martial/schedule/save-and-lock',
method: 'post', method: 'post',
data: { competitionId } data: { competitionId },
timeout: 60000 // 设置60秒超时,因为锁定操作可能耗时较长
}) })
} }
@@ -103,6 +104,82 @@ export const saveDraftSchedule = (data) => {
return request({ return request({
url: '/martial/schedule/save-draft', url: '/martial/schedule/save-draft',
method: 'post', method: 'post',
data,
timeout: 60000 // 设置60秒超时,因为保存草稿可能涉及大量数据
})
}
/**
* 触发自动编排
* @param {Number} competitionId - 赛事ID
*/
export const triggerAutoArrange = (competitionId) => {
return request({
url: '/martial/schedule/auto-arrange',
method: 'post',
data: { competitionId },
timeout: 60000 // 设置60秒超时,因为自动编排可能耗时较长
})
}
/**
* 移动赛程分组到指定场地和时间段
* @param {Object} data - 移动请求数据
* @param {Number} data.groupId - 分组ID
* @param {Number} data.targetVenueId - 目标场地ID
* @param {Number} data.targetTimeSlotIndex - 目标时间段索引
*/
export const moveScheduleGroup = (data) => {
return request({
url: '/martial/schedule/move-group',
method: 'post',
data
})
}
// ==================== 调度功能接口 ====================
/**
* 获取调度数据
* @param {Object} params - 查询参数
* @param {Number} params.competitionId - 赛事ID
* @param {Number} params.venueId - 场地ID
* @param {Number} params.timeSlotIndex - 时间段索引
*/
export const getDispatchData = (params) => {
return request({
url: '/martial/schedule/dispatch-data',
method: 'get',
params
})
}
/**
* 调整出场顺序
* @param {Object} data - 调整请求数据
* @param {Number} data.detailId - 编排明细ID
* @param {Number} data.participantId - 参赛者记录ID
* @param {String} data.action - 调整动作(move_up/move_down/swap)
* @param {Number} data.targetOrder - 目标顺序(交换时使用)
*/
export const adjustOrder = (data) => {
return request({
url: '/martial/schedule/adjust-order',
method: 'post',
data
})
}
/**
* 批量保存调度
* @param {Object} data - 保存调度数据
* @param {Number} data.competitionId - 赛事ID
* @param {Array} data.adjustments - 调整列表
*/
export const saveDispatch = (data) => {
return request({
url: '/martial/schedule/save-dispatch',
method: 'post',
data data
}) })
} }

View File

@@ -88,6 +88,21 @@ export const exportReferees = (params) => {
}) })
} }
/**
* 导出工作人员名单Excel
* @param {Object} params - 导出参数
* @param {Number} params.competitionId - 赛事ID
* @param {String} params.role - 角色类型(可选)
*/
export const exportStaff = (params) => {
return request({
url: '/api/blade-martial/export/staff',
method: 'get',
params,
responseType: 'blob'
})
}
/** /**
* 导出奖牌榜Excel * 导出奖牌榜Excel
* @param {Object} params - 导出参数 * @param {Object} params - 导出参数

View File

@@ -99,9 +99,8 @@ export const batchSendInvites = (data) => {
*/ */
export const resendInvite = (id) => { export const resendInvite = (id) => {
return request({ return request({
url: '/api/blade-martial/judgeInvite/resend', url: `/api/blade-martial/judgeInvite/resend/${id}`,
method: 'post', method: 'post'
params: { id }
}) })
} }
@@ -124,14 +123,13 @@ export const replyInvite = (data) => {
/** /**
* 取消邀请 * 取消邀请
* @param {Number} id - 邀请ID * @param {Number} id - 邀请ID
* @param {String} cancelReason - 取消原因 * @param {String} reason - 取消原因
*/ */
export const cancelInvite = (id, cancelReason) => { export const cancelInvite = (id, reason) => {
return request({ return request({
url: '/api/blade-martial/judgeInvite/cancel', url: `/api/blade-martial/judgeInvite/cancel/${id}`,
method: 'post', method: 'post',
params: { id }, params: { reason }
data: { cancelReason }
}) })
} }
@@ -141,9 +139,8 @@ export const cancelInvite = (id, cancelReason) => {
*/ */
export const confirmInvite = (id) => { export const confirmInvite = (id) => {
return request({ return request({
url: '/api/blade-martial/judgeInvite/confirm', url: `/api/blade-martial/judgeInvite/confirm/${id}`,
method: 'post', method: 'post'
params: { id }
}) })
} }
@@ -173,15 +170,14 @@ export const getAcceptedJudges = (competitionId) => {
/** /**
* 从裁判库导入 * 从裁判库导入
* @param {Object} data - 导入参数 * @param {Number} competitionId - 赛事ID
* @param {Number} data.competitionId - 赛事ID * @param {String} judgeIds - 裁判ID逗号分隔
* @param {Array} data.judgeIds - 裁判ID数组从裁判库选择
*/ */
export const importFromJudgePool = (data) => { export const importFromJudgePool = (competitionId, judgeIds) => {
return request({ return request({
url: '/api/blade-martial/judgeInvite/import-from-pool', url: '/api/blade-martial/judgeInvite/import/pool',
method: 'post', method: 'post',
data params: { competitionId, judgeIds }
}) })
} }
@@ -201,13 +197,70 @@ export const exportInvites = (params) => {
/** /**
* 发送提醒消息 * 发送提醒消息
* @param {Number} id - 邀请ID * @param {Number} id - 邀请ID
* @param {String} reminderMessage - 提醒消息 * @param {String} message - 提醒消息
*/ */
export const sendReminder = (id, reminderMessage) => { export const sendReminder = (id, message) => {
return request({ return request({
url: '/api/blade-martial/judgeInvite/send-reminder', url: `/api/blade-martial/judgeInvite/reminder/${id}`,
method: 'post', method: 'post',
params: { id }, params: { message }
data: { reminderMessage } })
}
/**
* 生成邀请码
* @param {Object} data - 生成参数
* @param {Number} data.competitionId - 赛事ID
* @param {Number} data.judgeId - 评委ID
* @param {String} data.role - 角色judge/chief_judge
* @param {Number} data.venueId - 场地ID普通评委必填
* @param {String} data.projects - 项目列表JSON
* @param {Number} data.expireDays - 过期天数默认30
*/
export const generateInviteCode = (data) => {
return request({
url: '/api/blade-martial/judgeInvite/generate',
method: 'post',
data
})
}
/**
* 批量生成邀请码
* @param {Object} data - 批量生成参数
* @param {Number} data.competitionId - 赛事ID
* @param {Array} data.judgeIds - 评委ID数组
* @param {String} data.role - 角色默认judge
* @param {Number} data.expireDays - 过期天数默认30
*/
export const batchGenerateInviteCode = (data) => {
return request({
url: '/api/blade-martial/judgeInvite/generate/batch',
method: 'post',
data
})
}
/**
* 重新生成邀请码
* @param {Number} inviteId - 邀请ID
*/
export const regenerateInviteCode = (inviteId) => {
return request({
url: `/api/blade-martial/judgeInvite/regenerate/${inviteId}`,
method: 'put'
})
}
/**
* 查询评委的邀请码
* @param {Number} competitionId - 赛事ID
* @param {Number} judgeId - 评委ID
*/
export const getInviteByJudge = (competitionId, judgeId) => {
return request({
url: '/api/blade-martial/judgeInvite/byJudge',
method: 'get',
params: { competitionId, judgeId }
}) })
} }

View File

@@ -35,6 +35,30 @@ export const getProjectDetail = (id) => {
}) })
} }
/**
* 新增项目
* @param {Object} data - 项目数据
*/
export const addProject = (data) => {
return request({
url: '/api/martial/project/save',
method: 'post',
data
})
}
/**
* 修改项目
* @param {Object} data - 项目数据
*/
export const updateProject = (data) => {
return request({
url: '/api/martial/project/update',
method: 'post',
data
})
}
/** /**
* 新增或修改项目 * 新增或修改项目
* @param {Object} data - 项目数据 * @param {Object} data - 项目数据
@@ -73,6 +97,31 @@ export const removeProject = (ids) => {
}) })
} }
/**
* 导入项目
* @param {Object} data - 导入数据
*/
export const importProjects = (data) => {
return request({
url: '/api/martial/project/import',
method: 'post',
data
})
}
/**
* 导出项目
* @param {Object} params - 导出参数
*/
export const exportProjects = (params) => {
return request({
url: '/api/martial/project/export',
method: 'get',
params,
responseType: 'blob'
})
}
/** /**
* 获取赛事的项目列表(不分页,用于下拉选择) * 获取赛事的项目列表(不分页,用于下拉选择)
* @param {Number} competitionId - 赛事ID * @param {Number} competitionId - 赛事ID

View File

@@ -232,3 +232,42 @@ export const exportResults = (params) => {
responseType: 'blob' responseType: 'blob'
}) })
} }
/**
* 导出获奖名单
* @param {Object} params - 查询参数
*/
export const exportAwardList = (params) => {
return request({
url: '/api/blade-martial/result/export-award',
method: 'get',
params,
responseType: 'blob'
})
}
/**
* 生成单个证书
* @param {Object} data - 证书参数
*/
export const generateCertificate = (data) => {
return request({
url: '/api/blade-martial/result/certificate/generate',
method: 'post',
data,
responseType: 'blob'
})
}
/**
* 批量生成证书
* @param {Object} data - 批量证书参数
*/
export const batchGenerateCertificates = (data) => {
return request({
url: '/api/blade-martial/result/certificate/batch-generate',
method: 'post',
data,
responseType: 'blob'
})
}

115
src/api/martial/rules.js Normal file
View File

@@ -0,0 +1,115 @@
import request from '@/axios'
// 获取赛事列表
export const getCompetitionList = (params) => {
return request({
url: '/api/martial/competition/list',
method: 'get',
params
})
}
// 获取赛事规程(小程序端)
export const getCompetitionRules = (params) => {
return request({
url: '/api/martial/competition/rules',
method: 'get',
params
})
}
// ==================== 附件管理 ====================
// 获取附件列表
export const getAttachmentList = (params) => {
return request({
url: '/api/martial/competition/rules/attachment/list',
method: 'get',
params
})
}
// 保存附件
export const saveAttachment = (data) => {
return request({
url: '/api/martial/competition/rules/attachment/save',
method: 'post',
data
})
}
// 删除附件
export const removeAttachment = (params) => {
return request({
url: '/api/martial/competition/rules/attachment/remove',
method: 'post',
params
})
}
// ==================== 章节管理 ====================
// 获取章节列表
export const getChapterList = (params) => {
return request({
url: '/api/martial/competition/rules/chapter/list',
method: 'get',
params
})
}
// 保存章节
export const saveChapter = (data) => {
return request({
url: '/api/martial/competition/rules/chapter/save',
method: 'post',
data
})
}
// 删除章节
export const removeChapter = (params) => {
return request({
url: '/api/martial/competition/rules/chapter/remove',
method: 'post',
params
})
}
// ==================== 章节内容管理 ====================
// 获取章节内容列表
export const getContentList = (params) => {
return request({
url: '/api/martial/competition/rules/content/list',
method: 'get',
params
})
}
// 保存章节内容
export const saveContent = (data) => {
return request({
url: '/api/martial/competition/rules/content/save',
method: 'post',
data
})
}
// 批量保存章节内容
export const batchSaveContents = (data) => {
return request({
url: '/api/martial/competition/rules/content/batch-save',
method: 'post',
data
})
}
// 删除章节内容
export const removeContent = (params) => {
return request({
url: '/api/martial/competition/rules/content/remove',
method: 'post',
params
})
}

View File

@@ -19,7 +19,7 @@ import { Base64 } from 'js-base64';
import { baseUrl } from '@/config/env'; import { baseUrl } from '@/config/env';
import crypto from '@/utils/crypto'; import crypto from '@/utils/crypto';
axios.defaults.timeout = 10000; axios.defaults.timeout = 60000; // 60秒超时支持编排等耗时操作
//返回其他状态吗 //返回其他状态吗
axios.defaults.validateStatus = function (status) { axios.defaults.validateStatus = function (status) {
return status >= 200 && status <= 500; // 默认的 return status >= 200 && status <= 500; // 默认的

View File

@@ -71,22 +71,6 @@ export default [
}, },
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/order/index.vue'), component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/order/index.vue'),
}, },
{
path: 'schedule/list',
name: '编排',
meta: {
keepAlive: false,
},
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/schedule/index.vue'),
},
{
path: 'dispatch/list',
name: '调度',
meta: {
keepAlive: false,
},
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/dispatch/index.vue'),
},
{ {
path: 'banner/index', path: 'banner/index',
name: '轮播图管理', name: '轮播图管理',
@@ -119,6 +103,22 @@ export default [
}, },
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/participant/index.vue'), component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/participant/index.vue'),
}, },
{
path: 'schedule/list',
name: '编排',
meta: {
keepAlive: false,
},
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/schedule/index.vue'),
},
{
path: 'dispatch/list',
name: '调度',
meta: {
keepAlive: false,
},
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/dispatch/index.vue'),
},
// 新增页面 - P0核心页面 // 新增页面 - P0核心页面
{ {
path: 'project/list', path: 'project/list',
@@ -218,6 +218,14 @@ export default [
}, },
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/activity/index.vue'), component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/activity/index.vue'),
}, },
{
path: 'rules/index',
name: '赛事规程管理',
meta: {
keepAlive: true,
},
component: () => import(/* webpackChunkName: "martial" */ '@/views/martial/rules/index.vue'),
},
], ],
}, },
]; ];

View File

@@ -32,6 +32,27 @@
show-overflow-tooltip show-overflow-tooltip
/> />
<el-table-column
prop="competitionCode"
label="比赛编码"
width="150"
align="center"
>
<template #default="scope">
<el-tag
v-if="scope.row.competitionCode"
type="success"
effect="dark"
size="default"
style="font-family: monospace; font-weight: bold; cursor: pointer;"
@click="copyToClipboard(scope.row.competitionCode, '比赛编码')"
>
{{ scope.row.competitionCode }}
</el-tag>
<el-tag v-else type="info" size="small">未设置</el-tag>
</template>
</el-table-column>
<el-table-column <el-table-column
prop="organizer" prop="organizer"
label="主办单位" label="主办单位"
@@ -171,6 +192,31 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
<el-col :span="12">
<el-form-item label="比赛编码" prop="competitionCode">
<el-input
v-model="formData.competitionCode"
placeholder="例如:WS2025 (留空自动生成)"
maxlength="50"
>
<template #append>
<el-button
v-if="formData.competitionCode"
icon="el-icon-document-copy"
@click="copyToClipboard(formData.competitionCode, '比赛编码')"
>
复制
</el-button>
</template>
</el-input>
<div style="font-size: 12px; color: #909399; margin-top: 4px;">
💡 用于评委小程序登录建议格式项目简称+年份如WS2025
</div>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="主办单位" prop="organizer"> <el-form-item label="主办单位" prop="organizer">
<el-input <el-input
@@ -179,9 +225,6 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="举办地点" prop="location"> <el-form-item label="举办地点" prop="location">
<el-input <el-input
@@ -190,6 +233,9 @@
/> />
</el-form-item> </el-form-item>
</el-col> </el-col>
</el-row>
<el-row :gutter="20">
<el-col :span="12"> <el-col :span="12">
<el-form-item label="比赛场馆" prop="venue"> <el-form-item label="比赛场馆" prop="venue">
<el-input <el-input
@@ -776,6 +822,7 @@ export default {
}, },
formData: { formData: {
competitionName: '', competitionName: '',
competitionCode: '', // 比赛编码
organizer: '', organizer: '',
location: '', location: '',
venue: '', venue: '',
@@ -1433,6 +1480,7 @@ export default {
resetFormData() { resetFormData() {
this.formData = { this.formData = {
competitionName: '', competitionName: '',
competitionCode: '', // 比赛编码
organizer: '', organizer: '',
location: '', location: '',
venue: '', venue: '',
@@ -1592,6 +1640,45 @@ export default {
} }
// 未开始(默认状态) // 未开始(默认状态)
return 1; return 1;
},
// 复制到剪贴板
copyToClipboard(text, label) {
if (!text) {
this.$message.warning(`${label}为空`);
return;
}
// 使用现代API复制
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(text).then(() => {
this.$message.success(`${label}已复制: ${text}`);
}).catch(() => {
this.fallbackCopyToClipboard(text, label);
});
} else {
this.fallbackCopyToClipboard(text, label);
}
},
// 降级复制方法(兼容旧浏览器)
fallbackCopyToClipboard(text, label) {
const textArea = document.createElement('textarea');
textArea.value = text;
textArea.style.position = 'fixed';
textArea.style.top = '-9999px';
document.body.appendChild(textArea);
textArea.focus();
textArea.select();
try {
document.execCommand('copy');
this.$message.success(`${label}已复制: ${text}`);
} catch (err) {
this.$message.error('复制失败,请手动复制');
}
document.body.removeChild(textArea);
} }
} }
}; };

View File

@@ -11,13 +11,27 @@
</div> </div>
<div class="tab-content"> <div class="tab-content">
<!-- 场地选择器 -->
<div class="venue-selector">
<el-button
v-for="venue in venues"
:key="venue.id"
size="small"
:type="selectedVenueId === venue.id ? 'primary' : ''"
@click="selectedVenueId = venue.id; loadDispatchData()"
>
{{ venue.venueName }}
</el-button>
</div>
<!-- 时间段选择器 -->
<div class="time-selector"> <div class="time-selector">
<el-button <el-button
v-for="(time, index) in timeSlots" v-for="(time, index) in timeSlots"
:key="index" :key="index"
size="small" size="small"
:type="selectedTime === index ? 'primary' : ''" :type="selectedTime === index ? 'primary' : ''"
@click="selectedTime = index" @click="selectedTime = index; loadDispatchData()"
> >
{{ time }} {{ time }}
</el-button> </el-button>
@@ -65,7 +79,7 @@
<el-table-column label="操作" width="100" align="center"> <el-table-column label="操作" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-button <el-button
type="text" link
size="small" size="small"
@click="handleMoveUp(index, scope.$index)" @click="handleMoveUp(index, scope.$index)"
:disabled="scope.$index === 0" :disabled="scope.$index === 0"
@@ -75,7 +89,7 @@
<img src="/img/图标 3@3x.png" class="move-icon" alt="上移" /> <img src="/img/图标 3@3x.png" class="move-icon" alt="上移" />
</el-button> </el-button>
<el-button <el-button
type="text" link
size="small" size="small"
@click="handleMoveDown(index, scope.$index)" @click="handleMoveDown(index, scope.$index)"
:disabled="scope.$index === group.items.length - 1" :disabled="scope.$index === group.items.length - 1"
@@ -88,110 +102,290 @@
</el-table-column> </el-table-column>
</el-table> </el-table>
</div> </div>
<!-- 保存按钮 -->
<div class="dispatch-footer" v-if="dispatchGroups.length > 0">
<el-button @click="goBack">返回</el-button>
<el-button type="primary" @click="handleSaveDispatch" :disabled="!hasChanges">
保存调度
</el-button>
</div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import { getVenuesByCompetition } from '@/api/martial/venue'
import { getCompetitionDetail } from '@/api/martial/competition'
import { getDispatchData, saveDispatch, getScheduleResult } from '@/api/martial/activitySchedule'
export default { export default {
name: 'MartialDispatchList', name: 'MartialDispatchList',
data() { data() {
return { return {
orderId: null, competitionId: null,
selectedTime: 1, loading: false,
timeSlots: [ selectedTime: 0,
'2025年11月6日上午8:30', selectedVenueId: null,
'2025年11月6日下午13:00', venues: [], // 场地列表
'2025年11月7日上午8:30' timeSlots: [], // 时间段列表
], dispatchGroups: [], // 调度分组列表
dispatchGroups: [ hasChanges: false, // 是否有未保存的更改
{ originalData: null // 原始数据(用于取消时恢复)
title: '1. 小学组小组赛男女类',
type: '集体',
count: '2队',
code: '1101',
venueType: 1,
viewMode: 'dispatch',
items: [
{ schoolUnit: '少林寺武校', completed: true, refereed: false },
{ schoolUnit: '访河社区', completed: false, refereed: false }
]
},
{
title: '1. 小学组小组赛男女类',
type: '单人',
count: '3队',
code: '1组',
venueType: 2,
viewMode: 'dispatch',
items: [
{ schoolUnit: '少林寺武校', completed: true, refereed: true },
{ schoolUnit: '访河社区', completed: false, refereed: false },
{ schoolUnit: '少林寺武校', completed: true, refereed: true }
]
},
{
title: '2. 中学组决赛',
type: '集体',
count: '4队',
code: '2101',
venueType: 1,
viewMode: 'dispatch',
items: [
{ schoolUnit: '成都体育学院', completed: true, refereed: true },
{ schoolUnit: '武侯实验中学', completed: true, refereed: false },
{ schoolUnit: '石室中学', completed: false, refereed: false },
{ schoolUnit: '七中育才', completed: false, refereed: false }
]
}
]
} }
}, },
mounted() { async mounted() {
this.orderId = this.$route.query.orderId this.competitionId = this.$route.query.competitionId
// 使用静态数据不调用API if (this.competitionId) {
// 先检查编排状态
await this.checkScheduleStatus()
this.loadCompetitionInfo()
this.loadVenues()
} else {
this.$message.warning('未获取到赛事ID')
}
}, },
methods: { methods: {
goBack() { goBack() {
this.$router.go(-1) this.$router.go(-1)
}, },
// 检查编排状态
async checkScheduleStatus() {
try {
const res = await getScheduleResult(this.competitionId)
const scheduleStatus = res.data?.data?.scheduleStatus || res.data?.scheduleStatus || 0
console.log('编排状态:', scheduleStatus)
// 如果编排未完成状态不是2提示用户并返回
if (scheduleStatus !== 2) {
this.$message.warning('请先完成编排并锁定后,才能进行调度操作')
// 延迟返回上一页
setTimeout(() => {
this.$router.go(-1)
}, 2000)
return false
}
return true
} catch (error) {
console.error('检查编排状态失败:', error)
this.$message.error('无法获取编排状态,请稍后重试')
setTimeout(() => {
this.$router.go(-1)
}, 2000)
return false
}
},
// 加载赛事信息
async loadCompetitionInfo() {
try {
this.loading = true
const res = await getCompetitionDetail(this.competitionId)
const data = res.data?.data
if (data) {
// 生成时间段
this.generateTimeSlots(data.competitionStartTime || data.competition_start_time, data.competitionEndTime || data.competition_end_time)
}
} catch (err) {
console.error('加载赛事信息失败', err)
this.$message.error('加载赛事信息失败')
} finally {
this.loading = false
}
},
// 生成时间段列表
generateTimeSlots(startTime, endTime) {
if (!startTime || !endTime) {
this.timeSlots = ['2025年11月6日 上午8:30', '2025年11月6日 下午13:30']
return
}
const slots = []
const start = new Date(startTime)
const end = new Date(endTime)
let currentDate = new Date(start)
while (currentDate <= end) {
const year = currentDate.getFullYear()
const month = currentDate.getMonth() + 1
const day = currentDate.getDate()
const dateStr = `${year}${month}${day}`
slots.push(`${dateStr} 上午8:30`)
slots.push(`${dateStr} 下午13:30`)
currentDate.setDate(currentDate.getDate() + 1)
}
this.timeSlots = slots
},
// 加载场地列表
async loadVenues() {
try {
this.loading = true
const res = await getVenuesByCompetition(this.competitionId)
const venuesData = res.data?.data?.records || res.data?.data || []
if (venuesData.length === 0) {
this.$message.warning('该赛事暂无场地信息')
this.venues = []
} else {
this.venues = venuesData.map(v => ({
id: v.id,
venueName: v.venueName || v.venue_name
}))
// 默认选中第一个场地
if (this.venues.length > 0) {
this.selectedVenueId = this.venues[0].id
this.loadDispatchData()
}
}
} catch (err) {
console.error('加载场地失败', err)
this.$message.error('加载场地失败')
this.venues = []
} finally {
this.loading = false
}
},
// 加载调度数据
async loadDispatchData() {
if (!this.selectedVenueId || this.selectedTime === null) {
this.dispatchGroups = []
return
}
try {
this.loading = true
const res = await getDispatchData({
competitionId: this.competitionId,
venueId: this.selectedVenueId,
timeSlotIndex: this.selectedTime
})
if (res.data.success) {
const groups = res.data.data.groups || []
this.dispatchGroups = groups.map(group => ({
...group,
viewMode: 'dispatch',
title: group.groupName,
items: group.participants.map(p => ({
...p,
schoolUnit: p.organization,
completed: false,
refereed: false
}))
}))
// 保存原始数据
this.originalData = JSON.parse(JSON.stringify(this.dispatchGroups))
this.hasChanges = false
} else {
this.$message.error(res.data.msg || '加载调度数据失败')
}
} catch (error) {
console.error('加载调度数据失败:', error)
this.$message.error('加载调度数据失败')
} finally {
this.loading = false
}
},
setViewMode(index, mode) { setViewMode(index, mode) {
this.dispatchGroups[index].viewMode = mode this.dispatchGroups[index].viewMode = mode
this.$message.success(`已切换到${mode === 'dispatch' ? '调度列表' : '场地'}模式`) this.$message.success(`已切换到${mode === 'dispatch' ? '调度列表' : '场地'}模式`)
}, },
handleMoveUp(groupIndex, itemIndex) { handleMoveUp(groupIndex, itemIndex) {
if (itemIndex === 0) return if (itemIndex === 0) return
const group = this.dispatchGroups[groupIndex] const group = this.dispatchGroups[groupIndex]
const temp = group.items[itemIndex] const temp = group.items[itemIndex]
group.items.splice(itemIndex, 1) group.items.splice(itemIndex, 1)
group.items.splice(itemIndex - 1, 0, temp) group.items.splice(itemIndex - 1, 0, temp)
// 更新顺序号
group.items.forEach((item, index) => {
item.performanceOrder = index + 1
})
this.hasChanges = true
this.$message.success('上移成功') this.$message.success('上移成功')
}, },
handleMoveDown(groupIndex, itemIndex) { handleMoveDown(groupIndex, itemIndex) {
const group = this.dispatchGroups[groupIndex] const group = this.dispatchGroups[groupIndex]
if (itemIndex === group.items.length - 1) return if (itemIndex === group.items.length - 1) return
const temp = group.items[itemIndex] const temp = group.items[itemIndex]
group.items.splice(itemIndex, 1) group.items.splice(itemIndex, 1)
group.items.splice(itemIndex + 1, 0, temp) group.items.splice(itemIndex + 1, 0, temp)
// 更新顺序号
group.items.forEach((item, index) => {
item.performanceOrder = index + 1
})
this.hasChanges = true
this.$message.success('下移成功') this.$message.success('下移成功')
}, },
handleBatchComplete() { handleBatchComplete() {
this.$confirm('确定要标记当前批次所有为完赛状态吗?', '批次完赛', { this.$confirm('确定要标记当前批次所有为完赛状态吗?', '批次完赛', {
confirmButtonText: '确定', confirmButtonText: '确定',
cancelButtonText: '取消', cancelButtonText: '取消',
type: 'warning' type: 'warning'
}).then(() => { }).then(() => {
// 标记当前时间段所有为完赛
this.dispatchGroups.forEach(group => { this.dispatchGroups.forEach(group => {
group.items.forEach(item => { group.items.forEach(item => {
item.completed = true item.completed = true
}) })
}) })
this.$message.success('批次完赛成功') this.$message.success('批次完赛成功')
}).catch(() => { }).catch(() => {})
// 取消操作 },
// 保存调度
async handleSaveDispatch() {
if (!this.hasChanges) {
this.$message.info('没有需要保存的更改')
return
}
try {
this.loading = true
const adjustments = this.dispatchGroups.map(group => ({
detailId: group.detailId,
participants: group.items.map(p => ({
id: p.id,
performanceOrder: p.performanceOrder
}))
}))
const res = await saveDispatch({
competitionId: this.competitionId,
adjustments
}) })
if (res.data.success) {
this.$message.success('调度保存成功')
this.hasChanges = false
await this.loadDispatchData()
} else {
this.$message.error(res.data.msg || '保存失败')
}
} catch (error) {
console.error('保存调度失败:', error)
this.$message.error('保存失败,请稍后重试')
} finally {
this.loading = false
}
} }
} }
} }
@@ -223,6 +417,14 @@ export default {
} }
.tab-content { .tab-content {
.venue-selector {
margin-bottom: 15px;
display: flex;
gap: 8px;
align-items: center;
flex-wrap: wrap;
}
.time-selector { .time-selector {
margin-bottom: 15px; margin-bottom: 15px;
display: flex; display: flex;
@@ -290,5 +492,17 @@ export default {
display: inline-block; display: inline-block;
vertical-align: middle; vertical-align: middle;
} }
.dispatch-footer {
margin-top: 30px;
text-align: center;
padding: 20px;
background: #f5f7fa;
border-radius: 4px;
.el-button {
min-width: 120px;
}
}
} }
</style> </style>

File diff suppressed because it is too large Load Diff

Binary file not shown.

File diff suppressed because it is too large Load Diff

Binary file not shown.

View File

@@ -69,7 +69,15 @@
<template #default="scope"> <template #default="scope">
<el-button type="primary" size="small" @click="handleRegistrationDetail(scope.row)">报名详情</el-button> <el-button type="primary" size="small" @click="handleRegistrationDetail(scope.row)">报名详情</el-button>
<el-button type="success" size="small" @click="handleSchedule(scope.row)">编排</el-button> <el-button type="success" size="small" @click="handleSchedule(scope.row)">编排</el-button>
<el-button type="warning" size="small" @click="handleDispatch(scope.row)">调度</el-button> <el-button
type="warning"
size="small"
@click="handleDispatch(scope.row)"
:disabled="!isScheduleCompleted(scope.row.id)"
:title="isScheduleCompleted(scope.row.id) ? '进入调度' : '请先完成编排'"
>
调度
</el-button>
</template> </template>
</el-table-column> </el-table-column>
</el-table> </el-table>
@@ -91,6 +99,7 @@
<script> <script>
import { getCompetitionList } from '@/api/martial/competition' import { getCompetitionList } from '@/api/martial/competition'
import { getScheduleResult } from '@/api/martial/activitySchedule'
export default { export default {
name: 'MartialOrderList', name: 'MartialOrderList',
@@ -106,12 +115,19 @@ export default {
current: 1, current: 1,
size: 10, size: 10,
total: 0 total: 0
} },
scheduleStatusMap: {} // 存储每个赛事的编排状态
} }
}, },
mounted() { mounted() {
this.loadCompetitionList() this.loadCompetitionList()
}, },
activated() {
// 当页面被激活时(从其他页面返回),重新加载编排状态
if (this.tableData.length > 0) {
this.loadScheduleStatus()
}
},
methods: { methods: {
// 加载赛事列表 // 加载赛事列表
loadCompetitionList() { loadCompetitionList() {
@@ -146,6 +162,9 @@ export default {
createTime: competition.createTime || competition.create_time createTime: competition.createTime || competition.create_time
})) }))
this.pagination.total = responseData.total || 0 this.pagination.total = responseData.total || 0
// 加载每个赛事的编排状态
this.loadScheduleStatus()
} }
}) })
.catch(err => { .catch(err => {
@@ -157,6 +176,28 @@ export default {
}) })
}, },
// 加载编排状态
async loadScheduleStatus() {
for (const competition of this.tableData) {
try {
const res = await getScheduleResult(competition.id)
if (res.data?.data) {
this.$set(this.scheduleStatusMap, competition.id, res.data.data.isCompleted || false)
} else {
this.$set(this.scheduleStatusMap, competition.id, false)
}
} catch (err) {
// 如果获取失败,默认为未完成
this.$set(this.scheduleStatusMap, competition.id, false)
}
}
},
// 检查编排是否完成
isScheduleCompleted(competitionId) {
return this.scheduleStatusMap[competitionId] === true
},
handleSearch() { handleSearch() {
this.pagination.current = 1 this.pagination.current = 1
this.loadCompetitionList() this.loadCompetitionList()
@@ -191,6 +232,12 @@ export default {
// 调度 - 传递赛事ID // 调度 - 传递赛事ID
handleDispatch(row) { handleDispatch(row) {
// 检查编排是否完成
if (!this.isScheduleCompleted(row.id)) {
this.$message.warning('请先完成编排后再进行调度')
return
}
this.$router.push({ this.$router.push({
path: '/martial/dispatch/list', path: '/martial/dispatch/list',
query: { competitionId: row.id } query: { competitionId: row.id }

View File

@@ -467,7 +467,7 @@ import {
exportProjects exportProjects
} from '@/api/martial/project' } from '@/api/martial/project'
import { getCompetitionList } from '@/api/martial/competition' import { getCompetitionList } from '@/api/martial/competition'
import { getToken } from '@/util/auth' import { getToken } from '@/utils/auth'
import dayjs from 'dayjs' import dayjs from 'dayjs'
// 数据定义 // 数据定义

View File

@@ -310,12 +310,10 @@ export default {
// 计算总金额(从缓存中获取) // 计算总金额(从缓存中获取)
let totalAmount = 0 let totalAmount = 0
const projectIds = new Set()
for (const athlete of participants) { for (const athlete of participants) {
const projectId = athlete.projectId || athlete.project_id const projectId = athlete.projectId || athlete.project_id
if (projectId && !projectIds.has(projectId)) { if (projectId) {
projectIds.add(projectId)
const project = this.projectCache.get(projectId) const project = this.projectCache.get(projectId)
if (project) { if (project) {
totalAmount += parseFloat(project.price || 0) totalAmount += parseFloat(project.price || 0)
@@ -436,38 +434,30 @@ export default {
if (!unitMap.has(unit)) { if (!unitMap.has(unit)) {
unitMap.set(unit, { unitMap.set(unit, {
projectIds: new Set(), projectIds: new Set(),
projectPrices: new Map() totalAmount: 0
}) })
} }
const stat = unitMap.get(unit) const stat = unitMap.get(unit)
// 添加项目IDSet自动去重 // 添加项目IDSet自动去重,用于统计项目数量
if (projectId) { if (projectId) {
stat.projectIds.add(projectId) stat.projectIds.add(projectId)
// 从缓存中获取价格不再重复调用API // 从缓存中获取价格并累加到总金额
if (!stat.projectPrices.has(projectId)) {
const project = this.projectCache.get(projectId) const project = this.projectCache.get(projectId)
const price = project ? (project.price || 0) : 0 const price = project ? (project.price || 0) : 0
stat.projectPrices.set(projectId, parseFloat(price)) stat.totalAmount += parseFloat(price)
}
} }
} }
// 3. 计算每个单位的总金额 // 3. 计算每个单位的总金额
const amountStats = [] const amountStats = []
for (const [unit, stat] of unitMap) { for (const [unit, stat] of unitMap) {
let totalAmount = 0
// 遍历该单位的所有项目,累加价格
for (const price of stat.projectPrices.values()) {
totalAmount += price
}
amountStats.push({ amountStats.push({
schoolUnit: unit, schoolUnit: unit,
projectCount: stat.projectIds.size, projectCount: stat.projectIds.size,
totalAmount: totalAmount.toFixed(2) totalAmount: stat.totalAmount.toFixed(2)
}) })
} }

View File

@@ -0,0 +1,625 @@
<template>
<div class="rules-container">
<!-- 赛事选择 -->
<el-card shadow="never" class="search-card">
<el-form :inline="true" class="search-form">
<el-form-item label="选择赛事">
<el-select
v-model="competitionId"
placeholder="请选择赛事"
clearable
filterable
style="width: 300px"
@change="handleCompetitionChange"
>
<el-option
v-for="item in competitionList"
:key="item.id"
:label="item.competitionName"
:value="item.id"
/>
</el-select>
</el-form-item>
</el-form>
</el-card>
<div v-if="competitionId" class="content-wrapper">
<!-- 附件管理 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">📎 规程附件</span>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddAttachment">
添加附件
</el-button>
</div>
</template>
<el-table :data="attachmentList" border stripe>
<el-table-column prop="fileName" label="文件名称" min-width="200" />
<el-table-column prop="fileType" label="文件类型" width="100" align="center">
<template #default="{ row }">
<el-tag>{{ row.fileType }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="fileSize" label="文件大小" width="120" align="center">
<template #default="{ row }">
{{ formatFileSize(row.fileSize) }}
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEditAttachment(row)">
编辑
</el-button>
<el-button link type="primary" size="small" @click="handlePreviewFile(row)">
预览
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteAttachment(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 章节管理 -->
<el-card shadow="never" class="section-card">
<template #header>
<div class="card-header">
<span class="card-title">📄 规程章节</span>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddChapter">
添加章节
</el-button>
</div>
</template>
<el-table :data="chapterList" border stripe>
<el-table-column prop="chapterNumber" label="章节编号" width="120" />
<el-table-column prop="title" label="章节标题" min-width="200" />
<el-table-column prop="orderNum" label="排序" width="80" align="center" />
<el-table-column prop="status" label="状态" width="80" align="center">
<template #default="{ row }">
<el-tag :type="row.status === 1 ? 'success' : 'danger'">
{{ row.status === 1 ? '启用' : '禁用' }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="220" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="handleEditChapter(row)">
编辑
</el-button>
<el-button link type="success" size="small" @click="handleManageContent(row)">
管理内容
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteChapter(row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
<el-empty v-else description="请先选择赛事" />
<!-- 附件编辑对话框 -->
<el-dialog
v-model="attachmentDialogVisible"
:title="attachmentForm.id ? '编辑附件' : '添加附件'"
width="600px"
>
<el-form :model="attachmentForm" :rules="attachmentRules" ref="attachmentFormRef" label-width="100px">
<el-form-item label="文件上传" prop="fileUrl">
<el-upload
class="upload-demo"
:action="uploadUrl"
:headers="uploadHeaders"
:on-success="handleFileUploadSuccess"
:before-upload="beforeFileUpload"
:file-list="fileList"
:limit="1"
>
<el-button type="primary">点击上传</el-button>
<template #tip>
<div class="el-upload__tip">
支持 pdf/doc/docx/xls/xlsx/ppt/pptx 等文档格式文件大小不超过50MB
</div>
</template>
</el-upload>
</el-form-item>
<el-form-item label="文件名称" prop="fileName">
<el-input v-model="attachmentForm.fileName" placeholder="请输入文件名称" />
</el-form-item>
<el-form-item label="排序" prop="orderNum">
<el-input-number v-model="attachmentForm.orderNum" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="attachmentForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="attachmentDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveAttachment">确定</el-button>
</template>
</el-dialog>
<!-- 章节编辑对话框 -->
<el-dialog
v-model="chapterDialogVisible"
:title="chapterForm.id ? '编辑章节' : '添加章节'"
width="600px"
>
<el-form :model="chapterForm" :rules="chapterRules" ref="chapterFormRef" label-width="100px">
<el-form-item label="章节编号" prop="chapterNumber">
<el-input v-model="chapterForm.chapterNumber" placeholder="如:第一章" />
</el-form-item>
<el-form-item label="章节标题" prop="title">
<el-input v-model="chapterForm.title" placeholder="请输入章节标题" />
</el-form-item>
<el-form-item label="排序" prop="orderNum">
<el-input-number v-model="chapterForm.orderNum" :min="0" :max="999" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-radio-group v-model="chapterForm.status">
<el-radio :label="1">启用</el-radio>
<el-radio :label="0">禁用</el-radio>
</el-radio-group>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="chapterDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSaveChapter">确定</el-button>
</template>
</el-dialog>
<!-- 章节内容管理对话框 -->
<el-dialog
v-model="contentDialogVisible"
title="管理章节内容"
width="800px"
>
<div class="content-header">
<span class="content-title">{{ currentChapter.chapterNumber }} {{ currentChapter.title }}</span>
<el-button type="primary" size="small" :icon="Plus" @click="handleAddContent">
添加内容
</el-button>
</div>
<el-table :data="contentList" border stripe class="content-table">
<el-table-column prop="content" label="内容" min-width="400">
<template #default="{ row, $index }">
<el-input
v-if="row.editing"
v-model="row.content"
type="textarea"
:rows="2"
placeholder="请输入内容"
/>
<span v-else>{{ row.content }}</span>
</template>
</el-table-column>
<el-table-column prop="orderNum" label="排序" width="80" align="center" />
<el-table-column label="操作" width="150" align="center">
<template #default="{ row, $index }">
<el-button
v-if="!row.editing"
link
type="primary"
size="small"
@click="handleEditContent(row, $index)"
>
编辑
</el-button>
<el-button
v-else
link
type="success"
size="small"
@click="handleSaveContent(row, $index)"
>
保存
</el-button>
<el-button link type="danger" size="small" @click="handleDeleteContent($index)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<template #footer>
<el-button @click="contentDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleBatchSaveContents">保存全部</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, Delete, Upload, Download, Refresh, Search } from '@element-plus/icons-vue'
import { getToken } from '@/utils/auth'
import {
getCompetitionList,
getAttachmentList,
saveAttachment,
removeAttachment,
getChapterList,
saveChapter,
removeChapter,
getContentList,
batchSaveContents
} from '@/api/martial/rules'
// 赛事列表
const competitionList = ref([])
const competitionId = ref(null)
// 附件相关
const attachmentList = ref([])
const attachmentDialogVisible = ref(false)
const attachmentFormRef = ref(null)
const attachmentForm = reactive({
id: null,
competitionId: null,
fileName: '',
fileUrl: '',
fileSize: null,
fileType: '',
orderNum: 0,
status: 1
})
const attachmentRules = {
fileName: [{ required: true, message: '请输入文件名称', trigger: 'blur' }],
fileUrl: [{ required: true, message: '请上传文件', trigger: 'change' }]
}
const fileList = ref([])
// 章节相关
const chapterList = ref([])
const chapterDialogVisible = ref(false)
const chapterFormRef = ref(null)
const chapterForm = reactive({
id: null,
competitionId: null,
chapterNumber: '',
title: '',
orderNum: 0,
status: 1
})
const chapterRules = {
chapterNumber: [{ required: true, message: '请输入章节编号', trigger: 'blur' }],
title: [{ required: true, message: '请输入章节标题', trigger: 'blur' }]
}
// 章节内容相关
const contentDialogVisible = ref(false)
const currentChapter = ref({})
const contentList = ref([])
// 文件上传配置
const uploadUrl = ref(import.meta.env.VITE_API_URL + '/blade-resource/oss/endpoint/put-file')
const uploadHeaders = ref({
'Blade-Auth': 'bearer ' + getToken()
})
// 初始化
onMounted(() => {
fetchCompetitionList()
})
// 获取赛事列表
const fetchCompetitionList = async () => {
try {
const res = await getCompetitionList({ current: 1, size: 1000 })
competitionList.value = res.data.records || []
} catch (error) {
ElMessage.error('获取赛事列表失败')
}
}
// 赛事切换
const handleCompetitionChange = () => {
if (competitionId.value) {
fetchAttachmentList()
fetchChapterList()
}
}
// 获取附件列表
const fetchAttachmentList = async () => {
try {
const res = await getAttachmentList({ competitionId: competitionId.value })
attachmentList.value = res.data || []
} catch (error) {
ElMessage.error('获取附件列表失败')
}
}
// 添加附件
const handleAddAttachment = () => {
Object.assign(attachmentForm, {
id: null,
competitionId: competitionId.value,
fileName: '',
fileUrl: '',
fileSize: null,
fileType: '',
orderNum: 0,
status: 1
})
fileList.value = []
attachmentDialogVisible.value = true
}
// 编辑附件
const handleEditAttachment = (row) => {
Object.assign(attachmentForm, { ...row })
fileList.value = row.fileUrl ? [{ name: row.fileName, url: row.fileUrl }] : []
attachmentDialogVisible.value = true
}
// 文件上传前校验
const beforeFileUpload = (file) => {
const allowedTypes = ['application/pdf', 'application/msword', 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
'application/vnd.ms-excel', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet']
const isAllowed = allowedTypes.includes(file.type)
const isLt50M = file.size / 1024 / 1024 < 50
if (!isAllowed) {
ElMessage.error('只能上传文档格式文件!')
}
if (!isLt50M) {
ElMessage.error('文件大小不能超过 50MB')
}
return isAllowed && isLt50M
}
// 文件上传成功
const handleFileUploadSuccess = (response) => {
if (response.code === 200) {
attachmentForm.fileUrl = response.data.link
attachmentForm.fileName = response.data.originalName
attachmentForm.fileSize = response.data.size
attachmentForm.fileType = response.data.extension
ElMessage.success('文件上传成功')
} else {
ElMessage.error('文件上传失败')
}
}
// 保存附件
const handleSaveAttachment = async () => {
await attachmentFormRef.value.validate()
try {
await saveAttachment(attachmentForm)
ElMessage.success('保存成功')
attachmentDialogVisible.value = false
fetchAttachmentList()
} catch (error) {
ElMessage.error('保存失败')
}
}
// 删除附件
const handleDeleteAttachment = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该附件吗?', '提示', {
type: 'warning'
})
await removeAttachment({ id: row.id })
ElMessage.success('删除成功')
fetchAttachmentList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 预览文件
const handlePreviewFile = (row) => {
window.open(row.fileUrl, '_blank')
}
// 格式化文件大小
const formatFileSize = (bytes) => {
if (!bytes || bytes === 0) return '0 B'
const k = 1024
const sizes = ['B', 'KB', 'MB', 'GB']
const i = Math.floor(Math.log(bytes) / Math.log(k))
return (bytes / Math.pow(k, i)).toFixed(2) + ' ' + sizes[i]
}
// 获取章节列表
const fetchChapterList = async () => {
try {
const res = await getChapterList({ competitionId: competitionId.value })
chapterList.value = res.data || []
} catch (error) {
ElMessage.error('获取章节列表失败')
}
}
// 添加章节
const handleAddChapter = () => {
Object.assign(chapterForm, {
id: null,
competitionId: competitionId.value,
chapterNumber: '',
title: '',
orderNum: 0,
status: 1
})
chapterDialogVisible.value = true
}
// 编辑章节
const handleEditChapter = (row) => {
Object.assign(chapterForm, { ...row })
chapterDialogVisible.value = true
}
// 保存章节
const handleSaveChapter = async () => {
await chapterFormRef.value.validate()
try {
await saveChapter(chapterForm)
ElMessage.success('保存成功')
chapterDialogVisible.value = false
fetchChapterList()
} catch (error) {
ElMessage.error('保存失败')
}
}
// 删除章节
const handleDeleteChapter = async (row) => {
try {
await ElMessageBox.confirm('确定要删除该章节吗?删除后章节下的所有内容也将被删除!', '提示', {
type: 'warning'
})
await removeChapter({ id: row.id })
ElMessage.success('删除成功')
fetchChapterList()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('删除失败')
}
}
}
// 管理章节内容
const handleManageContent = async (row) => {
currentChapter.value = row
await fetchContentList(row.id)
contentDialogVisible.value = true
}
// 获取章节内容列表
const fetchContentList = async (chapterId) => {
try {
const res = await getContentList({ chapterId })
contentList.value = (res.data || []).map(item => ({
...item,
editing: false
}))
} catch (error) {
ElMessage.error('获取章节内容失败')
}
}
// 添加内容
const handleAddContent = () => {
contentList.value.push({
id: null,
chapterId: currentChapter.value.id,
content: '',
orderNum: contentList.value.length + 1,
status: 1,
editing: true
})
}
// 编辑内容
const handleEditContent = (row, index) => {
row.editing = true
}
// 保存单个内容
const handleSaveContent = (row, index) => {
if (!row.content.trim()) {
ElMessage.warning('内容不能为空')
return
}
row.editing = false
}
// 删除内容
const handleDeleteContent = (index) => {
contentList.value.splice(index, 1)
// 重新排序
contentList.value.forEach((item, idx) => {
item.orderNum = idx + 1
})
}
// 批量保存内容
const handleBatchSaveContents = async () => {
const contents = contentList.value.map(item => item.content).filter(c => c.trim())
if (contents.length === 0) {
ElMessage.warning('请至少添加一条内容')
return
}
try {
await batchSaveContents({
chapterId: currentChapter.value.id,
contents
})
ElMessage.success('保存成功')
contentDialogVisible.value = false
} catch (error) {
ElMessage.error('保存失败')
}
}
</script>
<style lang="scss" scoped>
.rules-container {
padding: 20px;
}
.search-card {
margin-bottom: 20px;
}
.content-wrapper {
display: flex;
flex-direction: column;
gap: 20px;
}
.section-card {
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
.card-title {
font-size: 16px;
font-weight: bold;
}
}
}
.content-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
padding-bottom: 10px;
border-bottom: 1px solid #eee;
.content-title {
font-size: 16px;
font-weight: bold;
}
}
.content-table {
margin-top: 10px;
}
</style>

View File

@@ -6,6 +6,14 @@
<h2 class="page-title">编排</h2> <h2 class="page-title">编排</h2>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button
size="small"
type="primary"
:loading="autoArrangeLoading"
@click="handleAutoArrange"
>
{{ isScheduleCompleted ? '重新编排' : '自动编排' }}
</el-button>
<el-button size="small" type="danger" @click="showExceptionDialog"> <el-button size="small" type="danger" @click="showExceptionDialog">
异常组 <el-badge :value="exceptionList.length" :hidden="exceptionList.length === 0" /> 异常组 <el-badge :value="exceptionList.length" :hidden="exceptionList.length === 0" />
</el-button> </el-button>
@@ -18,6 +26,7 @@
size="small" size="small"
:type="activeTab === 'competition' ? 'primary' : ''" :type="activeTab === 'competition' ? 'primary' : ''"
@click="activeTab = 'competition'" @click="activeTab = 'competition'"
:disabled="isScheduleCompleted"
> >
竞赛分组 竞赛分组
</el-button> </el-button>
@@ -25,6 +34,7 @@
size="small" size="small"
:type="activeTab === 'venue' ? 'primary' : ''" :type="activeTab === 'venue' ? 'primary' : ''"
@click="activeTab = 'venue'" @click="activeTab = 'venue'"
:disabled="isScheduleCompleted"
> >
场地 场地
</el-button> </el-button>
@@ -91,7 +101,7 @@
<el-table-column label="操作" width="150" align="center"> <el-table-column label="操作" width="150" align="center">
<template #default="scope"> <template #default="scope">
<el-button <el-button
type="text" link
size="small" size="small"
@click="handleMoveUp(group, scope.$index)" @click="handleMoveUp(group, scope.$index)"
:disabled="scope.$index === 0 || isScheduleCompleted" :disabled="scope.$index === 0 || isScheduleCompleted"
@@ -101,7 +111,7 @@
<img src="/img/图标 3@3x.png" class="move-icon" alt="上移" /> <img src="/img/图标 3@3x.png" class="move-icon" alt="上移" />
</el-button> </el-button>
<el-button <el-button
type="text" link
size="small" size="small"
@click="handleMoveDown(group, scope.$index)" @click="handleMoveDown(group, scope.$index)"
:disabled="scope.$index === group.items.length - 1 || isScheduleCompleted" :disabled="scope.$index === group.items.length - 1 || isScheduleCompleted"
@@ -112,7 +122,7 @@
</el-button> </el-button>
<el-button <el-button
v-if="(scope.row.status || '未签到') === '未签到'" v-if="(scope.row.status || '未签到') === '未签到'"
type="text" link
size="small" size="small"
@click="markAsException(group, scope.$index)" @click="markAsException(group, scope.$index)"
:disabled="isScheduleCompleted" :disabled="isScheduleCompleted"
@@ -174,23 +184,25 @@
<!-- 确认对话框 --> <!-- 确认对话框 -->
<el-dialog <el-dialog
title="确定完成编排" title="确定完成编排"
:visible.sync="confirmDialogVisible" v-model="confirmDialogVisible"
width="450px" width="450px"
center center
> >
<p style="text-align: center; padding: 20px; color: #606266;"> <p style="text-align: center; padding: 20px; color: #606266;">
完成编排后不可再次调整确定完成编排吗 完成编排后不可再次调整确定完成编排吗
</p> </p>
<span slot="footer" class="dialog-footer"> <template #footer>
<span class="dialog-footer">
<el-button @click="confirmDialogVisible = false">取消</el-button> <el-button @click="confirmDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmComplete">确定</el-button> <el-button type="primary" @click="confirmComplete">确定</el-button>
</span> </span>
</template>
</el-dialog> </el-dialog>
<!-- 移动分组对话框 --> <!-- 移动分组对话框 -->
<el-dialog <el-dialog
title="移动竞赛分组" title="移动竞赛分组"
:visible.sync="moveDialogVisible" v-model="moveDialogVisible"
width="500px" width="500px"
center center
> >
@@ -216,16 +228,18 @@
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<span slot="footer" class="dialog-footer"> <template #footer>
<span class="dialog-footer">
<el-button @click="moveDialogVisible = false">取消</el-button> <el-button @click="moveDialogVisible = false">取消</el-button>
<el-button type="primary" @click="confirmMoveGroup">确定</el-button> <el-button type="primary" @click="confirmMoveGroup">确定</el-button>
</span> </span>
</template>
</el-dialog> </el-dialog>
<!-- 异常组对话框 --> <!-- 异常组对话框 -->
<el-dialog <el-dialog
title="异常组参赛人员" title="异常组参赛人员"
:visible.sync="exceptionDialogVisible" v-model="exceptionDialogVisible"
width="700px" width="700px"
center center
> >
@@ -241,7 +255,7 @@
<el-table-column label="操作" width="100" align="center"> <el-table-column label="操作" width="100" align="center">
<template #default="scope"> <template #default="scope">
<el-button <el-button
type="text" link
size="small" size="small"
@click="removeFromException(scope.$index)" @click="removeFromException(scope.$index)"
style="color: #409eff;" style="color: #409eff;"
@@ -254,9 +268,11 @@
<div v-if="exceptionList.length === 0" style="text-align: center; padding: 40px; color: #909399;"> <div v-if="exceptionList.length === 0" style="text-align: center; padding: 40px; color: #909399;">
暂无异常参赛人员 暂无异常参赛人员
</div> </div>
<span slot="footer" class="dialog-footer"> <template #footer>
<span class="dialog-footer">
<el-button @click="exceptionDialogVisible = false">关闭</el-button> <el-button @click="exceptionDialogVisible = false">关闭</el-button>
</span> </span>
</template>
</el-dialog> </el-dialog>
</div> </div>
</template> </template>
@@ -264,7 +280,7 @@
<script> <script>
import { getVenuesByCompetition } from '@/api/martial/venue' import { getVenuesByCompetition } from '@/api/martial/venue'
import { getCompetitionDetail } from '@/api/martial/competition' import { getCompetitionDetail } from '@/api/martial/competition'
import { getScheduleResult, saveAndLockSchedule, saveDraftSchedule } from '@/api/martial/activitySchedule' import { getScheduleResult, saveAndLockSchedule, saveDraftSchedule, triggerAutoArrange, moveScheduleGroup } from '@/api/martial/activitySchedule'
export default { export default {
name: 'MartialScheduleList', name: 'MartialScheduleList',
@@ -278,6 +294,7 @@ export default {
confirmDialogVisible: false, confirmDialogVisible: false,
isScheduleCompleted: false, // 是否已完成编排 isScheduleCompleted: false, // 是否已完成编排
loading: false, loading: false,
autoArrangeLoading: false, // 自动编排加载状态
venues: [], // 场地列表(从后端加载) venues: [], // 场地列表(从后端加载)
competitionInfo: { competitionInfo: {
competitionName: '', competitionName: '',
@@ -551,7 +568,7 @@ export default {
}, },
// 确认移动分组 // 确认移动分组
confirmMoveGroup() { async confirmMoveGroup() {
if (!this.moveTargetVenueId) { if (!this.moveTargetVenueId) {
this.$message.warning('请选择目标场地') this.$message.warning('请选择目标场地')
return return
@@ -564,6 +581,16 @@ export default {
const group = this.competitionGroups[this.moveGroupIndex] const group = this.competitionGroups[this.moveGroupIndex]
const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId) const targetVenue = this.venues.find(v => v.id === this.moveTargetVenueId)
try {
// 调用后端API移动分组
const res = await moveScheduleGroup({
groupId: group.id,
targetVenueId: this.moveTargetVenueId,
targetTimeSlotIndex: this.moveTargetTimeSlot
})
if (res.data.success) {
// 更新前端数据
group.venueId = this.moveTargetVenueId group.venueId = this.moveTargetVenueId
group.venueName = targetVenue ? targetVenue.venueName : '' group.venueName = targetVenue ? targetVenue.venueName : ''
group.timeSlotIndex = this.moveTargetTimeSlot group.timeSlotIndex = this.moveTargetTimeSlot
@@ -571,6 +598,13 @@ export default {
this.$message.success(`已移动到 ${group.venueName} - ${group.timeSlot}`) this.$message.success(`已移动到 ${group.venueName} - ${group.timeSlot}`)
this.moveDialogVisible = false this.moveDialogVisible = false
} else {
this.$message.error(res.data.msg || '移动分组失败')
}
} catch (error) {
console.error('移动分组失败:', error)
this.$message.error('移动分组失败,请稍后重试')
}
}, },
// 标记为异常 // 标记为异常
@@ -624,6 +658,39 @@ export default {
this.$message.success(`已将 ${exceptionItem.schoolUnit} 从异常组移除`) this.$message.success(`已将 ${exceptionItem.schoolUnit} 从异常组移除`)
}, },
// 触发自动编排
async handleAutoArrange() {
try {
// 确认操作
await this.$confirm('自动编排将重新生成赛程安排,当前编排数据将被覆盖。确定继续吗?', '提示', {
confirmButtonText: '确定',
cancelButtonText: '取消',
type: 'warning'
})
this.autoArrangeLoading = true
// 调用自动编排接口
const res = await triggerAutoArrange(this.competitionId)
if (res.data?.success) {
this.$message.success('自动编排完成')
// 重新加载编排数据
await this.loadScheduleData()
} else {
this.$message.error(res.data?.msg || '自动编排失败')
}
} catch (err) {
if (err !== 'cancel') {
console.error('自动编排失败', err)
this.$message.error('自动编排失败: ' + (err.message || ''))
}
} finally {
this.autoArrangeLoading = false
}
},
// 保存草稿 // 保存草稿
async handleSaveDraft() { async handleSaveDraft() {
try { try {
@@ -691,6 +758,16 @@ export default {
try { try {
this.loading = true this.loading = true
// 检查是否有编排数据
if (!this.competitionGroups || this.competitionGroups.length === 0) {
this.$message.warning('请先进行自动编排,生成编排数据后再完成编排')
this.confirmDialogVisible = false
return
}
console.log('开始完成编排竞赛ID:', this.competitionId)
console.log('编排分组数量:', this.competitionGroups.length)
// 1. 先保存当前的草稿数据 // 1. 先保存当前的草稿数据
const saveData = { const saveData = {
competitionId: this.competitionId, competitionId: this.competitionId,
@@ -714,19 +791,27 @@ export default {
})) }))
} }
await saveDraftSchedule(saveData) console.log('准备保存草稿数据:', saveData)
console.log('保存草稿成功,准备锁定')
const draftRes = await saveDraftSchedule(saveData)
console.log('保存草稿成功,返回:', draftRes)
// 2. 然后调用锁定接口 // 2. 然后调用锁定接口
await saveAndLockSchedule(saveData) console.log('准备锁定编排竞赛ID:', this.competitionId)
const lockRes = await saveAndLockSchedule(this.competitionId)
console.log('锁定接口返回:', lockRes)
// 3. 更新UI状态 // 3. 更新UI状态
this.isScheduleCompleted = true this.isScheduleCompleted = true
this.confirmDialogVisible = false this.confirmDialogVisible = false
this.$message.success('编排已完成并锁定') this.$message.success('编排已完成并锁定')
// 4. 重新加载编排数据以获取最新状态
await this.loadScheduleData()
} catch (err) { } catch (err) {
console.error('完成编排失败', err) console.error('完成编排失败', err)
this.$message.error('完成编排失败: ' + (err.message || '')) console.error('错误详情:', err.response?.data || err.message)
this.$message.error('完成编排失败: ' + (err.response?.data?.msg || err.message || '未知错误'))
} finally { } finally {
this.loading = false this.loading = false
} }

File diff suppressed because it is too large Load Diff

View File

@@ -65,30 +65,30 @@
<i class="card-arrow el-icon-arrow-right"></i> <i class="card-arrow el-icon-arrow-right"></i>
</div> </div>
<div class="access-card" @click="navigateTo('/martial/schedule/list')"> <div class="access-card" @click="navigateTo('/martial/project/list')">
<div class="card-bg"></div>
<div class="card-icon">
<i class="el-icon-menu"></i>
</div>
<div class="card-info">
<h3>项目管理</h3>
<p>武术项目管理</p>
</div>
<i class="card-arrow el-icon-arrow-right"></i>
</div>
<div class="access-card" @click="navigateTo('/martial/schedulePlan/list')">
<div class="card-bg"></div> <div class="card-bg"></div>
<div class="card-icon"> <div class="card-icon">
<i class="el-icon-date"></i> <i class="el-icon-date"></i>
</div> </div>
<div class="card-info"> <div class="card-info">
<h3>赛程编排</h3> <h3>赛程计划</h3>
<p>比赛赛程安排</p> <p>比赛赛程安排</p>
</div> </div>
<i class="card-arrow el-icon-arrow-right"></i> <i class="card-arrow el-icon-arrow-right"></i>
</div> </div>
<div class="access-card" @click="navigateTo('/martial/dispatch/list')">
<div class="card-bg"></div>
<div class="card-icon">
<i class="el-icon-s-promotion"></i>
</div>
<div class="card-info">
<h3>赛事调度</h3>
<p>实时进度跟踪</p>
</div>
<i class="card-arrow el-icon-arrow-right"></i>
</div>
<div class="access-card" @click="navigateTo('/martial/referee/list')"> <div class="access-card" @click="navigateTo('/martial/referee/list')">
<div class="card-bg"></div> <div class="card-bg"></div>
<div class="card-icon"> <div class="card-icon">
@@ -112,6 +112,18 @@
</div> </div>
<i class="card-arrow el-icon-arrow-right"></i> <i class="card-arrow el-icon-arrow-right"></i>
</div> </div>
<div class="access-card" @click="navigateTo('/martial/result/list')">
<div class="card-bg"></div>
<div class="card-icon">
<i class="el-icon-trophy"></i>
</div>
<div class="card-info">
<h3>成绩管理</h3>
<p>比赛成绩查询</p>
</div>
<i class="card-arrow el-icon-arrow-right"></i>
</div>
</div> </div>
</div> </div>