219 lines
8.2 KiB
TypeScript
219 lines
8.2 KiB
TypeScript
import { useState, useEffect, lazy, Suspense } from 'react'
|
|
import { Routes, Route, useNavigate, useLocation } from 'react-router-dom'
|
|
import { LoadingBar } from './components/ui'
|
|
|
|
const Dashboard = lazy(() => import('./pages/Dashboard').then(m => ({ default: m.Dashboard })))
|
|
const WorkList = lazy(() => import('./pages/WorkList').then(m => ({ default: m.WorkList })))
|
|
const ReviewCenter = lazy(() => import('./pages/ReviewCenter').then(m => ({ default: m.ReviewCenter })))
|
|
const PeoplePage = lazy(() => import('./pages/People').then(m => ({ default: m.PeoplePage })))
|
|
const StationsPage = lazy(() => import('./pages/Stations').then(m => ({ default: m.StationsPage })))
|
|
const ArchivePage = lazy(() => import('./pages/Archive').then(m => ({ default: m.ArchivePage })))
|
|
const NoticesPage = lazy(() => import('./pages/Notices').then(m => ({ default: m.NoticesPage })))
|
|
const SettingsPage = lazy(() => import('./pages/Settings').then(m => ({ default: m.SettingsPage })))
|
|
const RulesPage = lazy(() => import('./pages/Rules').then(m => ({ default: m.RulesPage })))
|
|
const ScoresPage = lazy(() => import('./pages/Scores').then(m => ({ default: m.ScoresPage })))
|
|
const LeaderboardPage = lazy(() => import('./pages/Leaderboard').then(m => ({ default: m.LeaderboardPage })))
|
|
const AppealsPage = lazy(() => import('./pages/Appeals').then(m => ({ default: m.AppealsPage })))
|
|
const SystemLogsPage = lazy(() => import('./pages/SystemLogs').then(m => ({ default: m.SystemLogsPage })))
|
|
const StationMapPage = lazy(() => import('./pages/StationMap').then(m => ({ default: m.StationMapPage })))
|
|
const CockpitPage = lazy(() => import('./pages/Cockpit').then(m => ({ default: m.CockpitPage })))
|
|
const ProfilePage = lazy(() => import('./pages/Profile').then(m => ({ default: m.ProfilePage })))
|
|
const LoginPage = lazy(() => import('./pages/Login').then(m => ({ default: m.LoginPage })))
|
|
import { Sidebar, TopBar } from './components/layout'
|
|
const CreateRecordModal = lazy(() => import('./modals/CreateRecordModal').then(m => ({ default: m.CreateRecordModal })))
|
|
const RecordDrawer = lazy(() => import('./modals/RecordDrawer').then(m => ({ default: m.RecordDrawer })))
|
|
import { RoleProvider, ToastProvider, useRole, useToast } from './context'
|
|
import { api } from './api'
|
|
import { navLabels, pathMap, pathToPage } from './routes'
|
|
import type { WorkRecord } from './types'
|
|
import type { PageKey } from './routes'
|
|
|
|
function AppShell() {
|
|
const { role, isAuthenticated } = useRole()
|
|
const navigate = useNavigate()
|
|
const location = useLocation()
|
|
const { showToast: showToastCtx } = useToast()
|
|
const [sidebarOpen, setSidebarOpen] = useState(false)
|
|
const [createOpen, setCreateOpen] = useState(false)
|
|
const [selected, setSelected] = useState<WorkRecord | null>(null)
|
|
const [records, setRecords] = useState<WorkRecord[]>([])
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadError, setLoadError] = useState('')
|
|
const [toast, setToast] = useState('')
|
|
|
|
const page: PageKey = pathToPage(location.pathname)
|
|
|
|
useEffect(() => {
|
|
let active = true
|
|
setLoading(true)
|
|
setLoadError('')
|
|
api.records(role)
|
|
.then(data => { if (active) setRecords(data) })
|
|
.catch(err => { if (active) setLoadError(err.message) })
|
|
.finally(() => { if (active) setLoading(false) })
|
|
return () => { active = false }
|
|
}, [role])
|
|
|
|
// 角色受限页面自动跳转
|
|
useEffect(() => {
|
|
const restricted: Record<string, PageKey[]> = {
|
|
reporter: ['review', 'people', 'stations', 'stationmap', 'cockpit'],
|
|
station: ['stations', 'stationmap', 'cockpit'],
|
|
}
|
|
if (restricted[role]?.includes(page)) {
|
|
navigate('/')
|
|
}
|
|
}, [role, page, navigate])
|
|
|
|
const navigateTo = (p: PageKey) => {
|
|
navigate(pathMap[p])
|
|
setSidebarOpen(false)
|
|
}
|
|
|
|
const pendingCount = records.filter(r =>
|
|
role === 'station' ? r.status === 'station_review' : r.status === 'headquarters_review'
|
|
).length
|
|
|
|
const handleCreate = async (record: WorkRecord) => {
|
|
setRecords(items => [record, ...items])
|
|
setCreateOpen(false)
|
|
showToast('工作记录已提交,进入分站审核')
|
|
}
|
|
|
|
const handleUpdate = async (id: string, patch: Partial<WorkRecord>) => {
|
|
try {
|
|
const updated = await api.reviewRecord(role, id, {
|
|
decision: patch.status === 'returned' ? 'return' : 'pass',
|
|
score: patch.score ?? 0,
|
|
note: patch.reviewNote ?? '',
|
|
})
|
|
setRecords(items => items.map(item => item.id === id ? updated : item))
|
|
setSelected(null)
|
|
return true
|
|
} catch (error) {
|
|
showToast(error instanceof Error ? error.message : '审核操作失败')
|
|
return false
|
|
}
|
|
}
|
|
|
|
const showToast = (msg: string) => {
|
|
setToast(msg)
|
|
showToastCtx?.(msg)
|
|
setTimeout(() => setToast(''), 2400)
|
|
}
|
|
|
|
// 未认证时显示登录页
|
|
if (!isAuthenticated && !localStorage.getItem('auth_token')) {
|
|
// 演示模式:允许直接进入
|
|
const isDemoMode = !localStorage.getItem('auth_token') && localStorage.getItem('auth_role')
|
|
if (!isDemoMode) {
|
|
return (
|
|
<Suspense fallback={<LoadingBar />}>
|
|
<LoginPage />
|
|
</Suspense>
|
|
)
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div className="app-shell">
|
|
<Sidebar
|
|
page={page}
|
|
onNavigate={navigateTo}
|
|
open={sidebarOpen}
|
|
onClose={() => setSidebarOpen(false)}
|
|
badge={pendingCount}
|
|
/>
|
|
|
|
<main className="main">
|
|
<TopBar
|
|
pageTitle={navLabels[page]}
|
|
onMenuToggle={() => setSidebarOpen(true)}
|
|
/>
|
|
|
|
<section className="content">
|
|
{loadError && (
|
|
<div className="error-banner">
|
|
{loadError}
|
|
<button onClick={() => window.location.reload()}>重新加载</button>
|
|
</div>
|
|
)}
|
|
{loading && <LoadingBar />}
|
|
|
|
<Suspense fallback={<LoadingBar />}>
|
|
<Routes>
|
|
<Route path="/" element={
|
|
<Dashboard
|
|
records={records}
|
|
onNavigate={navigateTo}
|
|
onCreate={() => setCreateOpen(true)}
|
|
onSelect={setSelected}
|
|
/>
|
|
} />
|
|
<Route path="/work" element={
|
|
<WorkList
|
|
records={records}
|
|
onCreate={() => setCreateOpen(true)}
|
|
onSelect={setSelected}
|
|
/>
|
|
} />
|
|
<Route path="/review" element={
|
|
<ReviewCenter records={records} onSelect={setSelected} />
|
|
} />
|
|
<Route path="/people" element={<PeoplePage />} />
|
|
<Route path="/stations" element={<StationsPage />} />
|
|
<Route path="/archive" element={<ArchivePage records={records} onSelect={setSelected} />} />
|
|
<Route path="/notices" element={<NoticesPage />} />
|
|
<Route path="/settings" element={<SettingsPage />} />
|
|
<Route path="/rules" element={<RulesPage />} />
|
|
<Route path="/scores" element={<ScoresPage />} />
|
|
<Route path="/leaderboard" element={<LeaderboardPage />} />
|
|
<Route path="/appeals" element={<AppealsPage />} />
|
|
<Route path="/logs" element={<SystemLogsPage />} />
|
|
<Route path="/stationmap" element={<StationMapPage />} />
|
|
<Route path="/cockpit" element={<CockpitPage records={records} />} />
|
|
<Route path="/profile" element={<ProfilePage records={records} />} />
|
|
</Routes>
|
|
</Suspense>
|
|
</section>
|
|
</main>
|
|
|
|
{createOpen && (
|
|
<Suspense fallback={null}>
|
|
<CreateRecordModal
|
|
onClose={() => setCreateOpen(false)}
|
|
onSubmit={handleCreate}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{selected && (
|
|
<Suspense fallback={null}>
|
|
<RecordDrawer
|
|
record={selected}
|
|
role={role}
|
|
onClose={() => setSelected(null)}
|
|
onUpdate={handleUpdate}
|
|
onNotify={showToast}
|
|
/>
|
|
</Suspense>
|
|
)}
|
|
|
|
{toast && <div className="toast">{toast}</div>}
|
|
|
|
{sidebarOpen && (
|
|
<div className="scrim" onClick={() => setSidebarOpen(false)} />
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
export default function App() {
|
|
return (
|
|
<RoleProvider>
|
|
<ToastProvider>
|
|
<AppShell />
|
|
</ToastProvider>
|
|
</RoleProvider>
|
|
)
|
|
} |