Compare commits

...

31 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
iven
834aa12076 fix: P0 panic风险修复 + P1编译warnings清零 + P2代码/文档清理
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
P0 安全性:
- account/handlers.rs: .unwrap() → .expect() 语义化错误信息
- relay/handlers.rs: SSE Response .unwrap() → .expect()

P1 编译质量 (6 warnings → 0):
- kernel.rs: 移除未使用的 Capability import 和 config_clone 变量
- pipeline_commands.rs: 未使用变量 id → _id
- db.rs: 移除多余括号
- relay/service.rs: 移除未使用的 StreamExt import
- telemetry/service.rs: 抑制 param_idx 未读赋值警告
- main.rs: TcpKeepalive::with_retries() Linux-only 条件编译

P2 代码清理:
- 移除 handStore/HandsPanel/HandTaskPanel/gateway-api/SchedulerPanel 调试 console.log
- SchedulerPanel: 修复 updateWorkflow 未解构导致 TS 编译错误
- 文档清理 zclaw-channels 已移除 crate 的引用
2026-03-30 11:33:47 +08:00
iven
813b49a986 feat: P0 KernelClient功能修复 + P1/P2/P3质量改进
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
P0 KernelClient 功能断裂修复:
- Skill CUD: registry.rs create/update/delete + serialize_skill_md + kernel proxy
- Workflow CUD: pipeline_commands.rs create/update/delete + serde_yaml依赖
- Agent更新: registry update方法 + AgentConfigUpdated事件 + agent_update命令
- Hand流式事件: HandStart/HandEnd变体替换ToolStart/ToolEnd
- 后端验证: hand_get/hand_run_status/hand_run_list确认实现完整
- Approval闭环: respond_to_approval后台spawn+5分钟超时轮询

P2/P3 质量改进:
- Browser WebDriver: TCP探测ChromeDriver/GeckoDriver/Edge端口替换硬编码true
- api-fallbacks: 移除假技能和16个捏造安全层,替换为真实能力映射
- dead_code清理: 移除5个模块级#![allow(dead_code)],删除3个真正死方法,
  删除未注册的compactor_compact_llm命令,warnings从8降到3
- 所有变更通过cargo check + tsc --noEmit验证
2026-03-30 10:55:08 +08:00
iven
d345e60a6a fix(scripts): start-all.ps1 适配 admin-v2 (Vite port 5173)
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 从 Next.js (port 3000) 迁移到 Vite (port 5173),
更新启动/停止/清理逻辑,保留旧端口 3000-3002 的 legacy 清理。
2026-03-30 09:44:49 +08:00
iven
a7d33d0207 feat(admin): Admin V2 — Ant Design Pro 纯 SPA 重写
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Next.js SSR/hydration 与 SWR fetch-on-mount 存在根本冲突:
hydration 卸载组件时 abort 的请求仍占用后端 DB 连接,
retry 循环耗尽 PostgreSQL 连接池导致后端完全卡死。

admin-v2 使用 Vite + React + antd 纯 SPA 彻底消除此问题:
- 12 页面全部完成(Login, Dashboard, Accounts, Providers, Models,
  API Keys, Usage, Relay, Config, Prompts, Logs, Agent Templates)
- ProTable + ProForm + ProLayout 统一 UI 模式
- TanStack Query + Axios + Zustand 数据层
- JWT 自动刷新 + 401 重试机制
- 全部 18 网络请求 200 OK,零 ERR_ABORTED

同时更新 troubleshooting 第 13 节和 SaaS 平台文档。
2026-03-30 09:35:59 +08:00
iven
13c0b18bbc feat: Batch 5-9 — GrowthIntegration桥接、验证补全、死代码清理、Pipeline模板、Speech/Twitter真实实现
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
Batch 5 (P0): GrowthIntegration 接入 Tauri
- Kernel 新增 set_viking()/set_extraction_driver() 桥接 SqliteStorage
- 中间件链共享存储,MemoryExtractor 接入 LLM 驱动

Batch 6 (P1): 输入验证 + Heartbeat
- Relay 验证补全(stream 兼容检查、API key 格式校验)
- UUID 类型校验、SessionId 错误返回
- Heartbeat 默认开启 + 首次聊天自动初始化

Batch 7 (P2): 死代码清理
- zclaw-channels 整体移除(317 行)
- multi-agent 特性门控、admin 方法标注

Batch 8 (P2): Pipeline 模板
- PipelineMetadata 新增 annotations 字段
- pipeline_templates 命令 + 2 个示例模板
- fallback driver base_url 修复(doubao/qwen/deepseek 端点)

Batch 9 (P1): SpeechHand/TwitterHand 真实实现
- SpeechHand: tts_method 字段 + Browser TTS 前端集成 (Web Speech API)
- TwitterHand: 12 个 action 全部替换为 Twitter API v2 真实 HTTP 调用
- chatStore/useAutomationEvents 双路径 TTS 触发
2026-03-30 09:24:50 +08:00
iven
5595083b96 feat(skills): SemanticSkillRouter — TF-IDF + Embedding 混合路由
实现 SemanticSkillRouter 核心模块 (zclaw-skills):
- Embedder trait + NoOpEmbedder (TF-IDF fallback)
- SkillTfidfIndex 全文索引
- retrieve_candidates() Top-K 检索
- route() 置信度阈值路由 (0.85)
- cosine_similarity 公共函数
- 单元测试覆盖

Kernel 适配层 (zclaw-kernel):
- EmbeddingAdapter: zclaw-growth EmbeddingClient → Embedder

文档同步:
- 01-intelligent-routing.md Phase 1+2 标记完成
2026-03-30 00:54:11 +08:00
iven
eed26a1ce4 feat(pipeline): Pipeline 图持久化 — GraphStore 实现
新增 GraphStore trait 和 MemoryGraphStore 实现:
- save/load/delete/list_ids 异步接口
- 可选文件持久化到 JSON 目录
- 启动时从磁盘加载已保存的图

SkillOrchestrationDriver 集成:
- 新增 with_graph_store() 构造函数
- graph_id 路径从硬编码错误改为从 GraphStore 查找
- 无 store 时返回明确的错误信息

修复了 "Graph loading by ID not yet implemented" 的 TODO
2026-03-30 00:25:38 +08:00
iven
f3f586efef feat(kernel): Agent 导入/导出 + message_count 跟踪
Sprint 3.1 message_count 修复:
- AgentRegistry 新增 message_counts 字段跟踪每个 agent 的消息数
- increment_message_count() 在 send_message 和 send_message_stream 中调用
- get_info() 返回实际计数值

Sprint 3.3 Agent 导入/导出:
- Kernel 新增 get_agent_config() 方法返回原始 AgentConfig
- 新增 agent_export Tauri 命令,导出配置为 JSON
- 新增 agent_import Tauri 命令,从 JSON 导入并自动生成新 ID
- 注册到 Tauri invoke_handler
2026-03-30 00:19:02 +08:00
iven
6040d98b18 fix(kernel): message_count 始终为 0 的 bug
- AgentRegistry 新增 message_counts: DashMap<AgentId, u64> 跟踪字段
- 添加 increment_message_count() 方法
- Kernel.send_message() 和 send_message_stream() 中递增计数
- get_info() 返回实际计数值而非硬编码 0
2026-03-30 00:04:55 +08:00
iven
ee29b7b752 fix(pipeline): BREAK-04 接入 pipeline-complete 事件监听
PipelinesPanel 新增 useEffect 订阅 PipelineClient.onComplete(),
处理用户导航离开后的后台 Pipeline 完成通知。

- 后台完成时 toast 提示成功/失败
- 跳过当前选中 pipeline 的重复通知(轮询路径已处理)
- 组件卸载时自动清理监听器
2026-03-29 23:51:55 +08:00
iven
7e90cea117 fix(kernel): BREAK-02 记忆提取链路闭合 + BREAK-03 审批 HandRun 跟踪
BREAK-02 记忆提取链路闭合:
- Kernel 新增 viking: Arc<VikingAdapter> 共享存储后端
- VikingAdapter 在 boot() 中初始化, 全生命周期共享
- create_middleware_chain() 注册 MemoryMiddleware (priority 150)
- CompactionMiddleware 的 growth 参数从 None 改为 GrowthIntegration
- zclaw-runtime 重新导出 VikingAdapter

BREAK-03 审批后 HandRun 跟踪:
- respond_to_approval() 添加完整 HandRun 生命周期跟踪
- Pending → Running → Completed/Failed 状态转换
- 支持 duration_ms 计时和 cancellation 注册
- 与 execute_hand() 保持一致的跟踪粒度
2026-03-29 23:45:52 +08:00
iven
09df242cf8 fix(saas): Sprint 1 P0 阻塞修复
1.1 补全 docker-compose.yml (PostgreSQL 16 + SaaS 后端容器)
1.2 Migration 系统化:
    - provider_keys.max_rpm/max_tpm 改为 BIGINT 匹配 Rust Option<i64>
    - 移除 seed_demo_data 中的 ALTER TABLE 运行时修补
    - seed 数据绑定类型 i32→i64 对齐列定义
1.3 saas-config.toml 修复:
    - 添加 cors_origins (开发环境 localhost)
    - 添加 [scheduler] section (注释示例)
    - 数据库密码改为开发默认值 + ZCLAW_DATABASE_URL 环境变量覆盖
    - 添加配置文档注释 (JWT/TOTP/管理员环境变量)
2026-03-29 23:27:24 +08:00
iven
04c366fe8b feat(runtime): DeerFlow 模式中间件链 Phase 1-4 全部完成
借鉴 DeerFlow 架构,实现完整中间件链系统:

Phase 1 - Agent 中间件链基础设施
- MiddlewareChain Clone 支持
- LoopRunner 双路径集成 (middleware/legacy)
- Kernel create_middleware_chain() 工厂方法

Phase 2 - 技能按需注入
- SkillIndexMiddleware (priority 200)
- SkillLoadTool 工具
- SkillDetail/SkillIndexEntry 结构体
- KernelSkillExecutor trait 扩展

Phase 3 - Guardrail 安全护栏
- GuardrailMiddleware (priority 400, fail_open)
- ShellExecRule / FileWriteRule / WebFetchRule

Phase 4 - 记忆闭环统一
- MemoryMiddleware (priority 150, 30s 防抖)
- after_completion 双路径调用

中间件注册顺序:
100 Compaction | 150 Memory | 200 SkillIndex
400 Guardrail  | 500 LoopGuard | 700 TokenCalibration

向后兼容:Option<MiddlewareChain> 默认 None 走旧路径
2026-03-29 23:19:41 +08:00
iven
7de294375b feat(auth): 添加异步密码哈希和验证函数
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
refactor(relay): 复用HTTP客户端和请求体序列化结果

feat(kernel): 添加获取单个审批记录的方法

fix(store): 改进SaaS连接错误分类和降级处理

docs: 更新审计文档和系统架构文档

refactor(prompt): 优化SQL查询参数化绑定

refactor(migration): 使用静态SQL和COALESCE更新配置项

feat(commands): 添加审批执行状态追踪和事件通知

chore: 更新启动脚本以支持Admin后台

fix(auth-guard): 优化授权状态管理和错误处理

refactor(db): 使用异步密码哈希函数

refactor(totp): 使用异步密码验证函数

style: 清理无用文件和注释

docs: 更新功能全景和审计文档

refactor(service): 优化HTTP客户端重用和请求处理

fix(connection): 改进SaaS不可用时的降级处理

refactor(handlers): 使用异步密码验证函数

chore: 更新依赖和工具链配置
2026-03-29 21:45:29 +08:00
iven
b7ec317d2c docs: 更新功能文档 — 反映架构重构成果
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
- docs/features/README.md — 技能数 69→70, Hands 11个, 成熟度更新
- 智能层文档成熟度上调 (身份演化 L3, 反思引擎 L3)
- 后端集成文档更新 SaaS 迁移系统说明
- 知识库添加架构重构记录
2026-03-29 19:42:37 +08:00
iven
a0ca35c9dd feat(saas): SQL 迁移系统 + TIMESTAMPTZ + 热路径重构
P0: SQL 迁移系统
- crates/zclaw-saas/migrations/ — 独立 SQL 迁移文件目录
- 20260329000001_initial_schema.sql — TIMESTAMPTZ 完整 schema
- 20260329000002_seed_data.sql — 角色种子数据
- db.rs: 移除 335 行内联 SCHEMA_SQL,改为文件加载
- 版本追踪: saas_schema_version 表管理迁移状态
- 向后兼容: 已有 TEXT 时间戳数据库不受影响

P1: 安全重构
- relay/service.rs: update_task_status 从 format!() 改为 3 条独立参数化查询
- config.rs: 移除 TODO 注释,补充字段文档说明
- state.rs: 添加 dispatch_log_operation 异步日志派发方法

