Files
Academic-Affairs-System/web-react/src/layout/AppShell.tsx
T

204 lines
10 KiB
TypeScript

import { useEffect, useMemo, useState } from 'react'
import {
AppstoreOutlined,
BellOutlined,
ExportOutlined,
IdcardOutlined,
MenuFoldOutlined,
MenuUnfoldOutlined,
SafetyCertificateOutlined,
CalendarOutlined,
FileDoneOutlined,
ReadOutlined,
WarningOutlined,
AuditOutlined,
CheckSquareOutlined,
FormOutlined,
BookOutlined,
LineChartOutlined,
EnvironmentOutlined,
SwapOutlined,
FileProtectOutlined,
SafetyOutlined,
WalletOutlined,
ScheduleOutlined,
TrophyOutlined,
SolutionOutlined,
CommentOutlined,
RocketOutlined,
HeartOutlined,
SafetyCertificateOutlined as DegreeOutlined,
CheckCircleOutlined,
ExperimentOutlined,
FundProjectionScreenOutlined,
BankOutlined,
ApartmentOutlined,
TeamOutlined,
CarryOutOutlined,
SettingOutlined,
} from '@ant-design/icons'
import { Avatar, Badge, Button, Layout, Menu, Tag } from 'antd'
import { useQuery } from '@tanstack/react-query'
import { Outlet, useLocation, useNavigate } from 'react-router-dom'
import http from '../api/http'
import { branding } from '../config/branding'
import { useAuthStore } from '../stores/auth'
import BrandMark from '../components/BrandMark'
const { Header, Sider, Content } = Layout
export default function AppShell() {
const location = useLocation()
const navigate = useNavigate()
const user = useAuthStore((state) => state.user)
const logout = useAuthStore((state) => state.logout)
const refreshUser = useAuthStore((state) => state.refreshUser)
const [collapsed, setCollapsed] = useState(false)
const roles = user?.roles ?? []
const isStudentHome = roles.includes('Student') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role))
const isTeacherHome = roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor', 'Student'].includes(role))
const unreadQuery = useQuery({
queryKey: ['notification-unread-count'],
queryFn: async () => (await http.get<{ count: number }>('/notifications/unread-count')).data.count,
refetchInterval: 60_000,
})
useEffect(() => { void refreshUser().catch(() => undefined) }, [refreshUser])
const items = useMemo(() => [
{ key: '/dashboard', icon: <AppstoreOutlined />, label: isStudentHome ? '学习首页' : isTeacherHome ? '教学工作台' : '教务总览' },
{ key: '/account', icon: <IdcardOutlined />, label: '个人账户' },
{ key: '/notifications', icon: <BellOutlined />, label: '通知中心' },
{ key: '/courses', icon: <BookOutlined />, label: '课程库' },
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/my-timetable', icon: <CalendarOutlined />, label: '我的课表' }]
: []),
...((roles.includes('Student') || roles.includes('Teacher'))
? [{ key: '/exams', icon: <FileDoneOutlined />, label: roles.includes('Teacher') ? '监考安排' : '考试安排' }]
: []),
...(isStudentHome ? [
{ key: '/my-profile', icon: <AuditOutlined />, label: '个人档案' },
{ key: '/grades', icon: <ReadOutlined />, label: '我的成绩' },
{ key: '/my-attendance', icon: <CheckSquareOutlined />, label: '我的考勤' },
{ key: '/course-selections', icon: <FormOutlined />, label: '学生选课' },
{ key: '/my-curriculum', icon: <BookOutlined />, label: '培养方案' },
{ key: '/academic-overview', icon: <LineChartOutlined />, label: '学业总览' },
{ key: '/free-classrooms', icon: <EnvironmentOutlined />, label: '空闲教室' },
{ key: '/academic-calendar', icon: <ScheduleOutlined />, label: '学术校历' },
{ key: '/student-status-changes', icon: <SwapOutlined />, label: '学籍异动' },
{ key: '/official-documents', icon: <FileProtectOutlined />, label: '电子凭证' },
{ key: '/graduation-audits', icon: <SafetyOutlined />, label: '毕业审核' },
{ key: '/tuition', icon: <WalletOutlined />, label: '我的学费' },
{ key: '/other-exams', icon: <TrophyOutlined />, label: '其他考试' },
{ key: '/makeup-exams', icon: <SolutionOutlined />, label: '补考安排' },
{ key: '/evaluations', icon: <CommentOutlined />, label: '教学评价' },
{ key: '/academic-planning', icon: <RocketOutlined />, label: '学业规划' },
{ key: '/physical-fitness', icon: <HeartOutlined />, label: '体质健康' },
{ key: '/degree-awards', icon: <DegreeOutlined />, label: '学位授予' },
{ key: '/graduation-clearance', icon: <CheckCircleOutlined />, label: '毕业离校' },
{ key: '/experiments', icon: <ExperimentOutlined />, label: '我的实验' },
{ key: '/experiment-grades', icon: <FundProjectionScreenOutlined />, label: '实验成绩' },
{ key: '/campus-registrations', icon: <BankOutlined />, label: '校园报名' },
{ key: '/approvals', icon: <ApartmentOutlined />, label: '审批申请' },
{ key: '/warnings', icon: <WarningOutlined />, label: '学业预警' },
] : []),
...(roles.includes('Teacher') ? [
{ key: '/teacher-roster', icon: <TeamOutlined />, label: '选课名单' },
] : []),
...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [
{ key: '/teacher-attendance', icon: <CarryOutOutlined />, label: '教学点名' },
] : []),
...(roles.some((role) => ['Teacher', 'SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [
{ key: '/teaching-preferences', icon: <ReadOutlined />, label: roles.includes('Teacher') && !roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? '授课科目申报' : '授课资格审核' },
{ key: '/course-adjustments', icon: <SwapOutlined />, label: '调停课管理' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [
{ key: '/teaching-tasks', icon: <ScheduleOutlined />, label: '教学任务' },
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [{ key: '/schedules', icon: <CalendarOutlined />, label: '排课管理' }] : []),
{ key: '/curriculum', icon: <ReadOutlined />, label: '培养方案管理' },
{ key: '/course-equivalency-rules', icon: <SwapOutlined />, label: '等价课程规则' },
{ key: '/course-enrollments', icon: <BookOutlined />, label: '修读课程管理' },
{ key: '/campus-wallets', icon: <WalletOutlined />, label: '学生余额管理' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin'].includes(role)) ? [
{ key: '/base-data/organization', icon: <ApartmentOutlined />, label: '组织机构' },
{ key: '/base-data/terms', icon: <CalendarOutlined />, label: '学年学期' },
{ key: '/base-data/facilities', icon: <EnvironmentOutlined />, label: '教学场所' },
{ key: '/base-data/course-categories', icon: <SettingOutlined />, label: '课程分类' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Leader'].includes(role)) ? [
{ key: '/class-timetable', icon: <CalendarOutlined />, label: '课表查询中心' },
] : []),
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin', 'Counselor'].includes(role)) ? [
{ key: '/teachers', icon: <TeamOutlined />, label: '教师档案' },
{ key: '/students', icon: <IdcardOutlined />, label: '学生档案' },
] : []),
...(user?.roles.includes('SuperAdmin')
? [{ key: '/users', icon: <SafetyCertificateOutlined />, label: '用户与权限' }]
: []),
{ key: 'legacy', icon: <ExportOutlined />, label: '返回现有系统' },
], [isStudentHome, isTeacherHome, user?.roles])
const pageTitle = items.find((item) => location.pathname.startsWith(item.key))?.label ?? '迁移预览'
function signOut() {
logout()
navigate('/login', { replace: true })
}
return (
<Layout className="app-shell">
<Sider
className="app-shell__sider"
width={238}
collapsedWidth={72}
collapsed={collapsed}
breakpoint="lg"
onBreakpoint={setCollapsed}
>
<div className="shell-brand">
<BrandMark />
{!collapsed && <div><strong>{branding.shortName}</strong><small>REACT MIGRATION</small></div>}
</div>
<Menu
className="shell-menu"
theme="dark"
mode="inline"
selectedKeys={[items.find((item) => item.key.startsWith('/') && location.pathname.startsWith(item.key))?.key ?? '/dashboard']}
items={items}
onClick={({ key }) => {
if (key === 'legacy') {
window.location.assign(import.meta.env.VITE_LEGACY_APP_URL ?? 'http://localhost:5255')
return
}
navigate(key)
}}
/>
</Sider>
<Layout>
<Header className="shell-header">
<div style={{ display: 'flex', alignItems: 'center', gap: 14, minWidth: 0 }}>
<Button
type="text"
aria-label={collapsed ? '展开导航' : '收起导航'}
icon={collapsed ? <MenuUnfoldOutlined /> : <MenuFoldOutlined />}
onClick={() => setCollapsed((value) => !value)}
/>
<div className="shell-context"><span>当前工作区</span><strong>{pageTitle}</strong></div>
</div>
<div className="shell-user">
<Badge count={unreadQuery.data ?? 0} size="small" overflowCount={99}>
<Button type="text" aria-label="通知中心" icon={<BellOutlined />} onClick={() => navigate('/notifications')} />
</Badge>
<Tag color="geekblue">预览</Tag>
<div className="shell-user__copy"><strong>{user?.displayName ?? '用户'}</strong><small>{user?.roles.join(' · ') || '正在读取权限'}</small></div>
<Avatar style={{ background: '#54cfbd', color: '#163f43' }}>{user?.displayName?.slice(0, 1) ?? '明'}</Avatar>
<Button type="text" onClick={signOut}>退出</Button>
</div>
</Header>
<Content className="shell-content"><Outlet /></Content>
</Layout>
</Layout>
)
}