新增多证书模板管理流程
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
118
frontend/src/views/AdminTemplates.vue
Normal file
118
frontend/src/views/AdminTemplates.vue
Normal 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>
|
||||
Reference in New Issue
Block a user