P2: Worker 集成
- state.rs: WorkerDispatcher 接入 AppState
- 所有异步后台任务基础设施就绪
2026-03-29 19:41:03 +08:00
iven
77374121dd fix(saas): 清理 role/mod.rs 重复路由定义
移除重复的 routes() 函数,将 get_role_permissions 路由指向 handlers_ext
2026-03-29 19:23:40 +08:00
iven
8b9d506893 refactor(saas): 架构重构 + 性能优化 — 借鉴 loco-rs 模式
Phase 0: 知识库
- docs/knowledge-base/loco-rs-patterns.md — loco-rs 10 个可借鉴模式研究

Phase 1: 数据层重构
- crates/zclaw-saas/src/models/ — 15 个 FromRow 类型化模型
- Login 3 次查询合并为 1 次 AccountLoginRow 查询
- 所有 service 文件从元组解构迁移到 FromRow 结构体

Phase 2: Worker + Scheduler 系统
- crates/zclaw-saas/src/workers/ — Worker trait + 5 个具体实现
- crates/zclaw-saas/src/scheduler.rs — TOML 声明式调度器
- crates/zclaw-saas/src/tasks/ — CLI 任务系统

Phase 3: 性能修复
- Relay N+1 查询 → 精准 SQL (relay/handlers.rs)
- Config RwLock → AtomicU32 无锁 rate limit (state.rs, middleware.rs)
- SSE std::sync::Mutex → tokio::sync::Mutex (relay/service.rs)
- /auth/refresh 阻塞清理 → Scheduler 定期执行

Phase 4: 多环境配置
- config/saas-{development,production,test}.toml
- ZCLAW_ENV 环境选择 + ZCLAW_SAAS_CONFIG 精确覆盖
- scheduler 配置集成到 TOML
2026-03-29 19:21:48 +08:00
iven
5fdf96c3f5 chore: 提交所有工作进度 — SaaS 后端增强、Admin UI、桌面端集成
包含大量 SaaS 平台改进、Admin 管理后台更新、桌面端集成完善、
文档同步、测试文件重构等内容。为 QA 测试准备干净工作树。
2026-03-29 10:46:41 +08:00
iven
9a5fad2b59 feat(saas): 合并 SaaS 后端、Admin 管理后台、桌面端集成
- 14 commits from worktree-saas-backend
- crates/zclaw-saas: Axum 后端 (auth, accounts, models, relay, config-sync)
- admin/: Next.js 管理后台
- desktop/: SaaS 客户端集成 (saasStore, 2FA, relay, config sync)
- saas-config.toml, docker-compose.yml, Dockerfile
- 84 files, 15558 insertions
2026-03-28 00:54:53 +08:00
438 changed files with 38530 additions and 11135 deletions

Submodule .claude/worktrees/saas-backend added at 4d8d560d1f

4
.gitignore vendored
View File

@@ -12,6 +12,10 @@ build/
.env.local
.env.*.local
# SaaS config (contains database credentials)
saas-config.toml
!saas-config.toml.example
# Logs
logs/
*.log

0
Authorization Normal file
View File

View File

