Files
Academic-Affairs-System/web/src/views/OfficialDocumentVerifyView.vue
T
biss ecc6546f3a 学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。
服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。
成绩单仅包含正式发布成绩;无已发布成绩时明确提示。
同类型、同用途 5 分钟内重复申请复用已有有效凭证。
申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。
管理员的下载记录、失效、重签能力保持不变。
2026-07-26 18:24:53 +08:00

150 lines
6.7 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.
<script setup lang="ts">
import { ref, watch } from 'vue'
import { CircleCheckFilled, CircleCloseFilled, Search } from '@element-plus/icons-vue'
import { useRoute, useRouter } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
const route = useRoute()
const router = useRouter()
const code = ref('')
const loading = ref(false)
const checked = ref(false)
const error = ref('')
const result = ref<any>()
const typeLabels: Record<string, string> = {
Transcript: '官方电子成绩单',
StudentStatusCertificate: '学籍状态证明',
}
const statusLabels: Record<string, string> = {
Valid: '有效',
Invalidated: '已失效',
Superseded: '已被重签凭证替代',
}
async function verify(value = code.value) {
const normalized = value.trim()
if (!normalized) return
code.value = normalized
loading.value = true
checked.value = false
error.value = ''
try {
const { data } = await http.get(`/official-documents/verify/${encodeURIComponent(normalized)}`)
result.value = data
checked.value = true
if (route.params.verificationCode !== normalized) {
await router.replace({ name: 'official-document-verification', params: { verificationCode: normalized } })
}
} catch (reason) {
error.value = apiErrorMessage(reason)
} finally {
loading.value = false
}
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
}
watch(
() => route.params.verificationCode,
(value) => {
if (typeof value === 'string' && value) {
code.value = value
verify(value)
}
},
{ immediate: true },
)
</script>
<template>
<main class="verify-page">
<section class="verify-shell">
<header>
<span>明序大学 · 教务处</span>
<h1>官方电子凭证验真</h1>
<p>扫描凭证二维码会自动验真也可以输入 PDF 上印制的唯一凭证编号</p>
</header>
<div class="verify-search">
<el-input v-model="code" size="large" placeholder="请输入凭证编号或验真码" @keyup.enter="verify()" />
<el-button type="primary" size="large" :icon="Search" :loading="loading" @click="verify()">立即验真</el-button>
</div>
<el-alert v-if="error" :title="error" type="error" :closable="false" show-icon />
<section v-if="checked" class="verify-result" :class="result?.valid ? 'valid' : 'invalid'">
<div class="result-state">
<el-icon><CircleCheckFilled v-if="result?.valid" /><CircleCloseFilled v-else /></el-icon>
<div v-if="!result?.found">
<h2>未查到该凭证</h2>
<p>验真码不存在或输入不完整请核对原始二维码</p>
</div>
<div v-else-if="result.valid">
<h2>验真通过 · 凭证有效</h2>
<p>该编号由本系统签发当前未失效未被替代</p>
</div>
<div v-else>
<h2>{{ statusLabels[result.status] || '凭证无效' }}</h2>
<p>请勿继续将此文件作为有效官方凭证使用</p>
</div>
</div>
<dl v-if="result?.found">
<div><dt>凭证编号</dt><dd>{{ result.documentNumber }}</dd></div>
<div><dt>凭证类型</dt><dd>{{ typeLabels[result.type] }}</dd></div>
<div><dt>签发单位</dt><dd>{{ result.institutionName }}</dd></div>
<div><dt>学生</dt><dd>{{ result.studentName }} · {{ result.studentNumber }}</dd></div>
<div><dt>学院专业</dt><dd>{{ result.collegeName }} · {{ result.majorName }}</dd></div>
<div><dt>签发时间</dt><dd>{{ formatTime(result.issuedAt) }}</dd></div>
<div v-if="result.invalidatedAt"><dt>失效时间</dt><dd>{{ formatTime(result.invalidatedAt) }}</dd></div>
<div v-if="result.replacementDocumentNumber"><dt>替代凭证</dt><dd>{{ result.replacementDocumentNumber }}</dd></div>
<div class="hash"><dt>PDF SHA-256</dt><dd>{{ result.pdfSha256 }}</dd></div>
</dl>
</section>
<footer>
<p>验真结果以本页面实时状态为准请核对 PDF 显示的凭证编号与本页一致</p>
<router-link to="/login">返回教务系统</router-link>
</footer>
</section>
</main>
</template>
<style scoped>
.verify-page { min-height: 100vh; display: grid; place-items: center; padding: 28px 16px; background: radial-gradient(circle at 15% 0, #dbece7, transparent 35%), linear-gradient(145deg, #edf3f1, #f8faf9 55%, #e6eeec); }
.verify-shell { width: min(720px, 100%); overflow: hidden; border: 1px solid rgba(41, 89, 82, .18); border-radius: 16px; background: rgba(255, 255, 255, .94); box-shadow: 0 24px 70px rgba(31, 70, 64, .14); }
header { padding: 34px 38px 28px; color: #f5fbf9; background: #214f49; }
header span { font: 650 11px Consolas, monospace; letter-spacing: .14em; opacity: .72; }
header h1 { margin: 9px 0 7px; font: 700 29px Georgia, "Noto Serif SC", serif; }
header p { margin: 0; max-width: 540px; color: rgba(245, 251, 249, .72); font-size: 13px; line-height: 1.65; }
.verify-search { display: grid; grid-template-columns: 1fr auto; gap: 10px; padding: 26px 38px 18px; }
.verify-shell > .el-alert { width: auto; margin: 0 38px; }
.verify-result { margin: 18px 38px 30px; overflow: hidden; border: 1px solid #cce2db; border-radius: 11px; }
.verify-result.invalid { border-color: #ecd2cf; }
.result-state { display: flex; gap: 14px; align-items: center; padding: 20px; color: #236b5b; background: #eff8f5; }
.invalid .result-state { color: #a44840; background: #fff3f1; }
.result-state .el-icon { flex: 0 0 auto; font-size: 35px; }
.result-state h2 { margin: 0 0 4px; font-size: 18px; }
.result-state p { margin: 0; color: #697a76; font-size: 12px; }
dl { display: grid; grid-template-columns: 1fr 1fr; margin: 0; padding: 10px 20px 18px; }
dl div { padding: 12px 4px; border-bottom: 1px solid #edf1f0; }
dt { margin-bottom: 4px; color: #83908d; font-size: 10px; }
dd { margin: 0; color: #263d39; font-size: 13px; font-weight: 650; }
.hash { grid-column: 1 / -1; }
.hash dd { overflow-wrap: anywhere; font: 500 11px Consolas, monospace; }
footer { display: flex; justify-content: space-between; gap: 18px; padding: 18px 38px; color: #7b8986; background: #f6f8f7; font-size: 11px; }
footer p { margin: 0; }
footer a { flex: 0 0 auto; color: #28675d; text-decoration: none; }
@media (max-width: 560px) {
header, .verify-search { padding-left: 22px; padding-right: 22px; }
.verify-search { grid-template-columns: 1fr; }
.verify-result, .verify-shell > .el-alert { margin-left: 22px; margin-right: 22px; }
dl { grid-template-columns: 1fr; }
.hash { grid-column: auto; }
footer { align-items: start; flex-direction: column; padding: 16px 22px; }
}
</style>