chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成

包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
This commit is contained in:
iven
2026-03-29 10:46:26 +08:00
parent 9a5fad2b59
commit 5fdf96c3f5
268 changed files with 22011 additions and 3886 deletions

View File

@@ -2,19 +2,25 @@
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
// ============================================================
import { getToken, logout } from './auth'
import { getToken, login as saveToken, logout, getAccount } from './auth'
import type {
AccountPublic,
AgentTemplate,
ApiError,
ConfigItem,
CreateTokenRequest,
DashboardStats,
DailyUsageStat,
LoginRequest,
LoginResponse,
Model,
ModelUsageStat,
OperationLog,
PaginatedResponse,
PromptTemplate,
PromptVersion,
Provider,
ProviderKey,
RelayTask,
TokenInfo,
UsageByModel,
@@ -35,51 +41,132 @@ export class ApiRequestError extends Error {
// ── 基础请求 ──────────────────────────────────────────────
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || '/api/v1'
const DEFAULT_TIMEOUT_MS = 10_000
const MAX_RETRIES = 2
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/** 判断是否为可重试的网络错误(不含 AbortError */
function isRetryableNetworkError(err: unknown): boolean {
// AbortError 不重试:可能是组件卸载或路由切换导致的外部取消
if (err instanceof DOMException && err.name === 'AbortError') return false
if (err instanceof TypeError) {
const msg = (err as TypeError).message
return msg.includes('Failed to fetch') || msg.includes('NetworkError') || msg.includes('ECONNREFUSED')
}
return false
}
/** 尝试刷新 Token成功返回新 token失败返回 null */
async function tryRefreshToken(): Promise<string | null> {
try {
const token = getToken()
if (!token) return null
const res = await fetch(`${BASE_URL}/auth/refresh`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
})
if (!res.ok) return null
const data = await res.json()
const newToken = data.token as string
const account = getAccount()
if (account && newToken) {
saveToken(newToken, account)
}
return newToken
} catch {
return null
}
}
async function request<T>(
method: string,
path: string,
body?: unknown,
_isRetry = false,
): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
let lastError: unknown
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
const controller = new AbortController()
const timeoutId = setTimeout(() => controller.abort(), DEFAULT_TIMEOUT_MS)
if (res.status === 401) {
logout()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
}
if (!res.ok) {
let errorBody: ApiError
try {
errorBody = await res.json()
} catch {
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
signal: controller.signal,
})
clearTimeout(timeoutId)
// 401: 尝试刷新 Token 后重试
if (res.status === 401 && !_isRetry) {
const newToken = await tryRefreshToken()
if (newToken) {
return request<T>(method, path, body, true)
}
logout()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
}
if (!res.ok) {
let errorBody: ApiError
try {
errorBody = await res.json()
} catch {
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
}
throw new ApiRequestError(res.status, errorBody)
}
// 204 No Content
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
} catch (err) {
clearTimeout(timeoutId)
// API 错误和外部取消的 AbortError 直接抛出,不重试
if (err instanceof ApiRequestError) throw err
if (err instanceof DOMException && err.name === 'AbortError') throw err
lastError = err
// 仅对可重试的网络错误重试
if (attempt < MAX_RETRIES && isRetryableNetworkError(err)) {
await sleep(1000 * Math.pow(2, attempt))
continue
}
throw err
}
throw new ApiRequestError(res.status, errorBody)
}
// 204 No Content
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
throw lastError
}
// ── API 客户端 ────────────────────────────────────────────
@@ -88,7 +175,7 @@ export const api = {
// ── 认证 ──────────────────────────────────────────────
auth: {
async login(data: LoginRequest): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/login', data)
return request<LoginResponse>('POST', '/auth/login', data)
},
async register(data: {
@@ -97,11 +184,11 @@ export const api = {
email: string
display_name?: string
}): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/register', data)
return request<LoginResponse>('POST', '/auth/register', data)
},
async me(): Promise<AccountPublic> {
return request<AccountPublic>('GET', '/api/auth/me')
return request<AccountPublic>('GET', '/auth/me')
},
},
@@ -115,25 +202,25 @@ export const api = {
status?: string
}): Promise<PaginatedResponse<AccountPublic>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
return request<PaginatedResponse<AccountPublic>>('GET', `/accounts${qs}`)
},
async get(id: string): Promise<AccountPublic> {
return request<AccountPublic>('GET', `/api/accounts/${id}`)
return request<AccountPublic>('GET', `/accounts/${id}`)
},
async update(
id: string,
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
): Promise<AccountPublic> {
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
},
async updateStatus(
id: string,
data: { status: AccountPublic['status'] },
): Promise<void> {
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
return request<void>('PATCH', `/accounts/${id}/status`, data)
},
},
@@ -144,22 +231,46 @@ export const api = {
page_size?: number
}): Promise<PaginatedResponse<Provider>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
return request<PaginatedResponse<Provider>>('GET', `/providers${qs}`)
},
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
return request<Provider>('POST', '/api/providers', data)
return request<Provider>('POST', '/providers', data)
},
async update(
id: string,
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
): Promise<Provider> {
return request<Provider>('PATCH', `/api/providers/${id}`, data)
return request<Provider>('PATCH', `/providers/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/providers/${id}`)
return request<void>('DELETE', `/providers/${id}`)
},
// Key Pool 管理
async listKeys(providerId: string): Promise<ProviderKey[]> {
return request<ProviderKey[]>('GET', `/providers/${providerId}/keys`)
},
async addKey(providerId: string, data: {
key_label: string
key_value: string
priority?: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
}): Promise<{ ok: boolean; key_id: string }> {
return request<{ ok: boolean; key_id: string }>('POST', `/providers/${providerId}/keys`, data)
},
async toggleKey(providerId: string, keyId: string, active: boolean): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('PUT', `/providers/${providerId}/keys/${keyId}/toggle`, { active })
},
async deleteKey(providerId: string, keyId: string): Promise<{ ok: boolean }> {
return request<{ ok: boolean }>('DELETE', `/providers/${providerId}/keys/${keyId}`)
},
},
@@ -171,19 +282,19 @@ export const api = {
provider_id?: string
}): Promise<PaginatedResponse<Model>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
},
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('POST', '/api/models', data)
return request<Model>('POST', '/models', data)
},
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('PATCH', `/api/models/${id}`, data)
return request<Model>('PATCH', `/models/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/models/${id}`)
return request<void>('DELETE', `/models/${id}`)
},
},
@@ -194,28 +305,30 @@ export const api = {
page_size?: number
}): Promise<PaginatedResponse<TokenInfo>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
},
async create(data: CreateTokenRequest): Promise<TokenInfo> {
return request<TokenInfo>('POST', '/api/tokens', data)
return request<TokenInfo>('POST', '/keys', data)
},
async revoke(id: string): Promise<void> {
return request<void>('DELETE', `/api/tokens/${id}`)
return request<void>('DELETE', `/keys/${id}`)
},
},
// ── 用量统计 ──────────────────────────────────────────
usage: {
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
const qs = buildQueryString(params)
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
const qs = buildQueryString({ ...params, group_by: 'day' })
const result = await request<{ by_day: UsageRecord[] }>('GET', `/usage${qs}`)
return result.by_day || []
},
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
const qs = buildQueryString(params)
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
const qs = buildQueryString({ ...params, group_by: 'model' })
const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`)
return result.by_model || []
},
},
@@ -227,11 +340,11 @@ export const api = {
status?: string
}): Promise<PaginatedResponse<RelayTask>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
},
async get(id: string): Promise<RelayTask> {
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
return request<RelayTask>('GET', `/relay/tasks/${id}`)
},
},
@@ -239,13 +352,16 @@ export const api = {
config: {
async list(params?: {
category?: string
page?: number
page_size?: number
}): Promise<ConfigItem[]> {
const qs = buildQueryString(params)
return request<ConfigItem[]>('GET', `/api/config${qs}`)
const result = await request<PaginatedResponse<ConfigItem>>('GET', `/config/items${qs}`)
return result.items
},
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
return request<ConfigItem>('PATCH', `/config/items/${id}`, data)
},
},
@@ -257,14 +373,149 @@ export const api = {
action?: string
}): Promise<PaginatedResponse<OperationLog>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
return request<PaginatedResponse<OperationLog>>('GET', `/logs/operations${qs}`)
},
},
// ── 仪表盘 ────────────────────────────────────────────
stats: {
async dashboard(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/api/stats/dashboard')
return request<DashboardStats>('GET', '/stats/dashboard')
},
},
// ── 提示词管理 ────────────────────────────────────────
prompts: {
async list(params?: {
category?: string
source?: string
status?: string
page?: number
page_size?: number
}): Promise<PaginatedResponse<PromptTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<PromptTemplate>>('GET', `/prompts${qs}`)
},
async get(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('GET', `/prompts/${encodeURIComponent(name)}`)
},
async create(data: {
name: string
category: string
description?: string
source?: string
system_prompt: string
user_prompt_template?: string
variables?: unknown[]
min_app_version?: string
}): Promise<PromptTemplate> {
return request<PromptTemplate>('POST', '/prompts', data)
},
async update(name: string, data: {
description?: string
status?: string
}): Promise<PromptTemplate> {
return request<PromptTemplate>('PUT', `/prompts/${encodeURIComponent(name)}`, data)
},
async archive(name: string): Promise<PromptTemplate> {
return request<PromptTemplate>('DELETE', `/prompts/${encodeURIComponent(name)}`)
},
async listVersions(name: string): Promise<PromptVersion[]> {
return request<PromptVersion[]>('GET', `/prompts/${encodeURIComponent(name)}/versions`)
},
async createVersion(name: string, data: {
system_prompt: string
user_prompt_template?: string
variables?: unknown[]
changelog?: string
min_app_version?: string
}): Promise<PromptVersion> {
return request<PromptVersion>('POST', `/prompts/${encodeURIComponent(name)}/versions`, data)
},
async rollback(name: string, version: number): Promise<PromptTemplate> {
return request<PromptTemplate>('POST', `/prompts/${encodeURIComponent(name)}/rollback/${version}`)
},
},
// ── Agent 配置模板 ──────────────────────────────────
agentTemplates: {
async list(params?: {
category?: string
source?: string
visibility?: string
status?: string
page?: number
page_size?: number
}): Promise<PaginatedResponse<AgentTemplate>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AgentTemplate>>('GET', `/agent-templates${qs}`)
},
async get(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('GET', `/agent-templates/${id}`)
},
async 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
}): Promise<AgentTemplate> {
return request<AgentTemplate>('POST', '/agent-templates', data)
},
async update(id: string, data: {
description?: string
model?: string
system_prompt?: string
tools?: string[]
capabilities?: string[]
temperature?: number
max_tokens?: number
visibility?: string
status?: string
}): Promise<AgentTemplate> {
return request<AgentTemplate>('POST', `/agent-templates/${id}`, data)
},
async archive(id: string): Promise<AgentTemplate> {
return request<AgentTemplate>('DELETE', `/agent-templates/${id}`)
},
},
// ── 遥测统计 ──────────────────────────────────────────
telemetry: {
/** 按模型聚合用量统计 */
async modelStats(params?: {
from?: string
to?: string
model_id?: string
connection_mode?: string
}): Promise<ModelUsageStat[]> {
const qs = buildQueryString(params)
return request<ModelUsageStat[]>('GET', `/telemetry/stats${qs}`)
},
/** 按天聚合用量统计 */
async dailyStats(params?: {
days?: number
}): Promise<DailyUsageStat[]> {
const qs = buildQueryString(params)
return request<DailyUsageStat[]>('GET', `/telemetry/daily${qs}`)
},
},
}