feat: enhance transitions and animations with UnoCSS

- Add advanced page/layout transitions (slide, zoom, fade with blur)
- Enhance UnoCSS config with keyframes, transitions, and shortcuts
- Add staggered animations for stat cards and table rows
- Improve sidebar menu animations with slide-in effects
- Add custom dialog/drawer transitions with bounce effects
- Optimize transition timing with cubic-bezier easing

Based on Element Plus transitions guide and Nuxt 4 transitions docs
This commit is contained in:
烧瑚烙饼 2026-03-22 08:30:49 +08:00
parent 9303fd2f45
commit e73f62133b
7 changed files with 958 additions and 270 deletions

View File

@ -4,3 +4,114 @@
<NuxtPage /> <NuxtPage />
</NuxtLayout> </NuxtLayout>
</template> </template>
<style>
/* Page Transitions - Enhanced with multiple effects */
.page-enter-active,
.page-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
width: 100%;
}
.page-enter-from {
opacity: 0;
transform: translateY(20px) scale(0.98);
filter: blur(2px);
}
.page-leave-to {
opacity: 0;
transform: translateY(-20px) scale(1.02);
filter: blur(2px);
}
/* Slide transitions for dynamic direction */
.page-slide-left-enter-from {
opacity: 0;
transform: translateX(50px);
}
.page-slide-left-leave-to {
opacity: 0;
transform: translateX(-50px);
}
.page-slide-right-enter-from {
opacity: 0;
transform: translateX(-50px);
}
.page-slide-right-leave-to {
opacity: 0;
transform: translateX(50px);
}
/* Zoom transitions */
.page-zoom-enter-from {
opacity: 0;
transform: scale(0.9);
}
.page-zoom-leave-to {
opacity: 0;
transform: scale(1.1);
}
/* Layout Transitions - Enhanced */
.layout-enter-active,
.layout-leave-active {
transition: all 0.4s cubic-bezier(0.4, 0, 0.2, 1);
position: absolute;
width: 100%;
}
.layout-enter-from {
opacity: 0;
filter: grayscale(100%) brightness(0.8);
transform: scale(0.95);
}
.layout-leave-to {
opacity: 0;
filter: grayscale(100%) brightness(1.2);
transform: scale(1.05);
}
/* Collapse transition for sidebar */
.sidebar-collapse-enter-active,
.sidebar-collapse-leave-active {
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Custom dialog/drawer transitions */
.dialog-zoom :deep(.el-dialog) {
animation: custom-zoom 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
.drawer-transition :deep(.el-drawer) {
animation: custom-slide-in 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
}
@keyframes custom-zoom {
0% {
opacity: 0;
transform: scale(0.8) translateY(-20px);
}
100% {
opacity: 1;
transform: scale(1) translateY(0);
}
}
@keyframes custom-slide-in {
0% {
opacity: 0;
transform: translateX(-100%);
}
100% {
opacity: 1;
transform: translateX(0);
}
}
</style>

View File

@ -1,12 +1,12 @@
<template> <template>
<el-container class="layout-container"> <el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only"> <el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only transition-base">
<div class="logo"> <div class="logo">
<h2 v-if="!collapsed">运动会记分板</h2> <h2 v-if="!collapsed" class="slide-down-enter">运动会记分板</h2>
<el-icon v-else :size="24"><Trophy /></el-icon> <el-icon v-else :size="24" class="slide-down-enter"><Trophy /></el-icon>
</div> </div>
<div class="sidebar-toggle"> <div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small"> <el-button text @click="toggleCollapse" size="small" class="rotate-icon">
<el-icon> <el-icon>
<ArrowLeft v-if="!collapsed" /> <ArrowLeft v-if="!collapsed" />
<ArrowRight v-else /> <ArrowRight v-else />
@ -19,9 +19,10 @@
:collapse="collapsed" :collapse="collapsed"
:collapse-transition="false" :collapse-transition="false"
> >
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item <el-menu-item
v-for="item in menuItems" v-if="!item.children"
:key="item.path"
:index="item.path" :index="item.path"
@click="onMenuClick(item.path)" @click="onMenuClick(item.path)"
> >
@ -29,6 +30,27 @@
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </el-menu-item>
<!-- Submenu with children -->
<el-sub-menu
v-else
:index="item.label"
>
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
@click="onMenuClick(child.path)"
>
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
<el-sub-menu v-if="hasAdminMenu" index="admin"> <el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
@ -54,6 +76,12 @@
<h3>运动会管理系统</h3> <h3>运动会管理系统</h3>
</div> </div>
<div class="header-right"> <div class="header-right">
<el-button text @click="toggleDark()" class="theme-toggle-btn">
<el-icon>
<Sunny v-if="isDark" />
<Moon v-else />
</el-icon>
</el-button>
<el-dropdown v-if="currentUser" @command="handleUserCommand"> <el-dropdown v-if="currentUser" @command="handleUserCommand">
<span class="user-info"> <span class="user-info">
<el-avatar :size="32"> <el-avatar :size="32">
@ -86,7 +114,7 @@
direction="ltr" direction="ltr"
size="70%" size="70%"
:with-header="false" :with-header="false"
class="mobile-only" class="mobile-only drawer-transition"
> >
<div class="drawer-logo"> <div class="drawer-logo">
<h2>运动会记分板</h2> <h2>运动会记分板</h2>
@ -95,9 +123,10 @@
:default-active="activeMenu" :default-active="activeMenu"
class="drawer-menu" class="drawer-menu"
> >
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item <el-menu-item
v-for="item in menuItems" v-if="!item.children"
:key="item.path"
:index="item.path" :index="item.path"
@click="onMenuClick(item.path, true)" @click="onMenuClick(item.path, true)"
> >
@ -105,6 +134,27 @@
<span>{{ item.label }}</span> <span>{{ item.label }}</span>
</el-menu-item> </el-menu-item>
<!-- Submenu with children -->
<el-sub-menu
v-else
:index="item.label"
>
<template #title>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</template>
<el-menu-item
v-for="child in item.children"
:key="child.path"
:index="child.path"
@click="onMenuClick(child.path, true)"
>
<el-icon><component :is="child.icon" /></el-icon>
<span>{{ child.label }}</span>
</el-menu-item>
</el-sub-menu>
</template>
<el-sub-menu v-if="hasAdminMenu" index="admin"> <el-sub-menu v-if="hasAdminMenu" index="admin">
<template #title> <template #title>
<el-icon><Setting /></el-icon> <el-icon><Setting /></el-icon>
@ -122,7 +172,7 @@
</el-menu> </el-menu>
</el-drawer> </el-drawer>
<el-dialog v-model="showLoginDialog" title="登录" width="400px"> <el-dialog v-model="showLoginDialog" title="登录" width="400px" class="dialog-zoom">
<el-form :model="loginForm" :rules="loginRules" ref="loginFormRef"> <el-form :model="loginForm" :rules="loginRules" ref="loginFormRef">
<el-form-item prop="username"> <el-form-item prop="username">
<el-input <el-input
@ -150,7 +200,8 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue' import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown, LocationFilled, Sunny, Moon } from '@element-plus/icons-vue'
import { useDark, useToggle } from '@vueuse/core'
const route = useRoute() const route = useRoute()
const activeMenu = computed(() => route.path) const activeMenu = computed(() => route.path)
@ -159,6 +210,16 @@ const showLoginDialog = ref(false)
const logging = ref(false) const logging = ref(false)
const loginFormRef = ref() const loginFormRef = ref()
// 使 VueUse useDark
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
storageKey: 'theme',
})
const toggleDark = useToggle(isDark)
const collapsed = ref(false) const collapsed = ref(false)
const currentUser = ref<any>(null) const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin') const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
@ -175,11 +236,18 @@ const loginRules = {
const menuItems = [ const menuItems = [
{ path: '/', label: '首页', icon: HomeFilled }, { path: '/', label: '首页', icon: HomeFilled },
{
label: '比赛管理',
icon: Trophy,
children: [
{ path: '/events', label: '比赛项目', icon: Trophy }, { path: '/events', label: '比赛项目', icon: Trophy },
{ path: '/teams', label: '队伍管理', icon: UserFilled }, { path: '/teams', label: '队伍管理', icon: UserFilled },
{ path: '/results', label: '成绩录入', icon: Edit }, { path: '/results', label: '成绩录入', icon: Edit },
{ path: '/scoreboard', label: '记分板', icon: DataLine } { path: '/scoreboard', label: '记分板', icon: DataLine }
] ]
},
{ path: '/checkins', label: '机位打卡', icon: LocationFilled }
]
const toggleCollapse = () => { const toggleCollapse = () => {
collapsed.value = !collapsed.value collapsed.value = !collapsed.value
@ -243,9 +311,45 @@ onMounted(() => {
if (savedUser) { if (savedUser) {
currentUser.value = JSON.parse(savedUser) currentUser.value = JSON.parse(savedUser)
} }
// VueUse useDark
}) })
</script> </script>
<!-- 全局主题变量 -->
<style global>
:root {
--sidebar-bg: #1e293b;
--sidebar-header-bg: #0f172a;
--sidebar-hover-bg: #334155;
--sidebar-text: #94a3b8;
--sidebar-active-text: #fff;
--sidebar-active-bg: #3b82f6;
--header-bg: #ffffff;
--header-border: #e2e8f0;
--header-text: #1e293b;
--main-bg: #f8fafc;
--footer-bg: #ffffff;
--footer-border: #e2e8f0;
--footer-text: #64748b;
}
html.dark {
--sidebar-bg: #0f172a;
--sidebar-header-bg: #020617;
--sidebar-hover-bg: #1e293b;
--sidebar-text: #94a3b8;
--sidebar-active-text: #fff;
--sidebar-active-bg: #3b82f6;
--header-bg: #1e293b;
--header-border: #334155;
--header-text: #f1f5f9;
--main-bg: #0f172a;
--footer-bg: #1e293b;
--footer-border: #334155;
--footer-text: #94a3b8;
}
</style>
<style scoped> <style scoped>
.layout-container { .layout-container {
height: 100vh; height: 100vh;
@ -256,73 +360,165 @@ onMounted(() => {
overflow-y: auto; overflow-y: auto;
} }
/* 侧边栏 - 始终使用深色背景 */
.sidebar { .sidebar {
background-color: #304156; background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: #fff; color: var(--sidebar-text);
transition: width 0.3s; transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden; overflow: hidden;
height: 100%; height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
} }
.logo { .logo {
height: 56px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #2b3a4a; background-color: var(--sidebar-header-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
} }
.logo h2 { .logo h2 {
color: #fff; color: #fff;
font-size: 16px; font-size: 18px;
font-weight: 600;
margin: 0; margin: 0;
white-space: nowrap; white-space: nowrap;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
} }
.sidebar-toggle { .sidebar-toggle {
display: flex; display: flex;
justify-content: flex-end; justify-content: flex-end;
padding: 4px 8px; padding: 8px 12px;
border-bottom: 1px solid #263445; border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.rotate-icon {
transition: transform 0.3s ease;
}
.rotate-icon:hover {
transform: rotate(180deg);
}
/* 菜单样式 */
.sidebar-menu { .sidebar-menu {
border-right: none; border-right: none;
background-color: #304156; background-color: transparent;
} }
.sidebar-menu:not(.el-menu--collapse) { .sidebar-menu:not(.el-menu--collapse) {
width: 200px; width: 100%;
} }
.sidebar-menu .el-menu-item, .sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title { .sidebar-menu .el-sub-menu__title {
color: #bfcbd9; color: var(--sidebar-text);
height: 48px;
line-height: 48px;
margin: 4px 8px;
border-radius: 8px;
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
animation: slide-in-left 0.3s ease-out;
animation-fill-mode: both;
}
/* Staggered animation for menu items */
.sidebar-menu .el-menu-item:nth-child(1),
.sidebar-menu .el-sub-menu:nth-child(1) {
animation-delay: 0.05s;
}
.sidebar-menu .el-menu-item:nth-child(2),
.sidebar-menu .el-sub-menu:nth-child(2) {
animation-delay: 0.1s;
}
.sidebar-menu .el-menu-item:nth-child(3),
.sidebar-menu .el-sub-menu:nth-child(3) {
animation-delay: 0.15s;
}
.sidebar-menu .el-menu-item:nth-child(4),
.sidebar-menu .el-sub-menu:nth-child(4) {
animation-delay: 0.2s;
}
.sidebar-menu .el-menu-item:nth-child(5),
.sidebar-menu .el-sub-menu:nth-child(5) {
animation-delay: 0.25s;
}
@keyframes slide-in-left {
0% {
opacity: 0;
transform: translateX(-20px);
}
100% {
opacity: 1;
transform: translateX(0);
}
} }
.sidebar-menu .el-menu-item:hover, .sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover, .sidebar-menu .el-sub-menu__title:hover {
background-color: var(--sidebar-hover-bg);
color: #fff;
transform: translateX(5px);
}
.sidebar-menu .el-menu-item.is-active, .sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-menu .el-menu-item.is-active { .sidebar-menu .el-sub-menu .el-menu-item.is-active {
background-color: #263445; background-color: var(--sidebar-active-bg);
color: #409eff; color: var(--sidebar-active-text);
font-weight: 500;
transform: translateX(5px);
}
.sidebar-menu .el-menu-item.is-active::before {
content: '';
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
width: 3px;
height: 24px;
background-color: #fff;
border-radius: 0 3px 3px 0;
}
/* 子菜单样式 */
.sidebar-menu .el-sub-menu .el-menu-item {
padding-left: 20px !important;
margin: 2px 8px;
}
/* 菜单图标 */
.sidebar-menu .el-icon {
margin-right: 8px;
font-size: 18px;
} }
.header { .header {
background-color: #fff; background-color: var(--header-bg);
border-bottom: 1px solid #e6e6e6; border-bottom: 1px solid var(--header-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 24px;
height: 56px; height: 60px;
flex-shrink: 0; flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 12px;
} }
.header-right { .header-right {
@ -332,7 +528,9 @@ onMounted(() => {
.header h3 { .header h3 {
margin: 0; margin: 0;
color: #303133; color: var(--header-text);
font-size: 18px;
font-weight: 600;
} }
.user-info { .user-info {
@ -340,37 +538,78 @@ onMounted(() => {
align-items: center; align-items: center;
gap: 8px; gap: 8px;
cursor: pointer; cursor: pointer;
padding: 6px 12px;
border-radius: 8px;
transition: all 0.3s ease;
}
.user-info:hover {
background-color: var(--sidebar-hover-bg);
transform: translateY(-2px);
} }
.username { .username {
color: #303133; color: var(--header-text);
font-weight: 500;
}
.theme-toggle-btn {
margin-right: 12px;
color: var(--header-text);
border-radius: 8px;
padding: 8px;
transition: all 0.3s ease;
}
.theme-toggle-btn:hover {
background-color: var(--sidebar-hover-bg) !important;
color: #fff;
transform: rotate(30deg);
} }
.main-content { .main-content {
background-color: #f0f2f5; background-color: var(--main-bg);
padding: 20px; padding: 24px;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
overflow-y: auto; overflow-y: auto;
animation: fade-in 0.4s ease-out;
} }
/* 移动端抽屉 */
.drawer-logo { .drawer-logo {
height: 56px; height: 60px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
background-color: #2b3a4a; background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: #fff; border-bottom: 1px solid rgba(255, 255, 255, 0.05);
} }
.drawer-logo h2 { .drawer-logo h2 {
margin: 0; margin: 0;
font-size: 18px; font-size: 18px;
font-weight: 600;
color: #fff; color: #fff;
} }
.drawer-menu { .drawer-menu {
border-right: none; border-right: none;
background-color: var(--sidebar-bg);
}
.drawer-menu .el-menu-item,
.drawer-menu .el-sub-menu__title,
.drawer-menu .el-sub-menu .el-menu-item {
color: var(--sidebar-text);
}
.drawer-menu .el-menu-item:hover,
.drawer-menu .el-sub-menu__title:hover,
.drawer-menu .el-menu-item.is-active,
.drawer-menu .el-sub-menu .el-menu-item.is-active {
background-color: var(--sidebar-hover-bg);
color: #fff;
} }
.desktop-only { .desktop-only {
@ -385,7 +624,7 @@ onMounted(() => {
display: none; display: none;
} }
@media (max-width: 900px) { @media (max-width: 768px) {
.desktop-only { .desktop-only {
display: none; display: none;
} }
@ -399,17 +638,17 @@ onMounted(() => {
} }
.header { .header {
padding: 0 12px; padding: 0 16px;
} }
.main-content { .main-content {
padding: 12px; padding: 16px;
} }
} }
.footer { .footer {
background-color: #fff; background-color: var(--footer-bg);
border-top: 1px solid #e6e6e6; border-top: 1px solid var(--footer-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
@ -420,7 +659,7 @@ onMounted(() => {
.footer p { .footer p {
margin: 0; margin: 0;
color: #909399; color: var(--footer-text);
font-size: 12px; font-size: 13px;
} }
</style> </style>

View File

@ -9,6 +9,7 @@
</template> </template>
<el-form :inline="true" class="filter-form"> <el-form :inline="true" class="filter-form">
<!-- 类别筛选 -->
<el-form-item label="类别"> <el-form-item label="类别">
<el-select v-model="filters.category" placeholder="全部" clearable @change="loadEvents"> <el-select v-model="filters.category" placeholder="全部" clearable @change="loadEvents">
<el-option label="田赛" value="田赛" /> <el-option label="田赛" value="田赛" />
@ -16,14 +17,30 @@
<el-option label="团体赛" value="团体赛" /> <el-option label="团体赛" value="团体赛" />
</el-select> </el-select>
</el-form-item> </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-form-item label="年级">
<el-select v-model="filters.grade" placeholder="全部" clearable @change="onFilterChange">
<el-option v-for="g in config.grades" :key="g" :label="g" :value="g" />
</el-select>
</el-form-item>
<!-- 班级类型筛选 -->
<el-form-item label="班级类型">
<el-select v-model="filters.classType" placeholder="全部" clearable :disabled="!filters.grade" @change="onFilterChange">
<el-option v-for="c in config.classTypes" :key="c" :label="c" :value="c" />
</el-select>
</el-form-item>
<!-- 性别筛选 -->
<el-form-item label="性别">
<el-select v-model="filters.gender" placeholder="全部" clearable :disabled="!filters.grade" @change="onFilterChange">
<el-option v-for="g in config.genders" :key="g" :label="g" :value="g" />
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-table :data="events" border stripe class="events-table"> <el-table :data="events" border stripe class="events-table transition-base">
<el-table-column prop="id" label="ID" width="80" class-name="col-id" /> <el-table-column prop="id" label="ID" width="80" class-name="col-id" />
<el-table-column prop="name" label="项目名称" /> <el-table-column prop="name" label="项目名称" />
<el-table-column prop="category" label="类别" width="120" class-name="col-category" /> <el-table-column prop="category" label="类别" width="120" class-name="col-category" />
@ -60,7 +77,12 @@
</el-form-item> </el-form-item>
<el-form-item label="组别"> <el-form-item label="组别">
<el-select v-model="form.event_group" placeholder="请选择"> <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-option
v-for="g in allGroupOptions"
:key="g.value"
:label="g.label"
:value="g.value"
/>
</el-select> </el-select>
</el-form-item> </el-form-item>
</el-form> </el-form>
@ -74,11 +96,22 @@
<script setup lang="ts"> <script setup lang="ts">
import { ElMessage } from 'element-plus' import { ElMessage } from 'element-plus'
import { computed } from 'vue'
const events = ref([]) const events = ref([])
const showAddDialog = ref(false) const showAddDialog = ref(false)
const filters = ref({ category: '', group: '' }) const config = ref({
const groups = ref([]) grades: [] as string[],
classTypes: [] as string[],
genders: [] as string[],
all: [] as string[]
})
const filters = ref({
category: '',
grade: '',
classType: '',
gender: ''
})
const eventTypes = ref({}) const eventTypes = ref({})
const form = ref({ const form = ref({
@ -93,15 +126,15 @@ const availableEvents = computed(() => {
return eventTypes.value[form.value.category] || [] return eventTypes.value[form.value.category] || []
}) })
const onCategoryChange = () => { // config API
form.value.name = '' const allGroupOptions = computed(() => {
form.value.unit = '' return (config.value.all || []).map(g => ({ value: g, label: g }))
} })
const loadConfig = async () => { const loadConfig = async () => {
try { try {
const res = await $fetch('/api/config') const res = await $fetch('/api/config')
groups.value = res.data.groups config.value = res.data.groups
eventTypes.value = res.data.eventTypes eventTypes.value = res.data.eventTypes
} catch (error) { } catch (error) {
ElMessage.error('加载配置失败') ElMessage.error('加载配置失败')
@ -112,7 +145,9 @@ const loadEvents = async () => {
try { try {
const params = new URLSearchParams() const params = new URLSearchParams()
if (filters.value.category) params.append('category', filters.value.category) if (filters.value.category) params.append('category', filters.value.category)
if (filters.value.group) params.append('group', filters.value.group) if (filters.value.grade) params.append('grade', filters.value.grade)
if (filters.value.classType) params.append('classType', filters.value.classType)
if (filters.value.gender) params.append('gender', filters.value.gender)
const res = await $fetch(`/api/events?${params}`) const res = await $fetch(`/api/events?${params}`)
events.value = res.data events.value = res.data
@ -121,6 +156,15 @@ const loadEvents = async () => {
} }
} }
const onFilterChange = () => {
loadEvents()
}
const onCategoryChange = () => {
form.value.name = ''
form.value.unit = ''
}
const addEvent = async () => { const addEvent = async () => {
try { try {
const selectedEvent = availableEvents.value.find(e => e.name === form.value.name) const selectedEvent = availableEvents.value.find(e => e.name === form.value.name)
@ -170,6 +214,34 @@ onMounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* Table row staggered animations */
.events-table :deep(.el-table__body tr) {
animation: slide-in-up 0.3s ease-out;
animation-fill-mode: both;
}
.events-table :deep(.el-table__body tr:nth-child(1)) { animation-delay: 0.05s; }
.events-table :deep(.el-table__body tr:nth-child(2)) { animation-delay: 0.1s; }
.events-table :deep(.el-table__body tr:nth-child(3)) { animation-delay: 0.15s; }
.events-table :deep(.el-table__body tr:nth-child(4)) { animation-delay: 0.2s; }
.events-table :deep(.el-table__body tr:nth-child(5)) { animation-delay: 0.25s; }
.events-table :deep(.el-table__body tr:nth-child(6)) { animation-delay: 0.3s; }
.events-table :deep(.el-table__body tr:nth-child(7)) { animation-delay: 0.35s; }
.events-table :deep(.el-table__body tr:nth-child(8)) { animation-delay: 0.4s; }
.events-table :deep(.el-table__body tr:nth-child(9)) { animation-delay: 0.45s; }
.events-table :deep(.el-table__body tr:nth-child(10)) { animation-delay: 0.5s; }
@keyframes slide-in-up {
0% {
opacity: 0;
transform: translateY(15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.card-header { .card-header {
flex-direction: column; flex-direction: column;

View File

@ -1,13 +1,13 @@
<template> <template>
<div class="home-container"> <div class="home-container">
<el-card class="welcome-card"> <el-card class="welcome-card slide-down-enter">
<h1>欢迎使用运动会记分板系统</h1> <h1>欢迎使用运动会记分板系统</h1>
<p>本系统用于管理运动会的比赛项目队伍和成绩</p> <p>本系统用于管理运动会的比赛项目队伍和成绩</p>
</el-card> </el-card>
<el-row :gutter="20" class="stats-row"> <el-row :gutter="20" class="stats-row">
<el-col :xs="12" :sm="12" :md="6" :lg="6"> <el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card class="stat-card"> <el-card class="stat-card bounce-enter" :class="staggerClass(0)">
<div class="stat-content"> <div class="stat-content">
<el-icon class="stat-icon" color="#409eff"><Trophy /></el-icon> <el-icon class="stat-icon" color="#409eff"><Trophy /></el-icon>
<div> <div>
@ -18,7 +18,7 @@
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6"> <el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card class="stat-card"> <el-card class="stat-card bounce-enter" :class="staggerClass(1)">
<div class="stat-content"> <div class="stat-content">
<el-icon class="stat-icon" color="#67c23a"><UserFilled /></el-icon> <el-icon class="stat-icon" color="#67c23a"><UserFilled /></el-icon>
<div> <div>
@ -29,7 +29,7 @@
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6"> <el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card class="stat-card"> <el-card class="stat-card bounce-enter" :class="staggerClass(2)">
<div class="stat-content"> <div class="stat-content">
<el-icon class="stat-icon" color="#e6a23c"><Edit /></el-icon> <el-icon class="stat-icon" color="#e6a23c"><Edit /></el-icon>
<div> <div>
@ -40,7 +40,7 @@
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="12" :sm="12" :md="6" :lg="6"> <el-col :xs="12" :sm="12" :md="6" :lg="6">
<el-card class="stat-card"> <el-card class="stat-card bounce-enter" :class="staggerClass(3)">
<div class="stat-content"> <div class="stat-content">
<el-icon class="stat-icon" color="#f56c6c"><DataLine /></el-icon> <el-icon class="stat-icon" color="#f56c6c"><DataLine /></el-icon>
<div> <div>
@ -54,38 +54,37 @@
<el-row :gutter="20"> <el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="12" :lg="12"> <el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card> <el-card class="slide-left-enter">
<template #header> <template #header>
<span>快速操作</span> <span>快速操作</span>
</template> </template>
<div class="quick-actions"> <div class="quick-actions">
<el-row :gutter="10"> <el-row :gutter="10">
<el-col :span="12"> <el-col :span="12">
<el-button type="primary" class="action-btn" @click="navigateTo('/events')">管理比赛项目</el-button> <el-button type="primary" class="action-btn zoom-enter" @click="navigateTo('/events')">管理比赛项目</el-button>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-button type="success" class="action-btn" @click="navigateTo('/teams')">管理队伍</el-button> <el-button type="success" class="action-btn zoom-enter" :class="staggerClass(1)" @click="navigateTo('/teams')">管理队伍</el-button>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-button type="warning" class="action-btn" @click="navigateTo('/results')">录入成绩</el-button> <el-button type="warning" class="action-btn zoom-enter" :class="staggerClass(2)" @click="navigateTo('/results')">录入成绩</el-button>
</el-col> </el-col>
<el-col :span="12"> <el-col :span="12">
<el-button type="info" class="action-btn" @click="navigateTo('/scoreboard')">查看记分板</el-button> <el-button type="info" class="action-btn zoom-enter" :class="staggerClass(3)" @click="navigateTo('/scoreboard')">查看记分板</el-button>
</el-col> </el-col>
</el-row> </el-row>
</div> </div>
</el-card> </el-card>
</el-col> </el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12"> <el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card> <el-card class="slide-right-enter">
<template #header> <template #header>
<span>系统说明</span> <span>系统说明</span>
</template> </template>
<ul class="system-info"> <ul class="system-info">
<li>田赛跳高跳远掷铅球</li> <li v-for="(item, index) in systemInfo" :key="index" class="fade-enter" :class="staggerClass(index)">
<li>径赛100m200m400m4×100m4×400m20×50m</li> {{ item }}
<li>团体赛旱地龙舟跳长绳折返跑</li> </li>
<li>组别教师组航空班组体育班组文化班甲组文化班乙组</li>
</ul> </ul>
</el-card> </el-card>
</el-col> </el-col>
@ -96,6 +95,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { Trophy, UserFilled, Edit, DataLine } from '@element-plus/icons-vue' import { Trophy, UserFilled, Edit, DataLine } from '@element-plus/icons-vue'
import { fetchEvents, fetchTeams, fetchResults } from '~/modules/scoreboard/api' import { fetchEvents, fetchTeams, fetchResults } from '~/modules/scoreboard/api'
import { computed } from 'vue'
const stats = ref({ const stats = ref({
events: 0, events: 0,
@ -104,6 +104,26 @@ const stats = ref({
categories: 3 categories: 3
}) })
// System info list
const systemInfo = computed(() => [
'田赛:跳高、跳远、掷铅球',
'径赛100m、200m、400m、4×100m、4×400m、20×50m',
'团体赛:旱地龙舟、跳长绳、折返跑',
'组别:教师组、航空班组、体育班组、文化班甲组、文化班乙组'
])
// Staggered animation helper
const staggerClass = (index: number) => {
const classes = [
'stagger-1',
'stagger-2',
'stagger-3',
'stagger-4',
'stagger-5'
]
return classes[index] || ''
}
onMounted(async () => { onMounted(async () => {
try { try {
const [eventsRes, teamsRes, resultsRes] = await Promise.all([ const [eventsRes, teamsRes, resultsRes] = await Promise.all([

View File

@ -21,7 +21,7 @@
</el-form-item> </el-form-item>
</el-form> </el-form>
<el-table :data="results" border stripe class="results-table"> <el-table :data="results" border stripe class="results-table transition-base">
<el-table-column prop="event_name" label="项目名称" /> <el-table-column prop="event_name" label="项目名称" />
<el-table-column prop="team_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="team_group" label="组别" width="150" class-name="col-group" />
@ -188,6 +188,34 @@ onMounted(() => {
margin-bottom: 20px; margin-bottom: 20px;
} }
/* Table row staggered animations */
.results-table :deep(.el-table__body tr) {
animation: slide-in-up 0.3s ease-out;
animation-fill-mode: both;
}
.results-table :deep(.el-table__body tr:nth-child(1)) { animation-delay: 0.05s; }
.results-table :deep(.el-table__body tr:nth-child(2)) { animation-delay: 0.1s; }
.results-table :deep(.el-table__body tr:nth-child(3)) { animation-delay: 0.15s; }
.results-table :deep(.el-table__body tr:nth-child(4)) { animation-delay: 0.2s; }
.results-table :deep(.el-table__body tr:nth-child(5)) { animation-delay: 0.25s; }
.results-table :deep(.el-table__body tr:nth-child(6)) { animation-delay: 0.3s; }
.results-table :deep(.el-table__body tr:nth-child(7)) { animation-delay: 0.35s; }
.results-table :deep(.el-table__body tr:nth-child(8)) { animation-delay: 0.4s; }
.results-table :deep(.el-table__body tr:nth-child(9)) { animation-delay: 0.45s; }
.results-table :deep(.el-table__body tr:nth-child(10)) { animation-delay: 0.5s; }
@keyframes slide-in-up {
0% {
opacity: 0;
transform: translateY(15px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
@media (max-width: 900px) { @media (max-width: 900px) {
.card-header { .card-header {
flex-direction: column; flex-direction: column;

View File

@ -6,8 +6,26 @@ export default defineNuxtConfig({
modules: [ modules: [
'@nuxt/eslint', '@nuxt/eslint',
'@pinia/nuxt', '@pinia/nuxt',
'@unocss/nuxt',
'@element-plus/nuxt' '@element-plus/nuxt'
], ],
css: ['element-plus/dist/index.css'] unocss: {},
css: [
'unocss',
'element-plus/dist/index.css',
'element-plus/theme-chalk/dark/css-vars.css'
],
app: {
pageTransition: {
name: 'page',
mode: 'out-in'
},
layoutTransition: {
name: 'layout',
mode: 'out-in'
}
}
}) })

200
uno.config.ts Normal file
View File

@ -0,0 +1,200 @@
import { defineConfig, presetUno, presetAttributify, presetIcons } from 'unocss'
export default defineConfig({
presets: [
presetUno(),
presetAttributify(),
presetIcons({
scale: 1.2,
warn: true,
}),
],
// Include Element Plus compatibility by safelisting common component classes
safelist: [
// Layout components
'el-main',
'el-header',
'el-footer',
'el-aside',
'el-container',
// Card
'el-card',
'el-card__header',
// Buttons
'el-button',
'el-button--primary',
'el-button--success',
'el-button--warning',
'el-button--info',
'el-button--text',
// Forms
'el-form',
'el-form-item',
'el-form-item__label',
'el-input',
'el-select',
'el-select__input',
// Tables
'el-table',
'el-table__header',
'el-table__body',
'el-table-column',
// Dialogs
'el-dialog',
'el-dialog__header',
'el-dialog__body',
'el-dialog__footer',
// Drawer
'el-drawer',
// Menu
'el-menu',
'el-menu-item',
'el-sub-menu',
'el-sub-menu__title',
// Dropdown
'el-dropdown',
'el-dropdown-menu',
'el-dropdown-item',
// Avatar
'el-avatar',
// Tags
'el-tag',
// Icons
'el-icon',
// Grid
'el-row',
'el-col',
// Input number
'el-input-number',
// Message
'el-message',
// Notification
'el-notification',
],
theme: {
extend: {
colors: {
// Preserve Element Plus color compatibility
primary: '#409eff',
success: '#67c23a',
warning: '#e6a23c',
danger: '#f56c6c',
info: '#909399',
},
spacing: {
// Custom spacing scale based on existing CSS usage
1: '0.25rem',
2: '0.5rem',
3: '0.75rem',
4: '1rem',
5: '1.25rem',
6: '1.5rem',
8: '2rem',
10: '2.5rem',
12: '3rem',
16: '4rem',
20: '5rem',
24: '6rem',
},
transitionDuration: {
'0': '0ms',
'75': '75ms',
'100': '100ms',
'150': '150ms',
'200': '200ms',
'300': '300ms',
'400': '400ms',
'500': '500ms',
'600': '600ms',
'700': '700ms',
'800': '800ms',
'900': '900ms',
'1000': '1000ms',
},
transitionTimingFunction: {
'ease-linear': 'linear',
'ease-in': 'cubic-bezier(0.4, 0, 1, 1)',
'ease-out': 'cubic-bezier(0, 0, 0.2, 1)',
'ease-in-out': 'cubic-bezier(0.4, 0, 0.2, 1)',
},
keyframes: {
'fade-in': {
'0%': { opacity: '0' },
'100%': { opacity: '1' },
},
'fade-out': {
'0%': { opacity: '1' },
'100%': { opacity: '0' },
},
'slide-up': {
'0%': { transform: 'translateY(20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'slide-down': {
'0%': { transform: 'translateY(-20px)', opacity: '0' },
'100%': { transform: 'translateY(0)', opacity: '1' },
},
'slide-left': {
'0%': { transform: 'translateX(20px)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' },
},
'slide-right': {
'0%': { transform: 'translateX(-20px)', opacity: '0' },
'100%': { transform: 'translateX(0)', opacity: '1' },
},
'zoom-in': {
'0%': { transform: 'scale(0.9)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
'zoom-out': {
'0%': { transform: 'scale(1.1)', opacity: '0' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
'bounce-in': {
'0%': { transform: 'scale(0.3)', opacity: '0' },
'50%': { transform: 'scale(1.05)' },
'70%': { transform: 'scale(0.9)' },
'100%': { transform: 'scale(1)', opacity: '1' },
},
},
},
},
shortcuts: {
// Transition utilities
'transition-base': 'transition-all duration-300 ease-in-out',
'transition-fast': 'transition-all duration-150 ease-in-out',
'transition-slow': 'transition-all duration-500 ease-in-out',
'transition-none': 'transition-none',
// Fade utilities
'fade-enter': 'animate-fade-in',
'fade-exit': 'animate-fade-out',
// Slide utilities
'slide-up-enter': 'animate-slide-up',
'slide-down-enter': 'animate-slide-down',
'slide-left-enter': 'animate-slide-left',
'slide-right-enter': 'animate-slide-right',
// Zoom utilities
'zoom-enter': 'animate-zoom-in',
'zoom-exit': 'animate-zoom-out',
// Bounce
'bounce-enter': 'animate-bounce-in',
// Staggered animations for lists
'stagger-1': 'transition-all duration-300 delay-100',
'stagger-2': 'transition-all duration-300 delay-200',
'stagger-3': 'transition-all duration-300 delay-300',
'stagger-4': 'transition-all duration-300 delay-400',
'stagger-5': 'transition-all duration-300 delay-500',
},
rules: [
// Custom transition classes that can be applied conditionally
['enter-active', { transition: 'all 0.3s ease-out' }],
['leave-active', { transition: 'all 0.3s ease-in' }],
['enter-from', { opacity: '0', transform: 'translateY(10px)' }],
['leave-to', { opacity: '0', transform: 'translateY(-10px)' }],
],
})