Compare commits

...

32 Commits

Author SHA1 Message Date
iven
eb956d0dce feat: 新增管理后台前端项目及安全加固
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(saas): 重构认证中间件与限流策略
- 登录限流调整为5次/分钟/IP
- 注册限流调整为3次/小时/IP
- GET请求不计入限流

fix(saas): 修复调度器时间戳处理
- 使用NOW()替代文本时间戳
- 兼容TEXT和TIMESTAMPTZ列类型

feat(saas): 实现环境变量插值
- 支持${ENV_VAR}语法解析
- 数据库密码支持环境变量注入

chore: 新增前端管理界面
- 基于React+Ant Design Pro
- 包含路由守卫/错误边界
- 对接58个API端点

docs: 更新安全加固文档
- 新增密钥管理规范
- 记录P0安全项审计结果
- 补充TLS终止说明

test: 完善配置解析单元测试
- 新增环境变量插值测试用例
2026-03-31 00:11:33 +08:00
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
535 changed files with 49816 additions and 11371 deletions

7
.cargo/config.toml Normal file
View File

@@ -0,0 +1,7 @@
# Reduce parallel compilation jobs to prevent compiler OOM.
# The desktop crate + its dependencies (tauri, sqlx, fantoccini, etc.)
# consume significant memory during borrow checking / type inference.
#
# If builds still OOM, try lowering further (e.g. 2 or 1).
[build]
jobs = 2

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

463
65-90p Normal file
View File

