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

3
frontend/src/App.vue Normal file
View File

@@ -0,0 +1,3 @@
<template>
<router-view />
</template>

106
frontend/src/api.ts Normal file
View File

@@ -0,0 +1,106 @@
import axios from "axios";
export const http = axios.create({
baseURL: "/api"
});
http.interceptors.request.use((config) => {
const token = localStorage.getItem("admin_token");
if (token) {
config.headers.Authorization = `Bearer ${token}`;
}
return config;
});
export interface PublicCertificate {
certificate_no: string;
learner_name: string;
certificate_name: string;
project_code: string;
course_name: string | null;
stage_name: string | null;
issue_date: string;
issuer_name: string;
status: string;
pdf_status: string;
public_token: string | null;
public_url: string | null;
download_url: string | null;
can_download_pdf: boolean;
disclaimer: string;
}
export interface Captcha {
captcha_id: string;
image: string;
}
export interface ProjectCourse {
id: number;
code: string;
name: string;
default_certificate_name: string;
default_course_name: string | null;
default_stage_name: string | null;
default_issuer_name: string;
status: string;
created_at: string;
updated_at: string;
}
export interface Learner {
id: number;
phone: string;
current_name: string;
student_no: string | null;
status: string;
remark: string | null;
created_at: string;
updated_at: string;
}
export interface AdminCertificate {
id: number;
learner_id: number;
project_code: string;
certificate_no: string;
certificate_name: string;
class_name: string | null;
course_name: string | null;
stage_name: string | null;
issue_date: string;
issuer_name: string;
status: string;
pdf_status: string;
created_at: string;
updated_at: string;
}
export interface ImportBatch {
id: number;
filename: string;
status: string;
total_rows: number;
valid_rows: number;
failed_rows: number;
conflict_rows: number;
error_report_path: string | null;
created_at: string;
updated_at: string;
}
export interface OperationLog {
id: number;
admin_user_id: number | null;
action: string;
action_label: string;
object_type: string;
object_label: string;
object_id: string | null;
ip_address: string | null;
detail_json: string | null;
risk: string;
risk_label: string;
summary: string;
created_at: string;
}

217
frontend/src/design.css Normal file
View File

@@ -0,0 +1,217 @@
:root {
--app-bg: #f5f8fa;
--app-surface: #ffffff;
--app-surface-soft: #f9fbfc;
--app-border: #dbe6ea;
--app-border-strong: #c8d7dd;
--app-text: #172033;
--app-muted: #64748b;
--app-primary: #16817e;
--app-primary-strong: #0f6f6b;
--app-primary-soft: #e6f5f3;
--app-blue-soft: #eef5fb;
--app-gold: #b88a2d;
--app-danger: #c9413a;
--app-warning: #b7791f;
--app-success: #16817e;
--app-shadow: 0 14px 36px rgba(42, 64, 78, 0.08);
--app-shadow-soft: 0 8px 22px rgba(42, 64, 78, 0.055);
}
* {
box-sizing: border-box;
}
body {
margin: 0;
color: var(--app-text);
background:
linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
font-family:
Inter, "Microsoft YaHei", "PingFang SC", "Helvetica Neue", Arial, sans-serif;
}
button,
input,
textarea,
select {
font: inherit;
}
.page-head {
display: flex;
justify-content: space-between;
align-items: flex-end;
gap: 18px;
margin-bottom: 18px;
}
.page-head h2 {
margin: 0;
color: var(--app-text);
font-size: 24px;
font-weight: 750;
letter-spacing: 0;
}
.page-head p {
margin: 7px 0 0;
color: var(--app-muted);
line-height: 1.6;
}
.panel,
.el-card.panel,
.el-card.stat {
border: 1px solid var(--app-border);
border-radius: 10px;
background: var(--app-surface);
box-shadow: var(--app-shadow-soft);
}
.el-card {
--el-card-border-color: var(--app-border);
--el-card-border-radius: 10px;
}
.el-button {
--el-button-border-radius: 7px;
font-weight: 600;
}
.el-button--primary {
--el-button-bg-color: var(--app-primary);
--el-button-border-color: var(--app-primary);
--el-button-hover-bg-color: var(--app-primary-strong);
--el-button-hover-border-color: var(--app-primary-strong);
--el-button-active-bg-color: var(--app-primary-strong);
--el-button-active-border-color: var(--app-primary-strong);
}
.el-input__wrapper,
.el-select__wrapper,
.el-textarea__inner {
border-radius: 8px;
box-shadow: 0 0 0 1px var(--app-border) inset;
}
.el-input__wrapper:hover,
.el-select__wrapper:hover,
.el-textarea__inner:hover {
box-shadow: 0 0 0 1px var(--app-border-strong) inset;
}
.el-input__wrapper.is-focus,
.el-select__wrapper.is-focused,
.el-textarea__inner:focus {
box-shadow: 0 0 0 1px var(--app-primary) inset, 0 0 0 3px rgba(22, 129, 126, 0.12);
}
.el-form-item__label {
color: #415466;
font-weight: 650;
}
.el-table {
--el-table-border-color: #e5edf0;
--el-table-header-bg-color: #f6fafb;
--el-table-header-text-color: #52677a;
--el-table-row-hover-bg-color: #f8fcfc;
border-radius: 8px;
overflow: hidden;
}
.el-table th.el-table__cell {
font-weight: 700;
}
.el-tag {
border-radius: 999px;
font-weight: 650;
}
.el-dialog {
border-radius: 12px;
}
.el-dialog__header {
padding: 22px 24px 12px;
margin: 0;
}
.el-dialog__body {
padding: 14px 24px 22px;
}
.el-dialog__title {
color: var(--app-text);
font-size: 20px;
font-weight: 750;
}
.el-segmented {
--el-segmented-item-selected-bg-color: var(--app-primary);
--el-segmented-item-selected-color: #ffffff;
border: 1px solid var(--app-border);
background: #f4f8fa;
}
.form-grid {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 14px;
}
.form-grid .el-form-item {
margin-bottom: 0;
}
.toolbar,
.filters {
align-items: center;
}
.pager {
border-top: 1px solid #edf2f4;
padding-top: 12px;
}
.stats {
align-items: stretch;
}
.stat {
transition: border-color 0.15s ease, box-shadow 0.15s ease, transform 0.15s ease;
}
.stat:hover {
border-color: var(--app-border-strong);
box-shadow: var(--app-shadow);
transform: translateY(-1px);
}
a {
color: var(--app-primary-strong);
text-decoration: none;
}
a:hover {
text-decoration: underline;
}
@media (max-width: 1100px) {
.form-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 720px) {
.page-head {
display: grid;
align-items: start;
}
.form-grid {
grid-template-columns: 1fr;
}
}

