first commit

main
zhaozhao 3 months ago
commit dc25f05ad6

24
front/.gitignore vendored

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

@ -0,0 +1,3 @@
{
"recommendations": ["Vue.volar"]
}

@ -0,0 +1,5 @@
# Vue 3 + Vite
This template should help get you started developing with Vue 3 in Vite. The template uses Vue 3 `<script setup>` SFCs, check out the [script setup docs](https://v3.vuejs.org/api/sfc-script-setup.html#sfc-script-setup) to learn more.
Learn more about IDE Support for Vue in the [Vue Docs Scaling up Guide](https://vuejs.org/guide/scaling-up/tooling.html#ide-support).

@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>random-roll-call-vue</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
</html>

2351
front/package-lock.json generated

File diff suppressed because it is too large Load Diff

@ -0,0 +1,25 @@
{
"name": "random-roll-call-vue",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"preview": "vite preview"
},
"dependencies": {
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.11.8",
"vue": "^3.5.24",
"vue-echarts": "^8.0.1",
"xlsx": "^0.18.5"
},
"devDependencies": {
"@vitejs/plugin-vue": "^6.0.1",
"unplugin-auto-import": "^20.2.0",
"unplugin-vue-components": "^30.0.0",
"vite": "^7.2.4"
}
}

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@ -0,0 +1,105 @@
<template>
<!-- 修改点 el-container 移到内部让背景能铺满全屏 -->
<div id="app-wrapper">
<el-container>
<el-header>
<!-- 修改点为主标题添加一个 class以便在 style 中设计样式 -->
<h1 class="main-title">课堂随机点名系统</h1>
</el-header>
<el-main v-loading="isLoading">
<el-row :gutter="20">
<!-- 左侧点名与学生管理 -->
<el-col :span="14">
<el-card class="box-card" shadow="never">
<template #header><span>课堂点名</span></template>
<RollCall :students="students" @update="handleStudentUpdate" />
</el-card>
<el-card class="box-card" shadow="never" style="margin-top: 20px;">
<template #header><span>学生名单管理</span></template>
<StudentManager :students="students" @import="handleStudentsImport" />
</el-card>
</el-col>
<!-- 右侧积分排行榜 -->
<el-col :span="10">
<el-card class="box-card" shadow="never">
<template #header><span>积分排行榜</span></template>
<Leaderboard :students="students" />
</el-card>
</el-col>
</el-row>
</el-main>
</el-container>
</div>
</template>
<script setup>
// script
import { ref, onMounted } from 'vue';
import { ElMessage, ElLoading } from 'element-plus';
import StudentManager from './components/StudentManager.vue';
import RollCall from './components/RollCall.vue';
import Leaderboard from './components/Leaderboard.vue';
import { studentApi } from './services/apiService.js';
const students = ref([]);
const isLoading = ref(false);
onMounted(async () => {
await fetchStudents();
});
const fetchStudents = async () => {
isLoading.value = true;
try {
const response = await studentApi.getStudents();
students.value = response.data;
} catch (error) {
ElMessage.error('获取学生列表失败!');
} finally {
isLoading.value = false;
}
};
const handleStudentUpdate = async (updatedStudent) => {
const loadingInstance = ElLoading.service({ text: '正在更新积分...', background: 'rgba(0, 0, 0, 0.7)' });
try {
await studentApi.updateStudent(updatedStudent);
const index = students.value.findIndex(s => s.id === updatedStudent.id);
if (index !== -1) {
students.value[index] = updatedStudent;
}
ElMessage.success('积分更新成功!');
} catch (error) {
ElMessage.error('更新失败,请重试!');
} finally {
loadingInstance.close();
}
};
const handleStudentsImport = (newStudents) => {
studentApi.importStudents(newStudents).then(response => {
students.value = response.data;
ElMessage.success('名单导入成功!');
}).catch(() => {
ElMessage.error('导入失败!');
});
};
</script>
<!-- 修改点添加 <style scoped> 来美化标题 -->
<style scoped>
#app-wrapper {
padding: 0 20px;
}
.main-title {
text-align: center;
margin-top: 20px;
font-size: 2.5em;
font-weight: 700;
color: #fff;
text-shadow: 0 0 10px rgba(255, 255, 255, 0.5), 0 0 20px rgba(0, 0, 0, 0.5);
}
</style>

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="37.07" height="36" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 198"><path fill="#41B883" d="M204.8 0H256L128 220.8L0 0h97.92L128 51.2L157.44 0h47.36Z"></path><path fill="#41B883" d="m0 0l128 220.8L256 0h-51.2L128 132.48L50.56 0H0Z"></path><path fill="#35495E" d="M50.56 0L128 133.12L204.8 0h-47.36L128 51.2L97.92 0H50.56Z"></path></svg>

