538 lines
13 KiB
Vue
538 lines
13 KiB
Vue
<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> |