Files
toolbox/toolbox-app/src/components/tools/CourseWithdrawal.vue
2025-10-24 12:53:22 +08:00

538 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<script setup>
import { ref, computed, onMounted } from 'vue';
import { useSettingsStore } from '../../store/settings';
const file = ref(null);
const previewData = ref([]);
const isParsing = ref(false);
const isWithdrawing = ref(false);
const fileInput = ref(null);
const schoolName = ref('');
const schoolNameError = 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 = '';
}
}
const settingsStore = useSettingsStore();
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);
if (settingsStore.authorization) {
schoolNameError.value = '令牌失效,请检查您的 Authorization';
} else {
schoolNameError.value = '请先在设置页面填写配置信息';
}
}
});
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 v-if="schoolNameError" class="school-info-tag school-info-error">
{{ schoolNameError }}
</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;
}
.school-info-error {
background-color: #fff1f0;
border-color: #ffa39e;
color: #cf1322;
}
.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>