已完成“组织与权限”优化:

基础数据拆分为:组织机构、学年学期、教学场所。
人员档案拆分为:教师档案、学生档案。
基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。
导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。
用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。
导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。
保留旧地址重定向,原有入口不会失效。
This commit is contained in:
2026-07-24 18:12:56 +08:00 Unverified
parent 662aa1051f
commit 022e79c48d
13 changed files with 2133 additions and 59 deletions
+102 -38
View File
@@ -1,7 +1,9 @@
<script setup lang="ts">
import { computed, onMounted, reactive, ref } from 'vue'
import { Plus, Refresh, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { Document, Download, Plus, Refresh, Search, Upload } from '@element-plus/icons-vue'
import { useRoute } from 'vue-router'
import http, { apiErrorMessage } from '../api/http'
import { downloadApiFile, importExcel } from '../api/excel'
import { useAuthStore } from '../stores/auth'
type Registry = 'teachers' | 'students'
@@ -13,8 +15,13 @@ interface PageResult {
}
const auth = useAuthStore()
const active = ref<Registry>('teachers')
const route = useRoute()
const active = computed<Registry>(() =>
route.meta.registry === 'students' ? 'students' : 'teachers',
)
const loading = ref(false)
const importing = ref(false)
const fileInput = ref<HTMLInputElement>()
const rows = ref<any[]>([])
const total = ref(0)
const dialogVisible = ref(false)
@@ -166,8 +173,7 @@ async function loadReferences() {
if (scopedCollegeId.value) query.collegeId = scopedCollegeId.value
}
function switchRegistry(kind: Registry) {
active.value = kind
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
@@ -180,17 +186,66 @@ function switchRegistry(kind: Registry) {
load()
}
function resetFilters() {
Object.assign(query, {
page: 1,
keyword: '',
collegeId: scopedCollegeId.value,
majorId: undefined,
classId: undefined,
enrollmentYear: undefined,
status: undefined,
})
load()
function exportParams() {
const params: Record<string, any> = {
keyword: query.keyword || undefined,
collegeId: query.collegeId,
}
if (active.value === 'teachers') params.teacherStatus = query.status
else {
Object.assign(params, {
majorId: query.majorId,
classId: query.classId,
enrollmentYear: query.enrollmentYear,
studentStatus: query.status,
})
}
return params
}
async function downloadTemplate() {
try {
await downloadApiFile(
`/personnel/${active.value}/template`,
`${pageTitle.value}导入模板.xlsx`,
)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
async function exportRows() {
try {
await downloadApiFile(
`/personnel/${active.value}/export`,
`${pageTitle.value}.xlsx`,
{ params: exportParams() },
)
ElMessage.success(`${pageTitle.value}已导出`)
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
function chooseImportFile() {
fileInput.value?.click()
}
async function handleImport(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
importing.value = true
try {
const { data } = await importExcel(`/personnel/${active.value}/import`, file)
ElMessage.success(`导入完成:新增 ${data.created} 条,更新 ${data.updated}`)
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
} finally {
importing.value = false
}
}
function openCreate() {
@@ -241,40 +296,37 @@ async function remove(row: any) {
onMounted(async () => {
await Promise.all([loadReferences(), load()])
})
watch(active, async () => {
resetFilters()
})
</script>
<template>
<div class="page-stack">
<section class="page-intro registry-intro">
<div>
<span class="section-kicker">ACADEMIC REGISTRY</span>
<h2>人员档案</h2>
<p>以学校统一编号管理教师和学生的归属状态与联系信息</p>
<span class="section-kicker">
{{ active === 'teachers' ? 'TEACHING STAFF' : 'STUDENT ROLL' }}
</span>
<h2>{{ pageTitle }}</h2>
<p>
{{ active === 'teachers'
? '以工号维护教师归属、职称、任职状态与联系信息。'
: '以学号维护学生班级归属、入学信息与学籍状态。' }}
</p>
</div>
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">
新增{{ pageTitle }}
</el-button>
</section>
<section class="registry-switch">
<button
type="button"
:class="{ active: active === 'teachers' }"
@click="switchRegistry('teachers')"
>
<span>TEACHING STAFF</span>
<b>教师档案</b>
<small>工号职称与任职状态</small>
</button>
<button
type="button"
:class="{ active: active === 'students' }"
@click="switchRegistry('students')"
>
<span>STUDENT ROLL</span>
<b>学生档案</b>
<small>学号班级与学籍状态</small>
</button>
<section class="registry-banner" :class="{ student: active === 'students' }">
<div>
<span>{{ active === 'teachers' ? 'STAFF DIRECTORY' : 'STUDENT DIRECTORY' }}</span>
<b>{{ active === 'teachers' ? '工号是教师身份与教学任务的唯一索引' : '学号贯穿学籍、选课、成绩与毕业业务' }}</b>
<small>当前筛选结果会原样用于 Excel 导出</small>
</div>
<div class="registry-total">
<span>当前结果</span>
<strong>{{ total }}</strong>
@@ -312,6 +364,18 @@ onMounted(async () => {
</el-select>
<el-button type="primary" @click="query.page = 1; load()">查询</el-button>
<el-button :icon="Refresh" @click="resetFilters">重置</el-button>
<template v-if="canManage">
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
<input
ref="fileInput"
class="visually-hidden"
type="file"
accept=".xlsx"
@change="handleImport"
/>
</template>
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
</div>
<el-table v-loading="loading" :data="rows" class="data-table registry-table">