@@ -36,17 +36,20 @@ ZCLAW/
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
│ ├── zclaw-protocols/ # 协议支持 (MCP, A2A)
│ └── zclaw-saas/ # SaaS 后端 (账号, 模型配置, 中转, 配置同步)
├── admin/ # Next.js 管理后台
├── desktop/ # Tauri 桌面应用
│ ├── src/
│ │ ├── components/ # React UI 组件
│ │ ├── store/ # Zustand 状态管理
│ │ └── lib/ # 客户端通信 / 工具函数
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
├── skills/ # SKILL.md 技能定义
├── hands/ # HAND.toml 自主能力配置
├── config/ # TOML 配置文件
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
├── docker-compose.yml # PostgreSQL 容器配置
├── docs/ # 架构文档和知识库
└── tests/ # Vitest 回归测试
```
@@ -66,7 +69,9 @@ ZCLAW/
| 桌面框架 | Tauri 2.x |
| 样式方案 | Tailwind CSS |
| 配置格式 | TOML |
| 后端核心 | Rust Workspace (8 crates) |
| 后端核心 | Rust Workspace (9 crates) |
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
| 管理后台 | Next.js (admin/) |
### 2.3 Crate 依赖关系
@@ -79,7 +84,9 @@ zclaw-runtime (→ types, memory)
zclaw-kernel (→ types, memory, runtime)
desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
zclaw-saas (→ types, 独立运行于 8080 端口)
desktop/src-tauri (→ kernel, skills, hands, protocols)
```
***
@@ -191,10 +198,10 @@ ZCLAW 提供 11 个自主能力包:
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
| Twitter | Twitter 自动化 | ✅ 可用12 个 API v2 真实调用,写操作需 OAuth 1.0a |
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo |
| Slideshow | 幻灯片生成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用 |
| Speech | 语音合成 | ✅ 可用Browser TTS 前端集成完成) |
| Quiz | 测验生成 | ✅ 可用 |
**触发 Hand 时:**
@@ -260,6 +267,18 @@ docs/
- **面向未来** - 文档要帮助未来的开发者快速理解
- **中文优先** - 所有面向用户的文档使用中文
### 8.3 完成工作后的文档同步(强制)
每次完成功能实现、架构变更、问题修复后,**必须**同步更新以下文档:
1. **CLAUDE.md** — 如果涉及项目结构、技术栈、工作流程、命令的变化
2. **docs/features/** — 如果涉及新功能、功能变更、功能状态更新
3. **docs/knowledge-base/** — 如果涉及新知识、故障排查经验、配置说明
4. **saas-config.toml 注释** — 如果涉及 SaaS 配置项变更
5. **CHANGELOG** — 如果涉及对外可见的行为变化
**执行时机:** 代码编译通过且验证成功后,在标记任务完成之前,立即执行文档更新。文档更新是任务完成的必要条件,不是可选步骤。
***
## 9. 常见问题排查

1007
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -9,7 +9,6 @@ members = [
# ZCLAW Extension Crates
"crates/zclaw-skills",
"crates/zclaw-hands",
"crates/zclaw-channels",
"crates/zclaw-protocols",
"crates/zclaw-pipeline",
"crates/zclaw-growth",
@@ -30,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"
@@ -57,7 +57,7 @@ chrono = { version = "0.4", features = ["serde"] }
uuid = { version = "1", features = ["v4", "v5", "serde"] }
# Database
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite"] }
sqlx = { version = "0.7", features = ["runtime-tokio", "sqlite", "postgres"] }
libsqlite3-sys = { version = "0.27", features = ["bundled"] }
# HTTP client (for LLM drivers)
@@ -94,19 +94,26 @@ regex = "1"
# Shell parsing
shlex = "1"
# WASM runtime
wasmtime = { version = "43", default-features = false, features = ["cranelift"] }
wasmtime-wasi = { version = "43" }
# Testing
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"] }
tower-http = { version = "0.5", features = ["cors", "trace", "limit", "timeout"] }
jsonwebtoken = "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" }
@@ -114,7 +121,6 @@ zclaw-runtime = { path = "crates/zclaw-runtime" }
zclaw-kernel = { path = "crates/zclaw-kernel" }
zclaw-skills = { path = "crates/zclaw-skills" }
zclaw-hands = { path = "crates/zclaw-hands" }
zclaw-channels = { path = "crates/zclaw-channels" }
zclaw-protocols = { path = "crates/zclaw-protocols" }
zclaw-pipeline = { path = "crates/zclaw-pipeline" }
zclaw-growth = { path = "crates/zclaw-growth" }

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

24
admin-v2/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
admin-v2/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
admin-v2/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
admin-v2/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>admin-v2</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

39
admin-v2/package.json Normal file
View File

@@ -0,0 +1,39 @@
{
"name": "admin-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@ant-design/charts": "^2.6.7",
"@ant-design/icons": "^6.1.1",
"@ant-design/pro-components": "^2.8.10",
"@ant-design/pro-layout": "^7.22.7",
"@tanstack/react-query": "^5.95.2",
"antd": "^6.3.4",
"axios": "^1.14.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.13.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

5008
admin-v2/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
admin-v2/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

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

@@ -0,0 +1,103 @@
// ============================================================
// AdminLayout — ProLayout 管理后台布局
// ============================================================
import { Outlet, useNavigate, useLocation } from 'react-router-dom'
import ProLayout from '@ant-design/pro-layout'
import {
DashboardOutlined,
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
KeyOutlined,
BarChartOutlined,
SwapOutlined,
SettingOutlined,
FileTextOutlined,
MessageOutlined,
RobotOutlined,
LogoutOutlined,
} from '@ant-design/icons'
import { useAuthStore } from '@/stores/authStore'
import { Avatar, Dropdown, message } from 'antd'
import type { MenuDataItem } from '@ant-design/pro-layout'
const menuConfig: MenuDataItem[] = [
{ path: '/', name: '仪表盘', icon: <DashboardOutlined /> },
{ path: '/accounts', name: '账号管理', icon: <TeamOutlined />, permission: 'account:admin' },
{ path: '/providers', name: '服务商', icon: <CloudServerOutlined />, permission: 'provider:manage' },
{ path: '/models', name: '模型管理', icon: <ApiOutlined />, permission: 'model:read' },
{ path: '/agent-templates', name: 'Agent 模板', icon: <RobotOutlined />, permission: 'model:read' },
{ path: '/api-keys', name: 'API 密钥', icon: <KeyOutlined />, permission: 'admin:full' },
{ path: '/usage', name: '用量统计', icon: <BarChartOutlined />, permission: 'admin:full' },
{ path: '/relay', name: '中转任务', icon: <SwapOutlined />, permission: 'relay:use' },
{ path: '/config', name: '系统配置', icon: <SettingOutlined />, permission: 'config:read' },
{ path: '/prompts', name: '提示词管理', icon: <MessageOutlined />, permission: 'prompt:read' },
{ path: '/logs', name: '操作日志', icon: <FileTextOutlined />, permission: 'admin:full' },
]
function filterMenuByPermission(
items: MenuDataItem[],
hasPermission: (p: string) => boolean,
): MenuDataItem[] {
return items
.filter((item) => !item.permission || hasPermission(item.permission as string))
.map(({ permission, ...rest }) => ({
...rest,
children: rest.children ? filterMenuByPermission(rest.children, hasPermission) : undefined,
}))
}
export default function AdminLayout() {
const navigate = useNavigate()
const location = useLocation()
const { account, hasPermission, logout } = useAuthStore()
const menuData = filterMenuByPermission(menuConfig, hasPermission)
const handleLogout = () => {
logout()
message.success('已退出登录')
navigate('/login', { replace: true })
}
return (
<ProLayout
title="ZCLAW"
logo={<div style={{ width: 28, height: 28, background: '#1677ff', borderRadius: 6, display: 'flex', alignItems: 'center', justifyContent: 'center', color: '#fff', fontWeight: 700, fontSize: 14 }}>Z</div>}
layout="mix"
fixSiderbar
fixedHeader
location={{ pathname: location.pathname }}
menuDataRender={() => menuData}
menuItemRender={(item, dom) => (
<div onClick={() => item.path && navigate(item.path)}>{dom}</div>
)}
avatarProps={{
src: undefined,
title: account?.display_name || account?.username || 'Admin',
size: 'small',
render: (_, dom) => (
<Dropdown
menu={{
items: [
{
key: 'logout',
icon: <LogoutOutlined />,
label: '退出登录',
onClick: handleLogout,
},
],
}}
>
{dom}
</Dropdown>
),
}}
suppressSiderWhenMenuEmpty
contentStyle={{ padding: 24 }}
>
<Outlet />
</ProLayout>
)
}

29
admin-v2/src/main.tsx Normal file
View File

@@ -0,0 +1,29 @@
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
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: {
queries: {
retry: 1,
refetchOnWindowFocus: false,
staleTime: 30_000,
},
},
})
createRoot(document.getElementById('root')!).render(
<ErrorBoundary>
<ConfigProvider locale={zhCN}>
<AntApp>
<QueryClientProvider client={queryClient}>
<RouterProvider router={router} />
</QueryClientProvider>
</AntApp>
</ConfigProvider>
</ErrorBoundary>,
)

View File

@@ -0,0 +1,170 @@
// ============================================================
// 账号管理
// ============================================================
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Popconfirm, Space } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { accountService } from '@/services/accounts'
import type { AccountPublic } from '@/types'
const roleLabels: Record<string, string> = {
super_admin: '超级管理员',
admin: '管理员',
user: '用户',
}
const roleColors: Record<string, string> = {
super_admin: 'red',
admin: 'blue',
user: 'default',
}
const statusLabels: Record<string, string> = {
active: '正常',
disabled: '已禁用',
suspended: '已封禁',
}
const statusColors: Record<string, string> = {
active: 'green',
disabled: 'default',
suspended: 'red',
}
export default function Accounts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['accounts'],
queryFn: ({ signal }) => accountService.list(signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<AccountPublic> }) =>
accountService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: string; status: AccountPublic['status'] }) =>
accountService.updateStatus(id, { status }),
onSuccess: () => {
message.success('状态更新成功')
queryClient.invalidateQueries({ queryKey: ['accounts'] })
},
onError: (err: Error) => message.error(err.message || '状态更新失败'),
})
const columns: ProColumns<AccountPublic>[] = [
{ title: '用户名', dataIndex: 'username', width: 120 },
{ title: '显示名', dataIndex: 'display_name', width: 120 },
{ title: '邮箱', dataIndex: 'email', width: 180 },
{
title: '角色',
dataIndex: 'role',
width: 120,
render: (_, record) => <Tag color={roleColors[record.role]}>{roleLabels[record.role] || record.role}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, record) => <Tag color={statusColors[record.status]}>{statusLabels[record.status] || record.status}</Tag>,
},
{
title: '2FA',
dataIndex: 'totp_enabled',
width: 80,
render: (_, record) => record.totp_enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '最后登录',
dataIndex: 'last_login_at',
width: 180,
render: (_, record) => record.last_login_at ? new Date(record.last_login_at).toLocaleString('zh-CN') : '-',
},
{
title: '操作',
width: 200,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
{record.status === 'active' ? (
<Popconfirm title="确定禁用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'disabled' })}>
<Button size="small" danger></Button>
</Popconfirm>
) : (
<Popconfirm title="确定启用此账号?" onConfirm={() => statusMutation.mutate({ id: record.id, status: 'active' })}>
<Button size="small" type="primary"></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
}
}
return (
<div>
<ProTable<AccountPublic>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => []}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="编辑账号"
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="display_name" label="显示名">
<Input />
</Form.Item>
<Form.Item name="email" label="邮箱">
<Input type="email" />
</Form.Item>
<Form.Item name="role" label="角色">
<Select options={[
{ value: 'super_admin', label: '超级管理员' },
{ value: 'admin', label: '管理员' },
{ value: 'user', label: '用户' },
]} />
</Form.Item>
</Form>
</Modal>
</div>
)
}
import { useState } from 'react'

View File

@@ -0,0 +1,190 @@
// ============================================================
// Agent 模板管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, InputNumber, Space, Popconfirm, Descriptions } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { agentTemplateService } from '@/services/agent-templates'
import type { AgentTemplate } from '@/types'
const { TextArea } = Input
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const visibilityLabels: Record<string, string> = { public: '公开', team: '团队', private: '私有' }
const statusLabels: Record<string, string> = { active: '活跃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', archived: 'default' }
export default function AgentTemplates() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [detailRecord, setDetailRecord] = useState<AgentTemplate | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['agent-templates'],
queryFn: ({ signal }) => agentTemplateService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof agentTemplateService.create>[0]) =>
agentTemplateService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (id: string) => agentTemplateService.archive(id),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['agent-templates'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const columns: ProColumns<AgentTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '模型', dataIndex: 'model', width: 140, render: (_, r) => r.model || '-' },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source] || r.source}</Tag>,
},
{
title: '可见性',
dataIndex: 'visibility',
width: 80,
render: (_, r) => <Tag color="blue">{visibilityLabels[r.visibility] || r.visibility}</Tag>,
},
{
title: '状态',
dataIndex: 'status',
width: 80,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailRecord(record)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此模板?" onConfirm={() => archiveMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
return (
<div>
<ProTable<AgentTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建 Agent 模板"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="category" label="分类">
<Input placeholder="如 assistant, tool" />
</Form.Item>
<Form.Item name="model" label="默认模型">
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词">
<TextArea rows={4} />
</Form.Item>
<Form.Item name="temperature" label="Temperature">
<InputNumber min={0} max={2} step={0.1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_tokens" label="最大 Token">
<InputNumber min={1} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="visibility" label="可见性">
<Select options={[
{ value: 'public', label: '公开' },
{ value: 'team', label: '团队' },
{ value: 'private', label: '私有' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="模板详情"
open={!!detailRecord}
onCancel={() => setDetailRecord(null)}
footer={null}
width={640}
>
{detailRecord && (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailRecord.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailRecord.category}</Descriptions.Item>
<Descriptions.Item label="模型">{detailRecord.model || '-'}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailRecord.source]}</Descriptions.Item>
<Descriptions.Item label="可见性">{visibilityLabels[detailRecord.visibility]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailRecord.status]}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailRecord.description || '-'}</Descriptions.Item>
<Descriptions.Item label="系统提示词" span={2}>
<div style={{ whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
{detailRecord.system_prompt || '-'}
</div>
</Descriptions.Item>
<Descriptions.Item label="工具" span={2}>
{detailRecord.tools?.map((t) => <Tag key={t}>{t}</Tag>) || '-'}
</Descriptions.Item>
<Descriptions.Item label="能力" span={2}>
{detailRecord.capabilities?.map((c) => <Tag key={c} color="blue">{c}</Tag>) || '-'}
</Descriptions.Item>
</Descriptions>
)}
</Modal>
</div>
)
}

View File

@@ -0,0 +1,165 @@
// ============================================================
// API 密钥管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Select, Popconfirm, Space, Typography } from 'antd'
import { PlusOutlined, CopyOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { apiKeyService } from '@/services/api-keys'
import type { TokenInfo } from '@/types'
const { Text } = Typography
export default function ApiKeys() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [newToken, setNewToken] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['api-keys'],
queryFn: ({ signal }) => apiKeyService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: { name: string; expires_days?: number; permissions: string[] }) =>
apiKeyService.create(data),
onSuccess: (result: TokenInfo) => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
if (result.token) {
setNewToken(result.token)
}
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const revokeMutation = useMutation({
mutationFn: (id: string) => apiKeyService.revoke(id),
onSuccess: () => {
message.success('已撤销')
queryClient.invalidateQueries({ queryKey: ['api-keys'] })
},
onError: (err: Error) => message.error(err.message || '撤销失败'),
})
const columns: ProColumns<TokenInfo>[] = [
{ title: '名称', dataIndex: 'name', width: 160 },
{ title: '前缀', dataIndex: 'token_prefix', width: 120, render: (_, r) => <Text code>{r.token_prefix}...</Text> },
{
title: '权限',
dataIndex: 'permissions',
width: 200,
render: (_, r) => r.permissions?.map((p) => <Tag key={p}>{p}</Tag>),
},
{
title: '过期时间',
dataIndex: 'expires_at',
width: 180,
render: (_, r) => r.expires_at ? new Date(r.expires_at).toLocaleString('zh-CN') : '永不过期',
},
{
title: '最后使用',
dataIndex: 'last_used_at',
width: 180,
render: (_, r) => r.last_used_at ? new Date(r.last_used_at).toLocaleString('zh-CN') : '-',
},
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 100,
render: (_, record) => (
<Popconfirm title="确定撤销此密钥?撤销后无法恢复。" onConfirm={() => revokeMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const copyToken = () => {
if (newToken) {
navigator.clipboard.writeText(newToken)
message.success('已复制到剪贴板')
}
}
return (
<div>
<ProTable<TokenInfo>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="创建 API 密钥"
open={modalOpen}
onOk={handleCreate}
onCancel={() => { setModalOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="给密钥起个名字" />
</Form.Item>
<Form.Item name="expires_days" label="有效期 (天)">
<InputNumber min={1} placeholder="留空则永不过期" style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="permissions" label="权限" rules={[{ required: true }]}>
<Select mode="multiple" placeholder="选择权限" options={[
{ value: 'relay:use', label: '中转使用' },
{ value: 'model:read', label: '模型读取' },
{ value: 'config:read', label: '配置读取' },
]} />
</Form.Item>
</Form>
</Modal>
<Modal
title="密钥创建成功"
open={!!newToken}
onOk={() => setNewToken(null)}
onCancel={() => setNewToken(null)}
>
<p></p>
<Input.TextArea
value={newToken || ''}
rows={3}
readOnly
addonAfter={<CopyOutlined onClick={copyToken} style={{ cursor: 'pointer' }} />}
/>
<Button type="primary" icon={<CopyOutlined />} onClick={copyToken} style={{ marginTop: 8 }}>
</Button>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,110 @@
// ============================================================
// 系统配置
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Card, Tabs, message, Tag, Input, Button, Space, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { configService } from '@/services/config'
import type { ConfigItem } from '@/types'
const { Title } = Typography
export default function Config() {
const queryClient = useQueryClient()
const [category, setCategory] = useState<string>('general')
const [editingId, setEditingId] = useState<string | null>(null)
const [editValue, setEditValue] = useState('')
const { data, isLoading } = useQuery({
queryKey: ['config', category],
queryFn: ({ signal }) => configService.list({ category }, signal),
})
const updateMutation = useMutation({
mutationFn: ({ id, value }: { id: string; value: string }) =>
configService.update(id, { value }),
onSuccess: () => {
message.success('配置已更新')
queryClient.invalidateQueries({ queryKey: ['config', category] })
setEditingId(null)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const columns: ProColumns<ConfigItem>[] = [
{ title: '配置路径', dataIndex: 'key_path', width: 200, render: (_, r) => <code>{r.key_path}</code> },
{
title: '当前值',
dataIndex: 'current_value',
width: 250,
render: (_, record) => {
if (editingId === record.id) {
return (
<Space>
<Input
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
style={{ width: 180 }}
onPressEnter={() => updateMutation.mutate({ id: record.id, value: editValue })}
/>
<Button size="small" type="primary" onClick={() => updateMutation.mutate({ id: record.id, value: editValue })}>
</Button>
<Button size="small" onClick={() => setEditingId(null)}></Button>
</Space>
)
}
return (
<span
onClick={() => { setEditingId(record.id); setEditValue(record.current_value || '') }}
style={{ cursor: 'pointer', color: '#1677ff' }}
>
{record.current_value || <Tag></Tag>}
</span>
)
},
},
{ title: '默认值', dataIndex: 'default_value', width: 200, render: (_, r) => r.default_value || '-' },
{ title: '类型', dataIndex: 'value_type', width: 80, render: (_, r) => <Tag>{r.value_type}</Tag> },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '需要重启',
dataIndex: 'requires_restart',
width: 90,
render: (_, r) => r.requires_restart ? <Tag color="orange"></Tag> : <Tag></Tag>,
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Tabs
activeKey={category}
onChange={(key) => { setCategory(key); setEditingId(null) }}
items={[
{ key: 'general', label: '通用' },
{ key: 'auth', label: '认证' },
{ key: 'relay', label: '中转' },
{ key: 'model', label: '模型' },
{ key: 'rate_limit', label: '限流' },
{ key: 'log', label: '日志' },
]}
/>
<ProTable<ConfigItem>
columns={columns}
dataSource={data ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</div>
)
}

View File

@@ -0,0 +1,121 @@
// ============================================================
// 仪表盘页面
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Card, Col, Row, Statistic, Table, Tag, Typography, Spin, Alert } from 'antd'
import {
TeamOutlined,
CloudServerOutlined,
ApiOutlined,
ThunderboltOutlined,
ColumnWidthOutlined,
} from '@ant-design/icons'
import { statsService } from '@/services/stats'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
export default function Dashboard() {
const { data: stats, isLoading: statsLoading, error: statsError } = useQuery({
queryKey: ['dashboard-stats'],
queryFn: ({ signal }) => statsService.dashboard(signal),
})
const { data: logsData, isLoading: logsLoading } = useQuery({
queryKey: ['recent-logs'],
queryFn: ({ signal }) => logService.list({ page: 1, page_size: 10 }, signal),
})
if (statsError) {
return <Alert type="error" message="加载仪表盘数据失败" description={(statsError as Error).message} showIcon />
}
const statCards = [
{ title: '总账号', value: stats?.total_accounts ?? 0, icon: <TeamOutlined />, color: '#1677ff' },
{ title: '活跃服务商', value: stats?.active_providers ?? 0, icon: <CloudServerOutlined />, color: '#52c41a' },
{ title: '活跃模型', value: stats?.active_models ?? 0, icon: <ApiOutlined />, color: '#722ed1' },
{ title: '今日请求', value: stats?.tasks_today ?? 0, icon: <ThunderboltOutlined />, color: '#fa8c16' },
{ title: '今日 Token', value: ((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0)), icon: <ColumnWidthOutlined />, color: '#eb2f96' },
]
const logColumns = [
{
title: '操作类型',
dataIndex: 'action',
key: 'action',
width: 140,
render: (action: string) => (
<Tag color={actionColors[action] || 'default'}>
{actionLabels[action] || action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', key: 'target_type', width: 100, render: (v: string | null) => v || '-' },
{
title: '时间',
dataIndex: 'created_at',
key: 'created_at',
width: 180,
render: (v: string) => new Date(v).toLocaleString('zh-CN'),
},
]
return (
<div>
<Title level={4} style={{ marginBottom: 24 }}></Title>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
{statsLoading ? (
<Col span={24}><Spin /></Col>
) : (
statCards.map((card) => (
<Col xs={24} sm={12} md={8} lg={4} key={card.title}>
<Card>
<Statistic
title={card.title}
value={card.value}
prefix={<span style={{ color: card.color }}>{card.icon}</span>}
/>
</Card>
</Col>
))
)}
</Row>
<Card title="最近操作日志" size="small">
<Table<OperationLog>
columns={logColumns}
dataSource={logsData?.items ?? []}
loading={logsLoading}
rowKey="id"
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,138 @@
// ============================================================
// 登录页面
// ============================================================
import { useState } from 'react'
import { useNavigate, useSearchParams } from 'react-router-dom'
import { LoginForm, ProFormText } from '@ant-design/pro-components'
import { LockOutlined, UserOutlined, SafetyOutlined } from '@ant-design/icons'
import { message, Divider, Typography } from 'antd'
import { authService } from '@/services/auth'
import { useAuthStore } from '@/stores/authStore'
import type { LoginRequest } from '@/types'
const { Title, Text } = Typography
export default function Login() {
const navigate = useNavigate()
const [searchParams] = useSearchParams()
const loginStore = useAuthStore((s) => s.login)
const [needTotp, setNeedTotp] = useState(false)
const [loading, setLoading] = useState(false)
const handleSubmit = async (values: Record<string, string>) => {
setLoading(true)
try {
const data: LoginRequest = {
username: values.username?.trim() || '',
password: values.password || '',
totp_code: values.totp_code?.trim() || undefined,
}
const res = await authService.login(data)
loginStore(res.token, res.refresh_token, res.account)
message.success('登录成功')
const from = searchParams.get('from') || '/'
navigate(from, { replace: true })
} catch (err: unknown) {
const error = err as { message?: string; status?: number }
const msg = error.message || ''
if (msg.includes('TOTP') || msg.includes('totp') || msg.includes('2FA') || msg.includes('验证码') || error.status === 403) {
setNeedTotp(true)
message.warning(msg || '请输入两步验证码')
} else {
message.error(msg || '登录失败,请检查用户名和密码')
}
} finally {
setLoading(false)
}
}
return (
<div style={{ minHeight: '100vh', display: 'flex' }}>
{/* 左侧品牌区 */}
<div
style={{
flex: '1 1 0',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
background: 'linear-gradient(135deg, #001529 0%, #003a70 50%, #001529 100%)',
position: 'relative',
overflow: 'hidden',
}}
>
<Title level={1} style={{ color: '#fff', marginBottom: 8, letterSpacing: 4 }}>
ZCLAW
</Title>
<Text style={{ color: 'rgba(255,255,255,0.65)', fontSize: 16 }}>AI Agent </Text>
<Divider style={{ borderColor: 'rgba(22,119,255,0.3)', width: 100, minWidth: 100 }} />
<Text style={{ color: 'rgba(255,255,255,0.45)', fontSize: 13, maxWidth: 320, textAlign: 'center' }}>
AI API
</Text>
</div>
{/* 右侧登录表单 */}
<div
style={{
flex: '0 0 480px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: 48,
}}
>
<div style={{ width: '100%', maxWidth: 360 }}>
<Title level={3} style={{ marginBottom: 4 }}></Title>
<Text type="secondary" style={{ display: 'block', marginBottom: 32 }}>
</Text>
<LoginForm
onFinish={handleSubmit}
submitter={{
searchConfig: { submitText: '登录' },
submitButtonProps: { loading, block: true },
}}
>
<ProFormText
name="username"
fieldProps={{
size: 'large',
prefix: <UserOutlined />,
autoComplete: 'username',
}}
placeholder="请输入用户名"
rules={[{ required: true, message: '请输入用户名' }]}
/>
<ProFormText.Password
name="password"
fieldProps={{
size: 'large',
prefix: <LockOutlined />,
autoComplete: 'current-password',
}}
placeholder="请输入密码"
rules={[{ required: true, message: '请输入密码' }]}
/>
{needTotp && (
<ProFormText
name="totp_code"
fieldProps={{
size: 'large',
prefix: <SafetyOutlined />,
maxLength: 6,
autoComplete: 'one-time-code',
}}
placeholder="请输入 6 位验证码"
rules={[{ required: true, message: '请输入验证码' }]}
/>
)}
</LoginForm>
</div>
</div>
</div>
)
}

