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,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>