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

@@ -0,0 +1,16 @@
import request from './request'
import type { AccountPublic, PaginatedResponse } from '@/types'
export const accountService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<AccountPublic>>('/accounts', { params }).then((r) => r.data),
get: (id: string) =>
request.get<AccountPublic>(`/accounts/${id}`).then((r) => r.data),
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>) =>
request.patch<AccountPublic>(`/accounts/${id}`, data).then((r) => r.data),
updateStatus: (id: string, data: { status: AccountPublic['status'] }) =>
request.patch(`/accounts/${id}/status`, data).then((r) => r.data),
}

View File

@@ -0,0 +1,28 @@
import request from './request'
import type { AgentTemplate, PaginatedResponse } from '@/types'
export const agentTemplateService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', { params }).then((r) => r.data),
get: (id: string) =>
request.get<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
create: (data: {
name: string; description?: string; category?: string; source?: string
model?: string; system_prompt?: string; tools?: string[]
capabilities?: string[]; temperature?: number; max_tokens?: number
visibility?: string
}) =>
request.post<AgentTemplate>('/agent-templates', data).then((r) => r.data),
update: (id: string, data: {
description?: string; model?: string; system_prompt?: string
tools?: string[]; capabilities?: string[]; temperature?: number
max_tokens?: number; visibility?: string; status?: string
}) =>
request.post<AgentTemplate>(`/agent-templates/${id}`, data).then((r) => r.data),
archive: (id: string) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,13 @@
import request from './request'
import type { TokenInfo, CreateTokenRequest, PaginatedResponse } from '@/types'
export const apiKeyService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<TokenInfo>>('/keys', { params }).then((r) => r.data),
create: (data: CreateTokenRequest) =>
request.post<TokenInfo>('/keys', data).then((r) => r.data),
revoke: (id: string) =>
request.delete(`/keys/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { AccountPublic, LoginRequest, LoginResponse } from '@/types'
export const authService = {
login: (data: LoginRequest) =>
request.post<LoginResponse>('/auth/login', data).then((r) => r.data),
me: () =>
request.get<AccountPublic>('/auth/me').then((r) => r.data),
}

View File

@@ -0,0 +1,11 @@
import request from './request'
import type { ConfigItem, PaginatedResponse } from '@/types'
export const configService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<ConfigItem>>('/config/items', { params })
.then((r) => r.data.items),
update: (id: string, data: { value: string | number | boolean }) =>
request.patch<ConfigItem>(`/config/items/${id}`, data).then((r) => r.data),
}

View File

@@ -0,0 +1,7 @@
import request from './request'
import type { OperationLog, PaginatedResponse } from '@/types'
export const logService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<OperationLog>>('/logs/operations', { params }).then((r) => r.data),
}

View File

@@ -0,0 +1,16 @@
import request from './request'
import type { Model, PaginatedResponse } from '@/types'
export const modelService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<Model>>('/models', { params }).then((r) => r.data),
create: (data: Partial<Omit<Model, 'id'>>) =>
request.post<Model>('/models', data).then((r) => r.data),
update: (id: string, data: Partial<Omit<Model, 'id'>>) =>
request.patch<Model>(`/models/${id}`, data).then((r) => r.data),
delete: (id: string) =>
request.delete(`/models/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,35 @@
import request from './request'
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
export const promptService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<PromptTemplate>>('/prompts', { params }).then((r) => r.data),
get: (name: string) =>
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
create: (data: {
name: string; category: string; description?: string; source?: string
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; min_app_version?: string
}) =>
request.post<PromptTemplate>('/prompts', data).then((r) => r.data),
update: (name: string, data: { description?: string; status?: string }) =>
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data).then((r) => r.data),
archive: (name: string) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
listVersions: (name: string) =>
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`).then((r) => r.data),
createVersion: (name: string, data: {
system_prompt: string; user_prompt_template?: string
variables?: unknown[]; changelog?: string; min_app_version?: string
}) =>
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data).then((r) => r.data),
rollback: (name: string, version: number) =>
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`).then((r) => r.data),
}

View File

@@ -0,0 +1,31 @@
import request from './request'
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
export const providerService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<Provider>>('/providers', { params }).then((r) => r.data),
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
request.post<Provider>('/providers', data).then((r) => r.data),
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
request.patch<Provider>(`/providers/${id}`, data).then((r) => r.data),
delete: (id: string) =>
request.delete(`/providers/${id}`).then((r) => r.data),
listKeys: (providerId: string) =>
request.get<ProviderKey[]>(`/providers/${providerId}/keys`).then((r) => r.data),
addKey: (providerId: string, data: {
key_label: string; key_value: string; priority?: number
max_rpm?: number; max_tpm?: number; quota_reset_interval?: string
}) =>
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data).then((r) => r.data),
toggleKey: (providerId: string, keyId: string, active: boolean) =>
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }).then((r) => r.data),
deleteKey: (providerId: string, keyId: string) =>
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`).then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { RelayTask, PaginatedResponse } from '@/types'
export const relayService = {
list: (params?: Record<string, unknown>) =>
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', { params }).then((r) => r.data),
get: (id: string) =>
request.get<RelayTask>(`/relay/tasks/${id}`).then((r) => r.data),
}

View File

@@ -0,0 +1,108 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
})
// ── 请求拦截器:自动附加 JWT ──────────────────────────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
})
const newToken = res.data.token as string
store.setToken(newToken)
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request

View File

@@ -0,0 +1,7 @@
import request from './request'
import type { DashboardStats } from '@/types'
export const statsService = {
dashboard: () =>
request.get<DashboardStats>('/stats/dashboard').then((r) => r.data),
}

View File

@@ -0,0 +1,10 @@
import request from './request'
import type { ModelUsageStat, DailyUsageStat } from '@/types'
export const telemetryService = {
modelStats: (params?: Record<string, unknown>) =>
request.get<ModelUsageStat[]>('/telemetry/stats', { params }).then((r) => r.data),
dailyStats: (params?: { days?: number }) =>
request.get<DailyUsageStat[]>('/telemetry/daily', { params }).then((r) => r.data),
}

View File

@@ -0,0 +1,12 @@
import request from './request'
import type { UsageRecord, UsageByModel } from '@/types'
export const usageService = {
daily: (params?: { days?: number }) =>
request.get<{ by_day: UsageRecord[] }>('/usage', { params: { ...params, group_by: 'day' } })
.then((r) => r.data.by_day || []),
byModel: (params?: { days?: number }) =>
request.get<{ by_model: UsageByModel[] }>('/usage', { params: { ...params, group_by: 'model' } })
.then((r) => r.data.by_model || []),
}