后端: - 添加 GET /api/v1/stats/dashboard 聚合统计端点 (账号数/活跃服务商/今日请求/今日Token用量等7项指标) - 需要 account:admin 权限 Admin 前端 (Next.js 14 + shadcn/ui + Tailwind + Recharts): - 设计系统: Dark Mode OLED (#020617 背景, #22C55E CTA) - 登录页: 双栏布局, 品牌区 + 表单 - Dashboard 布局: Sidebar 导航 + Header + 主内容区 - 仪表盘: 4 统计卡片 + AreaChart 请求趋势 + BarChart Token用量 - 8 个 CRUD 页面: - 账号管理 (搜索/角色/状态筛选, 编辑/启用禁用) - 服务商 (CRUD + API Key masked) - 模型管理 (Provider筛选, CRUD) - API 密钥 (创建/撤销, 一次性显示token) - 用量统计 (LineChart + BarChart) - 中转任务 (状态筛选, 展开详情) - 系统配置 (分类Tab, 编辑) - 操作日志 (Action筛选, 展开详情) - 14 个 shadcn 风格 UI 组件 (手写实现) - 类型化 API 客户端 (SaaSClient, 20+ 方法, 401 自动跳转) - AuthGuard 路由保护 + useAuth() hook 验证: tsc --noEmit 零 error, pnpm build 13 页面成功, cargo test 21 通过
285 lines
9.1 KiB
TypeScript
285 lines
9.1 KiB
TypeScript
// ============================================================
|
|
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
|
// ============================================================
|
|
|
|
import { getToken, logout } from './auth'
|
|
import type {
|
|
AccountPublic,
|
|
ApiError,
|
|
ConfigItem,
|
|
CreateTokenRequest,
|
|
DashboardStats,
|
|
LoginRequest,
|
|
LoginResponse,
|
|
Model,
|
|
OperationLog,
|
|
PaginatedResponse,
|
|
Provider,
|
|
RelayTask,
|
|
TokenInfo,
|
|
UsageByModel,
|
|
UsageRecord,
|
|
} from './types'
|
|
|
|
// ── 错误类 ────────────────────────────────────────────────
|
|
|
|
export class ApiRequestError extends Error {
|
|
constructor(
|
|
public status: number,
|
|
public body: ApiError,
|
|
) {
|
|
super(body.message || `Request failed with status ${status}`)
|
|
this.name = 'ApiRequestError'
|
|
}
|
|
}
|
|
|
|
// ── 基础请求 ──────────────────────────────────────────────
|
|
|
|
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
|
|
|
async function request<T>(
|
|
method: string,
|
|
path: string,
|
|
body?: unknown,
|
|
): Promise<T> {
|
|
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,
|
|
})
|
|
|
|
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})` }
|
|
}
|
|
throw new ApiRequestError(res.status, errorBody)
|
|
}
|
|
|
|
// 204 No Content
|
|
if (res.status === 204) {
|
|
return undefined as T
|
|
}
|
|
|
|
return res.json() as Promise<T>
|
|
}
|
|
|
|
// ── API 客户端 ────────────────────────────────────────────
|
|
|
|
export const api = {
|
|
// ── 认证 ──────────────────────────────────────────────
|
|
auth: {
|
|
async login(data: LoginRequest): Promise<LoginResponse> {
|
|
return request<LoginResponse>('POST', '/api/auth/login', data)
|
|
},
|
|
|
|
async register(data: {
|
|
username: string
|
|
password: string
|
|
email: string
|
|
display_name?: string
|
|
}): Promise<LoginResponse> {
|
|
return request<LoginResponse>('POST', '/api/auth/register', data)
|
|
},
|
|
|
|
async me(): Promise<AccountPublic> {
|
|
return request<AccountPublic>('GET', '/api/auth/me')
|
|
},
|
|
},
|
|
|
|
// ── 账号管理 ──────────────────────────────────────────
|
|
accounts: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
search?: string
|
|
role?: string
|
|
status?: string
|
|
}): Promise<PaginatedResponse<AccountPublic>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
|
|
},
|
|
|
|
async get(id: string): Promise<AccountPublic> {
|
|
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
|
},
|
|
|
|
async update(
|
|
id: string,
|
|
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
|
): Promise<AccountPublic> {
|
|
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
|
},
|
|
|
|
async updateStatus(
|
|
id: string,
|
|
data: { status: AccountPublic['status'] },
|
|
): Promise<void> {
|
|
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
|
|
},
|
|
},
|
|
|
|
// ── 服务商管理 ────────────────────────────────────────
|
|
providers: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
}): Promise<PaginatedResponse<Provider>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
|
|
},
|
|
|
|
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
|
return request<Provider>('POST', '/api/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)
|
|
},
|
|
|
|
async delete(id: string): Promise<void> {
|
|
return request<void>('DELETE', `/api/providers/${id}`)
|
|
},
|
|
},
|
|
|
|
// ── 模型管理 ──────────────────────────────────────────
|
|
models: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
provider_id?: string
|
|
}): Promise<PaginatedResponse<Model>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
|
},
|
|
|
|
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
|
return request<Model>('POST', '/api/models', data)
|
|
},
|
|
|
|
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
|
return request<Model>('PATCH', `/api/models/${id}`, data)
|
|
},
|
|
|
|
async delete(id: string): Promise<void> {
|
|
return request<void>('DELETE', `/api/models/${id}`)
|
|
},
|
|
},
|
|
|
|
// ── API 密钥 ──────────────────────────────────────────
|
|
tokens: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
}): Promise<PaginatedResponse<TokenInfo>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
|
},
|
|
|
|
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
|
return request<TokenInfo>('POST', '/api/tokens', data)
|
|
},
|
|
|
|
async revoke(id: string): Promise<void> {
|
|
return request<void>('DELETE', `/api/tokens/${id}`)
|
|
},
|
|
},
|
|
|
|
// ── 用量统计 ──────────────────────────────────────────
|
|
usage: {
|
|
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
|
const qs = buildQueryString(params)
|
|
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
|
|
},
|
|
|
|
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
|
const qs = buildQueryString(params)
|
|
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
|
|
},
|
|
},
|
|
|
|
// ── 中转任务 ──────────────────────────────────────────
|
|
relay: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
status?: string
|
|
}): Promise<PaginatedResponse<RelayTask>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
|
},
|
|
|
|
async get(id: string): Promise<RelayTask> {
|
|
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
|
},
|
|
},
|
|
|
|
// ── 系统配置 ──────────────────────────────────────────
|
|
config: {
|
|
async list(params?: {
|
|
category?: string
|
|
}): Promise<ConfigItem[]> {
|
|
const qs = buildQueryString(params)
|
|
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
|
},
|
|
|
|
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
|
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
|
|
},
|
|
},
|
|
|
|
// ── 操作日志 ──────────────────────────────────────────
|
|
logs: {
|
|
async list(params?: {
|
|
page?: number
|
|
page_size?: number
|
|
action?: string
|
|
}): Promise<PaginatedResponse<OperationLog>> {
|
|
const qs = buildQueryString(params)
|
|
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
|
|
},
|
|
},
|
|
|
|
// ── 仪表盘 ────────────────────────────────────────────
|
|
stats: {
|
|
async dashboard(): Promise<DashboardStats> {
|
|
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
|
},
|
|
},
|
|
}
|
|
|
|
// ── 工具函数 ──────────────────────────────────────────────
|
|
|
|
function buildQueryString(params?: Record<string, unknown>): string {
|
|
if (!params) return ''
|
|
const entries = Object.entries(params).filter(
|
|
([, v]) => v !== undefined && v !== null && v !== '',
|
|
)
|
|
if (entries.length === 0) return ''
|
|
const qs = entries
|
|
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
|
.join('&')
|
|
return `?${qs}`
|
|
}
|