After

Width:  |  Height:  |  Size: 496 B

@ -0,0 +1,87 @@
<template>
<div>
<el-empty v-if="!students || students.length === 0" description="暂无学生数据" />
<v-chart v-else class="chart" :option="chartOption" autoresize />
</div>
</template>
<script setup>
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
GridComponent,
} from 'echarts/components';
import VChart from "vue-echarts";
import { computed } from 'vue';
use([
CanvasRenderer,
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
]);
const props = defineProps({
students: {
type: Array,
required: true
}
});
const chartOption = computed(() => {
const sortedStudents = [...props.students].sort((a, b) => b.points - a.points);
const topStudents = sortedStudents.slice(0, 10);
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%', right: '4%', bottom: '3%',
containLabel: true
},
xAxis: {
type: 'value',
boundaryGap: [0, 0.01],
//
axisLabel: { color: '#fff' },
splitLine: { lineStyle: { color: 'rgba(255, 255, 255, 0.2)' } }
},
yAxis: {
type: 'category',
data: topStudents.map(s => s.name).reverse(),
axisLabel: { color: '#fff' } //
},
series: [
{
name: '积分',
type: 'bar',
data: topStudents.map(s => s.points).reverse(),
//
itemStyle: {
borderRadius: [0, 5, 5, 0],
color: {
type: 'linear',
x: 0, y: 0, x2: 1, y2: 0,
colorStops: [
{ offset: 0, color: '#23d5ab' },
{ offset: 1, color: '#23a6d5' }
]
}
}
}
]
};
});
</script>
<style scoped>
.chart {
height: 450px;
}
</style>

@ -0,0 +1,134 @@
<template>
<div style="text-align: center;">
<el-card shadow="inner" style="min-height: 180px; margin-bottom: 20px; display: flex; align-items: center; justify-content: center;">
<div v-if="selectedStudent">
<h1 style="font-size: 3em; margin: 0;">{{ selectedStudent.name }}</h1>
<p style="color: #909399; font-size: 1.2em;">{{ selectedStudent.id }}</p>
</div>
<div v-else>
<span style="color: #c0c4cc; font-size: 2em;">待点名</span>
</div>
</el-card>
<el-button-group size="large">
<el-button type="primary" @click="handleRandomCall"></el-button>
<el-button type="default" @click="handleSequentialCall"></el-button>
</el-button-group>
<!-- 打分弹窗 -->
<el-dialog v-model="scoringDialogVisible" :title="`为 ${selectedStudent?.name} 打分`" width="30%">
<!--
修改点
将原来的 div 替换为一个新的 div并添加 "scoring-grid" class
这个 class 将负责实现两行两列的网格布局
-->
<div class="scoring-grid">
<el-button @click="handleScore(1)" type="success" plain>到课 (+1)</el-button>
<el-button @click="handleScore(3)" type="success">回答优秀 (+3)</el-button>
<el-button @click="handleScore(0.5)" plain>重复问题正确 (+0.5)</el-button>
<el-button @click="handleScore(-1)" type="danger" plain>重复问题错误 (-1)</el-button>
</div>
</el-dialog>
</div>
</template>
<script setup>
// The <script> part has no changes
import { ref, computed, watch } from 'vue';
import { ElMessage } from 'element-plus';
const props = defineProps({
students: Array
});
const emit = defineEmits(['update']);
const selectedStudent = ref(null);
const scoringDialogVisible = ref(false);
const currentIndex = ref(0);
const sortedStudents = computed(() => {
if (!props.students || props.students.length === 0) {
return [];
}
return [...props.students].sort((a, b) => String(a.id).localeCompare(String(b.id)));
});
watch(() => props.students.length, () => {
currentIndex.value = 0;
});
const weightedRandomSelect = (students) => {
if (!students || students.length === 0) return null;
const maxPoints = Math.max(...students.map(s => s.points), 0);
let totalWeight = 0;
const studentsWithWeights = students.map(student => {
const weight = (maxPoints - student.points) + 1;
totalWeight += weight;
return { ...student, weight };
});
let random = Math.random() * totalWeight;
for (const student of studentsWithWeights) {
random -= student.weight;
if (random <= 0) return student;
}
return students[0];
};
const handleRandomCall = () => {
if (props.students.length === 0) {
ElMessage.warning("请先导入学生名单!");
return;
}
const student = weightedRandomSelect(props.students);
if (student) {
selectedStudent.value = student;
scoringDialogVisible.value = true;
} else {
ElMessage.error("选择学生时发生错误!");
}
};
const handleSequentialCall = () => {
if (sortedStudents.value.length === 0) {
ElMessage.warning("请先导入学生名单!");
return;
}
selectedStudent.value = sortedStudents.value[currentIndex.value];
scoringDialogVisible.value = true;
currentIndex.value = (currentIndex.value + 1) % sortedStudents.value.length;
};
const handleScore = (points) => {
if (selectedStudent.value) {
const { weight, ...studentToUpdate } = { ...selectedStudent.value };
studentToUpdate.points += points;
if (points > 0.000) { // > 0
studentToUpdate.callCount = (studentToUpdate.callCount || 0) + 1;
}
emit('update', studentToUpdate);
}
scoringDialogVisible.value = false;
};
</script>
<!--
新增点
添加 <style scoped> 来定义 scoring-grid 的样式
-->
<style scoped>
.scoring-grid {
display: grid;
/* 创建两列,每列占据可用空间的一半 */
grid-template-columns: repeat(2, 1fr);
/* 设置行与列之间的间距 */
gap: 15px;
}
/* 确保按钮宽度100%填满格子 */
.scoring-grid .el-button {
width: 100%;
margin: 0; /* 移除 Element Plus 可能自带的 margin */
}
</style>

