新增多证书模板管理流程

This commit is contained in:
Certificate System
2026-08-13 18:06:05 +08:00
parent eac19ea593
commit abbefc4b48
27 changed files with 774 additions and 61 deletions

View File

@@ -23,6 +23,7 @@ export interface PublicCertificate {
course_end_date: string | null;
issue_date: string;
issuer_name: string;
template_code: string;
status: string;
pdf_status: string;
public_token: string | null;
@@ -45,6 +46,7 @@ export interface ProjectCourse {
default_course_name: string | null;
default_stage_name: string | null;
default_issuer_name: string;
default_template_code: string;
status: string;
created_at: string;
updated_at: string;
@@ -76,6 +78,7 @@ export interface AdminCertificate {
course_end_date: string | null;
issue_date: string;
issuer_name: string;
template_code: string;
status: string;
pdf_status: string;
created_at: string;
@@ -85,6 +88,7 @@ export interface AdminCertificate {
export interface ImportBatch {
id: number;
filename: string;
template_code: string;
status: string;
total_rows: number;
valid_rows: number;
@@ -95,6 +99,15 @@ export interface ImportBatch {
updated_at: string;
}
export interface CertificateTemplate {
code: string;
name: string;
description: string;
dynamic_fields: string[];
preview_url: string;
status: string;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;

View File

@@ -9,6 +9,7 @@ import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue";
import AdminSettings from "../views/AdminSettings.vue";
import AdminTemplates from "../views/AdminTemplates.vue";
import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue";
@@ -25,6 +26,7 @@ export const router = createRouter({
children: [
{ path: "", component: AdminHome },
{ path: "projects", component: AdminProjects },
{ path: "templates", component: AdminTemplates },
{ path: "learners", component: AdminLearners },
{ path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports },

View File

@@ -5,41 +5,79 @@
<h2>证书管理</h2>
<p>支持单张新增预览下载作废链接重置和批量预生成PDF</p>
</div>
<el-button type="primary" @click="createCertificate">新增证书</el-button>
<el-button type="primary" @click="openCreateDialog">创建证书</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-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期">
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
<div class="form-tip">证书正文将显示这段课程起止时间结束日期不能早于开始日期</div>
</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.remark" />
<el-dialog v-model="createVisible" title="创建证书" width="920px" destroy-on-close>
<el-form label-position="top">
<el-form-item label="证书模板" required>
<div class="template-choices">
<button
v-for="item in templates"
:key="item.code"
type="button"
class="template-choice"
:class="{ selected: form.template_code === item.code }"
@click="selectTemplate(item.code)"
>
<img :src="item.preview_url" :alt="item.name" />
<span>
<strong>{{ item.name }}</strong>
<small>{{ item.dynamic_fields.join("、") }}</small>
</span>
</button>
</div>
</el-form-item>
<div class="create-grid">
<el-form-item label="学员" required>
<el-select v-model="form.learner_id" filterable placeholder="按姓名或手机号选择">
<el-option
v-for="item in activeLearners"
:key="item.id"
:label="`${item.current_name} ${item.phone}`"
:value="item.id"
/>
</el-select>
</el-form-item>
<el-form-item label="项目" required>
<el-select v-model="form.project_code" filterable placeholder="选择项目" @change="handleProjectChange">
<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="课程开始日期" required>
<el-date-picker v-model="form.course_start_date" type="date" value-format="YYYY-MM-DD" placeholder="选择开始日期" />
</el-form-item>
<el-form-item label="课程结束日期" required>
<el-date-picker v-model="form.course_end_date" type="date" value-format="YYYY-MM-DD" placeholder="选择结束日期" />
</el-form-item>
<el-form-item label="发证日期" required>
<el-date-picker v-model="form.issue_date" type="date" value-format="YYYY-MM-DD" placeholder="选择发证日期" />
</el-form-item>
<template v-if="form.template_code === 'classic'">
<el-form-item label="课程名称">
<el-input v-model.trim="form.course_name" placeholder="留空使用项目默认值" />
</el-form-item>
<el-form-item label="阶段名称">
<el-input v-model.trim="form.stage_name" placeholder="留空使用项目默认值" />
</el-form-item>
</template>
<el-form-item label="内部备注">
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
</el-form-item>
</div>
<el-alert
v-if="selectedTemplate"
type="info"
:closable="false"
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
/>
</el-form>
</el-card>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
<el-button type="primary" :loading="creating" @click="createCertificate">确认创建</el-button>
</template>
</el-dialog>
<el-card class="panel" shadow="never">
<div class="toolbar">
@@ -67,6 +105,9 @@
<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 label="模板" width="150" show-overflow-tooltip>
<template #default="{ row }">{{ templateName(row.template_code) }}</template>
</el-table-column>
<el-table-column prop="certificate_name" label="证书名称" min-width="140" show-overflow-tooltip />
<el-table-column label="课程时间" width="190">
<template #default="{ row }">{{ coursePeriodText(row) }}</template>
@@ -103,6 +144,7 @@
<el-descriptions-item label="项目/课程/阶段">
{{ preview.project_code }} {{ preview.course_name || "" }} {{ preview.stage_name || "" }}
</el-descriptions-item>
<el-descriptions-item label="证书模板">{{ templateName(preview.template_code) }}</el-descriptions-item>
<el-descriptions-item label="课程时间">{{ coursePeriodText(preview) }}</el-descriptions-item>
<el-descriptions-item label="发证日期">{{ preview.issue_date }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ preview.issuer_name }}</el-descriptions-item>
@@ -145,11 +187,21 @@
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 {
http,
type AdminCertificate,
type CertificateTemplate,
type ImportBatch,
type Learner,
type PdfPregenerationJob,
type ProjectCourse,
} from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const templates = ref<CertificateTemplate[]>([]);
const learners = ref<Learner[]>([]);
const importBatches = ref<ImportBatch[]>([]);
const keyword = ref("");
const statusValue = ref("");
@@ -162,6 +214,9 @@ const selectedCertificates = ref<AdminCertificate[]>([]);
const pregenerationStarting = ref(false);
const pregenerationVisible = ref(false);
const pregenerationJob = ref<PdfPregenerationJob | null>(null);
const createVisible = ref(false);
const creating = ref(false);
const templateManuallySelected = ref(false);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
@@ -171,10 +226,13 @@ const form = reactive({
course_start_date: "",
course_end_date: "",
issue_date: "",
template_code: "classic",
remark: "",
});
const activeProjects = computed(() => projects.value.filter((item) => item.status === "active"));
const activeLearners = computed(() => learners.value.filter((item) => item.status === "active"));
const selectedTemplate = computed(() => templates.value.find((item) => item.code === form.template_code));
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
@@ -190,6 +248,10 @@ function coursePeriodText(certificate: Pick<AdminCertificate, "course_start_date
return `${start}${end}`;
}
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
const pregenerationDone = computed(() => ["completed", "completed_with_errors"].includes(pregenerationJob.value?.status || ""));
async function loadProjects() {
@@ -197,6 +259,16 @@ async function loadProjects() {
projects.value = data;
}
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
async function loadLearners() {
const { data } = await http.get<Learner[]>("/admin/learners");
learners.value = data;
}
async function loadImportBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
importBatches.value = data;
@@ -218,6 +290,36 @@ function handleSelectionChange(rows: AdminCertificate[]) {
selectedCertificates.value = rows;
}
function resetCreateForm() {
templateManuallySelected.value = false;
Object.assign(form, {
learner_id: undefined,
project_code: "",
course_name: "",
stage_name: "",
course_start_date: "",
course_end_date: "",
issue_date: "",
template_code: "classic",
remark: "",
});
}
function selectTemplate(templateCode: string) {
form.template_code = templateCode;
templateManuallySelected.value = true;
}
function openCreateDialog() {
resetCreateForm();
createVisible.value = true;
}
function handleProjectChange(projectCode: string) {
const project = projects.value.find((item) => item.code === projectCode);
if (project && !templateManuallySelected.value) form.template_code = project.default_template_code;
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
@@ -227,18 +329,25 @@ async function createCertificate() {
ElMessage.warning("课程结束日期不能早于课程开始日期");
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,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
await loadCertificates();
creating.value = true;
try {
await http.post("/admin/certificates", {
learner_id: form.learner_id,
project_code: form.project_code,
course_name: form.template_code === "classic" ? form.course_name || null : null,
stage_name: form.template_code === "classic" ? form.stage_name || null : null,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
template_code: form.template_code,
remark: form.remark || null,
});
ElMessage.success("证书已创建");
createVisible.value = false;
await loadCertificates();
} finally {
creating.value = false;
}
}
async function previewCertificate(row: AdminCertificate) {
@@ -328,7 +437,7 @@ function stopPregenerationPolling() {
}
onMounted(async () => {
await Promise.all([loadProjects(), loadImportBatches(), loadCertificates()]);
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
@@ -360,6 +469,67 @@ onUnmounted(stopPregenerationPolling);
gap: 12px;
}
.template-choices {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px;
width: 100%;
}
.template-choice {
display: grid;
grid-template-columns: 148px minmax(0, 1fr);
align-items: center;
gap: 14px;
min-height: 112px;
padding: 10px;
border: 1px solid var(--app-border);
border-radius: 8px;
background: #fff;
color: var(--app-text);
text-align: left;
cursor: pointer;
}
.template-choice:hover,
.template-choice.selected {
border-color: var(--app-primary);
box-shadow: 0 0 0 3px rgba(22, 129, 126, 0.1);
}
.template-choice img {
width: 148px;
aspect-ratio: 1.36;
object-fit: contain;
border: 1px solid #edf2f4;
background: #f8fafb;
}
.template-choice span {
display: grid;
gap: 8px;
}
.template-choice strong {
font-size: 15px;
}
.template-choice small {
color: var(--app-muted);
line-height: 1.55;
}
.create-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0 14px;
}
.create-grid :deep(.el-select),
.create-grid :deep(.el-date-editor) {
width: 100%;
}
.toolbar {
justify-content: flex-start;
}
@@ -429,4 +599,11 @@ onUnmounted(stopPregenerationPolling);
padding-left: 18px;
color: #991b1b;
}
@media (max-width: 760px) {
.template-choices,
.create-grid {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -15,6 +15,17 @@
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
/>
<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" />
</div>
<el-upload
class="upload"
drag
@@ -34,6 +45,9 @@
<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>
@@ -99,10 +113,12 @@ 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 ImportBatch, type PdfPregenerationJob } from "../api";
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);
@@ -112,6 +128,11 @@ 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));
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
function statusText(status: string) {
const labels: Record<string, string> = {
@@ -136,12 +157,18 @@ async function loadBatches() {
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;
@@ -213,7 +240,7 @@ function stopPregenerationPolling() {
}
}
onMounted(loadBatches);
onMounted(() => Promise.all([loadBatches(), loadTemplates()]));
onUnmounted(stopPregenerationPolling);
</script>
@@ -235,6 +262,35 @@ onUnmounted(stopPregenerationPolling);
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;
}
.import-tip {
margin-bottom: 4px;
}
@@ -289,4 +345,14 @@ onUnmounted(stopPregenerationPolling);
color: #b91c1c;
line-height: 1.8;
}
@media (max-width: 900px) {
.batch-template {
grid-template-columns: 1fr;
}
.batch-template img {
width: min(100%, 260px);
}
}
</style>

View File

@@ -19,6 +19,11 @@
<el-form-item label="默认发证单位">
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认证书模板">
<el-select v-model="form.default_template_code">
<el-option v-for="item in templates" :key="item.code" :label="item.name" :value="item.code" />
</el-select>
</el-form-item>
<el-form-item label="默认课程名称">
<el-input v-model.trim="form.default_course_name" maxlength="128" />
</el-form-item>
@@ -47,6 +52,9 @@
<el-table-column prop="default_course_name" label="课程名称" min-width="140" />
<el-table-column prop="default_stage_name" label="阶段名称" min-width="120" />
<el-table-column prop="default_issuer_name" label="发证单位" min-width="140" />
<el-table-column label="默认模板" min-width="150">
<template #default="{ row }">{{ templateName(row.default_template_code) }}</template>
</el-table-column>
<el-table-column label="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
@@ -71,17 +79,19 @@
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type ProjectCourse } from "../api";
import { http, type CertificateTemplate, type ProjectCourse } from "../api";
const allProjects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const templates = ref<CertificateTemplate[]>([]);
const form = reactive({
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
default_template_code: "classic",
status: "active",
});
@@ -110,6 +120,15 @@ async function loadProjects() {
allProjects.value = data;
}
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
function templateName(code: string) {
return templates.value.find((item) => item.code === code)?.name || code;
}
function editProject(row: ProjectCourse) {
editingId.value = row.id;
Object.assign(form, {
@@ -118,6 +137,7 @@ function editProject(row: ProjectCourse) {
default_course_name: row.default_course_name || "",
default_stage_name: row.default_stage_name || "",
default_issuer_name: row.default_issuer_name,
default_template_code: row.default_template_code,
status: row.status,
});
}
@@ -130,6 +150,7 @@ function resetForm() {
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
default_template_code: "classic",
status: "active",
});
}
@@ -141,6 +162,7 @@ function projectPayload() {
default_course_name: form.default_course_name || null,
default_stage_name: form.default_stage_name || null,
default_issuer_name: form.default_issuer_name,
default_template_code: form.default_template_code,
status: form.status,
};
}
@@ -165,7 +187,7 @@ async function toggleStatus(row: ProjectCourse) {
await loadProjects();
}
onMounted(loadProjects);
onMounted(() => Promise.all([loadProjects(), loadTemplates()]));
</script>
<style scoped>

View File

@@ -8,6 +8,7 @@
<el-menu :default-active="route.path" router>
<el-menu-item index="/admin">数据概览</el-menu-item>
<el-menu-item index="/admin/projects">项目管理</el-menu-item>
<el-menu-item index="/admin/templates">证书模板</el-menu-item>
<el-menu-item index="/admin/learners">学员管理</el-menu-item>
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>

View File

@@ -0,0 +1,118 @@
<template>
<section>
<header class="page-head">
<div>
<h2>证书模板</h2>
<p>查看系统可用模板及其动态填写内容项目默认模板可在项目管理中设置</p>
</div>
</header>
<div class="template-list">
<article v-for="item in templates" :key="item.code" class="template-item">
<img :src="item.preview_url" :alt="item.name" />
<div class="template-content">
<div class="template-title">
<h3>{{ item.name }}</h3>
<el-tag type="success">启用</el-tag>
</div>
<p>{{ item.description }}</p>
<dl>
<dt>模板代码</dt>
<dd>{{ item.code }}</dd>
<dt>动态内容</dt>
<dd>
<el-tag v-for="field in item.dynamic_fields" :key="field" type="info">{{ field }}</el-tag>
</dd>
</dl>
</div>
</article>
</div>
</section>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { http, type CertificateTemplate } from "../api";
const templates = ref<CertificateTemplate[]>([]);
async function loadTemplates() {
const { data } = await http.get<CertificateTemplate[]>("/admin/certificate-templates");
templates.value = data;
}
onMounted(loadTemplates);
</script>
<style scoped>
.template-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 18px;
}
.template-item {
overflow: hidden;
border: 1px solid var(--app-border);
border-radius: 8px;
background: #fff;
}
.template-item img {
display: block;
width: 100%;
aspect-ratio: 1.36;
object-fit: contain;
background: #f6f8f9;
border-bottom: 1px solid var(--app-border);
}
.template-content {
padding: 18px;
}
.template-title {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
}
.template-title h3,
.template-content p,
.template-content dl {
margin: 0;
}
.template-content p {
min-height: 48px;
margin-top: 8px;
color: var(--app-muted);
line-height: 1.6;
}
.template-content dl {
display: grid;
grid-template-columns: 72px 1fr;
gap: 12px;
margin-top: 16px;
}
.template-content dt {
color: var(--app-muted);
}
.template-content dd {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin: 0;
}
@media (max-width: 1000px) {
.template-list {
grid-template-columns: 1fr;
}
}
</style>