feat: 运动会记分板系统核心功能
- 前后端分离架构 (Nuxt 3 + Element Plus) - SQLite 数据库 (better-sqlite3) - 比赛项目管理 (田赛/径赛/团体赛) - 队伍管理 (5 个组别) - 成绩录入与积分统计 - 记分板展示 (排名/奖牌榜) - 移动端响应式适配 - 侧边栏布局 + 抽屉菜单 - 自动生成初始化数据接口
This commit is contained in:
commit
22f073d8e7
44
.gitignore
vendored
Normal file
44
.gitignore
vendored
Normal file
@ -0,0 +1,44 @@
|
||||
# Dependencies
|
||||
node_modules/
|
||||
.pnpm-store/
|
||||
|
||||
# Build output
|
||||
.output/
|
||||
dist/
|
||||
|
||||
# Nuxt dev
|
||||
.nuxt/
|
||||
|
||||
# Database
|
||||
data/*.db
|
||||
|
||||
# Environment
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
npm-debug.log*
|
||||
pnpm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
|
||||
# Editor
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# Testing
|
||||
coverage/
|
||||
|
||||
# Temp
|
||||
tmp/
|
||||
temp/
|
||||
129
README.md
Normal file
129
README.md
Normal file
@ -0,0 +1,129 @@
|
||||
# 运动会记分板系统
|
||||
|
||||
基于 Nuxt 3 + Element Plus + SQLite 的运动会管理系统
|
||||
|
||||
## 功能特性
|
||||
|
||||
- **比赛项目管理**:支持田赛、径赛、团体赛三大类别
|
||||
- **队伍管理**:管理不同组别的参赛队伍
|
||||
- **成绩录入**:实时录入比赛成绩和排名
|
||||
- **记分板展示**:实时展示积分排名和奖牌统计
|
||||
|
||||
## 比赛项目
|
||||
|
||||
### 田赛
|
||||
- 跳高、跳远、掷铅球
|
||||
|
||||
### 径赛
|
||||
- 100m、200m、400m、4×100m、4×400m、20×50m
|
||||
|
||||
### 团体赛
|
||||
- 旱地龙舟、跳长绳、折返跑
|
||||
|
||||
## 组别
|
||||
- 教师组
|
||||
- 航空班组
|
||||
- 体育班组
|
||||
- 文化班甲组
|
||||
- 文化班乙组
|
||||
|
||||
## 积分规则
|
||||
- 第1名:7分 + 金牌
|
||||
- 第2名:5分 + 银牌
|
||||
- 第3名:3分 + 铜牌
|
||||
|
||||
## 技术栈
|
||||
|
||||
- **前端框架**:Nuxt 3
|
||||
- **UI 组件库**:Element Plus
|
||||
- **包管理器**:pnpm
|
||||
- **数据库**:SQLite (better-sqlite3)
|
||||
- **后端 API**:Nuxt Server API
|
||||
|
||||
## 安装依赖
|
||||
|
||||
```bash
|
||||
pnpm install
|
||||
```
|
||||
|
||||
## 初始化数据库
|
||||
|
||||
首次运行前,需要初始化数据库和种子数据:
|
||||
|
||||
```bash
|
||||
# 启动开发服务器
|
||||
pnpm dev
|
||||
|
||||
# 在浏览器中访问或使用 curl 初始化数据
|
||||
curl -X POST http://localhost:3000/api/seed
|
||||
```
|
||||
|
||||
## 开发
|
||||
|
||||
```bash
|
||||
pnpm dev
|
||||
```
|
||||
|
||||
访问 http://localhost:3000
|
||||
|
||||
## 构建生产版本
|
||||
|
||||
```bash
|
||||
pnpm build
|
||||
```
|
||||
|
||||
## 预览生产版本
|
||||
|
||||
```bash
|
||||
pnpm preview
|
||||
```
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
D:\SportMeetingAdminSys
|
||||
├── app/
|
||||
│ ├── layouts/
|
||||
│ │ └── default.vue # 侧边栏布局
|
||||
│ └── pages/
|
||||
│ ├── index.vue # 首页仪表盘
|
||||
│ ├── events.vue # 比赛项目管理
|
||||
│ ├── teams.vue # 队伍管理
|
||||
│ ├── results.vue # 成绩录入
|
||||
│ └── scoreboard.vue # 记分板展示
|
||||
├── server/
|
||||
│ ├── api/
|
||||
│ │ ├── config/ # 配置接口
|
||||
│ │ ├── events/ # 比赛项目接口
|
||||
│ │ ├── teams/ # 队伍接口
|
||||
│ │ ├── results/ # 成绩接口
|
||||
│ │ ├── scoreboard/ # 记分板接口
|
||||
│ │ └── seed.post.ts # 数据初始化
|
||||
│ └── db/
|
||||
│ └── index.ts # SQLite 数据库连接
|
||||
└── data/
|
||||
└── sports.db # SQLite 数据库文件
|
||||
```
|
||||
|
||||
## API 接口
|
||||
|
||||
### 配置接口
|
||||
- `GET /api/config` - 获取系统配置(类别、组别、项目类型)
|
||||
|
||||
### 比赛项目
|
||||
- `GET /api/events` - 获取比赛项目列表
|
||||
- `POST /api/events` - 创建比赛项目
|
||||
|
||||
### 队伍管理
|
||||
- `GET /api/teams` - 获取队伍列表
|
||||
- `POST /api/teams` - 创建队伍
|
||||
|
||||
### 成绩管理
|
||||
- `GET /api/results` - 获取成绩列表
|
||||
- `POST /api/results` - 录入成绩
|
||||
|
||||
### 记分板
|
||||
- `GET /api/scoreboard` - 获取积分排名
|
||||
|
||||
### 数据初始化
|
||||
- `POST /api/seed` - 初始化示例数据
|
||||
5
app/app.vue
Normal file
5
app/app.vue
Normal file
@ -0,0 +1,5 @@
|
||||
<template>
|
||||
<NuxtLayout>
|
||||
<NuxtPage />
|
||||
</NuxtLayout>
|
||||
</template>
|
||||
222
app/layouts/default.vue
Normal file
222
app/layouts/default.vue
Normal file
@ -0,0 +1,222 @@
|
||||
<template>
|
||||
<el-container class="layout-container">
|
||||
<el-aside width="200px" class="sidebar desktop-only">
|
||||
<div class="logo">
|
||||
<h2>运动会记分板</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="sidebar-menu"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:index="item.path"
|
||||
@click="onMenuClick(item.path)"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-aside>
|
||||
<el-container>
|
||||
<el-header class="header">
|
||||
<div class="header-left">
|
||||
<el-button class="mobile-only-btn" text @click="drawerOpen = true">
|
||||
<el-icon><Menu /></el-icon>
|
||||
</el-button>
|
||||
<h3>运动会管理系统</h3>
|
||||
</div>
|
||||
</el-header>
|
||||
<el-main class="main-content">
|
||||
<slot />
|
||||
</el-main>
|
||||
<el-footer class="footer">
|
||||
<p>© 2026 运动会记分板系统 - 版权所有</p>
|
||||
</el-footer>
|
||||
</el-container>
|
||||
</el-container>
|
||||
|
||||
<el-drawer
|
||||
v-model="drawerOpen"
|
||||
direction="ltr"
|
||||
size="70%"
|
||||
:with-header="false"
|
||||
class="mobile-only"
|
||||
>
|
||||
<div class="drawer-logo">
|
||||
<h2>运动会记分板</h2>
|
||||
</div>
|
||||
<el-menu
|
||||
:default-active="activeMenu"
|
||||
class="drawer-menu"
|
||||
>
|
||||
<el-menu-item
|
||||
v-for="item in menuItems"
|
||||
:key="item.path"
|
||||
:index="item.path"
|
||||
@click="onMenuClick(item.path, true)"
|
||||
>
|
||||
<el-icon><component :is="item.icon" /></el-icon>
|
||||
<span>{{ item.label }}</span>
|
||||
</el-menu-item>
|
||||
</el-menu>
|
||||
</el-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu } from '@element-plus/icons-vue'
|
||||
|
||||
const route = useRoute()
|
||||
const activeMenu = computed(() => route.path)
|
||||
const drawerOpen = ref(false)
|
||||
|
||||
const menuItems = [
|
||||
{ path: '/', label: '首页', icon: HomeFilled },
|
||||
{ path: '/events', label: '比赛项目', icon: Trophy },
|
||||
{ path: '/teams', label: '队伍管理', icon: UserFilled },
|
||||
{ path: '/results', label: '成绩录入', icon: Edit },
|
||||
{ path: '/scoreboard', label: '记分板', icon: DataLine }
|
||||
]
|
||||
|
||||
const onMenuClick = (path: string, closeDrawer = false) => {
|
||||
navigateTo(path)
|
||||
if (closeDrawer) {
|
||||
drawerOpen.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.layout-container {
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
background-color: #304156;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.logo {
|
||||
height: 60px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2b3a4a;
|
||||
}
|
||||
|
||||
.logo h2 {
|
||||
color: #fff;
|
||||
font-size: 18px;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.sidebar-menu {
|
||||
border-right: none;
|
||||
background-color: #304156;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item {
|
||||
color: #bfcbd9;
|
||||
}
|
||||
|
||||
.sidebar-menu .el-menu-item:hover,
|
||||
.sidebar-menu .el-menu-item.is-active {
|
||||
background-color: #263445;
|
||||
color: #409eff;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-color: #fff;
|
||||
border-bottom: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0 20px;
|
||||
height: 56px;
|
||||
}
|
||||
|
||||
.header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.header h3 {
|
||||
margin: 0;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
background-color: #f0f2f5;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.drawer-logo {
|
||||
height: 56px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background-color: #2b3a4a;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.drawer-logo h2 {
|
||||
margin: 0;
|
||||
font-size: 18px;
|
||||
color: #fff;
|
||||
}
|
||||
|
||||
.drawer-menu {
|
||||
border-right: none;
|
||||
}
|
||||
|
||||
.desktop-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only-btn {
|
||||
display: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.desktop-only {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.mobile-only {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.mobile-only-btn {
|
||||
display: inline-flex;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0 12px;
|
||||
}
|
||||
|
||||
.main-content {
|
||||
padding: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.footer {
|
||||
background-color: #fff;
|
||||
border-top: 1px solid #e6e6e6;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 40px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.footer p {
|
||||
margin: 0;
|
||||
color: #909399;
|
||||
font-size: 12px;
|
||||
}
|
||||
</style>
|
||||
196
app/pages/events.vue
Normal file
196
app/pages/events.vue
Normal file
@ -0,0 +1,196 @@
|
||||
<template>
|
||||
<div class="events-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>比赛项目管理</span>
|
||||
<el-button type="primary" @click="showAddDialog = true">添加项目</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="类别">
|
||||
<el-select v-model="filters.category" placeholder="全部" clearable @change="loadEvents">
|
||||
<el-option label="田赛" value="田赛" />
|
||||
<el-option label="径赛" value="径赛" />
|
||||
<el-option label="团体赛" value="团体赛" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="组别">
|
||||
<el-select v-model="filters.group" placeholder="全部" clearable @change="loadEvents">
|
||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="events" border stripe class="events-table">
|
||||
<el-table-column prop="id" label="ID" width="80" class-name="col-id" />
|
||||
<el-table-column prop="name" label="项目名称" />
|
||||
<el-table-column prop="category" label="类别" width="120" class-name="col-category" />
|
||||
<el-table-column prop="event_group" label="组别" width="150" class-name="col-group" />
|
||||
<el-table-column prop="unit" label="单位" width="100" class-name="col-unit" />
|
||||
<el-table-column prop="status" label="状态" width="100" class-name="col-status">
|
||||
<template #default="{ row }">
|
||||
<el-tag :type="row.status === 'completed' ? 'success' : 'info'">
|
||||
{{ row.status === 'completed' ? '已完成' : '进行中' }}
|
||||
</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="showAddDialog" title="添加比赛项目" width="500px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="类别">
|
||||
<el-select v-model="form.category" placeholder="请选择" @change="onCategoryChange">
|
||||
<el-option label="田赛" value="田赛" />
|
||||
<el-option label="径赛" value="径赛" />
|
||||
<el-option label="团体赛" value="团体赛" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="项目名称">
|
||||
<el-select v-model="form.name" placeholder="请选择">
|
||||
<el-option
|
||||
v-for="event in availableEvents"
|
||||
:key="event.name"
|
||||
:label="event.name"
|
||||
:value="event.name"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="组别">
|
||||
<el-select v-model="form.event_group" placeholder="请选择">
|
||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addEvent">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const events = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const filters = ref({ category: '', group: '' })
|
||||
const groups = ref([])
|
||||
const eventTypes = ref({})
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
category: '',
|
||||
event_group: '',
|
||||
unit: ''
|
||||
})
|
||||
|
||||
const availableEvents = computed(() => {
|
||||
if (!form.value.category) return []
|
||||
return eventTypes.value[form.value.category] || []
|
||||
})
|
||||
|
||||
const onCategoryChange = () => {
|
||||
form.value.name = ''
|
||||
form.value.unit = ''
|
||||
}
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/config')
|
||||
groups.value = res.data.groups
|
||||
eventTypes.value = res.data.eventTypes
|
||||
} catch (error) {
|
||||
ElMessage.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.category) params.append('category', filters.value.category)
|
||||
if (filters.value.group) params.append('group', filters.value.group)
|
||||
|
||||
const res = await $fetch(`/api/events?${params}`)
|
||||
events.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载项目失败')
|
||||
}
|
||||
}
|
||||
|
||||
const addEvent = async () => {
|
||||
try {
|
||||
const selectedEvent = availableEvents.value.find(e => e.name === form.value.name)
|
||||
if (!selectedEvent) {
|
||||
ElMessage.error('请选择项目')
|
||||
return
|
||||
}
|
||||
|
||||
await $fetch('/api/events', {
|
||||
method: 'POST',
|
||||
body: {
|
||||
name: form.value.name,
|
||||
category: form.value.category,
|
||||
event_group: form.value.event_group,
|
||||
unit: selectedEvent.unit
|
||||
}
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
form.value = { name: '', category: '', event_group: '', unit: '' }
|
||||
loadEvents()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadEvents()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.events-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.events-table :deep(.col-id),
|
||||
.events-table :deep(.col-category),
|
||||
.events-table :deep(.col-unit),
|
||||
.events-table :deep(.col-status) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
app/pages/index.vue
Normal file
213
app/pages/index.vue
Normal file
@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="home-container">
|
||||
<el-card class="welcome-card">
|
||||
<h1>欢迎使用运动会记分板系统</h1>
|
||||
<p>本系统用于管理运动会的比赛项目、队伍和成绩</p>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20" class="stats-row">
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon class="stat-icon" color="#409eff"><Trophy /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.events }}</div>
|
||||
<div class="stat-label">比赛项目</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon class="stat-icon" color="#67c23a"><UserFilled /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.teams }}</div>
|
||||
<div class="stat-label">参赛队伍</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon class="stat-icon" color="#e6a23c"><Edit /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.results }}</div>
|
||||
<div class="stat-label">成绩记录</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="12" :sm="12" :md="6" :lg="6">
|
||||
<el-card class="stat-card">
|
||||
<div class="stat-content">
|
||||
<el-icon class="stat-icon" color="#f56c6c"><DataLine /></el-icon>
|
||||
<div>
|
||||
<div class="stat-value">{{ stats.categories }}</div>
|
||||
<div class="stat-label">比赛类别</div>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
|
||||
<el-row :gutter="20">
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>快速操作</span>
|
||||
</template>
|
||||
<div class="quick-actions">
|
||||
<el-row :gutter="10">
|
||||
<el-col :span="12">
|
||||
<el-button type="primary" class="action-btn" @click="navigateTo('/events')">管理比赛项目</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button type="success" class="action-btn" @click="navigateTo('/teams')">管理队伍</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button type="warning" class="action-btn" @click="navigateTo('/results')">录入成绩</el-button>
|
||||
</el-col>
|
||||
<el-col :span="12">
|
||||
<el-button type="info" class="action-btn" @click="navigateTo('/scoreboard')">查看记分板</el-button>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="12" :lg="12">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>系统说明</span>
|
||||
</template>
|
||||
<ul class="system-info">
|
||||
<li>田赛:跳高、跳远、掷铅球</li>
|
||||
<li>径赛:100m、200m、400m、4×100m、4×400m、20×50m</li>
|
||||
<li>团体赛:旱地龙舟、跳长绳、折返跑</li>
|
||||
<li>组别:教师组、航空班组、体育班组、文化班甲组、文化班乙组</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { Trophy, UserFilled, Edit, DataLine } from '@element-plus/icons-vue'
|
||||
|
||||
const stats = ref({
|
||||
events: 0,
|
||||
teams: 0,
|
||||
results: 0,
|
||||
categories: 3
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
const [eventsRes, teamsRes, resultsRes] = await Promise.all([
|
||||
$fetch('/api/events'),
|
||||
$fetch('/api/teams'),
|
||||
$fetch('/api/results')
|
||||
])
|
||||
|
||||
stats.value.events = eventsRes.data?.length || 0
|
||||
stats.value.teams = teamsRes.data?.length || 0
|
||||
stats.value.results = resultsRes.data?.length || 0
|
||||
} catch (error) {
|
||||
console.error('Failed to load stats:', error)
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.home-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.welcome-card {
|
||||
margin-bottom: 20px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.welcome-card h1 {
|
||||
color: #303133;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.welcome-card p {
|
||||
color: #909399;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.stats-row {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.stat-card {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.stat-content {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 15px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 40px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 32px;
|
||||
font-weight: bold;
|
||||
color: #303133;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 14px;
|
||||
color: #909399;
|
||||
margin-top: 5px;
|
||||
}
|
||||
|
||||
.quick-actions {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.quick-actions .action-btn {
|
||||
width: 100%;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.system-info {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.system-info li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.system-info li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.stat-value {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
.stat-icon {
|
||||
font-size: 28px;
|
||||
}
|
||||
|
||||
.stats-row :deep(.el-col) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
213
app/pages/results.vue
Normal file
213
app/pages/results.vue
Normal file
@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<div class="results-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>成绩录入</span>
|
||||
<el-button type="primary" @click="showAddDialog = true">录入成绩</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="比赛项目">
|
||||
<el-select v-model="filters.event_id" placeholder="全部" clearable @change="loadResults">
|
||||
<el-option
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:label="`${event.name} (${event.event_group})`"
|
||||
:value="event.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="results" border stripe class="results-table">
|
||||
<el-table-column prop="event_name" label="项目名称" />
|
||||
<el-table-column prop="team_name" label="队伍" />
|
||||
<el-table-column prop="team_group" label="组别" width="150" class-name="col-group" />
|
||||
<el-table-column prop="score" label="成绩" width="120" />
|
||||
<el-table-column prop="unit" label="单位" width="80" class-name="col-unit" />
|
||||
<el-table-column prop="rank" label="名次" width="100" class-name="col-rank">
|
||||
<template #default="{ row }">
|
||||
<el-tag v-if="row.rank === 1" type="warning">第1名</el-tag>
|
||||
<el-tag v-else-if="row.rank === 2" type="success">第2名</el-tag>
|
||||
<el-tag v-else-if="row.rank === 3" type="info">第3名</el-tag>
|
||||
<span v-else-if="row.rank">第{{ row.rank }}名</span>
|
||||
<span v-else>-</span>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="created_at" label="录入时间" width="180" class-name="col-time">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="showAddDialog" title="录入成绩" width="500px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="比赛项目">
|
||||
<el-select v-model="form.event_id" placeholder="请选择" @change="onEventChange">
|
||||
<el-option
|
||||
v-for="event in events"
|
||||
:key="event.id"
|
||||
:label="`${event.name} (${event.event_group})`"
|
||||
:value="event.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="参赛队伍">
|
||||
<el-select v-model="form.team_id" placeholder="请选择">
|
||||
<el-option
|
||||
v-for="team in filteredTeams"
|
||||
:key="team.id"
|
||||
:label="team.name"
|
||||
:value="team.id"
|
||||
/>
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
<el-form-item label="成绩">
|
||||
<el-input v-model="form.score" placeholder="请输入成绩">
|
||||
<template #append>{{ currentUnit }}</template>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<el-form-item label="名次">
|
||||
<el-input-number v-model="form.rank" :min="1" placeholder="请输入名次" />
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addResult">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const results = ref([])
|
||||
const events = ref([])
|
||||
const teams = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const filters = ref({ event_id: '' })
|
||||
|
||||
const form = ref({
|
||||
event_id: null,
|
||||
team_id: null,
|
||||
score: '',
|
||||
rank: null
|
||||
})
|
||||
|
||||
const currentUnit = computed(() => {
|
||||
const event = events.value.find(e => e.id === form.value.event_id)
|
||||
return event?.unit || ''
|
||||
})
|
||||
|
||||
const filteredTeams = computed(() => {
|
||||
const event = events.value.find(e => e.id === form.value.event_id)
|
||||
if (!event) return []
|
||||
return teams.value.filter(t => t.team_group === event.event_group)
|
||||
})
|
||||
|
||||
const onEventChange = () => {
|
||||
form.value.team_id = null
|
||||
}
|
||||
|
||||
const loadEvents = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/events')
|
||||
events.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载项目失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadTeams = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/teams')
|
||||
teams.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载队伍失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadResults = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.event_id) params.append('event_id', filters.value.event_id)
|
||||
|
||||
const res = await $fetch(`/api/results?${params}`)
|
||||
results.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载成绩失败')
|
||||
}
|
||||
}
|
||||
|
||||
const addResult = async () => {
|
||||
if (!form.value.event_id || !form.value.team_id || !form.value.score) {
|
||||
ElMessage.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/results', {
|
||||
method: 'POST',
|
||||
body: form.value
|
||||
})
|
||||
|
||||
ElMessage.success('录入成功')
|
||||
showAddDialog.value = false
|
||||
form.value = { event_id: null, team_id: null, score: '', rank: null }
|
||||
loadResults()
|
||||
} catch (error) {
|
||||
ElMessage.error('录入失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadEvents()
|
||||
loadTeams()
|
||||
loadResults()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.results-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.results-table :deep(.col-group),
|
||||
.results-table :deep(.col-unit),
|
||||
.results-table :deep(.col-time) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
221
app/pages/scoreboard.vue
Normal file
221
app/pages/scoreboard.vue
Normal file
@ -0,0 +1,221 @@
|
||||
<template>
|
||||
<div class="scoreboard-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>记分板</span>
|
||||
<el-button type="primary" @click="loadScoreboard">刷新</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="组别">
|
||||
<el-select v-model="filters.group" placeholder="全部" clearable @change="loadScoreboard">
|
||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="scoreboard" border stripe class="scoreboard-table">
|
||||
<el-table-column label="排名" width="80" class-name="col-rank">
|
||||
<template #default="{ $index }">
|
||||
<div class="rank-cell">
|
||||
<el-icon v-if="$index === 0" color="#FFD700" :size="24"><Trophy /></el-icon>
|
||||
<el-icon v-else-if="$index === 1" color="#C0C0C0" :size="24"><Trophy /></el-icon>
|
||||
<el-icon v-else-if="$index === 2" color="#CD7F32" :size="24"><Trophy /></el-icon>
|
||||
<span v-else>{{ $index + 1 }}</span>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="name" label="队伍名称" />
|
||||
<el-table-column prop="team_group" label="组别" width="150" class-name="col-group" />
|
||||
<el-table-column prop="total_score" label="总分" width="100" sortable>
|
||||
<template #default="{ row }">
|
||||
<el-tag type="danger" size="large">{{ row.total_score }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="奖牌统计" width="200" class-name="col-medal">
|
||||
<template #default="{ row }">
|
||||
<div class="medals">
|
||||
<el-tag type="warning">🥇 {{ row.gold_count }}</el-tag>
|
||||
<el-tag type="success">🥈 {{ row.silver_count }}</el-tag>
|
||||
<el-tag type="info">🥉 {{ row.bronze_count }}</el-tag>
|
||||
</div>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-row :gutter="20" class="stats-section">
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>积分规则</span>
|
||||
</template>
|
||||
<ul class="rules-list">
|
||||
<li>第1名:7分 + 金牌</li>
|
||||
<li>第2名:5分 + 银牌</li>
|
||||
<li>第3名:3分 + 铜牌</li>
|
||||
<li>其他名次:不计分</li>
|
||||
</ul>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>金牌榜前三</span>
|
||||
</template>
|
||||
<div v-for="(item, index) in topGold" :key="item.id" class="top-item">
|
||||
<span>{{ index + 1 }}. {{ item.name }}</span>
|
||||
<el-tag type="warning">{{ item.gold_count }} 枚</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
<el-col :xs="24" :sm="24" :md="8" :lg="8">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<span>总分榜前三</span>
|
||||
</template>
|
||||
<div v-for="(item, index) in topScore" :key="item.id" class="top-item">
|
||||
<span>{{ index + 1 }}. {{ item.name }}</span>
|
||||
<el-tag type="danger">{{ item.total_score }} 分</el-tag>
|
||||
</div>
|
||||
</el-card>
|
||||
</el-col>
|
||||
</el-row>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
import { Trophy } from '@element-plus/icons-vue'
|
||||
|
||||
const scoreboard = ref([])
|
||||
const groups = ref([])
|
||||
const filters = ref({ group: '' })
|
||||
|
||||
const topGold = computed(() => {
|
||||
return [...scoreboard.value]
|
||||
.sort((a, b) => b.gold_count - a.gold_count)
|
||||
.slice(0, 3)
|
||||
})
|
||||
|
||||
const topScore = computed(() => {
|
||||
return scoreboard.value.slice(0, 3)
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/config')
|
||||
groups.value = res.data.groups
|
||||
} catch (error) {
|
||||
ElMessage.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadScoreboard = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.group) params.append('group', filters.value.group)
|
||||
|
||||
const res = await $fetch(`/api/scoreboard?${params}`)
|
||||
scoreboard.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载记分板失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadScoreboard()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.scoreboard-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.rank-cell {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
font-size: 18px;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.medals {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.stats-section {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.rules-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.rules-list li {
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
color: #606266;
|
||||
}
|
||||
|
||||
.rules-list li:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.top-item {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 8px 0;
|
||||
border-bottom: 1px solid #ebeef5;
|
||||
}
|
||||
|
||||
.top-item:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.scoreboard-table :deep(.col-group),
|
||||
.scoreboard-table :deep(.col-medal) {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.stats-section :deep(.el-col) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
147
app/pages/teams.vue
Normal file
147
app/pages/teams.vue
Normal file
@ -0,0 +1,147 @@
|
||||
<template>
|
||||
<div class="teams-container">
|
||||
<el-card>
|
||||
<template #header>
|
||||
<div class="card-header">
|
||||
<span>队伍管理</span>
|
||||
<el-button type="primary" @click="showAddDialog = true">添加队伍</el-button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<el-form :inline="true" class="filter-form">
|
||||
<el-form-item label="组别">
|
||||
<el-select v-model="filters.group" placeholder="全部" clearable @change="loadTeams">
|
||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
<el-table :data="teams" border stripe class="teams-table">
|
||||
<el-table-column prop="id" label="ID" width="80" />
|
||||
<el-table-column prop="name" label="队伍名称" />
|
||||
<el-table-column prop="team_group" label="组别" width="200" class-name="col-group" />
|
||||
<el-table-column prop="created_at" label="创建时间" width="200" class-name="col-time">
|
||||
<template #default="{ row }">
|
||||
{{ new Date(row.created_at).toLocaleString('zh-CN') }}
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
</el-card>
|
||||
|
||||
<el-dialog v-model="showAddDialog" title="添加队伍" width="500px">
|
||||
<el-form :model="form" label-width="100px">
|
||||
<el-form-item label="队伍名称">
|
||||
<el-input v-model="form.name" placeholder="请输入队伍名称" />
|
||||
</el-form-item>
|
||||
<el-form-item label="组别">
|
||||
<el-select v-model="form.team_group" placeholder="请选择组别">
|
||||
<el-option v-for="g in groups" :key="g.value" :label="g.label" :value="g.value" />
|
||||
</el-select>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="showAddDialog = false">取消</el-button>
|
||||
<el-button type="primary" @click="addTeam">确定</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ElMessage } from 'element-plus'
|
||||
|
||||
const teams = ref([])
|
||||
const showAddDialog = ref(false)
|
||||
const filters = ref({ group: '' })
|
||||
const groups = ref([])
|
||||
|
||||
const form = ref({
|
||||
name: '',
|
||||
team_group: ''
|
||||
})
|
||||
|
||||
const loadConfig = async () => {
|
||||
try {
|
||||
const res = await $fetch('/api/config')
|
||||
groups.value = res.data.groups
|
||||
} catch (error) {
|
||||
ElMessage.error('加载配置失败')
|
||||
}
|
||||
}
|
||||
|
||||
const loadTeams = async () => {
|
||||
try {
|
||||
const params = new URLSearchParams()
|
||||
if (filters.value.group) params.append('group', filters.value.group)
|
||||
|
||||
const res = await $fetch(`/api/teams?${params}`)
|
||||
teams.value = res.data
|
||||
} catch (error) {
|
||||
ElMessage.error('加载队伍失败')
|
||||
}
|
||||
}
|
||||
|
||||
const addTeam = async () => {
|
||||
if (!form.value.name || !form.value.team_group) {
|
||||
ElMessage.error('请填写完整信息')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await $fetch('/api/teams', {
|
||||
method: 'POST',
|
||||
body: form.value
|
||||
})
|
||||
|
||||
ElMessage.success('添加成功')
|
||||
showAddDialog.value = false
|
||||
form.value = { name: '', team_group: '' }
|
||||
loadTeams()
|
||||
} catch (error) {
|
||||
ElMessage.error('添加失败')
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadConfig()
|
||||
loadTeams()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.teams-container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.card-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.filter-form {
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.card-header {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-form-item) {
|
||||
margin-right: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.filter-form :deep(.el-select) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.teams-table :deep(.col-time) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
22
nuxt.config.ts
Normal file
22
nuxt.config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
// https://nuxt.com/docs/api/configuration/nuxt-config
|
||||
export default defineNuxtConfig({
|
||||
compatibilityDate: '2025-07-15',
|
||||
devtools: { enabled: true },
|
||||
|
||||
modules: [
|
||||
'@nuxt/eslint',
|
||||
'@nuxt/icon',
|
||||
'@pinia/nuxt',
|
||||
'@element-plus/nuxt'
|
||||
],
|
||||
|
||||
css: ['element-plus/dist/index.css'],
|
||||
|
||||
nitro: {
|
||||
esbuild: {
|
||||
options: {
|
||||
target: 'esnext'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
28
package.json
Normal file
28
package.json
Normal file
@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "sport-meeting-admin-sys",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nuxt build",
|
||||
"dev": "nuxt dev",
|
||||
"generate": "nuxt generate",
|
||||
"preview": "nuxt preview",
|
||||
"postinstall": "nuxt prepare"
|
||||
},
|
||||
"dependencies": {
|
||||
"@element-plus/nuxt": "1.0.10",
|
||||
"@pinia/nuxt": "0.11.3",
|
||||
"better-sqlite3": "^12.8.0",
|
||||
"element-plus": "2.9.2",
|
||||
"nuxt": "^4.3.1",
|
||||
"opencode-ai": "^1.2.27",
|
||||
"pinia": "2.3.1",
|
||||
"vue": "^3.5.29",
|
||||
"vue-router": "^4.6.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nuxt/eslint": "1.15.2",
|
||||
"@nuxt/icon": "2.2.1"
|
||||
},
|
||||
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
|
||||
}
|
||||
8985
pnpm-lock.yaml
generated
Normal file
8985
pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
8
pnpm-workspace.yaml
Normal file
8
pnpm-workspace.yaml
Normal file
@ -0,0 +1,8 @@
|
||||
ignoredBuiltDependencies:
|
||||
- '@parcel/watcher'
|
||||
- unrs-resolver
|
||||
|
||||
onlyBuiltDependencies:
|
||||
- esbuild
|
||||
- opencode-ai
|
||||
- vue-demi
|
||||
BIN
public/favicon.ico
Normal file
BIN
public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
2
public/robots.txt
Normal file
2
public/robots.txt
Normal file
@ -0,0 +1,2 @@
|
||||
User-Agent: *
|
||||
Disallow:
|
||||
48
server/api/config/index.get.ts
Normal file
48
server/api/config/index.get.ts
Normal file
@ -0,0 +1,48 @@
|
||||
export default defineEventHandler(() => {
|
||||
// 比赛类别
|
||||
const categories = [
|
||||
{ value: '田赛', label: '田赛' },
|
||||
{ value: '径赛', label: '径赛' },
|
||||
{ value: '团体赛', label: '团体赛' }
|
||||
]
|
||||
|
||||
// 组别
|
||||
const groups = [
|
||||
{ value: '教师组', label: '教师组' },
|
||||
{ value: '航空班组', label: '航空班组' },
|
||||
{ value: '体育班组', label: '体育班组' },
|
||||
{ value: '文化班甲组', label: '文化班甲组' },
|
||||
{ value: '文化班乙组', label: '文化班乙组' }
|
||||
]
|
||||
|
||||
// 项目配置
|
||||
const eventTypes = {
|
||||
'田赛': [
|
||||
{ name: '跳高', unit: '米' },
|
||||
{ name: '跳远', unit: '米' },
|
||||
{ name: '掷铅球', unit: '米' }
|
||||
],
|
||||
'径赛': [
|
||||
{ name: '100m', unit: '秒' },
|
||||
{ name: '200m', unit: '秒' },
|
||||
{ name: '400m', unit: '秒' },
|
||||
{ name: '4×100m', unit: '秒' },
|
||||
{ name: '4×400m', unit: '秒' },
|
||||
{ name: '20×50m', unit: '秒' }
|
||||
],
|
||||
'团体赛': [
|
||||
{ name: '旱地龙舟', unit: '秒' },
|
||||
{ name: '跳长绳', unit: '次' },
|
||||
{ name: '折返跑', unit: '秒' }
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
categories,
|
||||
groups,
|
||||
eventTypes
|
||||
}
|
||||
}
|
||||
})
|
||||
29
server/api/events/index.get.ts
Normal file
29
server/api/events/index.get.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const { category, group } = query
|
||||
|
||||
let sql = 'SELECT * FROM events WHERE 1=1'
|
||||
const params: any[] = []
|
||||
|
||||
if (category) {
|
||||
sql += ' AND category = ?'
|
||||
params.push(category)
|
||||
}
|
||||
|
||||
if (group) {
|
||||
sql += ' AND event_group = ?'
|
||||
params.push(group)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY created_at DESC'
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
const events = stmt.all(...params)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: events
|
||||
}
|
||||
})
|
||||
29
server/api/events/index.post.ts
Normal file
29
server/api/events/index.post.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { name, category, event_group, unit } = body
|
||||
|
||||
if (!name || !category || !event_group || !unit) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少必要参数'
|
||||
})
|
||||
}
|
||||
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
const result = stmt.run(name, category, event_group, unit)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
category,
|
||||
event_group,
|
||||
unit
|
||||
}
|
||||
}
|
||||
})
|
||||
35
server/api/results/index.get.ts
Normal file
35
server/api/results/index.get.ts
Normal file
@ -0,0 +1,35 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const { event_id, team_id } = query
|
||||
|
||||
let sql = `
|
||||
SELECT r.*, e.name as event_name, e.category, e.unit, t.name as team_name, t.team_group
|
||||
FROM results r
|
||||
LEFT JOIN events e ON r.event_id = e.id
|
||||
LEFT JOIN teams t ON r.team_id = t.id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
if (event_id) {
|
||||
sql += ' AND r.event_id = ?'
|
||||
params.push(event_id)
|
||||
}
|
||||
|
||||
if (team_id) {
|
||||
sql += ' AND r.team_id = ?'
|
||||
params.push(team_id)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY r.rank ASC, r.created_at DESC'
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
const results = stmt.all(...params)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: results
|
||||
}
|
||||
})
|
||||
56
server/api/results/index.post.ts
Normal file
56
server/api/results/index.post.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { event_id, team_id, score, rank } = body
|
||||
|
||||
if (!event_id || !team_id || !score) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少必要参数'
|
||||
})
|
||||
}
|
||||
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO results (event_id, team_id, score, rank) VALUES (?, ?, ?, ?)'
|
||||
)
|
||||
const result = stmt.run(event_id, team_id, score, rank || null)
|
||||
|
||||
// 更新团队积分
|
||||
if (rank) {
|
||||
let points = 0
|
||||
let medalType = ''
|
||||
|
||||
if (rank === 1) {
|
||||
points = 7
|
||||
medalType = 'gold'
|
||||
} else if (rank === 2) {
|
||||
points = 5
|
||||
medalType = 'silver'
|
||||
} else if (rank === 3) {
|
||||
points = 3
|
||||
medalType = 'bronze'
|
||||
}
|
||||
|
||||
if (points > 0) {
|
||||
const updateStmt = db.prepare(`
|
||||
UPDATE team_scores
|
||||
SET total_score = total_score + ?,
|
||||
${medalType}_count = ${medalType}_count + 1
|
||||
WHERE team_id = ?
|
||||
`)
|
||||
updateStmt.run(points, team_id)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.lastInsertRowid,
|
||||
event_id,
|
||||
team_id,
|
||||
score,
|
||||
rank
|
||||
}
|
||||
}
|
||||
})
|
||||
33
server/api/scoreboard/index.get.ts
Normal file
33
server/api/scoreboard/index.get.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const { group } = query
|
||||
|
||||
let sql = `
|
||||
SELECT t.id, t.name, t.team_group,
|
||||
COALESCE(ts.total_score, 0) as total_score,
|
||||
COALESCE(ts.gold_count, 0) as gold_count,
|
||||
COALESCE(ts.silver_count, 0) as silver_count,
|
||||
COALESCE(ts.bronze_count, 0) as bronze_count
|
||||
FROM teams t
|
||||
LEFT JOIN team_scores ts ON t.id = ts.team_id
|
||||
WHERE 1=1
|
||||
`
|
||||
const params: any[] = []
|
||||
|
||||
if (group) {
|
||||
sql += ' AND t.team_group = ?'
|
||||
params.push(group)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY ts.total_score DESC, ts.gold_count DESC, ts.silver_count DESC, ts.bronze_count DESC'
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
const scoreboard = stmt.all(...params)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: scoreboard
|
||||
}
|
||||
})
|
||||
73
server/api/seed.post.ts
Normal file
73
server/api/seed.post.ts
Normal file
@ -0,0 +1,73 @@
|
||||
import db from '../db'
|
||||
|
||||
// 初始化种子数据
|
||||
export default defineEventHandler(() => {
|
||||
try {
|
||||
// 检查是否已有数据
|
||||
const checkTeams = db.prepare('SELECT COUNT(*) as count FROM teams').get()
|
||||
|
||||
if (checkTeams.count > 0) {
|
||||
return { success: true, message: '数据已存在,跳过初始化' }
|
||||
}
|
||||
|
||||
// 插入示例队伍
|
||||
const teams = [
|
||||
{ name: '高一1班', group: '文化班甲组' },
|
||||
{ name: '高一2班', group: '文化班甲组' },
|
||||
{ name: '高二1班', group: '文化班乙组' },
|
||||
{ name: '高二2班', group: '文化班乙组' },
|
||||
{ name: '体育1班', group: '体育班组' },
|
||||
{ name: '体育2班', group: '体育班组' },
|
||||
{ name: '航空1班', group: '航空班组' },
|
||||
{ name: '航空2班', group: '航空班组' },
|
||||
{ name: '教师队', group: '教师组' }
|
||||
]
|
||||
|
||||
const insertTeam = db.prepare('INSERT INTO teams (name, team_group) VALUES (?, ?)')
|
||||
const insertScore = db.prepare('INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)')
|
||||
|
||||
teams.forEach(team => {
|
||||
const result = insertTeam.run(team.name, team.group)
|
||||
insertScore.run(result.lastInsertRowid)
|
||||
})
|
||||
|
||||
// 插入示例比赛项目
|
||||
const events = [
|
||||
// 田赛
|
||||
{ name: '跳高', category: '田赛', group: '文化班甲组', unit: '米' },
|
||||
{ name: '跳远', category: '田赛', group: '文化班甲组', unit: '米' },
|
||||
{ name: '掷铅球', category: '田赛', group: '文化班乙组', unit: '米' },
|
||||
|
||||
// 径赛
|
||||
{ name: '100m', category: '径赛', group: '体育班组', unit: '秒' },
|
||||
{ name: '200m', category: '径赛', group: '体育班组', unit: '秒' },
|
||||
{ name: '4×100m', category: '径赛', group: '航空班组', unit: '秒' },
|
||||
|
||||
// 团体赛
|
||||
{ name: '旱地龙舟', category: '团体赛', group: '教师组', unit: '秒' },
|
||||
{ name: '跳长绳', category: '团体赛', group: '文化班甲组', unit: '次' },
|
||||
{ name: '折返跑', category: '团体赛', group: '文化班乙组', unit: '秒' }
|
||||
]
|
||||
|
||||
const insertEvent = db.prepare('INSERT INTO events (name, category, event_group, unit) VALUES (?, ?, ?, ?)')
|
||||
|
||||
events.forEach(event => {
|
||||
insertEvent.run(event.name, event.category, event.group, event.unit)
|
||||
})
|
||||
|
||||
return {
|
||||
success: true,
|
||||
message: '初始化数据成功',
|
||||
data: {
|
||||
teams: teams.length,
|
||||
events: events.length
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Seed error:', error)
|
||||
throw createError({
|
||||
statusCode: 500,
|
||||
message: '初始化数据失败: ' + error.message
|
||||
})
|
||||
}
|
||||
})
|
||||
24
server/api/teams/index.get.ts
Normal file
24
server/api/teams/index.get.ts
Normal file
@ -0,0 +1,24 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler((event) => {
|
||||
const query = getQuery(event)
|
||||
const { group } = query
|
||||
|
||||
let sql = 'SELECT * FROM teams WHERE 1=1'
|
||||
const params: any[] = []
|
||||
|
||||
if (group) {
|
||||
sql += ' AND team_group = ?'
|
||||
params.push(group)
|
||||
}
|
||||
|
||||
sql += ' ORDER BY name ASC'
|
||||
|
||||
const stmt = db.prepare(sql)
|
||||
const teams = stmt.all(...params)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: teams
|
||||
}
|
||||
})
|
||||
33
server/api/teams/index.post.ts
Normal file
33
server/api/teams/index.post.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import db from '../../db'
|
||||
|
||||
export default defineEventHandler(async (event) => {
|
||||
const body = await readBody(event)
|
||||
const { name, team_group } = body
|
||||
|
||||
if (!name || !team_group) {
|
||||
throw createError({
|
||||
statusCode: 400,
|
||||
message: '缺少必要参数'
|
||||
})
|
||||
}
|
||||
|
||||
const stmt = db.prepare(
|
||||
'INSERT INTO teams (name, team_group) VALUES (?, ?)'
|
||||
)
|
||||
const result = stmt.run(name, team_group)
|
||||
|
||||
// 初始化团队积分
|
||||
const scoreStmt = db.prepare(
|
||||
'INSERT INTO team_scores (team_id, total_score, gold_count, silver_count, bronze_count) VALUES (?, 0, 0, 0, 0)'
|
||||
)
|
||||
scoreStmt.run(result.lastInsertRowid)
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
id: result.lastInsertRowid,
|
||||
name,
|
||||
team_group
|
||||
}
|
||||
}
|
||||
})
|
||||
58
server/db/index.ts
Normal file
58
server/db/index.ts
Normal file
@ -0,0 +1,58 @@
|
||||
import Database from 'better-sqlite3'
|
||||
import { join, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import fs from 'fs'
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url))
|
||||
const projectRoot = join(__dirname, '..', '..')
|
||||
const dataDir = join(projectRoot, 'data')
|
||||
const dbPath = join(dataDir, 'sports.db')
|
||||
|
||||
if (!fs.existsSync(dataDir)) {
|
||||
fs.mkdirSync(dataDir, { recursive: true })
|
||||
}
|
||||
|
||||
const db = new Database(dbPath)
|
||||
|
||||
// 初始化数据库表
|
||||
db.exec(`
|
||||
CREATE TABLE IF NOT EXISTS events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
category TEXT NOT NULL,
|
||||
event_group TEXT NOT NULL,
|
||||
unit TEXT NOT NULL,
|
||||
status TEXT DEFAULT 'pending',
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS teams (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
team_group TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS results (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
event_id INTEGER NOT NULL,
|
||||
team_id INTEGER NOT NULL,
|
||||
score TEXT NOT NULL,
|
||||
rank INTEGER,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY (event_id) REFERENCES events(id),
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS team_scores (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
team_id INTEGER NOT NULL,
|
||||
total_score INTEGER DEFAULT 0,
|
||||
gold_count INTEGER DEFAULT 0,
|
||||
silver_count INTEGER DEFAULT 0,
|
||||
bronze_count INTEGER DEFAULT 0,
|
||||
FOREIGN KEY (team_id) REFERENCES teams(id)
|
||||
);
|
||||
`)
|
||||
|
||||
export default db
|
||||
18
tsconfig.json
Normal file
18
tsconfig.json
Normal file
@ -0,0 +1,18 @@
|
||||
{
|
||||
// https://nuxt.com/docs/guide/concepts/typescript
|
||||
"files": [],
|
||||
"references": [
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.app.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.server.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.shared.json"
|
||||
},
|
||||
{
|
||||
"path": "./.nuxt/tsconfig.node.json"
|
||||
}
|
||||
]
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user