Compare commits

...

11 Commits

Author SHA1 Message Date
iven
6821df5f44 refactor(admin): 迁移 admin 项目到 admin-v2 并移除旧代码
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
重构 admin 项目为 admin-v2,移除 Next.js 相关代码,添加 Vite 配置和环境变量
删除所有 UI 组件、工具函数、API 客户端和类型定义
新增 ErrorBoundary 组件处理错误边界
调整代理配置支持 SSE 长连接超时设置
2026-03-31 00:10:42 +08:00
iven
9d310e5a3c docs: 更新 roadmap — S2/S4/S8/F16 标记已完成 2026-03-30 19:56:03 +08:00
iven
6529b67353 feat(a2a): 消息重入队列 + 广播丢弃修复 + Router group 管理
A2A 协议完善 (feature-gated by multi-agent):
- AgentInbox wrapper: VecDeque 暂存非匹配消息,requeue 替代丢弃
- a2a_delegate_task: 非匹配消息安全重入队列,不再静默丢弃
- A2aRouter: 广播/组播改用 try_send + 日志,避免持有 RwLock 跨 await
- 新增 group 管理方法: add_to_group/remove_from_group/list_groups/get_group_members
- 修复 Capability import 在 multi-agent feature 下的编译问题
2026-03-30 19:55:06 +08:00
iven
a0bbd4ba82 feat(scheduler): 定时任务后端持久化 + Pipeline trigger 编译修复
S4/S8 定时任务后端:
- 新增 scheduled_tasks 表 (migration v7)
- 新增 scheduled_task CRUD 模块 (handlers/service/types)
- 注册 /api/scheduler/tasks 路由 (GET/POST/PATCH/DELETE)
- 新增 start_user_task_scheduler() 30秒轮询循环
- 支持 cron/interval/once 三种调度类型
- once 类型执行后自动禁用

修复:
- pipeline_commands.rs: 修复 pipeline.trigger 字段不存在的编译错误
  (Pipeline 结构体无 trigger 字段,改用 metadata.tags/description)
2026-03-30 19:46:45 +08:00
iven
c2aff09811 feat(security): Auth Token HttpOnly Cookie — XSS 安全加固
后端:
- axum-extra 启用 cookie feature
- login/register/refresh 设置 HttpOnly + Secure + SameSite=Strict cookies
- 新增 POST /api/v1/auth/logout 清除 cookies
- auth_middleware 支持 cookie 提取路径(fallback from header)
- CORS: 添加 allow_credentials(true) + COOKIE header

前端 (admin-v2):
- authStore: token 仅存内存,不再写 localStorage(account 保留)
- request.ts: 添加 withCredentials: true 发送 cookies
- 修复 refresh token rotation bug(之前不更新 stored refreshToken)
- logout 调用后端清除 cookie 端点

向后兼容: API 客户端仍可用 Authorization: Bearer header
Desktop (Ed25519 设备认证) 完全不受影响
2026-03-30 19:30:42 +08:00
iven
e7b2d1c099 docs: 审计后文档同步 — feature-checklist/roadmap/technical-reference 更新
- feature-checklist: 新增 Admin V2 章节(12项全通过),Speech/Twitter 状态提升,
  Hands 9/11 可用,安全备注更新
- roadmap: 标记 S1/S3 审批/Hand 为已完成,更新 crate 数量(10),
  新增审计/依赖/清理已完成项
- technical-reference: 更新日期至 03-30,crate 数量 10
2026-03-30 18:32:02 +08:00
iven
88aa4b1310 refactor: 依赖健康度改进 — serde_yaml 迁移 + workspace 统一声明
- zclaw-pipeline: serde_yaml 0.9 → serde_yaml_bw 2.x (drop-in fork, panic-free)
  使用 package rename 保持代码中 use serde_yaml 不变
- zclaw-saas: regex/aes-gcm/bytes 改为 workspace 统一声明
  urlencoding/data-encoding 保留为 crate-local (仅此 crate 使用)
2026-03-30 18:23:13 +08:00
iven
ecd7f2e928 fix(desktop): console.log 清理 — 替换为结构化 logger
将 desktop/src 中 23 处 console.log 替换为 createLogger() 结构化日志:
- 生产构建自动静默 debug/info 级别
- 保留 console.error 用于关键错误可见性
- 新增 dompurify 依赖修复 XSS 防护引入缺失

涉及文件: App.tsx, offlineStore.ts, autonomy-manager.ts,
gateway-auth.ts, llm-service.ts, request-helper.ts,
security-index.ts, skill-discovery.ts, use-onboarding.ts 等 16 个文件
2026-03-30 16:22:16 +08:00
iven
544358764e fix(relay): 移除 SSE usage 记录中重复的 sleep
service.rs L316-317 有两行相同的 tokio::time::sleep(3s),
导致 SSE 流结束后实际等待 6 秒而非 3 秒才记录 usage。
2026-03-30 14:26:22 +08:00
iven
ba2c6a6105 fix(saas): P1 审计修复 — 连接池断路器 + Worker重试 + XSS防护 + 状态机SQL解析器
P1 修复内容:
- F7: health handler 连接池容量检查 (80%阈值返回503 degraded)
- F9: SSE spawned task 并发限制 (Semaphore 16 permits)
- F10: Key Pool 单次 JOIN 查询优化 (消除 N+1)
- F12: CORS panic → 配置错误
- F14: 连接池使用率计算修正 (ratio = used*100/total)
- F15: SQL 迁移解析器替换为状态机 (支持 $$, DO $body$, 存储过程)
- Worker 重试机制: 失败任务通过 mpsc channel 重新入队
- DOMPurify XSS 防护 (PipelineResultPreview)
- Admin V2: ErrorBoundary + SWR全局配置 + 请求优化
2026-03-30 14:21:39 +08:00
iven
bc8c77e7fe fix(security): P0 审计修复 — 6项关键安全/编译问题
F1: kernel.rs multi-agent 编译错误 — 重排 spawn_agent 中 A2A 注册顺序,
    在 config 被 registry.register() 消费前使用
F2: saas-config.toml 从 git 追踪中移除 — 包含数据库密码已进入版本历史
F3: config.rs 硬编码开发密钥改用 #[cfg(debug_assertions)] 编译时门控 —
    dev fallback 密钥不再进入 release 构建
F4: 公共认证端点添加 IP 速率限制 (20 RPM) — 防止暴力破解
F5: SSE relay 路由分离出全局 15s TimeoutLayer — 避免长流式响应被截断
F6: Provider API 密钥入库前 AES-256-GCM 加密 — 明文存储修复

附带:完整审计报告 docs/superpowers/specs/2026-03-30-comprehensive-audit-report.md
2026-03-30 13:32:22 +08:00
126 changed files with 2034 additions and 8940 deletions

61
Cargo.lock generated
View File

@@ -137,6 +137,12 @@ dependencies = [
"password-hash",
]
[[package]]
name = "arraydeque"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236"
[[package]]
name = "async-broadcast"
version = "0.7.2"
@@ -2506,7 +2512,7 @@ dependencies = [
"libc",
"percent-encoding",
"pin-project-lite",
"socket2",
"socket2 0.6.3",
"tokio",
"tower-service",
"tracing",
@@ -4189,7 +4195,7 @@ dependencies = [
"quinn-udp",
"rustc-hash",
"rustls",
"socket2",
"socket2 0.6.3",
"thiserror 2.0.18",
"tokio",
"tracing",
@@ -4226,7 +4232,7 @@ dependencies = [
"cfg_aliases",
"libc",
"once_cell",
"socket2",
"socket2 0.6.3",
"tracing",
"windows-sys 0.60.2",
]
@@ -4677,6 +4683,17 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "saphyr-parser-bw"
version = "0.0.610"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6d643f5e972f17219245b82f038c22cd3c74320bb17c6e8f7e8537de268b1bc6"
dependencies = [
"arraydeque",
"smallvec",
"thiserror 2.0.18",
]
[[package]]
name = "schannel"
version = "0.1.29"
@@ -4984,6 +5001,23 @@ dependencies = [
"unsafe-libyaml",
]
[[package]]
name = "serde_yaml_bw"
version = "2.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b4d05b26468431333c4d16963433fe0e04ef24e4a7b568c9da81e91d25c0dbb"
dependencies = [
"base64 0.22.1",
"indexmap 2.13.0",
"itoa",
"num-traits",
"regex",
"saphyr-parser-bw",
"serde",
"unsafe-libyaml-norway",
"zmij",
]
[[package]]
name = "serialize-to-javascript"
version = "0.1.2"
@@ -5133,6 +5167,16 @@ dependencies = [
"serde",
]
[[package]]
name = "socket2"
version = "0.5.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678"
dependencies = [
"libc",
"windows-sys 0.52.0",
]
[[package]]
name = "socket2"
version = "0.6.3"
@@ -6048,7 +6092,7 @@ dependencies = [
"parking_lot",
"pin-project-lite",
"signal-hook-registry",
"socket2",
"socket2 0.6.3",
"tokio-macros",
"windows-sys 0.61.2",
]
@@ -6531,6 +6575,12 @@ version = "0.2.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
[[package]]
name = "unsafe-libyaml-norway"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b39abd59bf32521c7f2301b52d05a6a2c975b6003521cbd0c6dc1582f0a22104"
[[package]]
name = "untrusted"
version = "0.9.0"
@@ -8246,7 +8296,7 @@ dependencies = [
"reqwest 0.12.28",
"serde",
"serde_json",
"serde_yaml",
"serde_yaml_bw",
"thiserror 2.0.18",
"tokio",
"tokio-test",
@@ -8328,6 +8378,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"socket2 0.5.10",
"sqlx",
"tempfile",
"thiserror 2.0.18",

View File

@@ -29,6 +29,7 @@ rust-version = "1.75"
# Async runtime
tokio = { version = "1", features = ["full"] }
tokio-stream = "0.1"
tokio-util = "0.7"
futures = "0.3"
async-stream = "0.3"
@@ -102,7 +103,7 @@ tempfile = "3"
# SaaS dependencies
axum = { version = "0.7", features = ["macros"] }
axum-extra = { version = "0.9", features = ["typed-header"] }
axum-extra = { version = "0.9", features = ["typed-header", "cookie"] }
tower = { version = "0.4", features = ["util"] }
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
jsonwebtoken = "9"
@@ -110,6 +111,9 @@ argon2 = "0.5"
totp-rs = "5"
hex = "0.4"
# TCP socket configuration
socket2 = { version = "0.5", features = ["all"] }
# Internal crates
zclaw-types = { path = "crates/zclaw-types" }
zclaw-memory = { path = "crates/zclaw-memory" }

View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=http://localhost:8080/api/v1

1
admin-v2/.env.production Normal file
View File

@@ -0,0 +1 @@
VITE_API_BASE_URL=/api/v1

View File

@@ -0,0 +1,53 @@
import { Component, type ReactNode } from 'react'
import { Result, Button } from 'antd'
interface Props {
children: ReactNode
}
interface State {
hasError: boolean
error: Error | null
}
export class ErrorBoundary extends Component<Props, State> {
constructor(props: Props) {
super(props)
this.state = { hasError: false, error: null }
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, error }
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error('[ErrorBoundary] Unhandled error:', error, info.componentStack)
}
private handleReload = () => {
window.location.reload()
}
private handleReset = () => {
this.setState({ hasError: false, error: null })
}
render() {
if (this.state.hasError) {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', minHeight: '100vh' }}>
<Result
status="error"
title="页面出现错误"
subTitle={this.state.error?.message ?? '发生了未知错误,请刷新页面重试'}
extra={[
<Button key="retry" onClick={this.handleReset}></Button>,
<Button key="reload" type="primary" onClick={this.handleReload}></Button>,
]}
/>
</div>
)
}
return this.props.children
}
}

View File

@@ -4,6 +4,7 @@ import { RouterProvider } from 'react-router-dom'
import { ConfigProvider, App as AntApp } from 'antd'
import zhCN from 'antd/locale/zh_CN'
import { router } from './router'
import { ErrorBoundary } from './components/ErrorBoundary'
const queryClient = new QueryClient({
defaultOptions: {
@@ -16,11 +17,13 @@ const queryClient = new QueryClient({
})
createRoot(document.getElementById('root')!).render(
<ConfigProvider locale={zhCN}>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>,
<ErrorBoundary>
<ConfigProvider locale={zhCN}>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>
</ErrorBoundary>,
)

View File

@@ -42,7 +42,7 @@ export default function Accounts() {
const { data, isLoading } = useQuery({
queryKey: ['accounts'],
queryFn: () => accountService.list(),
queryFn: ({ signal }) => accountService.list(signal),
})
const updateMutation = useMutation({

View File

@@ -26,7 +26,7 @@ export default function AgentTemplates() {
const { data, isLoading } = useQuery({
queryKey: ['agent-templates'],
queryFn: () => agentTemplateService.list(),
queryFn: ({ signal }) => agentTemplateService.list(signal),
})
const createMutation = useMutation({

View File

@@ -21,7 +21,7 @@ export default function ApiKeys() {
const { data, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: () => apiKeyService.list(),
queryFn: ({ signal }) => apiKeyService.list(signal),
})
const createMutation = useMutation({

View File

@@ -20,7 +20,7 @@ export default function Config() {
const { data, isLoading } = useQuery({
queryKey: ['config', category],
queryFn: () => configService.list({ category }),
queryFn: ({ signal }) => configService.list({ category }, signal),
})
const updateMutation = useMutation({

View File

@@ -42,12 +42,12 @@ const actionColors: Record<string, string> = {
export default function Dashboard() {
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: () => statsService.dashboard(),
queryFn: ({ signal }) => statsService.dashboard(signal),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['recent-logs'],
queryFn: () => logService.list({ page: 1, page_size: 10 }),
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
})
if (statsError) {

View File

@@ -42,7 +42,7 @@ export default function Logs() {
const { data, isLoading } = useQuery({
queryKey: ['logs', page, actionFilter],
queryFn: () => logService.list({ page, page_size: 20, action: actionFilter }),
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
})
const columns: ProColumns<OperationLog>[] = [

View File

@@ -20,12 +20,12 @@ export default function Models() {
const { data, isLoading } = useQuery({
queryKey: ['models'],
queryFn: () => modelService.list(),
queryFn: ({ signal }) => modelService.list(signal),
})
const { data: providersData } = useQuery({
queryKey: ['providers-for-select'],
queryFn: () => providerService.list(),
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({

View File

@@ -26,18 +26,18 @@ export default function Prompts() {
const { data, isLoading } = useQuery({
queryKey: ['prompts'],
queryFn: () => promptService.list(),
queryFn: ({ signal }) => promptService.list(signal),
})
const { data: detailData } = useQuery({
queryKey: ['prompt-detail', detailName],
queryFn: () => promptService.get(detailName!),
queryFn: ({ signal }) => promptService.get(detailName!, signal),
enabled: !!detailName,
})
const { data: versionsData } = useQuery({
queryKey: ['prompt-versions', detailName],
queryFn: () => promptService.listVersions(detailName!),
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
enabled: !!detailName,
})

View File

@@ -22,12 +22,12 @@ export default function Providers() {
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: () => providerService.list(),
queryFn: ({ signal }) => providerService.list(signal),
})
const { data: keysData, isLoading: keysLoading } = useQuery({
queryKey: ['provider-keys', keyModalProviderId],
queryFn: () => providerService.listKeys(keyModalProviderId!),
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
enabled: !!keyModalProviderId,
})

View File

@@ -34,7 +34,7 @@ export default function Relay() {
const { data, isLoading } = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: () => relayService.list({ page, page_size: 20, status: statusFilter }),
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
})
const columns: ProColumns<RelayTask>[] = [

View File

@@ -19,12 +19,12 @@ export default function Usage() {
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
queryKey: ['usage-daily', days],
queryFn: () => telemetryService.dailyStats({ days }),
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
})
const { data: modelData, isLoading: modelLoading } = useQuery({
queryKey: ['usage-model', days],
queryFn: () => telemetryService.modelStats({}),
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
})
if (dailyError) {

View File

@@ -1,16 +1,16 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string) =>
request.get<AccountPublic>(`/accounts/${id}`).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<AccountPublic>(`/accounts/${id}`, withSignal({}, signal)).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),
update: (id: string, data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>, signal?: AbortSignal) =>
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).then((r) => r.data),
updateStatus: (id: string, data: { status: AccountPublic['status'] }) =>
request.patch(`/accounts/${id}/status`, data).then((r) => r.data),
updateStatus: (id: string, data: { status: AccountPublic['status'] }, signal?: AbortSignal) =>
request.patch(`/accounts/${id}/status`, data, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,28 +1,28 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<AgentTemplate>>('/agent-templates', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string) =>
request.get<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).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),
}, signal?: AbortSignal) =>
request.post<AgentTemplate>('/agent-templates', data, withSignal({}, signal)).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),
}, signal?: AbortSignal) =>
request.post<AgentTemplate>(`/agent-templates/${id}`, data, withSignal({}, signal)).then((r) => r.data),
archive: (id: string) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`).then((r) => r.data),
archive: (id: string, signal?: AbortSignal) =>
request.delete<AgentTemplate>(`/agent-templates/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,13 +1,13 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<TokenInfo>>('/keys', withSignal({ params }, signal)).then((r) => r.data),
create: (data: CreateTokenRequest) =>
request.post<TokenInfo>('/keys', data).then((r) => r.data),
create: (data: CreateTokenRequest, signal?: AbortSignal) =>
request.post<TokenInfo>('/keys', data, withSignal({}, signal)).then((r) => r.data),
revoke: (id: string) =>
request.delete(`/keys/${id}`).then((r) => r.data),
revoke: (id: string, signal?: AbortSignal) =>
request.delete(`/keys/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,10 +1,10 @@
import request from './request'
import request, { withSignal } 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),
login: (data: LoginRequest, signal?: AbortSignal) =>
request.post<LoginResponse>('/auth/login', data, withSignal({}, signal)).then((r) => r.data),
me: () =>
request.get<AccountPublic>('/auth/me').then((r) => r.data),
me: (signal?: AbortSignal) =>
request.get<AccountPublic>('/auth/me', withSignal({}, signal)).then((r) => r.data),
}

View File

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

View File

@@ -1,7 +1,7 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<OperationLog>>('/logs/operations', withSignal({ params }, signal)).then((r) => r.data),
}

View File

@@ -1,16 +1,16 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<Model>>('/models', withSignal({ params }, signal)).then((r) => r.data),
create: (data: Partial<Omit<Model, 'id'>>) =>
request.post<Model>('/models', data).then((r) => r.data),
create: (data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
request.post<Model>('/models', data, withSignal({}, signal)).then((r) => r.data),
update: (id: string, data: Partial<Omit<Model, 'id'>>) =>
request.patch<Model>(`/models/${id}`, data).then((r) => r.data),
update: (id: string, data: Partial<Omit<Model, 'id'>>, signal?: AbortSignal) =>
request.patch<Model>(`/models/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string) =>
request.delete(`/models/${id}`).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,35 +1,35 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).then((r) => r.data),
get: (name: string) =>
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
get: (name: string, signal?: AbortSignal) =>
request.get<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).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),
}, signal?: AbortSignal) =>
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).then((r) => r.data),
update: (name: string, data: { description?: string; status?: string }) =>
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data).then((r) => r.data),
update: (name: string, data: { description?: string; status?: string }, signal?: AbortSignal) =>
request.put<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, data, withSignal({}, signal)).then((r) => r.data),
archive: (name: string) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`).then((r) => r.data),
archive: (name: string, signal?: AbortSignal) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).then((r) => r.data),
listVersions: (name: string) =>
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`).then((r) => r.data),
listVersions: (name: string, signal?: AbortSignal) =>
request.get<PromptVersion[]>(`/prompts/${encodeURIComponent(name)}/versions`, withSignal({}, signal)).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),
}, signal?: AbortSignal) =>
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).then((r) => r.data),
rollback: (name: string, version: number) =>
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`).then((r) => r.data),
rollback: (name: string, version: number, signal?: AbortSignal) =>
request.post<PromptTemplate>(`/prompts/${encodeURIComponent(name)}/rollback/${version}`, undefined, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,31 +1,31 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<Provider>>('/providers', withSignal({ params }, signal)).then((r) => r.data),
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
request.post<Provider>('/providers', data).then((r) => r.data),
create: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
request.post<Provider>('/providers', data, withSignal({}, signal)).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),
update: (id: string, data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>, signal?: AbortSignal) =>
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string) =>
request.delete(`/providers/${id}`).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/providers/${id}`, withSignal({}, signal)).then((r) => r.data),
listKeys: (providerId: string) =>
request.get<ProviderKey[]>(`/providers/${providerId}/keys`).then((r) => r.data),
listKeys: (providerId: string, signal?: AbortSignal) =>
request.get<ProviderKey[]>(`/providers/${providerId}/keys`, withSignal({}, signal)).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),
}, signal?: AbortSignal) =>
request.post<{ ok: boolean; key_id: string }>(`/providers/${providerId}/keys`, data, withSignal({}, signal)).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),
toggleKey: (providerId: string, keyId: string, active: boolean, signal?: AbortSignal) =>
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).then((r) => r.data),
deleteKey: (providerId: string, keyId: string) =>
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`).then((r) => r.data),
deleteKey: (providerId: string, keyId: string, signal?: AbortSignal) =>
request.delete<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,10 +1,10 @@
import request from './request'
import request, { withSignal } 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),
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<RelayTask>>('/relay/tasks', withSignal({ params }, signal)).then((r) => r.data),
get: (id: string) =>
request.get<RelayTask>(`/relay/tasks/${id}`).then((r) => r.data),
get: (id: string, signal?: AbortSignal) =>
request.get<RelayTask>(`/relay/tasks/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -1,9 +1,13 @@
// ============================================================
// ZCLAW Admin V2 — Axios 实例 + JWT 拦截器
// ============================================================
//
// 认证策略: 主路径使用 HttpOnly cookie浏览器自动附加
// Authorization header 作为 fallback 保留用于 API 客户端。
import axios from 'axios'
import type { AxiosError, InternalAxiosRequestConfig } from 'axios'
import type { AxiosRequestConfig } from 'axios'
import type { ApiError } from '@/types'
import { useAuthStore } from '@/stores/authStore'
@@ -25,9 +29,10 @@ const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:自动附加 JWT ──────────────────────────────
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
@@ -76,9 +81,15 @@ request.interceptors.response.use(
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
withCredentials: true, // 发送 refresh cookie
})
const newToken = res.data.token as string
const newRefreshToken = res.data.refresh_token as string
// 更新内存中的 token实际认证通过 HttpOnly cookie浏览器已自动更新
store.setToken(newToken)
if (newRefreshToken) {
store.setRefreshToken(newRefreshToken)
}
onTokenRefreshed(newToken)
originalRequest.headers.Authorization = `Bearer ${newToken}`
return request(originalRequest)
@@ -106,3 +117,11 @@ request.interceptors.response.use(
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}

View File

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

View File

@@ -1,10 +1,10 @@
import request from './request'
import request, { withSignal } 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),
modelStats: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<ModelUsageStat[]>('/telemetry/stats', withSignal({ params }, signal)).then((r) => r.data),
dailyStats: (params?: { days?: number }) =>
request.get<DailyUsageStat[]>('/telemetry/daily', { params }).then((r) => r.data),
dailyStats: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<DailyUsageStat[]>('/telemetry/daily', withSignal({ params }, signal)).then((r) => r.data),
}

View File