10
frontend/src/download.ts Normal file
View File

@@ -0,0 +1,10 @@
import { http } from "./api";
export async function downloadFile(url: string, filename: string) {
const { data } = await http.get(url, { responseType: "blob" });
const link = document.createElement("a");
link.href = URL.createObjectURL(data);
link.download = filename;
link.click();
URL.revokeObjectURL(link.href);
}

11
frontend/src/main.ts Normal file
View File

@@ -0,0 +1,11 @@
import "element-plus/dist/index.css";
import "./design.css";
import ElementPlus from "element-plus";
import { createPinia } from "pinia";
import { createApp } from "vue";
import App from "./App.vue";
import { router } from "./router";
createApp(App).use(createPinia()).use(router).use(ElementPlus).mount("#app");

View File

@@ -0,0 +1,42 @@
import { createRouter, createWebHistory } from "vue-router";
import AdminCertificates from "../views/AdminCertificates.vue";
import AdminHome from "../views/AdminHome.vue";
import AdminImports from "../views/AdminImports.vue";
import AdminLogin from "../views/AdminLogin.vue";
import AdminLogs from "../views/AdminLogs.vue";
import AdminLearners from "../views/AdminLearners.vue";
import AdminProjects from "../views/AdminProjects.vue";
import AdminShell from "../views/AdminShell.vue";
import CertificateQuery from "../views/CertificateQuery.vue";
import CertificateView from "../views/CertificateView.vue";
import VerifyView from "../views/VerifyView.vue";
export const router = createRouter({
history: createWebHistory(),
routes: [
{ path: "/", redirect: "/query" },
{ path: "/admin/login", component: AdminLogin },
{
path: "/admin",
component: AdminShell,
children: [
{ path: "", component: AdminHome },
{ path: "projects", component: AdminProjects },
{ path: "learners", component: AdminLearners },
{ path: "certificates", component: AdminCertificates },
{ path: "imports", component: AdminImports },
{ path: "logs", component: AdminLogs }
]
},
{ path: "/query", component: CertificateQuery },
{ path: "/cert/:token", component: CertificateView },
{ path: "/verify/:token", component: VerifyView }
]
});
router.beforeEach((to) => {
if (to.path.startsWith("/admin") && to.path !== "/admin/login" && !localStorage.getItem("admin_token")) {
return "/admin/login";
}
});

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>

View File

