学生可在“学籍管理 → 电子成绩单与证明”申请成绩单或学籍证明。

服务端根据登录账号绑定本人档案,不接受学生 ID,无法代他人申请。
成绩单仅包含正式发布成绩;无已发布成绩时明确提示。
同类型、同用途 5 分钟内重复申请复用已有有效凭证。
申请后即时生成 PDF,可在本人凭证列表下载、二维码验真。
管理员的下载记录、失效、重签能力保持不变。
This commit is contained in:
2026-07-26 18:24:53 +08:00 Unverified
parent 40b8cb6e3c
commit ecc6546f3a
24 changed files with 7203 additions and 0 deletions
@@ -0,0 +1,149 @@
<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>