451 lines
11 KiB
Vue
451 lines
11 KiB
Vue
<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>
|