193 lines
9.1 KiB
TypeScript
193 lines
9.1 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)) ? '授课科目申报' : '授课资格审核' },
|
|
] : []),
|
|
...(roles.some((role) => ['SuperAdmin', 'AcademicAdmin', 'CollegeAdmin'].includes(role)) ? [
|
|
{ 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: '课程分类' },
|
|
] : []),
|
|
...(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>
|
|
)
|
|
}
|