@@ -1,12 +1,12 @@
import request from './request'
import request, { withSignal } 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' } })
daily: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<{ by_day: UsageRecord[] }>('/usage', withSignal({ params: { ...params, group_by: 'day' } }, signal))
.then((r) => r.data.by_day || []),
byModel: (params?: { days?: number }) =>
request.get<{ by_model: UsageByModel[] }>('/usage', { params: { ...params, group_by: 'model' } })
byModel: (params?: { days?: number }, signal?: AbortSignal) =>
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
.then((r) => r.data.by_model || []),
}

View File

@@ -1,6 +1,10 @@
// ============================================================
// ZCLAW Admin V2 — Zustand 认证状态管理
// ============================================================
//
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
// 内存中的 token/refreshToken 仅用于 Authorization header fallbackAPI 客户端兼容)。
import { create } from 'zustand'
import type { AccountPublic } from '@/types'
@@ -14,25 +18,22 @@ const ROLE_PERMISSIONS: Record<string, string[]> = {
],
admin: [
'account:read', 'account:admin', 'provider:manage', 'model:read',
'model:manage', 'relay:use', 'relay:admin', 'config:read',
'model:manage', 'relay:use', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const TOKEN_KEY = 'zclaw_admin_token'
const REFRESH_KEY = 'zclaw_admin_refresh_token'
const ACCOUNT_KEY = 'zclaw_admin_account'
function loadFromStorage(): { token: string | null; refreshToken: string | null; account: AccountPublic | null } {
const token = localStorage.getItem(TOKEN_KEY)
const refreshToken = localStorage.getItem(REFRESH_KEY)
/** 从 localStorage 恢复 account 信息token 通过 HttpOnly cookie 管理) */
function loadFromStorage(): { account: AccountPublic | null } {
const raw = localStorage.getItem(ACCOUNT_KEY)
let account: AccountPublic | null = null
if (raw) {
try { account = JSON.parse(raw) } catch { /* ignore */ }
}
return { token, refreshToken, account }
return { account }
}
interface AuthState {
@@ -42,6 +43,7 @@ interface AuthState {
permissions: string[]
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
@@ -49,23 +51,28 @@ interface AuthState {
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account ? (ROLE_PERMISSIONS[stored.account.role] ?? []) : []
const perms = stored.account?.role
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
: []
return {
token: stored.token,
refreshToken: stored.refreshToken,
token: null,
refreshToken: null,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
localStorage.setItem(TOKEN_KEY, token)
set({ token })
},
setRefreshToken: (refreshToken: string) => {
set({ refreshToken })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(REFRESH_KEY, refreshToken)
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
// token 仅存内存(实际认证通过 HttpOnly cookie
set({
token,
refreshToken,
@@ -75,10 +82,10 @@ export const useAuthStore = create<AuthState>((set, get) => {
},
logout: () => {
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(REFRESH_KEY)
localStorage.removeItem(ACCOUNT_KEY)
set({ token: null, refreshToken: null, account: null, permissions: [] })
// 调用后端 logout 清除 HttpOnly cookiesfire-and-forget
fetch('/api/v1/auth/logout', { method: 'POST', credentials: 'include' }).catch(() => {})
},
hasPermission: (permission: string) => {

View File

@@ -12,9 +12,26 @@ export default defineConfig({
server: {
port: 5173,
proxy: {
// SSE relay 端点需要长超时(流式响应可持续数分钟)
'/api/v1/relay/chat/completions': {
target: 'http://localhost:8080',
changeOrigin: true,
timeout: 600_000,
proxyTimeout: 600_000,
},
'/api': {
target: 'http://localhost:8080',
changeOrigin: true,
timeout: 30_000,
proxyTimeout: 30_000,
configure: (proxy) => {
proxy.on('proxyReq', (proxyReq) => {
proxyReq.setTimeout(30_000)
})
proxy.on('proxyRes', (proxyRes) => {
proxyRes.setTimeout(30_000)
})
},
},
},
},

2
admin/.gitignore vendored
View File

@@ -1,2 +0,0 @@
.next/
node_modules/

5
admin/next-env.d.ts vendored
View File

@@ -1,5 +0,0 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.

View File

@@ -1,13 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
async rewrites() {
return [
{
source: '/api/:path*',
destination: 'http://localhost:8080/api/:path*',
},
]
},
}
module.exports = nextConfig

View File

@@ -1,38 +0,0 @@
{
"name": "zclaw-admin",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"@radix-ui/react-dialog": "^1.1.14",
"@radix-ui/react-select": "^2.2.5",
"@radix-ui/react-separator": "^1.1.7",
"@radix-ui/react-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"lucide-react": "^0.484.0",
"next": "14.2.29",
"react": "^18.3.1",
"react-dom": "^18.3.1",
"recharts": "^2.15.3",
"swr": "^2.4.1",
"tailwind-merge": "^3.0.2"
},
"devDependencies": {
"@types/node": "^20.17.19",
"@types/react": "^18.3.18",
"@types/react-dom": "^18.3.5",
"autoprefixer": "^10.4.20",
"postcss": "^8.5.3",
"tailwindcss": "^3.4.17",
"typescript": "^5.7.3"
},
"packageManager": "pnpm@10.30.2"
}

2200
admin/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +0,0 @@
module.exports = {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
}

View File

@@ -1,373 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Search,
Plus,
Loader2,
ChevronLeft,
ChevronRight,
Pencil,
Ban,
CheckCircle2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import { useDebounce } from '@/hooks/use-debounce'
import type { AccountPublic } from '@/lib/types'
const PAGE_SIZE = 20
const roleLabels: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
user: '普通用户',
}
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
active: 'success',
disabled: 'destructive',
suspended: 'warning',
}
const statusLabels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
suspended: '已暂停',
}
export default function AccountsPage() {
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [mutationError, setMutationError] = useState('')
const debouncedSearch = useDebounce(search, 300)
const { data, error: swrError, isLoading, mutate } = useSWR(
['accounts', page, debouncedSearch, roleFilter, statusFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (debouncedSearch.trim()) params.search = debouncedSearch.trim()
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.status = statusFilter
return api.accounts.list(params)
},
)
const accounts = data?.items ?? []
const total = data?.total ?? 0
const error = getSwrErrorMessage(swrError) || mutationError
// 编辑 Dialog
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
const [editSaving, setEditSaving] = useState(false)
// 确认 Dialog
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
const [confirmSaving, setConfirmSaving] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function openEditDialog(account: AccountPublic) {
setEditTarget(account)
setEditForm({
display_name: account.display_name,
email: account.email,
role: account.role,
})
}
async function handleEditSave() {
if (!editTarget) return
setEditSaving(true)
try {
await api.accounts.update(editTarget.id, {
display_name: editForm.display_name,
email: editForm.email,
role: editForm.role as AccountPublic['role'],
})
setEditTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) {
setMutationError(err.body.message)
}
} finally {
setEditSaving(false)
}
}
function openConfirmDialog(account: AccountPublic) {
const newStatus = account.status === 'active' ? 'disabled' : 'active'
setConfirmTarget({
id: account.id,
action: newStatus === 'disabled' ? '禁用' : '启用',
status: newStatus,
})
}
async function handleConfirmSave() {
if (!confirmTarget) return
setConfirmSaving(true)
try {
await api.accounts.updateStatus(confirmTarget.id, {
status: confirmTarget.status as AccountPublic['status'],
})
setConfirmTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) {
setMutationError(err.body.message)
}
} finally {
setConfirmSaving(false)
}
}
return (
<div className="space-y-4">
{/* 搜索和筛选 */}
<div className="flex flex-wrap items-center gap-3">
<div className="relative flex-1 min-w-[200px] max-w-sm">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="搜索用户名 / 邮箱 / 显示名..."
value={search}
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
className="pl-10"
/>
</div>
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="角色筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="super_admin"></SelectItem>
<SelectItem value="admin"></SelectItem>
<SelectItem value="user"></SelectItem>
</SelectContent>
</Select>
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="active"></SelectItem>
<SelectItem value="disabled"></SelectItem>
<SelectItem value="suspended"></SelectItem>
</SelectContent>
</Select>
</div>
{/* 错误提示 */}
{error && <ErrorBanner message={error} onDismiss={() => { setMutationError('') }} />}
{/* 表格 */}
{isLoading ? (
<TableSkeleton rows={6} cols={7} />
) : error ? null : accounts.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{accounts.map((account) => (
<TableRow key={account.id}>
<TableCell className="font-medium">{account.username}</TableCell>
<TableCell className="text-muted-foreground">{account.email}</TableCell>
<TableCell>{account.display_name || '-'}</TableCell>
<TableCell>
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
{roleLabels[account.role] || account.role}
</Badge>
</TableCell>
<TableCell>
<Badge variant={statusColors[account.status] || 'secondary'}>
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
{statusLabels[account.status] || account.status}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDate(account.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => openEditDialog(account)}
title="编辑"
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => openConfirmDialog(account)}
title={account.status === 'active' ? '禁用' : '启用'}
>
{account.status === 'active' ? (
<Ban className="h-4 w-4 text-destructive" />
) : (
<CheckCircle2 className="h-4 w-4 text-green-400" />
)}
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* 分页 */}
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
disabled={page <= 1}
onClick={() => setPage(page - 1)}
>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
disabled={page >= totalPages}
onClick={() => setPage(page + 1)}
>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
{/* 编辑 Dialog */}
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription></DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label></Label>
<Input
value={editForm.display_name}
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
type="email"
value={editForm.email}
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="user"></SelectItem>
<SelectItem value="admin"></SelectItem>
<SelectItem value="super_admin"></SelectItem>
</SelectContent>
</Select>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setEditTarget(null)}>
</Button>
<Button onClick={handleEditSave} disabled={editSaving}>
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 确认 Dialog */}
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>{confirmTarget?.action}</DialogTitle>
<DialogDescription>
{confirmTarget?.action}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
</Button>
<Button
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
onClick={handleConfirmSave}
disabled={confirmSaving}
>
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{confirmTarget?.action}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,290 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { api } from '@/lib/api-client'
import type { AgentTemplate } from '@/lib/types'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
export default function AgentTemplatesPage() {
const [page, setPage] = useState(1)
const [error, setError] = useState('')
const [showCreate, setShowCreate] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading, mutate } = useSWR(
['agentTemplates.list', page],
() => api.agentTemplates.list({ page, page_size: 50 }),
)
const templates = data?.items ?? []
const total = data?.total ?? 0
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
try {
const tools = (fd.get('tools') as string || '').split(',').map(s => s.trim()).filter(Boolean)
const capabilities = (fd.get('capabilities') as string || '').split(',').map(s => s.trim()).filter(Boolean)
await api.agentTemplates.create({
name: fd.get('name') as string,
description: (fd.get('description') as string) || undefined,
category: (fd.get('category') as string) || 'general',
model: (fd.get('model') as string) || undefined,
system_prompt: (fd.get('system_prompt') as string) || undefined,
tools: tools.length > 0 ? tools : undefined,
capabilities: capabilities.length > 0 ? capabilities : undefined,
temperature: (fd.get('temperature') as string) ? parseFloat(fd.get('temperature') as string) : undefined,
max_tokens: (fd.get('max_tokens') as string) ? parseInt(fd.get('max_tokens') as string, 10) : undefined,
visibility: (fd.get('visibility') as string) || 'public',
})
setShowCreate(false)
mutate()
} catch {
setError('创建失败')
}
}
const handleArchive = async (id: string, name: string) => {
if (!confirm(`确认归档模板 "${name}"`)) return
try {
await api.agentTemplates.archive(id)
mutate()
} catch {
setError('归档失败')
}
}
const statusBadge = (status: string) => {
const colors: Record<string, string> = {
active: 'bg-emerald-500/20 text-emerald-400',
archived: 'bg-zinc-500/20 text-zinc-400',
}
return <span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>{status}</span>
}
const sourceBadge = (source: string) => {
const colors: Record<string, string> = {
builtin: 'bg-blue-500/20 text-blue-400',
custom: 'bg-purple-500/20 text-purple-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
{source === 'builtin' ? '内置' : '自定义'}
</span>
)
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white">Agent </h1>
<p className="text-sm text-zinc-400 mt-1"> Agent </p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
+
</button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-right px-4 py-3 text-zinc-400 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={9}>
<TableSkeleton rows={5} cols={9} hasToolbar={false} />
</td>
</tr>
) : templates.length === 0 ? (
<tr><td colSpan={9}><EmptyState message="暂无 Agent 模板" /></td></tr>
) : (
templates.map(t => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3">
<div>
<span className="text-white font-medium">{t.name}</span>
{t.description && (
<p className="text-xs text-zinc-500 mt-0.5 truncate max-w-[200px]">{t.description}</p>
)}
</div>
</td>
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
<td className="px-4 py-3 text-zinc-300 font-mono text-xs">{t.model || '-'}</td>
<td className="px-4 py-3 text-zinc-400">{t.tools.length}</td>
<td className="px-4 py-3 text-zinc-400">{t.visibility}</td>
<td className="px-4 py-3">{statusBadge(t.status)}</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{new Date(t.updated_at).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => setEditingId(editingId === t.id ? null : t.id)}
className="text-zinc-400 hover:text-white mr-2"
>
</button>
{t.source === 'custom' && (
<button
onClick={() => handleArchive(t.id, t.name)}
className="text-red-400 hover:text-red-300"
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
{total}
</div>
</div>
{/* 展开详情 */}
{editingId && (() => {
const t = templates.find(t => t.id === editingId)
if (!t) return null
return (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-3">
<h2 className="text-lg font-semibold text-white">{t.name} </h2>
<button onClick={() => setEditingId(null)} className="text-zinc-400 hover:text-white text-sm"></button>
</div>
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300">{t.category}</span>
</div>
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300 font-mono">{t.model || '未指定'}</span>
</div>
<div>
<span className="text-zinc-500"></span>
<span className="text-zinc-300">{t.temperature?.toFixed(2) || '默认'}</span>
</div>
<div>
<span className="text-zinc-500"> Token</span>
<span className="text-zinc-300">{t.max_tokens || '未限制'}</span>
</div>
<div className="col-span-2">
<span className="text-zinc-500"></span>
<div className="flex flex-wrap gap-1 mt-1">
{t.tools.length > 0 ? t.tools.map(tool => (
<span key={tool} className="px-2 py-0.5 bg-zinc-800 rounded text-xs text-zinc-300">{tool}</span>
)) : <span className="text-zinc-600"></span>}
</div>
</div>
<div className="col-span-2">
<span className="text-zinc-500"></span>
<div className="flex flex-wrap gap-1 mt-1">
{t.capabilities.length > 0 ? t.capabilities.map(cap => (
<span key={cap} className="px-2 py-0.5 bg-blue-500/10 rounded text-xs text-blue-400">{cap}</span>
)) : <span className="text-zinc-600"></span>}
</div>
</div>
{t.system_prompt && (
<div className="col-span-2">
<span className="text-zinc-500"></span>
<pre className="text-xs text-zinc-400 bg-zinc-800/50 rounded p-2 mt-1 overflow-x-auto max-h-32">
{t.system_prompt.substring(0, 500)}{t.system_prompt.length > 500 ? '...' : ''}
</pre>
</div>
)}
</div>
</div>
)
})()}
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4 max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold text-white"> Agent </h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"> *</label>
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_agent" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="general"></option>
<option value="coding"></option>
<option value="research"></option>
<option value="creative"></option>
<option value="assistant"></option>
</select>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="model" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="如 glm-4-plus" />
</div>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" rows={4} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" placeholder="Agent 系统提示词" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="tools" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="browser, file_system, code_execute" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="capabilities" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="streaming, vision, function_calling" />
</div>
<div className="grid grid-cols-3 gap-4">
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="temperature" type="number" step="0.1" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="默认" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"> Token</label>
<input name="max_tokens" type="number" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="不限" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="visibility" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="public"></option>
<option value="team"></option>
<option value="private"></option>
</select>
</div>
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@@ -1,332 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Plus,
Loader2,
ChevronLeft,
ChevronRight,
Trash2,
Copy,
Check,
AlertTriangle,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { api } from '@/lib/api-client'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, getSwrErrorMessage } from '@/lib/utils'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { TokenInfo } from '@/lib/types'
const PAGE_SIZE = 20
const allPermissions = [
{ key: 'chat', label: '对话' },
{ key: 'relay', label: '中转' },
{ key: 'admin', label: '管理' },
]
export default function ApiKeysPage() {
const [page, setPage] = useState(1)
const [mutationError, setMutationError] = useState('')
const { data, error: swrError, isLoading, mutate } = useSWR(
['tokens', page],
() => api.tokens.list({ page, page_size: PAGE_SIZE }),
)
const tokens = data?.items ?? []
const total = data?.total ?? 0
const error = getSwrErrorMessage(swrError) || mutationError
// 创建 Dialog
const [createOpen, setCreateOpen] = useState(false)
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
const [creating, setCreating] = useState(false)
// 创建成功显示 token
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
const [copied, setCopied] = useState(false)
// 撤销确认
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
const [revoking, setRevoking] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function togglePermission(perm: string) {
setCreateForm((prev) => ({
...prev,
permissions: prev.permissions.includes(perm)
? prev.permissions.filter((p) => p !== perm)
: [...prev.permissions, perm],
}))
}
async function handleCreate() {
if (!createForm.name.trim() || createForm.permissions.length === 0) return
setCreating(true)
try {
const payload = {
name: createForm.name.trim(),
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
permissions: createForm.permissions,
}
const res = await api.tokens.create(payload)
setCreateOpen(false)
setCreatedToken(res)
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setMutationError(err.body.message)
} finally {
setCreating(false)
}
}
async function handleRevoke() {
if (!revokeTarget) return
setRevoking(true)
try {
await api.tokens.revoke(revokeTarget.id)
setRevokeTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setMutationError(err.body.message)
} finally {
setRevoking(false)
}
}
async function copyToken() {
if (!createdToken?.token) return
try {
await navigator.clipboard.writeText(createdToken.token)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
} catch {
// Fallback
const textarea = document.createElement('textarea')
textarea.value = createdToken.token
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
setCopied(true)
setTimeout(() => setCopied(false), 2000)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div />
<Button onClick={() => setCreateOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setMutationError('')} />}
{isLoading ? (
<TableSkeleton rows={6} cols={7} />
) : error ? null : tokens.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>使</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tokens.map((t) => (
<TableRow key={t.id}>
<TableCell className="font-medium">{t.name}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{t.token_prefix}...
</TableCell>
<TableCell>
<div className="flex gap-1">
{t.permissions.map((p) => (
<Badge key={p} variant="outline" className="text-xs">
{p}
</Badge>
))}
</div>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDate(t.created_at)}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
{/* 创建 Dialog */}
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle> API </DialogTitle>
<DialogDescription> API </DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label> *</Label>
<Input
value={createForm.name}
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
placeholder="例如: 生产环境"
/>
</div>
<div className="space-y-2">
<Label> ()</Label>
<Input
type="number"
value={createForm.expires_days}
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
placeholder="365"
/>
</div>
<div className="space-y-2">
<Label> *</Label>
<div className="flex flex-wrap gap-3 mt-1">
{allPermissions.map((perm) => (
<label
key={perm.key}
className="flex items-center gap-2 cursor-pointer"
>
<input
type="checkbox"
checked={createForm.permissions.includes(perm.key)}
onChange={() => togglePermission(perm.key)}
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
/>
<span className="text-sm text-foreground">{perm.label}</span>
</label>
))}
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setCreateOpen(false)}></Button>
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 创建成功 Dialog */}
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
</DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="rounded-md bg-muted p-4">
<p className="text-xs text-muted-foreground mb-2"></p>
<p className="font-mono text-sm break-all text-foreground">
{createdToken?.token}
</p>
</div>
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
</div>
</div>
<DialogFooter>
<Button onClick={copyToken} variant="outline">
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
{copied ? '已复制' : '复制密钥'}
</Button>
<Button onClick={() => setCreatedToken(null)}></Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 撤销确认 */}
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&quot;{revokeTarget?.name}&quot; 使访
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setRevokeTarget(null)}></Button>
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,260 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Loader2,
Pencil,
RotateCcw,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { api } from '@/lib/api-client'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { ApiRequestError } from '@/lib/api-client'
import type { ConfigItem } from '@/lib/types'
const sourceLabels: Record<string, string> = {
default: '默认值',
env: '环境变量',
db: '数据库',
}
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
default: 'secondary',
env: 'info',
db: 'default',
}
export default function ConfigPage() {
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('all')
// SWR for config list
const { data: configs = [], isLoading, mutate } = useSWR(
['config', activeTab],
() => {
const params: Record<string, unknown> = {}
if (activeTab !== 'all') params.category = activeTab
return api.config.list(params)
}
)
// 编辑 Dialog
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
const [editValue, setEditValue] = useState('')
const [saving, setSaving] = useState(false)
function openEditDialog(config: ConfigItem) {
setEditTarget(config)
setEditValue(config.current_value ?? '')
}
async function handleSave() {
if (!editTarget) return
setSaving(true)
try {
let parsedValue: string | number | boolean = editValue
if (editTarget.value_type === 'number') {
parsedValue = parseFloat(editValue) || 0
} else if (editTarget.value_type === 'boolean') {
parsedValue = editValue === 'true'
}
await api.config.update(editTarget.id, { value: parsedValue })
setEditTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setSaving(false)
}
}
function formatValue(value: unknown): string {
if (value === undefined || value === null) return '-'
if (typeof value === 'boolean') return value ? 'true' : 'false'
return String(value)
}
const categoryLabels: Record<string, string> = {
all: '全部',
server: '服务器',
agent: 'Agent',
memory: '记忆',
llm: 'LLM',
security: '安全策略',
}
const categories = Object.keys(categoryLabels)
return (
<div className="space-y-4">
{/* 分类 Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
{categories.map((cat) => (
<TabsTrigger key={cat} value={cat}>
{categoryLabels[cat] || cat}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{isLoading ? (
<TableSkeleton rows={8} cols={8} hasToolbar={false} />
) : error ? null : configs.length === 0 ? (
<EmptyState message="暂无配置项" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Key</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{configs.map((config) => (
<TableRow key={config.id}>
<TableCell>
<Badge variant="outline">{config.category}</Badge>
</TableCell>
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
<TableCell className="font-mono text-sm max-w-[200px] truncate">
{formatValue(config.current_value)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
{formatValue(config.default_value)}
</TableCell>
<TableCell>
<Badge variant={sourceVariants[config.source] || 'secondary'}>
{sourceLabels[config.source] || config.source}
</Badge>
</TableCell>
<TableCell>
{config.requires_restart ? (
<Badge variant="warning"></Badge>
) : (
<span className="text-muted-foreground"></span>
)}
</TableCell>
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
{config.description || '-'}
</TableCell>
<TableCell className="text-right">
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)}
{/* 编辑 Dialog */}
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
{editTarget?.key_path}
{editTarget?.requires_restart && (
<span className="block mt-1 text-yellow-400 text-xs">
注意: 修改此配置需要重启服务才能生效
</span>
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Key</Label>
<Input value={editTarget?.key_path || ''} disabled />
</div>
<div className="space-y-2">
<Label></Label>
<Input value={editTarget?.value_type || ''} disabled />
</div>
<div className="space-y-2">
<Label>
{editTarget?.default_value != null && (
<span className="text-xs text-muted-foreground ml-2">
(: {formatValue(editTarget.default_value)})
</span>
)}
</Label>
{editTarget?.value_type === 'boolean' ? (
<Select value={editValue} onValueChange={setEditValue}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="true">true</SelectItem>
<SelectItem value="false">false</SelectItem>
</SelectContent>
</Select>
) : (
<Input
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
/>
)}
</div>
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
if (editTarget?.default_value != null) {
setEditValue(String(editTarget.default_value))
}
}}
>
<RotateCcw className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={() => setEditTarget(null)}></Button>
<Button onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,241 +0,0 @@
'use client'
import { useState, type ReactNode } from 'react'
import Link from 'next/link'
import { usePathname, useRouter } from 'next/navigation'
import {
LayoutDashboard,
Users,
Server,
Cpu,
Key,
BarChart3,
ArrowLeftRight,
Settings,
FileText,
MessageSquare,
Bot,
LogOut,
ChevronLeft,
Menu,
Bell,
} from 'lucide-react'
import { AuthGuard, useAuth } from '@/components/auth-guard'
import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
/** 权限常量 — 与后端 db.rs SEED_ROLES 保持同步 */
const ROLE_PERMISSIONS: Record<string, string[]> = {
super_admin: ['admin:full', 'account:admin', 'provider:manage', 'model:manage', 'relay:admin', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish', 'prompt:admin'],
admin: ['account:read', 'account:admin', 'provider:manage', 'model:read', 'model:manage', 'relay:use', 'relay:admin', 'config:read', 'config:write', 'prompt:read', 'prompt:write', 'prompt:publish'],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
/** 根据 role 获取权限列表 */
function getPermissionsForRole(role: string): string[] {
return ROLE_PERMISSIONS[role] ?? []
}
const navItems = [
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
{ href: '/accounts', label: '账号管理', icon: Users, permission: 'account:admin' },
{ href: '/providers', label: '服务商', icon: Server, permission: 'provider:manage' },
{ href: '/models', label: '模型管理', icon: Cpu, permission: 'model:read' },
{ href: '/agent-templates', label: 'Agent 模板', icon: Bot, permission: 'model:read' },
{ href: '/api-keys', label: 'API 密钥', icon: Key, permission: 'admin:full' },
{ href: '/usage', label: '用量统计', icon: BarChart3, permission: 'admin:full' },
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight, permission: 'relay:use' },
{ href: '/config', label: '系统配置', icon: Settings, permission: 'config:read' },
{ href: '/prompts', label: '提示词管理', icon: MessageSquare, permission: 'prompt:read' },
{ href: '/logs', label: '操作日志', icon: FileText, permission: 'admin:full' },
]
function Sidebar({
collapsed,
onToggle,
}: {
collapsed: boolean
onToggle: () => void
}) {
const pathname = usePathname()
const router = useRouter()
const { account } = useAuth()
const permissions = account ? getPermissionsForRole(account.role) : []
function handleLogout() {
logout()
router.replace('/login')
}
const filteredNavItems = navItems.filter((item) => {
if (!item.permission) return true
return permissions.includes(item.permission) || permissions.includes('admin:full')
})
return (
<aside
className={cn(
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
collapsed ? 'w-16' : 'w-64',
)}
>
{/* Logo */}
<div className="flex h-14 items-center border-b border-border px-4">
<Link href="/" className="flex items-center gap-2 cursor-pointer">
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
Z
</div>
{!collapsed && (
<div className="flex flex-col">
<span className="text-sm font-bold text-foreground">ZCLAW</span>
<span className="text-[10px] text-muted-foreground">Admin</span>
</div>
)}
</Link>
</div>
{/* 导航 */}
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
<ul className="space-y-1">
{filteredNavItems.map((item) => {
const isActive =
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href)
const Icon = item.icon
return (
<li key={item.href}>
<Link
href={item.href}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
isActive
? 'bg-muted text-green-400'
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
collapsed && 'justify-center px-2',
)}
title={collapsed ? item.label : undefined}
>
<Icon className="h-4 w-4 shrink-0" />
{!collapsed && <span>{item.label}</span>}
</Link>
</li>
)
})}
</ul>
</nav>
{/* 底部折叠按钮 */}
<div className="border-t border-border p-2">
<button
onClick={onToggle}
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
>
<ChevronLeft
className={cn(
'h-4 w-4 transition-transform duration-200',
collapsed && 'rotate-180',
)}
/>
</button>
</div>
{/* 用户信息 */}
{!collapsed && (
<div className="border-t border-border p-3">
<div className="flex items-center gap-3">
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
</div>
<div className="flex-1 min-w-0">
<p className="truncate text-sm font-medium text-foreground">
{account?.display_name || account?.username || 'Admin'}
</p>
<p className="truncate text-xs text-muted-foreground">
{account?.role || 'admin'}
</p>
</div>
<button
onClick={handleLogout}
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
title="退出登录"
>
<LogOut className="h-4 w-4" />
</button>
</div>
</div>
)}
</aside>
)
}
function Header() {
const pathname = usePathname()
const currentNav = navItems.find(
(item) =>
item.href === '/'
? pathname === '/'
: pathname.startsWith(item.href),
)
return (
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
{/* 移动端菜单按钮 */}
<MobileMenuButton />
{/* 页面标题 */}
<h1 className="text-lg font-semibold text-foreground">
{currentNav?.label || '仪表盘'}
</h1>
<div className="ml-auto flex items-center gap-2">
{/* 通知 */}
<button
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
title="通知"
>
<Bell className="h-4 w-4" />
</button>
</div>
</header>
)
}
function MobileMenuButton() {
// Placeholder for mobile menu toggle
return (
<button
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
>
<Menu className="h-5 w-5" />
</button>
)
}
export default function DashboardLayout({ children }: { children: ReactNode }) {
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
return (
<AuthGuard>
<div className="flex min-h-screen">
<Sidebar
collapsed={sidebarCollapsed}
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
/>
<div
className={cn(
'flex flex-1 flex-col transition-all duration-300',
sidebarCollapsed ? 'ml-16' : 'ml-64',
)}
>
<Header />
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
{children}
</main>
</div>
</div>
</AuthGuard>
)
}

View File

@@ -1,414 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Plus,
Loader2,
ChevronLeft,
ChevronRight,
Pencil,
Trash2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils'
import type { Model, Provider } from '@/lib/types'
const PAGE_SIZE = 20
interface ModelForm {
provider_id: string
model_id: string
alias: string
context_window: string
max_output_tokens: string
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
pricing_input: string
pricing_output: string
}
const emptyForm: ModelForm = {
provider_id: '',
model_id: '',
alias: '',
context_window: '4096',
max_output_tokens: '4096',
supports_streaming: true,
supports_vision: false,
enabled: true,
pricing_input: '',
pricing_output: '',
}
export default function ModelsPage() {
const [page, setPage] = useState(1)
const [providerFilter, setProviderFilter] = useState<string>('all')
const [error, setError] = useState('')
// SWR for models list
const { data, isLoading, mutate } = useSWR(
['models', page, providerFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (providerFilter !== 'all') params.provider_id = providerFilter
return api.models.list(params)
}
)
const models = data?.items ?? []
const total = data?.total ?? 0
// SWR for providers list (dropdown)
const { data: providersData } = useSWR(
['providers.all'],
() => api.providers.list({ page: 1, page_size: 100 })
)
const providers = providersData?.items ?? []
// Dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Model | null>(null)
const [form, setForm] = useState<ModelForm>(emptyForm)
const [saving, setSaving] = useState(false)
// 删除
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
const [deleting, setDeleting] = useState(false)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
function openCreateDialog() {
setEditTarget(null)
setForm(emptyForm)
setDialogOpen(true)
}
function openEditDialog(model: Model) {
setEditTarget(model)
setForm({
provider_id: model.provider_id,
model_id: model.model_id,
alias: model.alias,
context_window: model.context_window.toString(),
max_output_tokens: model.max_output_tokens.toString(),
supports_streaming: model.supports_streaming,
supports_vision: model.supports_vision,
enabled: model.enabled,
pricing_input: model.pricing_input.toString(),
pricing_output: model.pricing_output.toString(),
})
setDialogOpen(true)
}
async function handleSave() {
if (!form.model_id.trim() || !form.provider_id) return
setSaving(true)
try {
const payload = {
provider_id: form.provider_id,
model_id: form.model_id.trim(),
alias: form.alias.trim(),
context_window: parseInt(form.context_window, 10) || 4096,
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
supports_streaming: form.supports_streaming,
supports_vision: form.supports_vision,
enabled: form.enabled,
pricing_input: parseFloat(form.pricing_input) || 0,
pricing_output: parseFloat(form.pricing_output) || 0,
}
if (editTarget) {
await api.models.update(editTarget.id, payload)
} else {
await api.models.create(payload)
}
setDialogOpen(false)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await api.models.delete(deleteTarget.id)
setDeleteTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setDeleting(false)
}
}
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
<SelectTrigger className="w-[200px]">
<SelectValue placeholder="按服务商筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
{providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{isLoading ? (
<TableSkeleton rows={8} cols={9} hasToolbar={false} />
) : error ? null : models.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{models.map((m) => (
<TableRow key={m.id}>
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
<TableCell>{m.alias || '-'}</TableCell>
<TableCell className="text-muted-foreground">
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(m.context_window)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(m.max_output_tokens)}
</TableCell>
<TableCell>
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
{m.supports_streaming ? '是' : '否'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
{m.supports_vision ? '是' : '否'}
</Badge>
</TableCell>
<TableCell>
<Badge variant={m.enabled ? 'success' : 'destructive'}>
{m.enabled ? '启用' : '禁用'}
</Badge>
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
{/* 创建/编辑 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
<DialogDescription>
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
<div className="space-y-2">
<Label> *</Label>
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
<SelectTrigger>
<SelectValue placeholder="选择服务商" />
</SelectTrigger>
<SelectContent>
{providers.map((p) => (
<SelectItem key={p.id} value={p.id}>
{p.display_name || p.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label> ID *</Label>
<Input
value={form.model_id}
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
placeholder="gpt-4o"
disabled={!!editTarget}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.alias}
onChange={(e) => setForm({ ...form, alias: e.target.value })}
placeholder="GPT-4o"
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={form.context_window}
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label> Tokens</Label>
<Input
type="number"
value={form.max_output_tokens}
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Input ($/1M tokens)</Label>
<Input
type="number"
step="0.01"
value={form.pricing_input}
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
placeholder="0"
/>
</div>
<div className="space-y-2">
<Label>Output ($/1M tokens)</Label>
<Input
type="number"
step="0.01"
value={form.pricing_output}
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
placeholder="0"
/>
</div>
</div>
<div className="flex items-center gap-6">
<div className="flex items-center gap-2">
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
<Label></Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
<Label></Label>
</div>
<div className="flex items-center gap-2">
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
<Label></Label>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&quot;{deleteTarget?.alias || deleteTarget?.model_id}&quot;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,315 +0,0 @@
'use client'
import {
Users,
Server,
ArrowLeftRight,
Zap,
TrendingUp,
} from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts'
import useSWR from 'swr'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { StatsSkeleton } from '@/components/ui/skeleton'
import { ChartSkeleton } from '@/components/ui/skeleton'
import { TableSkeleton } from '@/components/ui/skeleton'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { api } from '@/lib/api-client'
import { formatNumber, formatDate } from '@/lib/utils'
import type {
DashboardStats,
UsageRecord,
OperationLog,
} from '@/lib/types'
interface StatCardProps {
title: string
value: string | number
icon: React.ReactNode
color: string
subtitle?: string
}
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
return (
<Card>
<CardContent className="p-6">
<div className="flex items-center justify-between">
<div>
<p className="text-sm text-muted-foreground">{title}</p>
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
{subtitle && (
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
)}
</div>
<div
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
>
{icon}
</div>
</div>
</CardContent>
</Card>
)
}
function StatusBadge({ status }: { status: string }) {
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
active: 'success',
completed: 'success',
disabled: 'destructive',
failed: 'destructive',
processing: 'info',
queued: 'warning',
suspended: 'destructive',
}
return (
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
)
}
export default function DashboardPage() {
const { data: stats, isLoading: statsLoading } = useSWR(
['stats.dashboard'],
() => api.stats.dashboard(),
)
const { data: usageData = [], isLoading: usageLoading } = useSWR(
['usage.daily.30'],
() => api.usage.daily({ days: 30 }),
)
const { data: logsData, isLoading: logsLoading } = useSWR(
['logs.recent'],
() => api.logs.list({ page: 1, page_size: 5 }),
)
const recentLogs: OperationLog[] = logsData?.items ?? []
const chartData = usageData.map((r: UsageRecord) => ({
day: r.day.slice(5), // MM-DD
请求量: r.count,
Input: r.input_tokens,
Output: r.output_tokens,
}))
return (
<div className="space-y-6">
{/* 统计卡片 */}
{statsLoading ? (
<StatsSkeleton count={4} />
) : (
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="总账号数"
value={stats?.total_accounts ?? '-'}
icon={<Users className="h-5 w-5 text-blue-400" />}
color="bg-blue-500/10"
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
/>
<StatCard
title="活跃服务商"
value={stats?.active_providers ?? '-'}
icon={<Server className="h-5 w-5 text-green-400" />}
color="bg-green-500/10"
subtitle={`模型 ${stats?.active_models ?? 0}`}
/>
<StatCard
title="今日请求"
value={stats?.tasks_today ?? '-'}
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
color="bg-purple-500/10"
subtitle="中转任务"
/>
<StatCard
title="今日 Token"
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
icon={<Zap className="h-5 w-5 text-orange-400" />}
color="bg-orange-500/10"
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
/>
</div>
)}
{/* 图表 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
{/* 请求趋势 */}
{usageLoading ? (
<ChartSkeleton height={280} />
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<TrendingUp className="h-4 w-4 text-primary" />
(30 )
</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<AreaChart data={chartData}>
<defs>
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#94A3B8' }}
axisLine={{ stroke: '#1E293B' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#94A3B8' }}
axisLine={{ stroke: '#1E293B' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0F172A',
border: '1px solid #1E293B',
borderRadius: '8px',
color: '#F8FAFC',
fontSize: '12px',
}}
/>
<Area
type="monotone"
dataKey="请求量"
stroke="#22C55E"
fillOpacity={1}
fill="url(#colorRequests)"
strokeWidth={2}
/>
</AreaChart>
</ResponsiveContainer>
) : (
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
</div>
)}
</CardContent>
</Card>
)}
{/* Token 用量 */}
{usageLoading ? (
<ChartSkeleton height={280} />
) : (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-orange-400" />
Token (30 )
</CardTitle>
</CardHeader>
<CardContent>
{chartData.length > 0 ? (
<ResponsiveContainer width="100%" height={280}>
<BarChart data={chartData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
<XAxis
dataKey="day"
tick={{ fontSize: 12, fill: '#94A3B8' }}
axisLine={{ stroke: '#1E293B' }}
/>
<YAxis
tick={{ fontSize: 12, fill: '#94A3B8' }}
axisLine={{ stroke: '#1E293B' }}
/>
<Tooltip
contentStyle={{
backgroundColor: '#0F172A',
border: '1px solid #1E293B',
borderRadius: '8px',
color: '#F8FAFC',
fontSize: '12px',
}}
/>
<Legend
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
/>
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
</div>
)}
</CardContent>
</Card>
)}
</div>
{/* 最近操作日志 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{logsLoading ? (
<TableSkeleton rows={5} cols={5} hasToolbar={false} />
) : recentLogs.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead> ID</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{recentLogs.map((log) => (
<TableRow key={log.id}>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDate(log.created_at)}
</TableCell>
<TableCell className="font-mono text-xs">
{log.account_id.slice(0, 8)}...
</TableCell>
<TableCell>
<Badge variant="outline">{log.action}</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{log.target_type}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{log.target_id.slice(0, 8)}...
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
</div>
)}
</CardContent>
</Card>
</div>
)
}

View File

@@ -1,341 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { api } from '@/lib/api-client'
import type { PromptTemplate, PromptVersion } from '@/lib/types'
import { EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
export default function PromptsPage() {
const [page, setPage] = useState(1)
const [selectedName, setSelectedName] = useState<string | null>(null)
const [versions, setVersions] = useState<PromptVersion[]>([])
const [showCreate, setShowCreate] = useState(false)
const [showNewVersion, setShowNewVersion] = useState(false)
const [filter, setFilter] = useState<{ source?: string; status?: string }>({})
const { data, error, isLoading, mutate } = useSWR(
['prompts.list', page, filter.source, filter.status],
() => api.prompts.list({ page, page_size: 50, ...filter }),
)
const templates = data?.items ?? []
const total = data?.total ?? 0
const fetchVersions = async (name: string) => {
try {
const res = await api.prompts.listVersions(name)
setVersions(res)
setSelectedName(name)
} catch (err) {
console.error('Failed to fetch versions:', err)
}
}
const handleCreate = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
const fd = new FormData(e.currentTarget)
try {
await api.prompts.create({
name: fd.get('name') as string,
category: fd.get('category') as string,
description: (fd.get('description') as string) || undefined,
source: 'custom',
system_prompt: fd.get('system_prompt') as string,
})
setShowCreate(false)
mutate()
} catch (err) {
console.error('Failed to create prompt:', err)
}
}
const handleNewVersion = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault()
if (!selectedName) return
const fd = new FormData(e.currentTarget)
try {
await api.prompts.createVersion(selectedName, {
system_prompt: fd.get('system_prompt') as string,
changelog: (fd.get('changelog') as string) || undefined,
})
setShowNewVersion(false)
fetchVersions(selectedName)
} catch (err) {
console.error('Failed to create version:', err)
}
}
const handleRollback = async (name: string, version: number) => {
if (!confirm(`确认回退到版本 ${version}`)) return
try {
await api.prompts.rollback(name, version)
fetchVersions(name)
mutate()
} catch (err) {
console.error('Failed to rollback:', err)
}
}
const handleArchive = async (name: string) => {
if (!confirm(`确认归档 ${name}`)) return
try {
await api.prompts.archive(name)
mutate()
} catch (err) {
console.error('Failed to archive:', err)
}
}
const statusBadge = (status: string) => {
const colors: Record<string, string> = {
active: 'bg-emerald-500/20 text-emerald-400',
deprecated: 'bg-amber-500/20 text-amber-400',
archived: 'bg-zinc-500/20 text-zinc-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[status] || colors.archived}`}>
{status}
</span>
)
}
const sourceBadge = (source: string) => {
const colors: Record<string, string> = {
builtin: 'bg-blue-500/20 text-blue-400',
custom: 'bg-purple-500/20 text-purple-400',
}
return (
<span className={`px-2 py-0.5 text-xs rounded-full ${colors[source] || ''}`}>
{source === 'builtin' ? '内置' : '自定义'}
</span>
)
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-white"></h1>
<p className="text-sm text-zinc-400 mt-1"> OTA </p>
</div>
<button
onClick={() => setShowCreate(true)}
className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors text-sm"
>
+
</button>
</div>
{/* Filters */}
<div className="flex gap-2">
{(['all', 'builtin', 'custom'] as const).map(s => (
<button
key={s}
onClick={() => setFilter(s === 'all' ? {} : { source: s })}
className={`px-3 py-1 text-sm rounded-lg transition-colors ${
(filter.source || 'all') === s
? 'bg-zinc-700 text-white'
: 'bg-zinc-800 text-zinc-400 hover:text-white'
}`}
>
{s === 'all' ? '全部' : s === 'builtin' ? '内置' : '自定义'}
</button>
))}
</div>
{/* Template List */}
<div className="bg-zinc-900 rounded-xl border border-zinc-800 overflow-hidden">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800">
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-left px-4 py-3 text-zinc-400 font-medium"></th>
<th className="text-right px-4 py-3 text-zinc-400 font-medium"></th>
</tr>
</thead>
<tbody>
{isLoading ? (
<tr>
<td colSpan={7}>
<TableSkeleton rows={5} cols={7} hasToolbar={false} />
</td>
</tr>
) : error ? (
<tr><td colSpan={7} className="px-4 py-8 text-center text-red-400"></td></tr>
) : templates.length === 0 ? (
<tr><td colSpan={7}><EmptyState message="暂无提示词模板" /></td></tr>
) : (
templates.map(t => (
<tr key={t.id} className="border-b border-zinc-800/50 hover:bg-zinc-800/30">
<td className="px-4 py-3">
<button
onClick={() => fetchVersions(t.name)}
className="text-blue-400 hover:text-blue-300 font-mono"
>
{t.name}
</button>
</td>
<td className="px-4 py-3 text-zinc-400">{t.category}</td>
<td className="px-4 py-3">{sourceBadge(t.source)}</td>
<td className="px-4 py-3 text-zinc-300">v{t.current_version}</td>
<td className="px-4 py-3">{statusBadge(t.status)}</td>
<td className="px-4 py-3 text-zinc-500 text-xs">
{new Date(t.updated_at).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 text-right">
<button
onClick={() => fetchVersions(t.name)}
className="text-zinc-400 hover:text-white mr-2"
>
</button>
{t.source === 'custom' && (
<button
onClick={() => handleArchive(t.name)}
className="text-red-400 hover:text-red-300"
>
</button>
)}
</td>
</tr>
))
)}
</tbody>
</table>
<div className="px-4 py-2 text-xs text-zinc-500 border-t border-zinc-800">
{total}
</div>
</div>
{/* Version History Panel */}
{selectedName && (
<div className="bg-zinc-900 rounded-xl border border-zinc-800 p-4">
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-white">
{selectedName}
</h2>
<div className="flex gap-2">
<button
onClick={() => setShowNewVersion(true)}
className="px-3 py-1.5 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-xs"
>
</button>
<button
onClick={() => { setSelectedName(null); setVersions([]) }}
className="px-3 py-1.5 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-xs"
>
</button>
</div>
</div>
<div className="space-y-3">
{versions.map(v => (
<div key={v.id} className="bg-zinc-800/50 rounded-lg p-3">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-mono text-zinc-300">v{v.version}</span>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-500">
{new Date(v.created_at).toLocaleString('zh-CN')}
</span>
{v.changelog && (
<span className="text-xs text-zinc-400"> {v.changelog}</span>
)}
{v.min_app_version && (
<span className="text-xs text-amber-400">: {v.min_app_version}</span>
)}
</div>
</div>
<pre className="text-xs text-zinc-400 bg-zinc-900 rounded p-2 overflow-x-auto max-h-32">
{v.system_prompt.substring(0, 300)}{v.system_prompt.length > 300 ? '...' : ''}
</pre>
<div className="mt-2 flex gap-2">
<button
onClick={() => {
navigator.clipboard.writeText(v.system_prompt)
}}
className="text-xs text-zinc-500 hover:text-white"
>
</button>
<button
onClick={() => handleRollback(selectedName, v.version)}
className="text-xs text-amber-500 hover:text-amber-400"
>
退
</button>
</div>
</div>
))}
{versions.length === 0 && (
<EmptyState message="暂无版本历史" />
)}
</div>
</div>
)}
{/* Create Modal */}
{showCreate && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleCreate} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-white"></h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="name" required className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="my_prompt" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<select name="category" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm">
<option value="custom_system"></option>
<option value="custom_extraction"></option>
<option value="custom_compaction"></option>
<option value="custom_other"></option>
</select>
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="description" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="可选" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowCreate(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
{/* New Version Modal */}
{showNewVersion && selectedName && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50">
<form onSubmit={handleNewVersion} className="bg-zinc-900 rounded-xl border border-zinc-700 p-6 w-full max-w-lg space-y-4">
<h2 className="text-lg font-semibold text-white"> {selectedName} </h2>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<textarea name="system_prompt" required rows={6} className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm font-mono" />
</div>
<div>
<label className="block text-sm text-zinc-400 mb-1"></label>
<input name="changelog" className="w-full px-3 py-2 bg-zinc-800 border border-zinc-700 rounded-lg text-white text-sm" placeholder="描述本次变更" />
</div>
<div className="flex gap-2 justify-end">
<button type="button" onClick={() => setShowNewVersion(false)} className="px-4 py-2 bg-zinc-700 text-white rounded-lg hover:bg-zinc-600 text-sm"></button>
<button type="submit" className="px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 text-sm"></button>
</div>
</form>
</div>
)}
</div>
)
}

View File

@@ -1,605 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Plus,
Loader2,
ChevronLeft,
ChevronRight,
Pencil,
Trash2,
KeyRound,
Power,
PowerOff,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Badge } from '@/components/ui/badge'
import { Switch } from '@/components/ui/switch'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
DialogDescription,
} from '@/components/ui/dialog'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { TableSkeleton } from '@/components/ui/skeleton'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, maskApiKey } from '@/lib/utils'
function formatTokens(tokens: number): string {
if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(1)}M`
if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`
return String(tokens)
}
import type { Provider, ProviderKey } from '@/lib/types'
const PAGE_SIZE = 20
interface ProviderForm {
name: string
display_name: string
base_url: string
api_protocol: 'openai' | 'anthropic'
api_key: string
enabled: boolean
rate_limit_rpm: string
rate_limit_tpm: string
}
const emptyForm: ProviderForm = {
name: '',
display_name: '',
base_url: '',
api_protocol: 'openai',
api_key: '',
enabled: true,
rate_limit_rpm: '',
rate_limit_tpm: '',
}
export default function ProvidersPage() {
const [page, setPage] = useState(1)
const [error, setError] = useState('')
// SWR for providers list
const { data, isLoading, mutate } = useSWR(
['providers', page],
() => api.providers.list({ page, page_size: PAGE_SIZE })
)
const providers = data?.items ?? []
const total = data?.total ?? 0
// 创建/编辑 Dialog
const [dialogOpen, setDialogOpen] = useState(false)
const [editTarget, setEditTarget] = useState<Provider | null>(null)
const [form, setForm] = useState<ProviderForm>(emptyForm)
const [saving, setSaving] = useState(false)
// 删除确认 Dialog
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
const [deleting, setDeleting] = useState(false)
// Key Pool 管理
const [keyPoolProvider, setKeyPoolProvider] = useState<Provider | null>(null)
const [showAddKey, setShowAddKey] = useState(false)
const [addKeyForm, setAddKeyForm] = useState({
key_label: '',
key_value: '',
priority: 0,
max_rpm: '',
max_tpm: '',
quota_reset_interval: '',
})
const [addingKey, setAddingKey] = useState(false)
// SWR for key pool — only fetches when dialog is open
const { data: providerKeys = [], isLoading: keysLoading, mutate: mutateKeys } = useSWR(
keyPoolProvider ? ['provider.keys', keyPoolProvider.id] : null,
() => api.providers.listKeys(keyPoolProvider!.id)
)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function openCreateDialog() {
setEditTarget(null)
setForm(emptyForm)
setDialogOpen(true)
}
function openEditDialog(provider: Provider) {
setEditTarget(provider)
setForm({
name: provider.name,
display_name: provider.display_name,
base_url: provider.base_url,
api_protocol: provider.api_protocol,
api_key: provider.api_key || '',
enabled: provider.enabled,
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
})
setDialogOpen(true)
}
async function handleSave() {
if (!form.name.trim() || !form.base_url.trim()) return
setSaving(true)
try {
const payload = {
name: form.name.trim(),
display_name: form.display_name.trim(),
base_url: form.base_url.trim(),
api_protocol: form.api_protocol,
api_key: form.api_key.trim() || undefined,
enabled: form.enabled,
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
}
if (editTarget) {
await api.providers.update(editTarget.id, payload)
} else {
await api.providers.create(payload)
}
setDialogOpen(false)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setSaving(false)
}
}
async function handleDelete() {
if (!deleteTarget) return
setDeleting(true)
try {
await api.providers.delete(deleteTarget.id)
setDeleteTarget(null)
mutate()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setDeleting(false)
}
}
// ── Key Pool 管理 ─────────────────────────────────────
function openKeyPool(provider: Provider) {
setKeyPoolProvider(provider)
setShowAddKey(false)
}
async function handleAddKey() {
if (!keyPoolProvider || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()) return
setAddingKey(true)
try {
await api.providers.addKey(keyPoolProvider.id, {
key_label: addKeyForm.key_label.trim(),
key_value: addKeyForm.key_value.trim(),
priority: addKeyForm.priority,
max_rpm: addKeyForm.max_rpm ? parseInt(addKeyForm.max_rpm, 10) : undefined,
max_tpm: addKeyForm.max_tpm ? parseInt(addKeyForm.max_tpm, 10) : undefined,
quota_reset_interval: addKeyForm.quota_reset_interval.trim() || undefined,
})
setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' })
setShowAddKey(false)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setAddingKey(false)
}
}
async function handleToggleKey(keyId: string, active: boolean) {
if (!keyPoolProvider) return
try {
await api.providers.toggleKey(keyPoolProvider.id, keyId, active)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
}
}
async function handleDeleteKey(keyId: string) {
if (!keyPoolProvider || !confirm('确认删除此 Key')) return
try {
await api.providers.deleteKey(keyPoolProvider.id, keyId)
mutateKeys()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
}
}
return (
<div className="space-y-4">
{/* 工具栏 */}
<div className="flex items-center justify-between">
<div />
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{isLoading ? (
<TableSkeleton rows={6} cols={9} hasToolbar={false} />
) : error ? null : providers.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead></TableHead>
<TableHead>API Key</TableHead>
<TableHead></TableHead>
<TableHead>RPM </TableHead>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providers.map((p) => (
<TableRow key={p.id}>
<TableCell className="font-medium">{p.name}</TableCell>
<TableCell>{p.display_name || '-'}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
{p.base_url}
</TableCell>
<TableCell>
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
{p.api_protocol}
</Badge>
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{maskApiKey(p.api_key)}
</TableCell>
<TableCell>
<Badge variant={p.enabled ? 'success' : 'secondary'}>
{p.enabled ? '是' : '否'}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">
{p.rate_limit_rpm ?? '-'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDate(p.created_at)}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button variant="ghost" size="icon" onClick={() => openKeyPool(p)} title="Key Pool">
<KeyRound className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
<Pencil className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
<Trash2 className="h-4 w-4 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
{/* 创建/编辑 Dialog */}
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
<DialogDescription>
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
<div className="space-y-2">
<Label> *</Label>
<Input
value={form.name}
onChange={(e) => setForm({ ...form, name: e.target.value })}
placeholder="例如: openai"
disabled={!!editTarget}
/>
</div>
<div className="space-y-2">
<Label></Label>
<Input
value={form.display_name}
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
placeholder="例如: OpenAI"
/>
</div>
<div className="space-y-2">
<Label>Base URL *</Label>
<Input
value={form.base_url}
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
placeholder="https://api.openai.com/v1"
/>
</div>
<div className="space-y-2">
<Label>API </Label>
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="anthropic">Anthropic</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>API Key</Label>
<Input
type="password"
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
/>
</div>
<div className="flex items-center gap-3">
<Switch
checked={form.enabled}
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
/>
<Label></Label>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>RPM </Label>
<Input
type="number"
value={form.rate_limit_rpm}
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
placeholder="不限"
/>
</div>
<div className="space-y-2">
<Label>TPM </Label>
<Input
type="number"
value={form.rate_limit_tpm}
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
placeholder="不限"
/>
</div>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setDialogOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 删除确认 Dialog */}
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
&quot;{deleteTarget?.display_name || deleteTarget?.name}&quot;
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setDeleteTarget(null)}></Button>
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Key Pool 管理 Dialog */}
<Dialog open={!!keyPoolProvider} onOpenChange={() => setKeyPoolProvider(null)}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>Key Pool {keyPoolProvider?.display_name || keyPoolProvider?.name}</DialogTitle>
<DialogDescription>
API Key
</DialogDescription>
</DialogHeader>
<div className="max-h-[50vh] overflow-y-auto scrollbar-thin">
{keysLoading ? (
<TableSkeleton rows={4} cols={8} hasToolbar={false} />
) : providerKeys.length === 0 && !showAddKey ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<p> Key Pool</p>
<p className="mt-1 text-xs">使 API Key 退</p>
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>RPM</TableHead>
<TableHead>TPM</TableHead>
<TableHead></TableHead>
<TableHead>/Token</TableHead>
<TableHead> 429</TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{providerKeys.map((k) => {
const isCooling = k.cooldown_until && new Date(k.cooldown_until) > new Date()
return (
<TableRow key={k.id} className={isCooling ? 'opacity-60' : ''}>
<TableCell className="font-medium">{k.key_label}</TableCell>
<TableCell>{k.priority}</TableCell>
<TableCell className="text-muted-foreground">{k.max_rpm ?? '-'}</TableCell>
<TableCell className="text-muted-foreground">{k.max_tpm ?? '-'}</TableCell>
<TableCell>
<Badge variant={k.is_active ? 'success' : 'secondary'}>
{isCooling ? '冷却中' : k.is_active ? '活跃' : '禁用'}
</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{k.total_requests} / {formatTokens(k.total_tokens)}
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{k.last_429_at ? formatDate(k.last_429_at) : '-'}
</TableCell>
<TableCell className="text-right">
<div className="flex items-center justify-end gap-1">
<Button
variant="ghost"
size="icon"
onClick={() => handleToggleKey(k.id, !k.is_active)}
title={k.is_active ? '禁用' : '启用'}
>
{k.is_active ? <PowerOff className="h-3.5 w-3.5 text-amber-500" /> : <Power className="h-3.5 w-3.5 text-green-500" />}
</Button>
<Button
variant="ghost"
size="icon"
onClick={() => handleDeleteKey(k.id)}
title="删除"
>
<Trash2 className="h-3.5 w-3.5 text-destructive" />
</Button>
</div>
</TableCell>
</TableRow>
)
})}
</TableBody>
</Table>
)}
</div>
{!showAddKey ? (
<DialogFooter>
<Button variant="outline" onClick={() => setKeyPoolProvider(null)}></Button>
<Button onClick={() => setShowAddKey(true)}>
<Plus className="h-4 w-4 mr-2" />
Key
</Button>
</DialogFooter>
) : (
<div className="space-y-3 border-t pt-4">
<p className="text-sm font-medium"> Key</p>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<Label className="text-xs"> *</Label>
<Input
value={addKeyForm.key_label}
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_label: e.target.value })}
placeholder="如 zhipu-coding-1"
/>
</div>
<div className="space-y-1">
<Label className="text-xs"></Label>
<Input
type="number"
value={addKeyForm.priority}
onChange={(e) => setAddKeyForm({ ...addKeyForm, priority: parseInt(e.target.value, 10) || 0 })}
placeholder="0"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs">API Key *</Label>
<Input
type="password"
value={addKeyForm.key_value}
onChange={(e) => setAddKeyForm({ ...addKeyForm, key_value: e.target.value })}
placeholder="输入 API Key"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">RPM </Label>
<Input
type="number"
value={addKeyForm.max_rpm}
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_rpm: e.target.value })}
placeholder="不限"
/>
</div>
<div className="space-y-1">
<Label className="text-xs">TPM </Label>
<Input
type="number"
value={addKeyForm.max_tpm}
onChange={(e) => setAddKeyForm({ ...addKeyForm, max_tpm: e.target.value })}
placeholder="不限"
/>
</div>
<div className="col-span-2 space-y-1">
<Label className="text-xs"></Label>
<Input
value={addKeyForm.quota_reset_interval}
onChange={(e) => setAddKeyForm({ ...addKeyForm, quota_reset_interval: e.target.value })}
placeholder="如 5h, 1d可选"
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => { setShowAddKey(false); setAddKeyForm({ key_label: '', key_value: '', priority: 0, max_rpm: '', max_tpm: '', quota_reset_interval: '' }) }}>
</Button>
<Button onClick={handleAddKey} disabled={addingKey || !addKeyForm.key_label.trim() || !addKeyForm.key_value.trim()}>
{addingKey && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
</Button>
</DialogFooter>
</div>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -1,227 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import {
Search,
Loader2,
ChevronLeft,
ChevronRight,
ChevronDown,
ChevronUp,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, formatNumber, getSwrErrorMessage } from '@/lib/utils'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton } from '@/components/ui/skeleton'
import type { RelayTask } from '@/lib/types'
const PAGE_SIZE = 20
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
queued: 'warning',
processing: 'info',
completed: 'success',
failed: 'destructive',
}
const statusLabels: Record<string, string> = {
queued: '排队中',
processing: '处理中',
completed: '已完成',
failed: '失败',
}
export default function RelayPage() {
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState<string>('all')
const [expandedId, setExpandedId] = useState<string | null>(null)
const { data, error: swrError, isLoading } = useSWR(
['relay', page, statusFilter],
() => {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (statusFilter !== 'all') params.status = statusFilter
return api.relay.list(params)
},
)
const tasks = data?.items ?? []
const total = data?.total ?? 0
const error = getSwrErrorMessage(swrError)
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
function toggleExpand(id: string) {
setExpandedId((prev) => (prev === id ? null : id))
}
return (
<div className="space-y-4">
{/* 筛选 */}
<div className="flex items-center gap-3">
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="状态筛选" />
</SelectTrigger>
<SelectContent>
<SelectItem value="all"></SelectItem>
<SelectItem value="queued"></SelectItem>
<SelectItem value="processing"></SelectItem>
<SelectItem value="completed"></SelectItem>
<SelectItem value="failed"></SelectItem>
</SelectContent>
</Select>
</div>
{error && <ErrorBanner message={error} onDismiss={() => {}} />}
{isLoading ? (
<TableSkeleton rows={6} cols={10} />
) : error ? null : tasks.length === 0 ? (
<EmptyState />
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-8" />
<TableHead> ID</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead>Input Tokens</TableHead>
<TableHead>Output Tokens</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{tasks.map((task) => (
<>
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
<TableCell>
{expandedId === task.id ? (
<ChevronUp className="h-4 w-4 text-muted-foreground" />
) : (
<ChevronDown className="h-4 w-4 text-muted-foreground" />
)}
</TableCell>
<TableCell className="font-mono text-xs">
{task.id.slice(0, 8)}...
</TableCell>
<TableCell className="font-mono text-xs">
{task.model_id}
</TableCell>
<TableCell>
<Badge variant={statusVariants[task.status] || 'secondary'}>
{statusLabels[task.status] || task.status}
</Badge>
</TableCell>
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(task.input_tokens)}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatNumber(task.output_tokens)}
</TableCell>
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
{task.error_message || '-'}
</TableCell>
<TableCell className="font-mono text-xs text-muted-foreground">
{formatDate(task.created_at)}
</TableCell>
</TableRow>
{expandedId === task.id && (
<TableRow key={`${task.id}-detail`}>
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<p className="text-muted-foreground"> ID</p>
<p className="font-mono text-xs">{task.id}</p>
</div>
<div>
<p className="text-muted-foreground"> ID</p>
<p className="font-mono text-xs">{task.account_id}</p>
</div>
<div>
<p className="text-muted-foreground"> ID</p>
<p className="font-mono text-xs">{task.provider_id}</p>
</div>
<div>
<p className="text-muted-foreground"> ID</p>
<p className="font-mono text-xs">{task.model_id}</p>
</div>
{task.queued_at && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
</div>
)}
{task.started_at && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
</div>
)}
{task.completed_at && (
<div>
<p className="text-muted-foreground"></p>
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
</div>
)}
{task.error_message && (
<div className="col-span-2">
<p className="text-muted-foreground"></p>
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
</div>
)}
</div>
</TableCell>
</TableRow>
)}
</>
))}
</TableBody>
</Table>
<div className="flex items-center justify-between text-sm">
<p className="text-muted-foreground">
{page} / {totalPages} ({total} )
</p>
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
<ChevronLeft className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
<ChevronRight className="h-4 w-4 ml-1" />
</Button>
</div>
</div>
</>
)}
</div>
)
}

View File

@@ -1,330 +0,0 @@
'use client'
import { useState } from 'react'
import useSWR from 'swr'
import { Zap, Monitor, Smartphone } from 'lucide-react'
import {
LineChart,
Line,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { ErrorBanner, EmptyState } from '@/components/ui/state'
import { TableSkeleton, ChartSkeleton } from '@/components/ui/skeleton'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Badge } from '@/components/ui/badge'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { api } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils'
import type { UsageRecord, UsageByModel, ModelUsageStat, DailyUsageStat } from '@/lib/types'
export default function UsagePage() {
const [days, setDays] = useState(7)
const [activeTab, setActiveTab] = useState('relay')
const [error, setError] = useState('')
// 4 parallel SWR calls — each loads independently
const { data: dailyData = [], isLoading: dailyLoading } = useSWR(
['usage.daily', days],
() => api.usage.daily({ days })
)
const { data: modelData = [], isLoading: modelLoading } = useSWR(
['usage.byModel', days],
() => api.usage.byModel({ days })
)
const { data: telemetryModels = [] } = useSWR(
['telemetry.modelStats'],
() => api.telemetry.modelStats()
)
const { data: telemetryDaily = [] } = useSWR(
['telemetry.dailyStats', days],
() => api.telemetry.dailyStats({ days })
)
const relayLoading = dailyLoading || modelLoading
const telemetryLoading = !telemetryModels.length && !telemetryDaily.length && (dailyLoading || modelLoading)
// === Relay 用量图表数据 ===
const relayLineData = dailyData.map((r) => ({
day: r.day.slice(5),
Input: r.input_tokens,
Output: r.output_tokens,
}))
const relayBarData = modelData.map((r) => ({
model: r.model_id,
请求量: r.count,
Input: r.input_tokens,
Output: r.output_tokens,
}))
const relayTotalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
const relayTotalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
const relayTotalRequests = dailyData.reduce((s, r) => s + r.count, 0)
// === 遥测图表数据 ===
const telemetryLineData = telemetryDaily.map((r) => ({
day: r.day.slice(5),
Input: r.input_tokens,
Output: r.output_tokens,
设备数: r.unique_devices,
}))
const telemetryTotalInput = telemetryDaily.reduce((s, r) => s + r.input_tokens, 0)
const telemetryTotalOutput = telemetryDaily.reduce((s, r) => s + r.output_tokens, 0)
const telemetryTotalRequests = telemetryDaily.reduce((s, r) => s + r.request_count, 0)
// === 合计 ===
const totalInput = relayTotalInput + telemetryTotalInput
const totalOutput = relayTotalOutput + telemetryTotalOutput
const totalRequests = relayTotalRequests + telemetryTotalRequests
return (
<div className="space-y-6">
{error && <ErrorBanner message={error} onDismiss={() => setError('')} />}
{/* 时间范围 */}
<div className="flex items-center gap-3">
<span className="text-sm text-muted-foreground">:</span>
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
<SelectTrigger className="w-[140px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="7"> 7 </SelectItem>
<SelectItem value="30"> 30 </SelectItem>
<SelectItem value="90"> 90 </SelectItem>
</SelectContent>
</Select>
</div>
{/* 汇总统计 — render immediately, use 0 while loading */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-5">
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground"></p>
<p className="mt-1 text-2xl font-bold text-foreground">
{formatNumber(totalRequests)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground"> Input Tokens</p>
<p className="mt-1 text-2xl font-bold text-blue-400">
{formatNumber(totalInput)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<p className="text-sm text-muted-foreground"> Output Tokens</p>
<p className="mt-1 text-2xl font-bold text-orange-400">
{formatNumber(totalOutput)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Monitor className="h-4 w-4 text-green-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-green-400">
{formatNumber(relayTotalRequests)}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-6">
<div className="flex items-center gap-2">
<Smartphone className="h-4 w-4 text-purple-400" />
<p className="text-sm text-muted-foreground"></p>
</div>
<p className="mt-1 text-2xl font-bold text-purple-400">
{formatNumber(telemetryTotalRequests)}
</p>
</CardContent>
</Card>
</div>
{/* Tab 切换 */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="relay">
<Monitor className="h-4 w-4 mr-1" />
</TabsTrigger>
<TabsTrigger value="telemetry">
<Smartphone className="h-4 w-4 mr-1" />
</TabsTrigger>
</TabsList>
{/* Relay 用量 Tab */}
<TabsContent value="relay" className="space-y-6">
{relayLoading ? (
<>
<ChartSkeleton height={320} />
<ChartSkeleton height={280} />
</>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-primary" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{relayLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={relayLineData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState message="暂无中转数据" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{relayBarData.length > 0 ? (
<ResponsiveContainer width="100%" height={Math.max(200, relayBarData.length * 40)}>
<BarChart data={relayBarData} layout="vertical">
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
<XAxis type="number" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
<YAxis type="category" dataKey="model" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} width={120} />
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
</BarChart>
</ResponsiveContainer>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
{/* 遥测 Tab */}
<TabsContent value="telemetry" className="space-y-6">
{telemetryLoading ? (
<>
<ChartSkeleton height={320} />
<TableSkeleton rows={5} cols={6} hasToolbar={false} />
</>
) : (
<>
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Smartphone className="h-4 w-4 text-purple-400" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{telemetryLineData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={telemetryLineData}>
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
<XAxis dataKey="day" tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
<YAxis tick={{ fontSize: 12, fill: '#94A3B8' }} axisLine={{ stroke: '#1E293B' }} />
<Tooltip contentStyle={{ backgroundColor: '#0F172A', border: '1px solid #1E293B', borderRadius: '8px', color: '#F8FAFC', fontSize: '12px' }} />
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
</LineChart>
</ResponsiveContainer>
) : (
<EmptyState message="暂无桌面端遥测数据(需要桌面端上报)" />
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{telemetryModels.length > 0 ? (
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right">Input Tokens</TableHead>
<TableHead className="text-right">Output Tokens</TableHead>
<TableHead className="text-right"></TableHead>
<TableHead className="text-right"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{telemetryModels.map((stat) => (
<TableRow key={stat.model_id}>
<TableCell className="font-mono text-sm">{stat.model_id}</TableCell>
<TableCell className="text-right">{formatNumber(stat.request_count)}</TableCell>
<TableCell className="text-right text-blue-400">{formatNumber(stat.input_tokens)}</TableCell>
<TableCell className="text-right text-orange-400">{formatNumber(stat.output_tokens)}</TableCell>
<TableCell className="text-right">
{stat.avg_latency_ms !== null ? `${Math.round(stat.avg_latency_ms)}ms` : '-'}
</TableCell>
<TableCell className="text-right">
<Badge variant={stat.success_rate >= 0.95 ? 'default' : 'destructive'}>
{(stat.success_rate * 100).toFixed(1)}%
</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
) : (
<EmptyState />
)}
</CardContent>
</Card>
</>
)}
</TabsContent>
</Tabs>
</div>
)
}

View File

@@ -1,66 +0,0 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 222 47% 5%;
--foreground: 210 40% 98%;
--card: 222 47% 8%;
--card-foreground: 210 40% 98%;
--primary: 142 71% 45%;
--primary-foreground: 222 47% 5%;
--muted: 217 33% 17%;
--muted-foreground: 215 20% 65%;
--accent: 215 28% 23%;
--accent-foreground: 210 40% 98%;
--destructive: 0 84% 60%;
--destructive-foreground: 210 40% 98%;
--border: 217 33% 17%;
--input: 217 33% 17%;
--ring: 142 71% 45%;
}
* {
border-color: hsl(var(--border));
}
body {
background-color: hsl(var(--background));
color: hsl(var(--foreground));
font-family: 'Inter', system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
}
@layer utilities {
.scrollbar-thin {
scrollbar-width: thin;
scrollbar-color: hsl(var(--muted)) transparent;
}
.scrollbar-thin::-webkit-scrollbar {
width: 6px;
height: 6px;
}
.scrollbar-thin::-webkit-scrollbar-track {
background: transparent;
}
.scrollbar-thin::-webkit-scrollbar-thumb {
background-color: hsl(var(--muted));
border-radius: 3px;
}
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
background-color: hsl(var(--accent));
}
}
@layer components {
.glass-card {
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
}
}

View File

@@ -1,4 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" width="32" height="32" viewBox="0 0 32 32">
<rect width="32" height="32" rx="6" fill="#0f172a"/>
<text x="16" y="22" font-family="system-ui, sans-serif" font-size="16" font-weight="700" fill="#60a5fa" text-anchor="middle">Z</text>
</svg>

Before

Width:  |  Height:  |  Size: 282 B

View File

@@ -1,30 +0,0 @@
import type { Metadata } from 'next'
import { SWRProvider } from '@/lib/swr-provider'
import './globals.css'
export const metadata: Metadata = {
title: 'ZCLAW Admin',
description: 'ZCLAW AI Agent 管理平台',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="zh-CN" className="dark">
<head>
<link
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
rel="stylesheet"
/>
</head>
<body className="min-h-screen bg-background font-sans antialiased">
<SWRProvider>
{children}
</SWRProvider>
</body>
</html>
)
}

View File

@@ -1,243 +0,0 @@
'use client'
import { useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { Lock, User, Loader2, Eye, EyeOff, ShieldCheck } from 'lucide-react'
import { api } from '@/lib/api-client'
import { login } from '@/lib/auth'
import { ApiRequestError } from '@/lib/api-client'
export default function LoginPage() {
const router = useRouter()
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
const [totpCode, setTotpCode] = useState('')
const [showPassword, setShowPassword] = useState(false)
const [needTotp, setNeedTotp] = useState(false)
const [remember, setRemember] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState('')
async function handleSubmit(e: FormEvent) {
e.preventDefault()
setError('')
if (!username.trim()) {
setError('请输入用户名')
return
}
if (!password.trim()) {
setError('请输入密码')
return
}
setLoading(true)
try {
const res = await api.auth.login({
username: username.trim(),
password,
totp_code: totpCode.trim() || undefined,
})
login(res.token, res.account)
// 用 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 || ''
// 后端返回 "需要 TOTP" 时显示 TOTP 输入框
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || err.status === 403) {
setNeedTotp(true)
setError(msg || '请输入两步验证码')
} else {
setError(msg || '登录失败,请检查用户名和密码')
}
} else {
setError('网络错误,请稍后重试')
}
} finally {
setLoading(false)
}
}
return (
<div className="flex min-h-screen">
{/* 左侧品牌区域 */}
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
{/* 装饰性背景 */}
<div className="absolute inset-0">
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
</div>
{/* 品牌内容 */}
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
<div className="text-center">
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
ZCLAW
</h1>
<p className="text-xl text-muted-foreground font-light">
AI Agent
</p>
<div className="mt-8 flex items-center justify-center gap-2">
<div className="h-px w-12 bg-green-500/50" />
<div className="w-2 h-2 rounded-full bg-green-500" />
<div className="h-px w-12 bg-green-500/50" />
</div>
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
AI API
</p>
</div>
</div>
</div>
{/* 右侧登录表单 */}
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
<div className="w-full max-w-sm space-y-8">
{/* 移动端 Logo */}
<div className="lg:hidden text-center">
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
ZCLAW
</h1>
<p className="text-sm text-muted-foreground">AI Agent </p>
</div>
<div>
<h2 className="text-2xl font-semibold text-foreground"></h2>
<p className="mt-2 text-sm text-muted-foreground">
</p>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
{/* 用户名 */}
<div className="space-y-2">
<label
htmlFor="username"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="username"
type="text"
placeholder="请输入用户名"
value={username}
onChange={(e) => setUsername(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoComplete="username"
/>
</div>
</div>
{/* 密码 */}
<div className="space-y-2">
<label
htmlFor="password"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder="请输入密码"
value={password}
onChange={(e) => setPassword(e.target.value)}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
autoComplete="current-password"
/>
<button
type="button"
onClick={() => setShowPassword(!showPassword)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
>
{showPassword ? (
<EyeOff className="h-4 w-4" />
) : (
<Eye className="h-4 w-4" />
)}
</button>
</div>
</div>
{/* TOTP 验证码 */}
{needTotp && (
<div className="space-y-2">
<label
htmlFor="totp"
className="text-sm font-medium text-foreground"
>
</label>
<div className="relative">
<ShieldCheck className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<input
id="totp"
type="text"
placeholder="请输入 6 位验证码"
value={totpCode}
onChange={(e) => setTotpCode(e.target.value.replace(/\D/g, '').slice(0, 6))}
maxLength={6}
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring tracking-widest"
autoComplete="one-time-code"
inputMode="numeric"
/>
</div>
<p className="text-xs text-muted-foreground">
使 App Google Authenticator
</p>
</div>
)}
{/* 记住我 */}
<div className="flex items-center gap-2">
<input
id="remember"
type="checkbox"
checked={remember}
onChange={(e) => setRemember(e.target.checked)}
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
/>
<label
htmlFor="remember"
className="text-sm text-muted-foreground cursor-pointer select-none"
>
</label>
</div>
{/* 错误信息 */}
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
</div>
)}
{/* 登录按钮 */}
<button
type="submit"
disabled={loading}
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'登录'
)}
</button>
</form>
</div>
</div>
</div>
)
}

View File

@@ -1,44 +0,0 @@
'use client'
import { useEffect, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated, clearAuth } from '@/lib/auth'
import { api, ApiRequestError } from '@/lib/api-client'
interface AuthGuardProps {
children: ReactNode
}
/**
* AuthGuard — 纯 useEffect redirect始终渲染 children
*
* 不做任何 loading/authorized 状态切换,避免组件卸载。
* useEffect 在客户端 hydration 后执行,检查认证状态。
*/
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter()
useEffect(() => {
if (!isAuthenticated()) {
router.replace('/login')
return
}
// 后台验证 token
api.auth.me().catch((err) => {
if (err instanceof ApiRequestError && (err.status === 401 || err.status === 403)) {
clearAuth()
router.replace('/login')
}
})
}, [router])
return <>{children}</>
}
export function useAuth() {
// 简化版 — 直接读 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

@@ -1,42 +0,0 @@
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default:
'border-transparent bg-primary/15 text-primary',
secondary:
'border-transparent bg-muted text-muted-foreground',
destructive:
'border-transparent bg-destructive/15 text-destructive',
outline:
'text-foreground border-border',
success:
'border-transparent bg-green-500/15 text-green-400',
warning:
'border-transparent bg-yellow-500/15 text-yellow-400',
info:
'border-transparent bg-blue-500/15 text-blue-400',
},
},
defaultVariants: {
variant: 'default',
},
},
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -1,56 +0,0 @@
'use client'
import * as React from 'react'
import { cva, type VariantProps } from 'class-variance-authority'
import { cn } from '@/lib/utils'
const buttonVariants = cva(
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
{
variants: {
variant: {
default:
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
secondary:
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
destructive:
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
outline:
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
ghost:
'hover:bg-accent hover:text-accent-foreground',
link:
'text-primary underline-offset-4 hover:underline',
},
size: {
default: 'h-9 px-4 py-2',
sm: 'h-8 rounded-md px-3 text-xs',
lg: 'h-10 rounded-md px-8',
icon: 'h-9 w-9',
},
},
defaultVariants: {
variant: 'default',
size: 'default',
},
},
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, ...props }, ref) => {
return (
<button
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
},
)
Button.displayName = 'Button'
export { Button, buttonVariants }

View File

@@ -1,75 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
className,
)}
{...props}
/>
))
Card.displayName = 'Card'
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex flex-col space-y-1.5 p-6', className)}
{...props}
/>
))
CardHeader.displayName = 'CardHeader'
const CardTitle = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLHeadingElement>
>(({ className, ...props }, ref) => (
<h3
ref={ref}
className={cn('font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
CardTitle.displayName = 'CardTitle'
const CardDescription = React.forwardRef<
HTMLParagraphElement,
React.HTMLAttributes<HTMLParagraphElement>
>(({ className, ...props }, ref) => (
<p
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
CardDescription.displayName = 'CardDescription'
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
))
CardContent.displayName = 'CardContent'
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn('flex items-center p-6 pt-0', className)}
{...props}
/>
))
CardFooter.displayName = 'CardFooter'
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -1,118 +0,0 @@
'use client'
import * as React from 'react'
import * as DialogPrimitive from '@radix-ui/react-dialog'
import { X } from 'lucide-react'
import { cn } from '@/lib/utils'
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
className,
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
'rounded-lg',
className,
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
{...props}
/>
)
DialogHeader.displayName = 'DialogHeader'
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
{...props}
/>
)
DialogFooter.displayName = 'DialogFooter'
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn('text-sm text-muted-foreground', className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -1,28 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface InputProps
extends React.InputHTMLAttributes<HTMLInputElement> {}
const Input = React.forwardRef<HTMLInputElement, InputProps>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
'placeholder:text-muted-foreground',
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
)
},
)
Input.displayName = 'Input'
export { Input }

