已完成“组织与权限”优化:
基础数据拆分为:组织机构、学年学期、教学场所。 人员档案拆分为:教师档案、学生档案。 基础数据、教师、学生、用户权限均支持 Excel 模板下载、批量导入和导出。 导入按编码/工号/学号/账号新增或更新;整批校验,失败不写入,限制 2000 条、10 MB。 用户列表新增密码重置,包含双重确认、最低 8 位校验和服务端权限保护。 导入严格执行角色及学院数据范围校验,并保护当前超级管理员账号。 保留旧地址重定向,原有入口不会失效。
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import type { AxiosRequestConfig } from 'axios'
|
||||
import http from './http'
|
||||
|
||||
function responseFileName(header: string | undefined, fallback: string) {
|
||||
if (!header) return fallback
|
||||
const encoded = header.match(/filename\*=UTF-8''([^;]+)/i)?.[1]
|
||||
if (encoded) return decodeURIComponent(encoded)
|
||||
const plain = header.match(/filename="?([^";]+)"?/i)?.[1]
|
||||
return plain || fallback
|
||||
}
|
||||
|
||||
export async function downloadApiFile(
|
||||
path: string,
|
||||
fallbackName: string,
|
||||
config?: AxiosRequestConfig,
|
||||
) {
|
||||
const response = await http.get(path, {
|
||||
...config,
|
||||
responseType: 'blob',
|
||||
})
|
||||
const url = URL.createObjectURL(response.data)
|
||||
const anchor = document.createElement('a')
|
||||
anchor.href = url
|
||||
anchor.download = responseFileName(response.headers['content-disposition'], fallbackName)
|
||||
document.body.appendChild(anchor)
|
||||
anchor.click()
|
||||
anchor.remove()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
export async function importExcel(path: string, file: File) {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return http.post(path, form, {
|
||||
headers: { 'Content-Type': 'multipart/form-data' },
|
||||
timeout: 60000,
|
||||
})
|
||||
}
|
||||
@@ -49,11 +49,23 @@ const navigationGroups = computed<NavigationGroup[]>(() => [
|
||||
items: [
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||
{ path: '/base-data', label: '基础数据' },
|
||||
{ path: '/base-data/organization', label: '组织机构' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||
{ path: '/base-data/terms', label: '学年学期' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
|
||||
{ path: '/base-data/facilities', label: '教学场所' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor']),
|
||||
{ path: '/personnel', label: '人员档案' },
|
||||
{ path: '/teachers', label: '教师档案' },
|
||||
),
|
||||
...whenVisible(
|
||||
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor']),
|
||||
{ path: '/students', label: '学生档案' },
|
||||
),
|
||||
...whenVisible(auth.isSuperAdmin, { path: '/users', label: '用户与权限' }),
|
||||
],
|
||||
|
||||
+42
-3
@@ -23,16 +23,55 @@ const router = createRouter({
|
||||
},
|
||||
{
|
||||
path: 'base-data',
|
||||
name: 'base-data',
|
||||
redirect: '/base-data/organization',
|
||||
},
|
||||
{
|
||||
path: 'base-data/organization',
|
||||
name: 'organization-data',
|
||||
component: () => import('../views/BaseDataView.vue'),
|
||||
meta: { roles: ['SuperAdmin', 'AcademicAdmin'] },
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin'],
|
||||
baseGroup: 'organization',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'base-data/terms',
|
||||
name: 'term-data',
|
||||
component: () => import('../views/BaseDataView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin'],
|
||||
baseGroup: 'terms',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'base-data/facilities',
|
||||
name: 'facility-data',
|
||||
component: () => import('../views/BaseDataView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin'],
|
||||
baseGroup: 'facilities',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'personnel',
|
||||
name: 'personnel',
|
||||
redirect: '/teachers',
|
||||
},
|
||||
{
|
||||
path: 'teachers',
|
||||
name: 'teachers',
|
||||
component: () => import('../views/PersonnelView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'],
|
||||
registry: 'teachers',
|
||||
},
|
||||
},
|
||||
{
|
||||
path: 'students',
|
||||
name: 'students',
|
||||
component: () => import('../views/PersonnelView.vue'),
|
||||
meta: {
|
||||
roles: ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'],
|
||||
registry: 'students',
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
+16
-2
@@ -169,15 +169,16 @@ button { cursor: pointer; }
|
||||
.page-intro { min-height: 106px; padding: 8px 4px; display: flex; align-items: center; justify-content: space-between; gap: 20px; }
|
||||
.page-intro h2 { margin: 7px 0 7px; font-family: "STZhongsong", "Songti SC", serif; font-size: 27px; }
|
||||
.page-intro p { margin: 0; color: var(--muted); font-size: 13px; }
|
||||
.page-actions { display: flex; align-items: center; gap: 8px; }
|
||||
.data-card { border: 1px solid var(--line); background: white; overflow: hidden; }
|
||||
.data-tabs { display: grid; grid-template-columns: repeat(7, minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; }
|
||||
.data-tabs { display: grid; grid-template-columns: repeat(var(--tab-count, 7), minmax(105px, 1fr)); border-bottom: 1px solid var(--line); overflow-x: auto; }
|
||||
.data-tabs button { min-width: 110px; padding: 16px 15px 14px; text-align: left; border: none; border-right: 1px solid #eaedf1; border-bottom: 3px solid transparent; color: var(--ink); background: #fafbfc; }
|
||||
.data-tabs button:hover { background: white; }
|
||||
.data-tabs button.active { border-bottom-color: var(--teal); background: white; }
|
||||
.data-tabs b, .data-tabs span { display: block; }
|
||||
.data-tabs b { font-size: 13px; }
|
||||
.data-tabs span { margin-top: 4px; color: #9299a7; font-size: 10px; white-space: nowrap; }
|
||||
.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
|
||||
.table-toolbar { min-height: 69px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); }
|
||||
.table-toolbar .el-input { width: min(340px, 60vw); }
|
||||
.table-toolbar > span { margin-left: auto; color: var(--muted); font-size: 11px; }
|
||||
.data-table { min-height: 360px; }
|
||||
@@ -198,6 +199,8 @@ button { cursor: pointer; }
|
||||
.form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; }
|
||||
.form-grid.compact { align-items: center; }
|
||||
.form-grid.three { grid-template-columns: repeat(3, 1fr); }
|
||||
.visually-hidden { position: absolute !important; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0, 0, 0, 0); white-space: nowrap; border: 0; }
|
||||
.password-reset-form { margin-top: 18px; }
|
||||
|
||||
.registry-switch { display: grid; grid-template-columns: 1fr 1fr 180px; min-height: 112px; border: 1px solid var(--line); background: white; }
|
||||
.registry-switch button { padding: 22px 26px; display: grid; gap: 4px; text-align: left; border: none; border-right: 1px solid var(--line); border-bottom: 3px solid transparent; background: white; }
|
||||
@@ -209,6 +212,14 @@ button { cursor: pointer; }
|
||||
.registry-total { padding: 20px; display: grid; place-content: center; text-align: center; background: var(--indigo-deep); color: white; }
|
||||
.registry-total span, .registry-total small { color: #aeb8d6; font-size: 10px; }
|
||||
.registry-total strong { margin: 3px 0; font: 700 30px/1 Consolas, monospace; color: #59d3c1; }
|
||||
.registry-banner { min-height: 104px; display: grid; grid-template-columns: 1fr 180px; color: white; border: 1px solid #243d70; background: linear-gradient(105deg, #172858, #29417c 68%, #19736f); overflow: hidden; }
|
||||
.registry-banner.student { background: linear-gradient(105deg, #172858, #344877 60%, #6d5b91); }
|
||||
.registry-banner > div:first-child { padding: 22px 27px; display: grid; align-content: center; gap: 6px; position: relative; }
|
||||
.registry-banner > div:first-child::after { content: ""; position: absolute; right: 26px; top: 18px; width: 70px; height: 70px; opacity: .16; background-image: linear-gradient(rgba(255,255,255,.8) 1px, transparent 1px), linear-gradient(90deg,rgba(255,255,255,.8) 1px,transparent 1px); background-size: 14px 14px; }
|
||||
.registry-banner > div > span { color: #65dac8; font: 700 9px/1.2 Consolas, monospace; letter-spacing: .14em; }
|
||||
.registry-banner > div > b { font-family: "STZhongsong", "Songti SC", serif; font-size: 18px; font-weight: 600; }
|
||||
.registry-banner > div > small { color: #bdc8e1; font-size: 10px; }
|
||||
.registry-banner .registry-total { border-left: 1px solid rgba(255,255,255,.14); }
|
||||
.filter-bar { min-height: 72px; padding: 14px 18px; display: flex; flex-wrap: wrap; align-items: center; gap: 10px; border-bottom: 1px solid var(--line); background: #fbfcfd; }
|
||||
.filter-bar > .el-input { width: 280px; }
|
||||
.filter-bar > .el-select { width: 150px; }
|
||||
@@ -888,6 +899,9 @@ button { cursor: pointer; }
|
||||
.registry-switch button { padding: 17px; }
|
||||
.registry-total { grid-column: 1 / -1; display: flex; align-items: center; gap: 8px; min-height: 54px; }
|
||||
.registry-total strong { margin-left: auto; font-size: 22px; }
|
||||
.registry-banner { grid-template-columns: 1fr; }
|
||||
.registry-banner > div:first-child { min-height: 98px; padding: 19px; }
|
||||
.registry-banner .registry-total { grid-column: auto; min-height: 54px; border-left: none; border-top: 1px solid rgba(255,255,255,.14); }
|
||||
.filter-bar > .el-input { width: 100%; }
|
||||
.filter-bar > .el-select { flex: 1 1 140px; width: auto; }
|
||||
.course-ledger { min-height: auto; display: block; }
|
||||
|
||||
+116
-10
@@ -1,13 +1,15 @@
|
||||
<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 Kind = 'campuses' | 'colleges' | 'majors' | 'classes' | 'terms' | 'buildings' | 'classrooms'
|
||||
interface Row { id: string; code: string; name: string; isEnabled: boolean; [key: string]: unknown }
|
||||
|
||||
const tabs: { key: Kind; label: string; hint: string }[] = [
|
||||
const allTabs: { key: Kind; label: string; hint: string }[] = [
|
||||
{ key: 'campuses', label: '校区', hint: '学校的物理校区' },
|
||||
{ key: 'colleges', label: '学院', hint: '教学组织单位' },
|
||||
{ key: 'majors', label: '专业', hint: '专业与授予学位' },
|
||||
@@ -18,9 +20,39 @@ const tabs: { key: Kind; label: string; hint: string }[] = [
|
||||
]
|
||||
|
||||
const auth = useAuthStore()
|
||||
const active = ref<Kind>('campuses')
|
||||
const route = useRoute()
|
||||
const groups = {
|
||||
organization: {
|
||||
title: '组织机构',
|
||||
kicker: 'ORGANIZATION DIRECTORY',
|
||||
description: '维护校区、学院、专业与行政班的稳定组织层级。',
|
||||
kinds: ['campuses', 'colleges', 'majors', 'classes'] as Kind[],
|
||||
},
|
||||
terms: {
|
||||
title: '学年学期',
|
||||
kicker: 'ACADEMIC CALENDAR',
|
||||
description: '集中维护教学年度、学期边界与当前运行学期。',
|
||||
kinds: ['terms'] as Kind[],
|
||||
},
|
||||
facilities: {
|
||||
title: '教学场所',
|
||||
kicker: 'TEACHING FACILITIES',
|
||||
description: '按校区和教学楼维护可用于排课的教室资源。',
|
||||
kinds: ['buildings', 'classrooms'] as Kind[],
|
||||
},
|
||||
}
|
||||
const group = computed(() => {
|
||||
const key = String(route.meta.baseGroup ?? 'organization') as keyof typeof groups
|
||||
return groups[key] ?? groups.organization
|
||||
})
|
||||
const tabs = computed(() =>
|
||||
allTabs.filter((tab) => group.value.kinds.includes(tab.key)),
|
||||
)
|
||||
const active = ref<Kind>(group.value.kinds[0])
|
||||
const rows = ref<Row[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const dialogVisible = ref(false)
|
||||
const editingId = ref('')
|
||||
const keyword = ref('')
|
||||
@@ -29,7 +61,7 @@ const references = reactive<Record<string, any[]>>({
|
||||
})
|
||||
const form = reactive<Record<string, any>>({})
|
||||
|
||||
const title = computed(() => tabs.find((x) => x.key === active.value)?.label ?? '')
|
||||
const title = computed(() => tabs.value.find((x) => x.key === active.value)?.label ?? '')
|
||||
const canManage = computed(() =>
|
||||
auth.user?.roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ?? false,
|
||||
)
|
||||
@@ -81,6 +113,50 @@ async function changeTab() {
|
||||
await load()
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/base-data/${active.value}/template`,
|
||||
`${title.value}导入模板.xlsx`,
|
||||
)
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
try {
|
||||
await downloadApiFile(
|
||||
`/base-data/${active.value}/export`,
|
||||
`${title.value}.xlsx`,
|
||||
)
|
||||
ElMessage.success(`${title.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(`/base-data/${active.value}/import`, file)
|
||||
ElMessage.success(`导入完成:新增 ${data.created} 条,更新 ${data.updated} 条`)
|
||||
await Promise.all([load(), loadReferences()])
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
editingId.value = ''
|
||||
resetForm()
|
||||
@@ -130,21 +206,39 @@ async function remove(row: any) {
|
||||
onMounted(async () => {
|
||||
await Promise.all([load(), loadReferences()])
|
||||
})
|
||||
|
||||
watch(
|
||||
() => route.meta.baseGroup,
|
||||
async () => {
|
||||
active.value = group.value.kinds[0]
|
||||
keyword.value = ''
|
||||
await load()
|
||||
},
|
||||
)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="page-stack">
|
||||
<section class="page-intro">
|
||||
<div>
|
||||
<span class="section-kicker">MASTER DATA</span>
|
||||
<h2>基础数据</h2>
|
||||
<p>先建立稳定的组织与教学资源编码,后续业务均从这里引用。</p>
|
||||
<span class="section-kicker">{{ group.kicker }}</span>
|
||||
<h2>{{ group.title }}</h2>
|
||||
<p>{{ group.description }}</p>
|
||||
</div>
|
||||
<div class="page-actions">
|
||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">
|
||||
新增{{ title }}
|
||||
</el-button>
|
||||
</div>
|
||||
<el-button v-if="canManage" type="primary" :icon="Plus" @click="openCreate">新增{{ title }}</el-button>
|
||||
</section>
|
||||
|
||||
<section class="data-card">
|
||||
<nav class="data-tabs" aria-label="基础数据分类">
|
||||
<nav
|
||||
v-if="tabs.length > 1"
|
||||
class="data-tabs"
|
||||
:style="{ '--tab-count': tabs.length }"
|
||||
aria-label="基础数据分类"
|
||||
>
|
||||
<button
|
||||
v-for="tab in tabs"
|
||||
:key="tab.key"
|
||||
@@ -160,6 +254,18 @@ onMounted(async () => {
|
||||
<div class="table-toolbar">
|
||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索编码、名称或所属单位" />
|
||||
<el-button :icon="Refresh" @click="load">刷新</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>
|
||||
<el-button :icon="Download" @click="exportRows">导出结果</el-button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
</template>
|
||||
<span>共 {{ filteredRows.length }} 条</span>
|
||||
</div>
|
||||
|
||||
|
||||
+102
-38
@@ -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">
|
||||
|
||||
+131
-4
@@ -1,7 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref } from 'vue'
|
||||
import { EditPen, Plus, Search } from '@element-plus/icons-vue'
|
||||
import {
|
||||
Document, Download, EditPen, Key, Plus, Search, Upload,
|
||||
} from '@element-plus/icons-vue'
|
||||
import http, { apiErrorMessage } from '../api/http'
|
||||
import { downloadApiFile, importExcel } from '../api/excel'
|
||||
|
||||
interface UserRow {
|
||||
id: string; userName: string; displayName: string; staffNumber?: string
|
||||
@@ -14,13 +17,18 @@ const users = ref<UserRow[]>([])
|
||||
const roles = ref<Role[]>([])
|
||||
const colleges = ref<any[]>([])
|
||||
const loading = ref(false)
|
||||
const importing = ref(false)
|
||||
const fileInput = ref<HTMLInputElement>()
|
||||
const dialogVisible = ref(false)
|
||||
const roleDialogVisible = ref(false)
|
||||
const passwordDialogVisible = ref(false)
|
||||
const editingUser = ref<UserRow | null>(null)
|
||||
const passwordUser = ref<UserRow | null>(null)
|
||||
const editingRoles = ref<string[]>([])
|
||||
const editingStaffNumber = ref('')
|
||||
const editingCollegeId = ref<string>()
|
||||
const keyword = ref('')
|
||||
const passwordForm = reactive({ newPassword: '', confirmPassword: '' })
|
||||
const form = reactive({
|
||||
userName: '', displayName: '', password: '', staffNumber: '',
|
||||
collegeId: undefined as string | undefined, roles: [] as string[],
|
||||
@@ -48,6 +56,15 @@ const editingScope = computed(() => {
|
||||
)
|
||||
return scopeNames[effective]
|
||||
})
|
||||
const filteredUsers = computed(() => {
|
||||
const value = keyword.value.trim().toLowerCase()
|
||||
if (!value) return users.value
|
||||
return users.value.filter((user) =>
|
||||
[user.userName, user.displayName, user.staffNumber, ...user.roles]
|
||||
.filter(Boolean)
|
||||
.some((item) => String(item).toLowerCase().includes(value)),
|
||||
)
|
||||
})
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -58,11 +75,51 @@ async function load() {
|
||||
users.value = userRes.data
|
||||
roles.value = roleRes.data
|
||||
colleges.value = collegeRes.data
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadTemplate() {
|
||||
try {
|
||||
await downloadApiFile('/users/template', '用户与权限导入模板.xlsx')
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
async function exportRows() {
|
||||
try {
|
||||
await downloadApiFile('/users/export', '用户与权限.xlsx')
|
||||
ElMessage.success('用户与权限已导出')
|
||||
} 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('/users/import', file)
|
||||
ElMessage.success(`导入完成:新增 ${data.created} 个,更新 ${data.updated} 个`)
|
||||
await load()
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
} finally {
|
||||
importing.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function openCreate() {
|
||||
Object.assign(form, {
|
||||
userName: '', displayName: '', password: '', staffNumber: '',
|
||||
@@ -116,6 +173,33 @@ async function saveRoles() {
|
||||
}
|
||||
}
|
||||
|
||||
function openPasswordReset(row: any) {
|
||||
passwordUser.value = row
|
||||
Object.assign(passwordForm, { newPassword: '', confirmPassword: '' })
|
||||
passwordDialogVisible.value = true
|
||||
}
|
||||
|
||||
async function resetPassword() {
|
||||
if (!passwordUser.value) return
|
||||
if (passwordForm.newPassword.length < 8) {
|
||||
ElMessage.warning('新密码至少需要 8 位。')
|
||||
return
|
||||
}
|
||||
if (passwordForm.newPassword !== passwordForm.confirmPassword) {
|
||||
ElMessage.warning('两次输入的密码不一致。')
|
||||
return
|
||||
}
|
||||
try {
|
||||
await http.put(`/users/${passwordUser.value.id}/password`, {
|
||||
newPassword: passwordForm.newPassword,
|
||||
})
|
||||
ElMessage.success(`已重置 ${passwordUser.value.displayName} 的密码`)
|
||||
passwordDialogVisible.value = false
|
||||
} catch (error) {
|
||||
ElMessage.error(apiErrorMessage(error))
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
@@ -133,11 +217,21 @@ onMounted(load)
|
||||
<section class="data-card">
|
||||
<div class="table-toolbar">
|
||||
<el-input v-model="keyword" :prefix-icon="Search" clearable placeholder="搜索账号、姓名或工号" />
|
||||
<span>共 {{ users.length }} 个账号</span>
|
||||
<el-button :icon="Document" @click="downloadTemplate">下载模板</el-button>
|
||||
<el-button :icon="Upload" :loading="importing" @click="chooseImportFile">Excel 导入</el-button>
|
||||
<el-button :icon="Download" @click="exportRows">导出全部</el-button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
class="visually-hidden"
|
||||
type="file"
|
||||
accept=".xlsx"
|
||||
@change="handleImport"
|
||||
/>
|
||||
<span>共 {{ filteredUsers.length }} / {{ users.length }} 个账号</span>
|
||||
</div>
|
||||
<el-table
|
||||
v-loading="loading"
|
||||
:data="users.filter((x) => !keyword || `${x.userName}${x.displayName}${x.staffNumber}`.includes(keyword))"
|
||||
:data="filteredUsers"
|
||||
>
|
||||
<el-table-column prop="userName" label="账号" min-width="130" />
|
||||
<el-table-column prop="displayName" label="姓名" min-width="120" />
|
||||
@@ -155,11 +249,14 @@ onMounted(load)
|
||||
<el-table-column label="状态" width="90">
|
||||
<template #default="{ row }"><span class="table-status" :class="{ off: !row.isEnabled }">{{ row.isEnabled ? '启用' : '停用' }}</span></template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="175">
|
||||
<el-table-column label="操作" width="250" fixed="right">
|
||||
<template #default="{ row }">
|
||||
<el-button link type="primary" :icon="EditPen" @click="openRoleEditor(row)">
|
||||
调整角色
|
||||
</el-button>
|
||||
<el-button link type="primary" :icon="Key" @click="openPasswordReset(row)">
|
||||
重置密码
|
||||
</el-button>
|
||||
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
|
||||
{{ row.isEnabled ? '停用' : '启用' }}
|
||||
</el-button>
|
||||
@@ -195,6 +292,36 @@ onMounted(load)
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="passwordDialogVisible" title="重置密码" width="460px">
|
||||
<div v-if="passwordUser" class="role-editor-summary">
|
||||
<strong>{{ passwordUser.displayName }}</strong>
|
||||
<span>{{ passwordUser.userName }}</span>
|
||||
</div>
|
||||
<el-alert
|
||||
:closable="false"
|
||||
type="warning"
|
||||
show-icon
|
||||
title="保存后旧密码立即失效,请通过安全渠道通知本人。"
|
||||
/>
|
||||
<el-form label-position="top" class="entity-form password-reset-form">
|
||||
<el-form-item label="新密码" required>
|
||||
<el-input v-model="passwordForm.newPassword" type="password" show-password />
|
||||
</el-form-item>
|
||||
<el-form-item label="再次输入新密码" required>
|
||||
<el-input
|
||||
v-model="passwordForm.confirmPassword"
|
||||
type="password"
|
||||
show-password
|
||||
@keyup.enter="resetPassword"
|
||||
/>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
<template #footer>
|
||||
<el-button @click="passwordDialogVisible = false">取消</el-button>
|
||||
<el-button type="primary" @click="resetPassword">确认重置</el-button>
|
||||
</template>
|
||||
</el-dialog>
|
||||
|
||||
<el-dialog v-model="roleDialogVisible" title="调整角色与数据范围" width="540px">
|
||||
<div v-if="editingUser" class="role-editor-summary">
|
||||
<strong>{{ editingUser.displayName }}</strong>
|
||||
|
||||
Reference in New Issue
Block a user