Files
Academic-Affairs-System/web/src/layouts/AdminLayout.vue
T
2026-08-08 17:13:01 +08:00

424 lines
14 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, ref } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Bell, Operation } from '@element-plus/icons-vue'
import http from '../api/http'
import { useAuthStore } from '../stores/auth'
const unreadNotifCount = ref(0)
async function pollUnread() {
try {
const { data } = await http.get('/notifications/unread-count')
unreadNotifCount.value = data.count
} catch (_) { /* ignore */ }
}
interface NavigationItem {
path: string
label: string
}
interface NavigationGroup {
key: string
label: string
direct?: boolean
items: NavigationItem[]
}
const route = useRoute()
const router = useRouter()
const auth = useAuthStore()
const mobileMenu = ref(false)
const roles = computed(() => auth.user?.roles ?? [])
const isStudent = computed(() => roles.value.includes('Student'))
const isTeacher = computed(() => roles.value.includes('Teacher'))
const isTeachingAdmin = computed(() =>
roles.value.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)),
)
const isTimetableManager = computed(() =>
roles.value.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role),
),
)
const isStatisticsViewer = computed(() =>
roles.value.some((role) =>
['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role),
),
)
const canUseExperiments = computed(() =>
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student']),
)
function hasAnyRole(allowedRoles: string[]) {
return roles.value.some((role) => allowedRoles.includes(role))
}
function whenVisible(visible: boolean, item: NavigationItem) {
return visible ? [item] : []
}
const navigationGroups = computed<NavigationGroup[]>(() => [
{
key: 'overview',
label: '教务总览',
direct: true,
items: [{ path: '/dashboard', label: '教务总览' }],
},
...(isStatisticsViewer.value
? [{
key: 'statistics',
label: '统计报表',
direct: true,
items: [{ path: '/statistics', label: '统计报表' }],
} as NavigationGroup]
: []),
...(canUseExperiments.value
? [{
key: 'experiments',
label: isStudent.value ? '我的实验' : '实验管理',
items: [
{
path: '/experiments',
label: isStudent.value ? '实验安排' : '项目与场次',
},
{
path: '/experiment-grades',
label: '实验成绩',
},
],
} as NavigationGroup]
: []),
{
key: 'organization',
label: '组织与权限',
items: [
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
{ 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: '/teachers', label: '教师档案' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor']),
{ path: '/students', label: '学生档案' },
),
...whenVisible(auth.isSuperAdmin, { path: '/users', label: '用户与权限' }),
...whenVisible(auth.isSuperAdmin, { path: '/operations', label: '运维与审计' }),
],
},
{
key: 'teaching-construction',
label: '教学建设',
items: [
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
{ path: '/base-data/course-categories', label: '课程分类' },
),
{ path: '/courses', label: '课程库' },
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/teaching-preferences',
label: isTeacher.value ? '授课科目申报' : '授课资格审核',
},
),
...whenVisible(isTeachingAdmin.value, { path: '/curriculum', label: '培养方案' }),
...whenVisible(isStudent.value, { path: '/my-curriculum', label: '我的培养方案' }),
...whenVisible(isTeachingAdmin.value, { path: '/teaching-tasks', label: '教学任务' }),
],
},
{
key: 'teaching-operation',
label: '教学运行',
items: [
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin']),
{ path: '/schedules', label: '排课与课表' },
),
...whenVisible(isStudent.value || isTeacher.value, { path: '/my-timetable', label: isTeacher.value ? '我的授课课表' : '我的课表' }),
...whenVisible(isStudent.value, { path: '/free-classrooms', label: '空闲教室' }),
{
path: '/classroom-reservations',
label: hasAnyRole(['CollegeAdmin']) ? '教室借用审核' : '教室借用',
},
...whenVisible(isTeacher.value || isTeachingAdmin.value || hasAnyRole(['Counselor']), { path: '/teacher-attendance', label: '教学点名' }),
...whenVisible(isStudent.value, { path: '/my-attendance', label: '我的考勤' }),
{
path: '/evaluations',
label: isStudent.value ? '学生评教' : isTeacher.value ? '评教结果' : '教学评价',
},
...whenVisible(isTeacher.value, { path: '/teacher-roster', label: '选课名单' }),
...whenVisible(!isStudent.value, {
path: '/class-timetable',
label: isTimetableManager.value ? '课表查询中心' : '班级课表查询',
}),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{
path: '/course-selections',
label: isTeachingAdmin.value ? '选课管理' : '学生选课',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher', 'Student', 'Counselor']),
{ path: '/grades', label: isStudent.value ? '学业成绩' : isTeacher.value ? '成绩录入' : '成绩管理' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Student']),
{ path: '/other-exams', label: '其他考试成绩' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']),
{
path: '/exams',
label: isStudent.value ? '我的考试' : isTeacher.value ? '我的监考' : '考试管理',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Teacher', 'Student']),
{
path: '/makeup-exams',
label: isStudent.value ? '我的补考' : isTeacher.value ? '补考监考' : '补考安排',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Teacher']),
{
path: '/course-adjustments',
label: isTeacher.value ? '我的调停课' : '调停课审核',
},
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Teacher', 'Student']),
{ path: '/approvals', label: '审批中心' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'Counselor', 'Student']),
{ path: '/warnings', label: '学业预警' },
),
],
},
{
key: 'student-status',
label: '学籍管理',
items: [
...whenVisible(
isStudent.value,
{ path: '/my-profile', label: '个人信息' },
),
...whenVisible(
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 ? '电子成绩单与证明' : '官方凭证签发' },
),
],
},
{
key: 'graduation',
label: '毕业管理',
items: [
...whenVisible(
isStudent.value,
{ path: '/academic-planning', label: '学业规划与毕业模拟' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/graduation-audits', label: isStudent.value ? '毕业资格' : '毕业审核' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Student']),
{ path: '/degree-awards', label: isStudent.value ? '学位结果' : '学位授予' },
),
...whenVisible(
hasAnyRole(['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student']),
{ path: '/graduation-clearance', label: isStudent.value ? '毕业离校' : '离校办理' },
),
],
},
].filter((group) => group.items.length > 0))
const activeGroupKey = computed(
() => navigationGroups.value.find((group) =>
group.items.some((item) => item.path === route.path),
)?.key ?? '',
)
const workspaceLabel = computed(() => {
if (roles.value.includes('SuperAdmin')) return '系统全域管理'
if (roles.value.includes('AcademicAdmin')) return '校级教务管理'
if (roles.value.includes('CollegeAdmin')) return '学院教务管理'
if (roles.value.includes('Counselor')) return '辅导员班级工作'
if (roles.value.includes('Teacher')) return '教师教学工作'
if (roles.value.includes('Student')) return '学生学业服务'
return '教务工作台'
})
const pageTitle = computed(() => {
if (route.path === '/account') return '个人账户'
const matchedItem = navigationGroups.value
.flatMap((group) => group.items)
.find((item) => item.path === route.path)
return matchedItem?.label ?? '教务管理'
})
function signOut() {
auth.logout()
router.push('/login')
}
onMounted(() => {
auth.refresh().catch(() => undefined)
pollUnread()
setInterval(pollUnread, 60000)
})
</script>
<template>
<div class="shell">
<header class="app-header">
<div class="topbar">
<button
class="menu-toggle mobile-only"
type="button"
aria-label="打开导航菜单"
@click="mobileMenu = true"
>
<el-icon><Operation /></el-icon>
</button>
<div class="brand top-brand">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div>
<strong>明序教务</strong>
<small>ACADEMIC OFFICE</small>
</div>
</div>
<div class="workspace-context">
<span>{{ workspaceLabel }}</span>
<b>{{ pageTitle }}</b>
</div>
<div class="user-block">
<el-badge :value="unreadNotifCount" :hidden="!unreadNotifCount" :max="99">
<el-button :icon="Bell" circle text @click="router.push('/notifications')" title="消息中心" />
</el-badge>
<div class="avatar">{{ auth.user?.displayName?.slice(0, 1) ?? '管' }}</div>
<div class="user-copy">
<b>{{ auth.user?.displayName ?? '系统管理员' }}</b>
<span>{{ auth.user?.roles?.[0] ?? '教务人员' }}</span>
</div>
<el-button text @click="router.push('/account')">个人账户</el-button>
<el-button text @click="signOut">退出</el-button>
</div>
</div>
<nav class="desktop-navigation desktop-only" aria-label="主导航">
<el-menu
router
mode="horizontal"
menu-trigger="hover"
unique-opened
:default-active="route.path"
:ellipsis="false"
class="horizontal-menu"
>
<template v-for="group in navigationGroups" :key="group.key">
<el-menu-item v-if="group.direct" :index="group.items[0].path">
{{ group.label }}
</el-menu-item>
<el-sub-menu
v-else
:index="group.key"
:show-timeout="80"
:hide-timeout="180"
>
<template #title>{{ group.label }}</template>
<el-menu-item
v-for="item in group.items"
:key="item.path"
:index="item.path"
>
{{ item.label }}
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</nav>
</header>
<aside class="mobile-navigation" :class="{ 'is-mobile-open': mobileMenu }">
<div class="mobile-navigation-head">
<div class="brand">
<div class="brand-mark" aria-hidden="true">
<span v-for="index in 9" :key="index" />
</div>
<div>
<strong>明序教务</strong>
<small>ACADEMIC OFFICE</small>
</div>
</div>
<button type="button" aria-label="关闭导航菜单" @click="mobileMenu = false">×</button>
</div>
<div class="term-stamp">
<span>当前工作区</span>
<b>{{ workspaceLabel }}</b>
</div>
<el-menu
router
:default-active="route.path"
:default-openeds="activeGroupKey ? [activeGroupKey] : []"
unique-opened
class="mobile-nav-menu"
@select="mobileMenu = false"
>
<template v-for="group in navigationGroups" :key="group.key">
<el-menu-item v-if="group.direct" :index="group.items[0].path">
{{ group.label }}
</el-menu-item>
<el-sub-menu v-else :index="group.key">
<template #title>{{ group.label }}</template>
<el-menu-item
v-for="item in group.items"
:key="item.path"
:index="item.path"
>
{{ item.label }}
</el-menu-item>
</el-sub-menu>
</template>
</el-menu>
</aside>
<div v-if="mobileMenu" class="mobile-mask" @click="mobileMenu = false" />
<main class="main-area">
<section class="content">
<RouterView />
</section>
</main>
</div>
</template>