Merge branch 'frontend/dev'

master
Spark 2 months ago
commit e4c0b60f4c

@ -0,0 +1,30 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
/cypress/videos/
/cypress/screenshots/
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo

@ -0,0 +1,9 @@
<script setup>
</script>
<template>
<router-view></router-view>
</template>
<style>
</style>

@ -0,0 +1,9 @@
import request from "@/utils/request.js";
export const userRegisterService = (registerData) => {
return request.post('/admin/register', registerData);
}
export const userLoginService = (loginDTO) => {
return request.post('/admin/login', loginDTO)
}

@ -0,0 +1,69 @@
import request from '@/utils/request.js'
import dayjs from "dayjs";
export const listStudent = () => {
return request.get('/student')
}
export const getStudentById = (id) => {
return request.get(`/student/${id}`)
}
export const pageStudent = (pageQueryDTO) => {
const params = new URLSearchParams();
const formattedQuery = { ...pageQueryDTO };
// 处理创建时间
if (formattedQuery.createTime && Array.isArray(formattedQuery.createTime) && formattedQuery.createTime.length === 2) {
formattedQuery.createTimeStart = dayjs(formattedQuery.createTime[0]).format('YYYY-MM-DD HH:mm:ss');
formattedQuery.createTimeEnd = dayjs(formattedQuery.createTime[1]).format('YYYY-MM-DD HH:mm:ss');
delete formattedQuery.createTime; // 删除原始的时间范围数组
}
// 处理更新时间
if (formattedQuery.updateTime && Array.isArray(formattedQuery.updateTime) && formattedQuery.updateTime.length === 2) {
formattedQuery.updateTimeStart = dayjs(formattedQuery.updateTime[0]).format('YYYY-MM-DD HH:mm:ss');
formattedQuery.updateTimeEnd = dayjs(formattedQuery.updateTime[1]).format('YYYY-MM-DD HH:mm:ss');
delete formattedQuery.updateTime; // 删除原始的时间范围数组
}
// 构建URLSearchParams仅添加非空参数
for (let key in formattedQuery) {
if (formattedQuery[key] !== null && formattedQuery[key] !== undefined && formattedQuery[key] !== '') {
params.append(key, formattedQuery[key]);
}
}
return request.get(`/student/page?${params.toString()}`);
};
export const saveStudent = (studentDTO) => {
return request.post('/student', studentDTO)
}
export const upload = (file) => {
return request.post('/student/import', file)
}
export const download = () => {
return request.get('/student/export', {
responseType: 'blob'
});
}
export const updateStudent = (studentDTO) => {
return request.put('/student', studentDTO)
}
export const deleteStudents = (ids) => {
const idsArray = Array.isArray(ids) ? ids : [ids];
const idsString = idsArray.join(',')
return request.delete(`/student/${idsString}`)
}
export const rollCall = () => {
return request.get('/student/rollcall')
}
export const updatePoints = (id, pointsChange) => {
return request.put(`/student/points/${id}/${pointsChange}`)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

@ -0,0 +1,20 @@
@font-face {
font-family: 'Nautilus-pompilius';
src: url('@/assets/fonts/Nautilus-pompilius-2.woff2') format('woff');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: 'YaYa点名';
src: url('@/assets/fonts/YaYa点名.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
@font-face {
font-family: '隶书-online';
src: url('@/assets/fonts/隶书.woff2') format('woff2');
font-weight: normal;
font-style: normal;
}

@ -0,0 +1,27 @@
@font-face {
font-family: "iconfont"; /* Project id 4702540 */
src: url('iconfont.woff2?t=1728542908069') format('woff2'),
url('iconfont.woff?t=1728542908069') format('woff'),
url('iconfont.ttf?t=1728542908069') format('truetype');
}
.iconfont {
font-family: "iconfont" !important;
font-size: 16px;
font-style: normal;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
.yaya-xmark:before {
content: "\e649";
}
.yaya-duigou:before {
content: "\e60b";
}
.yaya-download:before {
content: "\e600";
}

@ -0,0 +1 @@
window._iconfont_svg_string_4702540='<svg><symbol id="yaya-xmark" viewBox="0 0 1024 1024"><path d="M813.2 301.2c25-25 25-65.6 0-90.6s-65.6-25-90.6 0L512 421.4 301.2 210.8c-25-25-65.6-25-90.6 0s-25 65.6 0 90.6L421.4 512 210.8 722.8c-25 25-25 65.6 0 90.6s65.6 25 90.6 0L512 602.6l210.8 210.6c25 25 65.6 25 90.6 0s25-65.6 0-90.6L602.6 512l210.6-210.8z" ></path></symbol><symbol id="yaya-duigou" viewBox="0 0 1024 1024"><path d="M964.693333 229.333333a55.466667 55.466667 0 0 1 0 78.08L472.106667 794.666667a56.533333 56.533333 0 0 1-33.92 16h-7.466667a55.253333 55.253333 0 0 1-37.546667-16.213334L59.093333 464.853333a55.253333 55.253333 0 0 1 0-77.866666 55.893333 55.893333 0 0 1 78.933334 0l294.4 289.706666 453.333333-448a56.32 56.32 0 0 1 78.933333 0.64z" fill="#7F7F7F" ></path></symbol><symbol id="yaya-download" viewBox="0 0 1024 1024"><path d="M576 64c0-35.4-28.6-64-64-64s-64 28.6-64 64v485.4l-146.8-146.8c-25-25-65.6-25-90.6 0s-25 65.6 0 90.6l256 256c25 25 65.6 25 90.6 0l256-256c25-25 25-65.6 0-90.6s-65.6-25-90.6 0L576 549.4V64zM128 704c-70.6 0-128 57.4-128 128v64c0 70.6 57.4 128 128 128h768c70.6 0 128-57.4 128-128v-64c0-70.6-57.4-128-128-128H693l-90.6 90.6c-50 50-131 50-181 0L331 704H128z m736 112a48 48 0 1 1 0 96 48 48 0 1 1 0-96z" ></path></symbol></svg>',(n=>{var t=(e=(e=document.getElementsByTagName("script"))[e.length-1]).getAttribute("data-injectcss"),e=e.getAttribute("data-disable-injectsvg");if(!e){var o,i,a,c,s,d=function(t,e){e.parentNode.insertBefore(t,e)};if(t&&!n.__iconfont__svg__cssinject__){n.__iconfont__svg__cssinject__=!0;try{document.write("<style>.svgfont {display: inline-block;width: 1em;height: 1em;fill: currentColor;vertical-align: -0.1em;font-size:16px;}</style>")}catch(t){console&&console.log(t)}}o=function(){var t,e=document.createElement("div");e.innerHTML=n._iconfont_svg_string_4702540,(e=e.getElementsByTagName("svg")[0])&&(e.setAttribute("aria-hidden","true"),e.style.position="absolute",e.style.width=0,e.style.height=0,e.style.overflow="hidden",e=e,(t=document.body).firstChild?d(e,t.firstChild):t.appendChild(e))},document.addEventListener?~["complete","loaded","interactive"].indexOf(document.readyState)?setTimeout(o,0):(i=function(){document.removeEventListener("DOMContentLoaded",i,!1),o()},document.addEventListener("DOMContentLoaded",i,!1)):document.attachEvent&&(a=o,c=n.document,s=!1,r(),c.onreadystatechange=function(){"complete"==c.readyState&&(c.onreadystatechange=null,l())})}function l(){s||(s=!0,a())}function r(){try{c.documentElement.doScroll("left")}catch(t){return void setTimeout(r,50)}l()}})(window);

@ -0,0 +1,30 @@
{
"id": "4702540",
"name": "YaYa",
"font_family": "iconfont",
"css_prefix_text": "yaya-",
"description": "",
"glyphs": [
{
"icon_id": "35012394",
"name": "xmark",
"font_class": "xmark",
"unicode": "e649",
"unicode_decimal": 58953
},
{
"icon_id": "21163946",
"name": "对勾",
"font_class": "duigou",
"unicode": "e60b",
"unicode_decimal": 58891
},
{
"icon_id": "35007441",
"name": "download",
"font_class": "download",
"unicode": "e600",
"unicode_decimal": 58880
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

@ -0,0 +1,32 @@
body {
margin: 0;
background-color: #f5f5f5;
}
/* fade-slide */
.fade-slide-leave-active,
.fade-slide-enter-active {
transition: all 0.3s;
}
.fade-slide-enter-from {
transform: translateX(-30px);
opacity: 0;
}
.fade-slide-leave-to {
transform: translateX(30px);
opacity: 0;
}
html {
height: 100%;
}
#app {
min-height: 100%;
}
* {
box-sizing: border-box;
}

@ -0,0 +1,209 @@
<script setup>
import { ref, computed } from 'vue'
import { useRoute } from 'vue-router'
import { useRouter} from "vue-router";
import { useTokenStore } from "@/store/token.js";
import {Clock, House, User} from '@element-plus/icons-vue'
import { ElMessage } from "element-plus";
import { useUsernameStore } from "@/store/username.js";
const route = useRoute()
const router = useRouter()
const tokenStore = useTokenStore()
const usernameStore = useUsernameStore()
const activeMenu = computed(() => route.path)
const handleQuit = () => {
tokenStore.removeToken()
usernameStore.removeUsername()
router.push('/login')
ElMessage.success('退出成功')
}
</script>
<template>
<div class="layout-container">
<el-container class="layout-container-main">
<el-aside width="200px" class="sidebar">
<router-link to="/home" class="logo-container">
<img src="../assets/duck.png" alt="Logo" class="logo" />
<span class="logo-text">&nbsp;YaYa点名</span>
</router-link>
<el-menu
:default-active="activeMenu"
class="sidebar-menu"
router
>
<el-menu-item index="/home">
<el-icon><House /></el-icon>
<span class="menu-font"> </span>
</el-menu-item>
<el-menu-item index="/rollcall">
<el-icon><Clock /></el-icon>
<span class="menu-font"> </span>
</el-menu-item>
<el-menu-item index="/student">
<el-icon><User /></el-icon>
<span class="menu-font">学生管理</span>
</el-menu-item>
</el-menu>
</el-aside>
<el-container>
<el-header height="55px" class="header">
<div class="header-content">
<div class="header-left"></div>
<div class="header-right">
<el-dropdown placement="bottom" class="user-option">
<button class="user-button">
<span style="font-size: 14px">您好{{ usernameStore.username }}</span>
</button>
<template #dropdown>
<el-dropdown-menu>
<el-dropdown-item @click="handleQuit"></el-dropdown-item>
</el-dropdown-menu>
</template>
</el-dropdown>
</div>
</div>
</el-header>
<el-main class="main-content">
<router-view></router-view>
</el-main>
<el-footer height="45px" class="footer" style="font-family: Nautilus-pompilius, sans-serif; font-size: 18px; color: #333;">
Power By &nbsp;<a href="https://www.aspark.cc" target="_blank" style="text-decoration: none">Aspark.cc</a>
</el-footer>
</el-container>
</el-container>
</div>
</template>
<style scoped>
.menu-font {
font-weight: bold;
color: #606266;
}
.layout-container {
height: 100vh;
width: 100vw;
}
.layout-container-main {
height: 100%;
}
.sidebar {
background-color: #ebf1f6;
color: white;
display: flex;
flex-direction: column;
}
.logo-container {
height: 65px;
padding: 10px;
display: flex;
justify-content: center;
align-items: center;
text-decoration: none;
}
.logo {
color: black;
max-width: 160px;
max-height: 40px;
}
.logo-text {
font-family: YaYa点名, sans-serif;
color: black;
font-size: 30px;
}
.sidebar-menu {
border-right: none;
background-color: #ebf1f6;
}
.header {
background-color: #ebf1f6;
padding: 0 20px;
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
height: 100%;
}
.user-button {
background: none;
border: none;
padding: 15px 15px;
cursor: pointer;
border-radius: 6px;
transition: all 0.3s;
}
.user-button:hover {
background-color: #dde3e8;
}
.main-content {
padding: 20px;
background-color: #ffffff;
border-radius: 6px;
}
.footer {
background-color: #f6f6f6;
color: #333;
display: flex;
align-items: center;
justify-content: center;
border-top: 1px solid #e6e6e6;
}
:deep(.el-menu) {
border-right: none;
}
:deep(.el-menu-item) {
color: #303133;
transition: all 0.3s;
margin: 6px 0;
}
:deep(.el-menu-item.is-active) {
color: #409EFF;
background-color: #f5f8fb;
margin-right: 6px;
margin-left: 6px;
border-radius: 6px;
}
:deep(.el-menu-item:hover) {
background-color: #dde3e8;
margin-right: 6px;
margin-left: 6px;
border-radius: 6px;
}
:deep(.el-dropdown-menu) {
border-radius: 4px;
}
:deep(.el-dropdown-menu__item) {
transition: all 0.3s;
}
:deep(.el-dropdown-menu__item:hover) {
background-color: #ecf5ff;
color: #409EFF;
}
</style>

@ -0,0 +1,113 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="./src/assets/duck.png">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link href="https://www.aspark.cc/live2d/css/pio.css" rel="stylesheet" type="text/css" />
<title>YaYa点名</title>
<style>
#pio-container {
position: fixed !important;
left: 0 !important;
bottom: 0 !important;
z-index: 1000;
display: flex !important;
align-items: flex-end !important;
pointer-events: auto;
cursor: grab !important;
}
#pio-container:active {
cursor: grabbing !important;
}
#pio {
width: 20vw !important;
height: 20vw !important;
min-width: 120px !important;
min-height: 120px !important;
max-width: 200px !important;
max-height: 200px !important;
pointer-events: auto;
}
.pio-action {
width: auto !important;
height: auto !important;
bottom: 75% !important;
/* 垂直居中 */
left: 100% !important;
/* 向右侧偏移 */
transform: translateY(50%) scale(0.75);
/* 垂直居中,缩放 */
transform-origin: left center;
/* 缩放的基点设置为左侧中心 */
display: flex !important;
flex-direction: column !important;
pointer-events: auto;
}
.pio-action .pio-home {
display: none !important;
}
.pio-action .pio-close {
display: none !important;
}
.pio-action .pio-skin {
display: none !important;
}
.pio-action .pio-info {
display: none !important;
}
.pio-action span {
margin: 2px 0 !important;
width: 30px !important;
height: 30px !important;
min-width: 20px !important;
min-height: 20px !important;
background-size: contain !important;
background-repeat: no-repeat !important;
pointer-events: auto;
}
@media (max-width: 768px) {
.pio-action span {
width: 25px !important;
height: 25px !important;
}
}
@media (max-width: 480px) {
.pio-action span {
width: 20px !important;
height: 20px !important;
}
}
</style>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
<script src="https://www.aspark.cc/live2d/js/TweenLite.js"></script>
<script src="https://www.aspark.cc/live2d/js/live2dcubismcore.min.js"></script>
<script src="https://www.aspark.cc/live2d/js/pixi.min.js"></script>
<script src="https://www.aspark.cc/live2d/js/cubism4.min.js"></script>
<script src="https://www.aspark.cc/live2d/js/pio.js"></script>
<script src="https://www.aspark.cc/live2d/js/pio_sdk4.js"></script>
<script src="https://www.aspark.cc/live2d/js/load.js"></script>
</body>
</html>

@ -0,0 +1,8 @@
{
"compilerOptions": {
"paths": {
"@/*": ["./src/*"]
}
},
"exclude": ["node_modules", "dist"]
}

@ -0,0 +1,29 @@
import '@/assets/main.scss'
import '@/assets/icons/iconfont.css'
import '@/assets/fonts/fonts.css'
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import zhCn from 'element-plus/es/locale/lang/zh-cn'
import router, {setupRouterGuard} from "@/router/index.js";
import * as ElementPlusIconsVue from '@element-plus/icons-vue';
import { createPinia } from 'pinia';
import { createPersistedState } from "pinia-persistedstate-plugin";
const app = createApp(App)
const pinia = createPinia()
const persist = createPersistedState()
pinia.use(persist)
app.use(ElementPlus, {locale: zhCn})
app.use(router)
app.use(pinia)
setupRouterGuard(router)
for (const [key, component] of Object.entries(ElementPlusIconsVue)) {
app.component(key, component)
}
app.mount('#app')

File diff suppressed because it is too large Load Diff

@ -0,0 +1,28 @@
{
"name": "test",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"@element-plus/icons-vue": "^2.3.1",
"axios": "^1.7.7",
"dayjs": "^1.11.13",
"element-plus": "^2.8.4",
"pinia": "^2.2.4",
"pinia-persistedstate-plugin": "^0.1.0",
"vue": "^3.4.29",
"vue-router": "^4.4.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^5.0.5",
"less": "^4.2.0",
"saas": "^1.0.0",
"sass-embedded": "^1.79.4",
"vite": "^5.3.1"
}
}

@ -0,0 +1,66 @@
// router/index.js
import { createRouter, createWebHistory } from 'vue-router'
import StudentManageView from "@/views/Manage.vue";
import LayoutView from '@/components/Layout.vue';
import HomeView from '@/views/Home.vue'
import LoginView from '@/views/Login.vue'
import RollCallView from '@/views/RollCall.vue'
import { useTokenStore } from "@/store/token.js";
const routes = [
{
path: '/login',
name: 'login',
component: LoginView
},
{
path: '/',
component: LayoutView,
children: [
{
path: '',
redirect: '/home'
},
{
path: '/home',
name: 'home',
component: HomeView
},
{
path: '/rollcall',
name: 'rollcall',
component: RollCallView
},
{
path: '/student',
name: 'student',
component: StudentManageView
}
]
}
]
const router = createRouter({
history: createWebHistory(),
routes
});
export function setupRouterGuard(router) {
router.beforeEach((to, from, next) => {
const tokenStore = useTokenStore()
const isAuthenticated = tokenStore.token !== ''
if (to.name !== 'login' && !isAuthenticated) {
next({ name: 'login' })
} else if (to.name === 'login' && isAuthenticated) {
next({ name: 'home' })
} else {
next()
}
})
}
export default router

@ -0,0 +1,25 @@
import { defineStore } from 'pinia';
import {ref} from "vue";
export const useTokenStore = defineStore(
'token',
() => {
const token = ref('')
const setToken = (newToken) => {
token.value = newToken
}
const removeToken = () => {
token.value = ''
}
return {
token, setToken, removeToken
}
},
{
persist: true
}
)

@ -0,0 +1,25 @@
import { defineStore } from 'pinia';
import { ref } from "vue";
export const useUsernameStore = defineStore(
'username',
() => {
const username = ref('')
const setUsername = (newUsername) => {
username.value = newUsername
}
const removeUsername = () => {
username.value = ''
}
return {
username, setUsername, removeUsername
}
},
{
persist: true
}
)

@ -0,0 +1,45 @@
import axios from 'axios';
import { ElMessage } from 'element-plus'
import { useTokenStore } from "@/store/token.js";
import router from "@/router/index.js";
const baseURL = '/api';
const instance = axios.create({baseURL})
instance.interceptors.request.use(
config => {
const tokenStore = useTokenStore();
if (tokenStore.token) {
config.headers.Authorization = tokenStore.token
}
return config
},
error => {
Promise.reject(error)
}
)
//添加响应拦截器
instance.interceptors.response.use(
response => {
const tokenStore = useTokenStore();
// 检查响应类型是否为blob文件下载
if (response.config.responseType === 'blob') {
return response; // 直接返回完整的响应对象
}
const data = response.data;
if (data.msg === '用户未登录') {
tokenStore.removeToken();
ElMessage.error(data.msg || '用户未登录')
router.push('/login')
} else {
return data;
}
},
error => {
ElMessage.error('服务异常')
return Promise.reject(error);
}
)
export default instance;

@ -0,0 +1,27 @@
<template>
<div class="home">
<h1>欢迎使用 <span class="logo-text">YaYa点名</span></h1>
<p>请从左侧菜单选择功能</p>
</div>
</template>
<script setup>
</script>
<style scoped>
.home {
text-align: center;
padding: 20px;
}
h1 {
color: #303133;
margin-bottom: 20px;
}
.logo-text {
font-family: YaYa点名, sans-serif;
color: black;
font-size: 25px;
}
</style>

@ -0,0 +1,204 @@
<script setup>
import { User, Lock } from '@element-plus/icons-vue'
import { ref } from 'vue'
import { useRouter } from "vue-router";
import { userLoginService, userRegisterService } from "@/api/admin.js";
import { ElMessage } from "element-plus";
import { useTokenStore } from "@/store/token.js";
import { useUsernameStore } from "@/store/username.js";
import {updateStudent} from "@/api/student.js";
const router = useRouter();
const tokenStore = useTokenStore()
const usernameStore = useUsernameStore()
const isRegister = ref(false)
const ruleFormRef = ref()
const loginForm = ref({
username: 'admin',
password: 'abcd1234'
})
const registerForm = ref({
username: '',
password: '',
rePassword: ''
})
const rules = {
username: [
{ required: true, message: '请输入用户名', trigger: 'change' },
{ min: 3, max: 20, message: '用户名长度应为 3 到 20 为非空字符', trigger: 'change' },
{ pattern: /^[a-zA-Z0-9_]+$/, message: '用户名只能包含字母、数字和下划线', trigger: 'change' }
],
password: [
{ required: true, message: '请输入密码', trigger: 'change' },
{ min: 8, max: 32, message: '密码长度应为 8 到 32 为非空字符', trigger: 'change' }
],
rePassword: [
{ required: true, message: '请再次输密码', trigger: 'change'},
{
validator: (rule, value, callback) => {
if (value === '') {
callback(new Error('请再次输入密码'));
} else if (value !== registerForm.value.password) {
callback(new Error('两次输入的密码不一致'));
} else {
callback();
}
},
trigger: 'change'
}
]
}
const handleCommitStudent = async () => {
if (!ruleFormRef.value) return
await ruleFormRef.value.validate(async (valid, fields) => {
if (valid) {
loadingSaveDialog.value = true
const response = await updateStudent(studentForm.value)
if (response.code === 1) {
ElMessage.success('保存成功')
editStudentDialogVisible.value = false
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error(response.msg || '保存失败')
}
loadingSaveDialog.value = false
} else {
return false
}
})
}
const handleUserLogin = async () => {
if (!ruleFormRef.value) return
await ruleFormRef.value.validate(async (valid, fields) => {
if (valid) {
let response = await userLoginService(loginForm.value)
if (response.code === 1) {
tokenStore.setToken(response.data.token)
usernameStore.setUsername(loginForm.value.username)
ElMessage.success('登录成功')
await router.push('/home')
} else {
ElMessage.error("登录失败,原因: " +response.msg)
}
} else return false
})
}
const handleUserRegister = async () => {
if (!ruleFormRef.value) return
await ruleFormRef.value.validate(async (valid, fields) => {
if (valid) {
let response = await userRegisterService(registerForm.value)
if (response.code === 1) {
ElMessage.success('注册成功')
isRegister.value = false
} else {
ElMessage.error('注册失败,原因: ' + response.msg)
}
} else return false
})
}
</script>
<template>
<el-row class="login-page">
<el-col :span="12" class="bg"></el-col>
<el-col :span="6" :offset="3" class="form">
<!-- 注册表单 -->
<el-form ref="ruleFormRef" size="large" autocomplete="off" v-if="isRegister" :model="registerForm" :rules="rules">
<el-form-item>
<h1> </h1>
</el-form-item>
<el-form-item prop="username">
<el-input :prefix-icon="User" placeholder="请输入用户名" v-model="registerForm.username"></el-input>
</el-form-item>
<el-form-item prop="password">
<el-input :prefix-icon="Lock" type="password" placeholder="请输入密码" v-model="registerForm.password"></el-input>
</el-form-item>
<el-form-item prop="rePassword">
<el-input :prefix-icon="Lock" type="password" placeholder="请输入再次密码" v-model="registerForm.rePassword"></el-input>
</el-form-item>
<!-- 注册按钮 -->
<el-form-item>
<el-button class="button" type="primary" auto-insert-space @click="handleUserRegister">
</el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = false">
</el-link>
</el-form-item>
</el-form>
<!-- 登录表单 -->
<el-form ref="ruleFormRef" size="large" autocomplete="off" :model="loginForm" :rules="rules" v-else>
<el-form-item>
<h1> </h1>
</el-form-item>
<el-form-item prop="username">
<el-input :prefix-icon="User" placeholder="请输入用户名" v-model="loginForm.username"/>
</el-form-item>
<el-form-item prop="password">
<el-input name="password" :prefix-icon="Lock" type="password" placeholder="请输入密码" v-model="loginForm.password" @keyup.enter />
</el-form-item>
<el-form-item class="flex">
<div class="flex">
<el-checkbox>记住我</el-checkbox>
<el-link type="primary" :underline="false">忘记密码</el-link>
</div>
</el-form-item>
<!-- 登录按钮 -->
<el-form-item>
<el-button class="button" type="primary" auto-insert-space @click="handleUserLogin"> </el-button>
</el-form-item>
<el-form-item class="flex">
<el-link type="info" :underline="false" @click="isRegister = true">
</el-link>
</el-form-item>
</el-form>
</el-col>
</el-row>
</template>
<style lang="scss" scoped>
/* 样式 */
.login-page {
height: 100vh;
background-color: #fff;
.bg {
background: url('@/assets/logo.webp') no-repeat 40% center / 400px auto,
url('@/assets/login_bg.webp') no-repeat center / cover;
border-radius: 0 20px 20px 0;
}
.form {
display: flex;
flex-direction: column;
justify-content: center;
user-select: none;
.title {
margin: 0 auto;
}
.button {
width: 100%;
}
.flex {
width: 100%;
display: flex;
justify-content: space-between;
}
}
}
</style>

@ -0,0 +1,765 @@
<script setup lang="ts">
import {onMounted, ref} from 'vue'
import {CirclePlusFilled, Delete, Edit, RefreshLeft, Search, UploadFilled} from "@element-plus/icons-vue"
import {deleteStudents, getStudentById, pageStudent, saveStudent, updateStudent, upload, download} from '@/api/student.js'
import {ElMessage, ElMessageBox} from "element-plus"
const tableData = ref([])
const total = ref(0)
const currentPage = ref(1)
const pageSize = ref(10)
const loading = ref(false)
const loadingEditDialog = ref(false)
const loadingSaveDialog = ref(false)
const loadingUpload = ref(false)
const loadingDownload = ref(false)
const addStudentDialogVisible = ref(false)
const editStudentDialogVisible = ref(false)
const ruleFormRef = ref()
const formSize = ref('default')
const pageQueryForm = ref({
no: '',
name: '',
gender: '',
className: '',
exactQuery: false,
createTime: '',
updateTime: '',
orderBy: '',
orderRule: ''
})
const studentForm = ref({
id: '',
no: '',
name: '',
gender: '',
className: '',
points: ''
})
const multipleSelection = ref([])
const handlePageQuery = async ({prop = null, order = null}) => {
loading.value = true
if (prop && order) {
if (prop !== 'Empty' && order !== 'Empty') {
pageQueryForm.value.orderBy = prop
pageQueryForm.value.orderRule = (order === 'ascending' ? 'ASC' : 'DESC')
}
} else {
pageQueryForm.value.orderBy = ''
pageQueryForm.value.orderRule = ''
}
const queryParams = {
...pageQueryForm.value,
page: currentPage.value,
pageSize: pageSize.value
}
const response = await pageStudent(queryParams)
if (response.code === 1) {
tableData.value = response.data.records
total.value = response.data.total
// ElMessage.success('')
} else {
ElMessage.error('查询失败,原因: ' + response.msg)
}
loading.value = false
}
onMounted(() => {
handlePageQuery({prop: 'Empty', order: 'Empty'})
})
const resetQueryForm = () => {
pageQueryForm.value = {
no: '',
name: '',
gender: '',
major: '',
className: '',
exactQuery: false,
createTime: '',
updateTime: ''
}
currentPage.value = 1
handlePageQuery({prop: 'Empty', order: 'Empty'})
}
const resetStudentForm = () => {
studentForm.value = {
no: '',
name: '',
gender: '',
major: '',
className: '',
points: ''
}
}
const handleSelectionChange = (val) => {
multipleSelection.value = val
}
const handleDelete = (row) => {
ElMessageBox.confirm(
'删除不可撤销,继续吗?',
'警告',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
const response = await deleteStudents(row.id)
if (response.code == 1) {
ElMessage.success('删除成功')
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error('删除失败')
}
}).catch(() => {
ElMessage.info('删除取消')
})
}
const handleBatchDelete = () => {
ElMessageBox.confirm(
'删除不可撤销,继续吗?',
'警告',
{
confirmButtonText: '继续',
cancelButtonText: '取消',
type: 'warning',
}).then(async () => {
const ids = multipleSelection.value.map(item => item.id)
const response = await deleteStudents(ids)
if (response.code === 1) {
ElMessage.success('删除成功')
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error('删除失败')
}
}).catch(() => {
ElMessage.info('删除取消')
})
}
const handleAddStudent = async () => {
if (!ruleFormRef.value) return
await ruleFormRef.value.validate(async (valid, fields) => {
if (valid) {
loadingSaveDialog.value = true
const response = await saveStudent(studentForm.value)
if (response.code === 1) {
ElMessage.success('保存成功')
addStudentDialogVisible.value = false
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error(response.msg || '保存失败')
}
loadingSaveDialog.value = false
} else {
return false
}
})
}
const handleEditStudent = async (row) => {
loadingEditDialog.value = true
resetStudentForm()
editStudentDialogVisible.value = true
const response = await getStudentById(row.id)
// console.log(response.data.className)
studentForm.value = {
...response.data,
className: response.data.className.toString(),
points: response.data.points.toString()
}
loadingEditDialog.value = false
}
const handleCommitStudent = async () => {
if (!ruleFormRef.value) return
await ruleFormRef.value.validate(async (valid, fields) => {
if (valid) {
loadingSaveDialog.value = true
const response = await updateStudent(studentForm.value)
if (response.code === 1) {
ElMessage.success('保存成功')
editStudentDialogVisible.value = false
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error(response.msg || '保存失败')
}
loadingSaveDialog.value = false
} else {
return false
}
})
}
const handleSizeChange = (val) => {
pageSize.value = val
currentPage.value = 1
handlePageQuery({prop: 'Empty', order: 'Empty'})
}
const handleCurrentChange = (val) => {
currentPage.value = val
handlePageQuery({prop: 'Empty', order: 'Empty'})
}
const rules = {
no: [
{required: true, message: '请输入学号', trigger: 'change'},
{max: 20, message: '最大长度应为 20 个字符', trigger: 'change'}
],
name: [
{required: true, message: '请输入姓名', trigger: 'change'},
{max: 20, message: '最大长度为 20 个字符', trigger: 'change'}
],
gender: [
{required: true, message: '请输入选择性别', trigger: 'change'}
],
className: [
{required: false, message: '请输入班级', trigger: 'change'},
{max: 20, message: '最大长度为 20 个字符', trigger: 'change'}
],
points: [
{max: 16, message: '最大为 16 位', trigger: 'change'}
]
}
const formatGender = (row) => {
return row.gender === 0 ? '男' : '女'
}
const gender_options = [
{
value: '',
label: '全部'
},
{
value: '0',
label: '男'
},
{
value: '1',
label: '女'
}
]
const shortcuts = [
{
text: '近一周',
value: () => {
const end = new Date()
const start = new Date()
start.setDate(start.getDate() - 7)
return [start, end]
},
},
{
text: '近一月',
value: () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 1)
return [start, end]
},
},
{
text: '近三月',
value: () => {
const end = new Date()
const start = new Date()
start.setMonth(start.getMonth() - 3)
return [start, end]
},
},
]
const beforeUpload = (file) => {
const isExcel = file.type === 'application/vnd.ms-excel' ||
file.type === 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
if (!isExcel) {
ElMessage.error('只能上传 Excel 文件!')
return false
}
return true
}
const handleUpload = async ({file}) => {
loadingUpload.value = true
const formData = new FormData()
formData.append('file', file)
const response = await upload(formData)
if (response.code === 1) {
ElMessage.success('导入成功')
await handlePageQuery({prop: 'Empty', order: 'Empty'})
} else {
ElMessage.error('导入失败,原因: ' + response.msg)
}
loadingUpload.value = false
}
const handleDownload = async () => {
try {
loadingDownload.value = true
const response = await download();
const blob = new Blob([response.data], { type: response.data.type });
const downloadLink = document.createElement('a');
downloadLink.href = URL.createObjectURL(blob);
downloadLink.download = 'students.xlsx';
document.body.appendChild(downloadLink);
downloadLink.click();
document.body.removeChild(downloadLink);
ElMessage.success('导出成功');
} catch (error) {
console.error('Download failed:', error);
ElMessage.error('导出失败');
}
loadingDownload.value = false
}
</script>
<template>
<el-card v-loading="loading">
<div class="query-form">
<el-form :model="pageQueryForm" label-width="80px" class="filter-form">
<!-- 第一行学号姓名性别 -->
<div class="form-row">
<el-form-item label="学号">
<el-input
v-model="pageQueryForm.no"
placeholder="请输入学号"
clearable
class="custom-input"
/>
</el-form-item>
<el-form-item label="姓名">
<el-input
v-model="pageQueryForm.name"
placeholder="请输入姓名"
clearable
class="custom-input"
/>
</el-form-item>
<el-form-item label="性别">
<el-select
v-model="pageQueryForm.gender"
placeholder="请选择性别"
clearable
class="custom-input"
>
<el-option
v-for="item in gender_options"
:key="item.value"
:label="item.label"
:value="item.value"
/>
</el-select>
</el-form-item>
</div>
<!-- 第二行时间 -->
<div class="form-row">
<el-form-item label="创建时间" class="date-item">
<el-date-picker
v-model="pageQueryForm.createTime"
type="datetimerange"
:shortcuts="shortcuts"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
class="date-picker"
/>
</el-form-item>
<el-form-item label="最后操作" class="date-item">
<el-date-picker
v-model="pageQueryForm.updateTime"
type="datetimerange"
:shortcuts="shortcuts"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
class="date-picker"
/>
</el-form-item>
</div>
<!-- 第三行按钮 -->
<div class="form-row">
<div class="button-wrapper">
<el-button type="primary" :icon="Search" @click="handlePageQuery({prop: 'Empty', order: 'Empty'})">查询</el-button>
<el-button
@click="resetQueryForm"
:icon="RefreshLeft"
:disabled="pageQueryForm.no === '' && pageQueryForm.name === '' &&
pageQueryForm.className === '' && pageQueryForm.exactQuery === false && pageQueryForm.gender === '' &&
pageQueryForm.updateTime === '' && pageQueryForm.createTime === ''"
>
重置
</el-button>
</div>
</div>
</el-form>
</div>
<div class="table-toolbar">
<el-button type="danger" :icon="Delete" @click="handleBatchDelete" :disabled="!multipleSelection.length">
批量删除
</el-button>
<el-button type="primary" :icon="CirclePlusFilled" @click="resetStudentForm(); addStudentDialogVisible = true">
新增学生
</el-button>
<el-upload
:auto-upload="true"
:show-file-list="false"
:http-request="handleUpload"
:before-upload="beforeUpload"
accept=".xlsx,.xls"
>
<el-button type="primary" :icon="UploadFilled" style="margin-left: 12px" :loading="loadingUpload">
</el-button>
</el-upload>
<el-button type="primary" style="margin-left: 12px" @click="handleDownload" :loading="loadingDownload">
<i class="iconfont yaya-download" style="font-size: 10px"></i>
&nbsp;&nbsp;
</el-button>
</div>
<el-table
:data="tableData"
style="width: 100%"
@selection-change="handleSelectionChange"
@sort-change="handlePageQuery"
border
stripe
class="student-table"
>
<el-table-column type="selection" width="40"/>
<el-table-column prop="no" label="学号" sortable="custom" width="120" show-overflow-tooltip/>
<el-table-column prop="name" label="姓名" sortable="custom" width="120" show-overflow-tooltip/>
<el-table-column prop="gender" label="性别" :formatter="formatGender" sortable="custom"/>
<el-table-column prop="className" label="班级" sortable="custom" width="120" show-overflow-tooltip/>
<el-table-column prop="points" label="积分" sortable="custom" show-overflow-tooltip/>
<el-table-column prop="createTime" label="创建时间" sortable="custom" width="180"/>
<el-table-column prop="updateTime" label="最后操作时间" sortable="custom" width="180"/>
<el-table-column label="操作" width="120" fixed="right">
<template #default="scope">
<el-button type="primary" :icon="Edit" circle title="编辑" @click="handleEditStudent(scope.row)"/>
<el-button type="danger" :icon="Delete" circle @click="handleDelete(scope.row)" title="删除"/>
</template>
</el-table-column>
</el-table>
<div class="pagination-container">
<el-pagination
v-model:current-page="currentPage"
v-model:page-size="pageSize"
:page-sizes="[10, 20, 30, 40]"
:total="total"
layout="total, sizes, prev, pager, next, jumper"
@size-change="handleSizeChange"
@current-change="handleCurrentChange"
/>
</div>
</el-card>
<el-dialog
v-model="addStudentDialogVisible"
title="新增学生"
width="460px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="ruleFormRef"
:model="studentForm"
:rules="rules"
label-position="right"
label-width="80px"
:size="formSize"
status-icon
class="add-student-form"
v-loading="loadingSaveDialog"
element-loading-text="保存中..."
>
<el-form-item label="学号" prop="no">
<el-input
v-model.trim="studentForm.no"
placeholder="请输入学号"
/>
</el-form-item>
<el-form-item label="姓名" prop="name">
<el-input
v-model.trim="studentForm.name"
placeholder="请输入姓名"
/>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="studentForm.gender">
<el-radio-button :value="0"></el-radio-button>
<el-radio-button :value="1"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="班级" prop="className">
<el-input
v-model.trim="studentForm.className"
placeholder="请输入班级"
/>
</el-form-item>
<el-form-item label="积分" prop="points">
<el-input
v-model.trim="studentForm.points"
placeholder="请输入积分,默认为 0"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="addStudentDialogVisible = false"> </el-button>
<el-button type="primary" @click="handleAddStudent">
</el-button>
</div>
</template>
</el-dialog>
<!-- 编辑学生信息-->
<el-dialog
v-model="editStudentDialogVisible"
title="编辑学生"
width="460px"
:close-on-click-modal="false"
destroy-on-close
>
<el-form
ref="ruleFormRef"
:model="studentForm"
:rules="rules"
label-position="right"
label-width="80px"
:size="formSize"
status-icon
v-loading="loadingEditDialog"
element-loading-text="加载中..."
class="edit-student-form"
>
<el-form-item label="学 号" prop="no">
<el-input
v-model.trim="studentForm.no"
placeholder="请输入学号"
/>
</el-form-item>
<el-form-item label="姓 名" prop="name">
<el-input
v-model.trim="studentForm.name"
placeholder="请输入姓名"
/>
</el-form-item>
<el-form-item label="性别" prop="gender">
<el-radio-group v-model="studentForm.gender">
<el-radio-button :value="0"></el-radio-button>
<el-radio-button :value="1"></el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="班级" prop="className">
<el-input
v-model.trim="studentForm.className"
placeholder="请输入班级"
/>
</el-form-item>
<el-form-item label="积分" prop="points">
<el-input
v-model.trim="studentForm.points"
placeholder="请输入积分,默认为 0"
/>
</el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button @click="editStudentDialogVisible = false"> </el-button>
<el-button type="primary" @click="handleCommitStudent" :loading="loadingSaveDialog">
</el-button>
</div>
</template>
</el-dialog>
</template>
<style scoped>
.query-form {
padding: 16px;
}
.filter-form {
display: flex;
flex-direction: column;
gap: 20px;
}
.form-row {
display: flex;
gap: 20px;
align-items: flex-start;
flex-wrap: wrap;
}
.form-row :deep(.el-form-item) {
margin-bottom: 0;
flex: 1;
}
.button-wrapper {
width: 100%;
display: flex;
justify-content: flex-end;
}
.date-item {
flex: 1;
}
.custom-input {
width: 100% !important;
}
.date-picker {
width: 100% !important;
}
:deep(.el-form-item__label) {
font-weight: bold;
color: #606266;
}
:deep(.el-input__wrapper),
:deep(.el-select__wrapper) {
box-shadow: 0 0 0 1px #dcdfe6 inset;
}
:deep(.el-input__wrapper:hover),
:deep(.el-select__wrapper:hover) {
box-shadow: 0 0 0 1px #c0c4cc inset;
}
/* 调整日期选择器的响应式布局 */
@media screen and (max-width: 1400px) {
.date-item {
flex: 0 0 calc(50% - 10px);
}
.date-picker {
width: 100% !important;
}
}
.form-buttons {
margin-left: auto;
}
.student-table :deep(th) {
text-align: center !important;
}
.student-table :deep(.cell) {
display: flex;
justify-content: center;
align-items: center;
}
.table-toolbar {
margin: 16px 0;
display: flex;
gap: 8px;
}
.pagination-container {
margin-top: 16px;
display: flex;
justify-content: flex-end;
}
:deep(.el-card__body) {
padding: 20px;
}
:deep(.el-form-item) {
margin-bottom: 0;
margin-right: 0;
}
:deep(.el-table) {
margin-top: 8px;
}
.el-dialog {
border-radius: 8px;
}
.el-dialog__body {
padding: 20px 40px;
}
.el-form-item {
margin-bottom: 24px;
}
.el-form-item:last-child {
margin-bottom: 0;
}
.dialog-footer {
display: flex;
justify-content: flex-end;
padding-top: 10px;
}
.dialog-footer .el-button {
min-width: 100px;
margin-left: 12px;
}
.el-form-item :deep(.el-form-item__content) {
justify-content: flex-start;
}
/* 调整输入框宽度 */
.el-input {
width: 100%;
}
/* 确保单选按钮组对齐 */
.el-radio-group {
width: 100%;
}
.edit-student-form {
min-height: 300px;
}
.add-student-form {
min-height: 300px;
}
</style>