@@ -0,0 +1,106 @@
<template>
<section>
<header class="page-head">
<div>
<h2>一期工作台</h2>
<p>快速查看证书导入和状态数据</p>
</div>
<el-button type="primary" @click="downloadTemplate">下载导入模板</el-button>
</header>
<div class="stats">
<el-card v-for="item in cards" :key="item.label" class="stat" shadow="never">
<span>{{ item.label }}</span>
<strong>{{ item.value }}</strong>
</el-card>
</div>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive } from "vue";
import { useRouter } from "vue-router";
import { http } from "../api";
import { downloadFile } from "../download";
const router = useRouter();
const summary = reactive({
learner_count: 0,
certificate_count: 0,
valid_certificate_count: 0,
voided_certificate_count: 0,
recent_import_count: 0,
});
const cards = computed(() => [
{ label: "学员总数", value: summary.learner_count },
{ label: "证书总数", value: summary.certificate_count },
{ label: "有效证书", value: summary.valid_certificate_count },
{ label: "作废证书", value: summary.voided_certificate_count },
{ label: "近期导入", value: summary.recent_import_count },
]);
async function loadSummary() {
try {
const { data } = await http.get("/admin/dashboard/summary");
Object.assign(summary, data);
} catch {
localStorage.removeItem("admin_token");
await router.push("/admin/login");
}
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
}
onMounted(loadSummary);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 18px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.stats {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 14px;
}
.stat {
border: 1px solid #dbe6ea;
border-radius: 10px;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.055);
}
.stat span {
color: #64748b;
font-size: 13px;
font-weight: 650;
}
.stat strong {
display: block;
margin-top: 8px;
font-size: 30px;
font-weight: 800;
color: #172033;
}
@media (max-width: 1100px) {
.stats {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
</style>

View File

@@ -0,0 +1,166 @@
<template>
<section>
<header class="page-head">
<div>
<h2>Excel 导入</h2>
<p>上传后先校验确认导入后才会正式写入学员和证书数据</p>
</div>
<el-button @click="downloadTemplate">下载模板</el-button>
</header>
<el-upload
class="upload"
drag
:auto-upload="false"
:show-file-list="true"
:limit="1"
accept=".xlsx"
@change="pickFile"
@remove="selectedFile = null"
>
<el-icon class="upload-icon"><UploadFilled /></el-icon>
<div> Excel 文件拖到这里或点击选择文件</div>
</el-upload>
<el-button type="primary" :disabled="!selectedFile" :loading="uploading" @click="uploadFile">上传并校验</el-button>
<el-card class="panel" shadow="never">
<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="120">
<template #default="{ row }">{{ statusText(row.status) }}</template>
</el-table-column>
<el-table-column prop="total_rows" label="总行数" width="100" />
<el-table-column prop="valid_rows" label="可导入" width="100" />
<el-table-column prop="failed_rows" label="失败" width="100" />
<el-table-column prop="created_at" label="上传时间" width="190" />
<el-table-column label="操作" width="380">
<template #default="{ row }">
<el-button size="small" @click="downloadSource(row)">下载原文件</el-button>
<el-button
v-if="row.status === 'validated'"
size="small"
type="primary"
:loading="confirmingId === row.id"
@click="confirmBatch(row)"
>
确认导入
</el-button>
<el-button v-if="row.error_report_path" size="small" @click="downloadErrorReport(row)">错误报告</el-button>
<el-button size="small" type="danger" @click="deleteBatch(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { UploadFilled } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox, type UploadFile } from "element-plus";
import { onMounted, ref } from "vue";
import { http, type ImportBatch } from "../api";
import { downloadFile } from "../download";
const batches = ref<ImportBatch[]>([]);
const selectedFile = ref<File | null>(null);
const uploading = ref(false);
const confirmingId = ref<number | null>(null);
function statusText(status: string) {
const labels: Record<string, string> = {
uploaded: "已上传",
validated: "已校验",
imported: "已导入",
failed: "校验失败",
};
return labels[status] || status;
}
function pickFile(file: UploadFile) {
selectedFile.value = file.raw || null;
}
function downloadTemplate() {
downloadFile("/admin/import-batches/template", "certificate-import-template.xlsx");
}
async function loadBatches() {
const { data } = await http.get<ImportBatch[]>("/admin/import-batches");
batches.value = data;
}
async function uploadFile() {
if (!selectedFile.value) return;
uploading.value = true;
try {
const body = new FormData();
body.append("file", selectedFile.value);
await http.post("/admin/import-batches", body);
ElMessage.success("文件已上传并完成校验,请检查结果后点击确认导入");
selectedFile.value = null;
await loadBatches();
} finally {
uploading.value = false;
}
}
async function confirmBatch(row: ImportBatch) {
await ElMessageBox.confirm(`确认把批次 ${row.id}${row.valid_rows} 行校验通过的数据写入系统?`, "确认导入", { type: "warning" });
confirmingId.value = row.id;
try {
const { data } = await http.post<ImportBatch>(`/admin/import-batches/${row.id}/confirm`);
row.status = data.status;
ElMessage.success(`导入完成,当前状态:${statusText(data.status)}`);
await loadBatches();
} finally {
confirmingId.value = null;
}
}
function downloadSource(row: ImportBatch) {
downloadFile(`/admin/import-batches/${row.id}/file`, row.filename);
}
function downloadErrorReport(row: ImportBatch) {
downloadFile(`/admin/import-batches/${row.id}/error-report`, `import-errors-${row.id}.xlsx`);
}
async function deleteBatch(row: ImportBatch) {
await ElMessageBox.confirm(`确认删除导入批次 ${row.id}?这只删除上传文件和导入记录,不会删除已经生成的学员和证书。`, "删除导入记录", { type: "warning" });
await http.delete(`/admin/import-batches/${row.id}`);
ElMessage.success("导入记录已删除");
await loadBatches();
}
onMounted(loadBatches);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.upload {
margin-bottom: 12px;
}
.upload-icon {
font-size: 36px;
color: #208a87;
}
.panel {
margin-top: 20px;
border-radius: 8px;
}
</style>

View File

@@ -0,0 +1,163 @@
<template>
<section>
<header class="page-head">
<div>
<h2>学员管理</h2>
<p>支持手动新增修改删除和查询学员也可以继续通过 Excel 批量导入</p>
</div>
<el-button type="primary" @click="saveLearner">{{ editingId ? "保存修改" : "新增学员" }}</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="姓名">
<el-input v-model.trim="form.current_name" />
</el-form-item>
<el-form-item label="手机号">
<el-input v-model.trim="form.phone" />
</el-form-item>
<el-form-item label="学员编号">
<el-input v-model.trim="form.student_no" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label="备注" class="wide">
<el-input v-model.trim="form.remark" />
</el-form-item>
<el-form-item label=" ">
<el-button @click="resetForm">清空</el-button>
</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="loadLearners" />
<el-button @click="loadLearners">搜索</el-button>
</div>
<el-table :data="learners" border>
<el-table-column prop="id" label="ID" width="80" />
<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 label="状态" width="110">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">{{ statusText(row.status) }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="remark" label="备注" />
<el-table-column label="操作" width="160">
<template #default="{ row }">
<el-button size="small" @click="editLearner(row)">修改</el-button>
<el-button size="small" type="danger" @click="deleteLearner(row)">删除</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { ElMessage, ElMessageBox } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import { http, type Learner } from "../api";
const learners = ref<Learner[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({ current_name: "", phone: "", student_no: "", status: "active", remark: "" });
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
}
async function loadLearners() {
const { data } = await http.get<Learner[]>("/admin/learners", { params: { keyword: keyword.value || undefined } });
learners.value = data;
}
function editLearner(row: Learner) {
editingId.value = row.id;
Object.assign(form, {
current_name: row.current_name,
phone: row.phone,
student_no: row.student_no || "",
status: row.status,
remark: row.remark || "",
});
}
function resetForm() {
editingId.value = null;
Object.assign(form, { current_name: "", phone: "", student_no: "", status: "active", remark: "" });
}
async function saveLearner() {
if (!form.current_name || !form.phone) {
ElMessage.warning("姓名和手机号必填");
return;
}
const payload = { ...form, student_no: form.student_no || null, remark: form.remark || null };
if (editingId.value) {
await http.put(`/admin/learners/${editingId.value}`, payload);
} else {
await http.post("/admin/learners", payload);
}
ElMessage.success("学员已保存");
resetForm();
await loadLearners();
}
async function deleteLearner(row: Learner) {
await ElMessageBox.confirm(`确认删除学员 ${row.current_name}`, "删除学员", { type: "warning" });
await http.delete(`/admin/learners/${row.id}`);
ElMessage.success("学员已删除");
await loadLearners();
}
onMounted(loadLearners);
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
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, 1fr);
gap: 12px;
}
.wide {
grid-column: span 3;
}
.toolbar {
justify-content: flex-start;
}
.toolbar .el-input {
max-width: 380px;
}
</style>

View File