View File

@@ -1,23 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
export interface LabelProps
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
({ className, ...props }, ref) => {
return (
<label
ref={ref}
className={cn(
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
className,
)}
{...props}
/>
)
},
)
Label.displayName = 'Label'
export { Label }

View File

@@ -1,100 +0,0 @@
'use client'
import * as React from 'react'
import * as SelectPrimitive from '@radix-ui/react-select'
import { Check, ChevronDown } from 'lucide-react'
import { cn } from '@/lib/utils'
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
'placeholder:text-muted-foreground',
'focus:outline-none focus:ring-1 focus:ring-ring',
'disabled:cursor-not-allowed disabled:opacity-50',
'[&>span]:line-clamp-1',
className,
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = 'popper', ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
'data-[state=open]:animate-in data-[state=closed]:animate-out',
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
position === 'popper' &&
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
className,
)}
position={position}
{...props}
>
<SelectPrimitive.Viewport
className={cn(
'p-1',
position === 'popper' &&
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
)}
>
{children}
</SelectPrimitive.Viewport>
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
'focus:bg-accent focus:text-accent-foreground',
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
className,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectItem,
}

View File

@@ -1,30 +0,0 @@
'use client'
import * as React from 'react'
import * as SeparatorPrimitive from '@radix-ui/react-separator'
import { cn } from '@/lib/utils'
const Separator = React.forwardRef<
React.ElementRef<typeof SeparatorPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
>(
(
{ className, orientation = 'horizontal', decorative = true, ...props },
ref,
) => (
<SeparatorPrimitive.Root
ref={ref}
decorative={decorative}
orientation={orientation}
className={cn(
'shrink-0 bg-border',
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
className,
)}
{...props}
/>
),
)
Separator.displayName = SeparatorPrimitive.Root.displayName
export { Separator }

