Initial certificate system

This commit is contained in:
nelso
2026-06-06 19:32:24 +08:00
commit ce4ed6213b
105 changed files with 9133 additions and 0 deletions

View File

@@ -0,0 +1,216 @@
<template>
<section>
<header class="page-head">
<div>
<h2>证书管理</h2>
<p>支持单张新增预览下载作废和链接重置</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" />
</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-button @click="loadCertificates">搜索</el-button>
<el-button @click="exportCertificates">导出</el-button>
</div>
<el-table :data="certificates" border>
<el-table-column prop="certificate_no" label="证书编号" width="220" />
<el-table-column prop="learner_id" label="学员ID" width="90" />
<el-table-column prop="project_code" label="项目" width="90" />
<el-table-column prop="certificate_name" label="证书名称" min-width="180" />
<el-table-column prop="issue_date" label="发证日期" width="120" />
<el-table-column label="状态" width="100">
<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="330">
<template #default="{ row }">
<el-button size="small" @click="previewCertificate(row)">预览</el-button>
<el-button size="small" @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>
</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>
</section>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type AdminCertificate, type ProjectCourse } from "../api";
import { downloadFile } from "../download";
const certificates = ref<AdminCertificate[]>([]);
const projects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const statusValue = ref("");
const previewVisible = ref(false);
const preview = ref<any>(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" ? "有效" : "已作废";
}
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
projects.value = data;
}
async function loadCertificates() {
const { data } = await http.get<AdminCertificate[]>("/admin/certificates", {
params: { keyword: keyword.value || undefined, status: statusValue.value || undefined },
});
certificates.value = data;
}
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) {
await downloadFile(`/admin/certificates/${row.id}/download`, `${row.certificate_no}.pdf`);
}
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");
}
onMounted(async () => {
await Promise.all([loadProjects(), loadCertificates()]);
});
</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;
}
</style>