222 lines
6.7 KiB
TypeScript
222 lines
6.7 KiB
TypeScript
import { createContext, useContext, useState, useCallback, useEffect, type ReactNode } from 'react'
|
||
import {
|
||
PERMISSIONS_CHANGED_EVENT, SESSION_EXPIRED_EVENT, getToken, onTokenRenewed, setToken, resetSessionExpiredFlag,
|
||
} from '@/services/request'
|
||
import {
|
||
getMe,
|
||
login as loginApi,
|
||
refreshSession,
|
||
updateMyStatus,
|
||
mePermissions,
|
||
type AgentPresence,
|
||
type LoginResult,
|
||
} from '@/services/api'
|
||
|
||
interface AuthState {
|
||
user: LoginResult | null
|
||
loading: boolean
|
||
permissions: Set<string>
|
||
permissionsLoaded: boolean
|
||
login: (username: string, password: string) => Promise<LoginResult>
|
||
logout: () => void
|
||
/** 切换本人在线 / 忙碌 / 离线 */
|
||
setPresence: (status: AgentPresence) => Promise<void>
|
||
refreshPermissions: () => Promise<void>
|
||
}
|
||
|
||
const AuthContext = createContext<AuthState>({
|
||
user: null,
|
||
loading: false,
|
||
permissions: new Set(),
|
||
permissionsLoaded: false,
|
||
login: async () => { throw new Error('认证上下文未初始化') },
|
||
logout: () => {},
|
||
setPresence: async () => {},
|
||
refreshPermissions: async () => {},
|
||
})
|
||
|
||
function persistUser(u: LoginResult | null) {
|
||
if (!u) {
|
||
localStorage.removeItem('auth_user')
|
||
return
|
||
}
|
||
localStorage.setItem('auth_user', JSON.stringify(u))
|
||
}
|
||
|
||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||
const [user, setUser] = useState<LoginResult | null>(() => {
|
||
const saved = localStorage.getItem('auth_user')
|
||
if (saved) {
|
||
try {
|
||
const u = JSON.parse(saved) as LoginResult
|
||
setToken(u.token)
|
||
return u
|
||
} catch {
|
||
return null
|
||
}
|
||
}
|
||
return null
|
||
})
|
||
const [loading, setLoading] = useState(false)
|
||
const [permissions, setPermissions] = useState<Set<string>>(new Set())
|
||
const [permissionsLoaded, setPermissionsLoaded] = useState(false)
|
||
|
||
const refreshPermissions = useCallback(async () => {
|
||
if (!user?.token) return
|
||
try {
|
||
const res = await mePermissions()
|
||
if (res.data?.permissions) {
|
||
setPermissions(new Set(res.data.permissions))
|
||
}
|
||
} catch {
|
||
setPermissions(new Set())
|
||
} finally {
|
||
setPermissionsLoaded(true)
|
||
}
|
||
}, [user?.token])
|
||
|
||
useEffect(() => {
|
||
const handlePermissionsChanged = () => { void refreshPermissions() }
|
||
window.addEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
|
||
return () => window.removeEventListener(PERMISSIONS_CHANGED_EVENT, handlePermissionsChanged)
|
||
}, [refreshPermissions])
|
||
|
||
// 刷新后同步服务端真实状态(含 status + permissions)
|
||
useEffect(() => {
|
||
if (!user?.token) return
|
||
let cancelled = false
|
||
setPermissionsLoaded(false)
|
||
Promise.all([getMe(), mePermissions()])
|
||
.then(([meRes, permRes]) => {
|
||
if (cancelled) return
|
||
if (meRes.data) {
|
||
setUser(prev => {
|
||
if (!prev) return prev
|
||
const next = {
|
||
...prev,
|
||
nickname: meRes.data.nickname || prev.nickname,
|
||
role: meRes.data.role || prev.role,
|
||
status: meRes.data.status || prev.status || 'offline',
|
||
}
|
||
persistUser(next)
|
||
return next
|
||
})
|
||
}
|
||
if (permRes.data?.permissions) {
|
||
setPermissions(new Set(permRes.data.permissions))
|
||
}
|
||
setPermissionsLoaded(true)
|
||
})
|
||
.catch(() => {
|
||
if (!cancelled) {
|
||
setPermissions(new Set())
|
||
setPermissionsLoaded(true)
|
||
}
|
||
})
|
||
return () => { cancelled = true }
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, [user?.token])
|
||
|
||
const login = useCallback(async (username: string, password: string) => {
|
||
setLoading(true)
|
||
try {
|
||
const res = await loginApi({ username, password })
|
||
const u: LoginResult = { ...res.data, status: res.data.status || 'online' }
|
||
resetSessionExpiredFlag()
|
||
setToken(u.token)
|
||
setUser(u)
|
||
persistUser(u)
|
||
setPermissionsLoaded(false)
|
||
try {
|
||
const permissionRes = await mePermissions()
|
||
setPermissions(new Set(permissionRes.data?.permissions || []))
|
||
} catch {
|
||
setPermissions(new Set())
|
||
} finally {
|
||
setPermissionsLoaded(true)
|
||
}
|
||
return u
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}, [])
|
||
|
||
// 所有续期入口共用:同步请求模块、React 状态和 localStorage 中的令牌
|
||
const applyRenewedToken = useCallback((nextToken: string) => {
|
||
if (!nextToken) return
|
||
setToken(nextToken)
|
||
setUser(prev => {
|
||
if (!prev || prev.token === nextToken) return prev
|
||
const updated = { ...prev, token: nextToken }
|
||
persistUser(updated)
|
||
return updated
|
||
})
|
||
}, [])
|
||
|
||
// 服务端滑动续期(X-Renewed-Token)
|
||
useEffect(() => onTokenRenewed(applyRenewedToken), [applyRenewedToken])
|
||
|
||
// 定时续期:长挂机场景下每 10 分钟主动换新令牌,避免 WS 重连/请求时已过期
|
||
useEffect(() => {
|
||
if (!user?.token) return
|
||
const sessionToken = user.token
|
||
const timer = window.setInterval(() => {
|
||
void refreshSession().then(res => {
|
||
// 请求期间已退出、重新登录或由其它响应完成续期时,不再应用旧响应。
|
||
if (getToken() !== sessionToken) return
|
||
applyRenewedToken(res.data?.token || '')
|
||
}).catch(() => { /* 401 已由 request 层统一处理;网络错误等待下一轮 */ })
|
||
}, 10 * 60 * 1000)
|
||
return () => window.clearInterval(timer)
|
||
}, [applyRenewedToken, user?.token])
|
||
|
||
// 会话过期(所有 401 的统一出口):清理登录态并回到登录页
|
||
useEffect(() => {
|
||
const handleExpired = () => {
|
||
setToken('')
|
||
setUser(null)
|
||
setPermissions(new Set())
|
||
setPermissionsLoaded(false)
|
||
persistUser(null)
|
||
if (window.location.pathname !== '/login') {
|
||
window.location.replace('/login?expired=1')
|
||
}
|
||
}
|
||
window.addEventListener(SESSION_EXPIRED_EVENT, handleExpired)
|
||
return () => window.removeEventListener(SESSION_EXPIRED_EVENT, handleExpired)
|
||
}, [])
|
||
|
||
const setPresence = useCallback(async (status: AgentPresence) => {
|
||
const res = await updateMyStatus(status)
|
||
const nextStatus = (res.data?.status || status) as string
|
||
setUser(prev => {
|
||
if (!prev) return prev
|
||
const next = { ...prev, status: nextStatus }
|
||
persistUser(next)
|
||
return next
|
||
})
|
||
}, [])
|
||
|
||
const logout = useCallback(() => {
|
||
void updateMyStatus('offline').catch(() => {})
|
||
setToken('')
|
||
setUser(null)
|
||
setPermissions(new Set())
|
||
setPermissionsLoaded(false)
|
||
persistUser(null)
|
||
}, [])
|
||
|
||
return (
|
||
<AuthContext.Provider value={{ user, loading, permissions, permissionsLoaded, login, logout, setPresence, refreshPermissions }}>
|
||
{children}
|
||
</AuthContext.Provider>
|
||
)
|
||
}
|
||
|
||
export const useAuth = () => useContext(AuthContext)
|
||
|
||
export function usePermission(code: string): boolean {
|
||
const { permissions } = useAuth()
|
||
return permissions.has(code)
|
||
}
|