View File

@@ -1,115 +0,0 @@
// ============================================================
// Skeleton 组件 — 替代全屏 spinner 的骨架屏
// ============================================================
import { cn } from '@/lib/utils'
function SkeletonBase({ className }: { className?: string }) {
return (
<div
className={cn(
'animate-pulse rounded-md bg-muted',
className,
)}
/>
)
}
/** 表格骨架屏 */
export function TableSkeleton({
rows = 5,
cols = 5,
hasToolbar = true,
}: {
rows?: number
cols?: number
hasToolbar?: boolean
}) {
return (
<div className="space-y-4">
{hasToolbar && (
<div className="flex items-center justify-between">
<SkeletonBase className="h-9 w-[200px]" />
<SkeletonBase className="h-9 w-[120px]" />
</div>
)}
<div className="rounded-md border border-border overflow-hidden">
{/* Header */}
<div className="border-b border-border bg-muted/30 px-4 py-3">
<div className="flex gap-4">
{Array.from({ length: cols }).map((_, i) => (
<SkeletonBase
key={i}
className={cn(
'h-4',
i === 0 ? 'w-[120px]' : i === cols - 1 ? 'w-[80px]' : 'w-[100px]',
)}
/>
))}
</div>
</div>
{/* Rows */}
{Array.from({ length: rows }).map((_, rowIdx) => (
<div
key={rowIdx}
className={cn(
'px-4 py-3',
rowIdx < rows - 1 && 'border-b border-border',
)}
>
<div className="flex gap-4">
{Array.from({ length: cols }).map((_, colIdx) => (
<SkeletonBase
key={colIdx}
className={cn(
'h-4',
colIdx === 0 ? 'w-[120px]' : colIdx === cols - 1 ? 'w-[80px]' : 'w-[100px]',
)}
/>
))}
</div>
</div>
))}
</div>
{/* Pagination */}
<div className="flex items-center justify-between">
<SkeletonBase className="h-4 w-[140px]" />
<div className="flex gap-2">
<SkeletonBase className="h-8 w-[80px]" />
<SkeletonBase className="h-8 w-[80px]" />
</div>
</div>
</div>
)
}
/** 统计卡片骨架屏 */
export function StatsSkeleton({ count = 4 }: { count?: number }) {
return (
<div className={`grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-${count}`}>
{Array.from({ length: count }).map((_, i) => (
<div key={i} className="rounded-lg border border-border p-6">
<SkeletonBase className="h-4 w-[80px]" />
<SkeletonBase className="mt-2 h-8 w-[100px]" />
<SkeletonBase className="mt-1 h-3 w-[120px]" />
</div>
))}
</div>
)
}
/** 图表骨架屏 */
export function ChartSkeleton({ height }: { height?: number }) {
return (
<div className="rounded-lg border border-border">
<div className="border-b border-border px-6 py-4">
<SkeletonBase className="h-5 w-[140px]" />
</div>
<div className="p-6">
<SkeletonBase className="w-full" />
</div>
</div>
)
}
export { SkeletonBase as Skeleton }

