Files
certificate-system/frontend/src/views/CertificateQuery.vue
2026-06-06 19:32:24 +08:00

451 lines
11 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>