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

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:
iven
2026-03-30 09:35:59 +08:00
parent 13c0b18bbc
commit a7d33d0207
52 changed files with 8102 additions and 118 deletions

View File

@@ -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 || ''

View File

@@ -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') }
}

View File

@@ -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
},
}}