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

服务端根据登录账号绑定本人档案,不接受学生 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
+4
View File
@@ -191,6 +191,10 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student']),
{ path: '/student-status-changes', label: isStudent.value ? '学籍异动' : '异动审核' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/official-documents', label: isStudent.value ? '电子成绩单与证明' : '官方凭证签发' },
),
],
},
{
+14
View File
@@ -23,6 +23,12 @@ const router = createRouter({
component: () => import('../views/AccountActivationView.vue'),
meta: { public: true },
},
{
path: '/verify/:verificationCode?',
name: 'official-document-verification',
component: () => import('../views/OfficialDocumentVerifyView.vue'),
meta: { public: true },
},
{
path: '/',
component: AdminLayout,
@@ -236,6 +242,14 @@ const router = createRouter({
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'],
},
},
{
path: 'official-documents',
name: 'official-documents',
component: () => import('../views/OfficialDocumentsView.vue'),
meta: {
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student'],
},
},
{
path: 'graduation-audits',
name: 'graduation-audits',
@@ -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>
+385
View File
@@ -0,0 +1,385 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Download, Plus, Refresh, Search } from '@element-plus/icons-vue'
import { ElMessage, ElMessageBox } from 'element-plus'
import http, { apiErrorMessage } from '../api/http'
import { useAuthStore } from '../stores/auth'
type DocumentType = 'Transcript' | 'StudentStatusCertificate'
type DocumentStatus = 'Valid' | 'Invalidated' | 'Superseded'
interface OfficialDocumentRow {
id: string
documentNumber: string
type: DocumentType
status: DocumentStatus
studentId: string
studentNumber: string
studentName: string
collegeName: string
purpose?: string
issuedAt: string
issuedByName: string
invalidatedAt?: string
invalidationReason?: string
reissuedFromDocumentId?: string
downloadCount: number
lastDownloadedAt?: string
}
interface StudentOption {
id: string
studentNumber: string
name: string
status: string
className: string
majorName: string
collegeName: string
}
const auth = useAuthStore()
const isManager = computed(() =>
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)),
)
const loading = ref(false)
const issuing = ref(false)
const documents = ref<OfficialDocumentRow[]>([])
const students = ref<StudentOption[]>([])
const issueDialog = ref(false)
const historyDrawer = ref(false)
const historyLoading = ref(false)
const downloadHistory = ref<any[]>([])
const selectedDocument = ref<OfficialDocumentRow>()
const filters = reactive<{ type?: DocumentType; status?: DocumentStatus }>({})
const issueForm = reactive<{ studentId: string; type: DocumentType; purpose: string }>({
studentId: '',
type: 'Transcript',
purpose: '',
})
const typeLabels: Record<DocumentType, string> = {
Transcript: '官方电子成绩单',
StudentStatusCertificate: '学籍状态证明',
}
const statusLabels: Record<DocumentStatus, string> = {
Valid: '有效',
Invalidated: '已失效',
Superseded: '已重签',
}
async function load() {
loading.value = true
try {
const { data } = await http.get('/official-documents', { params: filters })
documents.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
loading.value = false
}
}
async function loadStudents(keyword = '') {
if (!isManager.value) return
try {
const { data } = await http.get('/official-documents/students/options', {
params: { keyword: keyword || undefined },
})
students.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function openIssue() {
issueForm.studentId = ''
issueForm.type = 'Transcript'
issueForm.purpose = ''
issueDialog.value = true
if (isManager.value) await loadStudents()
}
async function issue() {
if (isManager.value && !issueForm.studentId) {
ElMessage.warning('请选择学生。')
return
}
issuing.value = true
try {
const response = isManager.value
? await http.post('/official-documents', issueForm)
: await http.post('/official-documents/mine', {
type: issueForm.type,
purpose: issueForm.purpose,
})
issueDialog.value = false
ElMessage.success(
response.data?.reused
? '已为你保留刚申请的同类有效凭证。'
: isManager.value
? '官方凭证已签发。'
: '电子凭证已生成,可在列表中下载。',
)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
issuing.value = false
}
}
async function download(row: OfficialDocumentRow) {
try {
const response = await http.get(`/official-documents/${row.id}/download`, {
responseType: 'blob',
timeout: 30000,
})
const url = URL.createObjectURL(response.data)
const anchor = document.createElement('a')
anchor.href = url
anchor.download = `${row.documentNumber}.pdf`
anchor.click()
URL.revokeObjectURL(url)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function invalidate(row: OfficialDocumentRow) {
try {
const { value } = await ElMessageBox.prompt(
'失效后学生不能再下载,但已下载文件仍可扫码看到“已失效”。请输入原因。',
'确认失效',
{ inputPattern: /^.{2,500}$/, inputErrorMessage: '原因需为 2-500 个字符', confirmButtonText: '确认失效' },
)
await http.post(`/official-documents/${row.id}/invalidate`, { reason: value })
ElMessage.success('凭证已失效。')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function reissue(row: OfficialDocumentRow) {
try {
const { value } = await ElMessageBox.prompt(
'系统会冻结原凭证并按当前正式数据生成新编号和新二维码。请输入重签原因。',
'重签凭证',
{ inputPattern: /^.{2,500}$/, inputErrorMessage: '原因需为 2-500 个字符', confirmButtonText: '确认重签' },
)
await http.post(`/official-documents/${row.id}/reissue`, { reason: value })
ElMessage.success('新凭证已签发,原凭证已标记为被替代。')
await load()
} catch (error: any) {
if (error === 'cancel' || error === 'close') return
ElMessage.error(apiErrorMessage(error))
}
}
async function showHistory(row: OfficialDocumentRow) {
selectedDocument.value = row
historyDrawer.value = true
historyLoading.value = true
try {
const { data } = await http.get(`/official-documents/${row.id}/downloads`)
downloadHistory.value = data
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
historyLoading.value = false
}
}
function formatTime(value?: string) {
return value ? new Date(value).toLocaleString('zh-CN', { hour12: false }) : '—'
}
function statusTagType(status: DocumentStatus) {
return status === 'Valid' ? 'success' : status === 'Superseded' ? 'warning' : 'danger'
}
onMounted(load)
</script>
<template>
<div class="document-page">
<header class="document-hero">
<div>
<span>OFFICIAL DOCUMENT SERVICE</span>
<h2>{{ isManager ? '官方凭证签发中心' : '电子成绩单与证明' }}</h2>
<p>{{ isManager ? '服务端固化签发快照和 PDF,凭证编号唯一,二维码可公开验真。' : '在线申请后即时生成,可下载 PDF,并通过二维码公开验真。' }}</p>
</div>
<el-button type="primary" :icon="Plus" @click="openIssue">
{{ isManager ? '签发新凭证' : '申请电子凭证' }}
</el-button>
</header>
<section class="trust-strip">
<div><b>唯一编号</b><span>每次签发独立编号</span></div>
<div><b>二维码验真</b><span>实时显示有效状态</span></div>
<div><b>下载留痕</b><span>时间账号与来源可追溯</span></div>
<div><b>失效与重签</b><span>旧凭证保留完整链路</span></div>
</section>
<section class="document-toolbar">
<el-select v-model="filters.type" clearable placeholder="全部凭证类型" @change="load">
<el-option v-for="(label, value) in typeLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-select v-model="filters.status" clearable placeholder="全部状态" @change="load">
<el-option v-for="(label, value) in statusLabels" :key="value" :label="label" :value="value" />
</el-select>
<el-button :icon="Refresh" @click="load">刷新</el-button>
<span> {{ documents.length }} 份凭证</span>
</section>
<section class="document-list" v-loading="loading">
<article v-for="row in documents" :key="row.id" class="document-card">
<div class="document-mark" :class="row.type === 'Transcript' ? 'transcript' : 'certificate'">
<span>{{ row.type === 'Transcript' ? 'TR' : 'SC' }}</span>
<small>PDF</small>
</div>
<div class="document-main">
<div class="document-title">
<div>
<span>{{ typeLabels[row.type] }}</span>
<h3>{{ row.documentNumber }}</h3>
</div>
<el-tag :type="statusTagType(row.status)" effect="plain">{{ statusLabels[row.status] }}</el-tag>
</div>
<p class="student-line">
<b>{{ row.studentName }}</b>
<span>{{ row.studentNumber }} · {{ row.collegeName }}</span>
</p>
<p class="document-meta">
签发于 {{ formatTime(row.issuedAt) }} · {{ row.issuedByName }}
<template v-if="row.purpose"> · 用途{{ row.purpose }}</template>
</p>
<el-alert
v-if="row.status !== 'Valid'"
:title="`${statusLabels[row.status]}${row.invalidationReason || '未填写原因'}`"
type="warning"
:closable="false"
/>
</div>
<div class="document-actions">
<el-button v-if="row.status === 'Valid'" type="primary" :icon="Download" @click="download(row)">下载 PDF</el-button>
<el-button v-if="isManager" :icon="Search" @click="showHistory(row)">下载记录{{ row.downloadCount }}</el-button>
<el-button v-if="isManager && row.status === 'Valid'" type="danger" plain @click="invalidate(row)">设为失效</el-button>
<el-button v-if="isManager && row.status !== 'Superseded'" type="warning" plain @click="reissue(row)">重签</el-button>
</div>
</article>
<el-empty
v-if="!documents.length"
:description="isManager ? '暂无符合条件的官方凭证' : '暂无电子凭证,可点击上方按钮在线申请'"
/>
</section>
<el-dialog v-model="issueDialog" :title="isManager ? '签发官方凭证' : '申请电子凭证'" width="620px">
<el-alert
:title="isManager
? '签发后将固化当前正式数据;后续数据修改不会悄悄改变已签发文件。'
: '系统将按你的学籍档案即时生成;成绩单仅包含已正式发布的成绩,5 分钟内重复申请同类型、同用途凭证将复用已有文件。'"
type="info"
:closable="false"
/>
<el-form label-position="top" class="issue-form">
<el-form-item v-if="isManager" label="学生" required>
<el-select
v-model="issueForm.studentId"
filterable
remote
:remote-method="loadStudents"
placeholder="按姓名或学号搜索"
style="width: 100%"
>
<el-option
v-for="student in students"
:key="student.id"
:value="student.id"
:label="`${student.studentNumber} · ${student.name}`"
>
<div class="student-option">
<b>{{ student.studentNumber }} · {{ student.name }}</b>
<span>{{ student.collegeName }} · {{ student.majorName }} · {{ student.className }}</span>
</div>
</el-option>
</el-select>
</el-form-item>
<el-form-item label="凭证类型" required>
<el-radio-group v-model="issueForm.type">
<el-radio-button value="Transcript">官方电子成绩单</el-radio-button>
<el-radio-button value="StudentStatusCertificate">学籍状态证明</el-radio-button>
</el-radio-group>
</el-form-item>
<el-form-item label="用途(可选)">
<el-input v-model="issueForm.purpose" maxlength="200" show-word-limit placeholder="例如:升学申请、实习材料" />
</el-form-item>
</el-form>
<template #footer>
<el-button @click="issueDialog = false">取消</el-button>
<el-button type="primary" :loading="issuing" @click="issue">
{{ isManager ? '确认签发' : '提交申请' }}
</el-button>
</template>
</el-dialog>
<el-drawer v-model="historyDrawer" title="凭证下载记录" size="560px">
<p class="drawer-number">{{ selectedDocument?.documentNumber }}</p>
<el-table :data="downloadHistory" v-loading="historyLoading">
<el-table-column label="下载人" prop="downloadedByName" min-width="110" />
<el-table-column label="下载时间" min-width="165">
<template #default="{ row }">{{ formatTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="来源 IP" prop="ipAddress" min-width="130" />
</el-table>
<el-empty v-if="!historyLoading && !downloadHistory.length" description="尚无下载记录" />
</el-drawer>
</div>
</template>
<style scoped>
.document-page { display: grid; gap: 18px; }
.document-hero { display: flex; align-items: end; justify-content: space-between; gap: 24px; padding: 26px 28px; border-radius: 12px; color: #f4fbf8; background: linear-gradient(120deg, #173b3a, #245e55 68%, #33746a); box-shadow: 0 14px 34px rgba(25, 65, 60, .16); }
.document-hero span { font: 650 10px/1.2 Consolas, monospace; letter-spacing: .18em; opacity: .72; }
.document-hero h2 { margin: 8px 0 5px; font: 700 27px/1.25 Georgia, "Noto Serif SC", serif; }
.document-hero p { margin: 0; color: rgba(244, 251, 248, .74); font-size: 13px; }
.trust-strip { display: grid; grid-template-columns: repeat(4, 1fr); gap: 1px; overflow: hidden; border: 1px solid #dce7e4; border-radius: 10px; background: #dce7e4; }
.trust-strip div { display: grid; gap: 3px; padding: 14px 18px; background: #f8fbfa; }
.trust-strip b { color: #244d48; font-size: 13px; }
.trust-strip span { color: #71807e; font-size: 11px; }
.document-toolbar { display: flex; align-items: center; gap: 10px; }
.document-toolbar .el-select { width: 180px; }
.document-toolbar > span { margin-left: auto; color: #7b8785; font-size: 12px; }
.document-list { display: grid; gap: 12px; min-height: 180px; }
.document-card { display: grid; grid-template-columns: 74px minmax(0, 1fr) auto; gap: 18px; align-items: center; padding: 18px; border: 1px solid #e0e7e5; border-radius: 10px; background: white; }
.document-mark { display: grid; place-content: center; width: 62px; height: 76px; border-radius: 5px 5px 12px 5px; color: white; background: #245e55; box-shadow: 0 8px 18px rgba(36, 94, 85, .18); }
.document-mark.certificate { background: #6b5541; }
.document-mark span { font: 800 18px/1 Consolas, monospace; letter-spacing: .1em; }
.document-mark small { margin-top: 7px; text-align: center; font: 700 8px/1 Consolas, monospace; opacity: .68; }
.document-main { min-width: 0; }
.document-title { display: flex; align-items: start; justify-content: space-between; gap: 12px; }
.document-title span { color: #687774; font-size: 11px; }
.document-title h3 { margin: 4px 0 0; overflow: hidden; color: #263d3a; font: 700 15px/1.3 Consolas, monospace; text-overflow: ellipsis; }
.student-line { display: flex; gap: 9px; align-items: baseline; margin: 11px 0 4px; }
.student-line b { color: #263d3a; }
.student-line span, .document-meta { color: #77827f; font-size: 12px; }
.document-meta { margin: 0 0 8px; }
.document-actions { display: flex; flex-direction: column; gap: 7px; min-width: 150px; }
.issue-form { margin-top: 18px; }
.student-option { display: grid; line-height: 1.3; }
.student-option span { color: #8a9693; font-size: 11px; }
.drawer-number { margin: -8px 0 18px; color: #667572; font: 600 12px Consolas, monospace; }
@media (max-width: 760px) {
.document-hero { align-items: stretch; flex-direction: column; padding: 22px; }
.trust-strip { grid-template-columns: 1fr 1fr; }
.document-toolbar { align-items: stretch; flex-wrap: wrap; }
.document-toolbar .el-select { width: calc(50% - 5px); }
.document-toolbar > span { width: 100%; margin-left: 0; }
.document-card { grid-template-columns: 54px minmax(0, 1fr); align-items: start; padding: 14px; }
.document-mark { width: 50px; height: 62px; }
.document-actions { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; }
.student-line { align-items: start; flex-direction: column; gap: 2px; }
}
</style>