112
admin-v2/src/pages/Logs.tsx Normal file
View File

@@ -0,0 +1,112 @@
// ============================================================
// 操作日志
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { logService } from '@/services/logs'
import type { OperationLog } from '@/types'
const { Title } = Typography
const actionLabels: Record<string, string> = {
login: '登录', logout: '登出',
create_account: '创建账号', update_account: '更新账号', delete_account: '删除账号',
create_provider: '创建服务商', update_provider: '更新服务商', delete_provider: '删除服务商',
create_model: '创建模型', update_model: '更新模型', delete_model: '删除模型',
create_token: '创建密钥', revoke_token: '撤销密钥',
update_config: '更新配置',
create_prompt: '创建提示词', update_prompt: '更新提示词', archive_prompt: '归档提示词',
desktop_audit: '桌面端审计',
}
const actionColors: Record<string, string> = {
login: 'green', logout: 'default',
create_account: 'blue', update_account: 'orange', delete_account: 'red',
create_provider: 'blue', update_provider: 'orange', delete_provider: 'red',
create_model: 'blue', update_model: 'orange', delete_model: 'red',
create_token: 'blue', revoke_token: 'red',
update_config: 'orange',
create_prompt: 'blue', update_prompt: 'orange', archive_prompt: 'red',
desktop_audit: 'default',
}
const actionOptions = Object.entries(actionLabels).map(([value, label]) => ({ value, label }))
export default function Logs() {
const [page, setPage] = useState(1)
const [actionFilter, setActionFilter] = useState<string | undefined>(undefined)
const { data, isLoading } = useQuery({
queryKey: ['logs', page, actionFilter],
queryFn: ({ signal }) => logService.list({ page, page_size: 20, action: actionFilter }, signal),
})
const columns: ProColumns<OperationLog>[] = [
{
title: '操作类型',
dataIndex: 'action',
width: 140,
render: (_, r) => (
<Tag color={actionColors[r.action] || 'default'}>
{actionLabels[r.action] || r.action}
</Tag>
),
},
{ title: '目标类型', dataIndex: 'target_type', width: 100, render: (_, r) => r.target_type || '-' },
{ title: '目标 ID', dataIndex: 'target_id', width: 120, render: (_, r) => r.target_id ? <code>{r.target_id.substring(0, 8)}...</code> : '-' },
{
title: '详情',
dataIndex: 'details',
width: 250,
ellipsis: true,
render: (_, r) => {
if (!r.details) return '-'
if (typeof r.details === 'string') return r.details
return JSON.stringify(r.details)
},
},
{ title: 'IP 地址', dataIndex: 'ip_address', width: 130, render: (_, r) => <code>{r.ip_address || '-'}</code> },
{
title: '时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={actionFilter}
onChange={(v) => { setActionFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="操作类型筛选"
style={{ width: 160 }}
allowClear
options={[{ value: 'all', label: '全部操作' }, ...actionOptions]}
/>
</div>
<ProTable<OperationLog>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,186 @@
// ============================================================
// 模型管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Select, Space, Popconfirm } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { modelService } from '@/services/models'
import { providerService } from '@/services/providers'
import type { Model } from '@/types'
export default function Models() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['models'],
queryFn: ({ signal }) => modelService.list(signal),
})
const { data: providersData } = useQuery({
queryKey: ['providers-for-select'],
queryFn: ({ signal }) => providerService.list(signal),
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Model, 'id'>>) => modelService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Model, 'id'>> }) =>
modelService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => modelService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['models'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Model>[] = [
{ title: '模型 ID', dataIndex: 'model_id', width: 180, render: (_, r) => <code>{r.model_id}</code> },
{ title: '别名', dataIndex: 'alias', width: 140 },
{
title: '服务商',
dataIndex: 'provider_id',
width: 140,
render: (_, r) => {
const provider = providersData?.items?.find((p) => p.id === r.provider_id)
return provider?.display_name || r.provider_id.substring(0, 8)
},
},
{ title: '上下文窗口', dataIndex: 'context_window', width: 110, render: (_, r) => r.context_window?.toLocaleString() },
{ title: '最大输出', dataIndex: 'max_output_tokens', width: 100, render: (_, r) => r.max_output_tokens?.toLocaleString() },
{
title: '流式',
dataIndex: 'supports_streaming',
width: 70,
render: (_, r) => r.supports_streaming ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '视觉',
dataIndex: 'supports_vision',
width: 70,
render: (_, r) => r.supports_vision ? <Tag color="blue"></Tag> : <Tag></Tag>,
},
{
title: '状态',
dataIndex: 'enabled',
width: 70,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 160,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Popconfirm title="确定删除此模型?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Model>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑模型' : '新建模型'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
width={600}
>
<Form form={form} layout="vertical">
<Form.Item name="provider_id" label="服务商" rules={[{ required: true }]}>
<Select
options={(providersData?.items ?? []).map((p) => ({ value: p.id, label: p.display_name }))}
placeholder="选择服务商"
/>
</Form.Item>
<Form.Item name="model_id" label="模型 ID" rules={[{ required: true }]}>
<Input placeholder="如 gpt-4o" />
</Form.Item>
<Form.Item name="alias" label="别名">
<Input />
</Form.Item>
<Form.Item name="context_window" label="上下文窗口">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="max_output_tokens" label="最大输出 Token">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_streaming" label="支持流式" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="supports_vision" label="支持视觉" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="pricing_input" label="输入价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
<Form.Item name="pricing_output" label="输出价格 (每百万 Token)">
<InputNumber min={0} step={0.01} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,228 @@
// ============================================================
// 提示词管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, Select, Space, Popconfirm, Descriptions, Tabs, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { promptService } from '@/services/prompts'
import type { PromptTemplate, PromptVersion } from '@/types'
const { TextArea } = Input
const { Text } = Typography
const sourceLabels: Record<string, string> = { builtin: '内置', custom: '自定义' }
const statusLabels: Record<string, string> = { active: '活跃', deprecated: '已废弃', archived: '已归档' }
const statusColors: Record<string, string> = { active: 'green', deprecated: 'orange', archived: 'default' }
export default function Prompts() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [createOpen, setCreateOpen] = useState(false)
const [detailName, setDetailName] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['prompts'],
queryFn: ({ signal }) => promptService.list(signal),
})
const { data: detailData } = useQuery({
queryKey: ['prompt-detail', detailName],
queryFn: ({ signal }) => promptService.get(detailName!, signal),
enabled: !!detailName,
})
const { data: versionsData } = useQuery({
queryKey: ['prompt-versions', detailName],
queryFn: ({ signal }) => promptService.listVersions(detailName!, signal),
enabled: !!detailName,
})
const createMutation = useMutation({
mutationFn: (data: Parameters<typeof promptService.create>[0]) => promptService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
setCreateOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const archiveMutation = useMutation({
mutationFn: (name: string) => promptService.archive(name),
onSuccess: () => {
message.success('已归档')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
},
onError: (err: Error) => message.error(err.message || '归档失败'),
})
const rollbackMutation = useMutation({
mutationFn: ({ name, version }: { name: string; version: number }) =>
promptService.rollback(name, version),
onSuccess: () => {
message.success('回滚成功')
queryClient.invalidateQueries({ queryKey: ['prompts'] })
queryClient.invalidateQueries({ queryKey: ['prompt-detail', detailName] })
queryClient.invalidateQueries({ queryKey: ['prompt-versions', detailName] })
},
onError: (err: Error) => message.error(err.message || '回滚失败'),
})
const columns: ProColumns<PromptTemplate>[] = [
{ title: '名称', dataIndex: 'name', width: 200, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '分类', dataIndex: 'category', width: 100 },
{ title: '描述', dataIndex: 'description', width: 200, ellipsis: true },
{
title: '来源',
dataIndex: 'source',
width: 80,
render: (_, r) => <Tag>{sourceLabels[r.source]}</Tag>,
},
{ title: '版本', dataIndex: 'current_version', width: 70 },
{
title: '状态',
dataIndex: 'status',
width: 90,
render: (_, r) => <Tag color={statusColors[r.status]}>{statusLabels[r.status]}</Tag>,
},
{
title: '操作',
width: 180,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => setDetailName(record.name)}></Button>
{record.status === 'active' && (
<Popconfirm title="确定归档此提示词?" onConfirm={() => archiveMutation.mutate(record.name)}>
<Button size="small" danger></Button>
</Popconfirm>
)}
</Space>
),
},
]
const handleCreate = async () => {
const values = await form.validateFields()
createMutation.mutate(values)
}
const versionColumns: ProColumns<PromptVersion>[] = [
{ title: '版本', dataIndex: 'version', width: 60 },
{ title: '更新说明', dataIndex: 'changelog', width: 200, ellipsis: true },
{ title: '最低版本', dataIndex: 'min_app_version', width: 100, render: (_, r) => r.min_app_version || '-' },
{
title: '创建时间',
dataIndex: 'created_at',
width: 180,
render: (_, r) => new Date(r.created_at).toLocaleString('zh-CN'),
},
{
title: '操作',
width: 80,
render: (_, record) => (
<Popconfirm
title={`确定回滚到版本 ${record.version}`}
onConfirm={() => detailName && rollbackMutation.mutate({ name: detailName, version: record.version })}
>
<Button size="small"></Button>
</Popconfirm>
),
},
]
return (
<div>
<ProTable<PromptTemplate>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { form.resetFields(); setCreateOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title="新建提示词"
open={createOpen}
onOk={handleCreate}
onCancel={() => { setCreateOpen(false); form.resetFields() }}
confirmLoading={createMutation.isPending}
width={640}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="名称" rules={[{ required: true }]}>
<Input placeholder="唯一标识" />
</Form.Item>
<Form.Item name="category" label="分类" rules={[{ required: true }]}>
<Input placeholder="如 system, tool" />
</Form.Item>
<Form.Item name="description" label="描述">
<TextArea rows={2} />
</Form.Item>
<Form.Item name="system_prompt" label="系统提示词" rules={[{ required: true }]}>
<TextArea rows={6} />
</Form.Item>
<Form.Item name="user_prompt_template" label="用户提示词模板">
<TextArea rows={4} />
</Form.Item>
</Form>
</Modal>
<Modal
title={`提示词详情: ${detailName || ''}`}
open={!!detailName}
onCancel={() => setDetailName(null)}
footer={null}
width={800}
>
<Tabs items={[
{
key: 'info',
label: '基本信息',
children: detailData ? (
<Descriptions column={2} bordered size="small">
<Descriptions.Item label="名称">{detailData.name}</Descriptions.Item>
<Descriptions.Item label="分类">{detailData.category}</Descriptions.Item>
<Descriptions.Item label="来源">{sourceLabels[detailData.source]}</Descriptions.Item>
<Descriptions.Item label="状态">{statusLabels[detailData.status]}</Descriptions.Item>
<Descriptions.Item label="当前版本">{detailData.current_version}</Descriptions.Item>
<Descriptions.Item label="描述" span={2}>{detailData.description || '-'}</Descriptions.Item>
</Descriptions>
) : null,
},
{
key: 'versions',
label: '版本历史',
children: (
<ProTable<PromptVersion>
columns={versionColumns}
dataSource={versionsData ?? []}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
loading={!versionsData}
/>
),
},
]} />
</Modal>
</div>
)
}

View File

@@ -0,0 +1,188 @@
// ============================================================
// 服务商管理
// ============================================================
import { useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Button, message, Tag, Modal, Form, Input, InputNumber, Switch, Space, Popconfirm, Typography } from 'antd'
import { PlusOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { providerService } from '@/services/providers'
import type { Provider, ProviderKey } from '@/types'
const { Text } = Typography
export default function Providers() {
const queryClient = useQueryClient()
const [form] = Form.useForm()
const [modalOpen, setModalOpen] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
const [keyModalProviderId, setKeyModalProviderId] = useState<string | null>(null)
const { data, isLoading } = useQuery({
queryKey: ['providers'],
queryFn: ({ signal }) => providerService.list(signal),
})
const { data: keysData, isLoading: keysLoading } = useQuery({
queryKey: ['provider-keys', keyModalProviderId],
queryFn: ({ signal }) => providerService.listKeys(keyModalProviderId!, signal),
enabled: !!keyModalProviderId,
})
const createMutation = useMutation({
mutationFn: (data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>) =>
providerService.create(data),
onSuccess: () => {
message.success('创建成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
form.resetFields()
},
onError: (err: Error) => message.error(err.message || '创建失败'),
})
const updateMutation = useMutation({
mutationFn: ({ id, data }: { id: string; data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>> }) =>
providerService.update(id, data),
onSuccess: () => {
message.success('更新成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
setModalOpen(false)
},
onError: (err: Error) => message.error(err.message || '更新失败'),
})
const deleteMutation = useMutation({
mutationFn: (id: string) => providerService.delete(id),
onSuccess: () => {
message.success('删除成功')
queryClient.invalidateQueries({ queryKey: ['providers'] })
},
onError: (err: Error) => message.error(err.message || '删除失败'),
})
const columns: ProColumns<Provider>[] = [
{ title: '名称', dataIndex: 'display_name', width: 140 },
{ title: '标识', dataIndex: 'name', width: 120, render: (_, r) => <Text code>{r.name}</Text> },
{ title: '协议', dataIndex: 'api_protocol', width: 100 },
{ title: 'RPM 限制', dataIndex: 'rate_limit_rpm', width: 100, render: (_, r) => r.rate_limit_rpm ?? '-' },
{
title: '状态',
dataIndex: 'enabled',
width: 80,
render: (_, r) => r.enabled ? <Tag color="green"></Tag> : <Tag></Tag>,
},
{
title: '操作',
width: 260,
render: (_, record) => (
<Space>
<Button size="small" onClick={() => { setEditingId(record.id); form.setFieldsValue(record); setModalOpen(true) }}>
</Button>
<Button size="small" onClick={() => setKeyModalProviderId(record.id)}>
Key Pool
</Button>
<Popconfirm title="确定删除此服务商?" onConfirm={() => deleteMutation.mutate(record.id)}>
<Button size="small" danger></Button>
</Popconfirm>
</Space>
),
},
]
const keyColumns: ProColumns<ProviderKey>[] = [
{ title: '标签', dataIndex: 'key_label', width: 120 },
{ title: '优先级', dataIndex: 'priority', width: 80 },
{ title: '请求数', dataIndex: 'total_requests', width: 80 },
{ title: 'Token 数', dataIndex: 'total_tokens', width: 100 },
{
title: '状态',
dataIndex: 'is_active',
width: 80,
render: (_, r) => r.is_active ? <Tag color="green"></Tag> : <Tag></Tag>,
},
]
const handleSave = async () => {
const values = await form.validateFields()
if (editingId) {
updateMutation.mutate({ id: editingId, data: values })
} else {
createMutation.mutate(values)
}
}
return (
<div>
<ProTable<Provider>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={() => [
<Button key="add" type="primary" icon={<PlusOutlined />} onClick={() => { setEditingId(null); form.resetFields(); setModalOpen(true) }}>
</Button>,
]}
pagination={{
total: data?.total ?? 0,
pageSize: data?.page_size ?? 20,
current: data?.page ?? 1,
showSizeChanger: false,
}}
/>
<Modal
title={editingId ? '编辑服务商' : '新建服务商'}
open={modalOpen}
onOk={handleSave}
onCancel={() => { setModalOpen(false); setEditingId(null); form.resetFields() }}
confirmLoading={createMutation.isPending || updateMutation.isPending}
>
<Form form={form} layout="vertical">
<Form.Item name="name" label="标识" rules={[{ required: true }]}>
<Input disabled={!!editingId} />
</Form.Item>
<Form.Item name="display_name" label="显示名称" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="base_url" label="Base URL" rules={[{ required: true }]}>
<Input />
</Form.Item>
<Form.Item name="api_protocol" label="API 协议">
<Input placeholder="openai" />
</Form.Item>
<Form.Item name="enabled" label="启用" valuePropName="checked">
<Switch />
</Form.Item>
<Form.Item name="rate_limit_rpm" label="RPM 限制">
<InputNumber min={0} style={{ width: '100%' }} />
</Form.Item>
</Form>
</Modal>
<Modal
title="Key Pool"
open={!!keyModalProviderId}
onCancel={() => setKeyModalProviderId(null)}
footer={null}
width={700}
>
<ProTable<ProviderKey>
columns={keyColumns}
dataSource={keysData ?? []}
loading={keysLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Modal>
</div>
)
}

View File

@@ -0,0 +1,109 @@
// ============================================================
// 中转任务
// ============================================================
import { useQuery } from '@tanstack/react-query'
import { Tag, Select, Typography } from 'antd'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { relayService } from '@/services/relay'
import { useState } from 'react'
import type { RelayTask } from '@/types'
const { Title } = Typography
const statusLabels: Record<string, string> = {
queued: '排队中',
running: '运行中',
completed: '已完成',
failed: '失败',
cancelled: '已取消',
}
const statusColors: Record<string, string> = {
queued: 'default',
running: 'processing',
completed: 'green',
failed: 'red',
cancelled: 'default',
}
export default function Relay() {
const [statusFilter, setStatusFilter] = useState<string | undefined>(undefined)
const [page, setPage] = useState(1)
const { data, isLoading } = useQuery({
queryKey: ['relay-tasks', page, statusFilter],
queryFn: ({ signal }) => relayService.list({ page, page_size: 20, status: statusFilter }, signal),
})
const columns: ProColumns<RelayTask>[] = [
{ title: 'ID', dataIndex: 'id', width: 120, render: (_, r) => <code>{r.id.substring(0, 8)}...</code> },
{
title: '状态',
dataIndex: 'status',
width: 100,
render: (_, r) => <Tag color={statusColors[r.status] || 'default'}>{statusLabels[r.status] || r.status}</Tag>,
},
{ title: '模型', dataIndex: 'model_id', width: 160 },
{ title: '优先级', dataIndex: 'priority', width: 70 },
{ title: '尝试次数', dataIndex: 'attempt_count', width: 80 },
{
title: 'Token',
width: 140,
render: (_, r) => `${r.input_tokens.toLocaleString()} / ${r.output_tokens.toLocaleString()}`,
},
{ title: '错误信息', dataIndex: 'error_message', width: 200, ellipsis: true },
{
title: '排队时间',
dataIndex: 'queued_at',
width: 180,
render: (_, r) => new Date(r.queued_at).toLocaleString('zh-CN'),
},
{
title: '完成时间',
dataIndex: 'completed_at',
width: 180,
render: (_, r) => r.completed_at ? new Date(r.completed_at).toLocaleString('zh-CN') : '-',
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={statusFilter}
onChange={(v) => { setStatusFilter(v === 'all' ? undefined : v); setPage(1) }}
placeholder="状态筛选"
style={{ width: 140 }}
allowClear
options={[
{ value: 'all', label: '全部' },
{ value: 'queued', label: '排队中' },
{ value: 'running', label: '运行中' },
{ value: 'completed', label: '已完成' },
{ value: 'failed', label: '失败' },
{ value: 'cancelled', label: '已取消' },
]}
/>
</div>
<ProTable<RelayTask>
columns={columns}
dataSource={data?.items ?? []}
loading={isLoading}
rowKey="id"
search={false}
toolBarRender={false}
pagination={{
total: data?.total ?? 0,
pageSize: 20,
current: page,
onChange: setPage,
showSizeChanger: false,
}}
/>
</div>
)
}

View File

@@ -0,0 +1,120 @@
// ============================================================
// 用量统计
// ============================================================
import { useState } from 'react'
import { useQuery } from '@tanstack/react-query'
import { Card, Row, Col, Select, Spin, Alert, Statistic, Typography } from 'antd'
import { ColumnWidthOutlined, ThunderboltOutlined } from '@ant-design/icons'
import type { ProColumns } from '@ant-design/pro-components'
import { ProTable } from '@ant-design/pro-components'
import { usageService } from '@/services/usage'
import { telemetryService } from '@/services/telemetry'
import type { DailyUsageStat, ModelUsageStat } from '@/types'
const { Title } = Typography
export default function Usage() {
const [days, setDays] = useState(30)
const { data: dailyData, isLoading: dailyLoading, error: dailyError } = useQuery({
queryKey: ['usage-daily', days],
queryFn: ({ signal }) => telemetryService.dailyStats({ days }, signal),
})
const { data: modelData, isLoading: modelLoading } = useQuery({
queryKey: ['usage-model', days],
queryFn: ({ signal }) => telemetryService.modelStats({}, signal),
})
if (dailyError) {
return <Alert type="error" message="加载用量数据失败" description={(dailyError as Error).message} showIcon />
}
const totalRequests = dailyData?.reduce((s, d) => s + d.request_count, 0) ?? 0
const totalTokens = dailyData?.reduce((s, d) => s + d.input_tokens + d.output_tokens, 0) ?? 0
const dailyColumns: ProColumns<DailyUsageStat>[] = [
{ title: '日期', dataIndex: 'day', width: 120 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{ title: '设备数', dataIndex: 'unique_devices', width: 80 },
]
const modelColumns: ProColumns<ModelUsageStat>[] = [
{ title: '模型', dataIndex: 'model_id', width: 200 },
{ title: '请求数', dataIndex: 'request_count', width: 100, render: (_, r) => r.request_count.toLocaleString() },
{ title: '输入 Token', dataIndex: 'input_tokens', width: 120, render: (_, r) => r.input_tokens.toLocaleString() },
{ title: '输出 Token', dataIndex: 'output_tokens', width: 120, render: (_, r) => r.output_tokens.toLocaleString() },
{
title: '平均延迟',
dataIndex: 'avg_latency_ms',
width: 100,
render: (_, r) => r.avg_latency_ms ? `${Math.round(r.avg_latency_ms)}ms` : '-',
},
{
title: '成功率',
dataIndex: 'success_rate',
width: 100,
render: (_, r) => `${(r.success_rate * 100).toFixed(1)}%`,
},
]
return (
<div>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
<Title level={4} style={{ margin: 0 }}></Title>
<Select
value={days}
onChange={setDays}
options={[
{ value: 7, label: '最近 7 天' },
{ value: 30, label: '最近 30 天' },
{ value: 90, label: '最近 90 天' },
]}
style={{ width: 140 }}
/>
</div>
<Row gutter={[16, 16]} style={{ marginBottom: 24 }}>
<Col span={12}>
<Card>
<Statistic title="总请求数" value={totalRequests} prefix={<ThunderboltOutlined />} />
</Card>
</Col>
<Col span={12}>
<Card>
<Statistic title="总 Token 数" value={totalTokens} prefix={<ColumnWidthOutlined />} />
</Card>
</Col>
</Row>
<Card title="每日统计" style={{ marginBottom: 24 }} size="small">
<ProTable<DailyUsageStat>
columns={dailyColumns}
dataSource={dailyData ?? []}
loading={dailyLoading}
rowKey="day"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
<Card title="按模型统计" size="small">
<ProTable<ModelUsageStat>
columns={modelColumns}
dataSource={modelData ?? []}
loading={modelLoading}
rowKey="model_id"
search={false}
toolBarRender={false}
pagination={false}
size="small"
/>
</Card>
</div>
)
}

View File

@@ -0,0 +1,17 @@
// ============================================================
// 路由守卫 — 未登录重定向到 /login
// ============================================================
import { Navigate, useLocation } from 'react-router-dom'
import { useAuthStore } from '@/stores/authStore'
export function AuthGuard({ children }: { children: React.ReactNode }) {
const token = useAuthStore((s) => s.token)
const location = useLocation()
if (!token) {
return <Navigate to="/login" state={{ from: location }} replace />
}
return <>{children}</>
}

View File

@@ -0,0 +1,35 @@
// ============================================================
// 路由定义
// ============================================================
import { createBrowserRouter } from 'react-router-dom'
import { AuthGuard } from './AuthGuard'
import AdminLayout from '@/layouts/AdminLayout'
export const router = createBrowserRouter([
{
path: '/login',
lazy: () => import('@/pages/Login').then((m) => ({ Component: m.default })),
},
{
path: '/',
element: (
<AuthGuard>
<AdminLayout />
</AuthGuard>
),
children: [
{ index: true, lazy: () => import('@/pages/Dashboard').then((m) => ({ Component: m.default })) },
{ path: 'accounts', lazy: () => import('@/pages/Accounts').then((m) => ({ Component: m.default })) },
{ path: 'providers', lazy: () => import('@/pages/Providers').then((m) => ({ Component: m.default })) },
{ path: 'models', lazy: () => import('@/pages/Models').then((m) => ({ Component: m.default })) },
{ path: 'agent-templates', lazy: () => import('@/pages/AgentTemplates').then((m) => ({ Component: m.default })) },
{ path: 'api-keys', lazy: () => import('@/pages/ApiKeys').then((m) => ({ Component: m.default })) },
{ path: 'usage', lazy: () => import('@/pages/Usage').then((m) => ({ Component: m.default })) },
{ path: 'relay', lazy: () => import('@/pages/Relay').then((m) => ({ Component: m.default })) },
{ path: 'config', lazy: () => import('@/pages/Config').then((m) => ({ Component: m.default })) },
{ path: 'prompts', lazy: () => import('@/pages/Prompts').then((m) => ({ Component: m.default })) },
{ path: 'logs', lazy: () => import('@/pages/Logs').then((m) => ({ Component: m.default })) },
],
},
])

View File

@@ -0,0 +1,16 @@
import request, { withSignal } from './request'
import type { AccountPublic, PaginatedResponse } from '@/types'
export const accountService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<AccountPublic>>('/accounts', withSignal({ params }, signal)).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'>>, signal?: AbortSignal) =>
request.patch<AccountPublic>(`/accounts/${id}`, data, withSignal({}, signal)).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

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

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import request, { withSignal } from './request'
import type { ConfigItem, PaginatedResponse } from '@/types'
export const configService = {
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 }, signal?: AbortSignal) =>
request.patch<ConfigItem>(`/config/items/${id}`, data, withSignal({}, signal)).then((r) => r.data),
}

View File

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

View File

@@ -0,0 +1,16 @@
import request, { withSignal } from './request'
import type { Model, PaginatedResponse } from '@/types'
export const modelService = {
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'>>, signal?: AbortSignal) =>
request.post<Model>('/models', data, withSignal({}, signal)).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, signal?: AbortSignal) =>
request.delete(`/models/${id}`, withSignal({}, signal)).then((r) => r.data),
}

View File

@@ -0,0 +1,35 @@
import request, { withSignal } from './request'
import type { PromptTemplate, PromptVersion, PaginatedResponse } from '@/types'
export const promptService = {
list: (params?: Record<string, unknown>, signal?: AbortSignal) =>
request.get<PaginatedResponse<PromptTemplate>>('/prompts', withSignal({ params }, signal)).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
}, signal?: AbortSignal) =>
request.post<PromptTemplate>('/prompts', data, withSignal({}, signal)).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, signal?: AbortSignal) =>
request.delete<PromptTemplate>(`/prompts/${encodeURIComponent(name)}`, withSignal({}, signal)).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
}, signal?: AbortSignal) =>
request.post<PromptVersion>(`/prompts/${encodeURIComponent(name)}/versions`, data, withSignal({}, signal)).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

@@ -0,0 +1,31 @@
import request, { withSignal } from './request'
import type { Provider, ProviderKey, PaginatedResponse } from '@/types'
export const providerService = {
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'>>, 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'>>, signal?: AbortSignal) =>
request.patch<Provider>(`/providers/${id}`, data, withSignal({}, signal)).then((r) => r.data),
delete: (id: string, signal?: AbortSignal) =>
request.delete(`/providers/${id}`, withSignal({}, signal)).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
}, 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, signal?: AbortSignal) =>
request.put<{ ok: boolean }>(`/providers/${providerId}/keys/${keyId}/toggle`, { active }, withSignal({}, signal)).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

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