@ -0,0 +1,72 @@
<template>
<div>
<!-- 修改点使用 el-space 组件来优雅地排列按钮 -->
<el-space wrap>
<el-upload
:auto-upload="false"
:on-change="handleFileChange"
:show-file-list="false"
accept=".xlsx, .xls"
>
<el-button type="primary">导入Excel名单</el-button>
</el-upload>
<!-- 新增点添加导出积分详单按钮 -->
<el-button type="success" @click="handleExport"></el-button>
</el-space>
<el-table
:data="props.students"
stripe
style="width: 100%; margin-top: 20px;"
max-height="400"
>
<el-table-column prop="id" label="学号" />
<el-table-column prop="name" label="姓名" />
<el-table-column prop="major" label="专业" />
<el-table-column prop="points" label="总积分" sortable />
<el-table-column prop="callCount" label="被点名次数" sortable />
</el-table>
</div>
</template>
<script setup>
import * as XLSX from 'xlsx';
const props = defineProps({
students: Array
});
const emit = defineEmits(['import']);
const handleFileChange = (file) => {
const reader = new FileReader();
reader.onload = (e) => {
const data = e.target.result;
const workbook = XLSX.read(data, { type: 'binary' });
const sheetName = workbook.SheetNames[0];
const worksheet = workbook.Sheets[sheetName];
const json = XLSX.utils.sheet_to_json(worksheet, { header: ["id", "name", "major"] });
const newStudents = (json.length > 0 && json[0].id === '学号' ? json.slice(1) : json)
.map(student => ({
...student,
points: 0,
callCount: 0,
}));
emit('import', newStudents);
};
reader.readAsBinaryString(file.raw);
};
// handleExport
const handleExport = () => {
//
const exportUrl = '/api/students/export';
// window.location.href
//
// URL Vite
window.location.href = exportUrl;
};
</script>

@ -0,0 +1,14 @@
import { createApp } from 'vue'
import ElementPlus from 'element-plus'
import 'element-plus/dist/index.css'
import App from './App.vue'
import './styles/global.css'
import VChart from "vue-echarts";
const app = createApp(App)
app.component("v-chart", VChart);
app.use(ElementPlus)
app.mount('#app')