View File

@@ -1,63 +0,0 @@
'use client'
import { AlertCircle, Inbox } from 'lucide-react'
/** 统一的错误提示横幅 */
export function ErrorBanner({
message,
onDismiss,
}: {
message: string
onDismiss?: () => void
}) {
return (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertCircle className="h-4 w-4 shrink-0" />
<span className="flex-1">{message}</span>
{onDismiss && (
<button
onClick={onDismiss}
className="underline cursor-pointer shrink-0"
>
</button>
)}
</div>
)
}
/** 统一的空状态占位 */
export function EmptyState({
message = '暂无数据',
}: {
message?: string
}) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-2 text-muted-foreground">
<Inbox className="h-8 w-8" />
<span className="text-sm">{message}</span>
</div>
)
}
/** 统一的加载失败提示 + 重试 */
export function ErrorRetry({
message = '请求失败,请重试',
onRetry,
}: {
message?: string
onRetry: () => void
}) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3 text-muted-foreground">
<AlertCircle className="h-8 w-8 text-destructive" />
<span className="text-sm">{message}</span>
<button
onClick={onRetry}
className="rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground hover:bg-primary/90 transition-colors cursor-pointer"
>
</button>
</div>
)
}

View File

@@ -1,32 +0,0 @@
'use client'
import * as React from 'react'
import * as SwitchPrimitive from '@radix-ui/react-switch'
import { cn } from '@/lib/utils'
const Switch = React.forwardRef<
React.ElementRef<typeof SwitchPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
>(({ className, ...props }, ref) => (
<SwitchPrimitive.Root
className={cn(
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
'disabled:cursor-not-allowed disabled:opacity-50',
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
className,
)}
{...props}
ref={ref}
>
<SwitchPrimitive.Thumb
className={cn(
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
)}
/>
</SwitchPrimitive.Root>
))
Switch.displayName = SwitchPrimitive.Root.displayName
export { Switch }

View File

@@ -1,119 +0,0 @@
import * as React from 'react'
import { cn } from '@/lib/utils'
const Table = React.forwardRef<
HTMLTableElement,
React.HTMLAttributes<HTMLTableElement>
>(({ className, ...props }, ref) => (
<div className="relative w-full overflow-auto scrollbar-thin">
<table
ref={ref}
className={cn('w-full caption-bottom text-sm', className)}
{...props}
/>
</div>
))
Table.displayName = 'Table'
const TableHeader = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
))
TableHeader.displayName = 'TableHeader'
const TableBody = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tbody
ref={ref}
className={cn('[&_tr:last-child]:border-0', className)}
{...props}
/>
))
TableBody.displayName = 'TableBody'
const TableFooter = React.forwardRef<
HTMLTableSectionElement,
React.HTMLAttributes<HTMLTableSectionElement>
>(({ className, ...props }, ref) => (
<tfoot
ref={ref}
className={cn(
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
className,
)}
{...props}
/>
))
TableFooter.displayName = 'TableFooter'
const TableRow = React.forwardRef<
HTMLTableRowElement,
React.HTMLAttributes<HTMLTableRowElement>
>(({ className, ...props }, ref) => (
<tr
ref={ref}
className={cn(
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
className,
)}
{...props}
/>
))
TableRow.displayName = 'TableRow'
const TableHead = React.forwardRef<
HTMLTableCellElement,
React.ThHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<th
ref={ref}
className={cn(
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
))
TableHead.displayName = 'TableHead'
const TableCell = React.forwardRef<
HTMLTableCellElement,
React.TdHTMLAttributes<HTMLTableCellElement>
>(({ className, ...props }, ref) => (
<td
ref={ref}
className={cn(
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
className,
)}
{...props}
/>
))
TableCell.displayName = 'TableCell'
const TableCaption = React.forwardRef<
HTMLTableCaptionElement,
React.HTMLAttributes<HTMLTableCaptionElement>
>(({ className, ...props }, ref) => (
<caption
ref={ref}
className={cn('mt-4 text-sm text-muted-foreground', className)}
{...props}
/>
))
TableCaption.displayName = 'TableCaption'
export {
Table,
TableHeader,
TableBody,
TableFooter,
TableHead,
TableRow,
TableCell,
TableCaption,
}

View File

@@ -1,57 +0,0 @@
'use client'
import * as React from 'react'
import * as TabsPrimitive from '@radix-ui/react-tabs'
import { cn } from '@/lib/utils'
const Tabs = TabsPrimitive.Root
const TabsList = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.List>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
>(({ className, ...props }, ref) => (
<TabsPrimitive.List
ref={ref}
className={cn(
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
))
TabsList.displayName = TabsPrimitive.List.displayName
const TabsTrigger = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Trigger
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
'disabled:pointer-events-none disabled:opacity-50',
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
className,
)}
{...props}
/>
))
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
const TabsContent = React.forwardRef<
React.ElementRef<typeof TabsPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
>(({ className, ...props }, ref) => (
<TabsPrimitive.Content
ref={ref}
className={cn(
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
className,
)}
{...props}
/>
))
TabsContent.displayName = TabsPrimitive.Content.displayName
export { Tabs, TabsList, TabsTrigger, TabsContent }

View File

@@ -1,31 +0,0 @@
'use client'
import * as React from 'react'
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
import { cn } from '@/lib/utils'
const TooltipProvider = TooltipPrimitive.Provider
const Tooltip = TooltipPrimitive.Root
const TooltipTrigger = TooltipPrimitive.Trigger
const TooltipContent = React.forwardRef<
React.ElementRef<typeof TooltipPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<TooltipPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
'animate-in fade-in-0 zoom-in-95',
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
className,
)}
{...props}
/>
))
TooltipContent.displayName = TooltipPrimitive.Content.displayName
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

View File

@@ -1,16 +0,0 @@
// ============================================================
// useDebounce — 防抖 hook
// ============================================================
import { useState, useEffect } from 'react'
export function useDebounce<T>(value: T, delay = 300): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value)
useEffect(() => {
const handler = setTimeout(() => setDebouncedValue(value), delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}

View File

@@ -1,535 +0,0 @@
// ============================================================
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
// ============================================================
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,
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 || '/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,
externalSignal?: AbortSignal,
): Promise<T> {
let lastError: unknown
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
// Merge external signal (e.g. from SWR) with a timeout signal
const signals: AbortSignal[] = [AbortSignal.timeout(DEFAULT_TIMEOUT_MS)]
if (externalSignal) signals.push(externalSignal)
const signal = signals.length === 1 ? signals[0] : AbortSignal.any(signals)
try {
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,
})
// 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) {
// 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 lastError
}
// ── API 客户端 ────────────────────────────────────────────
export const api = {
// ── 认证 ──────────────────────────────────────────────
auth: {
async login(data: LoginRequest): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/auth/login', data)
},
async register(data: {
username: string
password: string
email: string
display_name?: string
}): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/auth/register', data)
},
async me(): Promise<AccountPublic> {
return request<AccountPublic>('GET', '/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', `/accounts${qs}`)
},
async get(id: string): Promise<AccountPublic> {
return request<AccountPublic>('GET', `/accounts/${id}`)
},
async update(
id: string,
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
): Promise<AccountPublic> {
return request<AccountPublic>('PATCH', `/accounts/${id}`, data)
},
async updateStatus(
id: string,
data: { status: AccountPublic['status'] },
): Promise<void> {
return request<void>('PATCH', `/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', `/providers${qs}`)
},
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
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', `/providers/${id}`, data)
},
async delete(id: string): Promise<void> {
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}`)
},
},
// ── 模型管理 ──────────────────────────────────────────
models: {
async list(params?: {
page?: number
page_size?: number
provider_id?: string
}): Promise<PaginatedResponse<Model>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Model>>('GET', `/models${qs}`)
},
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('POST', '/models', data)
},
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('PATCH', `/models/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/models/${id}`)
},
},
// ── API 密钥 ──────────────────────────────────────────
tokens: {
async list(params?: {
page?: number
page_size?: number
}): Promise<PaginatedResponse<TokenInfo>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<TokenInfo>>('GET', `/keys${qs}`)
},
async create(data: CreateTokenRequest): Promise<TokenInfo> {
return request<TokenInfo>('POST', '/keys', data)
},
async revoke(id: string): Promise<void> {
return request<void>('DELETE', `/keys/${id}`)
},
},
// ── 用量统计 ──────────────────────────────────────────
usage: {
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
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, group_by: 'model' })
const result = await request<{ by_model: UsageByModel[] }>('GET', `/usage${qs}`)
return result.by_model || []
},
},
// ── 中转任务 ──────────────────────────────────────────
relay: {
async list(params?: {
page?: number
page_size?: number
status?: string
}): Promise<PaginatedResponse<RelayTask>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<RelayTask>>('GET', `/relay/tasks${qs}`)
},
async get(id: string): Promise<RelayTask> {
return request<RelayTask>('GET', `/relay/tasks/${id}`)
},
},
// ── 系统配置 ──────────────────────────────────────────
config: {
async list(params?: {
category?: string
page?: number
page_size?: number
}): Promise<ConfigItem[]> {
const qs = buildQueryString(params)
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', `/config/items/${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', `/logs/operations${qs}`)
},
},
// ── 仪表盘 ────────────────────────────────────────────
stats: {
async dashboard(): Promise<DashboardStats> {
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}`)
},
},
}
// ── 工具函数 ──────────────────────────────────────────────
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}`
}

View File

@@ -1,13 +0,0 @@
// ============================================================
// API Error 类 — 与 swr-fetcher 共享
// ============================================================
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: { error?: string; message?: string },
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}

View File

