377 lines
11 KiB
Vue
377 lines
11 KiB
Vue
<template>
|
||
<section>
|
||
<header class="page-head">
|
||
<div>
|
||
<h2>Excel 导入</h2>
|
||
<p>上传后先校验,确认导入后才会正式写入学员和证书数据。</p>
|
||
</div>
|
||
<el-button :disabled="!selectedTemplate" @click="downloadTemplate">下载所选模板</el-button>
|
||
</header>
|
||
|
||
<el-alert
|
||
class="import-tip"
|
||
type="info"
|
||
:closable="false"
|
||
title="请先选择证书模板,再下载对应 Excel。不同证书模板的列和必填项不同,不要混用文件。"
|
||
/>
|
||
|
||
<div class="batch-template">
|
||
<div>
|
||
<strong>本批次证书模板</strong>
|
||
<span>下载、校验和正式签发都以本次选择为准。</span>
|
||
</div>
|
||
<el-select v-model="templateCode" placeholder="选择证书模板">
|
||
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
|
||
</el-select>
|
||
<img v-if="selectedTemplate" :src="selectedTemplate.preview_url" :alt="selectedTemplate.name" />
|
||
<p v-if="selectedTemplate" class="template-fields">Excel 字段:{{ selectedImportColumns.join("、") }}</p>
|
||
</div>
|
||
|
||
<el-upload
|
||
class="upload"
|
||
drag
|
||
:auto-upload="false"
|
||
:show-file-list="true"
|
||
:limit="1"
|
||
accept=".xlsx"
|
||
@change="pickFile"
|
||
@remove="selectedFile = null"
|
||
>
|
||
<el-icon class="upload-icon"><UploadFilled /></el-icon>
|
||
<div>把 Excel 文件拖到这里,或点击选择文件</div>
|
||
</el-upload>
|
||
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
|
||
|
||
<el-card class="panel" shadow="never">
|
||
<el-table :data="batches" border>
|
||
<el-table-column prop="id" label="批次ID" width="90" />
|
||
<el-table-column prop="filename" label="文件名" min-width="220" />
|
||
<el-table-column label="证书模板" width="170">
|
||
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
|
||
</el-table-column>
|
||
<el-table-column label="状态" width="120">
|
||
<template #default="{ row }">{{ statusText(row.status) }}</template>
|
||
</el-table-column>
|
||
<el-table-column prop="total_rows" label="总行数" width="100" />
|
||
<el-table-column prop="valid_rows" label="可导入" width="100" />
|
||
<el-table-column prop="failed_rows" label="失败" width="100" />
|
||
<el-table-column prop="created_at" label="上传时间" width="190" />
|
||
<el-table-column label="操作" width="470">
|
||
<template #default="{ row }">
|
||
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
|
||
<el-button
|
||
v-if="row.status === 'validated'"
|
||
size="small"
|
||
type="primary"
|
||
:loading="confirmingId === row.id"
|
||
@click="confirmBatch(row)"
|
||
>
|
||
确认导入
|
||
</el-button>
|
||
<el-button
|
||
v-if="row.status === 'imported'"
|
||
size="small"
|
||
:loading="preGeneratingId === row.id"
|
||
@click="startPregeneration(row)"
|
||
>
|
||
预生成PDF
|
||
</el-button>
|
||
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
|
||
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
|
||
</template>
|
||
</el-table-column>
|
||
</el-table>
|
||
</el-card>
|
||
|
||
<el-dialog v-model="pregenerationVisible" title="批量预生成PDF" width="560px" @closed="stopPregenerationPolling">
|
||
<div v-if="pregenerationJob" class="pregeneration-panel">
|
||
<el-progress :percentage="pregenerationJob.percent" />
|
||
<dl class="pregeneration-stats">
|
||
<div><dt>总数</dt><dd>{{ pregenerationJob.total }}</dd></div>
|
||
<div><dt>已完成</dt><dd>{{ pregenerationJob.completed }}</dd></div>
|
||
<div><dt>新生成</dt><dd>{{ pregenerationJob.generated }}</dd></div>
|
||
<div><dt>已缓存</dt><dd>{{ pregenerationJob.cached }}</dd></div>
|
||
<div><dt>跳过</dt><dd>{{ pregenerationJob.skipped }}</dd></div>
|
||
<div><dt>失败</dt><dd>{{ pregenerationJob.failed }}</dd></div>
|
||
</dl>
|
||
<p class="progress-note">当前任务按系统设置的最大并发数执行:{{ pregenerationJob.concurrency_limit }}</p>
|
||
<el-alert
|
||
v-if="pregenerationDone"
|
||
:type="pregenerationJob.failed ? 'warning' : 'success'"
|
||
:closable="false"
|
||
:title="pregenerationJob.failed ? '预生成已完成,但有部分失败。' : '预生成已完成。'"
|
||
/>
|
||
<ul v-if="pregenerationJob.errors.length" class="error-list">
|
||
<li v-for="item in pregenerationJob.errors" :key="item">{{ item }}</li>
|
||
</ul>
|
||
</div>
|
||
</el-dialog>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { UploadFilled } from "@element-plus/icons-vue";
|
||
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
|
||
import { computed, onMounted, onUnmounted, ref } from "vue";
|
||
|
||
import { http, type CertificateTemplate, type ImportBatch, type PdfPregenerationJob } from "../api";
|
||
import { apiErrorMessage, downloadFile } from "../download";
|
||
|
||
const batches = ref<ImportBatch[]>([]);
|
||
const templates = ref<CertificateTemplate[]>([]);
|
||
const templateCode = ref("classic");
|
||
const selectedFile = ref<File | null>(null);
|
||
const uploading = ref(false);
|
||
const confirmingId = ref<number | null>(null);
|
||
const preGeneratingId = ref<number | null>(null);
|
||
const pregenerationVisible = ref(false);
|
||
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
|
||
let pregenerationTimer: number | null = null;
|
||
|
||
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
|
||
const selectedTemplate = computed(() => templates.value.find((item) => item.code === templateCode.value));
|
||
const selectedImportColumns = computed(() => [
|
||
"姓名",
|
||
"手机号",
|
||
"项目代码",
|
||
...(selectedTemplate.value?.dynamic_fields.filter((field) => field !== "姓名") || []),
|
||
]);
|
||
|
||
function templateName(code: string) {
|
||
return templates.value.find((item) => item.code === code)?.name || code;
|
||
}
|
||
|
||
function statusText(status: string) {
|
||
const labels: Record<string, string> = {
|
||
uploaded: "已上传",
|
||
validated: "已校验",
|
||
imported: "已导入",
|
||
failed: "校验失败",
|
||
};
|
||
return labels[status] || status;
|
||
}
|
||
|
||
function pickFile(file: UploadFile) {
|
||
selectedFile.value = file.raw || null;
|
||
}
|
||
|
||
function downloadTemplate() {
|
||
if (!selectedTemplate.value) return;
|
||
downloadFile(
|
||
`/admin/import-batches/template?template_code=${encodeURIComponent(templateCode.value)}`,
|
||
`certificate-import-${templateCode.value}.xlsx`,
|
||
);
|
||
}
|
||
|
||
async function loadBatches() {
|
||
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
|
||
batches.value = data;
|
||
}
|
||
|
||
async function loadTemplates() {
|
||
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
|
||
templates.value = data;
|
||
}
|
||
|
||
async function uploadFile() {
|
||
if (!selectedFile.value) return;
|
||
uploading.value = true;
|
||
try {
|
||
const body = new FormData();
|
||
body.append("file", selectedFile.value);
|
||
body.append("template_code", templateCode.value);
|
||
await http.post("/admin/import-batches", body);
|
||
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
|
||
selectedFile.value = null;
|
||
await loadBatches();
|
||
} finally {
|
||
uploading.value = false;
|
||
}
|
||
}
|
||
|
||
async function confirmBatch(row: ImportBatch) {
|
||
await ElMessageBox.confirm(`确认把批次 ${row.id} 中 ${row.valid_rows} 行校验通过的数据写入系统?`, "确认导入", { type: "warning" });
|
||
confirmingId.value = row.id;
|
||
try {
|
||
const { data } = await http.post<ImportBatch>(`/admin/import-batches/${row.id}/confirm`);
|
||
row.status = data.status;
|
||
ElMessage.success(`导入完成,当前状态:${statusText(data.status)}`);
|
||
await loadBatches();
|
||
} finally {
|
||
confirmingId.value = null;
|
||
}
|
||
}
|
||
|
||
function downloadSource(row: ImportBatch) {
|
||
downloadFile(`/admin/import-batches/${row.id}/file`, row.filename);
|
||
}
|
||
|
||
function downloadErrorReport(row: ImportBatch) {
|
||
downloadFile(`/admin/import-batches/${row.id}/error-report`, `import-errors-${row.id}.xlsx`);
|
||
}
|
||
|
||
async function deleteBatch(row: ImportBatch) {
|
||
await ElMessageBox.confirm(`确认删除导入批次 ${row.id}?这只删除上传文件和导入记录,不会删除已经生成的学员和证书。`, "删除导入记录", { type: "warning" });
|
||
await http.delete(`/admin/import-batches/${row.id}`);
|
||
ElMessage.success("导入记录已删除");
|
||
await loadBatches();
|
||
}
|
||
|
||
async function startPregeneration(row: ImportBatch) {
|
||
preGeneratingId.value = row.id;
|
||
try {
|
||
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", {
|
||
import_batch_id: row.id,
|
||
});
|
||
pregenerationJob.value = data;
|
||
pregenerationVisible.value = true;
|
||
startPregenerationPolling(data.id);
|
||
} catch (error: any) {
|
||
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
|
||
} finally {
|
||
preGeneratingId.value = null;
|
||
}
|
||
}
|
||
|
||
function startPregenerationPolling(jobId: string) {
|
||
stopPregenerationPolling();
|
||
pregenerationTimer = window.setInterval(async () => {
|
||
const { data } = await http.get<PdfPregenerationJob>(`/admin/certificates/pdf-pregeneration-jobs/${jobId}`);
|
||
pregenerationJob.value = data;
|
||
if (["completed", "completed_with_errors"].includes(data.status)) {
|
||
stopPregenerationPolling();
|
||
}
|
||
}, 1000);
|
||
}
|
||
|
||
function stopPregenerationPolling() {
|
||
if (pregenerationTimer) {
|
||
clearInterval(pregenerationTimer);
|
||
pregenerationTimer = null;
|
||
}
|
||
}
|
||
|
||
onMounted(() => Promise.all([loadBatches(), loadTemplates()]));
|
||
onUnmounted(stopPregenerationPolling);
|
||
</script>
|
||
|
||
<style scoped>
|
||
.page-head {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
margin-bottom: 16px;
|
||
}
|
||
|
||
.page-head p {
|
||
margin: 6px 0 0;
|
||
color: #64748b;
|
||
}
|
||
|
||
.upload {
|
||
margin-top: 16px;
|
||
margin-bottom: 12px;
|
||
}
|
||
|
||
.batch-template {
|
||
display: grid;
|
||
grid-template-columns: minmax(260px, 1fr) 240px 180px;
|
||
align-items: center;
|
||
gap: 18px;
|
||
margin-top: 16px;
|
||
padding: 14px 16px;
|
||
border: 1px solid var(--app-border);
|
||
border-radius: 8px;
|
||
background: #fff;
|
||
}
|
||
|
||
.batch-template div {
|
||
display: grid;
|
||
gap: 5px;
|
||
}
|
||
|
||
.batch-template span {
|
||
color: var(--app-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.batch-template img {
|
||
width: 180px;
|
||
aspect-ratio: 1.36;
|
||
object-fit: contain;
|
||
border: 1px solid #edf2f4;
|
||
}
|
||
|
||
.template-fields {
|
||
grid-column: 1 / -1;
|
||
margin: 0;
|
||
color: var(--app-muted);
|
||
font-size: 13px;
|
||
}
|
||
|
||
.import-tip {
|
||
margin-bottom: 4px;
|
||
}
|
||
|
||
.upload-icon {
|
||
font-size: 36px;
|
||
color: #208a87;
|
||
}
|
||
|
||
.panel {
|
||
margin-top: 20px;
|
||
border-radius: 8px;
|
||
}
|
||
|
||
.pregeneration-panel {
|
||
display: grid;
|
||
gap: 16px;
|
||
}
|
||
|
||
.pregeneration-stats {
|
||
display: grid;
|
||
grid-template-columns: repeat(6, minmax(0, 1fr));
|
||
gap: 8px;
|
||
margin: 0;
|
||
}
|
||
|
||
.pregeneration-stats div {
|
||
border: 1px solid #e2edf0;
|
||
border-radius: 8px;
|
||
padding: 10px;
|
||
text-align: center;
|
||
}
|
||
|
||
.pregeneration-stats dt {
|
||
color: #64748b;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.pregeneration-stats dd {
|
||
margin: 4px 0 0;
|
||
font-size: 18px;
|
||
font-weight: 700;
|
||
}
|
||
|
||
.progress-note {
|
||
margin: 0;
|
||
color: #64748b;
|
||
}
|
||
|
||
.error-list {
|
||
margin: 0;
|
||
color: #b91c1c;
|
||
line-height: 1.8;
|
||
}
|
||
|
||
@media (max-width: 900px) {
|
||
.batch-template {
|
||
grid-template-columns: 1fr;
|
||
}
|
||
|
||
.batch-template img {
|
||
width: min(100%, 260px);
|
||
}
|
||
}
|
||
</style>
|