@@ -0,0 +1,463 @@
00000000: 2f2f 2120 5a43 4c41 5720 5361 6153 20e6 //! ZCLAW SaaS .
00000010: 9c8d e58a a1e5 85a5 e58f a30d 0a0d 0a75 ...............u
00000020: 7365 2061 7875 6d3a 3a65 7874 7261 6374 se axum::extract
00000030: 3a3a 5374 6174 653b 0d0a 7573 6520 746f ::State;..use to
00000040: 7765 725f 6874 7470 3a3a 7469 6d65 6f75 wer_http::timeou
00000050: 743a 3a54 696d 656f 7574 4c61 7965 723b t::TimeoutLayer;
00000060: 0d0a 7573 6520 7472 6163 696e 673a 3a69 ..use tracing::i
00000070: 6e66 6f3b 0d0a 7573 6520 7a63 6c61 775f nfo;..use zclaw_
00000080: 7361 6173 3a3a 7b63 6f6e 6669 673a 3a53 saas::{config::S
00000090: 6161 5343 6f6e 6669 672c 2064 623a 3a69 aaSConfig, db::i
000000a0: 6e69 745f 6462 2c20 7374 6174 653a 3a41 nit_db, state::A
000000b0: 7070 5374 6174 657d 3b0d 0a75 7365 207a ppState};..use z
000000c0: 636c 6177 5f73 6161 733a 3a77 6f72 6b65 claw_saas::worke
000000d0: 7273 3a3a 576f 726b 6572 4469 7370 6174 rs::WorkerDispat
000000e0: 6368 6572 3b0d 0a75 7365 207a 636c 6177 cher;..use zclaw
000000f0: 5f73 6161 733a 3a77 6f72 6b65 7273 3a3a _saas::workers::
00000100: 6c6f 675f 6f70 6572 6174 696f 6e3a 3a4c log_operation::L
00000110: 6f67 4f70 6572 6174 696f 6e57 6f72 6b65 ogOperationWorke
00000120: 723b 0d0a 7573 6520 7a63 6c61 775f 7361 r;..use zclaw_sa
00000130: 6173 3a3a 776f 726b 6572 733a 3a63 6c65 as::workers::cle
00000140: 616e 7570 5f72 6566 7265 7368 5f74 6f6b anup_refresh_tok
00000150: 656e 733a 3a43 6c65 616e 7570 5265 6672 ens::CleanupRefr
00000160: 6573 6854 6f6b 656e 7357 6f72 6b65 723b eshTokensWorker;
00000170: 0d0a 7573 6520 7a63 6c61 775f 7361 6173 ..use zclaw_saas
00000180: 3a3a 776f 726b 6572 733a 3a63 6c65 616e ::workers::clean
00000190: 7570 5f72 6174 655f 6c69 6d69 743a 3a43 up_rate_limit::C
000001a0: 6c65 616e 7570 5261 7465 4c69 6d69 7457 leanupRateLimitW
000001b0: 6f72 6b65 723b 0d0a 7573 6520 7a63 6c61 orker;..use zcla
000001c0: 775f 7361 6173 3a3a 776f 726b 6572 733a w_saas::workers:
000001d0: 3a72 6563 6f72 645f 7573 6167 653a 3a52 :record_usage::R
000001e0: 6563 6f72 6455 7361 6765 576f 726b 6572 ecordUsageWorker
000001f0: 3b0d 0a75 7365 207a 636c 6177 5f73 6161 ;..use zclaw_saa
00000200: 733a 3a77 6f72 6b65 7273 3a3a 7570 6461 s::workers::upda
00000210: 7465 5f6c 6173 745f 7573 6564 3a3a 5570 te_last_used::Up
00000220: 6461 7465 4c61 7374 5573 6564 576f 726b dateLastUsedWork
00000230: 6572 3b0d 0a0d 0a23 5b74 6f6b 696f 3a3a er;....#[tokio::
00000240: 6d61 696e 5d0d 0a61 7379 6e63 2066 6e20 main]..async fn
00000250: 6d61 696e 2829 202d 3e20 616e 7968 6f77 main() -> anyhow
00000260: 3a3a 5265 7375 6c74 3c28 293e 207b 0d0a ::Result<()> {..
00000270: 2020 2020 7472 6163 696e 675f 7375 6273 tracing_subs
00000280: 6372 6962 6572 3a3a 666d 7428 290d 0a20 criber::fmt()..
00000290: 2020 2020 2020 202e 7769 7468 5f65 6e76 .with_env
000002a0: 5f66 696c 7465 7228 0d0a 2020 2020 2020 _filter(..
000002b0: 2020 2020 2020 7472 6163 696e 675f 7375 tracing_su
000002c0: 6273 6372 6962 6572 3a3a 456e 7646 696c bscriber::EnvFil
000002d0: 7465 723a 3a74 7279 5f66 726f 6d5f 6465 ter::try_from_de
000002e0: 6661 756c 745f 656e 7628 290d 0a20 2020 fault_env()..
000002f0: 2020 2020 2020 2020 2020 2020 202e 756e .un
00000300: 7772 6170 5f6f 725f 656c 7365 287c 5f7c wrap_or_else(|_|
00000310: 2022 7a63 6c61 775f 7361 6173 3d64 6562 "zclaw_saas=deb
00000320: 7567 2c74 6f77 6572 5f68 7474 703d 6465 ug,tower_http=de
00000330: 6275 6722 2e69 6e74 6f28 2929 2c0d 0a20 bug".into()),..
00000340: 2020 2020 2020 2029 0d0a 2020 2020 2020 )..
00000350: 2020 2e69 6e69 7428 293b 0d0a 0d0a 2020 .init();....
00000360: 2020 6c65 7420 636f 6e66 6967 203d 2053 let config = S
00000370: 6161 5343 6f6e 6669 673a 3a6c 6f61 6428 aaSConfig::load(
00000380: 293f 3b0d 0a20 2020 2069 6e66 6f21 2822 )?;.. info!("
00000390: 5361 6153 2063 6f6e 6669 6720 6c6f 6164 SaaS config load
000003a0: 6564 3a20 7b7d 3a7b 7d22 2c20 636f 6e66 ed: {}:{}", conf
000003b0: 6967 2e73 6572 7665 722e 686f 7374 2c20 ig.server.host,
000003c0: 636f 6e66 6967 2e73 6572 7665 722e 706f config.server.po
000003d0: 7274 293b 0d0a 0d0a 2020 2020 6c65 7420 rt);.... let
000003e0: 6462 203d 2069 6e69 745f 6462 2826 636f db = init_db(&co
000003f0: 6e66 6967 2e64 6174 6162 6173 652e 7572 nfig.database.ur
00000400: 6c29 2e61 7761 6974 3f3b 0d0a 2020 2020 l).await?;..
00000410: 696e 666f 2128 2244 6174 6162 6173 6520 info!("Database
00000420: 696e 6974 6961 6c69 7a65 6422 293b 0d0a initialized");..
00000430: 0d0a 2020 2020 2f2f 20e5 889d e5a7 8be5 .. // .......
00000440: 8c96 2057 6f72 6b65 7220 e8b0 83e5 baa6 .. Worker ......
00000450: e599 a820 2b20 e6b3 a8e5 868c e689 80e6 ... + ..........
00000460: 9c89 2057 6f72 6b65 720d 0a20 2020 206c .. Worker.. l
00000470: 6574 206d 7574 2064 6973 7061 7463 6865 et mut dispatche
00000480: 7220 3d20 576f 726b 6572 4469 7370 6174 r = WorkerDispat
00000490: 6368 6572 3a3a 6e65 7728 6462 2e63 6c6f cher::new(db.clo
000004a0: 6e65 2829 293b 0d0a 2020 2020 6469 7370 ne());.. disp
000004b0: 6174 6368 6572 2e72 6567 6973 7465 7228 atcher.register(
000004c0: 4c6f 674f 7065 7261 7469 6f6e 576f 726b LogOperationWork
000004d0: 6572 293b 0d0a 2020 2020 6469 7370 6174 er);.. dispat
000004e0: 6368 6572 2e72 6567 6973 7465 7228 436c cher.register(Cl
000004f0: 6561 6e75 7052 6566 7265 7368 546f 6b65 eanupRefreshToke
00000500: 6e73 576f 726b 6572 293b 0d0a 2020 2020 nsWorker);..
00000510: 6469 7370 6174 6368 6572 2e72 6567 6973 dispatcher.regis
00000520: 7465 7228 436c 6561 6e75 7052 6174 654c ter(CleanupRateL
00000530: 696d 6974 576f 726b 6572 293b 0d0a 2020 imitWorker);..
00000540: 2020 6469 7370 6174 6368 6572 2e72 6567 dispatcher.reg
00000550: 6973 7465 7228 5265 636f 7264 5573 6167 ister(RecordUsag
00000560: 6557 6f72 6b65 7229 3b0d 0a20 2020 2064 eWorker);.. d
00000570: 6973 7061 7463 6865 722e 7265 6769 7374 ispatcher.regist
00000580: 6572 2855 7064 6174 654c 6173 7455 7365 er(UpdateLastUse
00000590: 6457 6f72 6b65 7229 3b0d 0a20 2020 2069 dWorker);.. i
000005a0: 6e66 6f21 2822 576f 726b 6572 2064 6973 nfo!("Worker dis
000005b0: 7061 7463 6865 7220 696e 6974 6961 6c69 patcher initiali
000005c0: 7a65 6420 2835 2077 6f72 6b65 7273 2072 zed (5 workers r
000005d0: 6567 6973 7465 7265 6429 2229 3b0d 0a0d egistered)");...
000005e0: 0a20 2020 206c 6574 2073 7461 7465 203d . let state =
000005f0: 2041 7070 5374 6174 653a 3a6e 6577 2864 AppState::new(d
00000600: 622e 636c 6f6e 6528 292c 2063 6f6e 6669 b.clone(), confi
00000610: 672e 636c 6f6e 6528 292c 2064 6973 7061 g.clone(), dispa
00000620: 7463 6865 7229 3f3b 0d0a 0d0a 2020 2020 tcher)?;....
00000630: 2f2f 20e5 90af e58a a8e5 a3b0 e698 8ee5 // .............
00000640: bc8f 2053 6368 6564 756c 6572 efbc 88e4 .. Scheduler....
00000650: bb8e 2054 4f4d 4c20 e985 8de7 bdae e8af .. TOML ........
00000660: bbe5 8f96 e5ae 9ae6 97b6 e4bb bbe5 8aa1 ................
00000670: efbc 890d 0a20 2020 206c 6574 2073 6368 ..... let sch
00000680: 6564 756c 6572 5f63 6f6e 6669 6720 3d20 eduler_config =
00000690: 2663 6f6e 6669 672e 7363 6865 6475 6c65 &config.schedule
000006a0: 723b 0d0a 2020 2020 7a63 6c61 775f 7361 r;.. zclaw_sa
000006b0: 6173 3a3a 7363 6865 6475 6c65 723a 3a73 as::scheduler::s
000006c0: 7461 7274 5f73 6368 6564 756c 6572 2873 tart_scheduler(s
000006d0: 6368 6564 756c 6572 5f63 6f6e 6669 672c cheduler_config,
000006e0: 2064 622e 636c 6f6e 6528 292c 2073 7461 db.clone(), sta
000006f0: 7465 2e77 6f72 6b65 725f 6469 7370 6174 te.worker_dispat
00000700: 6368 6572 2e63 6c6f 6e65 5f72 6566 2829 cher.clone_ref()
00000710: 293b 0d0a 2020 2020 696e 666f 2128 2253 );.. info!("S
00000720: 6368 6564 756c 6572 2073 7461 7274 6564 cheduler started
00000730: 2077 6974 6820 7b7d 206a 6f62 7322 2c20 with {} jobs",
00000740: 7363 6865 6475 6c65 725f 636f 6e66 6967 scheduler_config
00000750: 2e6a 6f62 732e 6c65 6e28 2929 3b0d 0a0d .jobs.len());...
00000760: 0a20 2020 202f 2f20 e590 afe5 8aa8 e586 . // ........
00000770: 85e7 bdae 2044 4220 e6b8 85e7 9086 e4bb .... DB ........
00000780: bbe5 8aa1 efbc 88e8 aebe e5a4 87e6 b885 ................
00000790: e790 86e7 ad89 e4b8 8de9 809a e8bf 8720 ...............
000007a0: 576f 726b 6572 20e7 9a84 e4bb bbe5 8aa1 Worker .........
000007b0: efbc 890d 0a20 2020 207a 636c 6177 5f73 ..... zclaw_s
000007c0: 6161 733a 3a73 6368 6564 756c 6572 3a3a aas::scheduler::
000007d0: 7374 6172 745f 6462 5f63 6c65 616e 7570 start_db_cleanup
000007e0: 5f74 6173 6b73 2864 622e 636c 6f6e 6528 _tasks(db.clone(
000007f0: 2929 3b0d 0a0d 0a20 2020 202f 2f20 e590 ));.... // ..
00000800: afe5 8aa8 e586 85e5 ad98 e4b8 ade7 9a84 ................
00000810: 2072 6174 6520 6c69 6d69 7420 e69d a1e7 rate limit ....
00000820: 9bae e6b8 85e7 9086 0d0a 2020 2020 6c65 .......... le
00000830: 7420 7261 7465 5f6c 696d 6974 5f73 7461 t rate_limit_sta
00000840: 7465 203d 2073 7461 7465 2e63 6c6f 6e65 te = state.clone
00000850: 2829 3b0d 0a20 2020 2074 6f6b 696f 3a3a ();.. tokio::
00000860: 7370 6177 6e28 6173 796e 6320 6d6f 7665 spawn(async move
00000870: 207b 0d0a 2020 2020 2020 2020 6c65 7420 {.. let
00000880: 6d75 7420 696e 7465 7276 616c 203d 2074 mut interval = t
00000890: 6f6b 696f 3a3a 7469 6d65 3a3a 696e 7465 okio::time::inte
000008a0: 7276 616c 2873 7464 3a3a 7469 6d65 3a3a rval(std::time::
000008b0: 4475 7261 7469 6f6e 3a3a 6672 6f6d 5f73 Duration::from_s
000008c0: 6563 7328 3330 3029 293b 0d0a 2020 2020 ecs(300));..
000008d0: 2020 2020 6c6f 6f70 207b 0d0a 2020 2020 loop {..
000008e0: 2020 2020 2020 2020 696e 7465 7276 616c interval
000008f0: 2e74 6963 6b28 292e 6177 6169 743b 0d0a .tick().await;..
00000900: 2020 2020 2020 2020 2020 2020 7261 7465 rate
00000910: 5f6c 696d 6974 5f73 7461 7465 2e63 6c65 _limit_state.cle
00000920: 616e 7570 5f72 6174 655f 6c69 6d69 745f anup_rate_limit_
00000930: 656e 7472 6965 7328 293b 0d0a 2020 2020 entries();..
00000940: 2020 2020 7d0d 0a20 2020 207d 293b 0d0a }.. });..
00000950: 0d0a 2020 2020 6c65 7420 6170 7020 3d20 .. let app =
00000960: 6275 696c 645f 726f 7574 6572 2873 7461 build_router(sta
00000970: 7465 292e 6177 6169 743b 0d0a 0d0a 2020 te).await;....
00000980: 2020 6c65 7420 6c69 7374 656e 6572 203d let listener =
00000990: 2074 6f6b 696f 3a3a 6e65 743a 3a54 6370 tokio::net::Tcp
000009a0: 4c69 7374 656e 6572 3a3a 6269 6e64 2866 Listener::bind(f
000009b0: 6f72 6d61 7421 2822 7b7d 3a7b 7d22 2c20 ormat!("{}:{}",
000009c0: 636f 6e66 6967 2e73 6572 7665 722e 686f config.server.ho
000009d0: 7374 2c20 636f 6e66 6967 2e73 6572 7665 st, config.serve
000009e0: 722e 706f 7274 2929 0d0a 2020 2020 2020 r.port))..
000009f0: 2020 2e61 7761 6974 3f3b 0d0a 2020 2020 .await?;..
00000a00: 696e 666f 2128 2253 6161 5320 7365 7276 info!("SaaS serv
00000a10: 6572 206c 6973 7465 6e69 6e67 206f 6e20 er listening on
00000a20: 7b7d 3a7b 7d22 2c20 636f 6e66 6967 2e73 {}:{}", config.s
00000a30: 6572 7665 722e 686f 7374 2c20 636f 6e66 erver.host, conf
00000a40: 6967 2e73 6572 7665 722e 706f 7274 293b ig.server.port);
00000a50: 0d0a 0d0a 2020 2020 6178 756d 3a3a 7365 .... axum::se
00000a60: 7276 6528 6c69 7374 656e 6572 2c20 6170 rve(listener, ap
00000a70: 702e 696e 746f 5f6d 616b 655f 7365 7276 p.into_make_serv
00000a80: 6963 655f 7769 7468 5f63 6f6e 6e65 6374 ice_with_connect
00000a90: 5f69 6e66 6f3a 3a3c 7374 643a 3a6e 6574 _info::<std::net
00000aa0: 3a3a 536f 636b 6574 4164 6472 3e28 2929 ::SocketAddr>())
00000ab0: 0d0a 2020 2020 2020 2020 2e77 6974 685f .. .with_
00000ac0: 6772 6163 6566 756c 5f73 6875 7464 6f77 graceful_shutdow
00000ad0: 6e28 7368 7574 646f 776e 5f73 6967 6e61 n(shutdown_signa
00000ae0: 6c28 2929 0d0a 2020 2020 2020 2020 2e61 l()).. .a
00000af0: 7761 6974 3f3b 0d0a 2020 2020 4f6b 2828 wait?;.. Ok((
00000b00: 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 2066 ))..}....async f
00000b10: 6e20 6865 616c 7468 5f68 616e 646c 6572 n health_handler
00000b20: 2853 7461 7465 2873 7461 7465 293a 2053 (State(state): S
00000b30: 7461 7465 3c41 7070 5374 6174 653e 2920 tate<AppState>)
00000b40: 2d3e 2061 7875 6d3a 3a4a 736f 6e3c 7365 -> axum::Json<se
00000b50: 7264 655f 6a73 6f6e 3a3a 5661 6c75 653e rde_json::Value>
00000b60: 207b 0d0a 2020 2020 2f2f 2068 6561 6c74 {.. // healt
00000b70: 6820 e5bf 85e9 a1bb e78b ace7 ab8b e5bf h ..............
00000b80: abe9 809f e8bf 94e5 9b9e efbc 8ce7 94a8 ................
00000b90: 2033 7320 e8b6 85e6 97b6 e981 bfe5 858d 3s ............
00000ba0: e8bf 9ee6 8ea5 e6b1 a0e6 bba1 e697 b6e9 ................
00000bb0: 98bb e5a1 9e0d 0a20 2020 206c 6574 2064 ....... let d
00000bc0: 625f 6865 616c 7468 7920 3d20 746f 6b69 b_healthy = toki
00000bd0: 6f3a 3a74 696d 653a 3a74 696d 656f 7574 o::time::timeout
00000be0: 280d 0a20 2020 2020 2020 2073 7464 3a3a (.. std::
00000bf0: 7469 6d65 3a3a 4475 7261 7469 6f6e 3a3a time::Duration::
00000c00: 6672 6f6d 5f73 6563 7328 3329 2c0d 0a20 from_secs(3),..
00000c10: 2020 2020 2020 2073 716c 783a 3a71 7565 sqlx::que
00000c20: 7279 5f73 6361 6c61 723a 3a3c 5f2c 2069 ry_scalar::<_, i
00000c30: 3332 3e28 2253 454c 4543 5420 3122 292e 32>("SELECT 1").
00000c40: 6665 7463 685f 6f6e 6528 2673 7461 7465 fetch_one(&state
00000c50: 2e64 6229 2c0d 0a20 2020 2029 0d0a 2020 .db),.. )..
00000c60: 2020 2e61 7761 6974 0d0a 2020 2020 2e6d .await.. .m
00000c70: 6170 287c 727c 2072 2e69 735f 6f6b 2829 ap(|r| r.is_ok()
00000c80: 290d 0a20 2020 202e 756e 7772 6170 5f6f ).. .unwrap_o
00000c90: 7228 6661 6c73 6529 3b0d 0a0d 0a20 2020 r(false);....
00000ca0: 206c 6574 2073 7461 7475 7320 3d20 6966 let status = if
00000cb0: 2064 625f 6865 616c 7468 7920 7b20 2268 db_healthy { "h
00000cc0: 6561 6c74 6879 2220 7d20 656c 7365 207b ealthy" } else {
00000cd0: 2022 6465 6772 6164 6564 2220 7d3b 0d0a "degraded" };..
00000ce0: 2020 2020 6c65 7420 5f63 6f64 6520 3d20 let _code =
00000cf0: 6966 2064 625f 6865 616c 7468 7920 7b20 if db_healthy {
00000d00: 3230 3020 7d20 656c 7365 207b 2035 3033 200 } else { 503
00000d10: 207d 3b0d 0a0d 0a20 2020 2061 7875 6d3a };.... axum:
00000d20: 3a4a 736f 6e28 7365 7264 655f 6a73 6f6e :Json(serde_json
00000d30: 3a3a 6a73 6f6e 2128 7b0d 0a20 2020 2020 ::json!({..
00000d40: 2020 2022 7374 6174 7573 223a 2073 7461 "status": sta
00000d50: 7475 732c 0d0a 2020 2020 2020 2020 2264 tus,.. "d
00000d60: 6174 6162 6173 6522 3a20 6462 5f68 6561 atabase": db_hea
00000d70: 6c74 6879 2c0d 0a20 2020 2020 2020 2022 lthy,.. "
00000d80: 7469 6d65 7374 616d 7022 3a20 6368 726f timestamp": chro
00000d90: 6e6f 3a3a 5574 633a 3a6e 6f77 2829 2e74 no::Utc::now().t
00000da0: 6f5f 7266 6333 3333 3928 292c 0d0a 2020 o_rfc3339(),..
00000db0: 2020 2020 2020 2276 6572 7369 6f6e 223a "version":
00000dc0: 2065 6e76 2128 2243 4152 474f 5f50 4b47 env!("CARGO_PKG
00000dd0: 5f56 4552 5349 4f4e 2229 2c0d 0a20 2020 _VERSION"),..
00000de0: 207d 2929 0d0a 7d0d 0a0d 0a61 7379 6e63 }))..}....async
00000df0: 2066 6e20 6275 696c 645f 726f 7574 6572 fn build_router
00000e00: 2873 7461 7465 3a20 4170 7053 7461 7465 (state: AppState
00000e10: 2920 2d3e 2061 7875 6d3a 3a52 6f75 7465 ) -> axum::Route
00000e20: 7220 7b0d 0a20 2020 2075 7365 2061 7875 r {.. use axu
00000e30: 6d3a 3a6d 6964 646c 6577 6172 653b 0d0a m::middleware;..
00000e40: 2020 2020 7573 6520 746f 7765 725f 6874 use tower_ht
00000e50: 7470 3a3a 636f 7273 3a3a 7b41 6e79 2c20 tp::cors::{Any,
00000e60: 436f 7273 4c61 7965 727d 3b0d 0a20 2020 CorsLayer};..
00000e70: 2075 7365 2074 6f77 6572 5f68 7474 703a use tower_http:
00000e80: 3a74 7261 6365 3a3a 5472 6163 654c 6179 :trace::TraceLay
00000e90: 6572 3b0d 0a0d 0a20 2020 2075 7365 2061 er;.... use a
00000ea0: 7875 6d3a 3a68 7474 703a 3a48 6561 6465 xum::http::Heade
00000eb0: 7256 616c 7565 3b0d 0a20 2020 206c 6574 rValue;.. let
00000ec0: 2063 6f72 7320 3d20 7b0d 0a20 2020 2020 cors = {..
00000ed0: 2020 206c 6574 2063 6f6e 6669 6720 3d20 let config =
00000ee0: 7374 6174 652e 636f 6e66 6967 2e72 6561 state.config.rea
00000ef0: 6428 292e 6177 6169 743b 0d0a 2020 2020 d().await;..
00000f00: 2020 2020 6c65 7420 6973 5f64 6576 203d let is_dev =
00000f10: 2073 7464 3a3a 656e 763a 3a76 6172 2822 std::env::var("
00000f20: 5a43 4c41 575f 5341 4153 5f44 4556 2229 ZCLAW_SAAS_DEV")
00000f30: 0d0a 2020 2020 2020 2020 2020 2020 2e6d .. .m
00000f40: 6170 287c 767c 2076 203d 3d20 2274 7275 ap(|v| v == "tru
00000f50: 6522 207c 7c20 7620 3d3d 2022 3122 290d e" || v == "1").
00000f60: 0a20 2020 2020 2020 2020 2020 202e 756e . .un
00000f70: 7772 6170 5f6f 7228 6661 6c73 6529 3b0d wrap_or(false);.
00000f80: 0a20 2020 2020 2020 2069 6620 636f 6e66 . if conf
00000f90: 6967 2e73 6572 7665 722e 636f 7273 5f6f ig.server.cors_o
00000fa0: 7269 6769 6e73 2e69 735f 656d 7074 7928 rigins.is_empty(
00000fb0: 2920 7b0d 0a20 2020 2020 2020 2020 2020 ) {..
00000fc0: 2069 6620 6973 5f64 6576 207b 0d0a 2020 if is_dev {..
00000fd0: 2020 2020 2020 2020 2020 2020 2020 436f Co
00000fe0: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00000ff0: 2020 2020 2020 2020 2020 2020 2020 2020
00001000: 2020 2020 2e61 6c6c 6f77 5f6f 7269 6769 .allow_origi
00001010: 6e28 416e 7929 0d0a 2020 2020 2020 2020 n(Any)..
00001020: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001030: 6f77 5f6d 6574 686f 6473 2841 6e79 290d ow_methods(Any).
00001040: 0a20 2020 2020 2020 2020 2020 2020 2020 .
00001050: 2020 2020 202e 616c 6c6f 775f 6865 6164 .allow_head
00001060: 6572 7328 416e 7929 0d0a 2020 2020 2020 ers(Any)..
00001070: 2020 2020 2020 7d20 656c 7365 207b 0d0a } else {..
00001080: 2020 2020 2020 2020 2020 2020 2020 2020
00001090: 7472 6163 696e 673a 3a65 7272 6f72 2128 tracing::error!(
000010a0: 22e7 949f e4ba a7e7 8eaf e5a2 83e5 bf85 "...............
000010b0: e9a1 bbe9 858d e7bd ae20 7365 7276 6572 ......... server
000010c0: 2e63 6f72 735f 6f72 6967 696e 73ef bc8c .cors_origins...
000010d0: e4b8 8de8 83bd e4bd bfe7 94a8 2061 6c6c ............ all
000010e0: 6f77 5f6f 7269 6769 6e28 416e 7929 2229 ow_origin(Any)")
000010f0: 3b0d 0a20 2020 2020 2020 2020 2020 2020 ;..
00001100: 2020 2070 616e 6963 2128 22e7 949f e4ba panic!(".....
00001110: a7e7 8eaf e5a2 83e5 bf85 e9a1 bbe9 858d ................
00001120: e7bd ae20 7365 7276 6572 2e63 6f72 735f ... server.cors_
00001130: 6f72 6967 696e 7320 e799 bde5 908d e58d origins ........
00001140: 95e3 8082 e5bc 80e5 8f91 e78e afe5 a283 ................
00001150: e58f afe8 aebe e7bd ae20 5a43 4c41 575f ......... ZCLAW_
00001160: 5341 4153 5f44 4556 3d74 7275 6520 e7bb SAAS_DEV=true ..
00001170: 95e8 bf87 e380 8222 293b 0d0a 2020 2020 .......");..
00001180: 2020 2020 2020 2020 7d0d 0a20 2020 2020 }..
00001190: 2020 207d 2065 6c73 6520 7b0d 0a20 2020 } else {..
000011a0: 2020 2020 2020 2020 206c 6574 206f 7269 let ori
000011b0: 6769 6e73 3a20 5665 633c 4865 6164 6572 gins: Vec<Header
000011c0: 5661 6c75 653e 203d 2063 6f6e 6669 672e Value> = config.
000011d0: 7365 7276 6572 2e63 6f72 735f 6f72 6967 server.cors_orig
000011e0: 696e 732e 6974 6572 2829 0d0a 2020 2020 ins.iter()..
000011f0: 2020 2020 2020 2020 2020 2020 2e66 696c .fil
00001200: 7465 725f 6d61 7028 7c6f 3a20 2653 7472 ter_map(|o: &Str
00001210: 696e 677c 206f 2e70 6172 7365 3a3a 3c48 ing| o.parse::<H
00001220: 6561 6465 7256 616c 7565 3e28 292e 6f6b eaderValue>().ok
00001230: 2829 290d 0a20 2020 2020 2020 2020 2020 ())..
00001240: 2020 2020 202e 636f 6c6c 6563 7428 293b .collect();
00001250: 0d0a 2020 2020 2020 2020 2020 2020 436f .. Co
00001260: 7273 4c61 7965 723a 3a6e 6577 2829 0d0a rsLayer::new()..
00001270: 2020 2020 2020 2020 2020 2020 2020 2020
00001280: 2e61 6c6c 6f77 5f6f 7269 6769 6e28 6f72 .allow_origin(or
00001290: 6967 696e 7329 0d0a 2020 2020 2020 2020 igins)..
000012a0: 2020 2020 2020 2020 2e61 6c6c 6f77 5f6d .allow_m
000012b0: 6574 686f 6473 285b 0d0a 2020 2020 2020 ethods([..
000012c0: 2020 2020 2020 2020 2020 2020 2020 6178 ax
000012d0: 756d 3a3a 6874 7470 3a3a 4d65 7468 6f64 um::http::Method
000012e0: 3a3a 4745 542c 0d0a 2020 2020 2020 2020 ::GET,..
000012f0: 2020 2020 2020 2020 2020 2020 6178 756d axum
00001300: 3a3a 6874 7470 3a3a 4d65 7468 6f64 3a3a ::http::Method::
00001310: 504f 5354 2c0d 0a20 2020 2020 2020 2020 POST,..
00001320: 2020 2020 2020 2020 2020 2061 7875 6d3a axum:
00001330: 3a68 7474 703a 3a4d 6574 686f 643a 3a50 :http::Method::P
00001340: 5554 2c0d 0a20 2020 2020 2020 2020 2020 UT,..
00001350: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001360: 7474 703a 3a4d 6574 686f 643a 3a50 4154 ttp::Method::PAT
00001370: 4348 2c0d 0a20 2020 2020 2020 2020 2020 CH,..
00001380: 2020 2020 2020 2020 2061 7875 6d3a 3a68 axum::h
00001390: 7474 703a 3a4d 6574 686f 643a 3a44 454c ttp::Method::DEL
000013a0: 4554 452c 0d0a 2020 2020 2020 2020 2020 ETE,..
000013b0: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
000013c0: 6874 7470 3a3a 4d65 7468 6f64 3a3a 4f50 http::Method::OP
000013d0: 5449 4f4e 532c 0d0a 2020 2020 2020 2020 TIONS,..
000013e0: 2020 2020 2020 2020 5d29 0d0a 2020 2020 ])..
000013f0: 2020 2020 2020 2020 2020 2020 2e61 6c6c .all
00001400: 6f77 5f68 6561 6465 7273 285b 0d0a 2020 ow_headers([..
00001410: 2020 2020 2020 2020 2020 2020 2020 2020
00001420: 2020 6178 756d 3a3a 6874 7470 3a3a 6865 axum::http::he
00001430: 6164 6572 3a3a 4155 5448 4f52 495a 4154 ader::AUTHORIZAT
00001440: 494f 4e2c 0d0a 2020 2020 2020 2020 2020 ION,..
00001450: 2020 2020 2020 2020 2020 6178 756d 3a3a axum::
00001460: 6874 7470 3a3a 6865 6164 6572 3a3a 434f http::header::CO
00001470: 4e54 454e 545f 5459 5045 2c0d 0a20 2020 NTENT_TYPE,..
00001480: 2020 2020 2020 2020 2020 2020 2020 2020
00001490: 2061 7875 6d3a 3a68 7474 703a 3a48 6561 axum::http::Hea
000014a0: 6465 724e 616d 653a 3a66 726f 6d5f 7374 derName::from_st
000014b0: 6174 6963 2822 782d 7265 7175 6573 742d atic("x-request-
000014c0: 6964 2229 2c0d 0a20 2020 2020 2020 2020 id"),..
000014d0: 2020 2020 2020 205d 290d 0a20 2020 2020 ])..
000014e0: 2020 207d 0d0a 2020 2020 7d3b 0d0a 0d0a }.. };....
000014f0: 2020 2020 6c65 7420 7075 626c 6963 5f72 let public_r
00001500: 6f75 7465 7320 3d20 7a63 6c61 775f 7361 outes = zclaw_sa
00001510: 6173 3a3a 6175 7468 3a3a 726f 7574 6573 as::auth::routes
00001520: 2829 0d0a 2020 2020 2020 2020 2e72 6f75 ().. .rou
00001530: 7465 2822 2f61 7069 2f68 6561 6c74 6822 te("/api/health"
00001540: 2c20 6178 756d 3a3a 726f 7574 696e 673a , axum::routing:
00001550: 3a67 6574 2868 6561 6c74 685f 6861 6e64 :get(health_hand
00001560: 6c65 7229 290d 0a20 2020 2020 2020 202e ler)).. .
00001570: 6c61 7965 7228 6d69 6464 6c65 7761 7265 layer(middleware
00001580: 3a3a 6672 6f6d 5f66 6e5f 7769 7468 5f73 ::from_fn_with_s
00001590: 7461 7465 280d 0a20 2020 2020 2020 2020 tate(..
000015a0: 2020 2073 7461 7465 2e63 6c6f 6e65 2829 state.clone()
000015b0: 2c0d 0a20 2020 2020 2020 2020 2020 207a ,.. z
000015c0: 636c 6177 5f73 6161 733a 3a6d 6964 646c claw_saas::middl
000015d0: 6577 6172 653a 3a70 7562 6c69 635f 7261 eware::public_ra
000015e0: 7465 5f6c 696d 6974 5f6d 6964 646c 6577 te_limit_middlew
000015f0: 6172 652c 0d0a 2020 2020 2020 2020 2929 are,.. ))
00001600: 3b0d 0a0d 0a20 2020 206c 6574 2070 726f ;.... let pro
00001610: 7465 6374 6564 5f72 6f75 7465 7320 3d20 tected_routes =
00001620: 7a63 6c61 775f 7361 6173 3a3a 6175 7468 zclaw_saas::auth
00001630: 3a3a 7072 6f74 6563 7465 645f 726f 7574 ::protected_rout
00001640: 6573 2829 0d0a 2020 2020 2020 2020 2e6d es().. .m
00001650: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
00001660: 3a61 6363 6f75 6e74 3a3a 726f 7574 6573 :account::routes
00001670: 2829 290d 0a20 2020 2020 2020 202e 6d65 ()).. .me
00001680: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001690: 6d6f 6465 6c5f 636f 6e66 6967 3a3a 726f model_config::ro
000016a0: 7574 6573 2829 290d 0a20 2020 2020 2020 utes())..
000016b0: 202e 6d65 7267 6528 7a63 6c61 775f 7361 .merge(zclaw_sa
000016c0: 6173 3a3a 7265 6c61 793a 3a72 6f75 7465 as::relay::route
000016d0: 7328 2929 0d0a 2020 2020 2020 2020 2e6d s()).. .m
000016e0: 6572 6765 287a 636c 6177 5f73 6161 733a erge(zclaw_saas:
000016f0: 3a6d 6967 7261 7469 6f6e 3a3a 726f 7574 :migration::rout
00001700: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
00001710: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
00001720: 3a3a 726f 6c65 3a3a 726f 7574 6573 2829 ::role::routes()
00001730: 290d 0a20 2020 2020 2020 202e 6d65 7267 ).. .merg
00001740: 6528 7a63 6c61 775f 7361 6173 3a3a 7072 e(zclaw_saas::pr
00001750: 6f6d 7074 3a3a 726f 7574 6573 2829 290d ompt::routes()).
00001760: 0a20 2020 2020 2020 202e 6d65 7267 6528 . .merge(
00001770: 7a63 6c61 775f 7361 6173 3a3a 6167 656e zclaw_saas::agen
00001780: 745f 7465 6d70 6c61 7465 3a3a 726f 7574 t_template::rout
00001790: 6573 2829 290d 0a20 2020 2020 2020 202e es()).. .
000017a0: 6d65 7267 6528 7a63 6c61 775f 7361 6173 merge(zclaw_saas
000017b0: 3a3a 7465 6c65 6d65 7472 793a 3a72 6f75 ::telemetry::rou
000017c0: 7465 7328 2929 0d0a 2020 2020 2020 2020 tes())..
000017d0: 2e6c 6179 6572 286d 6964 646c 6577 6172 .layer(middlewar
000017e0: 653a 3a66 726f 6d5f 666e 5f77 6974 685f e::from_fn_with_
000017f0: 7374 6174 6528 0d0a 2020 2020 2020 2020 state(..
00001800: 2020 2020 7374 6174 652e 636c 6f6e 6528 state.clone(
00001810: 292c 0d0a 2020 2020 2020 2020 2020 2020 ),..
00001820: 7a63 6c61 775f 7361 6173 3a3a 6d69 6464 zclaw_saas::midd
00001830: 6c65 7761 7265 3a3a 6170 695f 7665 7273 leware::api_vers
00001840: 696f 6e5f 6d69 6464 6c65 7761 7265 2c0d ion_middleware,.
00001850: 0a20 2020 2020 2020 2029 290d 0a20 2020 . ))..
00001860: 2020 2020 202e 6c61 7965 7228 6d69 6464 .layer(midd
00001870: 6c65 7761 7265 3a3a 6672 6f6d 5f66 6e5f leware::from_fn_
00001880: 7769 7468 5f73 7461 7465 280d 0a20 2020 with_state(..
00001890: 2020 2020 2020 2020 2073 7461 7465 2e63 state.c
000018a0: 6c6f 6e65 2829 2c0d 0a20 2020 2020 2020 lone(),..
000018b0: 2020 2020 207a 636c 6177 5f73 6161 733a zclaw_saas:
000018c0: 3a6d 6964 646c 6577 6172 653a 3a72 6571 :middleware::req
000018d0: 7565 7374 5f69 645f 6d69 6464 6c65 7761 uest_id_middlewa
000018e0: 7265 2c0d 0a20 2020 2020 2020 2029 290d re,.. )).
000018f0: 0a20 2020 2020 2020 202e 6c61 7965 7228 . .layer(
00001900: 6d69 6464 6c65 7761 7265 3a3a 6672 6f6d middleware::from
00001910: 5f66 6e5f 7769 7468 5f73 7461 7465 280d _fn_with_state(.
00001920: 0a20 2020 2020 2020 2020 2020 2073 7461 . sta
00001930: 7465 2e63 6c6f 6e65 2829 2c0d 0a20 2020 te.clone(),..
00001940: 2020 2020 2020 2020 207a 636c 6177 5f73 zclaw_s
00001950: 6161 733a 3a6d 6964 646c 6577 6172 653a aas::middleware:
00001960: 3a72 6174 655f 6c69 6d69 745f 6d69 6464 :rate_limit_midd
00001970: 6c65 7761 7265 2c0d 0a20 2020 2020 2020 leware,..
00001980: 2029 290d 0a20 2020 2020 2020 202e 6c61 )).. .la
00001990: 7965 7228 6d69 6464 6c65 7761 7265 3a3a yer(middleware::
000019a0: 6672 6f6d 5f66 6e5f 7769 7468 5f73 7461 from_fn_with_sta
000019b0: 7465 280d 0a20 2020 2020 2020 2020 2020 te(..
000019c0: 2073 7461 7465 2e63 6c6f 6e65 2829 2c0d state.clone(),.
000019d0: 0a20 2020 2020 2020 2020 2020 207a 636c . zcl
000019e0: 6177 5f73 6161 733a 3a61 7574 683a 3a61 aw_saas::auth::a
000019f0: 7574 685f 6d69 6464 6c65 7761 7265 2c0d uth_middleware,.
00001a00: 0a20 2020 2020 2020 2029 293b 0d0a 0d0a . ));....
00001a10: 2020 2020 2f2f 20e9 9d9e e6b5 81e5 bc8f // .........
00001a20: e8b7 afe7 94b1 e5ba 94e7 94a8 e585 a8e5 ................
00001a30: b180 2031 3573 20e8 b685 e697 b6ef bc88 .. 15s .........
00001a40: 7265 6c61 7920 5353 4520 e7ab afe7 82b9 relay SSE ......
00001a50: e99c 80e8 a681 e69b b4e9 95bf e8b6 85e6 ................
00001a60: 97b6 efbc 890d 0a20 2020 206c 6574 206e ....... let n
00001a70: 6f6e 5f73 7472 6561 6d69 6e67 5f72 6f75 on_streaming_rou
00001a80: 7465 7320 3d20 6178 756d 3a3a 526f 7574 tes = axum::Rout
00001a90: 6572 3a3a 6e65 7728 290d 0a20 2020 2020 er::new()..
00001aa0: 2020 202e 6d65 7267 6528 7075 626c 6963 .merge(public
00001ab0: 5f72 6f75 7465 7329 0d0a 2020 2020 2020 _routes)..
00001ac0: 2020 2e6d 6572 6765 2870 726f 7465 6374 .merge(protect
00001ad0: 6564 5f72 6f75 7465 7329 0d0a 2020 2020 ed_routes)..
00001ae0: 2020 2020 2e6c 6179 6572 2854 696d 656f .layer(Timeo
00001af0: 7574 4c61 7965 723a 3a6e 6577 2873 7464 utLayer::new(std
00001b00: 3a3a 7469 6d65 3a3a 4475 7261 7469 6f6e ::time::Duration
00001b10: 3a3a 6672 6f6d 5f73 6563 7328 3135 2929 ::from_secs(15))
00001b20: 293b 0d0a 0d0a 2020 2020 6178 756d 3a3a );.... axum::
00001b30: 526f 7574 6572 3a3a 6e65 7728 290d 0a20 Router::new()..
00001b40: 2020 2020 2020 202e 6d65 7267 6528 6e6f .merge(no
00001b50: 6e5f 7374 7265 616d 696e 675f 726f 7574 n_streaming_rout
00001b60: 6573 290d 0a20 2020 2020 2020 202e 6d65 es).. .me
00001b70: 7267 6528 7a63 6c61 775f 7361 6173 3a3a rge(zclaw_saas::
00001b80: 7265 6c61 793a 3a72 6f75 7465 7328 2929 relay::routes())
00001b90: 0d0a 2020 2020 2020 2020 2e6c 6179 6572 .. .layer
00001ba0: 2854 7261 6365 4c61 7965 723a 3a6e 6577 (TraceLayer::new
00001bb0: 5f66 6f72 5f68 7474 7028 2929 0d0a 2020 _for_http())..
00001bc0: 2020 2020 2020 2e6c 6179 6572 2863 6f72 .layer(cor
00001bd0: 7329 0d0a 2020 2020 2020 2020 2e77 6974 s).. .wit
00001be0: 685f 7374 6174 6528 7374 6174 6529 0d0a h_state(state)..
00001bf0: 7d0d 0a0d 0a2f 2f2f 20e7 9b91 e590 ac20 }..../// ......
00001c00: 4374 726c 2b43 20e4 bfa1 e58f b7ef bc8c Ctrl+C .........
00001c10: e8a7 a6e5 8f91 2067 7261 6365 6675 6c20 ...... graceful
00001c20: 7368 7574 646f 776e 0d0a 6173 796e 6320 shutdown..async
00001c30: 666e 2073 6875 7464 6f77 6e5f 7369 676e fn shutdown_sign
00001c40: 616c 2829 207b 0d0a 2020 2020 746f 6b69 al() {.. toki
00001c50: 6f3a 3a73 6967 6e61 6c3a 3a63 7472 6c5f o::signal::ctrl_
00001c60: 6328 290d 0a20 2020 2020 2020 202e 6177 c().. .aw
00001c70: 6169 740d 0a20 2020 2020 2020 202e 6578 ait.. .ex
00001c80: 7065 6374 2822 4661 696c 6564 2074 6f20 pect("Failed to
00001c90: 696e 7374 616c 6c20 4374 726c 2b43 2068 install Ctrl+C h
00001ca0: 616e 646c 6572 2229 3b0d 0a20 2020 2069 andler");.. i
00001cb0: 6e66 6f21 2822 5265 6365 6976 6564 2073 nfo!("Received s
00001cc0: 6875 7464 6f77 6e20 7369 676e 616c 2c20 hutdown signal,
00001cd0: 6472 6169 6e69 6e67 2063 6f6e 6e65 6374 draining connect
00001ce0: 696f 6e73 2e2e 2e22 293b 0d0a 7d0d 0a ions...");..}..

0
Authorization Normal file
View File

113
CLAUDE.md
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. 常见问题排查
@@ -338,7 +357,81 @@ refactor(store): 统一 Store 数据获取方式
## 12. 安全注意事项
- 不在代码中硬编码密钥
</section>
< + + 寜### 安全注意事项
|`
|--- 不在代码中硬编码密钥`
| - 敄 操作需要确认
` - 不在代码中硬编码密V Token/ API |
| - 保留操作审计日志
` - 用户输入必须验证` ` - 敄 就环境变量 `ZCLAW_SAAS_DEV` 模式放宽安全限制(开发环境设 `ZCLAW_SAAS_DEV=true`) + ` - **生产环境 TLS 终止**:
nginx/caddy 反代向提供 HTTPS**
|
| - Cookie `Secure` 标记在生产环境设为 true,开发环境设为 false仅 臉 TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` 必须设置64 字符 hex)
密钥) |
| - **Cookie SameSite=Strict** 鰲止 CSRF)` |
| - Refresh Token 轮换: 退出时DB 撤销为关联, 旧 token` |
| + **Rotation 校验已使用 token 是否已撤销` |
| + **Logout 时撤销 refresh token` |
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS |
| - Cookie Secure 标记: 开发环境 false, 生产 true` |
| + + | **配置说明** |
| - saas-config.toml 支持 `${ENV_VAR}` 稡式环境变量插值,如 `${DB_PASSWORD}` |
| - `ZCLAW_DATABASE_URL` 茉境变量覆盖 |
优先级最高) |
| - **Auth**: /api/auth/login` - 5次/分钟/IP (防暴力破解) |
| - `/api/auth/register` - 3次/小时/IP (防刷注册) |
| - 公共端点默认 20次/分钟/IP (防滥用) |
| - JWT 寰钥: `#[cfg(debug_assertions)]` 保护 fallbackrelease 枋 | ` bail` 拒绝启动` | - TOTP 加密密钥: AES-256-GCM 加密, 支持 SHA-256 崾生 JWT 密钥派生` |
- Logout 撤销: refresh token 到 DB 栘 UPDATE` |
| - Cookie: Secure 标志: 开发环境 false, 生产 true
|
| + + `SameSite=Strict` + 跨站 CSRF + SSL ( CORS) |
| + | TLS 终止:: nginx/caddy 反向代理提供 HTTPS`, 或 |
生产环境日志写入 WAF - | | **TLS 终止说明**: | 反向代理实现 HTTPS | Axum 服务不负责 TLS 配置、 |
`saas-config.toml.example` 更新安全说明 |
| | 密钥管理 | 甤境变量引用 (`${DB_PASSWORD}` 等) |
数据库密码) | | TOML 解析支持 `${VAR}` 稡式环境变量插值, | | 通过 `ZCLAW_DATABASE_URL` 猯变量完整覆盖 (优先级最高) |
| - JWT fallback key | `#[cfg(debug_assertions)]` 保护 fallbackrelease 拒绝启动` | - TOTP/API Key 加密: `AES-256-GCM`, 支持 SHA-256 派生 JWT 密钥派生` | - Logout 时撤销 refresh token 到 DB (`used_at IS NULL` 切 `revoked`) + rotation 校验已撤销的旧 token` | - Cookie Secure: 开发环境 false, 生产 true | `SameSite=Strict` + 跨站 CSRF + SSR CORS 白名单 + `X-Request头 + 请求日志 | |
| - **TLS**: 生产环境必须使用反向代理 (nginx/caddy) 提供 HTTPS, | - **生产环境日志写入 WAF - | |
| - **配置说明**: `saas-config.toml` 支持 `${ENV_VAR}` 稡式环境变量插值, | 文件模板已示例已更新 |
| - `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (至少 32 字符随机字符串) | | | TOTP 加密密钥 `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP 加密密钥 (hex 编码, 64 字符) | |
| | SAAS 配置环境变量 | `ZCLAW_SAAS_DEV` 开发环境 |
| `ZCLAW_SAAS_DEV=true` 放宽安全限制 (开发环境: | | 公共端点请求限流 |
| - 公共端点限流 & login/register) | refresh/logout | 默认 | `ZCLAW_SAAS_DEV` 不设置) |
| | **Cookie**: HttpOnly + Secure + SameSite=Strict + 路径="/api" + "/api/v1/auth" + `Secure` 仅在生产环境为 true |
| | **TLS**: 反向代理** 提供 HTTPS 终止** | 反向代理(如 nginx/caddy配置上游 → [SSL 终止 (`proxy downgrade`) |
| **Cookie**: Secure 标记仅在开发环境 (`ZCLAW_SAAS_DEV=true`) 设为 false不强制 HTTPS生产环境设为 true |
| - **环境变量模板**: | | 瘾境命令 |
| - `DB_PASSWORD` | 数据库密码 |
| - `ZCLAW_DATABASE_URL` | 完整数据库连接 URL |
| - `ZCLAW_SAAS_JWT_SECRET` | JWT 签名密钥 (≥ 32 字符) |
| - `ZCLAW_TOTP_ENCRYPTION_KEY` | TOTP/API Key 加密密钥 (64 hex) |
| - `ZCLAW_ADMIN_USERNAME` | 初始管理员用户名 |
| - `ZCLAW_ADMIN_PASSWORD` | 初始管理员密码 |
| - `ZCLAW_SAAS_DEV` | 开发模式标志 (true=开发, false=生产) |
| - **生产环境清单单** |
| | nginx/caddy 配置反向代理 + HTTPS |
| | 确保设置 `ZCLAW_SAAS_DEV=false`(或不设置) |
| | 启用 CORS 白名单 | | | `cors_origins` 匇向实际域名 |
| | Cookie Secure=true + HttpOnly=true + SameSite=Strict |
| - JWT 寋名密钥 >= 32 字符随机字符串 |
| - 数据库密码通过 `${DB_PASSWORD}` 引用 | |
| **部署命令** (参考) |
| | 设置环境变量: `export DB_PASSWORD=your_password` |
| | `export ZCLAW_SAAS_JWT_SECRET=$(openssl rand -hex 32)` |
| | `cp saas-config.toml.example saas-config.toml` |
| | 编辑 saas-config.toml 填入实际数据库 URL |
| | `cargo build --release -p zclaw-saas` |
| | 启动服务: `./zclaw-saas` |- 不在代码中硬编码密钥
- 用户输入必须验证
- 敏感操作需要确认
- 保留操作审计日志

1009
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

View File

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

24
admin-temp-dir/.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-temp-dir/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...
},
},
])
```

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-temp-dir/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>

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-temp-dir/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

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>
)
}

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>
)
}

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"]
}

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"]
}

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)
})
},
},
},
},
})

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),
}

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