feat: 运动会记分板系统核心功能

- 前后端分离架构 (Nuxt 3 + Element Plus)
- SQLite 数据库 (better-sqlite3)
- 比赛项目管理 (田赛/径赛/团体赛)
- 队伍管理 (5 个组别)
- 成绩录入与积分统计
- 记分板展示 (排名/奖牌榜)
- 移动端响应式适配
- 侧边栏布局 + 抽屉菜单
- 自动生成初始化数据接口
This commit is contained in:
Administrator 2026-03-17 22:29:18 +08:00
commit 22f073d8e7
26 changed files with 10871 additions and 0 deletions

44
.gitignore vendored Normal file
View 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
View 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
View File

@ -0,0 +1,5 @@
<template>
<NuxtLayout>
<NuxtPage />
</NuxtLayout>
</template>

222
app/layouts/default.vue Normal file
View 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
View 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
View 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>径赛100m200m400m4×100m4×400m20×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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

8
pnpm-workspace.yaml Normal file
View File

@ -0,0 +1,8 @@
ignoredBuiltDependencies:
- '@parcel/watcher'
- unrs-resolver
onlyBuiltDependencies:
- esbuild
- opencode-ai
- vue-demi

BIN
public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

2
public/robots.txt Normal file
View File

@ -0,0 +1,2 @@
User-Agent: *
Disallow:

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

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

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

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

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

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

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

View 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
View 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
View 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"
}
]
}