@ -0,0 +1,60 @@
import axios from 'axios';
// --- MOCK DATA ---
// This data simulates what our backend database would store.
let mockStudents = [
{ id: '2024001', name: '张三', major: '软件工程', points: 5, callCount: 2 },
{ id: '2024002', name: '李四', major: '软件工程', points: 8, callCount: 3 },
{ id: '2024003', name: '王五', major: '计算机科学', points: 2, callCount: 1 },
{ id: '2024004', name: '赵六', major: '网络工程', points: 10, callCount: 4 },
];
/**
* Simulates network delay.
* @param {number} ms - Milliseconds to wait.
*/
const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms));
// --- MOCK API FUNCTIONS ---
// These functions mimic the behavior of real API calls.
export const studentApi = {
/**
* Fetches the list of all students.
* In the future, this will be: return axios.get('/api/students');
*/
getStudents: async () => {
console.log("API: Fetching all students...");
await sleep(500); // Simulate network latency
return { data: [...mockStudents] }; // Return a copy
},
/**
* Updates a specific student's data.
* @param {object} studentData - The student object with updated info.
* In the future, this will be: return axios.put(`/api/students/${studentData.id}`, studentData);
*/
updateStudent: async (studentData) => {
console.log(`API: Updating student ${studentData.id}...`, studentData);
await sleep(300);
const index = mockStudents.findIndex(s => s.id === studentData.id);
if (index !== -1) {
mockStudents[index] = studentData;
return { data: { ...studentData } }; // Return a copy
} else {
throw new Error("Student not found!");
}
},
/**
* Imports a new list of students, replacing the old one.
* @param {Array} newStudents - Array of new student objects.
* In the future, this will be: return axios.post('/api/students/import', newStudents);
*/
importStudents: async (newStudents) => {
console.log("API: Importing new students...");
await sleep(400);
mockStudents = newStudents;
return { data: [...mockStudents] };
}
};

@ -0,0 +1,87 @@
/* --- 全局美化样式 --- */
/* 1. 引入更适合屏幕阅读的字体 (可选,但推荐) */
@import url('https://fonts.googleapis.com/css2?family=Noto+Sans+SC:wght@300;400;500;700&display=swap');
body {
margin: 0;
font-family: 'Noto Sans SC', sans-serif;
/*
*/
background: linear-gradient(135deg, #29323c 0%, #485563 100%);
background-attachment: fixed; /* 确保背景在滚动时不会移动 */
min-height: 100vh;
}
/* 【【【 修改点 】】】: 删除了 @keyframes gradientBG {...} 动画规则 */
/* 3. 美化 Element Plus 的卡片组件 (样式保持不变,它们与新背景完美兼容) */
.box-card {
border: 1px solid rgba(255, 255, 255, 0.18) !important;
background: rgba(255, 255, 255, 0.1) !important;
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px !important;
color: #fff !important;
transition: all 0.3s ease;
}
.box-card:hover {
transform: translateY(-5px);
box-shadow: 0 8px 32px 0 rgba(31, 38, 135, 0.37);
}
/* 4. 修改卡片头部样式 */
.el-card__header {
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
color: #fff !important;
font-size: 1.1em;
font-weight: 500;
}
/* 5. 美化表格,使其适应深色背景 */
.el-table, .el-table__expanded-cell {
background-color: transparent !important;
}
.el-table th, .el-table tr {
background-color: transparent !important;
color: #fff !important;
}
.el-table td, .el-table th.is-leaf {
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
}
.el-table--striped .el-table__body tr.el-table__row--striped td.el-table__cell,
.el-table--enable-row-hover .el-table__body tr:hover>td {
background-color: rgba(255, 255, 255, 0.1) !important;
}
/* 6. 调整其他组件以适应新主题 */
.el-dialog {
background: rgba(40, 40, 40, 0.85);
backdrop-filter: blur(10px);
-webkit-backdrop-filter: blur(10px);
border-radius: 12px;
}
.el-dialog .el-dialog__title, .el-dialog .el-dialog__body {
color: #fff !important;
}
.el-empty__description {
color: #fff !important;
}
/* 待点名区域的文字颜色 */
.el-card span[style*="color: #c0c4cc"] {
color: rgba(255, 255, 255, 0.7) !important;
}
/* 被点名学生的学号颜色 */
.el-card p[style*="color: #909399"] {
color: rgba(255, 255, 255, 0.8) !important;
}

@ -0,0 +1,18 @@
// vite.config.js
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import AutoImport from 'unplugin-auto-import/vite'
import Components from 'unplugin-vue-components/vite'
import { ElementPlusResolver } from 'unplugin-vue-components/resolvers'
export default defineConfig({
plugins: [
vue(),
AutoImport({
resolvers: [ElementPlusResolver()],
}),
Components({
resolvers: [ElementPlusResolver()],
}),
],
})
Loading…
Cancel
Save