重构学员证书签发与批量导入流程

This commit is contained in:
Certificate System
2026-08-14 11:37:09 +08:00
parent 522f529c6a
commit 5e33657268
16 changed files with 1047 additions and 370 deletions

View File

@@ -57,6 +57,7 @@ export interface Learner {
student_no: string | null;
status: string;
remark: string | null;
certificate_count: number;
created_at: string;
updated_at: string;
}
@@ -65,6 +66,7 @@ export interface AdminCertificate {
id: number;
learner_id: number;
learner_name: string | null;
learner_phone: string | null;
import_batch_id: number | null;
project_code: string;
certificate_no: string;
@@ -101,10 +103,29 @@ export interface CertificateTemplate {
name: string;
description: string;
dynamic_fields: string[];
fields: CertificateTemplateField[];
preview_url: string;
status: string;
}
export interface CertificateTemplateField {
key: string;
label: string;
field_type: "text" | "date";
required: boolean;
example: string;
description: string;
}
export interface LearnerImportResult {
total_rows: number;
created_rows: number;
updated_rows: number;
unchanged_rows: number;
failed_rows: number;
errors: Array<{ row_no: number; message: string }>;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;

View File

@@ -30,38 +30,32 @@
</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 label="学员姓名" required>
<el-input v-model.trim="form.learner_name" placeholder="请输入证书上的姓名" />
</el-form-item>
<el-form-item label="手机号" required>
<el-input v-model.trim="form.learner_phone" maxlength="18" placeholder="用于匹配或自动创建学员" />
</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-form-item v-if="hasTemplateField('course_start_date')" 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-form-item v-if="hasTemplateField('course_end_date')" 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-form-item v-if="hasTemplateField('issue_date')" 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 v-if="hasTemplateField('course_name')" label="课程名称" required>
<el-input v-model.trim="form.course_name" placeholder="留空时使用项目默认课程名称" />
</el-form-item>
<el-form-item v-if="hasTemplateField('stage_name')" label="阶段名称" required>
<el-input v-model.trim="form.stage_name" placeholder="留空时使用项目默认阶段名称" />
</el-form-item>
<el-form-item label="内部备注">
<el-input v-model.trim="form.remark" placeholder="不会显示在证书上" />
</el-form-item>
@@ -72,6 +66,7 @@
:closable="false"
:title="`${selectedTemplate.name}将填写:${selectedTemplate.dynamic_fields.join('、')}`"
/>
<p class="identity-note">手机号已存在时会关联原学员不存在时将按本次姓名和手机号自动创建学员</p>
</el-form>
<template #footer>
<el-button @click="createVisible = false">取消</el-button>
@@ -108,6 +103,7 @@
<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="learner_phone" label="手机号" width="128" />
<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>
@@ -196,7 +192,6 @@ import {
type AdminCertificate,
type CertificateTemplate,
type ImportBatch,
type Learner,
type PdfPregenerationJob,
type ProjectCourse,
} from "../api";
@@ -205,7 +200,6 @@ 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("");
@@ -223,7 +217,8 @@ const creating = ref(false);
const templateManuallySelected = ref(false);
let pregenerationTimer: number | null = null;
const form = reactive({
learner_id: undefined as number | undefined,
learner_name: "",
learner_phone: "",
project_code: "",
course_name: "",
stage_name: "",
@@ -235,7 +230,6 @@ const form = reactive({
});
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) {
@@ -268,11 +262,6 @@ async function loadTemplates() {
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;
@@ -297,7 +286,8 @@ function handleSelectionChange(rows: AdminCertificate[]) {
function resetCreateForm() {
templateManuallySelected.value = false;
Object.assign(form, {
learner_id: undefined,
learner_name: "",
learner_phone: "",
project_code: "",
course_name: "",
stage_name: "",
@@ -312,6 +302,12 @@ function resetCreateForm() {
function selectTemplate(templateCode: string) {
form.template_code = templateCode;
templateManuallySelected.value = true;
if (!hasTemplateField("course_name")) form.course_name = "";
if (!hasTemplateField("stage_name")) form.stage_name = "";
}
function hasTemplateField(key: string) {
return !!selectedTemplate.value?.fields.some((field) => field.key === key);
}
function openCreateDialog() {
@@ -321,32 +317,50 @@ function openCreateDialog() {
function handleProjectChange(projectCode: string) {
const project = projects.value.find((item) => item.code === projectCode);
if (project && !templateManuallySelected.value) form.template_code = project.default_template_code;
if (!project) return;
if (!templateManuallySelected.value) form.template_code = project.default_template_code;
if (!form.course_name) form.course_name = project.default_course_name || "";
if (!form.stage_name) form.stage_name = project.default_stage_name || "";
}
async function createCertificate() {
if (!form.learner_id || !form.project_code || !form.course_start_date || !form.course_end_date || !form.issue_date) {
ElMessage.warning("请填写学员ID、项目、课程起止日期和发证日期");
if (!form.learner_name || !form.learner_phone || !form.project_code) {
ElMessage.warning("请填写学员姓名、手机号和项目");
return;
}
if (form.course_end_date < form.course_start_date) {
const fieldValues: Record<string, string> = {
course_name: form.course_name,
stage_name: form.stage_name,
course_start_date: form.course_start_date,
course_end_date: form.course_end_date,
issue_date: form.issue_date,
};
const missingField = selectedTemplate.value?.fields.find((field) => field.key !== "learner_name" && field.required && !fieldValues[field.key]);
const project = projects.value.find((item) => item.code === form.project_code);
const hasProjectDefault = missingField?.key === "course_name" ? project?.default_course_name : missingField?.key === "stage_name" ? project?.default_stage_name : null;
if (missingField && !hasProjectDefault) {
ElMessage.warning(`请填写${missingField.label}`);
return;
}
if (form.course_start_date && form.course_end_date && form.course_end_date < form.course_start_date) {
ElMessage.warning("课程结束日期不能早于课程开始日期");
return;
}
creating.value = true;
try {
await http.post("/admin/certificates", {
learner_id: form.learner_id,
learner_name: form.learner_name,
learner_phone: form.learner_phone,
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_name: hasTemplateField("course_name") ? form.course_name || null : null,
stage_name: hasTemplateField("stage_name") ? 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("证书已创建");
ElMessage.success("证书已创建,学员信息已自动关联");
createVisible.value = false;
await loadCertificates();
} finally {
@@ -441,7 +455,7 @@ function stopPregenerationPolling() {
}
onMounted(async () => {
await Promise.all([loadProjects(), loadTemplates(), loadLearners(), loadImportBatches(), loadCertificates()]);
await Promise.all([loadProjects(), loadTemplates(), loadImportBatches(), loadCertificates()]);
});
onUnmounted(stopPregenerationPolling);
@@ -518,6 +532,12 @@ onUnmounted(stopPregenerationPolling);
font-size: 15px;
}
.identity-note {
margin: 12px 0 0;
color: var(--app-text-secondary);
font-size: 13px;
}
.template-choice small {
color: var(--app-muted);
line-height: 1.55;

View File

@@ -5,25 +5,26 @@
<h2>Excel 导入</h2>
<p>上传后先校验确认导入后才会正式写入学员和证书数据</p>
</div>
<el-button @click="downloadTemplate">下载模板</el-button>
<el-button :disabled="!selectedTemplate" @click="downloadTemplate">下载所选模板</el-button>
</header>
<el-alert
class="import-tip"
type="info"
:closable="false"
title="课程开始日期、课程结束日期和发证日期均为必填项,请统一使用 YYYY-MM-DD 格式,例如 2026-06-01。"
title="请先选择证书模板,再下载对应 Excel。不同证书模板的列和必填项不同不要混用文件。"
/>
<div class="batch-template">
<div>
<strong>本批次证书模板</strong>
<span>确认导入后整批证书都会固化为所选模板</span>
<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
@@ -129,6 +130,12 @@ 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;
@@ -149,7 +156,11 @@ function pickFile(file: UploadFile) {
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
if (!selectedTemplate.value) return;
downloadFile(
`/admin/import-batches/template?template_code=${encodeURIComponent(templateCode.value)}`,
`certificate-import-${templateCode.value}.xlsx`,
);
}
async function loadBatches() {
@@ -291,6 +302,13 @@ onUnmounted(stopPregenerationPolling);
border: 1px solid #edf2f4;
}
.template-fields {
grid-column: 1 / -1;
margin: 0;
color: var(--app-muted);
font-size: 13px;
}
.import-tip {
margin-bottom: 4px;
}

View File

@@ -5,8 +5,38 @@
<h2>学员管理</h2>
<p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p>
</div>
<el-button type="primary" :icon="Upload" @click="openImportDialog">批量导入学员</el-button>
</header>
<el-dialog v-model="importVisible" title="批量导入学员" width="640px" destroy-on-close>
<div class="import-actions">
<div>
<h3>学员 Excel</h3>
<p>必填姓名和手机号学员编号备注为选填手机号已存在时姓名必须一致</p>
</div>
<el-button :icon="Download" @click="downloadImportTemplate">下载模板</el-button>
</div>
<el-upload drag :auto-upload="false" :limit="1" accept=".xlsx" @change="pickImportFile" @remove="importFile = null">
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div>拖入 Excel 文件或点击选择文件</div>
</el-upload>
<el-alert
v-if="importResult"
class="import-result"
:type="importResult.failed_rows ? 'warning' : 'success'"
:closable="false"
:title="`处理 ${importResult.total_rows} 行:新建 ${importResult.created_rows},更新 ${importResult.updated_rows},未变化 ${importResult.unchanged_rows},失败 ${importResult.failed_rows}`"
/>
<el-table v-if="importResult?.errors.length" :data="importResult.errors" size="small" max-height="220">
<el-table-column prop="row_no" label="行号" width="80" />
<el-table-column prop="message" label="失败原因" />
</el-table>
<template #footer>
<el-button @click="importVisible = false">关闭</el-button>
<el-button type="primary" :disabled="!importFile" :loading="importing" @click="importLearners">开始导入</el-button>
</template>
</el-dialog>
<el-card class="panel" shadow="never">
<div class="form-section-head">
<div>
@@ -53,6 +83,7 @@
<el-table-column prop="current_name" label="姓名" width="140" />
<el-table-column prop="phone" label="手机号" width="160" />
<el-table-column prop="student_no" label="学员编号" width="150" />
<el-table-column prop="certificate_count" label="证书数量" width="100" align="center" />
<el-table-column label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
@@ -71,16 +102,21 @@
</template>
<script setup lang="ts">
import { Check, Plus, RefreshLeft } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { Check, Download, Plus, RefreshLeft, Upload, UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { http, type Learner } from "../api";
import { http, type Learner, type LearnerImportResult } from "../api";
import { apiErrorMessage, downloadFile } from "../download";
const learners = ref<Learner[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
const importVisible = ref(false);
const importFile = ref<File | null>(null);
const importing = ref(false);
const importResult = ref<LearnerImportResult | null>(null);
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
@@ -130,6 +166,38 @@ async function deleteLearner(row: Learner) {
await loadLearners();
}
function openImportDialog() {
importFile.value = null;
importResult.value = null;
importVisible.value = true;
}
function downloadImportTemplate() {
downloadFile("/admin/learners/import-template", "learner-import-template.xlsx");
}
function pickImportFile(file: UploadFile) {
importFile.value = file.raw || null;
importResult.value = null;
}
async function importLearners() {
if (!importFile.value) return;
importing.value = true;
try {
const body = new FormData();
body.append("file", importFile.value);
const { data } = await http.post<LearnerImportResult>("/admin/learners/import", body);
importResult.value = data;
ElMessage.success(data.failed_rows ? "导入完成,请检查失败行" : "学员导入完成");
await loadLearners();
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "学员导入失败"));
} finally {
importing.value = false;
}
}
onMounted(loadLearners);
</script>
@@ -142,6 +210,34 @@ onMounted(loadLearners);
margin-bottom: 16px;
}
.import-actions {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 16px;
}
.import-actions h3,
.import-actions p {
margin: 0;
}
.import-actions p {
margin-top: 6px;
color: var(--app-muted);
font-size: 13px;
}
.upload-icon {
color: var(--app-primary);
font-size: 36px;
}
.import-result {
margin: 16px 0;
}
.panel {
margin-bottom: 16px;
}