@ -0,0 +1,561 @@
<script setup>
import {onMounted, ref, watch} from 'vue'
import {ElMessage, ElNotification} from 'element-plus'
import {listStudent, rollCall, updatePoints} from "@/api/student.js";
//
const loading = ref(false)
const cards = ref([])
const canSelect = ref(true)
const isResetting = ref(false)
const dialogVisible = ref(false)
const cardResult = ref({})
const studentTable = ref([])
const avatarUrl = ref('https://api.aspark.cc')
const avatarKey = ref(0)
const pointChange = ref(0)
//
const shuffle = (array, count) => {
//
const shuffled = [...array];
// Fisher-Yates
for (let i = shuffled.length - 1; i > 0; i--) {
const j = Math.floor(Math.random() * (i + 1));
[shuffled[i], shuffled[j]] = [shuffled[j], shuffled[i]]; //
}
// count count
if (shuffled.length >= count) {
return shuffled.slice(0, count);
}
//
const result = [...shuffled];
while (result.length < count) {
const randomIndex = Math.floor(Math.random() * array.length);
result.push(array[randomIndex]); //
}
return result;
};
const initCards = async () => {
const response = await rollCall()
pointChange.value = 0
cardResult.value = response.data
const totalCards = 27
const shuffledResult = shuffle(studentTable.value, totalCards)
// console.log(shuffledResult)
cards.value = shuffledResult.map(({name}) => ({name, isFlipped: false}))
avatarKey.value = Date.now()
const img = new Image()
img.src = `${avatarUrl.value}/random/${avatarKey.value}`
img.onerror = () => {
console.error('Failed to load avatar')
avatarUrl.value = 'path/to/default/avatar.png'
}
}
const selectCard = (index) => {
if (!canSelect.value || cards.value[index].isFlipped) return
cards.value[index] = {
name: cardResult.value.name,
isFlipped: true
}
canSelect.value = false
ElMessage.success(`恭喜你 ${cards.value[index].name}`)
setTimeout(() => {
cards.value.forEach((card, i) => {
if (i !== index) {
card.isFlipped = true
}
})
dialogVisible.value = true
}, 1000)
}
const handleRestart = () => {
dialogVisible.value = false
if (isResetting.value) return
isResetting.value = true
canSelect.value = false
cards.value.forEach(card => {
card.isFlipped = false
})
setTimeout(async () => {
await initCards()
canSelect.value = true
isResetting.value = false
}, 600)
}
onMounted(async () => {
loading.value = true
const response = await listStudent()
studentTable.value = response.data
await initCards()
loading.value = false
ElNotification({
title: '提示',
message: '从下方 27 张卡片中任意抽取一张吧!',
type: 'info',
})
})
const formatTooltip = (value) => {
return value > 0 ? `+${value}` : value.toString();
};
const updateSliderColor = () => {
const slider = document.querySelector('.el-slider__runway');
const sliderButton = document.querySelector('.el-slider__button');
if (!slider || !sliderButton) return;
//
if (pointChange.value < 0) {
sliderButton.style.borderColor = '#f56c6c';
} else if (pointChange.value > 0) {
sliderButton.style.borderColor = '#67c23a';
} else {
sliderButton.style.borderColor = '#409EFF'; // 0
}
//
const oldBars = slider.querySelectorAll('.custom-slider-bar');
oldBars.forEach(bar => bar.remove());
// 绿
const negativeBar = document.createElement('div');
const positiveBar = document.createElement('div');
negativeBar.className = 'custom-slider-bar negative';
positiveBar.className = 'custom-slider-bar positive';
slider.appendChild(negativeBar);
slider.appendChild(positiveBar);
const zeroPosition = 50; // 050%
const currentPosition = pointChange.value * 8 * 2 + 50;
if (pointChange.value < 0) {
// 0
negativeBar.style.left = `${currentPosition}%`;
negativeBar.style.width = `${zeroPosition - currentPosition}%`;
positiveBar.style.width = '0';
} else if (pointChange.value > 0) {
// 绿0
positiveBar.style.left = `${zeroPosition}%`;
positiveBar.style.width = `${currentPosition - zeroPosition}%`;
negativeBar.style.width = '0';
} else {
// 0
negativeBar.style.width = '0';
positiveBar.style.width = '0';
}
};
const decrementPoints = () => {
if (pointChange.value > -5) {
pointChange.value -= 0.5;
}
};
const incrementPoints = () => {
if (pointChange.value < 5) {
pointChange.value += 0.5;
}
};
watch(pointChange, updateSliderColor);
onMounted(() => {
updateSliderColor();
});
const handlePointsChange = async () => {
if (pointChange.value === 0) {
dialogVisible.value = false
return
}
const response = await updatePoints(cardResult.value.id, pointChange.value)
if (response.code === 0) {
ElMessage.error('失败,原因:' + response.msg)
}
dialogVisible.value = false
}
</script>
<template>
<el-card v-loading="loading">
<div class="lottery-container">
<div class="cards-grid">
<div
v-for="(card, index) in cards"
:key="index"
class="card"
:class="{ 'flipped': card.isFlipped }"
@click="selectCard(index)"
>
<div class="card-inner">
<div class="card-front"></div>
<div class="card-back">
<div class="card-content">
<!-- <div class="text-column">-->
<!-- <span class="vertical-text student-no">{{ card.no }}</span>-->
<!-- </div>-->
<div class="text-column">
<span class="vertical-text student-name">{{ card.name }}</span>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</el-card>
<el-dialog
v-model="dialogVisible"
width="400px"
:align-center="true"
class="profile-dialog"
@close="handleRestart"
>
<div class="profile-content">
<el-avatar
:size="100"
:src="avatarUrl + '/random/' + avatarKey"
class="profile-avatar"
/>
<h2 class="profile-name">{{ cardResult.no }}&nbsp;{{ cardResult.name }}</h2>
<h3 class="profile-points">当前积分{{ cardResult.points }}</h3>
<div class="slider-demo-block">
<div class="slider-controls">
<el-button
@click="decrementPoints"
size="small"
class="slider-button decrease"
>-</el-button>
<div class="slider-wrapper">
<el-slider
v-model="pointChange"
:min="-3"
:max="3"
:step="0.5"
show-stops
:format-tooltip="formatTooltip"
/>
</div>
<el-button
@click="incrementPoints"
size="small"
class="slider-button increase"
>+</el-button>
</div>
<div class="point-change-label">
<span>积分变动</span>
<span :class="{'positive': pointChange > 0, 'negative': pointChange < 0}">
{{ pointChange > 0 ? '+' : ''}}{{ pointChange }}
</span>
</div>
</div>
<div class="button-container">
<el-button
type="danger"
@click="handleRestart"
class="round-button"
>
<i class="iconfont yaya-xmark" style="font-size: 24px;"/>
</el-button>
<el-button
type="success"
@click="handlePointsChange"
class="round-button"
>
<i class="iconfont yaya-duigou" style="font-size: 24px;" />
</el-button>
</div>
<p class="profile-bio">Coding the World.</p>
</div>
</el-dialog>
</template>
<style>
body .el-dialog {
--el-dialog-border-radius: 20px;
}
</style>
<style scoped>
.lottery-container {
width: 100%;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
box-sizing: border-box;
}
.cards-grid {
display: grid;
grid-template-columns: repeat(9, 1fr);
grid-template-rows: repeat(3, 1fr);
gap: 10px;
width: 100%;
max-width: 1400px;
aspect-ratio: 2.7/1;
margin: 0 auto;
}
.card {
perspective: 1000px;
cursor: pointer;
aspect-ratio: 0.7/1;
}
.card-inner {
position: relative;
width: 100%;
height: 100%;
transition: transform 0.6s;
transform-style: preserve-3d;
}
.card.flipped .card-inner {
transform: rotateY(180deg);
}
.card-front,
.card-back {
position: absolute;
width: 100%;
height: 100%;
backface-visibility: hidden;
display: flex;
justify-content: center;
align-items: center;
border-radius: 5px;
background-size: cover;
background-position: center;
background-repeat: no-repeat;
}
.card-front {
background-image: url('../assets/card.webp');
}
.card-back {
background-image: url('../assets/card2.webp');
border: 1px solid #ccc;
color: black;
transform: rotateY(180deg);
font-family: '隶书', '隶书-online', '微软雅黑', sans-serif;
display: flex;
justify-content: center;
align-items: center;
}
.card-content {
display: flex;
justify-content: center;
gap: 2px;
width: 90%;
height: 90%;
}
.text-column {
display: flex;
justify-content: center;
align-items: center;
}
.vertical-text {
writing-mode: vertical-rl;
text-orientation: upright;
white-space: nowrap;
line-height: 1.2;
max-height: 100%;
overflow: hidden;
}
.text-column .student-name {
font-size: clamp(1px, 3vw, 24px);
}
@media (max-width: 768px) {
.text-column .student-name {
font-size: clamp(1px, 2.5vw, 14px);
}
.card-content {
gap: 1px;
}
}
.profile-dialog :deep(.el-dialog) {
background-color: #f5f7f5;
border-radius: 16px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.profile-content {
display: flex;
flex-direction: column;
align-items: center;
padding: 20px;
}
.profile-avatar {
margin-bottom: 20px;
}
.profile-name {
color: #2c3e50;
font-size: 1.8em;
margin-bottom: 8px;
font-weight: 600;
}
.profile-points {
color: #67c23a;
font-size: 1.4em;
margin-bottom: 25px;
font-weight: 500;
}
.profile-bio {
color: #5c6b77;
font-size: 1.1em;
margin-top: 20px;
font-family: 'Nautilus-pompilius', sans-serif;
font-style: italic;
}
.slider-demo-block {
width: 100%;
margin-bottom: 40px;
}
.slider-controls {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.slider-wrapper {
flex: 1;
margin: 0 15px;
}
.slider-button {
width: 32px;
height: 32px;
border: none;
color: white;
font-size: 18px;
cursor: pointer;
border-radius: 50%;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
}
.slider-button.decrease {
background-color: #f56c6c;
}
.slider-button.increase {
background-color: #67c23a;
}
.slider-button:hover {
transform: scale(1.05);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.15);
}
.point-change-label {
text-align: center;
font-size: 16px;
color: #606266;
}
.point-change-label .positive {
color: #67c23a;
font-weight: 500;
}
.point-change-label .negative {
color: #f56c6c;
font-weight: 500;
}
:deep(.el-slider__runway) {
margin: 0;
background-color: #e4e7ed;
position: relative;
}
:deep(.custom-slider-bar) {
position: absolute;
height: 6px;
transition: all 0.3s ease;
}
:deep(.custom-slider-bar.negative) {
background-color: #f56c6c;
}
:deep(.custom-slider-bar.positive) {
background-color: #67c23a;
}
:deep(.el-slider__bar) {
display: none; /* 隐藏原始的滑块条 */
}
:deep(.el-slider__button) {
width: 20px;
height: 20px;
border: 2px solid; /* 移除固定的边框颜色 */
background-color: white;
transition: all 0.3s ease;
z-index: 3;
}
:deep(.el-slider__button:hover) {
transform: scale(1.1);
}
:deep(.el-slider__stop) {
background-color: transparent;
}
.button-container {
display: flex;
justify-content: center;
gap: 20px; /* 可根据需要调整间距 */
}
.round-button {
border-radius: 50%;
width: 50px; /* 根据需要调整大小 */
height: 50px; /* 根据需要调整大小 */
display: flex;
align-items: center;
justify-content: center;
}
</style>

@ -0,0 +1,25 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
// https://vitejs.dev/config/
export default defineConfig({
plugins: [
vue(),
],
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
}
},
server: {
proxy: {
'/api': {
target: 'http://127.0.0.1:8080',
changeOrigin: true,
rewrite: (path) => path.replace(/^\/api/, '')
}
}
}
})
Loading…
Cancel
Save