Java全栈项目-田径运动会管理系统
项目介绍
本项目是一个基于Java全栈技术开发的田径运动会管理系统,旨在为学校、体育场馆提供一个完整的运动会管理解决方案。系统采用前后端分离架构,实现了运动员管理、比赛项目管理、成绩录入、数据统计等核心功能。
技术栈
后端技术
- Spring Boot 2.7.0:核心框架
- Spring Security:安全框架
- MyBatis-Plus:ORM框架
- MySQL 8.0:数据库
- Redis:缓存服务
- JWT:身份认证
前端技术
- Vue 3:前端框架
- Element Plus:UI组件库
- Axios:HTTP客户端
- Vuex:状态管理
- Vue Router:路由管理
核心功能
运动员管理
- 运动员信息录入与维护
- 参赛项目分配
- 号码簿生成
- 运动员成绩查询
比赛项目管理
- 项目设置与编排
- 赛程安排
- 场地分配
- 裁判员分配
成绩管理
- 实时成绩录入
- 成绩审核
- 成绩公示
- 破纪录提醒
数据统计
- 团体总分排名
- 单项成绩排名
- 奖牌榜统计
- 数据可视化展示
项目特点
-
实时数据更新
- 采用WebSocket技术实现成绩实时推送
- Redis缓存确保高并发场景下的数据一致性
-
高度可配置
- 灵活的项目设置
- 可自定义计分规则
- 支持多种赛制
-
安全性
- 基于RBAC的权限管理
- 操作日志记录
- 数据备份恢复
-
用户体验
- 响应式设计
- 多终端适配
- 直观的数据展示
项目部署
环境要求
- JDK 1.8+
- Maven 3.6+
- MySQL 8.0+
- Redis 6.0+
- Node.js 14+
部署步骤
- 后端部署
# 克隆项目
git clone https://github.com/username/sports-meet-manager.git
# 进入项目目录
cd sports-meet-manager
# 编译打包
mvn clean package
# 运行项目
java -jar target/sports-meet-manager.jar
- 前端部署
# 进入前端项目目录
cd web
# 安装依赖
npm install
# 编译打包
npm run build
# 部署到nginx
cp -r dist/* /usr/share/nginx/html/
项目展望
-
功能扩展
- 引入AI识别技术,实现自动计时
- 增加移动端应用
- 支持更多类型的运动会
-
性能优化
- 引入分布式架构
- 优化数据处理算法
- 提升系统并发能力
-
用户体验提升
- 完善数据可视化
- 优化操作流程
- 增加个性化配置
总结
本项目采用主流的Java全栈技术栈,实现了一个功能完善、性能稳定的运动会管理系统。通过前后端分离的架构设计,确保了系统的可维护性和扩展性。项目在实际应用中取得了良好的效果,为运动会的组织和管理提供了有力的技术支持。
运动会管理系统模块
1. 数据库设计
1.1 运动员管理相关表
-- 运动员基本信息表
CREATE TABLE t_athlete (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender TINYINT NOT NULL COMMENT '性别:0-女,1-男',
birth_date DATE NOT NULL COMMENT '出生日期',
id_card VARCHAR(18) UNIQUE COMMENT '身份证号',
team_id BIGINT NOT NULL COMMENT '代表队ID',
phone VARCHAR(20) COMMENT '联系电话',
photo_url VARCHAR(255) COMMENT '照片URL',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES t_team(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动员信息表';
-- 代表队表
CREATE TABLE t_team (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '代表队名称',
leader_name VARCHAR(50) COMMENT '领队姓名',
phone VARCHAR(20) COMMENT '联系电话',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='代表队表';
-- 运动员参赛项目关联表
CREATE TABLE t_athlete_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
athlete_id BIGINT NOT NULL COMMENT '运动员ID',
event_id BIGINT NOT NULL COMMENT '比赛项目ID',
bib_number VARCHAR(20) NOT NULL COMMENT '号码簿编号',
track_number INT COMMENT '道次号',
group_number VARCHAR(20) COMMENT '分组编号',
status TINYINT DEFAULT 1 COMMENT '状态:0-弃权,1-正常',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (athlete_id) REFERENCES t_athlete(id),
FOREIGN KEY (event_id) REFERENCES t_event(id),
UNIQUE KEY uk_athlete_event (athlete_id, event_id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动员参赛项目关联表';
-- 运动员成绩表
CREATE TABLE t_athlete_result (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
athlete_event_id BIGINT NOT NULL COMMENT '运动员参赛关联ID',
result VARCHAR(20) NOT NULL COMMENT '成绩',
ranking INT COMMENT '名次',
is_broken_record TINYINT DEFAULT 0 COMMENT '是否破记录:0-否,1-是',
status TINYINT DEFAULT 1 COMMENT '状态:0-无效,1-有效',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (athlete_event_id) REFERENCES t_athlete_event(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='运动员成绩表';
1.2 比赛项目管理相关表
-- 比赛项目表
CREATE TABLE t_event (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '项目名称',
type TINYINT NOT NULL COMMENT '项目类型:1-田赛,2-径赛',
gender_limit TINYINT NOT NULL COMMENT '性别限制:0-女,1-男',
age_min INT COMMENT '最小年龄限制',
age_max INT COMMENT '最大年龄限制',
max_participants INT COMMENT '最大参赛人数',
current_record VARCHAR(20) COMMENT '当前记录',
record_holder VARCHAR(50) COMMENT '记录保持者',
status TINYINT DEFAULT 1 COMMENT '状态:0-关闭,1-开放',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='比赛项目表';
-- 赛程表
CREATE TABLE t_schedule (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id BIGINT NOT NULL COMMENT '比赛项目ID',
venue_id BIGINT NOT NULL COMMENT '场地ID',
start_time DATETIME NOT NULL COMMENT '开始时间',
end_time DATETIME NOT NULL COMMENT '结束时间',
round_type TINYINT NOT NULL COMMENT '轮次:1-预赛,2-决赛',
status TINYINT DEFAULT 1 COMMENT '状态:0-取消,1-正常',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES t_event(id),
FOREIGN KEY (venue_id) REFERENCES t_venue(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='赛程表';
-- 场地表
CREATE TABLE t_venue (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(100) NOT NULL COMMENT '场地名称',
type TINYINT NOT NULL COMMENT '场地类型:1-田赛场地,2-径赛场地',
location VARCHAR(255) COMMENT '位置描述',
status TINYINT DEFAULT 1 COMMENT '状态:0-维护中,1-可用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='场地表';
-- 裁判员表
CREATE TABLE t_referee (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
name VARCHAR(50) NOT NULL COMMENT '姓名',
gender TINYINT NOT NULL COMMENT '性别:0-女,1-男',
phone VARCHAR(20) COMMENT '联系电话',
level VARCHAR(20) COMMENT '裁判等级',
status TINYINT DEFAULT 1 COMMENT '状态:0-禁用,1-启用',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='裁判员表';
-- 裁判员分配表
CREATE TABLE t_referee_assignment (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
referee_id BIGINT NOT NULL COMMENT '裁判员ID',
schedule_id BIGINT NOT NULL COMMENT '赛程ID',
role_type TINYINT NOT NULL COMMENT '角色:1-主裁判,2-副裁判,3-检录员',
status TINYINT DEFAULT 1 COMMENT '状态:0-取消,1-正常',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (referee_id) REFERENCES t_referee(id),
FOREIGN KEY (schedule_id) REFERENCES t_schedule(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='裁判员分配表';
2. 后端实现
2.1 项目结构
src/main/java/com/sports
├── common
│ ├── config
│ ├── exception
│ └── util
├── controller
├── service
├── mapper
└── model
├── entity
├── dto
└── vo
2.2 核心代码实现
2.2.1 运动员管理
// AthleteController.java
@RestController
@RequestMapping("/api/athletes")
@Slf4j
public class AthleteController {
@Autowired
private AthleteService athleteService;
@PostMapping
public ResponseEntity<AthleteVO> createAthlete(@RequestBody @Valid AthleteDTO athleteDTO) {
return ResponseEntity.ok(athleteService.createAthlete(athleteDTO));
}
#### 3.2.2 比赛项目管理页面
```vue
<!-- views/event/EventList.vue -->
<template>
<div class="event-list">
<div class="header">
<h1>比赛项目管理</h1>
<el-button type="primary" @click="showCreateDialog">新增项目</el-button>
</div>
<!-- 搜索表单 -->
<el-form :model="queryForm" :inline="true" class="search-form">
<el-form-item label="项目名称">
<el-input v-model="queryForm.name" placeholder="请输入项目名称"></el-input>
</el-form-item>
<el-form-item label="项目类型">
<el-select v-model="queryForm.type" placeholder="请选择项目类型">
<el-option label="田赛" :value="1"></el-option>
<el-option label="径赛" :value="2"></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
<!-- 项目列表 -->
<el-table :data="events" border stripe>
<el-table-column prop="name" label="项目名称"></el-table-column>
<el-table-column prop="type" label="项目类型">
@PutMapping("/{id}")
public ResponseEntity<AthleteVO> updateAthlete(@PathVariable Long id,
@RequestBody @Valid AthleteDTO athleteDTO) {
return ResponseEntity.ok(athleteService.updateAthlete(id, athleteDTO));
}
@PostMapping("/batch")
public ResponseEntity<List<AthleteVO>> batchImport(@RequestParam("file") MultipartFile file) {
return ResponseEntity.ok(athleteService.batchImport(file));
}
@GetMapping
public ResponseEntity<Page<AthleteVO>> getAthletes(AthleteQueryDTO queryDTO,
@PageableDefault Pageable pageable) {
return ResponseEntity.ok(athleteService.getAthletes(queryDTO, pageable));
}
}
// AthleteServiceImpl.java
@Service
@Slf4j
public class AthleteServiceImpl implements AthleteService {
@Autowired
private AthleteMapper athleteMapper;
@Autowired
private FileService fileService;
@Override
@Transactional
public AthleteVO createAthlete(AthleteDTO athleteDTO) {
// 验证身份证号
validateIdCard(athleteDTO.getIdCard());
// 上传照片
String photoUrl = fileService.uploadPhoto(athleteDTO.getPhoto());
// 保存运动员信息
Athlete athlete = convertToEntity(athleteDTO);
athlete.setPhotoUrl(photoUrl);
athleteMapper.insert(athlete);
return convertToVO(athlete);
}
@Override
@Transactional(readOnly = true)
public Page<AthleteVO> getAthletes(AthleteQueryDTO queryDTO, Pageable pageable) {
Page<Athlete> athletePage = athleteMapper.selectPage(queryDTO, pageable);
return athletePage.map(this::convertToVO);
}
}
2.2.2 比赛项目管理
// EventController.java
@RestController
@RequestMapping("/api/events")
@Slf4j
public class EventController {
@Autowired
private EventService eventService;
@PostMapping
public ResponseEntity<EventVO> createEvent(@RequestBody @Valid EventDTO eventDTO) {
return ResponseEntity.ok(eventService.createEvent(eventDTO));
}
@PostMapping("/{eventId}/schedule")
public ResponseEntity<ScheduleVO> createSchedule(@PathVariable Long eventId,
@RequestBody @Valid ScheduleDTO scheduleDTO) {
return ResponseEntity.ok(eventService.createSchedule(eventId, scheduleDTO));
}
@PostMapping("/{eventId}/referee-assignments")
public ResponseEntity<List<RefereeAssignmentVO>> assignReferees(
@PathVariable Long eventId,
@RequestBody @Valid List<RefereeAssignmentDTO> assignments) {
return ResponseEntity.ok(eventService.assignReferees(eventId, assignments));
}
}
// EventServiceImpl.java
@Service
@Slf4j
public class EventServiceImpl implements EventService {
@Autowired
private EventMapper eventMapper;
@Autowired
private ScheduleMapper scheduleMapper;
@Override
@Transactional
public EventVO createEvent(EventDTO eventDTO) {
// 验证项目信息
validateEventInfo(eventDTO);
// 保存项目信息
Event event = convertToEntity(eventDTO);
eventMapper.insert(event);
return convertToVO(event);
}
@Override
@Transactional
public ScheduleVO createSchedule(Long eventId, ScheduleDTO scheduleDTO) {
// 检查时间冲突
checkTimeConflict(scheduleDTO);
// 检查场地可用性
checkVenueAvailability(scheduleDTO.getVenueId(), scheduleDTO.getStartTime(),
scheduleDTO.getEndTime());
// 保存赛程信息
Schedule schedule = convertToEntity(scheduleDTO);
schedule.setEventId(eventId);
scheduleMapper.insert(schedule);
return convertToVO(schedule);
}
}
3. 前端实现
3.1 项目结构
src
├── api
├── assets
├── components
├── layouts
├── router
├── store
└── views
├── athlete
└── event
3.2 核心代码实现
3.2.1 运动员管理页面
<!-- views/athlete/AthleteList.vue -->
<template>
<div class="athlete-list">
<div class="header">
<h1>运动员管理</h1>
<div class="actions">
<el-button type="primary" @click="showCreateDialog">新增运动员</el-button>
<el-upload
class="upload-btn"
action="/api/athletes/batch"
:on-success="handleUploadSuccess"
>
<el-button type="primary">批量导入</el-button>
</el-upload>
</div>
</div>
<!-- 搜索表单 -->
<el-form :model="queryForm" :inline="true" class="search-form">
<el-form-item label="姓名">
<el-input v-model="queryForm.name" placeholder="请输入姓名"></el-input>
</el-form-item>
<el-form-item label="代表队">
<el-select v-model="queryForm.teamId" placeholder="请选择代表队">
<el-option
v-for="team in teams"
:key="team.id"
:label="team.name"
:value="team.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" @click="handleSearch">查询</el-button>
<el-button @click="resetForm">重置</el-button>
</el-form-item>
</el-form>
<!-- 运动员列表 -->
<el-table :data="athletes" border stripe>
<el-table-column prop="name" label="姓名"></el-table-column>
<el-table-column prop="gender" label="性别">
<template #default="scope">
{{ scope.row.gender === 1 ? '男' : '女' }}
</template>
</el-table-column>
<el-table-column prop="birthDate" label="出生日期"></el-table-column>
<el-table-column prop="teamName" label="代表队"></el-table-column>
<el-table-column prop="phone" label="联系电话"></el-table-column>
<el-table-column label="照片">
<template #default="scope">
<el-image
:src="scope.row.photoUrl"
:preview-src-list="[scope.row.photoUrl]"
fit="cover"
class="athlete-photo"
></el-image>
</template>
</el-table-column>
<el-table-column label="操作" width="250">
<template #default="scope">
<el-button type="primary" size="small" @click="showEditDialog(scope.row)">
编辑
</el-button>
<el-button type="success" size="small" @click="showAssignEvents(scope.row)">
分配项目
</el-button>
<el-button type="danger" size="small" @click="handleDelete(scope.row)">
删除
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 分页器 -->
<el-pagination
:current-page="page.current"
:page-size="page.size"
:total="page.total"
@current-change="handlePageChange"
layout="total, prev, pager, next"
></el-pagination>
<!-- 新增/编辑对话框 -->
<el-dialog
:title="dialogType === 'create' ? '新增运动员' : '编辑运动员'"
v-model="dialogVisible"
width="600px"
>
<el-form
ref="athleteForm"
:model="athleteForm"
:rules="rules"
label-width="100px"
>
<el-form-item label="姓名" prop="name">
<el-input v-model="athleteForm.name"></el-input>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="athleteForm.gender">
<el-radio :label="1">男</el-radio>
<el-radio :label="0">女</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="出生日期" prop="birthDate">
<el-date-picker
v-model="athleteForm.birthDate"
type="date"
placeholder="选择日期"
></el-date-picker>
</el-form-item>
<el-form-item label="身份证号" prop="idCard">
<el-input v-model="athleteForm.idCard"></el-input>
</el-form-item>
<el-form-item label="代表队" prop="teamId">
<el-select v-model="athleteForm.teamId" placeholder="请选择代表队">
<el-option
v-for="team in teams"
:key="team.id"
:label="team.name"
:value="team.id"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="联系电话" prop="phone">
<el-input v-model="athleteForm.phone"></el-input>
</el-form-item>
<el-form-item label="照片" prop="photo">
<el-upload
class="avatar-uploader"
action="/api/upload"
:show-file-list="false"
:before-upload="beforePhotoUpload"
:on-success="handlePhotoSuccess"
>
<img v-if="athleteForm.photoUrl" :src="athleteForm.photoUrl" class="avatar">
<el-icon v-else class="avatar-uploader-icon"><Plus /></el-icon>
</el-upload>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="dialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleSubmit">确定</el-button>
</template>
</el-dialog>
<!-- 项目分配对话框 -->
<el-dialog
title="分配比赛项目"
v-model="eventDialogVisible"
width="800px"
>
<el-transfer
v-model="selectedEvents"
:data="allEvents"
:titles="['可选项目', '已选项目']"
:props="{
key: 'id',
label: 'name'
}"
></el-transfer>
<template #footer>
<el-button @click="eventDialogVisible = false">取消</el-button>
<el-button type="primary" @click="handleEventAssign">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, onMounted, reactive } from 'vue'
import { getAthletes, createAthlete, updateAthlete, deleteAthlete } from '@/api/athlete'
import { getTeams } from '@/api/team'
import { getEvents, assignEvents } from '@/api/event'
import { ElMessage } from 'element-plus'
// 数据定义
const athletes = ref([])
const teams = ref([])
const allEvents = ref([])
const page = reactive({
current: 1,
size: 10,
total: 0
})
const queryForm = reactive({
name: '',
teamId: ''
})
const athleteForm = reactive({
name: '',
gender: 1,
birthDate: '',
idCard: '',
teamId: '',
phone: '',
photoUrl: ''
})
// 校验规则
const rules = {
name: [{ required: true, message: '请输入姓名', trigger: 'blur' }],
gender: [{ required: true, message: '请选择性别', trigger: 'change' }],
birthDate: [{ required: true, message: '请选择出生日期', trigger: 'change' }],
idCard: [
{ required: true, message: '请输入身份证号', trigger: 'blur' },
{ pattern: /(^\d{15}$)|(^\d{18}$)|(^\d{17}(\d|X|x)$)/, message: '请输入正确的身份证号', trigger: 'blur' }
],
teamId: [{ required: true, message: '请选择代表队', trigger: 'change' }],
phone: [{ pattern: /^1[3-9]\d{9}$/, message: '请输入正确的手机号', trigger: 'blur' }]
}
// 对话框控制
const dialogVisible = ref(false)
const dialogType = ref('create')
const eventDialogVisible = ref(false)
const selectedEvents = ref([])
const currentAthlete = ref(null)
// 初始化数据
onMounted(async () => {
await Promise.all([
loadAthletes(),
loadTeams(),
loadEvents()
])
})
// 方法定义
const loadAthletes = async () => {
try {
const res = await getAthletes({
...queryForm,
page: page.current,
size: page.size
})
athletes.value = res.data.records
page.total = res.data.total
} catch (error) {
console.error('加载运动员列表失败:', error)
ElMessage.error('加载运动员列表失败')
}
}
const loadTeams = async () => {
try {
const res = await getTeams()
teams.value = res.data
} catch (error) {
console.error('加载代表队列表失败:', error)
ElMessage.error('加载代表队列表失败')
}
}
const loadEvents = async () => {
try {
const res = await getEvents()
allEvents.value = res.data
} catch (error) {
console.error('加载比赛项目失败:', error)
ElMessage.error('加载比赛项目失败')
}
}
// 处理搜索
const handleSearch = () => {
page.current = 1
loadAthletes()
}
// 重置表单
const resetForm = () => {
queryForm.name = ''
queryForm.teamId = ''
handleSearch()
}
// 显示新增对话框
const showCreateDialog = () => {
dialogType.value = 'create'
Object.keys(athleteForm).forEach(key => {
athleteForm[key] = ''
})
dialogVisible.value = true
}
// 显示编辑对话框
const showEditDialog = (row) => {
dialogType.value = 'edit'
Object.assign(athleteForm, row)
dialogVisible.value = true
}
// 显示项目分配对话框
const showAssignEvents = async (row) => {
currentAthlete.value = row
selectedEvents.value = row.eventIds || []
eventDialogVisible.value = true
}
// 提交表单
const handleSubmit = async () => {
try {
if (dialogType.value === 'create') {
await createAthlete(athleteForm)
ElMessage.success('新增运动员成功')
} else {
await updateAthlete(currentAthlete.value.id, athleteForm)
ElMessage.success('更新运动员成功')
}
dialogVisible.value = false
loadAthletes()
} catch (error) {
console.error('保存运动员失败:', error)
ElMessage.error('保存运动员失败')
}
}
// 处理删除
const handleDelete = async (row) => {
try {
await ElMessageBox.confirm('确认删除该运动员?', '提示', {
type: 'warning'
})
await deleteAthlete(row.id)
ElMessage.success('删除成功')
loadAthletes()
} catch (error) {
if (error !== 'cancel') {
console.error('删除运动员失败:', error)
ElMessage.error('删除运动员失败')
}
}
}
// 处理项目分配
const handleEventAssign = async () => {
try {
await assignEvents(currentAthlete.value.id, selectedEvents.value)
ElMessage.success('项目分配成功')
eventDialogVisible.value = false
loadAthletes()
} catch (error) {
console.error('项目分配失败:', error)
ElMessage.error('项目分配失败')
}
}
</script>
<style scoped>
.athlete-list {
padding: 20px;
}
.header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.search-form {
margin-bottom: 20px;
}
.athlete-photo {
width: 60px;
height: 60px;
border-radius: 4px;
}
.avatar-uploader {
width: 148px;
height: 148px;
border: 1px dashed #d9d9d9;
border-radius: 6px;
cursor: pointer;
position: relative;
overflow: hidden;
}
.avatar-uploader:hover {
border-color: #409EFF;
}
.avatar-uploader-icon {
font-size: 28px;
color: #8c939d;
width: 148px;
height: 148px;
text-align: center;
line-height: 148px;
}
.avatar {
width: 148px;
height: 148px;
display: block;
}
成绩管理与数据统计模块
1. 数据库设计
1.1 成绩管理相关表
-- 成绩记录表
CREATE TABLE t_score (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
athlete_event_id BIGINT NOT NULL COMMENT '运动员参赛ID',
round_type TINYINT NOT NULL COMMENT '轮次:1-预赛,2-决赛',
score VARCHAR(20) NOT NULL COMMENT '成绩',
score_status TINYINT DEFAULT 0 COMMENT '成绩状态:0-待审核,1-已审核,2-已公示',
is_broken_record TINYINT DEFAULT 0 COMMENT '是否破记录:0-否,1-是',
recorder_id BIGINT NOT NULL COMMENT '记录员ID',
auditor_id BIGINT COMMENT '审核员ID',
audit_time DATETIME COMMENT '审核时间',
audit_remark VARCHAR(255) COMMENT '审核备注',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (athlete_event_id) REFERENCES t_athlete_event(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='成绩记录表';
-- 破纪录记录表
CREATE TABLE t_record_breaking (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id BIGINT NOT NULL COMMENT '比赛项目ID',
athlete_id BIGINT NOT NULL COMMENT '运动员ID',
old_record VARCHAR(20) COMMENT '原记录',
new_record VARCHAR(20) NOT NULL COMMENT '新记录',
breaking_time DATETIME NOT NULL COMMENT '破记录时间',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES t_event(id),
FOREIGN KEY (athlete_id) REFERENCES t_athlete(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='破纪录记录表';
1.2 数据统计相关表
-- 团体总分表
CREATE TABLE t_team_score (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
team_id BIGINT NOT NULL COMMENT '代表队ID',
total_score DECIMAL(10,2) NOT NULL DEFAULT 0 COMMENT '总分',
gold_count INT NOT NULL DEFAULT 0 COMMENT '金牌数',
silver_count INT NOT NULL DEFAULT 0 COMMENT '银牌数',
bronze_count INT NOT NULL DEFAULT 0 COMMENT '铜牌数',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
FOREIGN KEY (team_id) REFERENCES t_team(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='团体总分表';
-- 单项排名表
CREATE TABLE t_event_ranking (
id BIGINT PRIMARY KEY AUTO_INCREMENT,
event_id BIGINT NOT NULL COMMENT '比赛项目ID',
athlete_id BIGINT NOT NULL COMMENT '运动员ID',
rank_num INT NOT NULL COMMENT '名次',
score VARCHAR(20) NOT NULL COMMENT '成绩',
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (event_id) REFERENCES t_event(id),
FOREIGN KEY (athlete_id) REFERENCES t_athlete(id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='单项排名表';
2. 后端实现
2.1 成绩录入与管理
// ScoreController.java
@RestController
@RequestMapping("/api/scores")
@Slf4j
public class ScoreController {
@Autowired
private ScoreService scoreService;
@PostMapping
public ResponseEntity<ScoreVO> createScore(@RequestBody @Valid ScoreDTO scoreDTO) {
return ResponseEntity.ok(scoreService.createScore(scoreDTO));
}
@PutMapping("/{id}/audit")
public ResponseEntity<ScoreVO> auditScore(@PathVariable Long id,
@RequestBody @Valid ScoreAuditDTO auditDTO) {
return ResponseEntity.ok(scoreService.auditScore(id, auditDTO));
}
@GetMapping("/records")
public ResponseEntity<List<RecordBreakingVO>> getRecordBreakings() {
return ResponseEntity.ok(scoreService.getRecordBreakings());
}
}
// ScoreServiceImpl.java
@Service
@Slf4j
public class ScoreServiceImpl implements ScoreService {
@Autowired
private ScoreMapper scoreMapper;
@Autowired
private EventMapper eventMapper;
@Autowired
private WebSocketService webSocketService;
@Override
@Transactional
public ScoreVO createScore(ScoreDTO scoreDTO) {
// 验证成绩格式
validateScore(scoreDTO.getScore(), scoreDTO.getEventId());
// 检查是否破记录
boolean isBrokenRecord = checkRecordBreaking(scoreDTO);
// 保存成绩
Score score = convertToEntity(scoreDTO);
score.setIsBrokenRecord(isBrokenRecord);
scoreMapper.insert(score);
// 如果破记录,发送通知
if (isBrokenRecord) {
sendRecordBreakingNotification(score);
}
// 实时推送成绩更新
webSocketService.broadcastScore(convertToVO(score));
return convertToVO(score);
}
@Override
@Transactional
public ScoreVO auditScore(Long id, ScoreAuditDTO auditDTO) {
Score score = scoreMapper.selectById(id);
if (score == null) {
throw new NotFoundException("成绩记录不存在");
}
// 更新审核状态
score.setScoreStatus(auditDTO.getStatus());
score.setAuditorId(auditDTO.getAuditorId());
score.setAuditTime(LocalDateTime.now());
score.setAuditRemark(auditDTO.getRemark());
scoreMapper.updateById(score);
// 如果审核通过,更新排名和团体分
if (auditDTO.getStatus() == 1) {
updateRankingAndTeamScore(score);
}
return convertToVO(score);
}
}
2.2 数据统计服务
// StatisticsController.java
@RestController
@RequestMapping("/api/statistics")
@Slf4j
public class StatisticsController {
@Autowired
private StatisticsService statisticsService;
@GetMapping("/team-scores")
public ResponseEntity<List<TeamScoreVO>> getTeamScores() {
return ResponseEntity.ok(statisticsService.getTeamScores());
}
@GetMapping("/event-rankings/{eventId}")
public ResponseEntity<List<EventRankingVO>> getEventRankings(@PathVariable Long eventId) {
return ResponseEntity.ok(statisticsService.getEventRankings(eventId));
}
@GetMapping("/medal-statistics")
public ResponseEntity<List<MedalStatisticsVO>> getMedalStatistics() {
return ResponseEntity.ok(statisticsService.getMedalStatistics());
}
}
// StatisticsServiceImpl.java
@Service
@Slf4j
public class StatisticsServiceImpl implements StatisticsService {
@Autowired
private TeamScoreMapper teamScoreMapper;
@Autowired
private EventRankingMapper eventRankingMapper;
@Override
@Transactional(readOnly = true)
public List<TeamScoreVO> getTeamScores() {
List<TeamScore> teamScores = teamScoreMapper.selectList(null);
return teamScores.stream()
.map(this::convertToVO)
.sorted(Comparator.comparing(TeamScoreVO::getTotalScore).reversed())
.collect(Collectors.toList());
}
@Override
@Transactional(readOnly = true)
public List<EventRankingVO> getEventRankings(Long eventId) {
return eventRankingMapper.selectRankingsByEventId(eventId);
}
}
3. 前端实现
3.1 成绩录入组件
<!-- views/score/ScoreInput.vue -->
<template>
<div class="score-input">
<div class="input-header">
<h2>{{ event.name }} - 成绩录入</h2>
<div class="round-selector">
<el-radio-group v-model="roundType">
<el-radio :label="1">预赛</el-radio>
<el-radio :label="2">决赛</el-radio>
</el-radio-group>
</div>
</div>
<el-table :data="athletes" border>
<el-table-column prop="name" label="运动员"></el-table-column>
<el-table-column prop="teamName" label="代表队"></el-table-column>
<el-table-column prop="trackNumber" label="道次"></el-table-column>
<el-table-column label="成绩">
<template #default="scope">
<el-input
v-model="scope.row.score"
:placeholder="getScorePlaceholder(event.type)"
@blur="validateScore(scope.row)"
></el-input>
</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="scope">
<el-tag :type="getStatusType(scope.row.status)">
{{ getStatusText(scope.row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="200">
<template #default="scope">
<el-button
v-if="scope.row.status === 0"
type="success"
size="small"
@click="handleAudit(scope.row, 1)"
>
通过
</el-button>
<el-button
v-if="scope.row.status === 0"
type="danger"
size="small"
@click="handleAudit(scope.row, -1)"
>
驳回
</el-button>
<el-button
v-if="scope.row.status === 1"
type="primary"
size="small"
@click="handlePublish(scope.row)"
>
公示
</el-button>
</template>
</el-table-column>
</el-table>
<!-- 审核对话框 -->
<el-dialog
:title="auditType === 1 ? '通过审核' : '驳回审核'"
v-model="auditDialogVisible"
width="500px"
>
<el-form ref="auditForm" :model="auditForm" :rules="auditRules">
<el-form-item label="审核意见" prop="remark">
<el-input
type="textarea"
v-model="auditForm.remark"
:rows="4"
placeholder="请输入审核意见"
></el-input>
</el-form-item>
</el-form>
<template #footer>
<el-button @click="auditDialogVisible = false">取消</el-button>
<el-button type="primary" @click="submitAudit">确定</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import { getScores, auditScore, publishScore } from '@/api/score'
import { getEvents } from '@/api/event'
import { ElMessage, ElMessageBox } from 'element-plus'
// 数据定义
const scores = ref([])
const events = ref([])
const auditDialogVisible = ref(false)
const auditType = ref(1)
const currentScore = ref(null)
const queryForm = reactive({
eventId: '',
status: 0
})
const auditForm = reactive({
remark: ''
})
const auditRules = {
remark: [
{ required: true, message: '请输入审核意见', trigger: 'blur' },
{ min: 5, message: '审核意见至少5个字符', trigger: 'blur' }
]
}
// 初始化数据
onMounted(async () => {
await Promise.all([
loadEvents(),
loadScores()
])
})
// 加载项目列表
const loadEvents = async () => {
try {
const res = await getEvents()
events.value = res.data
} catch (error) {
ElMessage.error('加载项目列表失败')
}
}
// 加载成绩列表
const loadScores = async () => {
try {
const res = await getScores(queryForm)
scores.value = res.data
} catch (error) {
ElMessage.error('加载成绩列表失败')
}
}
// 处理审核
const handleAudit = (score, type) => {
currentScore.value = score
auditType.value = type
auditForm.remark = ''
auditDialogVisible.value = true
}
// 提交审核
const submitAudit = async () => {
try {
const auditData = {
status: auditType.value === 1 ? 1 : 0,
remark: auditForm.remark
}
await auditScore(currentScore.value.id, auditData)
ElMessage.success('审核操作成功')
auditDialogVisible.value = false
loadScores()
} catch (error) {
ElMessage.error('审核操作失败')
}
}
// 处理公示
const handlePublish = async (score) => {
try {
await ElMessageBox.confirm('确认公示该成绩?', '提示', {
type: 'warning'
})
await publishScore(score.id)
ElMessage.success('成绩公示成功')
loadScores()
} catch (error) {
if (error !== 'cancel') {
ElMessage.error('成绩公示失败')
}
}
}
// 状态转换
const getStatusType = (status) => {
const statusMap = {
0: 'warning',
1: 'success',
2: 'info'
}
return statusMap[status]
}
const getStatusText = (status) => {
const statusMap = {
0: '待审核',
1: '已审核',
2: '已公示'
}
return statusMap[status]
}
</script>
<style scoped>
.score-audit {
padding: 20px;
}
.filter-section {
margin-bottom: 20px;
}
</style>
3.3 数据统计组件
<!-- views/statistics/Dashboard.vue -->
<template>
<div class="dashboard">
<el-row :gutter="20">
<!-- 团体总分排名 -->
<el-col :span="12">
<el-card class="rank-card">
<template #header>
<div class="card-header">
<span>团体总分排名</span>
<el-button type="primary" size="small" @click="exportTeamScores">
导出
</el-button>
</div>
</template>
<div class="chart-container">
<bar-chart :data="teamScores" />
</div>
</el-card>
</el-col>
<!-- 奖牌榜 -->
<el-col :span="12">
<el-card class="rank-card">
<template #header>
<div class="card-header">
<span>奖牌榜</span>
</div>
</template>
<div class="chart-container">
<stacked-bar-chart :data="medalStatistics" />
</div>
</el-card>
</el-col>
</el-row>
<!-- 单项成绩排名 -->
<el-card class="rank-card mt-20">
<template #header>
<div class="card-header">
<span>单项成绩排名</span>
<el-select v-model="selectedEvent" placeholder="请选择比赛项目">
<el-option
v-for="event in events"
:key="event.id"
:label="event.name"
:value="event.id"
></el-option>
</el-select>
</div>
</template>
<el-table :data="eventRankings" border stripe>
<el-table-column type="index" label="名次" width="80"></el-table-column>
<el-table-column prop="athleteName" label="运动员"></el-table-column>
<el-table-column prop="teamName" label="代表队"></el-table-column>
<el-table-column prop="score" label="成绩"></el-table-column>
<el-table-column prop="isBrokenRecord" label="破记录" width="100">
<template #default="scope">
<el-tag v-if="scope.row.isBrokenRecord" type="success">破记录</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 破记录提醒 -->
<el-card class="rank-card mt-20">
<template #header>
<div class="card-header">
<span>破记录记录</span>
</div>
</template>
<el-timeline>
<el-timeline-item
v-for="record in recordBreakings"
:key="record.id"
:timestamp="record.breakingTime"
type="success"
>
<h4>{{ record.eventName }}</h4>
<p>
运动员 {{ record.athleteName }} ({{ record.teamName }})
以 {{ record.newRecord }} 的成绩打破原记录 {{ record.oldRecord }}
</p>
</el-timeline-item>
</el-timeline>
</el-card>
</div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import { getTeamScores, getMedalStatistics, getEventRankings, getRecordBreakings } from '@/api/statistics'
import { getEvents } from '@/api/event'
import BarChart from '@/components/charts/BarChart.vue'
import StackedBarChart from '@/components/charts/StackedBarChart.vue'
import { useWebSocket } from '@/hooks/useWebSocket'
// 数据定义
const teamScores = ref([])
const medalStatistics = ref([])
const eventRankings = ref([])
const recordBreakings = ref([])
const events = ref([])
const selectedEvent = ref('')
// WebSocket连接
const { messages } = useWebSocket('/ws/statistics')
// 监听实时数据更新
watch(messages, (newMessage) => {
if (newMessage) {
switch (newMessage.type) {
case 'TEAM_SCORE_UPDATE':
updateTeamScores(newMessage.data)
break
case 'RECORD_BREAKING':
handleRecordBreaking(newMessage.data)
break
}
}
})
// 加载数据
onMounted(async () => {
await Promise.all([
loadEvents(),
loadTeamScores(),
loadMedalStatistics(),
loadRecordBreakings()
])
})
// 监听项目选择
watch(selectedEvent, (newValue) => {
if (newValue) {
loadEventRankings(newValue)
}
})
// 加载团体总分
const loadTeamScores = async () => {
try {
const res = await getTeamScores()
teamScores.value = res.data
} catch (error) {
ElMessage.error('加载团体总分失败')
}
}
// 加载奖牌统计
const loadMedalStatistics = async () => {
try {
const res = await getMedalStatistics()
medalStatistics.value = res.data
} catch (error) {
ElMessage.error('加载奖牌统计失败')
}
}
// 加载项目列表
const loadEvents = async () => {
try {
const res = await getEvents()
events.value = res.data
} catch (error) {
ElMessage.error('加载项目列表失败')
}
}
// 加载单项排名
const loadEventRankings = async (eventId) => {
try {
const res = await getEventRankings(eventId)
eventRankings.value = res.data
} catch (error) {
ElMessage.error('加载单项排名失败')
}
}
// 加载破记录记录
const loadRecordBreakings = async () => {
try {
const res = await getRecordBreakings()
recordBreakings.value = res.data
} catch (error) {
ElMessage.error('加载破记录记录失败')
}
}
// 导出团体总分
const exportTeamScores = () => {
// 实现导出逻辑
}
// 处理实时破记录通知
const handleRecordBreaking = (record) => {
ElNotification({
title: '破记录提醒',
message: `${record.athleteName} 在 ${record.eventName} 中破记录!`,
type: 'success',
duration: 0
})
recordBreakings.value.unshift(record)
}
</script>
<style scoped>
.dashboard {
padding: 20px;
}
.rank-card {
margin-bottom: 20px;
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
}
.chart-container {
height: 400px;
}
.mt-20 {
margin-top: 20px;
}
</style>
3.4 图表组件实现
<!-- components/charts/BarChart.vue -->
<template>
<div ref="chartRef" class="chart"></div>
</template>
<script setup>
import { ref, onMounted, watch } from 'vue'
import * as echarts from 'echarts'
const props = defineProps({
data: {
type: Array,
required: true
}
})
const chartRef = ref(null)
let chart = null
onMounted(() => {
initChart()
})
watch(() => props.data, (newVal) => {
if (chart) {
updateChart(newVal)
}
})
const initChart = () => {
chart = echarts.init(chartRef.value)
updateChart(props.data)
}
const updateChart = (data) => {
const option = {
tooltip: {
trigger: 'axis',
axisPointer: {
type: 'shadow'
}
},
grid: {
left: '3%',
right: '4%',
bottom: '3%',
containLabel: true
},
xAxis: {
type: 'category',
data: data.map(item => item.teamName)
},
yAxis: {
type: 'value'
},
series: [
{
name: '总分',
type: 'bar',
data: data.map(item => item.totalScore),
itemStyle: {
color: '#409EFF'
}
}
]
}
chart.setOption(option)
}
</script>
<style scoped>
.chart {
width: 100%;
height: 100%;
}
</style>
这样,我们完成了成绩管理和数据统计模块的完整实现。