Compare commits

...

1 Commits

Author SHA1 Message Date
e73f62133b 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
2026-03-22 08:30:49 +08:00
7 changed files with 958 additions and 270 deletions

View File

@ -4,3 +4,114 @@
<NuxtPage />
</NuxtLayout>
</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,33 +1,55 @@
<template>
<el-container class="layout-container">
<el-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only">
<div class="logo">
<h2 v-if="!collapsed">运动会记分板</h2>
<el-icon v-else :size="24"><Trophy /></el-icon>
</div>
<div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small">
<el-icon>
<ArrowLeft v-if="!collapsed" />
<ArrowRight v-else />
</el-icon>
</el-button>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="collapsed"
:collapse-transition="false"
>
<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-aside :width="collapsed ? '64px' : '200px'" class="sidebar desktop-only transition-base">
<div class="logo">
<h2 v-if="!collapsed" class="slide-down-enter">运动会记分板</h2>
<el-icon v-else :size="24" class="slide-down-enter"><Trophy /></el-icon>
</div>
<div class="sidebar-toggle">
<el-button text @click="toggleCollapse" size="small" class="rotate-icon">
<el-icon>
<ArrowLeft v-if="!collapsed" />
<ArrowRight v-else />
</el-icon>
</el-button>
</div>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
:collapse="collapsed"
:collapse-transition="false"
>
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item
v-if="!item.children"
:index="item.path"
@click="onMenuClick(item.path)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</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">
<template #title>
@ -53,8 +75,14 @@
</el-button>
<h3>运动会管理系统</h3>
</div>
<div class="header-right">
<el-dropdown v-if="currentUser" @command="handleUserCommand">
<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">
<span class="user-info">
<el-avatar :size="32">
<el-icon><UserFilled /></el-icon>
@ -86,24 +114,46 @@
direction="ltr"
size="70%"
:with-header="false"
class="mobile-only"
class="mobile-only drawer-transition"
>
<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
:default-active="activeMenu"
class="drawer-menu"
>
<template v-for="item in menuItems" :key="item.path || item.label">
<!-- Regular menu item -->
<el-menu-item
v-if="!item.children"
:index="item.path"
@click="onMenuClick(item.path, true)"
>
<el-icon><component :is="item.icon" /></el-icon>
<span>{{ item.label }}</span>
</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">
<template #title>
@ -122,7 +172,7 @@
</el-menu>
</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-item prop="username">
<el-input
@ -149,19 +199,30 @@
</el-dialog>
</template>
<script setup lang="ts">
import { HomeFilled, Trophy, UserFilled, Edit, DataLine, Menu, Setting, Key, ArrowLeft, ArrowRight, ArrowDown } from '@element-plus/icons-vue'
<script setup lang="ts">
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 activeMenu = computed(() => route.path)
const drawerOpen = ref(false)
const showLoginDialog = ref(false)
const logging = ref(false)
const loginFormRef = ref()
const route = useRoute()
const activeMenu = computed(() => route.path)
const drawerOpen = ref(false)
const showLoginDialog = ref(false)
const logging = ref(false)
const loginFormRef = ref()
const collapsed = ref(false)
const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
// 使 VueUse useDark
const isDark = useDark({
selector: 'html',
attribute: 'class',
valueDark: 'dark',
valueLight: '',
storageKey: 'theme',
})
const toggleDark = useToggle(isDark)
const collapsed = ref(false)
const currentUser = ref<any>(null)
const hasAdminMenu = computed(() => currentUser.value?.role === 'admin')
const loginForm = ref({
username: '',
@ -173,13 +234,20 @@ const loginRules = {
password: [{ required: true, message: '请输入密码', trigger: 'blur' }]
}
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 menuItems = [
{ path: '/', label: '首页', icon: HomeFilled },
{
label: '比赛管理',
icon: Trophy,
children: [
{ path: '/events', label: '比赛项目', icon: Trophy },
{ path: '/teams', label: '队伍管理', icon: UserFilled },
{ path: '/results', label: '成绩录入', icon: Edit },
{ path: '/scoreboard', label: '记分板', icon: DataLine }
]
},
{ path: '/checkins', label: '机位打卡', icon: LocationFilled }
]
const toggleCollapse = () => {
collapsed.value = !collapsed.value
@ -233,194 +301,365 @@ const handleLogin = async () => {
})
}
onMounted(() => {
const savedCollapsed = localStorage.getItem('sidebar-collapsed')
if (savedCollapsed !== null) {
collapsed.value = savedCollapsed === 'true'
onMounted(() => {
const savedCollapsed = localStorage.getItem('sidebar-collapsed')
if (savedCollapsed !== null) {
collapsed.value = savedCollapsed === 'true'
}
const savedUser = localStorage.getItem('user')
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
// VueUse useDark
})
</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>
.layout-container {
height: 100vh;
overflow: hidden;
}
const savedUser = localStorage.getItem('user')
if (savedUser) {
currentUser.value = JSON.parse(savedUser)
}
})
</script>
<style scoped>
.layout-container {
height: 100vh;
overflow: hidden;
}
.el-main {
overflow-y: auto;
}
.sidebar {
background-color: #304156;
color: #fff;
transition: width 0.3s;
overflow: hidden;
height: 100%;
}
.logo {
height: 56px;
display: flex;
align-items: center;
justify-content: center;
background-color: #2b3a4a;
}
.logo h2 {
color: #fff;
font-size: 16px;
margin: 0;
white-space: nowrap;
}
.sidebar-toggle {
display: flex;
justify-content: flex-end;
padding: 4px 8px;
border-bottom: 1px solid #263445;
}
.sidebar-menu {
border-right: none;
background-color: #304156;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 200px;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
color: #bfcbd9;
}
.sidebar-menu .el-menu-item:hover,
.sidebar-menu .el-sub-menu__title:hover,
.sidebar-menu .el-menu-item.is-active,
.sidebar-menu .el-sub-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;
justify-content: space-between;
padding: 0 20px;
height: 56px;
flex-shrink: 0;
}
.header-left {
display: flex;
align-items: center;
gap: 8px;
}
.header-right {
display: flex;
align-items: center;
}
.header h3 {
margin: 0;
color: #303133;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.username {
color: #303133;
}
.main-content {
background-color: #f0f2f5;
padding: 20px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
}
.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;
.el-main {
overflow-y: auto;
}
.mobile-only {
display: block;
/* 侧边栏 - 始终使用深色背景 */
.sidebar {
background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
color: var(--sidebar-text);
transition: width 0.3s cubic-bezier(0.4, 0, 0.2, 1);
overflow: hidden;
height: 100%;
box-shadow: 2px 0 8px rgba(0, 0, 0, 0.1);
}
.mobile-only-btn {
display: inline-flex;
.logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background-color: var(--sidebar-header-bg);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
overflow: hidden;
}
.logo h2 {
color: #fff;
font-size: 18px;
font-weight: 600;
margin: 0;
white-space: nowrap;
letter-spacing: 0.5px;
transition: opacity 0.2s ease;
}
.sidebar-toggle {
display: flex;
justify-content: flex-end;
padding: 8px 12px;
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 {
border-right: none;
background-color: transparent;
}
.sidebar-menu:not(.el-menu--collapse) {
width: 100%;
}
.sidebar-menu .el-menu-item,
.sidebar-menu .el-sub-menu__title {
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-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-sub-menu .el-menu-item.is-active {
background-color: var(--sidebar-active-bg);
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 {
padding: 0 12px;
background-color: var(--header-bg);
border-bottom: 1px solid var(--header-border);
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 24px;
height: 60px;
flex-shrink: 0;
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
}
.header-left {
display: flex;
align-items: center;
gap: 12px;
}
.header-right {
display: flex;
align-items: center;
}
.header h3 {
margin: 0;
color: var(--header-text);
font-size: 18px;
font-weight: 600;
}
.user-info {
display: flex;
align-items: center;
gap: 8px;
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 {
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 {
padding: 12px;
background-color: var(--main-bg);
padding: 24px;
height: 100%;
box-sizing: border-box;
overflow-y: auto;
animation: fade-in 0.4s ease-out;
}
}
.footer {
background-color: #fff;
border-top: 1px solid #e6e6e6;
display: flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0;
flex-shrink: 0;
}
/* 移动端抽屉 */
.drawer-logo {
height: 60px;
display: flex;
align-items: center;
justify-content: center;
background: linear-gradient(180deg, var(--sidebar-bg) 0%, var(--sidebar-header-bg) 100%);
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.footer p {
margin: 0;
color: #909399;
font-size: 12px;
}
</style>
.drawer-logo h2 {
margin: 0;
font-size: 18px;
font-weight: 600;
color: #fff;
}
.drawer-menu {
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 {
display: block;
}
.mobile-only {
display: none;
}
.mobile-only-btn {
display: none;
}
@media (max-width: 768px) {
.desktop-only {
display: none;
}
.mobile-only {
display: block;
}
.mobile-only-btn {
display: inline-flex;
}
.header {
padding: 0 16px;
}
.main-content {
padding: 16px;
}
}
.footer {
background-color: var(--footer-bg);
border-top: 1px solid var(--footer-border);
display: flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0;
flex-shrink: 0;
}
.footer p {
margin: 0;
color: var(--footer-text);
font-size: 13px;
}
</style>

View File

@ -9,6 +9,7 @@
</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="田赛" />
@ -16,14 +17,30 @@
<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-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-form-item>
</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="name" label="项目名称" />
<el-table-column prop="category" label="类别" width="120" class-name="col-category" />
@ -60,7 +77,12 @@
</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-option
v-for="g in allGroupOptions"
:key="g.value"
:label="g.label"
:value="g.value"
/>
</el-select>
</el-form-item>
</el-form>
@ -74,11 +96,22 @@
<script setup lang="ts">
import { ElMessage } from 'element-plus'
import { computed } from 'vue'
const events = ref([])
const showAddDialog = ref(false)
const filters = ref({ category: '', group: '' })
const groups = ref([])
const config = ref({
grades: [] as string[],
classTypes: [] as string[],
genders: [] as string[],
all: [] as string[]
})
const filters = ref({
category: '',
grade: '',
classType: '',
gender: ''
})
const eventTypes = ref({})
const form = ref({
@ -93,15 +126,15 @@ const availableEvents = computed(() => {
return eventTypes.value[form.value.category] || []
})
const onCategoryChange = () => {
form.value.name = ''
form.value.unit = ''
}
// config API
const allGroupOptions = computed(() => {
return (config.value.all || []).map(g => ({ value: g, label: g }))
})
const loadConfig = async () => {
try {
const res = await $fetch('/api/config')
groups.value = res.data.groups
config.value = res.data.groups
eventTypes.value = res.data.eventTypes
} catch (error) {
ElMessage.error('加载配置失败')
@ -112,7 +145,9 @@ 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)
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}`)
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 () => {
try {
const selectedEvent = availableEvents.value.find(e => e.name === form.value.name)
@ -170,6 +214,34 @@ onMounted(() => {
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) {
.card-header {
flex-direction: column;

View File

@ -1,13 +1,13 @@
<template>
<div class="home-container">
<el-card class="welcome-card">
<el-card class="welcome-card slide-down-enter">
<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">
<el-card class="stat-card bounce-enter" :class="staggerClass(0)">
<div class="stat-content">
<el-icon class="stat-icon" color="#409eff"><Trophy /></el-icon>
<div>
@ -18,7 +18,7 @@
</el-card>
</el-col>
<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">
<el-icon class="stat-icon" color="#67c23a"><UserFilled /></el-icon>
<div>
@ -29,7 +29,7 @@
</el-card>
</el-col>
<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">
<el-icon class="stat-icon" color="#e6a23c"><Edit /></el-icon>
<div>
@ -40,7 +40,7 @@
</el-card>
</el-col>
<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">
<el-icon class="stat-icon" color="#f56c6c"><DataLine /></el-icon>
<div>
@ -54,38 +54,37 @@
<el-row :gutter="20">
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card>
<el-card class="slide-left-enter">
<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-button type="primary" class="action-btn zoom-enter" @click="navigateTo('/events')">管理比赛项目</el-button>
</el-col>
<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 :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 :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-row>
</div>
</el-card>
</el-col>
<el-col :xs="24" :sm="24" :md="12" :lg="12">
<el-card>
<el-card class="slide-right-enter">
<template #header>
<span>系统说明</span>
</template>
<ul class="system-info">
<li>田赛跳高跳远掷铅球</li>
<li>径赛100m200m400m4×100m4×400m20×50m</li>
<li>团体赛旱地龙舟跳长绳折返跑</li>
<li>组别教师组航空班组体育班组文化班甲组文化班乙组</li>
<li v-for="(item, index) in systemInfo" :key="index" class="fade-enter" :class="staggerClass(index)">
{{ item }}
</li>
</ul>
</el-card>
</el-col>
@ -96,6 +95,7 @@
<script setup lang="ts">
import { Trophy, UserFilled, Edit, DataLine } from '@element-plus/icons-vue'
import { fetchEvents, fetchTeams, fetchResults } from '~/modules/scoreboard/api'
import { computed } from 'vue'
const stats = ref({
events: 0,
@ -104,6 +104,26 @@ const stats = ref({
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 () => {
try {
const [eventsRes, teamsRes, resultsRes] = await Promise.all([

View File

@ -21,7 +21,7 @@
</el-form-item>
</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="team_name" label="队伍" />
<el-table-column prop="team_group" label="组别" width="150" class-name="col-group" />
@ -188,6 +188,34 @@ onMounted(() => {
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) {
.card-header {
flex-direction: column;

View File

@ -6,8 +6,26 @@ export default defineNuxtConfig({
modules: [
'@nuxt/eslint',
'@pinia/nuxt',
'@unocss/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)' }],
],
})