@@ -0,0 +1,82 @@
<template>
<main class="login-page">
<section class="login-box">
<h1>后台登录</h1>
<el-form label-position="top">
<el-form-item label="账号">
<el-input v-model.trim="form.username" />
</el-form-item>
<el-form-item label="密码">
<el-input v-model="form.password" type="password" show-password @keyup.enter="login" />
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="login">登录</el-button>
</el-form>
<el-alert v-if="message" class="message" :title="message" type="error" :closable="false" />
</section>
</main>
</template>
<script setup lang="ts">
import { reactive, ref } from "vue";
import { useRouter } from "vue-router";
import { http } from "../api";
const router = useRouter();
const loading = ref(false);
const message = ref("");
const form = reactive({
username: "",
password: "",
});
async function login() {
loading.value = true;
message.value = "";
try {
const { data } = await http.post("/admin/auth/login", form);
localStorage.setItem("admin_token", data.access_token);
await router.push("/admin");
} catch (error: any) {
message.value = error?.response?.data?.detail || "登录失败,请重试";
} finally {
loading.value = false;
}
}
</script>
<style scoped>
.login-page {
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
padding: 24px;
}
.login-box {
width: min(440px, 100%);
padding: 32px;
border: 1px solid #dbe6ea;
border-radius: 14px;
background: #ffffff;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
}
h1 {
margin: 0 0 22px;
color: #172033;
font-size: 26px;
font-weight: 800;
}
.submit {
width: 100%;
height: 38px;
font-weight: 700;
}
.message {
margin-top: 16px;
}
</style>

View File

