Add exam score import and management workflows
This commit is contained in:
1 parent
71ee21b063
commit
24d2a96144
26 files changed
+3865
No files matched your search
@@ -0,0 +1,91 @@
|
||||
import axios from 'axios'
|
||||
import {
|
||||
authStorageKeys,
|
||||
clearAuthSession,
|
||||
markActivity,
|
||||
refreshAuthSession,
|
||||
refreshIfNeeded,
|
||||
} from '../auth/session'
|
||||
|
||||
declare module 'axios' {
|
||||
export interface AxiosRequestConfig {
|
||||
skipAuthSession?: boolean
|
||||
}
|
||||
}
|
||||
|
||||
const http = axios.create({
|
||||
baseURL: import.meta.env.VITE_API_BASE_URL ?? '/api',
|
||||
timeout: 15000,
|
||||
})
|
||||
|
||||
function isAuthenticationRequest(url?: string) {
|
||||
return url?.startsWith('/auth/login') ||
|
||||
url?.endsWith('/auth/refresh') ||
|
||||
url?.endsWith('/auth/logout') ||
|
||||
url?.endsWith('/auth/sso/exchange') ||
|
||||
url?.endsWith('/auth/sso/bind')
|
||||
}
|
||||
|
||||
function goLogin(returnUrl: string) {
|
||||
const target = `/next/login?redirect=${encodeURIComponent(returnUrl.replace(/^\/next/, '') || '/dashboard')}`
|
||||
if (location.pathname + location.search !== target) location.assign(target)
|
||||
}
|
||||
|
||||
http.interceptors.request.use(async (config) => {
|
||||
if (!isAuthenticationRequest(config.url) && !config.skipAuthSession) {
|
||||
const activeToken = await refreshIfNeeded(true)
|
||||
if (activeToken) markActivity()
|
||||
}
|
||||
const token = localStorage.getItem(authStorageKeys.token)
|
||||
if (token && !config.skipAuthSession) config.headers.Authorization = `Bearer ${token}`
|
||||
return config
|
||||
})
|
||||
|
||||
http.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const authenticationRequest = isAuthenticationRequest(error.config?.url)
|
||||
const retryableConfig = error.config as (typeof error.config & { _jiaowuRetried?: boolean }) | undefined
|
||||
if (error.response?.status === 401 && !authenticationRequest && !error.config?.skipAuthSession &&
|
||||
!retryableConfig?._jiaowuRetried) {
|
||||
const token = await refreshAuthSession()
|
||||
if (token && retryableConfig) {
|
||||
retryableConfig._jiaowuRetried = true
|
||||
retryableConfig.headers.Authorization = `Bearer ${token}`
|
||||
return http.request(retryableConfig)
|
||||
}
|
||||
}
|
||||
if (error.response?.status === 401 && !authenticationRequest && !error.config?.skipAuthSession) {
|
||||
clearAuthSession()
|
||||
goLogin(location.pathname + location.search + location.hash)
|
||||
}
|
||||
return Promise.reject(error)
|
||||
},
|
||||
)
|
||||
|
||||
export function apiErrorMessage(error: unknown) {
|
||||
if (!axios.isAxiosError(error)) return '操作失败,请稍后重试。'
|
||||
const status = error.response?.status
|
||||
const data = error.response?.data
|
||||
const message = typeof data?.message === 'string' ? data.message.trim() : ''
|
||||
if (message) return message
|
||||
if (data?.errors && typeof data.errors === 'object') {
|
||||
const validationMessage = Object.values(data.errors)
|
||||
.flat()
|
||||
.map((item) => String(item).trim())
|
||||
.filter(Boolean)
|
||||
.join(';')
|
||||
if (validationMessage) return validationMessage
|
||||
}
|
||||
const detail = typeof data?.detail === 'string' ? data.detail.trim() : ''
|
||||
if (detail) return detail
|
||||
const title = typeof data?.title === 'string' ? data.title.trim() : ''
|
||||
if (title) return title
|
||||
if (status === 403) return '您没有权限执行此操作,请联系管理员。'
|
||||
if (status === 404) return '请求的资源不存在。'
|
||||
if (status === 401) return '登录已过期,请重新登录。'
|
||||
if (status && status >= 500) return '服务器繁忙,请稍后重试。'
|
||||
return '操作失败,请检查网络连接。'
|
||||
}
|
||||
|
||||
export default http
|
||||
@@ -0,0 +1,87 @@
|
||||
import { lazy, Suspense, useEffect } from 'react'
|
||||
import { ConfigProvider, Skeleton } from 'antd'
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Navigate, Route, Routes, useLocation, useNavigate } from 'react-router-dom'
|
||||
import zhCN from 'antd/locale/zh_CN'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
|
||||
const LoginPage = lazy(() => import('../pages/LoginPage'))
|
||||
const DashboardPage = lazy(() => import('../pages/DashboardPage'))
|
||||
const MigrationPlaceholderPage = lazy(() => import('../pages/MigrationPlaceholderPage'))
|
||||
const AppShell = lazy(() => import('../layout/AppShell'))
|
||||
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: {
|
||||
staleTime: 30_000,
|
||||
retry: 1,
|
||||
refetchOnWindowFocus: false,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
function ProtectedRoute() {
|
||||
const token = useAuthStore((state) => state.token)
|
||||
const location = useLocation()
|
||||
if (!token) return <Navigate to={`/login?redirect=${encodeURIComponent(location.pathname + location.search)}`} replace />
|
||||
return <AppShell />
|
||||
}
|
||||
|
||||
function SessionExpiryBridge() {
|
||||
const navigate = useNavigate()
|
||||
useEffect(() => {
|
||||
const onExpired = () => navigate(`/login?redirect=${encodeURIComponent(location.pathname + location.search + location.hash)}`, { replace: true })
|
||||
window.addEventListener('mingxu-session-expired', onExpired)
|
||||
return () => window.removeEventListener('mingxu-session-expired', onExpired)
|
||||
}, [navigate])
|
||||
return null
|
||||
}
|
||||
|
||||
function AppRoutes() {
|
||||
return (
|
||||
<Suspense fallback={<div style={{ maxWidth: 1180, margin: '64px auto', padding: 24 }}><Skeleton active paragraph={{ rows: 8 }} /></div>}>
|
||||
<SessionExpiryBridge />
|
||||
<Routes>
|
||||
<Route path="/login" element={<LoginPage />} />
|
||||
<Route element={<ProtectedRoute />}>
|
||||
<Route path="/dashboard" element={<DashboardPage />} />
|
||||
<Route path="/account" element={<MigrationPlaceholderPage />} />
|
||||
<Route path="/users" element={<MigrationPlaceholderPage />} />
|
||||
<Route path="/" element={<Navigate to="/dashboard" replace />} />
|
||||
</Route>
|
||||
<Route path="*" element={<Navigate to="/dashboard" replace />} />
|
||||
</Routes>
|
||||
</Suspense>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
return (
|
||||
<ConfigProvider
|
||||
locale={zhCN}
|
||||
theme={{
|
||||
token: {
|
||||
colorPrimary: '#284583',
|
||||
colorSuccess: '#087f73',
|
||||
colorWarning: '#b8781d',
|
||||
colorError: '#b42318',
|
||||
colorInfo: '#476282',
|
||||
colorText: '#182033',
|
||||
colorBgLayout: '#f2f4f7',
|
||||
borderRadius: 7,
|
||||
fontFamily: '"Microsoft YaHei", "PingFang SC", "Noto Sans CJK SC", sans-serif',
|
||||
},
|
||||
components: {
|
||||
Button: { controlHeightLG: 44, fontWeight: 650 },
|
||||
Input: { controlHeightLG: 44 },
|
||||
Layout: { headerBg: '#ffffff', siderBg: '#162858' },
|
||||
Menu: { darkItemBg: '#162858', darkItemSelectedBg: '#284583', darkItemSelectedColor: '#ffffff' },
|
||||
},
|
||||
}}
|
||||
>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<BrowserRouter basename="/next"><AppRoutes /></BrowserRouter>
|
||||
</QueryClientProvider>
|
||||
</ConfigProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import axios from 'axios'
|
||||
import { Capacitor } from '@capacitor/core'
|
||||
|
||||
const TOKEN_KEY = 'jiaowu_token'
|
||||
const REFRESH_TOKEN_KEY = 'jiaowu_refresh_token'
|
||||
const ACCESS_EXPIRES_KEY = 'jiaowu_access_expires_at'
|
||||
const SESSION_EXPIRES_KEY = 'jiaowu_session_expires_at'
|
||||
const USER_KEY = 'jiaowu_user'
|
||||
const LAST_ACTIVITY_KEY = 'jiaowu_last_activity_at'
|
||||
const WEB_IDLE_MILLISECONDS = 30 * 60 * 1000
|
||||
const REFRESH_AHEAD_MILLISECONDS = 60 * 1000
|
||||
const WEB_SLIDING_TOUCH_MILLISECONDS = 60 * 1000
|
||||
|
||||
export interface AuthSessionPayload {
|
||||
token: string
|
||||
accessTokenExpiresAt: string
|
||||
refreshToken: string
|
||||
sessionExpiresAt: string
|
||||
user: unknown
|
||||
}
|
||||
|
||||
export const isNativeApp = () => Capacitor.isNativePlatform()
|
||||
|
||||
export function saveAuthSession(payload: AuthSessionPayload, recordActivity = true) {
|
||||
localStorage.setItem(TOKEN_KEY, payload.token)
|
||||
localStorage.setItem(REFRESH_TOKEN_KEY, payload.refreshToken)
|
||||
localStorage.setItem(ACCESS_EXPIRES_KEY, payload.accessTokenExpiresAt)
|
||||
localStorage.setItem(SESSION_EXPIRES_KEY, payload.sessionExpiresAt)
|
||||
localStorage.setItem(USER_KEY, JSON.stringify(payload.user))
|
||||
if (recordActivity) markActivity()
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
}
|
||||
|
||||
export function clearAuthSession(notifyExpired = false) {
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(REFRESH_TOKEN_KEY)
|
||||
localStorage.removeItem(ACCESS_EXPIRES_KEY)
|
||||
localStorage.removeItem(SESSION_EXPIRES_KEY)
|
||||
localStorage.removeItem(USER_KEY)
|
||||
localStorage.removeItem(LAST_ACTIVITY_KEY)
|
||||
window.dispatchEvent(new Event('mingxu-auth-changed'))
|
||||
if (notifyExpired) window.dispatchEvent(new Event('mingxu-session-expired'))
|
||||
}
|
||||
|
||||
export function markActivity() {
|
||||
localStorage.setItem(LAST_ACTIVITY_KEY, String(Date.now()))
|
||||
}
|
||||
|
||||
export function hasExceededWebIdleTimeout(now = Date.now()) {
|
||||
if (isNativeApp()) return false
|
||||
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||
return lastActivity > 0 && now - lastActivity >= WEB_IDLE_MILLISECONDS
|
||||
}
|
||||
|
||||
export function hasLocallyExpired(now = Date.now()) {
|
||||
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||
return hasExceededWebIdleTimeout(now) ||
|
||||
(Number.isFinite(sessionExpiresAt) && sessionExpiresAt <= now)
|
||||
}
|
||||
|
||||
let refreshPromise: Promise<string | null> | null = null
|
||||
|
||||
export function refreshAuthSession(): Promise<string | null> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
const refreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
if (!refreshToken || hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return Promise.resolve(null)
|
||||
}
|
||||
|
||||
refreshPromise = axios.post<AuthSessionPayload>(
|
||||
`${import.meta.env.VITE_API_BASE_URL ?? '/api'}/auth/refresh`,
|
||||
{ refreshToken },
|
||||
{ timeout: 15000 },
|
||||
).then(({ data }) => {
|
||||
saveAuthSession(data, false)
|
||||
return data.token
|
||||
}).catch((error: unknown) => {
|
||||
const currentRefreshToken = localStorage.getItem(REFRESH_TOKEN_KEY)
|
||||
if (currentRefreshToken && currentRefreshToken !== refreshToken) {
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
if (axios.isAxiosError(error) && error.response && [400, 401, 403].includes(error.response.status)) {
|
||||
clearAuthSession(true)
|
||||
return null
|
||||
}
|
||||
throw error
|
||||
}).finally(() => {
|
||||
refreshPromise = null
|
||||
})
|
||||
return refreshPromise
|
||||
}
|
||||
|
||||
export async function refreshIfNeeded(isCurrentRequestActivity = false) {
|
||||
const token = localStorage.getItem(TOKEN_KEY)
|
||||
if (!token) return null
|
||||
if (hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return null
|
||||
}
|
||||
const expiresAt = Date.parse(localStorage.getItem(ACCESS_EXPIRES_KEY) ?? '')
|
||||
const sessionExpiresAt = Date.parse(localStorage.getItem(SESSION_EXPIRES_KEY) ?? '')
|
||||
const lastActivity = Number(localStorage.getItem(LAST_ACTIVITY_KEY) ?? 0)
|
||||
const now = Date.now()
|
||||
const hasRecentWebActivity = !isNativeApp() &&
|
||||
(isCurrentRequestActivity || now - lastActivity <= WEB_SLIDING_TOUCH_MILLISECONDS)
|
||||
const needsSlidingTouch = hasRecentWebActivity && Number.isFinite(sessionExpiresAt) &&
|
||||
sessionExpiresAt - now <= WEB_IDLE_MILLISECONDS - WEB_SLIDING_TOUCH_MILLISECONDS
|
||||
if (!Number.isFinite(expiresAt) || expiresAt - now <= REFRESH_AHEAD_MILLISECONDS || needsSlidingTouch) {
|
||||
return refreshAuthSession()
|
||||
}
|
||||
return token
|
||||
}
|
||||
|
||||
let initialized = false
|
||||
|
||||
export function initializeAuthSession() {
|
||||
if (initialized) return
|
||||
initialized = true
|
||||
if (localStorage.getItem(TOKEN_KEY) && hasLocallyExpired()) clearAuthSession(true)
|
||||
|
||||
let lastActivityWrite = 0
|
||||
const recordActivity = () => {
|
||||
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||
const now = Date.now()
|
||||
if (now - lastActivityWrite < 5000) return
|
||||
lastActivityWrite = now
|
||||
markActivity()
|
||||
}
|
||||
const activityEvents: Array<keyof WindowEventMap> = ['pointerdown', 'keydown', 'touchstart', 'scroll']
|
||||
activityEvents.forEach((event) => window.addEventListener(event, recordActivity, { passive: true }))
|
||||
|
||||
window.setInterval(() => {
|
||||
if (!localStorage.getItem(TOKEN_KEY)) return
|
||||
if (hasLocallyExpired()) {
|
||||
clearAuthSession(true)
|
||||
return
|
||||
}
|
||||
if (document.visibilityState === 'visible') void refreshIfNeeded().catch(() => undefined)
|
||||
}, 30000)
|
||||
document.addEventListener('visibilitychange', () => {
|
||||
if (document.visibilityState === 'visible') void refreshIfNeeded(true).catch(() => undefined)
|
||||
})
|
||||
window.addEventListener('online', () => void refreshIfNeeded(true).catch(() => undefined))
|
||||
}
|
||||
|
||||
export const authStorageKeys = {
|
||||
token: TOKEN_KEY,
|
||||
refreshToken: REFRESH_TOKEN_KEY,
|
||||
user: USER_KEY,
|
||||
}
|
||||
Loaded 3 of 26 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user