View File

@@ -0,0 +1,127 @@
// ============================================================
// 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'
const BASE_URL = import.meta.env.VITE_API_BASE_URL || '/api/v1'
const TIMEOUT_MS = 30_000
/** API 业务错误 */
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
const request = axios.create({
baseURL: BASE_URL,
timeout: TIMEOUT_MS,
headers: { 'Content-Type': 'application/json' },
withCredentials: true, // 发送 HttpOnly cookies
})
// ── 请求拦截器:附加 Authorization header fallback ──────────
request.interceptors.request.use((config: InternalAxiosRequestConfig) => {
const token = useAuthStore.getState().token
if (token) {
config.headers.Authorization = `Bearer ${token}`
}
return config
})
// ── 响应拦截器401 自动刷新 ──────────────────────────────
let isRefreshing = false
let pendingRequests: Array<(token: string) => void> = []
function onTokenRefreshed(newToken: string) {
pendingRequests.forEach((cb) => cb(newToken))
pendingRequests = []
}
request.interceptors.response.use(
(response) => response,
async (error: AxiosError<{ error?: string; message?: string }>) => {
const originalRequest = error.config as InternalAxiosRequestConfig & { _retry?: boolean }
// 401 → 尝试刷新 Token
if (error.response?.status === 401 && !originalRequest._retry) {
const store = useAuthStore.getState()
if (!store.refreshToken) {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
}
if (isRefreshing) {
return new Promise((resolve) => {
pendingRequests.push((newToken: string) => {
originalRequest.headers.Authorization = `Bearer ${newToken}`
resolve(request(originalRequest))
})
})
}
originalRequest._retry = true
isRefreshing = true
try {
const res = await axios.post(`${BASE_URL}/auth/refresh`, null, {
headers: { Authorization: `Bearer ${store.refreshToken}` },
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)
} catch {
store.logout()
window.location.href = '/login'
return Promise.reject(error)
} finally {
isRefreshing = false
}
}
// 构造 ApiRequestError
if (error.response) {
const body: ApiError = {
error: error.response.data?.error || 'unknown',
message: error.response.data?.message || `请求失败 (${error.response.status})`,
status: error.response.status,
}
return Promise.reject(new ApiRequestError(error.response.status, body))
}
return Promise.reject(error)
},
)
export default request
/** 将 AbortSignal 注入 Axios config用于 TanStack Query 的请求取消 */
export function withSignal(config: AxiosRequestConfig = {}, signal?: AbortSignal): AxiosRequestConfig {
if (signal) {
return { ...config, signal }
}
return config
}

View File

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

View File

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

View File

@@ -0,0 +1,12 @@
import request, { withSignal } from './request'
import type { UsageRecord, UsageByModel } from '@/types'
export const usageService = {
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 }, signal?: AbortSignal) =>
request.get<{ by_model: UsageByModel[] }>('/usage', withSignal({ params: { ...params, group_by: 'model' } }, signal))
.then((r) => r.data.by_model || []),
}

