Initial commit: 批量退课工具项目

This commit is contained in:
Nvex
2025-10-22 17:58:21 +08:00
commit ef9a218bfc
25 changed files with 3743 additions and 0 deletions

View File

@@ -0,0 +1,521 @@
<script setup>
import { ref, computed, onMounted } from 'vue';
import axios from 'axios';
const file = ref(null);
const previewData = ref([]);
const isParsing = ref(false);
const isWithdrawing = ref(false);
const fileInput = ref(null);
const schoolName = ref('');
const showFailedOnly = ref(false);
const withdrawalResults = ref(null);
const failedWithdrawals = computed(() => {
if (!withdrawalResults.value || !withdrawalResults.value.details) {
return [];
}
const failedDetails = withdrawalResults.value.details.filter(result => result.includes('失败') || result.includes('failed'));
const studentIds = failedDetails.map(result => {
const match = result.match(/学生ID: (\S+),/);
return match ? match[1] : null;
});
return [...new Set(studentIds.filter(id => id !== null))];
});
const groupedPreviewData = computed(() => {
if (!previewData.value || previewData.value.length === 0) {
return [];
}
const groups = {};
previewData.value.forEach(item => {
const studentId = item['学生ID'];
if (!groups[studentId]) {
groups[studentId] = {
studentId: studentId,
studentName: item['学生姓名'],
adminClasses: item['班级'] || [],
courses: []
};
}
groups[studentId].courses.push({
classId: item['课程班ID'],
className: item['课程班名称']
});
});
return Object.values(groups);
});
function triggerFileInput() {
fileInput.value.click();
}
function downloadTemplate() {
window.location.href = 'http://127.0.0.1:5001/api/download-template';
}
async function handleFileUpload(event) {
const target = event.target;
if (target.files.length === 0) {
return;
}
file.value = target.files[0];
isParsing.value = true;
previewData.value = [];
const formData = new FormData();
formData.append('file', file.value);
try {
const response = await axios.post('http://127.0.0.1:5001/api/preview', formData, {
headers: {
'Content-Type': 'multipart/form-data'
}
});
previewData.value = response.data.data;
} catch (error) {
console.error('Error uploading file:', error);
alert('文件预览失败');
} finally {
isParsing.value = false;
}
}
async function executeWithdrawal() {
if (previewData.value.length === 0) {
alert('请先上传并预览文件');
return;
}
isWithdrawing.value = true;
try {
const response = await axios.post('http://127.0.0.1:5001/api/withdraw', {
withdraw_list: previewData.value
});
withdrawalResults.value = response.data;
if (response.data.success) {
previewData.value = [];
}
} catch (error) {
console.error('Error during withdrawal:', error);
if (error.response && error.response.data) {
withdrawalResults.value = { error: error.response.data.error || error.response.data.message || '未知错误' };
} else {
withdrawalResults.value = { error: '退课过程中发生网络错误' };
}
} finally {
isWithdrawing.value = false;
}
}
function clearPreview() {
file.value = null;
previewData.value = [];
withdrawalResults.value = null;
if (fileInput.value) {
fileInput.value.value = '';
}
}
onMounted(async () => {
try {
const response = await axios.get('http://127.0.0.1:5001/api/school-name');
schoolName.value = response.data.school_name;
} catch (error) {
console.error('Error fetching school name:', error);
}
});
function getResultItemClass(studentId) {
const baseClass = 'result-item';
// 假设传入的 studentId 都是失败的,所以直接返回失败的样式
return `${baseClass} result-failed`;
}
function getResultIcon(studentId) {
// 假设传入的 studentId 都是失败的,所以直接返回失败的图标
return '❌';
}
</script>
<template>
<div class="page-container">
<div class="content-card">
<h3 class="card-title">批量退课工具</h3>
<div v-if="schoolName" class="school-info-tag">
正在给 {{ schoolName }} 学校退课
</div>
<div class="step-section">
<div class="step-header">
<span class="step-number">1</span>
<h5>下载模板</h5>
</div>
<p class="step-description">下载Excel模板文件并根据模板格式填写需要退课的学生和课程信息</p>
<button @click="downloadTemplate" class="btn btn-secondary">下载模板</button>
</div>
<div class="step-section">
<div class="step-header">
<span class="step-number">2</span>
<h5>上传文件</h5>
</div>
<div v-if="previewData.length === 0">
<div class="file-upload-area" @click="triggerFileInput">
<input type="file" ref="fileInput" @change="handleFileUpload" accept=".xlsx, .xls" hidden>
<span v-if="!isParsing">点击或拖拽文件到此处上传</span>
<div v-if="isParsing" class="loader"></div>
</div>
</div>
<div v-else-if="file" class="file-uploaded-info">
<p>文件 <strong>{{ file.name }}</strong> 已上传请在下方预览数据</p>
<button @click="clearPreview" class="btn btn-outline-secondary">重新上传</button>
</div>
</div>
<div v-if="previewData.length > 0" class="step-section">
<div class="step-header">
<span class="step-number">3</span>
<h5>预览退课信息</h5>
</div>
<div class="table-responsive">
<table class="table table-bordered table-hover">
<thead class="table-light">
<tr>
<th>学生ID</th>
<th>学生姓名</th>
<th>班级</th>
<th>课程班名称</th>
</tr>
</thead>
<tbody>
<tr v-for="group in groupedPreviewData" :key="group.studentId">
<td>{{ group.studentId }}</td>
<td>{{ group.studentName }}</td>
<td>
<div class="admin-classes-grid">
<div v-for="adminClass in group.adminClasses" :key="adminClass" class="admin-class-item">{{ adminClass }}</div>
</div>
</td>
<td>
<span>
{{ group.courses.map(c => c.className).join(' / ') }}
</span>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<div v-if="previewData.length > 0" class="step-section">
<div class="step-header">
<span class="step-number">4</span>
<h5>执行退课</h5>
</div>
<p class="step-description">确认预览信息无误后点击执行退课</p>
<div v-if="isWithdrawing" class="text-center">
<div class="spinner-border" role="status">
<span class="visually-hidden">退课中...</span>
</div>
<p class="mt-2">正在执行退课操作...</p>
</div>
<button @click="executeWithdrawal" class="btn btn-danger" :disabled="isWithdrawing">
{{ isWithdrawing ? '退课中...' : '执行退课' }}
</button>
</div>
<div v-if="withdrawalResults" class="results-section">
<h5 class="results-title">退课结果</h5>
<div class="results-summary">
<div v-if="withdrawalResults.message" class="summary-item">
<span class="summary-label">状态:</span>
<span class="summary-value">{{ withdrawalResults.message }}</span>
</div>
<div v-if="withdrawalResults.error" class="summary-item">
<span class="summary-label">错误:</span>
<span class="summary-value">{{ withdrawalResults.error }}</span>
</div>
</div>
<div v-if="failedWithdrawals.length > 0" class="results-grid">
<div v-for="(studentId, index) in failedWithdrawals" :key="index" :class="getResultItemClass(studentId)">
<span class="result-icon">{{ getResultIcon(studentId) }}</span>
<span class="result-text">学生ID: {{ studentId }} 退课失败</span>
</div>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.page-container {
padding: 20px;
background-color: #f0f2f5;
min-height: 100vh;
}
.content-card {
max-width: 1600px;
margin: 0 auto;
background-color: #fff;
padding: 24px;
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
.card-title {
font-size: 20px;
font-weight: 600;
margin-bottom: 24px;
}
.step-section {
margin-bottom: 24px;
}
.step-header {
display: flex;
align-items: center;
margin-bottom: 12px;
}
.step-number {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
border-radius: 50%;
background-color: #1890ff;
color: #fff;
font-weight: 600;
margin-right: 12px;
}
.step-description {
color: #555;
margin-bottom: 12px;
}
.file-upload-area {
border: 2px dashed #d9d9d9;
border-radius: 8px;
padding: 40px;
text-align: center;
cursor: pointer;
background-color: #fafafa;
transition: border-color 0.3s;
}
.file-upload-area:hover {
border-color: #1890ff;
}
.progress-bar-container {
width: 100%;
background-color: #f3f3f3;
border-radius: 5px;
margin-top: 10px;
position: relative;
}
.progress-bar {
height: 20px;
background-color: #4caf50;
border-radius: 5px;
text-align: center;
line-height: 20px;
color: white;
}
.progress-text {
position: absolute;
width: 100%;
text-align: center;
top: 0;
left: 0;
color: #000;
line-height: 20px;
}
.file-uploaded-info {
display: flex;
align-items: center;
justify-content: space-between;
}
.table-responsive {
max-height: 600px;
overflow-y: auto;
border: 1px solid #e8e8e8;
border-radius: 8px;
}
.table th:nth-child(2),
.table td:nth-child(2) {
width: 70px;
}
.table th:nth-child(3),
.table td:nth-child(3) {
width: 50px;
}
.table th, .table td {
padding: 12px 16px;
border: 1px solid #e8e8e8;
}
.student-cell {
vertical-align: middle;
text-align: center;
}
.results-section {
margin-top: 24px;
padding-top: 24px;
border-top: 1px solid #e8e8e8;
}
.school-info-tag {
background-color: #e6f7ff;
border: 1px solid #91d5ff;
color: #096dd9;
padding: 8px 12px;
border-radius: 4px;
margin-bottom: 16px;
display: inline-block;
}
.admin-classes-grid {
grid-template-columns: repeat(3, 1fr);
gap: 4px;
text-align: left;
margin-top: 4px;
}
.btn {
border-radius: 8px;
}
.results-summary {
background-color: #f8f9fa;
padding: 16px;
border-radius: 8px;
margin-bottom: 16px;
}
.summary-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 0;
}
.summary-label {
font-weight: 600;
color: #333;
}
.summary-value {
color: #555;
}
.results-title {
font-size: 18px;
font-weight: 600;
margin-bottom: 16px;
}
.summary-message {
margin-bottom: 16px;
}
.results-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 12px;
}
.result-item {
display: flex;
align-items: center;
padding: 12px;
border-radius: 6px;
background-color: #f8f9fa;
border: 1px solid #dee2e6;
font-size: 0.9em;
transition: transform 0.2s, box-shadow 0.2s;
}
.result-item:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
}
.result-icon {
margin-right: 10px;
font-size: 1.2em;
}
.result-text {
flex-grow: 1;
}
.result-success {
background-color: #e6f7ff;
border-color: #91d5ff;
color: #096dd9;
}
.result-failed {
background-color: #fff1f0;
border-color: #ffa39e;
color: #cf1322;
}
.result-skipped {
background-color: #fffbe6;
border-color: #ffe58f;
color: #ad8b00;
}
/* From Uiverse.io by Shoh2008 */
.loader {
width: 45px;
height: 40px;
background: linear-gradient(#0000 calc(1 * 100% / 6), #000 0 calc(3 * 100% / 6), #0000 0),
linear-gradient(#0000 calc(2 * 100% / 6), #000 0 calc(4 * 100% / 6), #0000 0),
linear-gradient(#0000 calc(3 * 100% / 6), #000 0 calc(5 * 100% / 6), #0000 0);
background-size: 10px 400%;
background-repeat: no-repeat;
animation: matrix 1s infinite linear;
margin: 0 auto;
}
.loader-small {
width: 22.5px;
height: 20px;
background: linear-gradient(#0000 calc(1 * 100% / 6), #000 0 calc(3 * 100% / 6), #0000 0),
linear-gradient(#0000 calc(2 * 100% / 6), #000 0 calc(4 * 100% / 6), #0000 0),
linear-gradient(#0000 calc(3 * 100% / 6), #000 0 calc(5 * 100% / 6), #0000 0);
background-size: 5px 400%;
background-repeat: no-repeat;
animation: matrix 1s infinite linear;
margin: 0 auto;
}
@keyframes matrix {
0% {
background-position: 0% 100%, 50% 100%, 100% 100%;
}
100% {
background-position: 0% 0%, 50% 0%, 100% 0%;
}
}
</style>