feat(saas): Phase 2 Admin Web 管理后台 — 完整 CRUD + Dashboard 统计

后端:
- 添加 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 通过
This commit is contained in:
iven
2026-03-27 14:06:50 +08:00
parent d760b9ca10
commit a66b675675
39 changed files with 6798 additions and 0 deletions

284
admin/src/lib/api-client.ts Normal file
View File

@@ -0,0 +1,284 @@
// ============================================================
// 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}`
}