@@ -1,52 +0,0 @@
// ============================================================
// ZCLAW SaaS Admin — JWT Token 管理
// ============================================================
import type { AccountPublic } from './types'
const TOKEN_KEY = 'zclaw_admin_token'
const ACCOUNT_KEY = 'zclaw_admin_account'
/** 保存登录凭证 */
export function login(token: string, account: AccountPublic): void {
if (typeof window === 'undefined') return
localStorage.setItem(TOKEN_KEY, token)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
}
/** 清除登录凭证 */
export function logout(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ACCOUNT_KEY)
}
/** 清除认证状态(用于 Token 验证失败时) */
export function clearAuth(): void {
if (typeof window === 'undefined') return
localStorage.removeItem(TOKEN_KEY)
localStorage.removeItem(ACCOUNT_KEY)
}
/** 获取 JWT token */
export function getToken(): string | null {
if (typeof window === 'undefined') return null
return localStorage.getItem(TOKEN_KEY)
}
/** 获取当前登录用户信息 */
export function getAccount(): AccountPublic | null {
if (typeof window === 'undefined') return null
const raw = localStorage.getItem(ACCOUNT_KEY)
if (!raw) return null
try {
return JSON.parse(raw) as AccountPublic
} catch {
return null
}
}
/** 是否已认证 */
export function isAuthenticated(): boolean {
return !!getToken()
}

View File

@@ -1,75 +0,0 @@
// ============================================================
// SWR fetcher — 将 SWR key 映射到 api-client 调用
// ============================================================
import { api } from './api-client'
import { ApiRequestError } from './api-client'
type ApiMethod = typeof api
/** SWR fetcher: key 可以是字符串或 [method-path, params] 元组 */
type SwrKey =
| string
| [string, ...unknown[]]
/** SWR fetcher 支持 AbortSignal 传递 */
type SwrFetcherArgs = { signal?: AbortSignal } | null
async function resolveApiCall(key: SwrKey, args: SwrFetcherArgs): Promise<unknown> {
if (typeof key === 'string') {
// 简单字符串 key直接 fetch
return fetchGeneric(key, args?.signal)
}
const [path, ...rest] = key
return callByPath(path, rest, args?.signal)
}
async function fetchGeneric(path: string, signal?: AbortSignal): Promise<unknown> {
const res = await fetch(path, {
headers: {
'Content-Type': 'application/json',
},
signal,
})
if (!res.ok) {
const body = await res.json().catch(() => ({ error: 'unknown', message: `请求失败 (${res.status})` }))
throw new ApiRequestError(res.status, body)
}
if (res.status === 204) return null
return res.json()
}
/** 根据 path 调用对应的 api 方法 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
async function callByPath(path: string, callArgs: unknown[], signal?: AbortSignal): Promise<unknown> {
const parts = path.split('.')
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let target: any = api
for (const part of parts) {
target = target[part]
if (!target) throw new Error(`API method not found: ${path}`)
}
// Append signal as last argument if the target is the request function
// For api.xxx() calls that ultimately use request(), we pass signal through
// The simplest approach: pass signal as part of an options bag
return target(...callArgs, signal ? { signal } : undefined)
}
/**
* SWR fetcher — 接受 SWR 自动传入的 AbortSignal
*
* 用法: useSWR(key, swrFetcher)
* SWR 会自动在组件卸载或 key 变化时 abort 请求
*/
export function swrFetcher<T = unknown>(key: SwrKey, args: SwrFetcherArgs): Promise<T> {
return resolveApiCall(key, args) as Promise<T>
}
/** 创建 SWR key helper — 类型安全 */
export function createKey<TMethod extends string>(
method: TMethod,
...args: unknown[]
): [TMethod, ...unknown[]] {
return [method, ...args]
}

View File

@@ -1,47 +0,0 @@
'use client'
import { SWRConfig } from 'swr'
import type { ReactNode } from 'react'
/** 判断是否为请求被中断(页面导航等场景) */
function isAbortError(err: unknown): boolean {
if (err instanceof DOMException && err.name === 'AbortError') return true
if (err instanceof Error && err.message?.includes('aborted')) return true
return false
}
export function SWRProvider({ children }: { children: ReactNode }) {
return (
<SWRConfig
value={{
// 关闭所有自动 revalidation — 只在手动 mutate 或 key 变化时刷新
revalidateOnFocus: false,
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 && status !== 404
}
return true
},
onError: (err: unknown) => {
if (isAbortError(err)) return
},
}}
>
{children}
</SWRConfig>
)
}

View File

@@ -1,299 +0,0 @@
// ============================================================
// ZCLAW SaaS Admin — 全局类型定义
// ============================================================
/** 公共账号信息 */
export interface AccountPublic {
id: string
username: string
email: string
display_name: string
role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
/** 登录请求 */
export interface LoginRequest {
username: string
password: string
totp_code?: string
}
/** 登录响应 */
export interface LoginResponse {
token: string
refresh_token: string
account: AccountPublic
}
/** 注册请求 */
export interface RegisterRequest {
username: string
password: string
email: string
display_name?: string
}
/** 分页响应 */
export interface PaginatedResponse<T> {
items: T[]
total: number
page: number
page_size: number
}
/** 服务商 (Provider) */
export interface Provider {
id: string
name: string
display_name: string
api_key?: string
base_url: string
api_protocol: string
enabled: boolean
rate_limit_rpm: number | null
rate_limit_tpm: number | null
created_at: string
updated_at: string
}
/** 模型 */
export interface Model {
id: string
provider_id: string
model_id: string
alias: string
context_window: number
max_output_tokens: number
supports_streaming: boolean
supports_vision: boolean
enabled: boolean
pricing_input: number
pricing_output: number
}
/** API 密钥信息 */
export interface TokenInfo {
id: string
name: string
token_prefix: string
permissions: string[]
last_used_at?: string
expires_at?: string
created_at: string
token?: string
}
/** 创建 Token 请求 */
export interface CreateTokenRequest {
name: string
expires_days?: number
permissions: string[]
}
/** 中转任务 */
export interface RelayTask {
id: string
account_id: string
provider_id: string
model_id: string
status: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
/** 用量记录 */
export interface UsageRecord {
day: string
count: number
input_tokens: number
output_tokens: number
}
/** 按模型用量 */
export interface UsageByModel {
model_id: string
count: number
input_tokens: number
output_tokens: number
}
/** 系统配置项 */
export interface ConfigItem {
id: string
category: string
key_path: string
value_type: string
current_value: string | null
default_value: string | null
source: string
description: string | null
requires_restart: boolean
created_at: string
updated_at: string
}
/** 操作日志 */
export interface OperationLog {
id: number
account_id: string | null
action: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
/** 仪表盘统计 */
export interface DashboardStats {
total_accounts: number
active_accounts: number
tasks_today: number
active_providers: number
active_models: number
tokens_today_input: number
tokens_today_output: number
}
/** API 错误响应 */
export interface ApiError {
error: string
message: string
status?: number
}
// ── 提示词模板 ────────────────────────────────────────────
/** 提示词模板 */
export interface PromptTemplate {
id: string
name: string
category: string
description?: string
source: 'builtin' | 'custom'
current_version: number
status: 'active' | 'deprecated' | 'archived'
created_at: string
updated_at: string
}
/** 提示词版本 */
export interface PromptVersion {
id: string
template_id: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
changelog?: string
min_app_version?: string
created_at: string
}
/** 提示词变量定义 */
export interface PromptVariable {
name: string
type: 'string' | 'number' | 'select' | 'boolean'
default_value?: string
description?: string
required?: boolean
}
/** OTA 更新检查请求 */
export interface PromptCheckRequest {
device_id: string
versions: Record<string, number>
}
/** OTA 更新响应 */
export interface PromptCheckResponse {
updates: PromptUpdatePayload[]
server_time: string
}
/** 单个更新载荷 */
export interface PromptUpdatePayload {
name: string
version: number
system_prompt: string
user_prompt_template?: string
variables: PromptVariable[]
source: string
min_app_version?: string
changelog?: string
}
// ── Agent 配置模板 ────────────────────────────────────────
/** Agent 模板 */
export interface AgentTemplate {
id: string
name: string
description?: string
category: string
source: 'builtin' | 'custom'
model?: string
system_prompt?: string
tools: string[]
capabilities: string[]
temperature?: number
max_tokens?: number
visibility: 'public' | 'team' | 'private'
status: 'active' | 'archived'
current_version: number
created_at: string
updated_at: string
}
// ── Provider Key Pool ─────────────────────────────────────
/** Provider Key */
export interface ProviderKey {
id: string
provider_id: string
key_label: string
priority: number
max_rpm?: number
max_tpm?: number
quota_reset_interval?: string
is_active: boolean
last_429_at?: string
cooldown_until?: string
total_requests: number
total_tokens: number
created_at: string
updated_at: string
}
// ── 遥测统计 ────────────────────────────────────────────
/** 按模型聚合的用量统计 */
export interface ModelUsageStat {
model_id: string
request_count: number
input_tokens: number
output_tokens: number
avg_latency_ms: number | null
success_rate: number
}
/** 按天的用量统计 */
export interface DailyUsageStat {
day: string
request_count: number
input_tokens: number
output_tokens: number
unique_devices: number
}

View File

@@ -1,45 +0,0 @@
import { type ClassValue, clsx } from 'clsx'
import { twMerge } from 'tailwind-merge'
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}
export function formatDate(date: string | Date): string {
const d = new Date(date)
return d.toLocaleString('zh-CN', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
export function formatNumber(n: number): string {
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
return n.toLocaleString()
}
export function maskApiKey(key?: string): string {
if (!key) return '-'
if (key.length <= 8) return '****'
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
}
export function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms))
}
/** 从 SWR error 中提取用户可见消息,过滤 abort 错误 */
export function getSwrErrorMessage(err: unknown): string | undefined {
if (!err) return undefined
if (err instanceof DOMException && err.name === 'AbortError') return undefined
if (err instanceof Error) {
if (err.name === 'AbortError' || err.message?.includes('aborted')) return undefined
return err.message
}
return String(err)
}

View File

@@ -1,62 +0,0 @@
import type { Config } from 'tailwindcss'
const config: Config = {
darkMode: 'class',
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
],
theme: {
extend: {
colors: {
background: '#020617',
foreground: '#F8FAFC',
card: {
DEFAULT: '#0F172A',
foreground: '#F8FAFC',
},
primary: {
DEFAULT: '#22C55E',
foreground: '#020617',
hover: '#16A34A',
},
muted: {
DEFAULT: '#1E293B',
foreground: '#94A3B8',
},
accent: {
DEFAULT: '#334155',
foreground: '#F8FAFC',
},
destructive: {
DEFAULT: '#EF4444',
foreground: '#F8FAFC',
},
border: '#1E293B',
input: '#1E293B',
ring: '#22C55E',
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
},
keyframes: {
'fade-in': {
'0%': { opacity: '0', transform: 'translateY(4px)' },
'100%': { opacity: '1', transform: 'translateY(0)' },
},
'slide-in': {
'0%': { opacity: '0', transform: 'translateX(-8px)' },
'100%': { opacity: '1', transform: 'translateX(0)' },
},
},
animation: {
'fade-in': 'fade-in 0.2s ease-out',
'slide-in': 'slide-in 0.2s ease-out',
},
},
},
plugins: [],
}
export default config

View File

@@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "es2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": { "@/*": ["./src/*"] }
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}

View File

