上传项目:front前端 + rollcall_backend后端

main
Ba 3 months ago
parent dc25f05ad6
commit fc5456540d

@ -11,6 +11,7 @@
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.11.8",
"file-saver": "^2.0.5",
"vue": "^3.5.24",
"vue-echarts": "^8.0.1",
"xlsx": "^0.18.5"
@ -1519,6 +1520,12 @@
}
}
},
"node_modules/file-saver": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/file-saver/-/file-saver-2.0.5.tgz",
"integrity": "sha512-P9bmyZ3h/PRG+Nzga+rbdI4OEpNDzAVyy74uVO9ATgzLK6VtAsYybF/+TOCvrc0MO793d6+42lLyZTw7/ArVzA==",
"license": "MIT"
},
"node_modules/follow-redirects": {
"version": "1.15.11",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.11.tgz",

@ -12,6 +12,7 @@
"axios": "^1.13.2",
"echarts": "^6.0.0",
"element-plus": "^2.11.8",
"file-saver": "^2.0.5",
"vue": "^3.5.24",
"vue-echarts": "^8.0.1",
"xlsx": "^0.18.5"

@ -1,105 +1,44 @@
<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>
<el-container>
<el-main>
<el-row :gutter="20">
<el-col :span="12">
<h2>点名区</h2>
<RollCall :students="students" @data-changed="fetchAllData" />
<h2 style="margin-top: 20px;">学生列表</h2>
<StudentManager :students="students" @data-changed="fetchAllData" />
</el-col>
<el-col :span="12">
<h2>积分排行榜 (Top 10)</h2>
<Leaderboard :top-students="topStudents" />
</el-col>
</el-row>
</el-main>
</el-container>
</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 StudentManager from './components/StudentManager.vue';
import Leaderboard from './components/Leaderboard.vue';
import { studentApi } from './services/apiService.js';
import { api } from './services/apiService.js';
const students = ref([]);
const isLoading = ref(false);
onMounted(async () => {
await fetchStudents();
});
const topStudents = ref([]);
const fetchStudents = async () => {
isLoading.value = true;
const fetchAllData = async () => {
try {
const response = await studentApi.getStudents();
students.value = response.data;
students.value = await api.getStudents();
} catch (error) {
ElMessage.error('获取学生列表失败!');
} finally {
isLoading.value = false;
console.error("获取学生列表失败:", error);
students.value = [];
}
};
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('积分更新成功!');
topStudents.value = await api.getTopNStudents(10);
} catch (error) {
ElMessage.error('更新失败,请重试!');
} finally {
loadingInstance.close();
console.error("获取Top 10失败:", error);
topStudents.value = [];
}
};
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>
onMounted(fetchAllData);
</script>

