feat(admin): Admin V2 — Ant Design Pro 纯 SPA 重写
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Next.js SSR/hydration 与 SWR fetch-on-mount 存在根本冲突: hydration 卸载组件时 abort 的请求仍占用后端 DB 连接, retry 循环耗尽 PostgreSQL 连接池导致后端完全卡死。 admin-v2 使用 Vite + React + antd 纯 SPA 彻底消除此问题: - 12 页面全部完成(Login, Dashboard, Accounts, Providers, Models, API Keys, Usage, Relay, Config, Prompts, Logs, Agent Templates) - ProTable + ProForm + ProLayout 统一 UI 模式 - TanStack Query + Axios + Zustand 数据层 - JWT 自动刷新 + 401 重试机制 - 全部 18 网络请求 200 OK,零 ERR_ABORTED 同时更新 troubleshooting 第 13 节和 SaaS 平台文档。
This commit is contained in:
@@ -39,7 +39,9 @@ export default function LoginPage() {
|
||||
totp_code: totpCode.trim() || undefined,
|
||||
})
|
||||
login(res.token, res.account)
|
||||
router.replace('/')
|
||||
// 用 window.location.href 替代 router.replace 避免 Next.js RSC flight
|
||||
// 导致 client component 树重建和 SWR abort 循环
|
||||
window.location.href = '/'
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
const msg = err.body.message || ''
|
||||
|
||||
@@ -1,124 +1,44 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useRef, useCallback, type ReactNode } from 'react'
|
||||
import { useEffect, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated, getAccount, clearAuth } from '@/lib/auth'
|
||||
import { isAuthenticated, clearAuth } from '@/lib/auth'
|
||||
import { api, ApiRequestError } from '@/lib/api-client'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
import { AlertTriangle, RefreshCw } from 'lucide-react'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* AuthGuard — 纯 useEffect redirect,始终渲染 children
|
||||
*
|
||||
* 不做任何 loading/authorized 状态切换,避免组件卸载。
|
||||
* useEffect 在客户端 hydration 后执行,检查认证状态。
|
||||
*/
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const [authorized, setAuthorized] = useState(false)
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
const [verifying, setVerifying] = useState(true)
|
||||
const [connectionError, setConnectionError] = useState(false)
|
||||
|
||||
// Ref 跟踪授权状态,避免 useCallback 闭包捕获过时的 state
|
||||
const authorizedRef = useRef(false)
|
||||
// 防止并发验证(RSC 导航可能触发多次 effect)
|
||||
const verifyingRef = useRef(false)
|
||||
|
||||
const verifyAuth = useCallback(async () => {
|
||||
// 防止并发验证
|
||||
if (verifyingRef.current) return
|
||||
verifyingRef.current = true
|
||||
setVerifying(true)
|
||||
setConnectionError(false)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
setVerifying(false)
|
||||
verifyingRef.current = false
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const serverAccount = await api.auth.me()
|
||||
setAccount(serverAccount)
|
||||
setAuthorized(true)
|
||||
authorizedRef.current = true
|
||||
} catch (err) {
|
||||
// AbortError: 导航/SWR 取消了请求,忽略
|
||||
// 如果已有授权(ref 跟踪),保持不变;否则尝试 localStorage 缓存
|
||||
if (err instanceof DOMException && err.name === 'AbortError') {
|
||||
if (!authorizedRef.current) {
|
||||
const cachedAccount = getAccount()
|
||||
if (cachedAccount) {
|
||||
setAccount(cachedAccount)
|
||||
setAuthorized(true)
|
||||
authorizedRef.current = true
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
// 401/403: 真正的认证失败,清除 token
|
||||
// 后台验证 token
|
||||
api.auth.me().catch((err) => {
|
||||
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
|
||||
clearAuth()
|
||||
authorizedRef.current = false
|
||||
router.replace('/login')
|
||||
} else {
|
||||
// 网络错误/超时 — 仅在未授权时显示连接错误
|
||||
// 已授权的情况下忽略瞬态错误,保持当前状态
|
||||
if (!authorizedRef.current) {
|
||||
setConnectionError(true)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
setVerifying(false)
|
||||
verifyingRef.current = false
|
||||
}
|
||||
})
|
||||
}, [router])
|
||||
|
||||
useEffect(() => {
|
||||
verifyAuth()
|
||||
}, [verifyAuth])
|
||||
|
||||
if (verifying) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (connectionError) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen flex-col items-center justify-center gap-4 bg-background">
|
||||
<AlertTriangle className="h-12 w-12 text-yellow-500" />
|
||||
<h2 className="text-lg font-semibold text-foreground">连接中断</h2>
|
||||
<p className="text-sm text-muted-foreground">无法连接到服务器,请检查网络后重试</p>
|
||||
<button
|
||||
onClick={verifyAuth}
|
||||
className="mt-2 inline-flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm font-medium text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
重新连接
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!authorized) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const acc = getAccount()
|
||||
setAccount(acc)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return { account, loading, isAuthenticated: isAuthenticated() }
|
||||
// 简化版 — 直接读 localStorage
|
||||
const account = typeof window !== 'undefined'
|
||||
? JSON.parse(localStorage.getItem('zclaw_admin_account') || 'null')
|
||||
: null
|
||||
return { account, loading: false, isAuthenticated: !!localStorage.getItem('zclaw_admin_token') }
|
||||
}
|
||||
|
||||
@@ -14,20 +14,29 @@ export function SWRProvider({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<SWRConfig
|
||||
value={{
|
||||
// 关闭所有自动 revalidation — 只在手动 mutate 或 key 变化时刷新
|
||||
revalidateOnFocus: false,
|
||||
dedupingInterval: 5000,
|
||||
errorRetryCount: 2,
|
||||
revalidateOnReconnect: false,
|
||||
|
||||
// 60s 去重窗口:Dashboard 数据变化不频繁,避免短时间内重复请求
|
||||
dedupingInterval: 60_000,
|
||||
|
||||
// 保留旧数据直到新数据返回,避免 loading 闪烁
|
||||
keepPreviousData: true,
|
||||
|
||||
// 最多重试 1 次,间隔 3s
|
||||
errorRetryCount: 1,
|
||||
errorRetryInterval: 3000,
|
||||
|
||||
shouldRetryOnError: (err: unknown) => {
|
||||
if (isAbortError(err)) return false
|
||||
if (err && typeof err === 'object' && 'status' in err) {
|
||||
const status = (err as { status: number }).status
|
||||
return status !== 401 && status !== 403
|
||||
return status !== 401 && status !== 403 && status !== 404
|
||||
}
|
||||
return true
|
||||
},
|
||||
onError: (err: unknown) => {
|
||||
// 中断错误静默忽略,不展示给用户
|
||||
if (isAbortError(err)) return
|
||||
},
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user