View File

@@ -0,0 +1,96 @@
// ============================================================
// 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'
/** 权限常量 — 与后端 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', 'config:read',
'config:write', 'prompt:read', 'prompt:write', 'prompt:publish',
],
user: ['model:read', 'relay:use', 'config:read', 'prompt:read'],
}
const ACCOUNT_KEY = 'zclaw_admin_account'
/** 从 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 { account }
}
interface AuthState {
token: string | null
refreshToken: string | null
account: AccountPublic | null
permissions: string[]
setToken: (token: string) => void
setRefreshToken: (refreshToken: string) => void
login: (token: string, refreshToken: string, account: AccountPublic) => void
logout: () => void
hasPermission: (permission: string) => boolean
}
export const useAuthStore = create<AuthState>((set, get) => {
const stored = loadFromStorage()
const perms = stored.account?.role
? (ROLE_PERMISSIONS[stored.account.role] ?? [])
: []
return {
token: null,
refreshToken: null,
account: stored.account,
permissions: perms,
setToken: (token: string) => {
set({ token })
},
setRefreshToken: (refreshToken: string) => {
set({ refreshToken })
},
login: (token: string, refreshToken: string, account: AccountPublic) => {
// account 保留 localStorage仅用于 UI 显示,非敏感)
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
// token 仅存内存(实际认证通过 HttpOnly cookie
set({
token,
refreshToken,
account,
permissions: ROLE_PERMISSIONS[account.role] ?? [],
})
},
logout: () => {
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) => {
const { permissions } = get()
return permissions.includes(permission) || permissions.includes('admin:full')
},
}
})

View File

@@ -11,6 +11,7 @@ export interface AccountPublic {
role: 'super_admin' | 'admin' | 'user'
status: 'active' | 'disabled' | 'suspended'
totp_enabled: boolean
last_login_at: string | null
created_at: string
}
@@ -18,11 +19,13 @@ export interface AccountPublic {
export interface LoginRequest {
username: string
password: string
totp_code?: string
}
/** 登录响应 */
export interface LoginResponse {
token: string
refresh_token: string
account: AccountPublic
}
@@ -49,10 +52,10 @@ export interface Provider {
display_name: string
api_key?: string
base_url: string
api_protocol: 'openai' | 'anthropic'
api_protocol: string
enabled: boolean
rate_limit_rpm?: number
rate_limit_tpm?: number
rate_limit_rpm: number | null
rate_limit_tpm: number | null
created_at: string
updated_at: string
}
@@ -97,15 +100,16 @@ export interface RelayTask {
account_id: string
provider_id: string
model_id: string
status: 'queued' | 'processing' | 'completed' | 'failed'
status: string
priority: number
attempt_count: number
max_attempts: number
input_tokens: number
output_tokens: number
error_message?: string
queued_at?: string
started_at?: string
completed_at?: string
error_message: string | null
queued_at: string
started_at: string | null
completed_at: string | null
created_at: string
}
@@ -130,23 +134,25 @@ export interface ConfigItem {
id: string
category: string
key_path: string
value_type: 'string' | 'number' | 'boolean'
current_value?: string | number | boolean
default_value?: string | number | boolean
source: 'default' | 'env' | 'db'
description?: 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: string
account_id: string
id: number
account_id: string | null
action: string
target_type: string
target_id: string
details?: string
ip_address?: string
target_type: string | null
target_id: string | null
details: Record<string, unknown> | null
ip_address: string | null
created_at: string
}
@@ -167,3 +173,95 @@ export interface ApiError {
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
}
/** 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 */
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

@@ -0,0 +1,34 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Path alias */
"baseUrl": ".",
"paths": {
"@/*": ["src/*"]
},
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
admin-v2/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

38
admin-v2/vite.config.ts Normal file
View File

@@ -0,0 +1,38 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
'@': path.resolve(__dirname, 'src'),
},
},
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,4 +0,0 @@
/** @type {import('next').NextConfig} */
const nextConfig = {}
module.exports = nextConfig

View File

@@ -1,37 +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-switch": "^1.2.5",
"@radix-ui/react-tabs": "^1.1.12",
"@radix-ui/react-tooltip": "^1.2.7",
"@radix-ui/react-separator": "^1.1.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",
"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"
}