@@ -0,0 +1,290 @@
<template>
<section>
<header class="page-head">
<div>
<h2>操作日志</h2>
<p>记录后台关键动作支持筛选导出和查看完整详情</p>
</div>
<div class="head-actions">
<el-button @click="loadLogs">刷新</el-button>
<el-button @click="cleanupLogs">清理过期日志</el-button>
<el-button type="primary" @click="exportLogs">导出日志</el-button>
</div>
</header>
<el-card class="panel" shadow="never">
<p class="retention-note">日志保留策略普通后台日志公开查询和公开下载日志保留 180 高风险后台操作保留 365 </p>
<div class="filters">
<el-input v-model.trim="filters.keyword" clearable placeholder="搜索摘要、对象ID、详情" @keyup.enter="loadLogs" />
<el-select v-model="filters.action" clearable placeholder="操作类型">
<el-option v-for="item in actionOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select v-model="filters.object_type" clearable placeholder="对象类型">
<el-option label="项目" value="project_course" />
<el-option label="学员" value="learner" />
<el-option label="证书" value="certificate" />
<el-option label="导入批次" value="import_batch" />
<el-option label="公开访问" value="public_access" />
</el-select>
<el-select v-model="filters.risk" clearable placeholder="风险等级">
<el-option label="高风险" value="high" />
<el-option label="中风险" value="medium" />
<el-option label="低风险" value="low" />
</el-select>
<el-button type="primary" @click="loadLogs">搜索</el-button>
<el-button @click="resetFilters">重置</el-button>
</div>
<div v-if="totalPages > 1" class="pager">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="item">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
<el-table :data="logs" border>
<el-table-column prop="created_at" label="时间" width="190" />
<el-table-column prop="admin_user_id" label="操作人ID" width="100" />
<el-table-column prop="action_label" label="操作" width="150" />
<el-table-column prop="object_label" label="对象" width="100" />
<el-table-column prop="object_id" label="对象ID" width="110" />
<el-table-column label="风险" width="100">
<template #default="{ row }">
<el-tag :type="riskTag(row.risk)">{{ row.risk_label }}</el-tag>
</template>
</el-table-column>
<el-table-column prop="summary" label="摘要" min-width="260" show-overflow-tooltip />
<el-table-column label="操作" width="90">
<template #default="{ row }">
<el-button size="small" @click="openDetail(row)">详情</el-button>
</template>
</el-table-column>
</el-table>
<div v-if="totalPages > 1" class="pager bottom">
<el-button :disabled="page <= 1" @click="loadLogs(page - 1)">上一页</el-button>
<template v-for="item in pageItems" :key="`bottom-${item}`">
<span v-if="item === '...'" class="ellipsis">...</span>
<el-button v-else :type="item === page ? 'primary' : 'default'" @click="loadLogs(Number(item))">{{ item }}</el-button>
</template>
<el-button :disabled="page >= totalPages" @click="loadLogs(page + 1)">下一页</el-button>
<span class="total"> {{ total }} </span>
</div>
</el-card>
<el-dialog v-model="detailVisible" title="操作日志详情" width="720px">
<el-descriptions v-if="current" :column="1" border>
<el-descriptions-item label="时间">{{ current.created_at }}</el-descriptions-item>
<el-descriptions-item label="操作人ID">{{ current.admin_user_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="操作">{{ current.action_label }}</el-descriptions-item>
<el-descriptions-item label="对象">{{ current.object_label }} #{{ current.object_id || "-" }}</el-descriptions-item>
<el-descriptions-item label="风险等级">
<el-tag :type="riskTag(current.risk)">{{ current.risk_label }}</el-tag>
</el-descriptions-item>
<el-descriptions-item label="摘要">{{ current.summary }}</el-descriptions-item>
</el-descriptions>
<pre class="detail-json">{{ prettyDetail }}</pre>
</el-dialog>
</section>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from "vue";
import { http, type OperationLog } from "../api";
import { downloadFile } from "../download";
const logs = ref<OperationLog[]>([]);
const current = ref<OperationLog | null>(null);
const detailVisible = ref(false);
const filters = reactive({ keyword: "", action: "", object_type: "", risk: "" });
const page = ref(1);
const pageSize = 20;
const total = ref(0);
const totalPages = ref(1);
const actionOptions = [
{ value: "create_project", label: "新增项目" },
{ value: "update_project", label: "修改项目" },
{ value: "create_learner", label: "新增学员" },
{ value: "update_learner", label: "修改学员" },
{ value: "delete_learner", label: "删除学员" },
{ value: "create_certificate", label: "新增证书" },
{ value: "void_certificate", label: "作废证书" },
{ value: "regenerate_public_token", label: "重置证书链接" },
{ value: "upload_import_file", label: "上传导入文件" },
{ value: "confirm_import_batch", label: "确认导入" },
{ value: "delete_import_batch", label: "删除导入批次" },
{ value: "export_certificates", label: "导出证书" },
{ value: "public_search_certificate", label: "公开查询证书" },
{ value: "public_download_certificate", label: "公开下载证书" },
];
const prettyDetail = computed(() => {
if (!current.value?.detail_json) return "{}";
try {
return JSON.stringify(JSON.parse(current.value.detail_json), null, 2);
} catch {
return current.value.detail_json;
}
});
const pageItems = computed<Array<number | string>>(() => compactPages(page.value, totalPages.value));
function queryParams() {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
params.set("page", String(page.value));
params.set("page_size", String(pageSize));
return params.toString();
}
function compactPages(current: number, totalPageCount: number) {
const pages = new Set<number>();
pages.add(1);
for (let item = current - 2; item <= current + 2; item += 1) {
if (item > 1 && item < totalPageCount) pages.add(item);
}
if (totalPageCount > 1) pages.add(totalPageCount);
const sorted = Array.from(pages).sort((a, b) => a - b);
const result: Array<number | string> = [];
sorted.forEach((item, index) => {
if (index > 0 && item - sorted[index - 1] > 1) result.push("...");
result.push(item);
});
return result;
}
function riskTag(risk?: string | null) {
if (risk === "high") return "danger";
if (risk === "medium") return "warning";
return "success";
}
function openDetail(row: OperationLog) {
current.value = row;
detailVisible.value = true;
}
function resetFilters() {
filters.keyword = "";
filters.action = "";
filters.object_type = "";
filters.risk = "";
loadLogs(1);
}
async function loadLogs(nextPage = 1) {
page.value = nextPage;
const query = queryParams();
const { data } = await http.get<{ items: OperationLog[]; total: number; page: number; page_size: number; total_pages: number }>(`/admin/logs${query ? `?${query}` : ""}`);
logs.value = data.items;
total.value = data.total;
page.value = data.page;
totalPages.value = data.total_pages;
}
async function exportLogs() {
const query = queryParams();
await downloadFile(`/admin/logs/export${query ? `?${query}` : ""}`, "operation-logs.xlsx");
}
async function cleanupLogs() {
await http.post("/admin/logs/cleanup");
await loadLogs();
}
onMounted(loadLogs);
</script>
<style scoped>
.page-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
margin-bottom: 16px;
}
.page-head p {
margin: 6px 0 0;
color: #64748b;
}
.head-actions {
display: flex;
gap: 8px;
}
.panel {
border-radius: 8px;
}
.retention-note {
margin: 0 0 12px;
color: #64748b;
font-size: 13px;
}
.filters {
display: grid;
grid-template-columns: minmax(220px, 1fr) repeat(3, 160px) auto auto;
gap: 10px;
margin-bottom: 14px;
}
.filters :deep(.el-select__wrapper) {
padding-left: 14px;
padding-right: 14px;
}
.filters :deep(.el-select__suffix) {
margin-left: 14px;
}
.pager {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 6px;
flex-wrap: wrap;
margin: 12px 0;
}
.pager.bottom {
margin-top: 14px;
margin-bottom: 0;
}
.ellipsis,
.total {
color: #64748b;
font-size: 13px;
}
.ellipsis {
padding: 0 4px;
}
.detail-json {
margin: 14px 0 0;
max-height: 360px;
overflow: auto;
white-space: pre-wrap;
border: 1px solid #dfe9ec;
border-radius: 8px;
background: #f8fafc;
padding: 14px;
}
@media (max-width: 1100px) {
.filters {
grid-template-columns: 1fr 1fr;
}
}
</style>

View File

@@ -0,0 +1,199 @@
<template>
<section>
<header class="page-head">
<div>
<h2>项目管理</h2>
<p>维护项目代码和导入时使用的证书默认信息</p>
</div>
<el-button type="primary" @click="saveProject">{{ editingId ? "保存修改" : "新增项目" }}</el-button>
</header>
<el-card class="panel" shadow="never">
<el-form class="form-grid" label-position="top">
<el-form-item label="项目代码">
<el-input v-model.trim="form.code" :disabled="!!editingId" maxlength="16" />
</el-form-item>
<el-form-item label="项目名称">
<el-input v-model.trim="form.name" maxlength="128" />
</el-form-item>
<el-form-item label="默认发证单位">
<el-input v-model.trim="form.default_issuer_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认课程名称">
<el-input v-model.trim="form.default_course_name" maxlength="128" />
</el-form-item>
<el-form-item label="默认阶段名称">
<el-input v-model.trim="form.default_stage_name" maxlength="128" />
</el-form-item>
<el-form-item label="状态">
<el-select v-model="form.status">
<el-option label="启用" value="active" />
<el-option label="停用" value="disabled" />
</el-select>
</el-form-item>
<el-form-item label=" ">
<el-button @click="resetForm">清空</el-button>
</el-form-item>
</el-form>
</el-card>
<el-card class="panel" shadow="never">
<div class="toolbar">
<el-input v-model.trim="keyword" placeholder="搜索项目代码、名称、课程、阶段或状态" clearable />
</div>
<el-table :data="projects" border>
<el-table-column prop="code" label="项目代码" width="120" />
<el-table-column prop="name" label="项目名称" width="150" />
<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="状态" width="100">
<template #default="{ row }">
<el-tag :type="row.status === 'active' ? 'success' : 'info'">
{{ statusText(row.status) }}
</el-tag>
</template>
</el-table-column>
<el-table-column label="操作" width="180" fixed="right">
<template #default="{ row }">
<el-button size="small" @click="editProject(row)">修改</el-button>
<el-button size="small" @click="toggleStatus(row)">
{{ row.status === "active" ? "停用" : "启用" }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
</section>
</template>
<script setup lang="ts">
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import { http, type ProjectCourse } from "../api";
const allProjects = ref<ProjectCourse[]>([]);
const keyword = ref("");
const editingId = ref<number | null>(null);
const form = reactive({
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
status: "active",
});
const projects = computed(() => {
const value = keyword.value.toLowerCase();
if (!value) return allProjects.value;
return allProjects.value.filter((item) => {
const text = [
item.code,
item.name,
item.default_course_name || "",
item.default_stage_name || "",
item.default_issuer_name,
statusText(item.status),
].join(" ").toLowerCase();
return text.includes(value);
});
});
function statusText(status: string) {
return status === "active" ? "启用" : "停用";
}
async function loadProjects() {
const { data } = await http.get<ProjectCourse[]>("/admin/projects");
allProjects.value = data;
}
function editProject(row: ProjectCourse) {
editingId.value = row.id;
Object.assign(form, {
code: row.code,
name: row.name,
default_course_name: row.default_course_name || "",
default_stage_name: row.default_stage_name || "",
default_issuer_name: row.default_issuer_name,
status: row.status,
});
}
function resetForm() {
editingId.value = null;
Object.assign(form, {
code: "",
name: "",
default_course_name: "",
default_stage_name: "",
default_issuer_name: "本公司",
status: "active",
});
}
function projectPayload() {
return {
code: form.code,
name: form.name,
default_course_name: form.default_course_name || null,
default_stage_name: form.default_stage_name || null,
default_issuer_name: form.default_issuer_name,
status: form.status,
};
}
async function saveProject() {
if (!form.code || !form.name || !form.default_issuer_name) {
ElMessage.warning("项目代码、项目名称和发证单位必填");
return;
}
if (editingId.value) {
await http.put(`/admin/projects/${editingId.value}`, projectPayload());
} else {
await http.post("/admin/projects", projectPayload());
}
ElMessage.success("项目已保存");
resetForm();
await loadProjects();
}
async function toggleStatus(row: ProjectCourse) {
await http.put(`/admin/projects/${row.id}`, { status: row.status === "active" ? "disabled" : "active" });
await loadProjects();
}
onMounted(loadProjects);
</script>
<style scoped>
.page-head,
.toolbar {
display: flex;
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, 1fr);
gap: 12px;
}
.toolbar .el-input {
max-width: 420px;
}
</style>

View File

@@ -0,0 +1,113 @@
<template>
<main class="admin-shell">
<aside class="sidebar">
<div class="brand">
<h1>证书管理</h1>
<p>培训结业证书后台</p>
</div>
<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/learners">学员管理</el-menu-item>
<el-menu-item index="/admin/certificates">证书管理</el-menu-item>
<el-menu-item index="/admin/imports">Excel 导入</el-menu-item>
<el-menu-item index="/admin/logs">操作日志</el-menu-item>
</el-menu>
<el-button class="logout" @click="logout">退出登录</el-button>
</aside>
<section class="workspace">
<router-view />
</section>
</main>
</template>
<script setup lang="ts">
import { useRoute, useRouter } from "vue-router";
const route = useRoute();
const router = useRouter();
function logout() {
localStorage.removeItem("admin_token");
router.push("/admin/login");
}
</script>
<style scoped>
.admin-shell {
min-height: 100vh;
display: grid;
grid-template-columns: 248px 1fr;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
color: #172033;
}
.sidebar {
position: sticky;
top: 0;
height: 100vh;
background: rgba(255, 255, 255, 0.94);
border-right: 1px solid #e1ebef;
padding: 24px 14px;
box-shadow: 8px 0 26px rgba(42, 64, 78, 0.045);
}
.brand {
padding: 0 12px 20px;
border-bottom: 1px solid #e1ebef;
margin-bottom: 18px;
}
.brand h1 {
font-size: 21px;
font-weight: 800;
margin: 0;
letter-spacing: 0;
}
.brand p {
margin: 7px 0 0;
color: #6b8795;
font-size: 13px;
}
:deep(.el-menu) {
border-right: 0;
}
:deep(.el-menu-item) {
height: 42px;
margin: 4px 0;
border-radius: 8px;
color: #42576a;
font-weight: 650;
}
:deep(.el-menu-item.is-active),
:deep(.el-menu-item:hover) {
background: #e6f5f3;
color: #116d6b;
}
.logout {
width: calc(100% - 16px);
margin: 20px 8px 0;
}
.workspace {
min-width: 0;
padding: 28px;
overflow: auto;
}
@media (max-width: 900px) {
.admin-shell {
grid-template-columns: 1fr;
}
.sidebar {
position: static;
height: auto;
}
}
</style>

View File

@@ -0,0 +1,450 @@
<template>
<main class="public-page">
<section class="query-panel">
<div class="heading">
<p>电子证书查询</p>
<h1>培训结业证书查询</h1>
</div>
<el-form label-position="top">
<el-form-item label="查询方式">
<el-segmented v-model="queryMode" :options="queryOptions" />
</el-form-item>
<el-form-item v-if="queryMode === 'certificate'" label="证书编号">
<el-input v-model.trim="form.certificateNo" placeholder="例如 PX2026-DBY-K7M9Q2R-83" />
</el-form-item>
<el-form-item v-else label="手机号">
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
</el-form-item>
<el-form-item label="姓名">
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" />
</el-form-item>
<el-form-item label="图形验证码">
<div class="captcha-row">
<el-input v-model.trim="form.captcha" placeholder="请输入验证码" />
<button class="captcha-button" type="button" @click="loadCaptcha">
<img v-if="captchaImage" :src="captchaImage" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</el-form-item>
<el-button type="primary" class="submit" :loading="loading" @click="submitQuery">查询</el-button>
</el-form>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
<section v-if="results.length" class="results">
<div class="results-head">
<div>
<h2>查询结果</h2>
<p>共找到 {{ results.length }} 张证书</p>
</div>
</div>
<article v-for="item in results" :key="item.certificate_no" class="certificate-card">
<div class="card-top">
<div>
<span :class="['status', item.status === 'valid' ? 'ok' : 'bad']">{{ statusText(item.status) }}</span>
<h3>{{ item.certificate_name }}</h3>
</div>
<p class="number">{{ item.certificate_no }}</p>
</div>
<dl class="meta">
<div>
<dt>学员姓名</dt>
<dd>{{ item.learner_name }}</dd>
</div>
<div>
<dt>项目代码</dt>
<dd>{{ item.project_code }}</dd>
</div>
<div>
<dt>课程/阶段</dt>
<dd>{{ courseText(item) }}</dd>
</div>
<div>
<dt>发证日期</dt>
<dd>{{ item.issue_date }}</dd>
</div>
<div>
<dt>发证单位</dt>
<dd>{{ item.issuer_name }}</dd>
</div>
</dl>
<div class="card-actions">
<el-button @click="preview(item)">预览</el-button>
<el-button type="primary" :disabled="!item.can_download_pdf" @click="downloadPdf(item)">下载 PDF</el-button>
</div>
</article>
</section>
<p class="disclaimer">
本证书仅用于证明学员完成相关培训课程/阶段学习不代表国家学历学位职业资格或职业技能等级认证
</p>
</section>
<el-dialog v-model="previewVisible" title="证书预览" width="720px" @closed="clearPreviewUrl">
<div v-if="previewPdfUrl" class="pdf-frame">
<embed :src="previewPdfUrl" type="application/pdf" />
</div>
<div v-else-if="current" class="preview-card">
<p class="preview-kicker">Training Completion Certificate</p>
<h2>{{ current.certificate_name }}</h2>
<p class="preview-name">{{ current.learner_name }}</p>
<p class="preview-copy">
已完成 {{ current.course_name || current.project_code }} {{ current.stage_name || "" }} 相关培训学习
</p>
<div class="preview-meta">
<span>证书编号{{ current.certificate_no }}</span>
<span>发证日期{{ current.issue_date }}</span>
<span>发证单位{{ current.issuer_name }}</span>
</div>
</div>
<template #footer>
<el-button @click="closePreview">关闭</el-button>
<el-button v-if="current?.can_download_pdf" type="primary" @click="downloadPdf(current)">下载 PDF</el-button>
</template>
</el-dialog>
</main>
</template>
<script setup lang="ts">
import { onMounted, reactive, ref, watch } from "vue";
import { http, type Captcha, type PublicCertificate } from "../api";
interface SearchResponse {
items: PublicCertificate[];
}
const queryOptions = [
{ label: "证书编号查询", value: "certificate" },
{ label: "手机号查询", value: "phone" },
];
const queryMode = ref<"certificate" | "phone">("certificate");
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "" });
const captchaId = ref("");
const captchaImage = ref("");
const loading = ref(false);
const message = ref("");
const messageType = ref<"success" | "warning" | "error">("warning");
const results = ref<PublicCertificate[]>([]);
const previewVisible = ref(false);
const current = ref<PublicCertificate | null>(null);
const previewPdfUrl = ref("");
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
function courseText(item: PublicCertificate) {
return [item.course_name, item.stage_name].filter(Boolean).join(" / ") || "-";
}
watch(queryMode, () => {
message.value = "";
results.value = [];
});
async function loadCaptcha() {
const { data } = await http.get<Captcha>("/public/captcha");
captchaId.value = data.captcha_id;
captchaImage.value = data.image;
form.captcha = "";
}
async function submitQuery() {
loading.value = true;
message.value = "";
results.value = [];
try {
const { data } = await http.post<SearchResponse>("/public/certificates/search", {
certificate_no: queryMode.value === "certificate" ? form.certificateNo : null,
phone: queryMode.value === "phone" ? form.phone : null,
name: form.name,
captcha_id: captchaId.value,
captcha_code: form.captcha,
});
results.value = data.items;
const hasVoided = data.items.some((item) => item.status !== "valid");
message.value = hasVoided ? "已查询到证书,其中包含作废证书,请核对状态。" : "已查询到匹配证书。";
messageType.value = hasVoided ? "warning" : "success";
} catch (error: any) {
message.value = error?.response?.data?.detail || "查询失败,请稍后再试";
messageType.value = "error";
await loadCaptcha();
} finally {
loading.value = false;
}
}
async function preview(item: PublicCertificate) {
current.value = item;
clearPreviewUrl();
if (item.can_download_pdf && item.download_url) {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
previewPdfUrl.value = URL.createObjectURL(data);
}
previewVisible.value = true;
}
function clearPreviewUrl() {
if (previewPdfUrl.value) {
URL.revokeObjectURL(previewPdfUrl.value);
previewPdfUrl.value = "";
}
}
function closePreview() {
previewVisible.value = false;
}
function downloadPdf(item: PublicCertificate) {
if (item.download_url) window.location.href = item.download_url;
}
onMounted(loadCaptcha);
</script>
<style scoped>
.public-page {
min-height: 100vh;
display: grid;
place-items: start center;
background: linear-gradient(180deg, #f7fafb 0%, #eef4f6 100%);
padding: 42px 20px;
}
.query-panel {
width: min(820px, 100%);
background: #ffffff;
border: 1px solid #dbe6ea;
border-radius: 14px;
padding: 32px;
box-shadow: 0 18px 48px rgba(42, 64, 78, 0.09);
}
.heading {
margin-bottom: 22px;
}
.heading p {
margin: 0 0 6px;
color: #16817e;
font-size: 13px;
font-weight: 700;
}
h1,
h2,
h3 {
margin: 0;
}
h1 {
font-size: 30px;
color: #172033;
letter-spacing: 0;
}
.captcha-row {
display: grid;
grid-template-columns: 1fr 132px;
gap: 10px;
width: 100%;
}
.captcha-button {
height: 34px;
padding: 0;
border: 1px solid #dbe6ea;
border-radius: 8px;
background: #ffffff;
cursor: pointer;
overflow: hidden;
}
.captcha-button img {
width: 100%;
height: 100%;
display: block;
}
.submit {
width: 100%;
height: 38px;
font-weight: 700;
}
.notice,
.results {
margin-top: 20px;
}
.results-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
}
.results-head p {
margin: 4px 0 0;
color: #64748b;
font-size: 13px;
}
.certificate-card {
border: 1px solid #dbe6ea;
border-radius: 10px;
padding: 20px;
margin-bottom: 14px;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
box-shadow: 0 8px 22px rgba(42, 64, 78, 0.05);
}
.card-top {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: flex-start;
padding-bottom: 14px;
border-bottom: 1px solid #e8eef1;
}
.status {
display: inline-flex;
height: 24px;
align-items: center;
border-radius: 999px;
padding: 0 9px;
margin-bottom: 10px;
font-size: 12px;
}
.status.ok {
color: #0f6f6b;
background: #e6f5f3;
}
.status.bad {
color: #991b1b;
background: #fee2e2;
}
.number {
margin: 0;
color: #475569;
font-family: ui-monospace, SFMono-Regular, Consolas, monospace;
font-size: 13px;
padding-top: 4px;
}
.meta {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 14px 22px;
margin: 16px 0;
}
.meta div {
min-width: 0;
}
.meta dt {
color: #64748b;
font-size: 12px;
font-weight: 650;
margin-bottom: 4px;
}
.meta dd {
margin: 0;
color: #172033;
font-size: 14px;
}
.card-actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
.disclaimer {
margin: 20px 0 0;
color: #6b7280;
font-size: 13px;
line-height: 1.6;
padding-top: 16px;
border-top: 1px solid #e8eef1;
}
.preview-card {
border: 1px solid #dfe9ec;
border-radius: 10px;
padding: 32px;
text-align: center;
background: linear-gradient(180deg, #ffffff, #f8fbfc);
}
.pdf-frame {
height: 70vh;
border: 1px solid #dfe9ec;
border-radius: 10px;
overflow: hidden;
background: #f8fafc;
}
.pdf-frame embed {
width: 100%;
height: 100%;
}
.preview-kicker {
margin: 0 0 10px;
color: #16817e;
font-size: 12px;
letter-spacing: 0;
text-transform: uppercase;
}
.preview-name {
margin: 22px 0 10px;
font-size: 28px;
font-weight: 700;
}
.preview-copy {
margin: 0 auto 24px;
max-width: 520px;
color: #475569;
line-height: 1.8;
}
.preview-meta {
display: grid;
gap: 8px;
color: #64748b;
font-size: 13px;
}
@media (max-width: 640px) {
.query-panel {
padding: 22px;
}
.card-top,
.card-actions {
display: grid;
justify-content: stretch;
}
.meta {
grid-template-columns: 1fr;
}
}
</style>

View File

@@ -0,0 +1,168 @@
<template>
<main class="public-page">
<section class="certificate-page">
<p class="kicker">电子证书</p>
<h1>证书公开展示</h1>
<el-alert v-if="message" class="notice" :title="message" :type="messageType" :closable="false" />
<article v-if="certificate" class="certificate-card">
<span :class="['status', certificate.status === 'valid' ? 'ok' : 'bad']">{{ statusText(certificate.status) }}</span>
<h2>{{ certificate.certificate_name }}</h2>
<p class="name">{{ certificate.learner_name }}</p>
<p class="copy">
已完成 {{ certificate.course_name || certificate.project_code }} {{ certificate.stage_name || "" }} 相关培训学习
</p>
<dl class="meta">
<div>
<dt>证书编号</dt>
<dd>{{ certificate.certificate_no }}</dd>
</div>
<div>
<dt>发证日期</dt>
<dd>{{ certificate.issue_date }}</dd>
</div>
<div>
<dt>发证单位</dt>
<dd>{{ certificate.issuer_name }}</dd>
</div>
</dl>
<el-button v-if="certificate.can_download_pdf" type="primary" class="download" @click="downloadPdf">下载 PDF 证书</el-button>
</article>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
const route = useRoute();
const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null);
const message = ref("");
const messageType = ref<"success" | "warning" | "error">("success");
function statusText(status: string) {
return status === "valid" ? "有效" : "已作废";
}
async function loadCertificate() {
try {
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
certificate.value = data;
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
messageType.value = data.status === "valid" ? "success" : "warning";
} catch (error: any) {
message.value = error?.response?.data?.detail || "链接无效或已失效";
messageType.value = "error";
}
}
function downloadPdf() {
window.location.href = `/api/public/certificates/token/${token}/download`;
}
onMounted(loadCertificate);
</script>
<style scoped>
.public-page {
min-height: 100vh;
background: #f7fafc;
padding: 36px 20px;
}
.certificate-page {
max-width: 760px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e1ebef;
border-radius: 12px;
padding: 30px;
box-shadow: 0 16px 46px rgba(76, 102, 118, 0.08);
}
.kicker {
margin: 0 0 6px;
color: #208a87;
font-size: 13px;
font-weight: 700;
}
h1,
h2 {
margin: 0;
}
h1 {
font-size: 28px;
}
.notice {
margin-top: 20px;
}
.certificate-card {
margin-top: 20px;
border: 1px solid #dfe9ec;
border-radius: 10px;
padding: 34px;
text-align: center;
background: #fbfefe;
}
.status {
display: inline-flex;
height: 24px;
align-items: center;
border-radius: 999px;
padding: 0 9px;
margin-bottom: 16px;
font-size: 12px;
}
.status.ok {
color: #116d6b;
background: #dff5f3;
}
.status.bad {
color: #991b1b;
background: #fee2e2;
}
.name {
margin: 24px 0 10px;
font-size: 30px;
font-weight: 700;
}
.copy {
max-width: 560px;
margin: 0 auto 24px;
color: #475569;
line-height: 1.8;
}
.meta {
display: grid;
gap: 10px;
margin: 0;
color: #64748b;
}
.meta dt {
font-size: 12px;
}
.meta dd {
margin: 3px 0 0;
color: #172033;
}
.download {
margin-top: 24px;
}
</style>

