多角色账号按 All > College > Class > Self 自动取最高权限。

学院管理员限制在所属学院。
辅导员通过稳定账号 ID 绑定行政班,避免重名串班。
教师只能访问本人档案、授课课程和所授课学生。
学生只能访问本人档案及所在班级课程。
教师/学生角色会自动校验并绑定工号或学号档案。
超级管理员可在用户页面调整角色、学院、工号/学号,并预览生效后的数据范围。
This commit is contained in:
2026-07-24 14:54:30 +08:00 Unverified
parent 0b463fa4f2
commit bcc4d33bd5
26 changed files with 2333 additions and 88 deletions
+19 -4
View File
@@ -24,8 +24,8 @@ const loading = ref(false)
const dialogVisible = ref(false)
const editingId = ref('')
const keyword = ref('')
const references = reactive<Record<string, Row[]>>({
campuses: [], colleges: [], majors: [], buildings: [],
const references = reactive<Record<string, any[]>>({
campuses: [], colleges: [], majors: [], buildings: [], counselors: [],
})
const form = reactive<Record<string, any>>({})
@@ -48,7 +48,7 @@ function resetForm(row?: Row) {
code: '', name: '', sortOrder: 0, isEnabled: true,
campusId: undefined, collegeId: undefined, majorId: undefined, buildingId: undefined,
shortName: '', degreeType: '学士', schoolingYears: 4,
grade: new Date().getFullYear(), counselorName: '',
grade: new Date().getFullYear(), counselorUserId: undefined,
academicYear: `${new Date().getFullYear()}-${new Date().getFullYear() + 1}`,
season: 'Autumn', startDate: '', endDate: '', isCurrent: false,
capacity: 60, roomType: '普通教室', equipment: '',
@@ -71,6 +71,9 @@ async function loadReferences() {
await Promise.all(kinds.map(async (kind) => {
references[kind] = (await http.get(`/base-data/${kind}`)).data
}))
if (canManage.value) {
references.counselors = (await http.get('/base-data/counselors')).data
}
}
async function changeTab() {
@@ -168,6 +171,9 @@ onMounted(async () => {
<el-table-column v-if="active === 'majors'" prop="degreeType" label="学位类型" min-width="120" />
<el-table-column v-if="active === 'classes'" prop="majorName" label="所属专业" min-width="170" />
<el-table-column v-if="active === 'classes'" prop="grade" label="年级" width="90" />
<el-table-column v-if="active === 'classes'" prop="counselorName" label="辅导员" min-width="120">
<template #default="{ row }">{{ row.counselorName || '未分配' }}</template>
</el-table-column>
<el-table-column v-if="active === 'terms'" prop="academicYear" label="学年" width="120" />
<el-table-column v-if="active === 'terms'" label="当前学期" width="100">
<template #default="{ row }"><el-tag v-if="row.isCurrent" type="success">当前</el-tag><span v-else></span></template>
@@ -221,7 +227,16 @@ onMounted(async () => {
</el-form-item>
<div v-if="active === 'classes'" class="form-grid">
<el-form-item label="年级"><el-input-number v-model="form.grade" :min="2000" :max="2200" /></el-form-item>
<el-form-item label="辅导员"><el-input v-model="form.counselorName" /></el-form-item>
<el-form-item label="辅导员账号">
<el-select v-model="form.counselorUserId" clearable filterable>
<el-option
v-for="item in references.counselors"
:key="item.id"
:label="`${item.displayName}${item.staffNumber ? ` · ${item.staffNumber}` : ''}`"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<template v-if="active === 'terms'">
<div class="form-grid">
+108 -5
View File
@@ -1,24 +1,53 @@
<script setup lang="ts">
import { onMounted, reactive, ref } from 'vue'
import { Plus, Search } from '@element-plus/icons-vue'
import { computed, onMounted, reactive, ref } from 'vue'
import { EditPen, Plus, Search } from '@element-plus/icons-vue'
import http, { apiErrorMessage } from '../api/http'
interface UserRow {
id: string; userName: string; displayName: string; staffNumber?: string
roles: string[]; isEnabled: boolean; lastLoginAt?: string
collegeId?: string; roles: string[]; isEnabled: boolean; lastLoginAt?: string
}
interface Role { name: string; description: string }
type DataScope = 'Self' | 'Class' | 'College' | 'All'
interface Role { name: string; description: string; dataScope: DataScope }
const users = ref<UserRow[]>([])
const roles = ref<Role[]>([])
const colleges = ref<any[]>([])
const loading = ref(false)
const dialogVisible = ref(false)
const roleDialogVisible = ref(false)
const editingUser = ref<UserRow | null>(null)
const editingRoles = ref<string[]>([])
const editingStaffNumber = ref('')
const editingCollegeId = ref<string>()
const keyword = ref('')
const form = reactive({
userName: '', displayName: '', password: '', staffNumber: '',
collegeId: undefined as string | undefined, roles: [] as string[],
})
const scopePriority: Record<DataScope, number> = {
Self: 0,
Class: 1,
College: 2,
All: 3,
}
const scopeNames: Record<DataScope, string> = {
Self: '本人数据',
Class: '所带班级',
College: '所属学院',
All: '全校数据',
}
const editingScope = computed(() => {
const values = roles.value
.filter((role) => editingRoles.value.includes(role.name))
.map((role) => role.dataScope)
const effective = values.reduce<DataScope>(
(current, value) =>
scopePriority[value] > scopePriority[current] ? value : current,
'Self',
)
return scopeNames[effective]
})
async function load() {
loading.value = true
@@ -63,6 +92,30 @@ async function setStatus(row: any) {
}
}
function openRoleEditor(row: any) {
editingUser.value = row
editingRoles.value = [...row.roles]
editingStaffNumber.value = row.staffNumber ?? ''
editingCollegeId.value = row.collegeId
roleDialogVisible.value = true
}
async function saveRoles() {
if (!editingUser.value) return
try {
await http.put(`/users/${editingUser.value.id}/roles`, {
staffNumber: editingStaffNumber.value,
collegeId: editingCollegeId.value,
roles: editingRoles.value,
})
ElMessage.success('角色与数据范围已更新')
roleDialogVisible.value = false
await load()
} catch (error) {
ElMessage.error(apiErrorMessage(error))
}
}
onMounted(load)
</script>
@@ -102,8 +155,11 @@ 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="100">
<el-table-column label="操作" width="175">
<template #default="{ row }">
<el-button link type="primary" :icon="EditPen" @click="openRoleEditor(row)">
调整角色
</el-button>
<el-button link :type="row.isEnabled ? 'danger' : 'primary'" @click="setStatus(row)">
{{ row.isEnabled ? '停用' : '启用' }}
</el-button>
@@ -138,5 +194,52 @@ onMounted(load)
<el-button type="primary" @click="create">创建账号</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>
<span>{{ editingUser.userName }} · {{ editingUser.staffNumber || '未关联工号/学号' }}</span>
</div>
<el-form label-position="top" class="entity-form">
<div class="form-grid">
<el-form-item label="工号/学号">
<el-input v-model="editingStaffNumber" />
</el-form-item>
<el-form-item label="所属学院">
<el-select v-model="editingCollegeId" clearable>
<el-option
v-for="item in colleges"
:key="item.id"
:label="item.name"
:value="item.id"
/>
</el-select>
</el-form-item>
</div>
<el-form-item label="角色" required>
<el-select v-model="editingRoles" multiple>
<el-option
v-for="role in roles"
:key="role.name"
:label="`${role.name} · ${role.description}`"
:value="role.name"
/>
</el-select>
</el-form-item>
<el-alert
:closable="false"
type="info"
show-icon
:title="`保存后有效范围:${editingScope}`"
description="多角色账号自动采用最高数据范围。教师或学生角色要求账号的工号/学号能匹配对应档案。"
/>
</el-form>
<template #footer>
<el-button @click="roleDialogVisible = false">取消</el-button>
<el-button type="primary" :disabled="!editingRoles.length" @click="saveRoles">
保存角色
</el-button>
</template>
</el-dialog>
</div>
</template>