2171
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,393 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 } from '@/lib/utils'
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 [accounts, setAccounts] = useState<AccountPublic[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [search, setSearch] = useState('')
const [roleFilter, setRoleFilter] = useState<string>('all')
const [statusFilter, setStatusFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 编辑 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 fetchAccounts = useCallback(async () => {
setLoading(true)
setError('')
try {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (search.trim()) params.search = search.trim()
if (roleFilter !== 'all') params.role = roleFilter
if (statusFilter !== 'all') params.status = statusFilter
const res = await api.accounts.list(params)
setAccounts(res.items)
setTotal(res.total)
} catch (err) {
if (err instanceof ApiRequestError) {
setError(err.body.message)
} else {
setError('加载失败')
}
} finally {
setLoading(false)
}
}, [page, search, roleFilter, statusFilter])
useEffect(() => {
fetchAccounts()
}, [fetchAccounts])
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)
fetchAccounts()
} catch (err) {
if (err instanceof ApiRequestError) {
setError(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)
fetchAccounts()
} catch (err) {
if (err instanceof ApiRequestError) {
setError(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 && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
</button>
</div>
)}
{/* 表格 */}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : accounts.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<>
<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,351 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 { ApiRequestError } from '@/lib/api-client'
import { formatDate } from '@/lib/utils'
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 [tokens, setTokens] = useState<TokenInfo[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 创建 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 fetchTokens = useCallback(async () => {
setLoading(true)
setError('')
try {
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
setTokens(res.items)
setTotal(res.total)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载失败')
} finally {
setLoading(false)
}
}, [page])
useEffect(() => {
fetchTokens()
}, [fetchTokens])
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'] })
fetchTokens()
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
} finally {
setCreating(false)
}
}
async function handleRevoke() {
if (!revokeTarget) return
setRevoking(true)
try {
await api.tokens.revoke(revokeTarget.id)
setRevokeTarget(null)
fetchTokens()
} catch (err) {
if (err instanceof ApiRequestError) setError(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 && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer"></button>
</div>
)}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tokens.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<>
<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,270 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 { 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 [configs, setConfigs] = useState<ConfigItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [activeTab, setActiveTab] = useState('all')
// 编辑 Dialog
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
const [editValue, setEditValue] = useState('')
const [saving, setSaving] = useState(false)
const fetchConfigs = useCallback(async (category?: string) => {
setLoading(true)
setError('')
try {
const params: Record<string, unknown> = {}
if (category && category !== 'all') params.category = category
const res = await api.config.list(params)
setConfigs(res)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载失败')
} finally {
setLoading(false)
}
}, [])
useEffect(() => {
fetchConfigs(activeTab)
}, [fetchConfigs, activeTab])
function openEditDialog(config: ConfigItem) {
setEditTarget(config)
setEditValue(config.current_value !== undefined ? String(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)
fetchConfigs(activeTab)
} 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 categories = ['all', 'auth', 'relay', 'model', 'system']
return (
<div className="space-y-4">
{/* 分类 Tabs */}
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
{categories.map((cat) => (
<TabsTrigger key={cat} value={cat}>
{cat === 'all' ? '全部' : cat}
</TabsTrigger>
))}
</TabsList>
</Tabs>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer"></button>
</div>
)}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : configs.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<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 !== undefined && (
<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 !== undefined) {
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,218 +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,
LogOut,
ChevronLeft,
Menu,
Bell,
} from 'lucide-react'
import { AuthGuard, useAuth } from '@/components/auth-guard'
import { logout } from '@/lib/auth'
import { cn } from '@/lib/utils'
const navItems = [
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
{ href: '/accounts', label: '账号管理', icon: Users },
{ href: '/providers', label: '服务商', icon: Server },
{ href: '/models', label: '模型管理', icon: Cpu },
{ href: '/api-keys', label: 'API 密钥', icon: Key },
{ href: '/usage', label: '用量统计', icon: BarChart3 },
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
{ href: '/config', label: '系统配置', icon: Settings },
{ href: '/logs', label: '操作日志', icon: FileText },
]
function Sidebar({
collapsed,
onToggle,
}: {
collapsed: boolean
onToggle: () => void
}) {
const pathname = usePathname()
const router = useRouter()
const { account } = useAuth()
function handleLogout() {
logout()
router.replace('/login')
}
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">
{navItems.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,436 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 { 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 [models, setModels] = useState<Model[]>([])
const [providers, setProviders] = useState<Provider[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [providerFilter, setProviderFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 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 fetchModels = useCallback(async () => {
setLoading(true)
setError('')
try {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (providerFilter !== 'all') params.provider_id = providerFilter
const res = await api.models.list(params)
setModels(res.items)
setTotal(res.total)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载失败')
} finally {
setLoading(false)
}
}, [page, providerFilter])
const fetchProviders = useCallback(async () => {
try {
const res = await api.providers.list({ page: 1, page_size: 100 })
setProviders(res.items)
} catch {
// ignore
}
}, [])
useEffect(() => {
fetchModels()
fetchProviders()
}, [fetchModels, fetchProviders])
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)
fetchModels()
} 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)
fetchModels()
} 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 && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer"></button>
</div>
)}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : models.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<>
<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,336 +0,0 @@
'use client'
import { useEffect, useState } from 'react'
import {
Users,
Server,
ArrowLeftRight,
Zap,
Loader2,
TrendingUp,
} from 'lucide-react'
import {
AreaChart,
Area,
XAxis,
YAxis,
CartesianGrid,
Tooltip,
ResponsiveContainer,
BarChart,
Bar,
Legend,
} from 'recharts'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
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 [stats, setStats] = useState<DashboardStats | null>(null)
const [usageData, setUsageData] = useState<UsageRecord[]>([])
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
useEffect(() => {
async function fetchData() {
try {
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
api.stats.dashboard(),
api.usage.daily({ days: 30 }),
api.logs.list({ page: 1, page_size: 5 }),
])
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value)
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items)
} catch (err) {
setError('加载数据失败,请检查后端服务是否启动')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="text-center">
<p className="text-destructive">{error}</p>
<button
onClick={() => window.location.reload()}
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
>
</button>
</div>
</div>
)
}
const chartData = usageData.map((r) => ({
day: r.day.slice(5), // MM-DD
请求量: r.count,
Input: r.input_tokens,
Output: r.output_tokens,
}))
return (
<div className="space-y-6">
{/* 统计卡片 */}
<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">
{/* 请求趋势 */}
<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 用量 */}
<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>
{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,386 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatDate, maskApiKey } from '@/lib/utils'
import type { Provider } 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 [providers, setProviders] = useState<Provider[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
// 创建/编辑 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)
const fetchProviders = useCallback(async () => {
setLoading(true)
setError('')
try {
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
setProviders(res.items)
setTotal(res.total)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载失败')
} finally {
setLoading(false)
}
}, [page])
useEffect(() => {
fetchProviders()
}, [fetchProviders])
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)
fetchProviders()
} 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)
fetchProviders()
} 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">
<div />
<Button onClick={openCreateDialog}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</div>
{error && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer"></button>
</div>
)}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : providers.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<>
<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={() => 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>
</div>
)
}

View File

@@ -1,245 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
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 } from '@/lib/utils'
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 [tasks, setTasks] = useState<RelayTask[]>([])
const [total, setTotal] = useState(0)
const [page, setPage] = useState(1)
const [statusFilter, setStatusFilter] = useState<string>('all')
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const [expandedId, setExpandedId] = useState<string | null>(null)
const fetchTasks = useCallback(async () => {
setLoading(true)
setError('')
try {
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
if (statusFilter !== 'all') params.status = statusFilter
const res = await api.relay.list(params)
setTasks(res.items)
setTotal(res.total)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载失败')
} finally {
setLoading(false)
}
}, [page, statusFilter])
useEffect(() => {
fetchTasks()
}, [fetchTasks])
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 && (
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
{error}
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer"></button>
</div>
)}
{loading ? (
<div className="flex h-64 items-center justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
) : tasks.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
</div>
) : (
<>
<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,235 +0,0 @@
'use client'
import { useEffect, useState, useCallback } from 'react'
import { Loader2, Zap } 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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { api } from '@/lib/api-client'
import { ApiRequestError } from '@/lib/api-client'
import { formatNumber } from '@/lib/utils'
import type { UsageRecord, UsageByModel } from '@/lib/types'
export default function UsagePage() {
const [days, setDays] = useState(7)
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
const [modelData, setModelData] = useState<UsageByModel[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
const fetchData = useCallback(async () => {
setLoading(true)
setError('')
try {
const [dailyRes, modelRes] = await Promise.allSettled([
api.usage.daily({ days }),
api.usage.byModel({ days }),
])
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value)
else throw new Error('Failed to fetch daily usage')
if (modelRes.status === 'fulfilled') setModelData(modelRes.value)
} catch (err) {
if (err instanceof ApiRequestError) setError(err.body.message)
else setError('加载数据失败')
} finally {
setLoading(false)
}
}, [days])
useEffect(() => {
fetchData()
}, [fetchData])
const lineChartData = dailyData.map((r) => ({
day: r.day.slice(5),
Input: r.input_tokens,
Output: r.output_tokens,
}))
const barChartData = modelData.map((r) => ({
model: r.model_id,
请求量: r.count,
Input: r.input_tokens,
Output: r.output_tokens,
}))
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0)
if (loading) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="flex flex-col items-center gap-3">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
<p className="text-sm text-muted-foreground">...</p>
</div>
</div>
)
}
if (error) {
return (
<div className="flex h-[60vh] items-center justify-center">
<div className="text-center">
<p className="text-destructive">{error}</p>
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
</button>
</div>
</div>
)
}
return (
<div className="space-y-6">
{/* 时间范围 */}
<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>
{/* 汇总统计 */}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<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>
</div>
{/* Token 用量趋势 */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-base">
<Zap className="h-4 w-4 text-primary" />
Token
</CardTitle>
</CardHeader>
<CardContent>
{lineChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<LineChart data={lineChartData}>
<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>
) : (
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
</div>
)}
</CardContent>
</Card>
{/* 按模型分布 */}
<Card>
<CardHeader>
<CardTitle className="text-base"></CardTitle>
</CardHeader>
<CardContent>
{barChartData.length > 0 ? (
<ResponsiveContainer width="100%" height={320}>
<BarChart data={barChartData} 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>
) : (
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
</div>
)}
</CardContent>
</Card>
</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,27 +0,0 @@
import type { Metadata } from 'next'
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">
{children}
</body>
</html>
)
}

View File