View File

@@ -0,0 +1,69 @@
<template>
<main class="public-page">
<section class="verify-page">
<h1>证书核验</h1>
<el-alert :title="message" :type="messageType" :closable="false" />
<el-descriptions v-if="certificate" :column="1" border class="details">
<el-descriptions-item label="核验结果">{{ certificate.status === "valid" ? "该证书真实有效" : "该证书已作废" }}</el-descriptions-item>
<el-descriptions-item label="证书状态">{{ certificate.status }}</el-descriptions-item>
<el-descriptions-item label="证书编号">{{ certificate.certificate_no }}</el-descriptions-item>
<el-descriptions-item label="学员姓名">{{ certificate.learner_name }}</el-descriptions-item>
<el-descriptions-item label="证书名称">{{ certificate.certificate_name }}</el-descriptions-item>
<el-descriptions-item label="发证单位">{{ certificate.issuer_name }}</el-descriptions-item>
</el-descriptions>
</section>
</main>
</template>
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute } from "vue-router";
import { http, type PublicCertificate } from "../api";
const route = useRoute();
const token = String(route.params.token);
const certificate = ref<PublicCertificate | null>(null);
const message = ref("正在核验证书信息");
const messageType = ref<"success" | "warning" | "error" | "info">("info");
async function loadCertificate() {
try {
const { data } = await http.get<PublicCertificate>(`/public/certificates/token/${token}`);
certificate.value = data;
message.value = data.status === "valid" ? "该证书真实有效。" : "该证书已作废,请联系发证单位核实。";
messageType.value = data.status === "valid" ? "success" : "warning";
} catch (error: any) {
message.value = error?.response?.data?.detail || "链接无效或已失效";
messageType.value = "error";
}
}
onMounted(loadCertificate);
</script>
<style scoped>
.public-page {
min-height: 100vh;
background: #f7f8fb;
padding: 32px;
}
.verify-page {
max-width: 680px;
margin: 0 auto;
background: #ffffff;
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 28px;
}
h1 {
margin: 0 0 20px;
font-size: 24px;
}
.details {
margin-top: 20px;
}
</style>