@ -1,6 +1,7 @@
<!-- [文件名]: src/components/Leaderboard.vue -->
<template>
<div>
<el-empty v-if="!students || students.length === 0" description="暂无学生数据" />
<el-empty v-if="!props.topStudents || props.topStudents.length === 0" description="暂无学生数据" />
<v-chart v-else class="chart" :option="chartOption" autoresize />
</div>
</template>
@ -9,69 +10,42 @@
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import { BarChart } from 'echarts/charts';
import {
TitleComponent,
TooltipComponent,
GridComponent,
} from 'echarts/components';
import { TitleComponent, TooltipComponent, GridComponent } from 'echarts/components';
import VChart from "vue-echarts";
import { computed } from 'vue';
use([
CanvasRenderer,
BarChart,
TitleComponent,
TooltipComponent,
GridComponent,
]);
use([CanvasRenderer, BarChart, TitleComponent, TooltipComponent, GridComponent]);
const props = defineProps({
students: {
type: Array,
required: true
}
topStudents: { type: Array, required: true }
});
const chartOption = computed(() => {
const sortedStudents = [...props.students].sort((a, b) => b.points - a.points);
const topStudents = sortedStudents.slice(0, 10);
const studentData = props.topStudents;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' }
},
grid: {
left: '3%', right: '4%', bottom: '3%',
containLabel: true
},
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' } //
data: studentData.map(s => s.name).reverse(),
axisLabel: { color: '#fff' }
},
series: [
{
name: '积分',
type: 'bar',
data: topStudents.map(s => s.points).reverse(),
//
data: studentData.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' }
]
type: 'linear', x: 0, y: 0, x2: 1, y2: 0,
colorStops: [{ offset: 0, color: '#23d5ab' }, { offset: 1, color: '#23a6d5' }]
}
}
}
@ -81,7 +55,5 @@ const chartOption = computed(() => {
</script>
<style scoped>
.chart {
height: 450px;
}
.chart { height: 450px; }
</style>

@ -1,9 +1,10 @@
<!-- [文件名]: src/components/RollCall.vue -->
<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>
<p style="color: #909399; font-size: 1.2em;">{{ selectedStudent.studentId }}</p>
</div>
<div v-else>
<span style="color: #c0c4cc; font-size: 2em;">待点名</span>
@ -11,124 +12,62 @@
</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 type="primary" @click="startCall(api.randomRollCall)" :loading="isCalling">随机点名</el-button>
<el-button type="default" @click="startCall(api.sequentialRollCall)" :loading="isCalling">按学号顺序点名</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>
<el-button @click="handleScore('high')" type="success">准确回答</el-button>
<el-button @click="handleScore('mid')" type="success" plain>回答良好</el-button>
<el-button @click="handleScore('low')" type="success" plain>回答一般</el-button>
<el-button @click="handleScore('repeat_error')" type="danger" plain>问题重复错误</el-button>
</div>
<template #footer><el-button @click="scoringDialogVisible = false">取消</el-button></template>
</el-dialog>
</div>
</template>
<script setup>
// The <script> part has no changes
import { ref, computed, watch } from 'vue';
import { ref } from 'vue';
import { ElMessage } from 'element-plus';
import { api } from '../services/apiService';
const props = defineProps({
students: Array
});
const emit = defineEmits(['update']);
const props = defineProps({ students: Array });
const emit = defineEmits(['data-changed']);
const selectedStudent = ref(null);
const scoringDialogVisible = ref(false);
const currentIndex = ref(0);
const isCalling = ref(false);
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;
const startCall = async (callFunction) => {
if (!props.students || props.students.length === 0) return ElMessage.warning("请先导入学生名单!");
isCalling.value = true;
try {
selectedStudent.value = await callFunction();
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;
emit('data-changed');
} catch (error) { selectedStudent.value = null; }
finally { isCalling.value = false; }
};
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;
const handleScore = async (level) => {
if (!selectedStudent.value) return;
const payload = {
isAnswer: level !== 'repeat_error',
repeatQuestion: level !== 'repeat_error',
answerCorrectLevel: level === 'repeat_error' ? 'none' : level,
};
try {
await api.updateStudentPoints(selectedStudent.value.studentId, payload);
ElMessage.success(`学生 ${selectedStudent.value.name} 的积分已更新!`);
emit('data-changed');
} catch (error) { /* apiService已处理 */ }
finally { 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 */
}
.scoring-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: 15px; }
.scoring-grid .el-button { width: 100%; margin: 0; }
</style>

@ -1,72 +1,68 @@
<!-- [文件名]: src/components/StudentManager.vue -->
<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 :auto-upload="false" :on-change="handleFileChange" :show-file-list="false" accept=".xlsx">
<el-button type="primary" :loading="isImporting">导入Excel名单</el-button>
</el-upload>
<!-- 新增点添加导出积分详单按钮 -->
<el-button type="success" @click="handleExport"></el-button>
<el-button type="success" @click="handleExport" :loading="isExporting">导出积分详单</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 :data="paginatedStudents" stripe style="width: 100%; margin-top: 20px;" max-height="400">
<el-table-column prop="rank" label="排名" width="80" />
<el-table-column prop="studentId" 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>
<el-pagination v-if="props.students.length > 0" background layout="total, sizes, prev, pager, next, jumper" :total="props.students.length" :current-page="pagination.page" :page-size="pagination.limit" :page-sizes="[10, 20, 50, 100]" @size-change="handleSizeChange" @current-change="handleCurrentChange" style="margin-top: 20px; justify-content: flex-end;" />
</div>
</template>
<script setup>
import * as XLSX from 'xlsx';
import { ref, computed } from 'vue';
import { ElMessage } from 'element-plus';
import { saveAs } from 'file-saver';
import { api } from '../services/apiService.js';
const props = defineProps({
students: Array
const props = defineProps({ students: Array });
const emit = defineEmits(['data-changed']);
const isImporting = ref(false);
const isExporting = ref(false);
const pagination = ref({ page: 1, limit: 10 });
const paginatedStudents = computed(() => {
const start = (pagination.value.page - 1) * pagination.value.limit;
return props.students.slice(start, start + pagination.value.limit);
});
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);
const handleSizeChange = (newSize) => {
pagination.value.limit = newSize;
pagination.value.page = 1;
};
const handleCurrentChange = (newPage) => { pagination.value.page = newPage; };
const handleFileChange = async (file) => {
if (!file) return;
isImporting.value = true;
try {
const response = await api.importStudents(file.raw);
ElMessage.success(`导入完成!成功 ${response.successCount} 条,失败 ${response.failCount} 条。`);
emit('data-changed');
} catch (error) { /* apiService已处理 */ }
finally { isImporting.value = false; }
};
// handleExport
const handleExport = () => {
//
const exportUrl = '/api/students/export';
// window.location.href
//
// URL Vite
window.location.href = exportUrl;
const handleExport = async () => {
if (props.students.length === 0) return ElMessage.warning('当前没有学生数据可导出。');
isExporting.value = true;
try {
const blob = await api.exportStudentDetails();
saveAs(blob, `学生积分详单_${Date.now()}.xlsx`);
} catch (error) { /* apiService已处理 */ }
finally { isExporting.value = false; }
};
</script>

@ -1,60 +1,74 @@
import axios from 'axios';
import { ElMessage } from 'element-plus';
import { saveAs } from 'file-saver';
// --- 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));
// --- 辅助函数用于命名风格转换后端snake_case -> 前端camelCase---
const toCamel = (s) => s.replace(/([-_][a-z])/ig, ($1) => $1.toUpperCase().replace('_', ''));
const isObject = (o) => o === Object(o) && !Array.isArray(o) && typeof o !== 'function';
const keysToCamel = (o) => {
if (isObject(o)) {
const n = {};
Object.keys(o).forEach((k) => { n[toCamel(k)] = keysToCamel(o[k]); });
return n;
} else if (Array.isArray(o)) {
return o.map((i) => keysToCamel(i));
}
return o;
};
// --- MOCK API FUNCTIONS ---
// These functions mimic the behavior of real API calls.
// --- Axios 实例配置 ---
const apiClient = axios.create({
baseURL: 'http://127.0.0.1:5000/api',
headers: { 'Content-Type': 'application/json' },
});
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
// --- 响应拦截器 ---
apiClient.interceptors.response.use(
(response) => {
if (response.data.code === 200) {
return keysToCamel(response.data.data);
} else {
throw new Error("Student not found!");
ElMessage.error(response.data.msg || '操作失败');
return Promise.reject(response.data);
}
},
(error) => {
const msg = error.response?.data?.msg || '网络请求失败,请检查服务器';
ElMessage.error(msg);
return Promise.reject(error);
}
);
/**
* 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] };
export const api = {
getStudents: () => apiClient.get('/student/rank'),
getTopNStudents: (n) => apiClient.get(`/student/rank/top/${n}`),
randomRollCall: () => apiClient.get('/rollcall/random'),
sequentialRollCall: () => apiClient.get('/rollcall/order'),
importStudents: (file) => {
const formData = new FormData();
formData.append('file', file);
return apiClient.post('/student/import', formData, { headers: { 'Content-Type': 'multipart/form-data' } });
},
updateStudentPoints: (studentId, payload) => {
const backendPayload = {
student_id: studentId,
is_answer: payload.isAnswer,
repeat_question: payload.repeatQuestion,
answer_correct_level: payload.answerCorrectLevel
};
return apiClient.post('/student/update-points', backendPayload);
},
exportStudentDetails: async () => {
try {
const response = await axios({
url: 'http://127.0.0.1:5000/api/student/export',
method: 'GET',
responseType: 'blob',
});
return response.data;
} catch (error) {
ElMessage.error('导出失败');
return Promise.reject(error);
}
}
};

@ -0,0 +1,432 @@
from flask import Flask, request, jsonify, send_file
from flask_sqlalchemy import SQLAlchemy
import pandas as pd
import random
from datetime import datetime
import os
from sqlalchemy import desc
import math
from flask_cors import CORS
# ---------------------- 1. 应用初始化核心修改适配db4free在线MySQL ----------------------
app = Flask(__name__)
CORS(app)
# ---------------------- 关键配置替换为你的db4free账号信息 ----------------------
DB_USER = 'bagood'
DB_PASSWORD = 'czb098221'
DB_NAME = 'rollcall_db'
DB_HOST = 'db4free.net'
DB_PORT = 3306
# 配置db4free在线MySQL连接核心修改部分
app.config['SQLALCHEMY_DATABASE_URI'] = f'mysql+pymysql://{DB_USER}:{DB_PASSWORD}@{DB_HOST}:{DB_PORT}/{DB_NAME}'
app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
# db4free专属适配配置解决连接超时/断开问题)
app.config['SQLALCHEMY_ENGINE_OPTIONS'] = {
'pool_size': 10, # 连接池大小适配db4free并发限制
'pool_recycle': 180, # 3分钟回收连接db4free空闲连接10分钟自动断开
'pool_pre_ping': True, # 执行查询前检测连接,无效则自动重连
'connect_args': {
'charset': 'utf8mb4', # 支持中文和特殊字符
'connect_timeout': 10, # 连接超时时间10秒
'read_timeout': 15, # 读取超时时间15秒
'write_timeout': 15 # 写入超时时间15秒
}
}
db = SQLAlchemy(app)
# 临时文件存储路径用于Excel导出
EXPORT_DIR = 'exports'
os.makedirs(EXPORT_DIR, exist_ok=True)
# ---------------------- 2. 数据库模型设计无修改兼容MySQL ----------------------
class Student(db.Model):
"""学生表:存储学生基础信息与积分"""
__tablename__ = 'students'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
student_id = db.Column(db.String(20), unique=True, nullable=False, comment='学号')
name = db.Column(db.String(20), nullable=False, comment='姓名')
major = db.Column(db.String(50), nullable=False, comment='专业')
points = db.Column(db.Float, default=0.0, comment='总积分')
call_count = db.Column(db.Integer, default=0, comment='被点名次数')
create_time = db.Column(db.DateTime, default=datetime.now, comment='创建时间')
class RollCallRecord(db.Model):
"""点名记录表:存储每次点名详情"""
__tablename__ = 'roll_call_records'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
student_id = db.Column(db.String(20), db.ForeignKey('students.student_id'), nullable=False, comment='学号')
call_mode = db.Column(db.String(10), nullable=False, comment='点名模式random/order')
call_time = db.Column(db.DateTime, default=datetime.now, comment='点名时间')
is_answer = db.Column(db.Boolean, default=False, comment='是否回答问题')
repeat_question = db.Column(db.Boolean, nullable=True, comment='是否准确重复问题')
answer_correct = db.Column(db.Boolean, nullable=True, comment='回答是否正确')
point_change = db.Column(db.Float, default=0.0, comment='本次积分变化')
class PointRule(db.Model):
"""积分规则表:可灵活配置积分规则(避免硬编码)"""
__tablename__ = 'point_rules'
id = db.Column(db.Integer, primary_key=True, autoincrement=True)
rule_type = db.Column(db.String(30), unique=True, nullable=False, comment='规则类型')
point_value = db.Column(db.Float, nullable=False, comment='对应分值')
description = db.Column(db.String(100), comment='规则描述')
# ---------------------- 3. 初始化数据库表与默认规则无修改兼容MySQL ----------------------
def init_db():
with app.app_context():
db.create_all() # 自动创建所有表(首次运行时执行)
# 插入默认积分规则(若不存在)
default_rules = [
('attend_call', 1.0, '到课被点到加分'),
('repeat_correct', 0.5, '准确重复问题加分'),
('repeat_incorrect', -1.0, '未准确重复问题扣分'),
('answer_correct_low', 0.5, '回答正确(基础分)'),
('answer_correct_mid', 1.5, '回答正确(中等分)'),
('answer_correct_high', 3.0, '回答正确(高分)')
]
for rule_type, point_value, desc in default_rules:
if not PointRule.query.filter_by(rule_type=rule_type).first():
new_rule = PointRule(rule_type=rule_type, point_value=point_value, description=desc)
db.session.add(new_rule)
db.session.commit()
print("数据库初始化完成数据已存储到db4free在线MySQL中")
# ---------------------- 4. 工具函数(无修改,逻辑不变) ----------------------
def get_point_rule(rule_type):
"""获取指定类型的积分规则分值"""
rule = PointRule.query.filter_by(rule_type=rule_type).first()
return rule.point_value if rule else 0.0
def calculate_random_weight(students):
"""计算随机点名权重:积分越高,权重越低(权重=1/(积分+1)"""
total_weight = sum(1/(student.points + 1) for student in students)
weights = [(1/(s.points + 1))/total_weight for s in students]
return weights
# ---------------------- 5. 核心接口(无修改,所有功能保持不变) ----------------------
@app.route('/api/student/import', methods=['POST'])
def import_students():
"""导入学生信息Excel文件- 支持.xlsx格式含格式校验"""
if 'file' not in request.files:
return jsonify({'code': 400, 'msg': '未上传文件'}), 400
file = request.files['file']
try:
if not file.filename.endswith('.xlsx'):
return jsonify({'code': 400, 'msg': '仅支持.xlsx格式的Excel文件请将文件另存为.xlsx后上传'}), 400
df = pd.read_excel(file, engine='openpyxl', dtype=str)
df = df.dropna(how='all')
required_cols = ['学号', '姓名', '专业']
if not all(col in df.columns for col in required_cols):
missing_cols = [col for col in required_cols if col not in df.columns]
return jsonify({'code': 400, 'msg': f'Excel缺少必填列{", ".join(missing_cols)}(表头必须严格为「学号、姓名、专业」)'}), 400
success_count = 0
fail_count = 0
fail_msg = []
existing_student_ids = [s.student_id for s in Student.query.with_entities(Student.student_id).all()]
for idx, row in df.iterrows():
student_id = str(row['学号']).strip() if pd.notna(row['学号']) else ''
name = str(row['姓名']).strip() if pd.notna(row['姓名']) else ''
major = str(row['专业']).strip() if pd.notna(row['专业']) else ''
if not student_id or not name or not major:
fail_count += 1
fail_msg.append(f'{idx+1}行:存在空数据,跳过')
continue
if not student_id.isdigit() or len(student_id) != 9:
fail_count += 1
fail_msg.append(f'{idx+1}行:学号{student_id}格式错误需9位数字跳过')
continue
if student_id in existing_student_ids:
fail_count += 1
fail_msg.append(f'{idx+1}行:学号{student_id}已存在,跳过')
continue
new_student = Student(student_id=student_id, name=name, major=major)
db.session.add(new_student)
success_count += 1
existing_student_ids.append(student_id)
db.session.commit()
return jsonify({
'code': 200,
'msg': f'导入完成!成功导入{success_count}条,失败{fail_count}',
'data': {
'success_count': success_count,
'fail_count': fail_count,
'fail_msg': fail_msg
}
}), 200
except Exception as e:
db.session.rollback()
error_msg = str(e)
# db4free专属错误提示
if 'Lost connection' in error_msg or '10060' in error_msg or '10061' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时/失败建议1. 检查账号密码/数据库名是否正确2. 1分钟后重试3. 检查网络是否稳定'}), 500
return jsonify({'code': 500, 'msg': f'导入失败:{error_msg}'}), 500
@app.route('/api/rollcall/random', methods=['GET'])
def random_rollcall():
"""随机点名(按积分权重)"""
try:
students = Student.query.all()
if not students:
return jsonify({'code': 400, 'msg': '暂无学生信息,请先导入'}), 400
weights = calculate_random_weight(students)
selected_student = random.choices(students, weights=weights, k=1)[0]
new_record = RollCallRecord(
student_id=selected_student.student_id,
call_mode='random',
is_answer=False,
point_change=get_point_rule('attend_call')
)
selected_student.call_count += 1
selected_student.points = round(selected_student.points + new_record.point_change, 1)
db.session.add(new_record)
db.session.commit()
return jsonify({
'code': 200,
'msg': '随机点名成功',
'data': {
'student_id': selected_student.student_id,
'name': selected_student.name,
'major': selected_student.major,
'current_points': selected_student.points,
'call_count': selected_student.call_count,
'call_time': new_record.call_time.strftime('%Y-%m-%d %H:%M:%S')
}
}), 200
except Exception as e:
db.session.rollback()
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'点名失败:{error_msg}'}), 500
@app.route('/api/rollcall/order', methods=['GET'])
def order_rollcall():
"""顺序点名(按学号升序循环)"""
try:
last_record = RollCallRecord.query.filter_by(call_mode='order').order_by(desc('call_time')).first()
students = Student.query.order_by(Student.student_id).all()
if not students:
return jsonify({'code': 400, 'msg': '暂无学生信息,请先导入'}), 400
if not last_record:
selected_student = students[0]
else:
last_index = next((i for i, s in enumerate(students) if s.student_id == last_record.student_id), -1)
selected_student = students[(last_index + 1) % len(students)]
new_record = RollCallRecord(
student_id=selected_student.student_id,
call_mode='order',
is_answer=False,
point_change=get_point_rule('attend_call')
)
selected_student.call_count += 1
selected_student.points = round(selected_student.points + new_record.point_change, 1)
db.session.add(new_record)
db.session.commit()
return jsonify({
'code': 200,
'msg': '顺序点名成功',
'data': {
'student_id': selected_student.student_id,
'name': selected_student.name,
'major': selected_student.major,
'current_points': selected_student.points,
'call_count': selected_student.call_count,
'call_time': new_record.call_time.strftime('%Y-%m-%d %H:%M:%S')
}
}), 200
except Exception as e:
db.session.rollback()
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'点名失败:{error_msg}'}), 500
@app.route('/api/student/update-points', methods=['POST'])
def update_student_points():
"""更新学生积分(回答问题后)"""
data = request.json
required_fields = ['student_id', 'is_answer', 'repeat_question', 'answer_correct_level']
if not all(field in data for field in required_fields):
return jsonify({'code': 400, 'msg': '缺少必填参数student_id/is_answer/repeat_question/answer_correct_level'}), 400
try:
student = Student.query.filter_by(student_id=data['student_id']).first()
if not student:
return jsonify({'code': 404, 'msg': '学生不存在'}), 404
last_record = RollCallRecord.query.filter_by(
student_id=data['student_id'],
is_answer=False
).order_by(desc('call_time')).first()
if not last_record:
return jsonify({'code': 400, 'msg': '无未完成的点名记录(需先点名再更新积分)'}), 400
point_change = last_record.point_change
if data['is_answer']:
if data['repeat_question']:
point_change += get_point_rule('repeat_correct')
else:
point_change += get_point_rule('repeat_incorrect')
level_map = {
'low': 'answer_correct_low',
'mid': 'answer_correct_mid',
'high': 'answer_correct_high'
}
rule_type = level_map.get(data['answer_correct_level'], 'answer_correct_low')
point_change += get_point_rule(rule_type)
points_to_add = point_change - last_record.point_change
student.points = round(student.points + points_to_add, 1)
last_record.is_answer = data['is_answer']
last_record.repeat_question = data['repeat_question']
last_record.answer_correct = (data['answer_correct_level'] != 'none') if data['is_answer'] else None
last_record.point_change = point_change
db.session.commit()
return jsonify({
'code': 200,
'msg': '积分更新成功',
'data': {
'student_id': student.student_id,
'name': student.name,
'current_points': student.points,
'total_point_change': round(point_change, 1)
}
}), 200
except Exception as e:
db.session.rollback()
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'积分更新失败:{error_msg}'}), 500
@app.route('/api/student/rank', methods=['GET'])
def get_student_rank():
"""获取学生积分排名(全量)"""
try:
students = Student.query.order_by(desc(Student.points)).all()
rank_list = [
{
'rank': i+1,
'student_id': s.student_id,
'name': s.name,
'major': s.major,
'points': round(s.points, 1),
'call_count': s.call_count
} for i, s in enumerate(students)
]
return jsonify({
'code': 200,
'msg': '排名查询成功',
'data': rank_list
}), 200
except Exception as e:
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'排名查询失败:{error_msg}'}), 500
@app.route('/api/student/rank/top/<int:top_n>', methods=['GET'])
def get_top_student_rank(top_n):
"""获取积分最高的top_n名学生前端可视化专用"""
try:
top_students = Student.query.order_by(desc(Student.points)).limit(top_n).all()
if not top_students:
return jsonify({'code': 400, 'msg': '暂无学生信息,请先导入'}), 400
rank_list = []
for idx, student in enumerate(top_students):
random_call_count = RollCallRecord.query.filter_by(
student_id=student.student_id,
call_mode='random'
).count()
rank_list.append({
'rank': idx + 1,
'student_id': student.student_id,
'name': student.name,
'major': student.major,
'points': round(student.points, 1),
'call_count': student.call_count,
'random_call_count': random_call_count
})
return jsonify({
'code': 200,
'msg': f'获取Top{top_n}积分排名成功',
'data': rank_list
}), 200
except Exception as e:
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'Top{top_n}排名查询失败:{error_msg}'}), 500
@app.route('/api/student/export', methods=['GET'])
def export_student_points():
"""导出积分详单Excel"""
try:
students = Student.query.order_by(desc(Student.points)).all()
export_data = [
{
'学号': s.student_id,
'姓名': s.name,
'专业': s.major,
'随机点名次数': len(RollCallRecord.query.filter_by(student_id=s.student_id, call_mode='random').all()),
'总点名次数': s.call_count,
'总积分': round(s.points, 1)
} for s in students
]
df = pd.DataFrame(export_data)
export_filename = f'积分详单_{datetime.now().strftime("%Y%m%d%H%M%S")}.xlsx'
export_path = os.path.join(EXPORT_DIR, export_filename)
df.to_excel(export_path, index=False, engine='openpyxl')
return send_file(export_path, as_attachment=True, download_name=export_filename)
except Exception as e:
error_msg = str(e)
if 'Lost connection' in error_msg:
return jsonify({'code': 500, 'msg': 'db4free连接超时请1分钟后重试'}), 500
return jsonify({'code': 500, 'msg': f'导出失败:{error_msg}'}), 500
# ---------------------- 6. 启动入口(无修改) ----------------------
if __name__ == '__main__':
init_db() # 首次运行自动创建表和默认规则db4free云端
app.run(host='0.0.0.0', port=5000, debug=True)

Binary file not shown.
Loading…
Cancel
Save