@@ -1,199 +0,0 @@
'use client'
import { useState, type FormEvent } from 'react'
import { useRouter } from 'next/navigation'
import { Lock, User, Loader2, Eye, EyeOff } 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 [showPassword, setShowPassword] = 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 })
login(res.token, res.account)
router.replace('/')
} catch (err) {
if (err instanceof ApiRequestError) {
setError(err.body.message || '登录失败,请检查用户名和密码')
} 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>
{/* 记住我 */}
<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,48 +0,0 @@
'use client'
import { useEffect, useState, type ReactNode } from 'react'
import { useRouter } from 'next/navigation'
import { isAuthenticated, getAccount } from '@/lib/auth'
import type { AccountPublic } from '@/lib/types'
interface AuthGuardProps {
children: ReactNode
}
export function AuthGuard({ children }: AuthGuardProps) {
const router = useRouter()
const [authorized, setAuthorized] = useState(false)
const [account, setAccount] = useState<AccountPublic | null>(null)
useEffect(() => {
if (!isAuthenticated()) {
router.replace('/login')
return
}
setAccount(getAccount())
setAuthorized(true)
}, [router])
if (!authorized) {
return (
<div className="flex h-screen w-screen items-center justify-center bg-background">
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
</div>
)
}
return <>{children}</>
}
export function useAuth() {
const [account, setAccount] = useState<AccountPublic | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const acc = getAccount()
setAccount(acc)
setLoading(false)
}, [])
return { account, loading, isAuthenticated: isAuthenticated() }
}

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,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,284 +0,0 @@
// ============================================================
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
// ============================================================
import { getToken, logout } from './auth'
import type {
AccountPublic,
ApiError,
ConfigItem,
CreateTokenRequest,
DashboardStats,
LoginRequest,
LoginResponse,
Model,
OperationLog,
PaginatedResponse,
Provider,
RelayTask,
TokenInfo,
UsageByModel,
UsageRecord,
} from './types'
// ── 错误类 ────────────────────────────────────────────────
export class ApiRequestError extends Error {
constructor(
public status: number,
public body: ApiError,
) {
super(body.message || `Request failed with status ${status}`)
this.name = 'ApiRequestError'
}
}
// ── 基础请求 ──────────────────────────────────────────────
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
async function request<T>(
method: string,
path: string,
body?: unknown,
): Promise<T> {
const token = getToken()
const headers: Record<string, string> = {
'Content-Type': 'application/json',
}
if (token) {
headers['Authorization'] = `Bearer ${token}`
}
const res = await fetch(`${BASE_URL}${path}`, {
method,
headers,
body: body ? JSON.stringify(body) : undefined,
})
if (res.status === 401) {
logout()
if (typeof window !== 'undefined') {
window.location.href = '/login'
}
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
}
if (!res.ok) {
let errorBody: ApiError
try {
errorBody = await res.json()
} catch {
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
}
throw new ApiRequestError(res.status, errorBody)
}
// 204 No Content
if (res.status === 204) {
return undefined as T
}
return res.json() as Promise<T>
}
// ── API 客户端 ────────────────────────────────────────────
export const api = {
// ── 认证 ──────────────────────────────────────────────
auth: {
async login(data: LoginRequest): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/login', data)
},
async register(data: {
username: string
password: string
email: string
display_name?: string
}): Promise<LoginResponse> {
return request<LoginResponse>('POST', '/api/auth/register', data)
},
async me(): Promise<AccountPublic> {
return request<AccountPublic>('GET', '/api/auth/me')
},
},
// ── 账号管理 ──────────────────────────────────────────
accounts: {
async list(params?: {
page?: number
page_size?: number
search?: string
role?: string
status?: string
}): Promise<PaginatedResponse<AccountPublic>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
},
async get(id: string): Promise<AccountPublic> {
return request<AccountPublic>('GET', `/api/accounts/${id}`)
},
async update(
id: string,
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
): Promise<AccountPublic> {
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
},
async updateStatus(
id: string,
data: { status: AccountPublic['status'] },
): Promise<void> {
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
},
},
// ── 服务商管理 ────────────────────────────────────────
providers: {
async list(params?: {
page?: number
page_size?: number
}): Promise<PaginatedResponse<Provider>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
},
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
return request<Provider>('POST', '/api/providers', data)
},
async update(
id: string,
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
): Promise<Provider> {
return request<Provider>('PATCH', `/api/providers/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/providers/${id}`)
},
},
// ── 模型管理 ──────────────────────────────────────────
models: {
async list(params?: {
page?: number
page_size?: number
provider_id?: string
}): Promise<PaginatedResponse<Model>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
},
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('POST', '/api/models', data)
},
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
return request<Model>('PATCH', `/api/models/${id}`, data)
},
async delete(id: string): Promise<void> {
return request<void>('DELETE', `/api/models/${id}`)
},
},
// ── API 密钥 ──────────────────────────────────────────
tokens: {
async list(params?: {
page?: number
page_size?: number
}): Promise<PaginatedResponse<TokenInfo>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
},
async create(data: CreateTokenRequest): Promise<TokenInfo> {
return request<TokenInfo>('POST', '/api/tokens', data)
},
async revoke(id: string): Promise<void> {
return request<void>('DELETE', `/api/tokens/${id}`)
},
},
// ── 用量统计 ──────────────────────────────────────────
usage: {
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
const qs = buildQueryString(params)
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
},
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
const qs = buildQueryString(params)
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
},
},
// ── 中转任务 ──────────────────────────────────────────
relay: {
async list(params?: {
page?: number
page_size?: number
status?: string
}): Promise<PaginatedResponse<RelayTask>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
},
async get(id: string): Promise<RelayTask> {
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
},
},
// ── 系统配置 ──────────────────────────────────────────
config: {
async list(params?: {
category?: string
}): Promise<ConfigItem[]> {
const qs = buildQueryString(params)
return request<ConfigItem[]>('GET', `/api/config${qs}`)
},
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
},
},
// ── 操作日志 ──────────────────────────────────────────
logs: {
async list(params?: {
page?: number
page_size?: number
action?: string
}): Promise<PaginatedResponse<OperationLog>> {
const qs = buildQueryString(params)
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
},
},
// ── 仪表盘 ────────────────────────────────────────────
stats: {
async dashboard(): Promise<DashboardStats> {
return request<DashboardStats>('GET', '/api/stats/dashboard')
},
},
}
// ── 工具函数 ──────────────────────────────────────────────
function buildQueryString(params?: Record<string, unknown>): string {
if (!params) return ''
const entries = Object.entries(params).filter(
([, v]) => v !== undefined && v !== null && v !== '',
)
if (entries.length === 0) return ''
const qs = entries
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
.join('&')
return `?${qs}`
}

View File

@@ -1,45 +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)
}
/** 获取 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,34 +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))
}

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

@@ -0,0 +1,33 @@
# ZCLAW SaaS 开发环境配置
# 通过 ZCLAW_ENV=development 或默认使用此配置
[server]
host = "0.0.0.0"
port = 8080
cors_origins = [] # 空 = 开发模式允许所有来源
[database]
url = "postgres://postgres:123123@localhost:5432/zclaw"
[auth]
jwt_expiration_hours = 24
totp_issuer = "ZCLAW SaaS (dev)"
refresh_token_hours = 168
[relay]
max_queue_size = 1000
max_concurrent_per_provider = 5
batch_window_ms = 50
retry_delay_ms = 1000
max_attempts = 3
[rate_limit]
requests_per_minute = 120
burst = 20
[scheduler]
jobs = [
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = false },
]

View File

@@ -0,0 +1,35 @@
# ZCLAW SaaS 生产环境配置
# 通过 ZCLAW_ENV=production 使用此配置
[server]
host = "0.0.0.0"
port = 8080
# 生产环境必须配置 CORS 白名单
cors_origins = ["https://admin.zclaw.ai", "https://zclaw.ai"]
[database]
# 生产环境通过 ZCLAW_DATABASE_URL 环境变量覆盖,此处为占位
url = "postgres://zclaw:CHANGE_ME@db:5432/zclaw"
[auth]
jwt_expiration_hours = 12
totp_issuer = "ZCLAW SaaS"
refresh_token_hours = 168
[relay]
max_queue_size = 5000
max_concurrent_per_provider = 10
batch_window_ms = 50
retry_delay_ms = 2000
max_attempts = 3
[rate_limit]
requests_per_minute = 60
burst = 10
[scheduler]
jobs = [
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = true },
]

31
config/saas-test.toml Normal file
View File

@@ -0,0 +1,31 @@
# ZCLAW SaaS 测试环境配置
# 通过 ZCLAW_ENV=test 使用此配置
[server]
host = "127.0.0.1"
port = 8090
cors_origins = []
[database]
# 测试环境使用独立数据库
url = "postgres://postgres:123123@localhost:5432/zclaw_test"
[auth]
jwt_expiration_hours = 1
totp_issuer = "ZCLAW SaaS (test)"
refresh_token_hours = 24
[relay]
max_queue_size = 100
max_concurrent_per_provider = 2
batch_window_ms = 10
retry_delay_ms = 100
max_attempts = 2
[rate_limit]
requests_per_minute = 200
burst = 50
[scheduler]
# 测试环境不启动定时任务
jobs = []

View File

@@ -1,21 +0,0 @@
[package]
name = "zclaw-channels"
version.workspace = true
edition.workspace = true
license.workspace = true
repository.workspace = true
rust-version.workspace = true
description = "ZCLAW Channels - external platform adapters"
[dependencies]
zclaw-types = { workspace = true }
tokio = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
thiserror = { workspace = true }
tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
chrono = { workspace = true }

View File

@@ -1,71 +0,0 @@
//! Console channel adapter for testing
use async_trait::async_trait;
use std::sync::Arc;
use tokio::sync::mpsc;
use zclaw_types::Result;
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
/// Console channel adapter (for testing)
pub struct ConsoleChannel {
config: ChannelConfig,
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
}
impl ConsoleChannel {
pub fn new(config: ChannelConfig) -> Self {
Self {
config,
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
}
}
}
#[async_trait]
impl Channel for ConsoleChannel {
fn config(&self) -> &ChannelConfig {
&self.config
}
async fn connect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Connected;
tracing::info!("Console channel connected");
Ok(())
}
async fn disconnect(&self) -> Result<()> {
let mut status = self.status.write().await;
*status = ChannelStatus::Disconnected;
tracing::info!("Console channel disconnected");
Ok(())
}
async fn status(&self) -> ChannelStatus {
self.status.read().await.clone()
}
async fn send(&self, message: OutgoingMessage) -> Result<String> {
// Print to console for testing
let msg_id = format!("console_{}", chrono::Utc::now().timestamp());
match &message.content {
crate::MessageContent::Text { text } => {
tracing::info!("[Console] To {}: {}", message.conversation_id, text);
}
_ => {
tracing::info!("[Console] To {}: {:?}", message.conversation_id, message.content);
}
}
Ok(msg_id)
}
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
let (_tx, rx) = mpsc::channel(100);
// Console channel doesn't receive messages automatically
// Messages would need to be injected via a separate method
Ok(rx)
}
}

View File

@@ -1,5 +0,0 @@
//! Channel adapters
mod console;
pub use console::ConsoleChannel;

View File

@@ -1,94 +0,0 @@
//! Channel bridge manager
//!
//! Coordinates multiple channel adapters and routes messages.
use std::collections::HashMap;
use std::sync::Arc;
use tokio::sync::RwLock;
use zclaw_types::Result;
use super::{Channel, ChannelConfig, OutgoingMessage};
/// Channel bridge manager
pub struct ChannelBridge {
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
configs: RwLock<HashMap<String, ChannelConfig>>,
}
impl ChannelBridge {
pub fn new() -> Self {
Self {
channels: RwLock::new(HashMap::new()),
configs: RwLock::new(HashMap::new()),
}
}
/// Register a channel adapter
pub async fn register(&self, channel: Arc<dyn Channel>) {
let config = channel.config().clone();
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.insert(config.id.clone(), channel);
configs.insert(config.id.clone(), config);
}
/// Get a channel by ID
pub async fn get(&self, id: &str) -> Option<Arc<dyn Channel>> {
let channels = self.channels.read().await;
channels.get(id).cloned()
}
/// Get channel configuration
pub async fn get_config(&self, id: &str) -> Option<ChannelConfig> {
let configs = self.configs.read().await;
configs.get(id).cloned()
}
/// List all channels
pub async fn list(&self) -> Vec<ChannelConfig> {
let configs = self.configs.read().await;
configs.values().cloned().collect()
}
/// Connect all channels
pub async fn connect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.connect().await?;
}
Ok(())
}
/// Disconnect all channels
pub async fn disconnect_all(&self) -> Result<()> {
let channels = self.channels.read().await;
for channel in channels.values() {
channel.disconnect().await?;
}
Ok(())
}
/// Send message through a specific channel
pub async fn send(&self, channel_id: &str, message: OutgoingMessage) -> Result<String> {
let channel = self.get(channel_id).await
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Channel not found: {}", channel_id)))?;
channel.send(message).await
}
/// Remove a channel
pub async fn remove(&self, id: &str) {
let mut channels = self.channels.write().await;
let mut configs = self.configs.write().await;
channels.remove(id);
configs.remove(id);
}
}
impl Default for ChannelBridge {
fn default() -> Self {
Self::new()
}
}

View File

@@ -1,109 +0,0 @@
//! Channel trait and types
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use zclaw_types::{Result, AgentId};
/// Channel configuration
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChannelConfig {
/// Unique channel identifier
pub id: String,
/// Channel type (telegram, discord, slack, etc.)
pub channel_type: String,
/// Human-readable name
pub name: String,
/// Whether the channel is enabled
#[serde(default = "default_enabled")]
pub enabled: bool,
/// Channel-specific configuration
#[serde(default)]
pub config: serde_json::Value,
/// Associated agent for this channel
pub agent_id: Option<AgentId>,
}
fn default_enabled() -> bool { true }
/// Incoming message from a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct IncomingMessage {
/// Message ID from the platform
pub platform_id: String,
/// Channel/conversation ID
pub conversation_id: String,
/// Sender information
pub sender: MessageSender,
/// Message content
pub content: MessageContent,
/// Timestamp
pub timestamp: i64,
/// Reply-to message ID if any
pub reply_to: Option<String>,
}
/// Message sender information
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MessageSender {
pub id: String,
pub name: Option<String>,
pub username: Option<String>,
pub is_bot: bool,
}
/// Message content types
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum MessageContent {
Text { text: String },
Image { url: String, caption: Option<String> },
File { url: String, filename: String },
Audio { url: String },
Video { url: String },
Location { latitude: f64, longitude: f64 },
Sticker { emoji: Option<String>, url: Option<String> },
}
/// Outgoing message to a channel
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OutgoingMessage {
/// Conversation/channel ID to send to
pub conversation_id: String,
/// Message content
pub content: MessageContent,
/// Reply-to message ID if any
pub reply_to: Option<String>,
/// Whether to send silently (no notification)
pub silent: bool,
}
/// Channel connection status
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum ChannelStatus {
Disconnected,
Connecting,
Connected,
Error(String),
}
/// Channel trait for platform adapters
#[async_trait]
pub trait Channel: Send + Sync {
/// Get channel configuration
fn config(&self) -> &ChannelConfig;
/// Connect to the platform
async fn connect(&self) -> Result<()>;
/// Disconnect from the platform
async fn disconnect(&self) -> Result<()>;
/// Get current connection status
async fn status(&self) -> ChannelStatus;
/// Send a message
async fn send(&self, message: OutgoingMessage) -> Result<String>;
/// Receive incoming messages (streaming)
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
}

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