Files
certificate-system/frontend/src/views/AdminCertificates.vue
2026-06-23 13:59:56 +08:00

413 lines
15 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>证书管理</h2>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="学员ID">
<el-input v-model.number="form.learner_id" />
</el-form-item>
<el-form-item label="项目代码">
<el-select v-model="form.project_code" filterable>
<el-option v-for="item in activeProjects" :key="item.code" :label="`${item.code} ${item.name}`" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="发证日期">
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" />
</el-form-item>
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" />
</el-form-item>
<el-form-item label="阶段名称">
<el-input v-model.trim="form.stage_name" placeholder="如:全部、初级、中级" />
<div class="form-tip">证书上将显示为{输入内容}课程的专业学习留空则默认显示初级课程的专业学习</div>
</el-form-item>
<el-form-item label="发证单位">
<el-input v-model.trim="form.issuer_name" />
</el-form-item>
<el-form-item label="备注">
<el-input v-model.trim="form.remark" />
</el-form-item>
</el-form>
</el-card>
<el-card class="panel" shadow="never">
<div class="toolbar">
<el-input v-model.trim="keyword" placeholder="按证书编号、名称、项目代码搜索" clearable @keyup.enter="loadCertificates" />
<el-select v-model="statusValue" clearable placeholder="状态">
<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 @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_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>
</template>
</el-table-column>
<el-table-column label="操作" width="270">
<template #default="{ row }">
<div class="table-actions">
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
<el-button size="small" :loading="downloadingCertificateId === row.id" @click="downloadCertificate(row)">下载</el-button>
<el-button size="small" @click="regenerateToken(row)">重置链接</el-button>
<el-button v-if="row.status === 'valid'" size="small" type="danger" @click="voidCertificate(row)">作废</el-button>
</div>
</template>
</el-table-column>
</el-table>
</el-card>
<el-dialog v-model="previewVisible" title="证书预览" width="680px">
<el-descriptions v-if="preview" :column="1" border>
<el-descriptions-item label="证书编号">{{ preview.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ preview.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ preview.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
<el-descriptions-item label="公开链接">
<a :href="preview.public_url" target="_blank" rel="noreferrer">{{ preview.public_url }}</a>
</el-descriptions-item>
<el-descriptions-item label="核验链接">
<a :href="preview.verify_url" target="_blank" rel="noreferrer">{{ preview.verify_url }}</a>
</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, onUnmounted, reactive, ref } from "vue";
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: "",
course_name: "",
stage_name: "",
issue_date: "",
issuer_name: "",
remark: "",
});
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
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,
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、项目、发证日期和发证单位");
return;
}
await http.post("/admin/certificates", {
learner_id: form.learner_id,
project_code: form.project_code,
course_name: form.course_name || null,
stage_name: form.stage_name || null,
issue_date: form.issue_date,
issuer_name: form.issuer_name,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
await loadCertificates();
}
async function previewCertificate(row: AdminCertificate) {
const { data } = await http.get(`/admin/certificates/${row.id}/preview`);
preview.value = data;
previewVisible.value = true;
}
async function downloadCertificate(row: AdminCertificate) {
downloadingCertificateId.value = row.id;
const loading = ElLoading.service({
lock: true,
text: "当前查询人数较多,请稍后。。。",
background: "rgba(255, 255, 255, 0.72)",
});
try {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloadingCertificateId.value = null;
}
}
async function voidCertificate(row: AdminCertificate) {
await ElMessageBox.confirm(`确认作废证书 ${row.certificate_no}`, "作废证书", { type: "warning" });
await http.post(`/admin/certificates/${row.id}/void`);
await loadCertificates();
}
async function regenerateToken(row: AdminCertificate) {
await ElMessageBox.confirm(`重新生成 ${row.certificate_no} 的直达链接?旧链接会失效。`, "重置链接", { type: "warning" });
await http.post(`/admin/certificates/${row.id}/token/regenerate`);
ElMessage.success("直达链接已重置");
}
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(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
gap: 12px;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.panel {
margin-bottom: 16px;
border-radius: 8px;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 12px;
}
.toolbar {
justify-content: flex-start;
}
.toolbar .el-input {
max-width: 340px;
}
.toolbar .el-select {
width: 170px;
}
.form-tip {
font-size: 12px;
color: #94a3b8;
line-height: 1.6;
margin-top: 4px;
}
.table-actions {
display: flex;
gap: 8px;
flex-wrap: nowrap;
}
.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>