Files
certificate-system/frontend/src/views/CertificateQuery.vue
2026-06-23 13:10:26 +08:00

628 lines
16 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-if="queryMode === 'certificate'" label="姓名">
<el-input v-model.trim="form.name" placeholder="请输入证书对应姓名" />
</el-form-item>
<el-form-item v-if="queryMode === 'certificate'" 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-form-item v-if="queryMode === 'sms'" label="手机号">
<el-input v-model.trim="form.phone" placeholder="请输入证书对应手机号" />
</el-form-item>
<el-form-item v-if="queryMode === 'sms'" label="图形验证码">
<div class="captcha-row">
<el-input v-model.trim="form.smsCaptcha" placeholder="请输入验证码" />
<button class="captcha-button" type="button" @click="loadSmsCaptcha">
<img v-if="smsCaptchaImage" :src="smsCaptchaImage" alt="验证码" />
<span v-else>刷新</span>
</button>
</div>
</el-form-item>
<el-form-item v-if="queryMode === 'sms'" label="短信验证码">
<div class="sms-row">
<el-input v-model.trim="form.smsCode" placeholder="请输入短信验证码" />
<el-button
type="primary"
:disabled="smsCooldown > 0 || !form.phone"
@click="sendSmsCode"
:loading="sendingSms"
>
{{ smsCooldown > 0 ? `${smsCooldown}秒后重试` : '获取验证码' }}
</el-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"
:loading="downloadingCertificateNo === item.certificate_no"
@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="previewLoading" class="pdf-loading">
<el-icon class="is-loading"><Loading /></el-icon>
<p>当前查询人数较多请稍后</p>
</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"
:loading="downloadingCertificateNo === current.certificate_no"
@click="downloadPdf(current)"
>
下载 PDF
</el-button>
</template>
</el-dialog>
</main>
</template>
<script setup lang="ts">
import { Loading } from "@element-plus/icons-vue";
import { ElLoading, ElMessage } from "element-plus";
import { onMounted, onUnmounted, reactive, ref, watch } from "vue";
import { http, type Captcha, type PublicCertificate } from "../api";
import { apiErrorMessage, saveBlob } from "../download";
interface SearchResponse {
items: PublicCertificate[];
}
const queryOptions = [
{ label: "证书编号查询", value: "certificate" },
{ label: "短信验证码查询", value: "sms" },
];
const queryMode = ref<"certificate" | "sms">("certificate");
const form = reactive({ certificateNo: "", phone: "", name: "", captcha: "", smsCaptcha: "", smsCode: "" });
const captchaId = ref("");
const captchaImage = ref("");
const smsCaptchaId = ref("");
const smsCaptchaImage = 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("");
const previewLoading = ref(false);
const downloadingCertificateNo = ref("");
const smsId = ref("");
const sendingSms = ref(false);
const smsCooldown = ref(0);
let smsTimer: number | null = null;
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 = [];
if (queryMode.value === "sms") {
loadSmsCaptcha();
}
});
async function loadCaptcha() {
const { data } = await http.get<Captcha>("/public/captcha");
captchaId.value = data.captcha_id;
captchaImage.value = data.image;
form.captcha = "";
}
async function loadSmsCaptcha() {
const { data } = await http.get<Captcha>("/public/captcha");
smsCaptchaId.value = data.captcha_id;
smsCaptchaImage.value = data.image;
form.smsCaptcha = "";
}
async function sendSmsCode() {
if (!form.phone) {
message.value = "请先输入手机号";
messageType.value = "warning";
return;
}
sendingSms.value = true;
message.value = "";
try {
const { data } = await http.post<{ sms_id: string; message: string }>("/public/sms/send", {
phone: form.phone,
captcha_id: smsCaptchaId.value,
captcha_code: form.smsCaptcha,
});
smsId.value = data.sms_id;
message.value = data.message;
messageType.value = "success";
// 开始倒计时
smsCooldown.value = 60;
if (smsTimer) clearInterval(smsTimer);
smsTimer = window.setInterval(() => {
smsCooldown.value--;
if (smsCooldown.value <= 0) {
if (smsTimer) clearInterval(smsTimer);
smsTimer = null;
}
}, 1000);
} catch (error: any) {
message.value = error?.response?.data?.detail || "发送失败,请稍后再试";
messageType.value = "error";
await loadSmsCaptcha();
} finally {
sendingSms.value = false;
}
}
async function submitQuery() {
loading.value = true;
message.value = "";
results.value = [];
try {
let data: SearchResponse;
if (queryMode.value === "certificate") {
// 证书编号查询
const response = await http.post<SearchResponse>("/public/certificates/search", {
certificate_no: form.certificateNo,
phone: null,
name: form.name,
captcha_id: captchaId.value,
captcha_code: form.captcha,
});
data = response.data;
} else {
// 短信验证码查询
if (!smsId.value) {
message.value = "请先获取短信验证码";
messageType.value = "warning";
return;
}
const response = await http.post<SearchResponse>("/public/certificates/search-by-sms", {
phone: form.phone,
sms_id: smsId.value,
sms_code: form.smsCode,
});
data = response.data;
}
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";
if (queryMode.value === "certificate") {
await loadCaptcha();
}
} finally {
loading.value = false;
}
}
async function preview(item: PublicCertificate) {
current.value = item;
clearPreviewUrl();
previewVisible.value = true;
if (item.can_download_pdf && item.download_url) {
previewLoading.value = true;
try {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
previewPdfUrl.value = URL.createObjectURL(data);
} catch (error: any) {
message.value = await apiErrorMessage(error, "PDF生成失败请稍后再试");
messageType.value = "error";
} finally {
previewLoading.value = false;
}
}
}
function clearPreviewUrl() {
if (previewPdfUrl.value) {
URL.revokeObjectURL(previewPdfUrl.value);
previewPdfUrl.value = "";
}
}
function closePreview() {
previewVisible.value = false;
}
async function downloadPdf(item: PublicCertificate) {
if (!item.download_url) return;
downloadingCertificateNo.value = item.certificate_no;
const loading = ElLoading.service({
lock: true,
text: "当前查询人数较多,请稍后。。。",
background: "rgba(255, 255, 255, 0.72)",
});
try {
const url = item.download_url.replace(/^\/api/, "");
const { data } = await http.get(url, { responseType: "blob" });
saveBlob(data, `${item.certificate_no}.pdf`);
} catch (error: any) {
ElMessage.error(await apiErrorMessage(error, "PDF生成失败请稍后再试"));
} finally {
loading.close();
downloadingCertificateNo.value = "";
}
}
onMounted(loadCaptcha);
// 清理定时器
onUnmounted(() => {
if (smsTimer) {
clearInterval(smsTimer);
smsTimer = null;
}
});
</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%;
}
.sms-row {
display: grid;
grid-template-columns: 1fr 120px;
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%;
}
.pdf-loading {
min-height: 240px;
display: grid;
place-items: center;
align-content: center;
gap: 12px;
color: #64748b;
}
.pdf-loading .el-icon {
color: #16817e;
font-size: 28px;
}
.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>