Files
certificate-system/frontend/src/views/AdminImports.vue
2026-08-14 11:37:09 +08:00

377 lines
11 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.
<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>