@@ -5,6 +5,8 @@ use std::sync::Arc;
use tokio::sync::{broadcast, mpsc, Mutex};
use zclaw_types::{AgentConfig, AgentId, AgentInfo, Event, Result, HandRun, HandRunId, HandRunStatus, HandRunFilter, TriggerSource};
#[cfg(feature = "multi-agent")]
use zclaw_types::Capability;
#[cfg(feature = "multi-agent")]
use zclaw_protocols::{A2aRouter, A2aAgentProfile, A2aCapability, A2aEnvelope, A2aMessageType, A2aRecipient};
use async_trait::async_trait;
use serde_json::Value;
@@ -114,6 +116,39 @@ impl SkillExecutor for KernelSkillExecutor {
}
}
/// Inbox wrapper for A2A message receivers that supports re-queuing
/// non-matching messages instead of dropping them.
#[cfg(feature = "multi-agent")]
struct AgentInbox {
rx: tokio::sync::mpsc::Receiver<A2aEnvelope>,
pending: std::collections::VecDeque<A2aEnvelope>,
}
#[cfg(feature = "multi-agent")]
impl AgentInbox {
fn new(rx: tokio::sync::mpsc::Receiver<A2aEnvelope>) -> Self {
Self { rx, pending: std::collections::VecDeque::new() }
}
fn try_recv(&mut self) -> std::result::Result<A2aEnvelope, tokio::sync::mpsc::error::TryRecvError> {
if let Some(msg) = self.pending.pop_front() {
return Ok(msg);
}
self.rx.try_recv()
}
async fn recv(&mut self) -> Option<A2aEnvelope> {
if let Some(msg) = self.pending.pop_front() {
return Some(msg);
}
self.rx.recv().await
}
fn requeue(&mut self, envelope: A2aEnvelope) {
self.pending.push_back(envelope);
}
}
/// The ZCLAW Kernel
pub struct Kernel {
config: KernelConfig,
@@ -137,9 +172,9 @@ pub struct Kernel {
/// A2A router for inter-agent messaging (gated by multi-agent feature)
#[cfg(feature = "multi-agent")]
a2a_router: Arc<A2aRouter>,
/// Per-agent A2A inbox receivers
/// Per-agent A2A inbox receivers (supports re-queuing non-matching messages)
#[cfg(feature = "multi-agent")]
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<mpsc::Receiver<A2aEnvelope>>>>>,
a2a_inboxes: Arc<dashmap::DashMap<AgentId, Arc<Mutex<AgentInbox>>>>,
}
impl Kernel {
@@ -435,21 +470,22 @@ impl Kernel {
// Register in memory
self.memory.save_agent(&config).await?;
// Register in registry
self.registry.register(config);
// Register with A2A router for multi-agent messaging
// Register with A2A router for multi-agent messaging (before config is moved)
#[cfg(feature = "multi-agent")]
{
let profile = Self::agent_config_to_a2a_profile(&config_clone);
let profile = Self::agent_config_to_a2a_profile(&config);
let rx = self.a2a_router.register_agent(profile).await;
self.a2a_inboxes.insert(id, Arc::new(Mutex::new(rx)));
self.a2a_inboxes.insert(id, Arc::new(Mutex::new(AgentInbox::new(rx))));
}
// Register in registry (consumes config)
let name = config.name.clone();
self.registry.register(config);
// Emit event
self.events.publish(Event::AgentSpawned {
agent_id: id,
name: self.registry.get(&id).map(|a| a.name.clone()).unwrap_or_default(),
name,
});
Ok(id)
@@ -1332,8 +1368,8 @@ impl Kernel {
format!("No A2A inbox for agent: {}", agent_id)
))?;
let mut rx = inbox.lock().await;
match rx.try_recv() {
let mut inbox = inbox.lock().await;
match inbox.try_recv() {
Ok(envelope) => {
self.events.publish(Event::A2aMessageReceived {
from: envelope.from,
@@ -1392,23 +1428,24 @@ impl Kernel {
// Wait for response with timeout
let timeout = tokio::time::Duration::from_millis(timeout_ms);
let result = tokio::time::timeout(timeout, async {
let inbox = self.a2a_inboxes.get(from)
let inbox_entry = self.a2a_inboxes.get(from)
.ok_or_else(|| zclaw_types::ZclawError::NotFound(
format!("No A2A inbox for agent: {}", from)
))?;
let mut rx = inbox.lock().await;
let mut inbox = inbox_entry.lock().await;
// Poll for matching response
loop {
match rx.recv().await {
match inbox.recv().await {
Some(msg) => {
// Check if this is a response to our task
if msg.message_type == A2aMessageType::Response
&& msg.reply_to.as_deref() == Some(&envelope_id) {
return Ok::<_, zclaw_types::ZclawError>(msg.payload);
}
// Not our response — put it back by logging it (would need a re-queue mechanism for production)
tracing::warn!("Received non-matching A2A response, discarding: {}", msg.id);
// Not our response — requeue it for later processing
tracing::debug!("Re-queuing non-matching A2A message: {}", msg.id);
inbox.requeue(msg);
}
None => {
return Err(zclaw_types::ZclawError::Internal(

View File

@@ -12,7 +12,7 @@ tokio = { workspace = true }
futures = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = "0.9"
serde_yaml = { package = "serde_yaml_bw", version = "2" }
thiserror = { workspace = true }
anyhow = { workspace = true }
tracing = { workspace = true }

View File

@@ -405,7 +405,15 @@ impl A2aRouter {
if let Some(members) = groups.get(group_id) {
for agent_id in members {
if let Some(tx) = queues.get(agent_id) {
let _ = tx.send(envelope.clone()).await;
match tx.try_send(envelope.clone()) {
Ok(()) => {},
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("A2A delivery to agent {} dropped: channel full", agent_id);
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("A2A delivery to agent {} dropped: channel closed", agent_id);
}
}
}
}
}
@@ -414,7 +422,15 @@ impl A2aRouter {
// Broadcast to all registered agents
for (agent_id, tx) in queues.iter() {
if agent_id != &envelope.from {
let _ = tx.send(envelope.clone()).await;
match tx.try_send(envelope.clone()) {
Ok(()) => {},
Err(tokio::sync::mpsc::error::TrySendError::Full(_)) => {
tracing::warn!("A2A delivery to agent {} dropped: channel full", agent_id);
}
Err(tokio::sync::mpsc::error::TrySendError::Closed(_)) => {
tracing::warn!("A2A delivery to agent {} dropped: channel closed", agent_id);
}
}
}
}
}
@@ -444,6 +460,35 @@ impl A2aRouter {
}
}
/// Add agent to a group (creates group if not exists)
pub async fn add_to_group(&self, group_id: &str, agent_id: AgentId) {
let mut groups = self.groups.write().await;
let members = groups.entry(group_id.to_string()).or_insert_with(Vec::new);
if !members.contains(&agent_id) {
members.push(agent_id);
}
}
/// Remove agent from a group
pub async fn remove_from_group(&self, group_id: &str, agent_id: &AgentId) {
let mut groups = self.groups.write().await;
if let Some(members) = groups.get_mut(group_id) {
members.retain(|id| id != agent_id);
}
}
/// List all groups
pub async fn list_groups(&self) -> Vec<String> {
let groups = self.groups.read().await;
groups.keys().cloned().collect()
}
/// Get members of a group
pub async fn get_group_members(&self, group_id: &str) -> Vec<AgentId> {
let groups = self.groups.read().await;
groups.get(group_id).cloned().unwrap_or_default()
}
/// Get all registered agent profiles
pub async fn list_profiles(&self) -> Vec<A2aAgentProfile> {
let profiles = self.profiles.read().await;

View File

@@ -31,6 +31,7 @@ sha2 = { workspace = true }
rand = { workspace = true }
dashmap = { workspace = true }
hex = { workspace = true }
socket2 = { workspace = true }
url = "2"
axum = { workspace = true }
@@ -42,9 +43,9 @@ argon2 = { workspace = true }
totp-rs = { workspace = true }
urlencoding = "2"
data-encoding = "2"
regex = "1"
aes-gcm = "0.10"
bytes = "1"
regex = { workspace = true }
aes-gcm = { workspace = true }
bytes = { workspace = true }
[dev-dependencies]
tempfile = { workspace = true }

View File

@@ -0,0 +1,24 @@
-- 用户定义的定时任务表
-- 前端 SchedulerPanel 通过此表持久化定时任务配置
CREATE TABLE IF NOT EXISTS scheduled_tasks (
id TEXT PRIMARY KEY,
account_id TEXT NOT NULL REFERENCES accounts(id) ON DELETE CASCADE,
name TEXT NOT NULL,
description TEXT,
schedule TEXT NOT NULL, -- cron 表达式 / interval / ISO timestamp
schedule_type TEXT NOT NULL DEFAULT 'cron' CHECK (schedule_type IN ('cron', 'interval', 'once')),
target_type TEXT NOT NULL CHECK (target_type IN ('agent', 'hand', 'workflow')),
target_id TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT TRUE,
input_payload JSONB, -- 执行时的输入参数
last_run_at TIMESTAMPTZ,
next_run_at TIMESTAMPTZ,
run_count INTEGER NOT NULL DEFAULT 0,
last_error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_account ON scheduled_tasks(account_id);
CREATE INDEX IF NOT EXISTS idx_scheduled_tasks_enabled_next ON scheduled_tasks(enabled, next_run_at) WHERE enabled = TRUE;

View File

@@ -1,6 +1,8 @@
//! 认证 HTTP 处理器
use axum::{extract::{State, ConnectInfo}, http::StatusCode, Json};
use axum::{extract::{State, ConnectInfo}, Json};
use axum_extra::extract::CookieJar;
use axum_extra::extract::cookie::{Cookie, SameSite};
use std::net::SocketAddr;
use secrecy::ExposeSecret;
use crate::state::AppState;
@@ -12,13 +14,49 @@ use super::{
types::{AuthContext, LoginRequest, LoginResponse, RegisterRequest, ChangePasswordRequest, AccountPublic, RefreshRequest},
};
/// Cookie 配置常量
const ACCESS_TOKEN_COOKIE: &str = "zclaw_access_token";
const REFRESH_TOKEN_COOKIE: &str = "zclaw_refresh_token";
/// 构建 auth cookies 并附加到 CookieJar
fn set_auth_cookies(jar: CookieJar, token: &str, refresh_token: &str) -> CookieJar {
let access_max_age = std::time::Duration::from_secs(2 * 3600); // 2h
let refresh_max_age = std::time::Duration::from_secs(7 * 86400); // 7d
// cookie crate 需要 time::Duration从 std 转换
let access = Cookie::build((ACCESS_TOKEN_COOKIE, token.to_string()))
.http_only(true)
.secure(true)
.same_site(SameSite::Strict)
.path("/api")
.max_age(access_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(3600).try_into().unwrap()))
.build();
let refresh = Cookie::build((REFRESH_TOKEN_COOKIE, refresh_token.to_string()))
.http_only(true)
.secure(true)
.same_site(SameSite::Strict)
.path("/api/v1/auth")
.max_age(refresh_max_age.try_into().unwrap_or_else(|_| std::time::Duration::from_secs(86400).try_into().unwrap()))
.build();
jar.add(access).add(refresh)
}
/// 清除 auth cookies
fn clear_auth_cookies(jar: CookieJar) -> CookieJar {
jar.remove(Cookie::build(ACCESS_TOKEN_COOKIE).path("/api"))
.remove(Cookie::build(REFRESH_TOKEN_COOKIE).path("/api/v1/auth"))
}
/// POST /api/v1/auth/register
/// 注册成功后自动签发 JWT返回与 login 一致的 LoginResponse
pub async fn register(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
jar: CookieJar,
Json(req): Json<RegisterRequest>,
) -> SaasResult<(StatusCode, Json<LoginResponse>)> {
) -> SaasResult<(CookieJar, Json<LoginResponse>)> {
if req.username.len() < 3 {
return Err(SaasError::InvalidInput("用户名至少 3 个字符".into()));
}
@@ -100,9 +138,9 @@ pub async fn register(
state.jwt_secret.expose_secret(), 168,
).await?;
Ok((StatusCode::CREATED, Json(LoginResponse {
let resp = LoginResponse {
token,
refresh_token,
refresh_token: refresh_token.clone(),
account: AccountPublic {
id: account_id,
username: req.username,
@@ -113,15 +151,18 @@ pub async fn register(
totp_enabled: false,
created_at: now,
},
})))
};
let jar = set_auth_cookies(jar, &resp.token, &refresh_token);
Ok((jar, Json(resp)))
}
/// POST /api/v1/auth/login
pub async fn login(
State(state): State<AppState>,
ConnectInfo(addr): ConnectInfo<SocketAddr>,
jar: CookieJar,
Json(req): Json<LoginRequest>,
) -> SaasResult<Json<LoginResponse>> {
) -> SaasResult<(CookieJar, Json<LoginResponse>)> {
// 一次查询获取用户信息 + password_hash + totp_secret合并原来的 3 次查询)
let row: Option<AccountLoginRow> =
sqlx::query_as(
@@ -189,14 +230,16 @@ pub async fn login(
state.jwt_secret.expose_secret(), 168,
).await?;
Ok(Json(LoginResponse {
let resp = LoginResponse {
token,
refresh_token,
refresh_token: refresh_token.clone(),
account: AccountPublic {
id: r.id, username: r.username, email: r.email, display_name: r.display_name,
role: r.role, status: r.status, totp_enabled: r.totp_enabled, created_at: r.created_at,
},
}))
};
let jar = set_auth_cookies(jar, &resp.token, &refresh_token);
Ok((jar, Json(resp)))
}
/// POST /api/v1/auth/refresh
@@ -204,8 +247,9 @@ pub async fn login(
/// refresh_token 一次性使用,使用后立即失效
pub async fn refresh(
State(state): State<AppState>,
jar: CookieJar,
Json(req): Json<RefreshRequest>,
) -> SaasResult<Json<serde_json::Value>> {
) -> SaasResult<(CookieJar, Json<serde_json::Value>)> {
// 1. 验证 refresh token 签名 (跳过过期检查,但有 7 天窗口限制)
let claims = verify_token_skip_expiry(&req.refresh_token, state.jwt_secret.expose_secret())?;
@@ -282,10 +326,11 @@ pub async fn refresh(
// 9. 清理过期/已使用的 refresh tokens 已迁移到 Scheduler 定期执行
// 不再在每次 refresh 时阻塞请求
Ok(Json(serde_json::json!({
let jar = set_auth_cookies(jar, &new_access, &new_refresh);
Ok((jar, Json(serde_json::json!({
"token": new_access,
"refresh_token": new_refresh,
})))
}))))
}
/// GET /api/v1/auth/me — 返回当前认证用户的公开信息
@@ -456,3 +501,10 @@ fn sha256_hex(input: &str) -> String {
use sha2::{Sha256, Digest};
hex::encode(Sha256::digest(input.as_bytes()))
}
/// POST /api/v1/auth/logout — 清除 auth cookies
pub async fn logout(
jar: CookieJar,
) -> (CookieJar, axum::http::StatusCode) {
(clear_auth_cookies(jar), axum::http::StatusCode::NO_CONTENT)
}

View File

@@ -103,9 +103,10 @@ fn extract_client_ip(req: &Request) -> Option<String> {
.map(|s| s.to_string())
}
/// 认证中间件: 从 JWT API Token 提取身份
/// 认证中间件: 从 JWT Cookie / Authorization Header / API Token 提取身份
pub async fn auth_middleware(
State(state): State<AppState>,
jar: axum_extra::extract::cookie::CookieJar,
mut req: Request,
next: Next,
) -> Response {
@@ -114,25 +115,30 @@ pub async fn auth_middleware(
.get(header::AUTHORIZATION)
.and_then(|v| v.to_str().ok());
let result = if let Some(auth) = auth_header {
if let Some(token) = auth.strip_prefix("Bearer ") {
if token.starts_with("zclaw_") {
// API Token 路径
verify_api_token(&state, token, client_ip.clone()).await
} else {
// JWT 路径
let verify_result = jwt::verify_token(token, state.jwt_secret.expose_secret());
verify_result
.map(|claims| AuthContext {
account_id: claims.sub,
role: claims.role,
permissions: claims.permissions,
client_ip,
})
.map_err(|_| SaasError::Unauthorized)
}
// 尝试从 Authorization header 提取 token
let header_token = auth_header.and_then(|auth| auth.strip_prefix("Bearer "));
// 尝试从 HttpOnly cookie 提取 token (仅当 header 不存在时)
let cookie_token = jar.get("zclaw_access_token").map(|c| c.value().to_string());
let token = header_token
.or(cookie_token.as_deref());
let result = if let Some(token) = token {
if token.starts_with("zclaw_") {
// API Token 路径
verify_api_token(&state, token, client_ip.clone()).await
} else {
Err(SaasError::Unauthorized)
// JWT 路径
let verify_result = jwt::verify_token(token, state.jwt_secret.expose_secret());
verify_result
.map(|claims| AuthContext {
account_id: claims.sub,
role: claims.role,
permissions: claims.permissions,
client_ip,
})
.map_err(|_| SaasError::Unauthorized)
}
} else {
Err(SaasError::Unauthorized)
@@ -155,6 +161,7 @@ pub fn routes() -> axum::Router<AppState> {
.route("/api/v1/auth/register", post(handlers::register))
.route("/api/v1/auth/login", post(handlers::login))
.route("/api/v1/auth/refresh", post(handlers::refresh))
.route("/api/v1/auth/logout", post(handlers::logout))
}
/// 需要认证的路由

View File

@@ -148,6 +148,34 @@ pub async fn verify_totp(
return Err(SaasError::InvalidInput("TOTP 码必须是 6 位数字".into()));
}
// TOTP 暴力破解保护: 10 分钟内最多 5 次失败
const MAX_TOTP_FAILURES: u32 = 5;
const TOTP_LOCKOUT_SECS: u64 = 600;
let now = std::time::Instant::now();
let lockout_duration = std::time::Duration::from_secs(TOTP_LOCKOUT_SECS);
let is_locked = {
if let Some(entry) = state.totp_fail_counts.get(&ctx.account_id) {
let (count, first_fail) = entry.value();
if *count >= MAX_TOTP_FAILURES && now.duration_since(*first_fail) < lockout_duration {
true
} else {
// 窗口过期,重置
drop(entry);
state.totp_fail_counts.remove(&ctx.account_id);
false
}
} else {
false
}
};
if is_locked {
return Err(SaasError::RateLimited(
format!("TOTP 验证失败次数过多,请 {} 秒后重试", TOTP_LOCKOUT_SECS)
));
}
// 获取存储的密钥
let (totp_secret,): (Option<String>,) = sqlx::query_as(
"SELECT totp_secret FROM accounts WHERE id = $1"
@@ -172,9 +200,24 @@ pub async fn verify_totp(
};
if !verify_totp_code(&secret, code) {
// 记录失败次数
let new_count = {
let mut entry = state.totp_fail_counts
.entry(ctx.account_id.clone())
.or_insert((0, now));
entry.value_mut().0 += 1;
entry.value().0
};
tracing::warn!(
"TOTP verify failed for account {} ({}/{} attempts)",
ctx.account_id, new_count, MAX_TOTP_FAILURES
);
return Err(SaasError::Totp("TOTP 码验证失败".into()));
}
// 验证成功 → 清除失败计数
state.totp_fail_counts.remove(&ctx.account_id);
// 验证成功 → 启用 TOTP同时确保密钥已加密
let final_secret = if encrypted_secret.starts_with(crypto::ENCRYPTED_PREFIX) {
encrypted_secret
@@ -183,10 +226,10 @@ pub async fn verify_totp(
encrypt_totp_secret(&secret, &enc_key)?
};
let now = chrono::Utc::now().to_rfc3339();
let now_ts = chrono::Utc::now().to_rfc3339();
sqlx::query("UPDATE accounts SET totp_enabled = true, totp_secret = $1, updated_at = $2 WHERE id = $3")
.bind(&final_secret)
.bind(&now)
.bind(&now_ts)
.bind(&ctx.account_id)
.execute(&state.db)
.await?;

View File

@@ -2,7 +2,10 @@
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use secrecy::{ExposeSecret, SecretString};
use secrecy::SecretString;
#[cfg(not(debug_assertions))]
use secrecy::ExposeSecret;
#[cfg(not(debug_assertions))]
use sha2::Digest;
/// SaaS 服务器完整配置
@@ -226,21 +229,20 @@ impl SaaSConfig {
/// 获取 JWT 密钥 (从环境变量或生成临时值)
/// 生产环境必须设置 ZCLAW_SAAS_JWT_SECRET
pub fn jwt_secret(&self) -> anyhow::Result<SecretString> {
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
match std::env::var("ZCLAW_SAAS_JWT_SECRET") {
Ok(secret) => Ok(SecretString::from(secret)),
Err(_) => {
if is_dev {
// 开发 fallback 密钥仅在 debug 构建中可用,不会进入 release
#[cfg(debug_assertions)]
{
tracing::warn!("ZCLAW_SAAS_JWT_SECRET not set, using development default (INSECURE)");
Ok(SecretString::from("zclaw-dev-only-secret-do-not-use-in-prod".to_string()))
} else {
}
#[cfg(not(debug_assertions))]
{
anyhow::bail!(
"ZCLAW_SAAS_JWT_SECRET 环境变量未设置。\
请设置一个强随机密钥 (至少 32 字符)。\
开发环境可设置 ZCLAW_SAAS_DEV=true 使用默认值。"
请设置一个强随机密钥 (至少 32 字符)。"
)
}
}
@@ -256,10 +258,6 @@ impl SaaSConfig {
/// 从 ZCLAW_TOTP_ENCRYPTION_KEY 环境变量加载 (hex 编码的 64 字符)
/// 开发环境使用默认值 (不安全)
pub fn totp_encryption_key(&self) -> anyhow::Result<[u8; 32]> {
let is_dev = std::env::var("ZCLAW_SAAS_DEV")
.map(|v| v == "true" || v == "1")
.unwrap_or(false);
match std::env::var("ZCLAW_TOTP_ENCRYPTION_KEY") {
Ok(hex_key) => {
if hex_key.len() != 64 {
@@ -273,13 +271,16 @@ impl SaaSConfig {
Ok(key)
}
Err(_) => {
if is_dev {
// 开发环境: 仅在 debug 构建中使用固定密钥
#[cfg(debug_assertions)]
{
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, using development default (INSECURE)");
// 开发环境使用固定密钥
let mut key = [0u8; 32];
key.copy_from_slice(b"zclaw-dev-totp-encrypt-key-32b!x");
Ok(key)
} else {
}
#[cfg(not(debug_assertions))]
{
// 生产环境: 使用 JWT 密钥的 SHA-256 哈希作为加密密钥
tracing::warn!("ZCLAW_TOTP_ENCRYPTION_KEY not set, deriving from JWT secret");
let jwt = self.jwt_secret()?;

View File

@@ -4,7 +4,7 @@ use sqlx::postgres::PgPoolOptions;
use sqlx::PgPool;
use crate::error::SaasResult;
const SCHEMA_VERSION: i32 = 6;
const SCHEMA_VERSION: i32 = 7;
/// 初始化数据库
pub async fn init_db(database_url: &str) -> SaasResult<PgPool> {
@@ -90,7 +90,7 @@ async fn run_migration_files(pool: &PgPool, dir: &std::path::Path) -> SaasResult
let filename = path.file_name().unwrap_or_default().to_string_lossy();
tracing::info!("Running migration: {}", filename);
let content = std::fs::read_to_string(path)?;
for stmt in content.split(';') {
for stmt in split_sql_statements(&content) {
let trimmed = stmt.trim();
if !trimmed.is_empty() && !trimmed.starts_with("--") {
sqlx::query(trimmed).execute(pool).await?;
@@ -100,6 +100,150 @@ async fn run_migration_files(pool: &PgPool, dir: &std::path::Path) -> SaasResult
Ok(())
}
/// 按语句分割 SQL 文件内容,正确处理:
/// - 单引号字符串 `'...'`
/// - 双引号标识符 `"..."`
/// - 美元符号引用字符串 `$$...$$` 和 `$tag$...$tag$`
/// - `--` 单行注释
/// - `/* ... */` 块注释
/// - `E'...'` 转义字符串
fn split_sql_statements(sql: &str) -> Vec<String> {
let mut statements = Vec::new();
let mut current = String::new();
let mut chars = sql.chars().peekable();
while let Some(ch) = chars.next() {
match ch {
'\'' => {
// 单引号字符串
current.push(ch);
loop {
match chars.next() {
Some('\'') => {
current.push('\'');
// 检查是否为转义引号 ''
if chars.peek() == Some(&'\'') {
current.push(chars.next().unwrap());
} else {
break;
}
}
Some(c) => current.push(c),
None => break,
}
}
}
'"' => {
// 双引号标识符
current.push(ch);
loop {
match chars.next() {
Some('"') => {
current.push('"');
break;
}
Some(c) => current.push(c),
None => break,
}
}
}
'-' if chars.peek() == Some(&'-') => {
// 单行注释: 跳过直到行尾
chars.next(); // consume second '-'
while let Some(&c) = chars.peek() {
if c == '\n' {
chars.next();
current.push(c);
break;
}
chars.next();
}
}
'/' if chars.peek() == Some(&'*') => {
// 块注释: 跳过直到 */
chars.next(); // consume '*'
current.push_str("/*");
let mut prev = ' ';
loop {
match chars.next() {
Some('/') if prev == '*' => {
current.push('/');
break;
}
Some(c) => {
current.push(c);
prev = c;
}
None => break,
}
}
}
'$' => {
// 美元符号引用: $$ 或 $tag$ ... $tag$
current.push(ch);
// 读取 tag (字母数字和下划线)
let mut tag = String::new();
while let Some(&c) = chars.peek() {
if c == '$' || c.is_alphanumeric() || c == '_' {
if c == '$' {
chars.next();
current.push(c);
break;
}
chars.next();
tag.push(c);
current.push(c);
} else {
break;
}
}
// 如果 tag 为空,就是 $$ 格式
let end_marker = if tag.is_empty() {
"$$".to_string()
} else {
format!("${}$", tag)
};
// 读取直到遇到 end_marker
let mut buf = String::new();
loop {
match chars.next() {
Some(c) => {
current.push(c);
buf.push(c);
if buf.len() > end_marker.len() {
buf.remove(0);
}
if buf == end_marker {
break;
}
}
None => break,
}
}
}
';' => {
// 语句结束
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
statements.push(trimmed);
}
current.clear();
}
_ => {
current.push(ch);
}
}
}
// 最后一条语句 (可能不以分号结尾)
let trimmed = current.trim().to_string();
if !trimmed.is_empty() {
statements.push(trimmed);
}
statements
}
/// Seed 角色数据
async fn seed_roles(pool: &PgPool) -> SaasResult<()> {
let now = chrono::Utc::now().to_rfc3339();

View File

@@ -52,6 +52,10 @@ pub enum SaasError {
#[error("中转错误: {0}")]
Relay(String),
#[error("通用错误: {0}")]
General(#[from] anyhow::Error),
#[error("速率限制: {0}")]
RateLimited(String),
@@ -77,6 +81,7 @@ impl SaasError {
Self::Totp(_) => StatusCode::BAD_REQUEST,
Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Relay(_) => StatusCode::BAD_GATEWAY,
Self::General(_) => StatusCode::INTERNAL_SERVER_ERROR,
}
}
@@ -100,6 +105,7 @@ impl SaasError {
Self::Encryption(_) => "ENCRYPTION_ERROR",
Self::Config(_) => "CONFIG_ERROR",
Self::Relay(_) => "RELAY_ERROR",
Self::General(_) => "GENERAL_ERROR",
}
}
}

View File

@@ -22,4 +22,5 @@ pub mod migration;
pub mod role;
pub mod prompt;
pub mod agent_template;
pub mod scheduled_task;
pub mod telemetry;

View File

@@ -1,7 +1,7 @@
//! ZCLAW SaaS 服务入口
use axum::extract::State;
use socket2::{Domain, Protocol, Socket, TcpKeepalive, Type};
use tokio_util::sync::CancellationToken;
use tower_http::timeout::TimeoutLayer;
use tracing::info;
use zclaw_saas::{config::SaaSConfig, db::init_db, state::AppState};
@@ -36,7 +36,9 @@ async fn main() -> anyhow::Result<()> {
dispatcher.register(UpdateLastUsedWorker);
info!("Worker dispatcher initialized (5 workers registered)");
let state = AppState::new(db.clone(), config.clone(), dispatcher)?;
// 优雅停机令牌 — 取消后所有 SSE 流和长连接立即终止
let shutdown_token = CancellationToken::new();
let state = AppState::new(db.clone(), config.clone(), dispatcher, shutdown_token.clone())?;
// 启动声明式 Scheduler从 TOML 配置读取定时任务)
let scheduler_config = &config.scheduler;
@@ -46,6 +48,9 @@ async fn main() -> anyhow::Result<()> {
// 启动内置 DB 清理任务(设备清理等不通过 Worker 的任务)
zclaw_saas::scheduler::start_db_cleanup_tasks(db.clone());
// 启动用户定时任务调度循环30s 轮询 scheduled_tasks 表)
zclaw_saas::scheduler::start_user_task_scheduler(db.clone());
// 启动内存中的 rate limit 条目清理
let rate_limit_state = state.clone();
tokio::spawn(async move {
@@ -58,34 +63,58 @@ async fn main() -> anyhow::Result<()> {
let app = build_router(state).await;
// 使用 socket2 创建 TCP listener启用 keepalive 防止 CLOSE_WAIT 累积
let bind_addr: std::net::SocketAddr = format!("{}:{}", config.server.host, config.server.port).parse()?;
let domain = if bind_addr.is_ipv6() { Domain::IPV6 } else { Domain::IPV4 };
let socket = Socket::new(domain, Type::STREAM, Some(Protocol::TCP))?;
socket.set_reuse_address(true)?;
socket.set_nonblocking(true)?;
let keepalive = TcpKeepalive::new()
.with_time(std::time::Duration::from_secs(60))
.with_interval(std::time::Duration::from_secs(10));
#[cfg(target_os = "linux")]
let keepalive = keepalive.with_retries(3);
socket.set_tcp_keepalive(&keepalive)?;
info!("TCP keepalive enabled: 60s idle, 10s interval");
socket.bind(&bind_addr.into())?;
socket.listen(128)?;
let std_listener: std::net::TcpListener = socket.into();
let listener = tokio::net::TcpListener::from_std(std_listener)?;
// 配置 TCP keepalive + 短 SO_LINGER防止 CLOSE_WAIT 累积
let listener = create_listener(&config.server.host, config.server.port)?;
info!("SaaS server listening on {}:{}", config.server.host, config.server.port);
// 优雅停机: Ctrl+C → 取消 CancellationToken → SSE 流终止 → 连接排空
let token = shutdown_token.clone();
axum::serve(listener, app.into_make_service_with_connect_info::<std::net::SocketAddr>())
.with_graceful_shutdown(shutdown_signal())
.with_graceful_shutdown(async move {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
info!("Received shutdown signal, cancelling SSE streams and draining connections...");
token.cancel();
})
.await?;
Ok(())
}
async fn health_handler(State(state): State<AppState>) -> axum::Json<serde_json::Value> {
/// 创建带 TCP keepalive 和短 SO_LINGER 的 TcpListener防止 CLOSE_WAIT 累积
fn create_listener(host: &str, port: u16) -> anyhow::Result<tokio::net::TcpListener> {
let addr = format!("{}:{}", host, port);
let socket = socket2::Socket::new(
socket2::Domain::for_address(addr.parse::<std::net::SocketAddr>()?),
socket2::Type::STREAM,
Some(socket2::Protocol::TCP),
)?;
// SO_REUSEADDR: 允许快速重启时复用 TIME_WAIT 端口
socket.set_reuse_address(true)?;
// TCP keepalive: 60s 空闲后每 10s 探测,连续 3 次无响应则关闭
// 防止已断开但对端未发 FIN 的连接永远留在 CLOSE_WAIT
let keepalive = socket2::SockRef::from(&socket);
keepalive.set_tcp_keepalive(
&socket2::TcpKeepalive::new()
.with_time(std::time::Duration::from_secs(60))
.with_interval(std::time::Duration::from_secs(10)),
)?;
// 短 SO_LINGER (1s): 关闭时最多等 1 秒即 RST避免大量 TIME_WAIT
socket.set_linger(Some(std::time::Duration::from_secs(1)))?;
socket.bind(&addr.parse::<std::net::SocketAddr>()?.into())?;
socket.listen(1024)?;
socket.set_nonblocking(true)?;
Ok(tokio::net::TcpListener::from_std(socket.into())?)
}
async fn health_handler(
State(state): State<AppState>,
) -> (axum::http::StatusCode, axum::Json<serde_json::Value> ) {
// health 必须独立快速返回,用 3s 超时避免连接池满时阻塞
let db_healthy = tokio::time::timeout(
std::time::Duration::from_secs(3),
@@ -95,15 +124,41 @@ async fn health_handler(State(state): State<AppState>) -> axum::Json<serde_json:
.map(|r| r.is_ok())
.unwrap_or(false);
let status = if db_healthy { "healthy" } else { "degraded" };
let _code = if db_healthy { 200 } else { 503 };
// 连接池容量检查: 使用率 >= 80% 返回 503 (degraded)
let pool = &state.db;
let total = pool.options().get_max_connections() as usize;
if total > 0 {
let idle = pool.num_idle() as usize;
let used = total - idle;
let ratio = used * 100 / total;
if ratio >= 80 {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
axum::Json(serde_json::json!({
"status": "degraded",
"database": true,
"database_pool": {
"usage_pct": ratio,
"used": used,
"total": total,
},
"timestamp": chrono::Utc::now().to_rfc3339(),
"version": env!("CARGO_PKG_VERSION"),
})),
);
}
}
axum::Json(serde_json::json!({
let status = if db_healthy { "healthy" } else { "degraded" };
let code = if db_healthy {
axum::http::StatusCode::OK } else { axum::http::StatusCode::SERVICE_UNAVAILABLE };
(code, axum::Json(serde_json::json!({
"status": status,
"database": db_healthy,
"timestamp": chrono::Utc::now().to_rfc3339(),
"version": env!("CARGO_PKG_VERSION"),
}))
})))
}
async fn build_router(state: AppState) -> axum::Router {
@@ -123,6 +178,7 @@ async fn build_router(state: AppState) -> axum::Router {
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any)
.allow_credentials(true)
} else {
tracing::error!("生产环境必须配置 server.cors_origins不能使用 allow_origin(Any)");
panic!("生产环境必须配置 server.cors_origins 白名单。开发环境可设置 ZCLAW_SAAS_DEV=true 绕过。");
@@ -144,13 +200,19 @@ async fn build_router(state: AppState) -> axum::Router {
.allow_headers([
axum::http::header::AUTHORIZATION,
axum::http::header::CONTENT_TYPE,
axum::http::header::COOKIE,
axum::http::HeaderName::from_static("x-request-id"),
])
.allow_credentials(true)
}
};
let public_routes = zclaw_saas::auth::routes()
.route("/api/health", axum::routing::get(health_handler));
.route("/api/health", axum::routing::get(health_handler))
.layer(middleware::from_fn_with_state(
state.clone(),
zclaw_saas::middleware::public_rate_limit_middleware,
));
let protected_routes = zclaw_saas::auth::protected_routes()
.merge(zclaw_saas::account::routes())
@@ -160,6 +222,7 @@ async fn build_router(state: AppState) -> axum::Router {
.merge(zclaw_saas::role::routes())
.merge(zclaw_saas::prompt::routes())
.merge(zclaw_saas::agent_template::routes())
.merge(zclaw_saas::scheduled_task::routes())
.merge(zclaw_saas::telemetry::routes())
.layer(middleware::from_fn_with_state(
state.clone(),
@@ -178,19 +241,16 @@ async fn build_router(state: AppState) -> axum::Router {
zclaw_saas::auth::auth_middleware,
));
axum::Router::new()
// 非流式路由应用全局 15s 超时relay SSE 端点需要更长超时)
let non_streaming_routes = axum::Router::new()
.merge(public_routes)
.merge(protected_routes)
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)))
.layer(TimeoutLayer::new(std::time::Duration::from_secs(15)));
axum::Router::new()
.merge(non_streaming_routes)
.merge(zclaw_saas::relay::routes())
.layer(TraceLayer::new_for_http())
.layer(cors)
.with_state(state)
}
/// 监听 Ctrl+C 信号,触发 graceful shutdown
async fn shutdown_signal() {
tokio::signal::ctrl_c()
.await
.expect("Failed to install Ctrl+C handler");
info!("Received shutdown signal, draining connections...");
}

View File

@@ -49,6 +49,10 @@ pub async fn api_version_middleware(
/// 速率限制中间件
/// 基于账号的请求频率限制
///
/// ⚠️ CRITICAL: DashMap 的 RefMut 持有 parking_lot 写锁。
/// 必须在独立作用域块内完成所有 DashMap 操作,确保锁在 .await 之前释放。
/// 否则并发请求争抢同一 shard 锁会阻塞 tokio worker thread导致运行时死锁。
pub async fn rate_limit_middleware(
State(state): State<AppState>,
req: Request<Body>,
@@ -59,25 +63,77 @@ pub async fn rate_limit_middleware(
.map(|ctx| ctx.account_id.clone())
.unwrap_or_else(|| "anonymous".to_string());
// 无锁读取 rate limit 配置(避免每个请求获取 RwLock
let rate_limit = state.rate_limit_rpm() as usize;
let key = format!("rate_limit:{}", account_id);
let now = Instant::now();
let window_start = now - std::time::Duration::from_secs(60);
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= rate_limit {
// DashMap 操作限定在作用域块内,确保 RefMut持有 parking_lot 锁)在 await 前释放
let blocked = {
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= rate_limit {
true
} else {
entries.push(now);
false
}
}; // ← RefMut 在此处 drop释放 parking_lot shard 锁
if blocked {
return SaasError::RateLimited(format!(
"请求频率超限,每分钟最多 {} 次请求",
rate_limit
)).into_response();
}
entries.push(now);
next.run(req).await
}
/// 公共端点速率限制中间件 (基于客户端 IP更严格)
/// 用于登录/注册/刷新等无认证端点,防止暴力破解
const PUBLIC_RATE_LIMIT_RPM: usize = 20;
pub async fn public_rate_limit_middleware(
State(state): State<AppState>,
req: Request<Body>,
next: Next,
) -> Response<Body> {
// 从连接信息或 header 提取客户端 IP
let client_ip = req.extensions()
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
.map(|ci| ci.0.ip().to_string())
.unwrap_or_else(|| {
req.headers()
.get("x-real-ip")
.or_else(|| req.headers().get("x-forwarded-for"))
.and_then(|v| v.to_str().ok())
.map(|s| s.split(',').next().unwrap_or("unknown").trim().to_string())
.unwrap_or_else(|| "unknown".to_string())
});
let key = format!("public_rate_limit:{}", client_ip);
let now = Instant::now();
let window_start = now - std::time::Duration::from_secs(60);
let blocked = {
let mut entries = state.rate_limit_entries.entry(key).or_insert_with(Vec::new);
entries.retain(|&time| time > window_start);
if entries.len() >= PUBLIC_RATE_LIMIT_RPM {
true
} else {
entries.push(now);
false
}
};
if blocked {
return SaasError::RateLimited(
"请求频率超限,请稍后再试".into()
).into_response();
}
next.run(req).await
}

View File

@@ -399,8 +399,12 @@ pub async fn add_provider_key(
return Err(SaasError::InvalidInput("key_value 不能包含空白字符".into()));
}
// Encrypt the API key before storing in database
let enc_key = state.config.read().await.totp_encryption_key()?;
let encrypted_value = crate::crypto::encrypt_value(&req.key_value, &enc_key)?;
let key_id = super::key_pool::add_provider_key(
&state.db, &provider_id, &req.key_label, &req.key_value,
&state.db, &provider_id, &req.key_label, &encrypted_value,
req.priority, req.max_rpm, req.max_tpm,
req.quota_reset_interval.as_deref(),
).await?;

View File

@@ -4,7 +4,7 @@
use sqlx::PgPool;
use crate::error::{SaasError, SaasResult};
use crate::models::{ProviderKeySelectRow, ProviderKeyRow};
use crate::models::ProviderKeyRow;
use crate::crypto;
/// 解密 key_value (如果已加密),否则原样返回
@@ -36,19 +36,63 @@ pub struct KeySelection {
}
/// 从 provider 的 Key Pool 中选择最佳可用 Key
///
/// 优化: 单次 JOIN 查询获取 Key + 当前分钟使用量,避免 N+1 查询
pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32]) -> SaasResult<KeySelection> {
let now = chrono::Utc::now().to_rfc3339();
let current_minute = chrono::Utc::now().format("%Y-%m-%dT%H:%M").to_string();
// 获取所有活跃 Key
let rows: Vec<ProviderKeySelectRow> =
// 单次查询: 活跃 Key + 当前分钟的 RPM/TPM 使用量 (LEFT JOIN)
let rows: Vec<(String, String, i32, Option<i64>, Option<i64>, Option<String>, Option<i64>, Option<i64>)> =
sqlx::query_as(
"SELECT id, key_value, priority, max_rpm, max_tpm, quota_reset_interval
FROM provider_keys
WHERE provider_id = $1 AND is_active = TRUE AND (cooldown_until IS NULL OR cooldown_until <= $2)
ORDER BY priority ASC"
).bind(provider_id).bind(&now).fetch_all(db).await?;
"SELECT pk.id, pk.key_value, pk.priority, pk.max_rpm, pk.max_tpm, pk.quota_reset_interval,
uw.request_count, uw.token_count
FROM provider_keys pk
LEFT JOIN key_usage_window uw ON pk.id = uw.key_id AND uw.window_minute = $1
WHERE pk.provider_id = $2 AND pk.is_active = TRUE
AND (pk.cooldown_until IS NULL OR pk.cooldown_until <= $3)
ORDER BY pk.priority ASC"
).bind(&current_minute).bind(provider_id).bind(&now).fetch_all(db).await?;
for (id, key_value, priority, max_rpm, max_tpm, quota_reset_interval, req_count, token_count) in &rows {
// RPM 检查
if let Some(rpm_limit) = max_rpm {
if *rpm_limit > 0 {
let count = req_count.unwrap_or(0);
if count >= *rpm_limit {
tracing::debug!("Key {} hit RPM limit ({}/{})", id, count, rpm_limit);
continue;
}
}
}
// TPM 检查
if let Some(tpm_limit) = max_tpm {
if *tpm_limit > 0 {
let tokens = token_count.unwrap_or(0);
if tokens >= *tpm_limit {
tracing::debug!("Key {} hit TPM limit ({}/{})", id, tokens, tpm_limit);
continue;
}
}
}
// 此 Key 可用 — 解密 key_value
let decrypted_kv = decrypt_key_value(key_value, enc_key)?;
return Ok(KeySelection {
key: PoolKey {
id: id.clone(),
key_value: decrypted_kv,
priority: *priority,
max_rpm: *max_rpm,
max_tpm: *max_tpm,
quota_reset_interval: quota_reset_interval.clone(),
},
key_id: id.clone(),
});
}
// 所有 Key 都超限或无 Key
if rows.is_empty() {
// 检查是否有冷却中的 Key返回预计等待时间
let cooldown_row: Option<(String,)> = sqlx::query_as(
@@ -59,88 +103,14 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
).bind(provider_id).bind(&now).fetch_optional(db).await?;
if let Some((earliest,)) = cooldown_row {
// 尝试解析时间差
let wait_secs = parse_cooldown_remaining(&earliest, &now);
return Err(SaasError::RateLimited(
format!("所有 Key 均在冷却中,预计 {} 秒后可用", wait_secs)
));
}
// 检查 provider 级别的单 Key
let provider_key: Option<String> = sqlx::query_scalar(
"SELECT api_key FROM providers WHERE id = $1"
).bind(provider_id).fetch_optional(db).await?.flatten();
if let Some(key) = provider_key {
let decrypted = decrypt_key_value(&key, enc_key)?;
return Ok(KeySelection {
key: PoolKey {
id: "provider-fallback".to_string(),
key_value: decrypted,
priority: 0,
max_rpm: None,
max_tpm: None,
quota_reset_interval: None,
},
key_id: "provider-fallback".to_string(),
});
}
return Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id)));
}
// 检查滑动窗口使用量
for row in rows {
// 检查 RPM 限额
if let Some(rpm_limit) = row.max_rpm {
if rpm_limit > 0 {
let window: Option<(i64,)> = sqlx::query_as(
"SELECT COALESCE(SUM(request_count), 0) FROM key_usage_window
WHERE key_id = $1 AND window_minute = $2"
).bind(&row.id).bind(&current_minute).fetch_optional(db).await?;
if let Some((count,)) = window {
if count >= rpm_limit {
tracing::debug!("Key {} hit RPM limit ({}/{})", row.id, count, rpm_limit);
continue;
}
}
}
}
// 检查 TPM 限额
if let Some(tpm_limit) = row.max_tpm {
if tpm_limit > 0 {
let window: Option<(i64,)> = sqlx::query_as(
"SELECT COALESCE(SUM(token_count), 0) FROM key_usage_window
WHERE key_id = $1 AND window_minute = $2"
).bind(&row.id).bind(&current_minute).fetch_optional(db).await?;
if let Some((tokens,)) = window {
if tokens >= tpm_limit {
tracing::debug!("Key {} hit TPM limit ({}/{})", row.id, tokens, tpm_limit);
continue;
}
}
}
}
// 此 Key 可用 — 解密 key_value
let decrypted_kv = decrypt_key_value(&row.key_value, enc_key)?;
return Ok(KeySelection {
key: PoolKey {
id: row.id.clone(),
key_value: decrypted_kv,
priority: row.priority,
max_rpm: row.max_rpm,
max_tpm: row.max_tpm,
quota_reset_interval: row.quota_reset_interval,
},
key_id: row.id,
});
}
// 所有 Key 都超限,回退到 provider 单 Key
// 回退到 provider 单 Key
let provider_key: Option<String> = sqlx::query_scalar(
"SELECT api_key FROM providers WHERE id = $1"
).bind(provider_id).fetch_optional(db).await?.flatten();
@@ -160,9 +130,13 @@ pub async fn select_best_key(db: &PgPool, provider_id: &str, enc_key: &[u8; 32])
});
}
Err(SaasError::RateLimited(
format!("Provider {} 所有 Key 均已达限额", provider_id)
))
if rows.is_empty() {
Err(SaasError::NotFound(format!("Provider {} 没有可用的 API Key", provider_id)))
} else {
Err(SaasError::RateLimited(
format!("Provider {} 所有 Key 均已达限额", provider_id)
))
}
}
/// 记录 Key 使用量(滑动窗口)

View File

@@ -298,7 +298,20 @@ pub async fn execute_relay(
let body = axum::body::Body::from_stream(body_stream);
// SSE 流结束后异步记录 usage + Key 使用量
// 使用全局 Arc<Semaphore> 限制并发 spawned tasks防止高并发时耗尽连接池
static SSE_SPAWN_SEMAPHORE: std::sync::OnceLock<Arc<tokio::sync::Semaphore>> = std::sync::OnceLock::new();
let semaphore = SSE_SPAWN_SEMAPHORE.get_or_init(|| Arc::new(tokio::sync::Semaphore::new(16)));
let permit = match semaphore.clone().try_acquire_owned() {
Ok(p) => p,
Err(_) => {
// 信号量满时跳过 usage 记录,流本身不受影响
tracing::warn!("SSE usage spawn at capacity, skipping usage record for task {}", task_id);
return Ok(RelayResponse::Sse(body));
}
};
tokio::spawn(async move {
let _permit = permit; // 持有 permit 直到任务完成
tokio::time::sleep(std::time::Duration::from_secs(3)).await;
let capture = usage_capture.lock().await;
let (input, output) = (
@@ -464,11 +477,11 @@ async fn validate_provider_url(url: &str) -> SaasResult<()> {
// 去除 IPv6 方括号
let host = host.trim_start_matches('[').trim_end_matches(']');
// 精确匹配的阻止列表
// 精确匹配的阻止列表: 仅包含主机名和特殊域名
// 私有 IP 范围 (10.x, 172.16-31.x, 192.168.x, 127.x, 169.254.x, ::1 等)
// 由 is_private_ip() 统一判断,无需在此重复列出
let blocked_exact = [
"127.0.0.1", "0.0.0.0", "localhost", "::1", "::ffff:127.0.0.1",
"0:0:0:0:0:ffff:7f00:1", "169.254.169.254", "metadata.google.internal",
"10.0.0.1", "172.16.0.1", "192.168.0.1",
"localhost", "metadata.google.internal",
];
if blocked_exact.contains(&host) {
return Err(SaasError::InvalidInput(format!("provider URL 指向禁止的内网地址: {}", host)));

View File

@@ -0,0 +1,79 @@
//! 定时任务 HTTP 处理器
use axum::{
extract::{State, Path, Extension},
http::StatusCode,
Json,
};
use crate::state::AppState;
use crate::error::SaasResult;
use crate::auth::types::AuthContext;
use super::{types::*, service};
/// POST /api/scheduler/tasks — 创建定时任务
pub async fn create_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Json(req): Json<CreateScheduledTaskRequest>,
) -> SaasResult<(StatusCode, Json<ScheduledTaskResponse>)> {
// 验证
if req.name.is_empty() {
return Err(crate::error::SaasError::InvalidInput("任务名称不能为空".into()));
}
if req.schedule.is_empty() {
return Err(crate::error::SaasError::InvalidInput("调度表达式不能为空".into()));
}
if !["cron", "interval", "once"].contains(&req.schedule_type.as_str()) {
return Err(crate::error::SaasError::InvalidInput(
format!("无效的 schedule_type: {},可选: cron, interval, once", req.schedule_type)
));
}
if !["agent", "hand", "workflow"].contains(&req.target.target_type.as_str()) {
return Err(crate::error::SaasError::InvalidInput(
format!("无效的 target_type: {},可选: agent, hand, workflow", req.target.target_type)
));
}
let resp = service::create_task(&state.db, &ctx.account_id, &req).await?;
Ok((StatusCode::CREATED, Json(resp)))
}
/// GET /api/scheduler/tasks — 列出定时任务
pub async fn list_tasks(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
) -> SaasResult<Json<Vec<ScheduledTaskResponse>>> {
let tasks = service::list_tasks(&state.db, &ctx.account_id).await?;
Ok(Json(tasks))
}
/// GET /api/scheduler/tasks/:id — 获取单个定时任务
pub async fn get_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<Json<ScheduledTaskResponse>> {
let task = service::get_task(&state.db, &ctx.account_id, &id).await?;
Ok(Json(task))
}
/// PATCH /api/scheduler/tasks/:id — 更新定时任务
pub async fn update_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
Json(req): Json<UpdateScheduledTaskRequest>,
) -> SaasResult<Json<ScheduledTaskResponse>> {
let task = service::update_task(&state.db, &ctx.account_id, &id, &req).await?;
Ok(Json(task))
}
/// DELETE /api/scheduler/tasks/:id — 删除定时任务
pub async fn delete_task(
State(state): State<AppState>,
Extension(ctx): Extension<AuthContext>,
Path(id): Path<String>,
) -> SaasResult<StatusCode> {
service::delete_task(&state.db, &ctx.account_id, &id).await?;
Ok(StatusCode::NO_CONTENT)
}

View File

@@ -0,0 +1,15 @@
//! 用户定时任务管理模块
pub mod types;
pub mod service;
pub mod handlers;
use axum::routing::{get, post, patch, delete};
use crate::state::AppState;
/// 定时任务路由 (需要认证)
pub fn routes() -> axum::Router<AppState> {
axum::Router::new()
.route("/api/scheduler/tasks", get(handlers::list_tasks).post(handlers::create_task))
.route("/api/scheduler/tasks/:id", get(handlers::get_task).patch(handlers::update_task).delete(handlers::delete_task))
}

View File

@@ -0,0 +1,195 @@
//! 定时任务数据库服务层
use sqlx::{PgPool, FromRow};
use crate::error::SaasResult;
use super::types::*;
/// 数据库行结构
#[derive(Debug, FromRow)]
struct ScheduledTaskRow {
id: String,
account_id: String,
name: String,
description: Option<String>,
schedule: String,
schedule_type: String,
target_type: String,
target_id: String,
enabled: bool,
last_run_at: Option<String>,
next_run_at: Option<String>,
run_count: i32,
last_error: Option<String>,
input_payload: Option<serde_json::Value>,
created_at: String,
}
impl ScheduledTaskRow {
fn to_response(&self) -> ScheduledTaskResponse {
ScheduledTaskResponse {
id: self.id.clone(),
name: self.name.clone(),
schedule: self.schedule.clone(),
schedule_type: self.schedule_type.clone(),
target: TaskTarget {
target_type: self.target_type.clone(),
id: self.target_id.clone(),
},
enabled: self.enabled,
description: self.description.clone(),
last_run: self.last_run_at.clone(),
next_run: self.next_run_at.clone(),
run_count: self.run_count,
last_error: self.last_error.clone(),
created_at: self.created_at.clone(),
}
}
}
/// 创建定时任务
pub async fn create_task(
db: &PgPool,
account_id: &str,
req: &CreateScheduledTaskRequest,
) -> SaasResult<ScheduledTaskResponse> {
let id = uuid::Uuid::new_v4().to_string();
let now = chrono::Utc::now().to_rfc3339();
let input_json = req.input.as_ref().map(|v| v.to_string());
sqlx::query(
"INSERT INTO scheduled_tasks (id, account_id, name, description, schedule, schedule_type, target_type, target_id, enabled, input_payload, created_at, updated_at)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)"
)
.bind(&id)
.bind(account_id)
.bind(&req.name)
.bind(&req.description)
.bind(&req.schedule)
.bind(&req.schedule_type)
.bind(&req.target.target_type)
.bind(&req.target.id)
.bind(req.enabled.unwrap_or(true))
.bind(&input_json)
.bind(&now)
.execute(db)
.await?;
Ok(ScheduledTaskResponse {
id,
name: req.name.clone(),
schedule: req.schedule.clone(),
schedule_type: req.schedule_type.clone(),
target: req.target.clone(),
enabled: req.enabled.unwrap_or(true),
description: req.description.clone(),
last_run: None,
next_run: None,
run_count: 0,
last_error: None,
created_at: now,
})
}
/// 列出用户的定时任务
pub async fn list_tasks(
db: &PgPool,
account_id: &str,
) -> SaasResult<Vec<ScheduledTaskResponse>> {
let rows: Vec<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
FROM scheduled_tasks WHERE account_id = $1 ORDER BY created_at DESC"
)
.bind(account_id)
.fetch_all(db)
.await?;
Ok(rows.iter().map(|r| r.to_response()).collect())
}
/// 获取单个定时任务
pub async fn get_task(
db: &PgPool,
account_id: &str,
task_id: &str,
) -> SaasResult<ScheduledTaskResponse> {
let row: Option<ScheduledTaskRow> = sqlx::query_as(
"SELECT id, account_id, name, description, schedule, schedule_type,
target_type, target_id, enabled, last_run_at, next_run_at,
run_count, last_error, input_payload, created_at
FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
)
.bind(task_id)
.bind(account_id)
.fetch_optional(db)
.await?;
Ok(row
.ok_or_else(|| crate::error::SaasError::NotFound("定时任务不存在".into()))?
.to_response())
}
/// 更新定时任务
pub async fn update_task(
db: &PgPool,
account_id: &str,
task_id: &str,
req: &UpdateScheduledTaskRequest,
) -> SaasResult<ScheduledTaskResponse> {
let existing = get_task(db, account_id, task_id).await?;
let name = req.name.as_deref().unwrap_or(&existing.name);
let schedule = req.schedule.as_deref().unwrap_or(&existing.schedule);
let schedule_type = req.schedule_type.as_deref().unwrap_or(&existing.schedule_type);
let enabled = req.enabled.unwrap_or(existing.enabled);
let description = req.description.as_deref().or(existing.description.as_deref());
let now = chrono::Utc::now().to_rfc3339();
let (target_type, target_id) = if let Some(ref target) = req.target {
(target.target_type.as_str(), target.id.as_str())
} else {
(existing.target.target_type.as_str(), existing.target.id.as_str())
};
sqlx::query(
"UPDATE scheduled_tasks SET name = $1, schedule = $2, schedule_type = $3,
target_type = $4, target_id = $5, enabled = $6, description = $7,
updated_at = $8
WHERE id = $9 AND account_id = $10"
)
.bind(name)
.bind(schedule)
.bind(schedule_type)
.bind(target_type)
.bind(target_id)
.bind(enabled)
.bind(description)
.bind(&now)
.bind(task_id)
.bind(account_id)
.execute(db)
.await?;
get_task(db, account_id, task_id).await
}
/// 删除定时任务
pub async fn delete_task(
db: &PgPool,
account_id: &str,
task_id: &str,
) -> SaasResult<()> {
let result = sqlx::query(
"DELETE FROM scheduled_tasks WHERE id = $1 AND account_id = $2"
)
.bind(task_id)
.bind(account_id)
.execute(db)
.await?;
if result.rows_affected() == 0 {
return Err(crate::error::SaasError::NotFound("定时任务不存在".into()));
}
Ok(())
}

View File

@@ -0,0 +1,63 @@
//! 定时任务类型定义
use serde::{Deserialize, Serialize};
/// 创建定时任务请求
#[derive(Debug, Deserialize)]
pub struct CreateScheduledTaskRequest {
pub name: String,
pub schedule: String,
/// "cron" | "interval" | "once"
#[serde(default = "default_schedule_type")]
pub schedule_type: String,
pub target: TaskTarget,
pub description: Option<String>,
#[serde(default = "default_enabled")]
pub enabled: Option<bool>,
pub input: Option<serde_json::Value>,
}
fn default_schedule_type() -> String {
"cron".to_string()
}
fn default_enabled() -> Option<bool> {
Some(true)
}
/// 任务目标
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct TaskTarget {
#[serde(rename = "type")]
pub target_type: String,
pub id: String,
}
/// 更新定时任务请求
#[derive(Debug, Deserialize)]
pub struct UpdateScheduledTaskRequest {
pub name: Option<String>,
pub schedule: Option<String>,
pub schedule_type: Option<String>,
pub target: Option<TaskTarget>,
pub description: Option<String>,
pub enabled: Option<bool>,
pub input: Option<serde_json::Value>,
}
/// 定时任务响应
#[derive(Debug, Serialize)]
pub struct ScheduledTaskResponse {
pub id: String,
pub name: String,
pub schedule: String,
pub schedule_type: String,
pub target: TaskTarget,
pub enabled: bool,
pub description: Option<String>,
pub last_run: Option<String>,
pub next_run: Option<String>,
pub run_count: i32,
pub last_error: Option<String>,
pub created_at: String,
}

Some files were not shown because too many files have changed in this diff Show More