413 lines
15 KiB
Vue
413 lines
15 KiB
Vue
<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>
|