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

386 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.
<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>