重构学员证书签发与批量导入流程
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user