新增PDF批量预生成

This commit is contained in:
2026-06-23 13:59:56 +08:00
parent b6be8b4593
commit 4742c175c3
18 changed files with 725 additions and 24 deletions

View File

@@ -62,6 +62,8 @@ export interface Learner {
export interface AdminCertificate {
id: number;
learner_id: number;
learner_name: string | null;
import_batch_id: number | null;
project_code: string;
certificate_no: string;
certificate_name: string;
@@ -124,3 +126,19 @@ export interface PdfSettings {
pdf_generation_concurrency_limit: number;
updated_at: string | null;
}
export interface PdfPregenerationJob {
id: string;
status: string;
total: number;
completed: number;
generated: number;
cached: number;
skipped: number;
failed: number;
percent: number;
concurrency_limit: number;
errors: string[];
created_at: string;
finished_at: string | null;
}

View File

@@ -3,7 +3,7 @@
<header class="page-head">
<div>
<h2>证书管理</h2>
<p>支持单张新增预览下载作废和链接重置</p>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
</header>
@@ -44,16 +44,32 @@
<el-option label="有效" value="valid" />
<el-option label="已作废" value="voided" />
</el-select>
<el-select v-model="pdfStatusValue" clearable placeholder="PDF状态">
<el-option label="未生成" value="not_generated" />
<el-option label="已生成" value="generated" />
<el-option label="已清理" value="cleaned" />
</el-select>
<el-select v-model="importBatchId" clearable filterable placeholder="导入批次">
<el-option v-for="item in importBatches" :key="item.id" :label="`#${item.id} ${item.filename}`" :value="item.id" />
</el-select>
<el-button @click="loadCertificates">搜索</el-button>
<el-button type="primary" :disabled="!selectedCertificates.length" :loading="pregenerationStarting" @click="startPregenerationSelected">预生成所选PDF</el-button>
<el-button :disabled="!importBatchId" :loading="pregenerationStarting" @click="startPregenerationBatch">预生成当前批次全部</el-button>
<el-button @click="exportCertificates">导出</el-button>
</div>
<el-table :data="certificates" border>
<el-table :data="certificates" border @selection-change="handleSelectionChange">
<el-table-column type="selection" width="44" />
<el-table-column prop="certificate_no" label="证书编号" width="180" show-overflow-tooltip />
<el-table-column prop="learner_id" label="学员ID" width="76" />
<el-table-column prop="learner_name" label="学员姓名" width="100" show-overflow-tooltip />
<el-table-column prop="project_code" label="项目" width="72" />
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column prop="issue_date" label="发证日期" width="108" />
<el-table-column label="PDF" width="92">
<template #default="{ row }">
<el-tag :type="row.pdf_status === 'generated' ? 'success' : 'info'">{{ pdfStatusText(row.pdf_status) }}</el-tag>
</template>
</el-table-column>
<el-table-column label="状态" width="80">
<template #default="{ row }">
<el-tag :type="row.status === 'valid' ? 'success' : 'danger'">{{ statusText(row.status) }}</el-tag>
@@ -90,23 +106,55 @@
</el-descriptions-item>
</el-descriptions>
</el-dialog>
<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 { ElLoading, ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ProjectCourse } from "../api";
import { http, type AdminCertificate, type ImportBatch, type PdfPregenerationJob, type ProjectCourse } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const importBatches = ref<ImportBatch[]>([]);
const keyword = ref("");
const statusValue = ref("");
const pdfStatusValue = ref("");
const importBatchId = ref<number | undefined>();
const previewVisible = ref(false);
const preview = ref<any>(null);
const downloadingCertificateId = ref<number | null>(null);
const selectedCertificates = ref<AdminCertificate[]>([]);
const pregenerationStarting = ref(false);
const pregenerationVisible = ref(false);
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
project_code: "",
@@ -123,18 +171,38 @@ function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
function pdfStatusText(status: string) {
return { generated: "已生成", cleaned: "已清理", not_generated: "未生成" }[status] || status;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
projects.value = data;
}
async function loadImportBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
importBatches.value = data;
}
async function loadCertificates() {
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
params: {
keyword: keyword.value || undefined,
status: statusValue.value || undefined,
pdf_status: pdfStatusValue.value || undefined,
import_batch_id: importBatchId.value || undefined,
},
});
certificates.value = data;
}
function handleSelectionChange(rows: AdminCertificate[]) {
selectedCertificates.value = rows;
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.issue_date || !form.issuer_name) {
ElMessage.warning("请填写学员ID、项目、发证日期和发证单位");
@@ -192,9 +260,58 @@ function exportCertificates() {
downloadFile("/admin/exports/certificates", "certificates-export.xlsx");
}
async function startPregenerationSelected() {
const ids = selectedCertificates.value.map((item) => item.id);
if (!ids.length) {
ElMessage.warning("请先勾选证书");
return;
}
await startPregeneration({ certificate_ids: ids });
}
async function startPregenerationBatch() {
if (!importBatchId.value) return;
await startPregeneration({ import_batch_id: importBatchId.value });
}
async function startPregeneration(payload: { certificate_ids?: number[]; import_batch_id?: number }) {
pregenerationStarting.value = true;
try {
const { data } = await http.post<PdfPregenerationJob>("/admin/certificates/pdf-pregeneration-jobs", payload);
pregenerationJob.value = data;
pregenerationVisible.value = true;
startPregenerationPolling(data.id);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "启动PDF预生成失败"));
} finally {
pregenerationStarting.value = false;
}
}
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();
await loadCertificates();
}
}, 1000);
}
function stopPregenerationPolling() {
if (pregenerationTimer) {
clearInterval(pregenerationTimer);
pregenerationTimer = null;
}
}
onMounted(async () => {
await Promise.all([loadProjects(), loadCertificates()]);
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
</script>
<style scoped>
@@ -231,6 +348,10 @@ onMounted(async () => {
max-width: 340px;
}
.toolbar .el-select {
width: 170px;
}
.form-tip {
font-size: 12px;
color: #94a3b8;
@@ -247,4 +368,45 @@ onMounted(async () => {
.table-actions :deep(.el-button + .el-button) {
margin-left: 0;
}
.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;
padding-left: 18px;
color: #991b1b;
}
</style>

View File

@@ -34,7 +34,7 @@
<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="380">
<el-table-column label="操作" width="470">
<template #default="{ row }">
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
<el-button
@@ -46,27 +46,65 @@
>
确认导入
</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 { onMounted, ref } from "vue";
import { computed, onMounted, onUnmounted, ref } from "vue";
import { http, type ImportBatch } from "../api";
import { downloadFile } from "../download";
import { http, type ImportBatch, type PdfPregenerationJob } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
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 || ""));
function statusText(status: string) {
const labels: Record<string, string> = {
@@ -134,7 +172,42 @@ async function deleteBatch(row: ImportBatch) {
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(loadBatches);
onUnmounted(stopPregenerationPolling);
</script>
<style scoped>
@@ -163,4 +236,45 @@ onMounted(loadBatches);
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;
}
</style>