Compare commits
31 Commits
worktree-s
...
6821df5f44
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6821df5f44 | ||
|
|
9d310e5a3c | ||
|
|
6529b67353 | ||
|
|
a0bbd4ba82 | ||
|
|
c2aff09811 | ||
|
|
e7b2d1c099 | ||
|
|
88aa4b1310 | ||
|
|
ecd7f2e928 | ||
|
|
544358764e | ||
|
|
ba2c6a6105 | ||
|
|
bc8c77e7fe | ||
|
|
834aa12076 | ||
|
|
813b49a986 | ||
|
|
d345e60a6a | ||
|
|
a7d33d0207 | ||
|
|
13c0b18bbc | ||
|
|
5595083b96 | ||
|
|
eed26a1ce4 | ||
|
|
f3f586efef | ||
|
|
6040d98b18 | ||
|
|
ee29b7b752 | ||
|
|
7e90cea117 | ||
|
|
09df242cf8 | ||
|
|
04c366fe8b | ||
|
|
7de294375b | ||
|
|
b7ec317d2c | ||
|
|
a0ca35c9dd | ||
|
|
77374121dd | ||
|
|
8b9d506893 | ||
|
|
5fdf96c3f5 | ||
|
|
9a5fad2b59 |
1
.claude/worktrees/saas-backend
Submodule
1
.claude/worktrees/saas-backend
Submodule
Submodule .claude/worktrees/saas-backend added at 4d8d560d1f
4
.gitignore
vendored
4
.gitignore
vendored
@@ -12,6 +12,10 @@ build/
|
||||
.env.local
|
||||
.env.*.local
|
||||
|
||||
# SaaS config (contains database credentials)
|
||||
saas-config.toml
|
||||
!saas-config.toml.example
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
0
Authorization
Normal file
0
Authorization
Normal file
37
CLAUDE.md
37
CLAUDE.md
@@ -36,17 +36,20 @@ ZCLAW/
|
||||
│ ├── zclaw-kernel/ # L4: 核心协调 (注册, 调度, 事件, 工作流)
|
||||
│ ├── zclaw-skills/ # 技能系统 (SKILL.md解析, 执行器)
|
||||
│ ├── zclaw-hands/ # 自主能力 (Hand/Trigger 注册管理)
|
||||
│ ├── zclaw-channels/ # 通道适配器 (仅 ConsoleChannel 测试适配器)
|
||||
│ └── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||
│ ├── zclaw-protocols/ # 协议支持 (MCP, A2A)
|
||||
│ └── zclaw-saas/ # SaaS 后端 (账号, 模型配置, 中转, 配置同步)
|
||||
├── admin/ # Next.js 管理后台
|
||||
├── desktop/ # Tauri 桌面应用
|
||||
│ ├── src/
|
||||
│ │ ├── components/ # React UI 组件
|
||||
│ │ ├── store/ # Zustand 状态管理
|
||||
│ │ └── lib/ # 客户端通信 / 工具函数
|
||||
│ │ ├── components/ # React UI 组件 (含 SaaS 集成)
|
||||
│ │ ├── store/ # Zustand 状态管理 (含 saasStore)
|
||||
│ │ └── lib/ # 客户端通信 / 工具函数 (含 saas-client)
|
||||
│ └── src-tauri/ # Tauri Rust 后端 (集成 Kernel)
|
||||
├── skills/ # SKILL.md 技能定义
|
||||
├── hands/ # HAND.toml 自主能力配置
|
||||
├── config/ # TOML 配置文件
|
||||
├── saas-config.toml # SaaS 后端配置 (PostgreSQL 连接等)
|
||||
├── docker-compose.yml # PostgreSQL 容器配置
|
||||
├── docs/ # 架构文档和知识库
|
||||
└── tests/ # Vitest 回归测试
|
||||
```
|
||||
@@ -66,7 +69,9 @@ ZCLAW/
|
||||
| 桌面框架 | Tauri 2.x |
|
||||
| 样式方案 | Tailwind CSS |
|
||||
| 配置格式 | TOML |
|
||||
| 后端核心 | Rust Workspace (8 crates) |
|
||||
| 后端核心 | Rust Workspace (9 crates) |
|
||||
| SaaS 后端 | Axum + PostgreSQL (zclaw-saas) |
|
||||
| 管理后台 | Next.js (admin/) |
|
||||
|
||||
### 2.3 Crate 依赖关系
|
||||
|
||||
@@ -79,7 +84,9 @@ zclaw-runtime (→ types, memory)
|
||||
↑
|
||||
zclaw-kernel (→ types, memory, runtime)
|
||||
↑
|
||||
desktop/src-tauri (→ kernel, skills, hands, channels, protocols)
|
||||
zclaw-saas (→ types, 独立运行于 8080 端口)
|
||||
↑
|
||||
desktop/src-tauri (→ kernel, skills, hands, protocols)
|
||||
```
|
||||
|
||||
***
|
||||
@@ -191,10 +198,10 @@ ZCLAW 提供 11 个自主能力包:
|
||||
| Predictor | 预测分析 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Lead | 销售线索发现 | ❌ 已禁用 (enabled=false),无 Rust 实现 |
|
||||
| Clip | 视频处理 | ⚠️ 需 FFmpeg |
|
||||
| Twitter | Twitter 自动化 | ⚠️ 需 API Key |
|
||||
| Twitter | Twitter 自动化 | ✅ 可用(12 个 API v2 真实调用,写操作需 OAuth 1.0a) |
|
||||
| Whiteboard | 白板演示 | ✅ 可用(导出功能开发中,标注 demo) |
|
||||
| Slideshow | 幻灯片生成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用 |
|
||||
| Speech | 语音合成 | ✅ 可用(Browser TTS 前端集成完成) |
|
||||
| Quiz | 测验生成 | ✅ 可用 |
|
||||
|
||||
**触发 Hand 时:**
|
||||
@@ -260,6 +267,18 @@ docs/
|
||||
- **面向未来** - 文档要帮助未来的开发者快速理解
|
||||
- **中文优先** - 所有面向用户的文档使用中文
|
||||
|
||||
### 8.3 完成工作后的文档同步(强制)
|
||||
|
||||
每次完成功能实现、架构变更、问题修复后,**必须**同步更新以下文档:
|
||||
|
||||
1. **CLAUDE.md** — 如果涉及项目结构、技术栈、工作流程、命令的变化
|
||||
2. **docs/features/** — 如果涉及新功能、功能变更、功能状态更新
|
||||
3. **docs/knowledge-base/** — 如果涉及新知识、故障排查经验、配置说明
|
||||
4. **saas-config.toml 注释** — 如果涉及 SaaS 配置项变更
|
||||
5. **CHANGELOG** — 如果涉及对外可见的行为变化
|
||||
|
||||
**执行时机:** 代码编译通过且验证成功后,在标记任务完成之前,立即执行文档更新。文档更新是任务完成的必要条件,不是可选步骤。
|
||||
|
||||
***
|
||||
|
||||
## 9. 常见问题排查
|
||||
|
||||
1007
Cargo.lock
generated
1007
Cargo.lock
generated
File diff suppressed because it is too large
Load Diff
16
Cargo.toml
16
Cargo.toml
@@ -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" }
|
||||
|
||||
1
admin-v2/.env.development
Normal file
1
admin-v2/.env.development
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=http://localhost:8080/api/v1
|
||||
1
admin-v2/.env.production
Normal file
1
admin-v2/.env.production
Normal file
@@ -0,0 +1 @@
|
||||
VITE_API_BASE_URL=/api/v1
|
||||
24
admin-v2/.gitignore
vendored
Normal file
24
admin-v2/.gitignore
vendored
Normal 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
73
admin-v2/README.md
Normal 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
23
admin-v2/eslint.config.js
Normal 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
13
admin-v2/index.html
Normal 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
39
admin-v2/package.json
Normal 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
5008
admin-v2/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
1
admin-v2/public/favicon.svg
Normal file
1
admin-v2/public/favicon.svg
Normal file
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
24
admin-v2/public/icons.svg
Normal 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 |
BIN
admin-v2/src/assets/hero.png
Normal file
BIN
admin-v2/src/assets/hero.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
1
admin-v2/src/assets/react.svg
Normal file
1
admin-v2/src/assets/react.svg
Normal 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 |
1
admin-v2/src/assets/vite.svg
Normal file
1
admin-v2/src/assets/vite.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
53
admin-v2/src/components/ErrorBoundary.tsx
Normal file
53
admin-v2/src/components/ErrorBoundary.tsx
Normal 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
|
||||
}
|
||||
}
|
||||
103
admin-v2/src/layouts/AdminLayout.tsx
Normal file
103
admin-v2/src/layouts/AdminLayout.tsx
Normal 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
29
admin-v2/src/main.tsx
Normal 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>,
|
||||
)
|
||||
170
admin-v2/src/pages/Accounts.tsx
Normal file
170
admin-v2/src/pages/Accounts.tsx
Normal 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'
|
||||
190
admin-v2/src/pages/AgentTemplates.tsx
Normal file
190
admin-v2/src/pages/AgentTemplates.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
165
admin-v2/src/pages/ApiKeys.tsx
Normal file
165
admin-v2/src/pages/ApiKeys.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
110
admin-v2/src/pages/Config.tsx
Normal file
110
admin-v2/src/pages/Config.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
121
admin-v2/src/pages/Dashboard.tsx
Normal file
121
admin-v2/src/pages/Dashboard.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
138
admin-v2/src/pages/Login.tsx
Normal file
138
admin-v2/src/pages/Login.tsx
Normal 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
112
admin-v2/src/pages/Logs.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
186
admin-v2/src/pages/Models.tsx
Normal file
186
admin-v2/src/pages/Models.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
228
admin-v2/src/pages/Prompts.tsx
Normal file
228
admin-v2/src/pages/Prompts.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
188
admin-v2/src/pages/Providers.tsx
Normal file
188
admin-v2/src/pages/Providers.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
109
admin-v2/src/pages/Relay.tsx
Normal file
109
admin-v2/src/pages/Relay.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
120
admin-v2/src/pages/Usage.tsx
Normal file
120
admin-v2/src/pages/Usage.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
17
admin-v2/src/router/AuthGuard.tsx
Normal file
17
admin-v2/src/router/AuthGuard.tsx
Normal 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}</>
|
||||
}
|
||||
35
admin-v2/src/router/index.tsx
Normal file
35
admin-v2/src/router/index.tsx
Normal 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 })) },
|
||||
],
|
||||
},
|
||||
])
|
||||
16
admin-v2/src/services/accounts.ts
Normal file
16
admin-v2/src/services/accounts.ts
Normal 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),
|
||||
}
|
||||
28
admin-v2/src/services/agent-templates.ts
Normal file
28
admin-v2/src/services/agent-templates.ts
Normal 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),
|
||||
}
|
||||
13
admin-v2/src/services/api-keys.ts
Normal file
13
admin-v2/src/services/api-keys.ts
Normal 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),
|
||||
}
|
||||
10
admin-v2/src/services/auth.ts
Normal file
10
admin-v2/src/services/auth.ts
Normal 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),
|
||||
}
|
||||
11
admin-v2/src/services/config.ts
Normal file
11
admin-v2/src/services/config.ts
Normal 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),
|
||||
}
|
||||
7
admin-v2/src/services/logs.ts
Normal file
7
admin-v2/src/services/logs.ts
Normal 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),
|
||||
}
|
||||
16
admin-v2/src/services/models.ts
Normal file
16
admin-v2/src/services/models.ts
Normal 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),
|
||||
}
|
||||
35
admin-v2/src/services/prompts.ts
Normal file
35
admin-v2/src/services/prompts.ts
Normal 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),
|
||||
}
|
||||
31
admin-v2/src/services/providers.ts
Normal file
31
admin-v2/src/services/providers.ts
Normal 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),
|
||||
}
|
||||
10
admin-v2/src/services/relay.ts
Normal file
10
admin-v2/src/services/relay.ts
Normal 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),
|
||||
}
|
||||
127
admin-v2/src/services/request.ts
Normal file
127
admin-v2/src/services/request.ts
Normal 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
|
||||
}
|
||||
7
admin-v2/src/services/stats.ts
Normal file
7
admin-v2/src/services/stats.ts
Normal 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),
|
||||
}
|
||||
10
admin-v2/src/services/telemetry.ts
Normal file
10
admin-v2/src/services/telemetry.ts
Normal 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),
|
||||
}
|
||||
12
admin-v2/src/services/usage.ts
Normal file
12
admin-v2/src/services/usage.ts
Normal 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 || []),
|
||||
}
|
||||
96
admin-v2/src/stores/authStore.ts
Normal file
96
admin-v2/src/stores/authStore.ts
Normal file
@@ -0,0 +1,96 @@
|
||||
// ============================================================
|
||||
// ZCLAW Admin V2 — Zustand 认证状态管理
|
||||
// ============================================================
|
||||
//
|
||||
// 安全策略: JWT token 通过 HttpOnly cookie 传递,前端 JS 无法读取。
|
||||
// account 信息(显示名/角色)仍存 localStorage 用于页面刷新后恢复 UI。
|
||||
// 内存中的 token/refreshToken 仅用于 Authorization header fallback(API 客户端兼容)。
|
||||
|
||||
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 cookies(fire-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')
|
||||
},
|
||||
}
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
34
admin-v2/tsconfig.app.json
Normal file
34
admin-v2/tsconfig.app.json
Normal file
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Path alias */
|
||||
"baseUrl": ".",
|
||||
"paths": {
|
||||
"@/*": ["src/*"]
|
||||
},
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
admin-v2/tsconfig.json
Normal file
7
admin-v2/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
admin-v2/tsconfig.node.json
Normal file
26
admin-v2/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
38
admin-v2/vite.config.ts
Normal file
38
admin-v2/vite.config.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import path from 'path'
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, 'src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
// SSE relay 端点需要长超时(流式响应可持续数分钟)
|
||||
'/api/v1/relay/chat/completions': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
timeout: 600_000,
|
||||
proxyTimeout: 600_000,
|
||||
},
|
||||
'/api': {
|
||||
target: 'http://localhost:8080',
|
||||
changeOrigin: true,
|
||||
timeout: 30_000,
|
||||
proxyTimeout: 30_000,
|
||||
configure: (proxy) => {
|
||||
proxy.on('proxyReq', (proxyReq) => {
|
||||
proxyReq.setTimeout(30_000)
|
||||
})
|
||||
proxy.on('proxyRes', (proxyRes) => {
|
||||
proxyRes.setTimeout(30_000)
|
||||
})
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
2
admin/.gitignore
vendored
2
admin/.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
.next/
|
||||
node_modules/
|
||||
5
admin/next-env.d.ts
vendored
5
admin/next-env.d.ts
vendored
@@ -1,5 +0,0 @@
|
||||
/// <reference types="next" />
|
||||
/// <reference types="next/image-types/global" />
|
||||
|
||||
// NOTE: This file should not be edited
|
||||
// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information.
|
||||
@@ -1,4 +0,0 @@
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {}
|
||||
|
||||
module.exports = nextConfig
|
||||
@@ -1,37 +0,0 @@
|
||||
{
|
||||
"name": "zclaw-admin",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.14",
|
||||
"@radix-ui/react-select": "^2.2.5",
|
||||
"@radix-ui/react-switch": "^1.2.5",
|
||||
"@radix-ui/react-tabs": "^1.1.12",
|
||||
"@radix-ui/react-tooltip": "^1.2.7",
|
||||
"@radix-ui/react-separator": "^1.1.7",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.484.0",
|
||||
"next": "14.2.29",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1",
|
||||
"recharts": "^2.15.3",
|
||||
"tailwind-merge": "^3.0.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.17.19",
|
||||
"@types/react": "^18.3.18",
|
||||
"@types/react-dom": "^18.3.5",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"postcss": "^8.5.3",
|
||||
"tailwindcss": "^3.4.17",
|
||||
"typescript": "^5.7.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.30.2"
|
||||
}
|
||||
2171
admin/pnpm-lock.yaml
generated
2171
admin/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -1,6 +0,0 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
}
|
||||
@@ -1,393 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Ban,
|
||||
CheckCircle2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const roleLabels: Record<string, string> = {
|
||||
super_admin: '超级管理员',
|
||||
admin: '管理员',
|
||||
user: '普通用户',
|
||||
}
|
||||
|
||||
const statusColors: Record<string, 'success' | 'destructive' | 'warning'> = {
|
||||
active: 'success',
|
||||
disabled: 'destructive',
|
||||
suspended: 'warning',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
active: '正常',
|
||||
disabled: '已禁用',
|
||||
suspended: '已暂停',
|
||||
}
|
||||
|
||||
export default function AccountsPage() {
|
||||
const [accounts, setAccounts] = useState<AccountPublic[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [search, setSearch] = useState('')
|
||||
const [roleFilter, setRoleFilter] = useState<string>('all')
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 编辑 Dialog
|
||||
const [editTarget, setEditTarget] = useState<AccountPublic | null>(null)
|
||||
const [editForm, setEditForm] = useState({ display_name: '', email: '', role: 'user' })
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
|
||||
// 确认 Dialog
|
||||
const [confirmTarget, setConfirmTarget] = useState<{ id: string; action: string; status: string } | null>(null)
|
||||
const [confirmSaving, setConfirmSaving] = useState(false)
|
||||
|
||||
const fetchAccounts = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (search.trim()) params.search = search.trim()
|
||||
if (roleFilter !== 'all') params.role = roleFilter
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
|
||||
const res = await api.accounts.list(params)
|
||||
setAccounts(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
} else {
|
||||
setError('加载失败')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, search, roleFilter, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchAccounts()
|
||||
}, [fetchAccounts])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function openEditDialog(account: AccountPublic) {
|
||||
setEditTarget(account)
|
||||
setEditForm({
|
||||
display_name: account.display_name,
|
||||
email: account.email,
|
||||
role: account.role,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleEditSave() {
|
||||
if (!editTarget) return
|
||||
setEditSaving(true)
|
||||
try {
|
||||
await api.accounts.update(editTarget.id, {
|
||||
display_name: editForm.display_name,
|
||||
email: editForm.email,
|
||||
role: editForm.role as AccountPublic['role'],
|
||||
})
|
||||
setEditTarget(null)
|
||||
fetchAccounts()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
}
|
||||
} finally {
|
||||
setEditSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function openConfirmDialog(account: AccountPublic) {
|
||||
const newStatus = account.status === 'active' ? 'disabled' : 'active'
|
||||
setConfirmTarget({
|
||||
id: account.id,
|
||||
action: newStatus === 'disabled' ? '禁用' : '启用',
|
||||
status: newStatus,
|
||||
})
|
||||
}
|
||||
|
||||
async function handleConfirmSave() {
|
||||
if (!confirmTarget) return
|
||||
setConfirmSaving(true)
|
||||
try {
|
||||
await api.accounts.updateStatus(confirmTarget.id, {
|
||||
status: confirmTarget.status as AccountPublic['status'],
|
||||
})
|
||||
setConfirmTarget(null)
|
||||
fetchAccounts()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message)
|
||||
}
|
||||
} finally {
|
||||
setConfirmSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 搜索和筛选 */}
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="relative flex-1 min-w-[200px] max-w-sm">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索用户名 / 邮箱 / 显示名..."
|
||||
value={search}
|
||||
onChange={(e) => { setSearch(e.target.value); setPage(1) }}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
<Select value={roleFilter} onValueChange={(v) => { setRoleFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="角色筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部角色</SelectItem>
|
||||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="active">正常</SelectItem>
|
||||
<SelectItem value="disabled">已禁用</SelectItem>
|
||||
<SelectItem value="suspended">已暂停</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 错误提示 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">
|
||||
关闭
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 表格 */}
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : accounts.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>用户名</TableHead>
|
||||
<TableHead>邮箱</TableHead>
|
||||
<TableHead>显示名</TableHead>
|
||||
<TableHead>角色</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{accounts.map((account) => (
|
||||
<TableRow key={account.id}>
|
||||
<TableCell className="font-medium">{account.username}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{account.email}</TableCell>
|
||||
<TableCell>{account.display_name || '-'}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={account.role === 'super_admin' ? 'default' : account.role === 'admin' ? 'info' : 'secondary'}>
|
||||
{roleLabels[account.role] || account.role}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusColors[account.status] || 'secondary'}>
|
||||
<span className="mr-1 inline-block h-1.5 w-1.5 rounded-full bg-current" />
|
||||
{statusLabels[account.status] || account.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(account.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openEditDialog(account)}
|
||||
title="编辑"
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => openConfirmDialog(account)}
|
||||
title={account.status === 'active' ? '禁用' : '启用'}
|
||||
>
|
||||
{account.status === 'active' ? (
|
||||
<Ban className="h-4 w-4 text-destructive" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-4 w-4 text-green-400" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
{/* 分页 */}
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page <= 1}
|
||||
onClick={() => setPage(page - 1)}
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={page >= totalPages}
|
||||
onClick={() => setPage(page + 1)}
|
||||
>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 编辑 Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑账号</DialogTitle>
|
||||
<DialogDescription>修改账号信息</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>显示名</Label>
|
||||
<Input
|
||||
value={editForm.display_name}
|
||||
onChange={(e) => setEditForm({ ...editForm, display_name: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>邮箱</Label>
|
||||
<Input
|
||||
type="email"
|
||||
value={editForm.email}
|
||||
onChange={(e) => setEditForm({ ...editForm, email: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>角色</Label>
|
||||
<Select value={editForm.role} onValueChange={(v) => setEditForm({ ...editForm, role: v })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="user">普通用户</SelectItem>
|
||||
<SelectItem value="admin">管理员</SelectItem>
|
||||
<SelectItem value="super_admin">超级管理员</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleEditSave} disabled={editSaving}>
|
||||
{editSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 确认 Dialog */}
|
||||
<Dialog open={!!confirmTarget} onOpenChange={() => setConfirmTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认{confirmTarget?.action}</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要{confirmTarget?.action}该账号吗?此操作将立即生效。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setConfirmTarget(null)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant={confirmTarget?.status === 'disabled' ? 'destructive' : 'default'}
|
||||
onClick={handleConfirmSave}
|
||||
disabled={confirmSaving}
|
||||
>
|
||||
{confirmSaving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
确认{confirmTarget?.action}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,351 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Trash2,
|
||||
Copy,
|
||||
Check,
|
||||
AlertTriangle,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate } from '@/lib/utils'
|
||||
import type { TokenInfo } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const allPermissions = [
|
||||
{ key: 'chat', label: '对话' },
|
||||
{ key: 'relay', label: '中转' },
|
||||
{ key: 'admin', label: '管理' },
|
||||
]
|
||||
|
||||
export default function ApiKeysPage() {
|
||||
const [tokens, setTokens] = useState<TokenInfo[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 创建 Dialog
|
||||
const [createOpen, setCreateOpen] = useState(false)
|
||||
const [createForm, setCreateForm] = useState({ name: '', expires_days: '', permissions: ['chat'] as string[] })
|
||||
const [creating, setCreating] = useState(false)
|
||||
|
||||
// 创建成功显示 token
|
||||
const [createdToken, setCreatedToken] = useState<TokenInfo | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
|
||||
// 撤销确认
|
||||
const [revokeTarget, setRevokeTarget] = useState<TokenInfo | null>(null)
|
||||
const [revoking, setRevoking] = useState(false)
|
||||
|
||||
const fetchTokens = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.tokens.list({ page, page_size: PAGE_SIZE })
|
||||
setTokens(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTokens()
|
||||
}, [fetchTokens])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function togglePermission(perm: string) {
|
||||
setCreateForm((prev) => ({
|
||||
...prev,
|
||||
permissions: prev.permissions.includes(perm)
|
||||
? prev.permissions.filter((p) => p !== perm)
|
||||
: [...prev.permissions, perm],
|
||||
}))
|
||||
}
|
||||
|
||||
async function handleCreate() {
|
||||
if (!createForm.name.trim() || createForm.permissions.length === 0) return
|
||||
setCreating(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: createForm.name.trim(),
|
||||
expires_days: createForm.expires_days ? parseInt(createForm.expires_days, 10) : undefined,
|
||||
permissions: createForm.permissions,
|
||||
}
|
||||
const res = await api.tokens.create(payload)
|
||||
setCreateOpen(false)
|
||||
setCreatedToken(res)
|
||||
setCreateForm({ name: '', expires_days: '', permissions: ['chat'] })
|
||||
fetchTokens()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleRevoke() {
|
||||
if (!revokeTarget) return
|
||||
setRevoking(true)
|
||||
try {
|
||||
await api.tokens.revoke(revokeTarget.id)
|
||||
setRevokeTarget(null)
|
||||
fetchTokens()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setRevoking(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function copyToken() {
|
||||
if (!createdToken?.token) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(createdToken.token)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} catch {
|
||||
// Fallback
|
||||
const textarea = document.createElement('textarea')
|
||||
textarea.value = createdToken.token
|
||||
document.body.appendChild(textarea)
|
||||
textarea.select()
|
||||
document.execCommand('copy')
|
||||
document.body.removeChild(textarea)
|
||||
setCopied(true)
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<Button onClick={() => setCreateOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建密钥
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tokens.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>前缀</TableHead>
|
||||
<TableHead>权限</TableHead>
|
||||
<TableHead>最后使用</TableHead>
|
||||
<TableHead>过期时间</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tokens.map((t) => (
|
||||
<TableRow key={t.id}>
|
||||
<TableCell className="font-medium">{t.name}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.token_prefix}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex gap-1">
|
||||
{t.permissions.map((p) => (
|
||||
<Badge key={p} variant="outline" className="text-xs">
|
||||
{p}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.last_used_at ? formatDate(t.last_used_at) : '未使用'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{t.expires_at ? formatDate(t.expires_at) : '永不过期'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(t.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => setRevokeTarget(t)} title="撤销">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建 Dialog */}
|
||||
<Dialog open={createOpen} onOpenChange={setCreateOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>新建 API 密钥</DialogTitle>
|
||||
<DialogDescription>创建新的 API 密钥用于接口调用</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={createForm.name}
|
||||
onChange={(e) => setCreateForm({ ...createForm, name: e.target.value })}
|
||||
placeholder="例如: 生产环境"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>过期天数 (留空则永不过期)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={createForm.expires_days}
|
||||
onChange={(e) => setCreateForm({ ...createForm, expires_days: e.target.value })}
|
||||
placeholder="365"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>权限 *</Label>
|
||||
<div className="flex flex-wrap gap-3 mt-1">
|
||||
{allPermissions.map((perm) => (
|
||||
<label
|
||||
key={perm.key}
|
||||
className="flex items-center gap-2 cursor-pointer"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={createForm.permissions.includes(perm.key)}
|
||||
onChange={() => togglePermission(perm.key)}
|
||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||
/>
|
||||
<span className="text-sm text-foreground">{perm.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setCreateOpen(false)}>取消</Button>
|
||||
<Button onClick={handleCreate} disabled={creating || !createForm.name.trim() || createForm.permissions.length === 0}>
|
||||
{creating && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
创建
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 创建成功 Dialog */}
|
||||
<Dialog open={!!createdToken} onOpenChange={() => setCreatedToken(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<AlertTriangle className="h-5 w-5 text-yellow-400" />
|
||||
密钥已创建
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
请立即复制并安全保存此密钥,关闭后将无法再次查看完整密钥。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-md bg-muted p-4">
|
||||
<p className="text-xs text-muted-foreground mb-2">完整密钥</p>
|
||||
<p className="font-mono text-sm break-all text-foreground">
|
||||
{createdToken?.token}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-md bg-yellow-500/10 border border-yellow-500/20 p-3 text-sm text-yellow-400">
|
||||
此密钥仅显示一次。请确保已保存到安全的位置。
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button onClick={copyToken} variant="outline">
|
||||
{copied ? <Check className="h-4 w-4 mr-2" /> : <Copy className="h-4 w-4 mr-2" />}
|
||||
{copied ? '已复制' : '复制密钥'}
|
||||
</Button>
|
||||
<Button onClick={() => setCreatedToken(null)}>我已保存</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 撤销确认 */}
|
||||
<Dialog open={!!revokeTarget} onOpenChange={() => setRevokeTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认撤销</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要撤销密钥 "{revokeTarget?.name}" 吗?使用此密钥的应用将立即失去访问权限。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRevokeTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleRevoke} disabled={revoking}>
|
||||
{revoking && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
撤销
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,270 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Loader2,
|
||||
Pencil,
|
||||
RotateCcw,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import type { ConfigItem } from '@/lib/types'
|
||||
|
||||
const sourceLabels: Record<string, string> = {
|
||||
default: '默认值',
|
||||
env: '环境变量',
|
||||
db: '数据库',
|
||||
}
|
||||
|
||||
const sourceVariants: Record<string, 'secondary' | 'info' | 'default'> = {
|
||||
default: 'secondary',
|
||||
env: 'info',
|
||||
db: 'default',
|
||||
}
|
||||
|
||||
export default function ConfigPage() {
|
||||
const [configs, setConfigs] = useState<ConfigItem[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [activeTab, setActiveTab] = useState('all')
|
||||
|
||||
// 编辑 Dialog
|
||||
const [editTarget, setEditTarget] = useState<ConfigItem | null>(null)
|
||||
const [editValue, setEditValue] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
const fetchConfigs = useCallback(async (category?: string) => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = {}
|
||||
if (category && category !== 'all') params.category = category
|
||||
const res = await api.config.list(params)
|
||||
setConfigs(res)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchConfigs(activeTab)
|
||||
}, [fetchConfigs, activeTab])
|
||||
|
||||
function openEditDialog(config: ConfigItem) {
|
||||
setEditTarget(config)
|
||||
setEditValue(config.current_value !== undefined ? String(config.current_value) : '')
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!editTarget) return
|
||||
setSaving(true)
|
||||
try {
|
||||
let parsedValue: string | number | boolean = editValue
|
||||
if (editTarget.value_type === 'number') {
|
||||
parsedValue = parseFloat(editValue) || 0
|
||||
} else if (editTarget.value_type === 'boolean') {
|
||||
parsedValue = editValue === 'true'
|
||||
}
|
||||
await api.config.update(editTarget.id, { value: parsedValue })
|
||||
setEditTarget(null)
|
||||
fetchConfigs(activeTab)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
function formatValue(value: unknown): string {
|
||||
if (value === undefined || value === null) return '-'
|
||||
if (typeof value === 'boolean') return value ? 'true' : 'false'
|
||||
return String(value)
|
||||
}
|
||||
|
||||
const categories = ['all', 'auth', 'relay', 'model', 'system']
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 分类 Tabs */}
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
{categories.map((cat) => (
|
||||
<TabsTrigger key={cat} value={cat}>
|
||||
{cat === 'all' ? '全部' : cat}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : configs.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无配置项
|
||||
</div>
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>分类</TableHead>
|
||||
<TableHead>Key</TableHead>
|
||||
<TableHead>当前值</TableHead>
|
||||
<TableHead>默认值</TableHead>
|
||||
<TableHead>来源</TableHead>
|
||||
<TableHead>需重启</TableHead>
|
||||
<TableHead>描述</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{configs.map((config) => (
|
||||
<TableRow key={config.id}>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{config.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-sm">{config.key_path}</TableCell>
|
||||
<TableCell className="font-mono text-sm max-w-[200px] truncate">
|
||||
{formatValue(config.current_value)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{formatValue(config.default_value)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={sourceVariants[config.source] || 'secondary'}>
|
||||
{sourceLabels[config.source] || config.source}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{config.requires_restart ? (
|
||||
<Badge variant="warning">是</Badge>
|
||||
) : (
|
||||
<span className="text-muted-foreground">否</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-sm text-muted-foreground max-w-[250px] truncate">
|
||||
{config.description || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(config)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
|
||||
{/* 编辑 Dialog */}
|
||||
<Dialog open={!!editTarget} onOpenChange={() => setEditTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑配置</DialogTitle>
|
||||
<DialogDescription>
|
||||
修改 {editTarget?.key_path} 的值
|
||||
{editTarget?.requires_restart && (
|
||||
<span className="block mt-1 text-yellow-400 text-xs">
|
||||
注意: 修改此配置需要重启服务才能生效
|
||||
</span>
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Key</Label>
|
||||
<Input value={editTarget?.key_path || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>类型</Label>
|
||||
<Input value={editTarget?.value_type || ''} disabled />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>
|
||||
新值 {editTarget?.default_value !== undefined && (
|
||||
<span className="text-xs text-muted-foreground ml-2">
|
||||
(默认: {formatValue(editTarget.default_value)})
|
||||
</span>
|
||||
)}
|
||||
</Label>
|
||||
{editTarget?.value_type === 'boolean' ? (
|
||||
<Select value={editValue} onValueChange={setEditValue}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="true">true</SelectItem>
|
||||
<SelectItem value="false">false</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
<Input
|
||||
type={editTarget?.value_type === 'number' ? 'number' : 'text'}
|
||||
value={editValue}
|
||||
onChange={(e) => setEditValue(e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (editTarget?.default_value !== undefined) {
|
||||
setEditValue(String(editTarget.default_value))
|
||||
}
|
||||
}}
|
||||
>
|
||||
<RotateCcw className="h-4 w-4 mr-2" />
|
||||
恢复默认
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setEditTarget(null)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type ReactNode } from 'react'
|
||||
import Link from 'next/link'
|
||||
import { usePathname, useRouter } from 'next/navigation'
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Users,
|
||||
Server,
|
||||
Cpu,
|
||||
Key,
|
||||
BarChart3,
|
||||
ArrowLeftRight,
|
||||
Settings,
|
||||
FileText,
|
||||
LogOut,
|
||||
ChevronLeft,
|
||||
Menu,
|
||||
Bell,
|
||||
} from 'lucide-react'
|
||||
import { AuthGuard, useAuth } from '@/components/auth-guard'
|
||||
import { logout } from '@/lib/auth'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navItems = [
|
||||
{ href: '/', label: '仪表盘', icon: LayoutDashboard },
|
||||
{ href: '/accounts', label: '账号管理', icon: Users },
|
||||
{ href: '/providers', label: '服务商', icon: Server },
|
||||
{ href: '/models', label: '模型管理', icon: Cpu },
|
||||
{ href: '/api-keys', label: 'API 密钥', icon: Key },
|
||||
{ href: '/usage', label: '用量统计', icon: BarChart3 },
|
||||
{ href: '/relay', label: '中转任务', icon: ArrowLeftRight },
|
||||
{ href: '/config', label: '系统配置', icon: Settings },
|
||||
{ href: '/logs', label: '操作日志', icon: FileText },
|
||||
]
|
||||
|
||||
function Sidebar({
|
||||
collapsed,
|
||||
onToggle,
|
||||
}: {
|
||||
collapsed: boolean
|
||||
onToggle: () => void
|
||||
}) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const { account } = useAuth()
|
||||
|
||||
function handleLogout() {
|
||||
logout()
|
||||
router.replace('/login')
|
||||
}
|
||||
|
||||
return (
|
||||
<aside
|
||||
className={cn(
|
||||
'fixed left-0 top-0 z-40 flex h-screen flex-col border-r border-border bg-card transition-all duration-300',
|
||||
collapsed ? 'w-16' : 'w-64',
|
||||
)}
|
||||
>
|
||||
{/* Logo */}
|
||||
<div className="flex h-14 items-center border-b border-border px-4">
|
||||
<Link href="/" className="flex items-center gap-2 cursor-pointer">
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-md bg-primary text-primary-foreground font-bold text-sm">
|
||||
Z
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="flex flex-col">
|
||||
<span className="text-sm font-bold text-foreground">ZCLAW</span>
|
||||
<span className="text-[10px] text-muted-foreground">Admin</span>
|
||||
</div>
|
||||
)}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* 导航 */}
|
||||
<nav className="flex-1 overflow-y-auto scrollbar-thin py-2 px-2">
|
||||
<ul className="space-y-1">
|
||||
{navItems.map((item) => {
|
||||
const isActive =
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href)
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<li key={item.href}>
|
||||
<Link
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors duration-200 cursor-pointer',
|
||||
isActive
|
||||
? 'bg-muted text-green-400'
|
||||
: 'text-muted-foreground hover:bg-muted hover:text-foreground',
|
||||
collapsed && 'justify-center px-2',
|
||||
)}
|
||||
title={collapsed ? item.label : undefined}
|
||||
>
|
||||
<Icon className="h-4 w-4 shrink-0" />
|
||||
{!collapsed && <span>{item.label}</span>}
|
||||
</Link>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
</nav>
|
||||
|
||||
{/* 底部折叠按钮 */}
|
||||
<div className="border-t border-border p-2">
|
||||
<button
|
||||
onClick={onToggle}
|
||||
className="flex w-full items-center justify-center rounded-md px-3 py-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
<ChevronLeft
|
||||
className={cn(
|
||||
'h-4 w-4 transition-transform duration-200',
|
||||
collapsed && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* 用户信息 */}
|
||||
{!collapsed && (
|
||||
<div className="border-t border-border p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted text-xs font-medium text-foreground">
|
||||
{account?.display_name?.[0] || account?.username?.[0] || 'A'}
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="truncate text-sm font-medium text-foreground">
|
||||
{account?.display_name || account?.username || 'Admin'}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{account?.role || 'admin'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="rounded-md p-1.5 text-muted-foreground hover:bg-muted hover:text-destructive transition-colors duration-200 cursor-pointer"
|
||||
title="退出登录"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
)
|
||||
}
|
||||
|
||||
function Header() {
|
||||
const pathname = usePathname()
|
||||
const currentNav = navItems.find(
|
||||
(item) =>
|
||||
item.href === '/'
|
||||
? pathname === '/'
|
||||
: pathname.startsWith(item.href),
|
||||
)
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-30 flex h-14 items-center border-b border-border bg-background/80 backdrop-blur-sm px-6">
|
||||
{/* 移动端菜单按钮 */}
|
||||
<MobileMenuButton />
|
||||
|
||||
{/* 页面标题 */}
|
||||
<h1 className="text-lg font-semibold text-foreground">
|
||||
{currentNav?.label || '仪表盘'}
|
||||
</h1>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
{/* 通知 */}
|
||||
<button
|
||||
className="relative rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
title="通知"
|
||||
>
|
||||
<Bell className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
)
|
||||
}
|
||||
|
||||
function MobileMenuButton() {
|
||||
// Placeholder for mobile menu toggle
|
||||
return (
|
||||
<button
|
||||
className="mr-3 rounded-md p-2 text-muted-foreground hover:bg-muted hover:text-foreground transition-colors duration-200 lg:hidden cursor-pointer"
|
||||
>
|
||||
<Menu className="h-5 w-5" />
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardLayout({ children }: { children: ReactNode }) {
|
||||
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||
|
||||
return (
|
||||
<AuthGuard>
|
||||
<div className="flex min-h-screen">
|
||||
<Sidebar
|
||||
collapsed={sidebarCollapsed}
|
||||
onToggle={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-1 flex-col transition-all duration-300',
|
||||
sidebarCollapsed ? 'ml-16' : 'ml-64',
|
||||
)}
|
||||
>
|
||||
<Header />
|
||||
<main className="flex-1 overflow-auto p-6 scrollbar-thin">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
</AuthGuard>
|
||||
)
|
||||
}
|
||||
@@ -1,436 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { Model, Provider } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface ModelForm {
|
||||
provider_id: string
|
||||
model_id: string
|
||||
alias: string
|
||||
context_window: string
|
||||
max_output_tokens: string
|
||||
supports_streaming: boolean
|
||||
supports_vision: boolean
|
||||
enabled: boolean
|
||||
pricing_input: string
|
||||
pricing_output: string
|
||||
}
|
||||
|
||||
const emptyForm: ModelForm = {
|
||||
provider_id: '',
|
||||
model_id: '',
|
||||
alias: '',
|
||||
context_window: '4096',
|
||||
max_output_tokens: '4096',
|
||||
supports_streaming: true,
|
||||
supports_vision: false,
|
||||
enabled: true,
|
||||
pricing_input: '',
|
||||
pricing_output: '',
|
||||
}
|
||||
|
||||
export default function ModelsPage() {
|
||||
const [models, setModels] = useState<Model[]>([])
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [providerFilter, setProviderFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// Dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<Model | null>(null)
|
||||
const [form, setForm] = useState<ModelForm>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除
|
||||
const [deleteTarget, setDeleteTarget] = useState<Model | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchModels = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (providerFilter !== 'all') params.provider_id = providerFilter
|
||||
const res = await api.models.list(params)
|
||||
setModels(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, providerFilter])
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
try {
|
||||
const res = await api.providers.list({ page: 1, page_size: 100 })
|
||||
setProviders(res.items)
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
fetchModels()
|
||||
fetchProviders()
|
||||
}, [fetchModels, fetchProviders])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
const providerMap = new Map(providers.map((p) => [p.id, p.display_name || p.name]))
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditTarget(null)
|
||||
setForm(emptyForm)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(model: Model) {
|
||||
setEditTarget(model)
|
||||
setForm({
|
||||
provider_id: model.provider_id,
|
||||
model_id: model.model_id,
|
||||
alias: model.alias,
|
||||
context_window: model.context_window.toString(),
|
||||
max_output_tokens: model.max_output_tokens.toString(),
|
||||
supports_streaming: model.supports_streaming,
|
||||
supports_vision: model.supports_vision,
|
||||
enabled: model.enabled,
|
||||
pricing_input: model.pricing_input.toString(),
|
||||
pricing_output: model.pricing_output.toString(),
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.model_id.trim() || !form.provider_id) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
provider_id: form.provider_id,
|
||||
model_id: form.model_id.trim(),
|
||||
alias: form.alias.trim(),
|
||||
context_window: parseInt(form.context_window, 10) || 4096,
|
||||
max_output_tokens: parseInt(form.max_output_tokens, 10) || 4096,
|
||||
supports_streaming: form.supports_streaming,
|
||||
supports_vision: form.supports_vision,
|
||||
enabled: form.enabled,
|
||||
pricing_input: parseFloat(form.pricing_input) || 0,
|
||||
pricing_output: parseFloat(form.pricing_output) || 0,
|
||||
}
|
||||
if (editTarget) {
|
||||
await api.models.update(editTarget.id, payload)
|
||||
} else {
|
||||
await api.models.create(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchModels()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.models.delete(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
fetchModels()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<Select value={providerFilter} onValueChange={(v) => { setProviderFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[200px]">
|
||||
<SelectValue placeholder="按服务商筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部服务商</SelectItem>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建模型
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : models.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>模型 ID</TableHead>
|
||||
<TableHead>别名</TableHead>
|
||||
<TableHead>服务商</TableHead>
|
||||
<TableHead>上下文窗口</TableHead>
|
||||
<TableHead>最大输出</TableHead>
|
||||
<TableHead>流式</TableHead>
|
||||
<TableHead>视觉</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.map((m) => (
|
||||
<TableRow key={m.id}>
|
||||
<TableCell className="font-mono text-sm">{m.model_id}</TableCell>
|
||||
<TableCell>{m.alias || '-'}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{providerMap.get(m.provider_id) || m.provider_id.slice(0, 8)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.context_window)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(m.max_output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_streaming ? 'success' : 'secondary'}>
|
||||
{m.supports_streaming ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.supports_vision ? 'success' : 'secondary'}>
|
||||
{m.supports_vision ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={m.enabled ? 'success' : 'destructive'}>
|
||||
{m.enabled ? '启用' : '禁用'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(m)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(m)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑模型' : '新建模型'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改模型配置' : '添加新的 AI 模型'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>服务商 *</Label>
|
||||
<Select value={form.provider_id} onValueChange={(v) => setForm({ ...form, provider_id: v })} disabled={!!editTarget}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择服务商" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{providers.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.display_name || p.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>模型 ID *</Label>
|
||||
<Input
|
||||
value={form.model_id}
|
||||
onChange={(e) => setForm({ ...form, model_id: e.target.value })}
|
||||
placeholder="gpt-4o"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>别名</Label>
|
||||
<Input
|
||||
value={form.alias}
|
||||
onChange={(e) => setForm({ ...form, alias: e.target.value })}
|
||||
placeholder="GPT-4o"
|
||||
/>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>上下文窗口</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.context_window}
|
||||
onChange={(e) => setForm({ ...form, context_window: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>最大输出 Tokens</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.max_output_tokens}
|
||||
onChange={(e) => setForm({ ...form, max_output_tokens: e.target.value })}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>Input 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_input}
|
||||
onChange={(e) => setForm({ ...form, pricing_input: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Output 定价 ($/1M tokens)</Label>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={form.pricing_output}
|
||||
onChange={(e) => setForm({ ...form, pricing_output: e.target.value })}
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_streaming} onCheckedChange={(v) => setForm({ ...form, supports_streaming: v })} />
|
||||
<Label>流式</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.supports_vision} onCheckedChange={(v) => setForm({ ...form, supports_vision: v })} />
|
||||
<Label>视觉</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Switch checked={form.enabled} onCheckedChange={(v) => setForm({ ...form, enabled: v })} />
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.model_id.trim() || !form.provider_id}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除模型 "{deleteTarget?.alias || deleteTarget?.model_id}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,336 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
Users,
|
||||
Server,
|
||||
ArrowLeftRight,
|
||||
Zap,
|
||||
Loader2,
|
||||
TrendingUp,
|
||||
} from 'lucide-react'
|
||||
import {
|
||||
AreaChart,
|
||||
Area,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { formatNumber, formatDate } from '@/lib/utils'
|
||||
import type {
|
||||
DashboardStats,
|
||||
UsageRecord,
|
||||
OperationLog,
|
||||
} from '@/lib/types'
|
||||
|
||||
interface StatCardProps {
|
||||
title: string
|
||||
value: string | number
|
||||
icon: React.ReactNode
|
||||
color: string
|
||||
subtitle?: string
|
||||
}
|
||||
|
||||
function StatCard({ title, value, icon, color, subtitle }: StatCardProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">{title}</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">{value}</p>
|
||||
{subtitle && (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-lg ${color}`}
|
||||
>
|
||||
{icon}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const variantMap: Record<string, 'success' | 'destructive' | 'warning' | 'info' | 'secondary'> = {
|
||||
active: 'success',
|
||||
completed: 'success',
|
||||
disabled: 'destructive',
|
||||
failed: 'destructive',
|
||||
processing: 'info',
|
||||
queued: 'warning',
|
||||
suspended: 'destructive',
|
||||
}
|
||||
return (
|
||||
<Badge variant={variantMap[status] || 'secondary'}>{status}</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
export default function DashboardPage() {
|
||||
const [stats, setStats] = useState<DashboardStats | null>(null)
|
||||
const [usageData, setUsageData] = useState<UsageRecord[]>([])
|
||||
const [recentLogs, setRecentLogs] = useState<OperationLog[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
async function fetchData() {
|
||||
try {
|
||||
const [statsRes, usageRes, logsRes] = await Promise.allSettled([
|
||||
api.stats.dashboard(),
|
||||
api.usage.daily({ days: 30 }),
|
||||
api.logs.list({ page: 1, page_size: 5 }),
|
||||
])
|
||||
|
||||
if (statsRes.status === 'fulfilled') setStats(statsRes.value)
|
||||
if (usageRes.status === 'fulfilled') setUsageData(usageRes.value)
|
||||
if (logsRes.status === 'fulfilled') setRecentLogs(logsRes.value.items)
|
||||
} catch (err) {
|
||||
setError('加载数据失败,请检查后端服务是否启动')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
fetchData()
|
||||
}, [])
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button
|
||||
onClick={() => window.location.reload()}
|
||||
className="mt-4 text-sm text-primary hover:underline cursor-pointer"
|
||||
>
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const chartData = usageData.map((r) => ({
|
||||
day: r.day.slice(5), // MM-DD
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 统计卡片 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
title="总账号数"
|
||||
value={stats?.total_accounts ?? '-'}
|
||||
icon={<Users className="h-5 w-5 text-blue-400" />}
|
||||
color="bg-blue-500/10"
|
||||
subtitle={`活跃 ${stats?.active_accounts ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="活跃服务商"
|
||||
value={stats?.active_providers ?? '-'}
|
||||
icon={<Server className="h-5 w-5 text-green-400" />}
|
||||
color="bg-green-500/10"
|
||||
subtitle={`模型 ${stats?.active_models ?? 0}`}
|
||||
/>
|
||||
<StatCard
|
||||
title="今日请求"
|
||||
value={stats?.tasks_today ?? '-'}
|
||||
icon={<ArrowLeftRight className="h-5 w-5 text-purple-400" />}
|
||||
color="bg-purple-500/10"
|
||||
subtitle="中转任务"
|
||||
/>
|
||||
<StatCard
|
||||
title="今日 Token"
|
||||
value={formatNumber((stats?.tokens_today_input ?? 0) + (stats?.tokens_today_output ?? 0))}
|
||||
icon={<Zap className="h-5 w-5 text-orange-400" />}
|
||||
color="bg-orange-500/10"
|
||||
subtitle={`In: ${formatNumber(stats?.tokens_today_input ?? 0)} / Out: ${formatNumber(stats?.tokens_today_output ?? 0)}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 图表 */}
|
||||
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
|
||||
{/* 请求趋势 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<TrendingUp className="h-4 w-4 text-primary" />
|
||||
请求趋势 (30 天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<AreaChart data={chartData}>
|
||||
<defs>
|
||||
<linearGradient id="colorRequests" x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="#22C55E" stopOpacity={0.3} />
|
||||
<stop offset="95%" stopColor="#22C55E" stopOpacity={0} />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
dataKey="请求量"
|
||||
stroke="#22C55E"
|
||||
fillOpacity={1}
|
||||
fill="url(#colorRequests)"
|
||||
strokeWidth={2}
|
||||
/>
|
||||
</AreaChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Token 用量 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-orange-400" />
|
||||
Token 用量 (30 天)
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{chartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={280}>
|
||||
<BarChart data={chartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend
|
||||
wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }}
|
||||
/>
|
||||
<Bar dataKey="Input" fill="#3B82F6" radius={[2, 2, 0, 0]} />
|
||||
<Bar dataKey="Output" fill="#F97316" radius={[2, 2, 0, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[280px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 最近操作日志 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">最近操作</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{recentLogs.length > 0 ? (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>时间</TableHead>
|
||||
<TableHead>账号 ID</TableHead>
|
||||
<TableHead>操作</TableHead>
|
||||
<TableHead>目标类型</TableHead>
|
||||
<TableHead>目标 ID</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{recentLogs.map((log) => (
|
||||
<TableRow key={log.id}>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(log.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{log.account_id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{log.action}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{log.target_type}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{log.target_id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
) : (
|
||||
<div className="flex h-32 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无操作日志
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,386 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Plus,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Pencil,
|
||||
Trash2,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Switch } from '@/components/ui/switch'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, maskApiKey } from '@/lib/utils'
|
||||
import type { Provider } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
interface ProviderForm {
|
||||
name: string
|
||||
display_name: string
|
||||
base_url: string
|
||||
api_protocol: 'openai' | 'anthropic'
|
||||
api_key: string
|
||||
enabled: boolean
|
||||
rate_limit_rpm: string
|
||||
rate_limit_tpm: string
|
||||
}
|
||||
|
||||
const emptyForm: ProviderForm = {
|
||||
name: '',
|
||||
display_name: '',
|
||||
base_url: '',
|
||||
api_protocol: 'openai',
|
||||
api_key: '',
|
||||
enabled: true,
|
||||
rate_limit_rpm: '',
|
||||
rate_limit_tpm: '',
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
const [providers, setProviders] = useState<Provider[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
// 创建/编辑 Dialog
|
||||
const [dialogOpen, setDialogOpen] = useState(false)
|
||||
const [editTarget, setEditTarget] = useState<Provider | null>(null)
|
||||
const [form, setForm] = useState<ProviderForm>(emptyForm)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// 删除确认 Dialog
|
||||
const [deleteTarget, setDeleteTarget] = useState<Provider | null>(null)
|
||||
const [deleting, setDeleting] = useState(false)
|
||||
|
||||
const fetchProviders = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.providers.list({ page, page_size: PAGE_SIZE })
|
||||
setProviders(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page])
|
||||
|
||||
useEffect(() => {
|
||||
fetchProviders()
|
||||
}, [fetchProviders])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function openCreateDialog() {
|
||||
setEditTarget(null)
|
||||
setForm(emptyForm)
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
function openEditDialog(provider: Provider) {
|
||||
setEditTarget(provider)
|
||||
setForm({
|
||||
name: provider.name,
|
||||
display_name: provider.display_name,
|
||||
base_url: provider.base_url,
|
||||
api_protocol: provider.api_protocol,
|
||||
api_key: provider.api_key || '',
|
||||
enabled: provider.enabled,
|
||||
rate_limit_rpm: provider.rate_limit_rpm?.toString() || '',
|
||||
rate_limit_tpm: provider.rate_limit_tpm?.toString() || '',
|
||||
})
|
||||
setDialogOpen(true)
|
||||
}
|
||||
|
||||
async function handleSave() {
|
||||
if (!form.name.trim() || !form.base_url.trim()) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const payload = {
|
||||
name: form.name.trim(),
|
||||
display_name: form.display_name.trim(),
|
||||
base_url: form.base_url.trim(),
|
||||
api_protocol: form.api_protocol,
|
||||
api_key: form.api_key.trim() || undefined,
|
||||
enabled: form.enabled,
|
||||
rate_limit_rpm: form.rate_limit_rpm ? parseInt(form.rate_limit_rpm, 10) : undefined,
|
||||
rate_limit_tpm: form.rate_limit_tpm ? parseInt(form.rate_limit_tpm, 10) : undefined,
|
||||
}
|
||||
if (editTarget) {
|
||||
await api.providers.update(editTarget.id, payload)
|
||||
} else {
|
||||
await api.providers.create(payload)
|
||||
}
|
||||
setDialogOpen(false)
|
||||
fetchProviders()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!deleteTarget) return
|
||||
setDeleting(true)
|
||||
try {
|
||||
await api.providers.delete(deleteTarget.id)
|
||||
setDeleteTarget(null)
|
||||
fetchProviders()
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
} finally {
|
||||
setDeleting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div />
|
||||
<Button onClick={openCreateDialog}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
新建服务商
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : providers.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>名称</TableHead>
|
||||
<TableHead>显示名</TableHead>
|
||||
<TableHead>Base URL</TableHead>
|
||||
<TableHead>协议</TableHead>
|
||||
<TableHead>API Key</TableHead>
|
||||
<TableHead>启用</TableHead>
|
||||
<TableHead>RPM 限制</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
<TableHead className="text-right">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providers.map((p) => (
|
||||
<TableRow key={p.id}>
|
||||
<TableCell className="font-medium">{p.name}</TableCell>
|
||||
<TableCell>{p.display_name || '-'}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground max-w-[200px] truncate">
|
||||
{p.base_url}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.api_protocol === 'openai' ? 'default' : 'info'}>
|
||||
{p.api_protocol}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{maskApiKey(p.api_key)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={p.enabled ? 'success' : 'secondary'}>
|
||||
{p.enabled ? '是' : '否'}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{p.rate_limit_rpm ?? '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(p.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex items-center justify-end gap-1">
|
||||
<Button variant="ghost" size="icon" onClick={() => openEditDialog(p)} title="编辑">
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" onClick={() => setDeleteTarget(p)} title="删除">
|
||||
<Trash2 className="h-4 w-4 text-destructive" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 创建/编辑 Dialog */}
|
||||
<Dialog open={dialogOpen} onOpenChange={setDialogOpen}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{editTarget ? '编辑服务商' : '新建服务商'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{editTarget ? '修改服务商配置' : '添加新的 AI 服务商'}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 max-h-[60vh] overflow-y-auto scrollbar-thin pr-1">
|
||||
<div className="space-y-2">
|
||||
<Label>名称 *</Label>
|
||||
<Input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
placeholder="例如: openai"
|
||||
disabled={!!editTarget}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>显示名</Label>
|
||||
<Input
|
||||
value={form.display_name}
|
||||
onChange={(e) => setForm({ ...form, display_name: e.target.value })}
|
||||
placeholder="例如: OpenAI"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>Base URL *</Label>
|
||||
<Input
|
||||
value={form.base_url}
|
||||
onChange={(e) => setForm({ ...form, base_url: e.target.value })}
|
||||
placeholder="https://api.openai.com/v1"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API 协议</Label>
|
||||
<Select value={form.api_protocol} onValueChange={(v) => setForm({ ...form, api_protocol: v as 'openai' | 'anthropic' })}>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="openai">OpenAI</SelectItem>
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>API Key</Label>
|
||||
<Input
|
||||
type="password"
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder={editTarget ? '留空则不修改' : 'sk-...'}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<Switch
|
||||
checked={form.enabled}
|
||||
onCheckedChange={(v) => setForm({ ...form, enabled: v })}
|
||||
/>
|
||||
<Label>启用</Label>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label>RPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_rpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_rpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label>TPM 限制</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={form.rate_limit_tpm}
|
||||
onChange={(e) => setForm({ ...form, rate_limit_tpm: e.target.value })}
|
||||
placeholder="不限"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDialogOpen(false)}>取消</Button>
|
||||
<Button onClick={handleSave} disabled={saving || !form.name.trim() || !form.base_url.trim()}>
|
||||
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
保存
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 删除确认 Dialog */}
|
||||
<Dialog open={!!deleteTarget} onOpenChange={() => setDeleteTarget(null)}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>确认删除</DialogTitle>
|
||||
<DialogDescription>
|
||||
确定要删除服务商 "{deleteTarget?.display_name || deleteTarget?.name}" 吗?此操作不可撤销。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setDeleteTarget(null)}>取消</Button>
|
||||
<Button variant="destructive" onClick={handleDelete} disabled={deleting}>
|
||||
{deleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
删除
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import {
|
||||
Search,
|
||||
Loader2,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronDown,
|
||||
ChevronUp,
|
||||
} from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatDate, formatNumber } from '@/lib/utils'
|
||||
import type { RelayTask } from '@/lib/types'
|
||||
|
||||
const PAGE_SIZE = 20
|
||||
|
||||
const statusVariants: Record<string, 'success' | 'info' | 'warning' | 'destructive' | 'secondary'> = {
|
||||
queued: 'warning',
|
||||
processing: 'info',
|
||||
completed: 'success',
|
||||
failed: 'destructive',
|
||||
}
|
||||
|
||||
const statusLabels: Record<string, string> = {
|
||||
queued: '排队中',
|
||||
processing: '处理中',
|
||||
completed: '已完成',
|
||||
failed: '失败',
|
||||
}
|
||||
|
||||
export default function RelayPage() {
|
||||
const [tasks, setTasks] = useState<RelayTask[]>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [page, setPage] = useState(1)
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [expandedId, setExpandedId] = useState<string | null>(null)
|
||||
|
||||
const fetchTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const params: Record<string, unknown> = { page, page_size: PAGE_SIZE }
|
||||
if (statusFilter !== 'all') params.status = statusFilter
|
||||
const res = await api.relay.list(params)
|
||||
setTasks(res.items)
|
||||
setTotal(res.total)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [page, statusFilter])
|
||||
|
||||
useEffect(() => {
|
||||
fetchTasks()
|
||||
}, [fetchTasks])
|
||||
|
||||
const totalPages = Math.max(1, Math.ceil(total / PAGE_SIZE))
|
||||
|
||||
function toggleExpand(id: string) {
|
||||
setExpandedId((prev) => (prev === id ? null : id))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 筛选 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<Select value={statusFilter} onValueChange={(v) => { setStatusFilter(v); setPage(1) }}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="状态筛选" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">全部状态</SelectItem>
|
||||
<SelectItem value="queued">排队中</SelectItem>
|
||||
<SelectItem value="processing">处理中</SelectItem>
|
||||
<SelectItem value="completed">已完成</SelectItem>
|
||||
<SelectItem value="failed">失败</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
<button onClick={() => setError('')} className="ml-2 underline cursor-pointer">关闭</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : tasks.length === 0 ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-8" />
|
||||
<TableHead>任务 ID</TableHead>
|
||||
<TableHead>模型</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>优先级</TableHead>
|
||||
<TableHead>重试次数</TableHead>
|
||||
<TableHead>Input Tokens</TableHead>
|
||||
<TableHead>Output Tokens</TableHead>
|
||||
<TableHead>错误信息</TableHead>
|
||||
<TableHead>创建时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{tasks.map((task) => (
|
||||
<>
|
||||
<TableRow key={task.id} className="cursor-pointer" onClick={() => toggleExpand(task.id)}>
|
||||
<TableCell>
|
||||
{expandedId === task.id ? (
|
||||
<ChevronUp className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.id.slice(0, 8)}...
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs">
|
||||
{task.model_id}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant={statusVariants[task.status] || 'secondary'}>
|
||||
{statusLabels[task.status] || task.status}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">{task.priority}</TableCell>
|
||||
<TableCell className="text-muted-foreground">{task.attempt_count}</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(task.input_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatNumber(task.output_tokens)}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[200px] truncate text-xs text-destructive">
|
||||
{task.error_message || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-muted-foreground">
|
||||
{formatDate(task.created_at)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
{expandedId === task.id && (
|
||||
<TableRow key={`${task.id}-detail`}>
|
||||
<TableCell colSpan={10} className="bg-muted/20 px-8 py-4">
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-muted-foreground">任务 ID</p>
|
||||
<p className="font-mono text-xs">{task.id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">账号 ID</p>
|
||||
<p className="font-mono text-xs">{task.account_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">服务商 ID</p>
|
||||
<p className="font-mono text-xs">{task.provider_id}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-muted-foreground">模型 ID</p>
|
||||
<p className="font-mono text-xs">{task.model_id}</p>
|
||||
</div>
|
||||
{task.queued_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">排队时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.queued_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.started_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">开始时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.started_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.completed_at && (
|
||||
<div>
|
||||
<p className="text-muted-foreground">完成时间</p>
|
||||
<p className="font-mono text-xs">{formatDate(task.completed_at)}</p>
|
||||
</div>
|
||||
)}
|
||||
{task.error_message && (
|
||||
<div className="col-span-2">
|
||||
<p className="text-muted-foreground">错误信息</p>
|
||||
<p className="text-xs text-destructive mt-1">{task.error_message}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<div className="flex items-center justify-between text-sm">
|
||||
<p className="text-muted-foreground">
|
||||
第 {page} 页 / 共 {totalPages} 页 ({total} 条)
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>
|
||||
<ChevronLeft className="h-4 w-4 mr-1" />
|
||||
上一页
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled={page >= totalPages} onClick={() => setPage(page + 1)}>
|
||||
下一页
|
||||
<ChevronRight className="h-4 w-4 ml-1" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,235 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Loader2, Zap } from 'lucide-react'
|
||||
import {
|
||||
LineChart,
|
||||
Line,
|
||||
XAxis,
|
||||
YAxis,
|
||||
CartesianGrid,
|
||||
Tooltip,
|
||||
ResponsiveContainer,
|
||||
BarChart,
|
||||
Bar,
|
||||
Legend,
|
||||
} from 'recharts'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
import { formatNumber } from '@/lib/utils'
|
||||
import type { UsageRecord, UsageByModel } from '@/lib/types'
|
||||
|
||||
export default function UsagePage() {
|
||||
const [days, setDays] = useState(7)
|
||||
const [dailyData, setDailyData] = useState<UsageRecord[]>([])
|
||||
const [modelData, setModelData] = useState<UsageByModel[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const fetchData = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const [dailyRes, modelRes] = await Promise.allSettled([
|
||||
api.usage.daily({ days }),
|
||||
api.usage.byModel({ days }),
|
||||
])
|
||||
if (dailyRes.status === 'fulfilled') setDailyData(dailyRes.value)
|
||||
else throw new Error('Failed to fetch daily usage')
|
||||
if (modelRes.status === 'fulfilled') setModelData(modelRes.value)
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) setError(err.body.message)
|
||||
else setError('加载数据失败')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [days])
|
||||
|
||||
useEffect(() => {
|
||||
fetchData()
|
||||
}, [fetchData])
|
||||
|
||||
const lineChartData = dailyData.map((r) => ({
|
||||
day: r.day.slice(5),
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const barChartData = modelData.map((r) => ({
|
||||
model: r.model_id,
|
||||
请求量: r.count,
|
||||
Input: r.input_tokens,
|
||||
Output: r.output_tokens,
|
||||
}))
|
||||
|
||||
const totalInput = dailyData.reduce((s, r) => s + r.input_tokens, 0)
|
||||
const totalOutput = dailyData.reduce((s, r) => s + r.output_tokens, 0)
|
||||
const totalRequests = dailyData.reduce((s, r) => s + r.count, 0)
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="flex flex-col items-center gap-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary" />
|
||||
<p className="text-sm text-muted-foreground">加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="flex h-[60vh] items-center justify-center">
|
||||
<div className="text-center">
|
||||
<p className="text-destructive">{error}</p>
|
||||
<button onClick={() => fetchData()} className="mt-4 text-sm text-primary hover:underline cursor-pointer">
|
||||
重新加载
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 时间范围 */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">时间范围:</span>
|
||||
<Select value={String(days)} onValueChange={(v) => setDays(Number(v))}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="7">最近 7 天</SelectItem>
|
||||
<SelectItem value="30">最近 30 天</SelectItem>
|
||||
<SelectItem value="90">最近 90 天</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 汇总统计 */}
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">总请求数</p>
|
||||
<p className="mt-1 text-2xl font-bold text-foreground">
|
||||
{formatNumber(totalRequests)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Input Tokens</p>
|
||||
<p className="mt-1 text-2xl font-bold text-blue-400">
|
||||
{formatNumber(totalInput)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<p className="text-sm text-muted-foreground">Output Tokens</p>
|
||||
<p className="mt-1 text-2xl font-bold text-orange-400">
|
||||
{formatNumber(totalOutput)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Token 用量趋势 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Zap className="h-4 w-4 text-primary" />
|
||||
Token 用量趋势
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{lineChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<LineChart data={lineChartData}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
dataKey="day"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||
<Line type="monotone" dataKey="Input" stroke="#3B82F6" strokeWidth={2} dot={false} />
|
||||
<Line type="monotone" dataKey="Output" stroke="#F97316" strokeWidth={2} dot={false} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 按模型分布 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">按模型分布</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{barChartData.length > 0 ? (
|
||||
<ResponsiveContainer width="100%" height={320}>
|
||||
<BarChart data={barChartData} layout="vertical">
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#1E293B" />
|
||||
<XAxis
|
||||
type="number"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
/>
|
||||
<YAxis
|
||||
type="category"
|
||||
dataKey="model"
|
||||
tick={{ fontSize: 12, fill: '#94A3B8' }}
|
||||
axisLine={{ stroke: '#1E293B' }}
|
||||
width={120}
|
||||
/>
|
||||
<Tooltip
|
||||
contentStyle={{
|
||||
backgroundColor: '#0F172A',
|
||||
border: '1px solid #1E293B',
|
||||
borderRadius: '8px',
|
||||
color: '#F8FAFC',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
/>
|
||||
<Legend wrapperStyle={{ fontSize: '12px', color: '#94A3B8' }} />
|
||||
<Bar dataKey="Input" fill="#3B82F6" radius={[0, 2, 2, 0]} />
|
||||
<Bar dataKey="Output" fill="#F97316" radius={[0, 2, 2, 0]} />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<div className="flex h-[320px] items-center justify-center text-muted-foreground text-sm">
|
||||
暂无数据
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
@layer base {
|
||||
:root {
|
||||
--background: 222 47% 5%;
|
||||
--foreground: 210 40% 98%;
|
||||
--card: 222 47% 8%;
|
||||
--card-foreground: 210 40% 98%;
|
||||
--primary: 142 71% 45%;
|
||||
--primary-foreground: 222 47% 5%;
|
||||
--muted: 217 33% 17%;
|
||||
--muted-foreground: 215 20% 65%;
|
||||
--accent: 215 28% 23%;
|
||||
--accent-foreground: 210 40% 98%;
|
||||
--destructive: 0 84% 60%;
|
||||
--destructive-foreground: 210 40% 98%;
|
||||
--border: 217 33% 17%;
|
||||
--input: 217 33% 17%;
|
||||
--ring: 142 71% 45%;
|
||||
}
|
||||
|
||||
* {
|
||||
border-color: hsl(var(--border));
|
||||
}
|
||||
|
||||
body {
|
||||
background-color: hsl(var(--background));
|
||||
color: hsl(var(--foreground));
|
||||
font-family: 'Inter', system-ui, -apple-system, sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
-moz-osx-font-smoothing: grayscale;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
.scrollbar-thin {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: hsl(var(--muted)) transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb {
|
||||
background-color: hsl(var(--muted));
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.scrollbar-thin::-webkit-scrollbar-thumb:hover {
|
||||
background-color: hsl(var(--accent));
|
||||
}
|
||||
}
|
||||
|
||||
@layer components {
|
||||
.glass-card {
|
||||
@apply bg-card/80 backdrop-blur-sm border border-border rounded-lg;
|
||||
}
|
||||
}
|
||||
@@ -1,27 +0,0 @@
|
||||
import type { Metadata } from 'next'
|
||||
import './globals.css'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'ZCLAW Admin',
|
||||
description: 'ZCLAW AI Agent 管理平台',
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
}) {
|
||||
return (
|
||||
<html lang="zh-CN" className="dark">
|
||||
<head>
|
||||
<link
|
||||
href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&display=swap"
|
||||
rel="stylesheet"
|
||||
/>
|
||||
</head>
|
||||
<body className="min-h-screen bg-background font-sans antialiased">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
@@ -1,199 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, type FormEvent } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { Lock, User, Loader2, Eye, EyeOff } from 'lucide-react'
|
||||
import { api } from '@/lib/api-client'
|
||||
import { login } from '@/lib/auth'
|
||||
import { ApiRequestError } from '@/lib/api-client'
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [showPassword, setShowPassword] = useState(false)
|
||||
const [remember, setRemember] = useState(false)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
async function handleSubmit(e: FormEvent) {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
|
||||
if (!username.trim()) {
|
||||
setError('请输入用户名')
|
||||
return
|
||||
}
|
||||
if (!password.trim()) {
|
||||
setError('请输入密码')
|
||||
return
|
||||
}
|
||||
|
||||
setLoading(true)
|
||||
try {
|
||||
const res = await api.auth.login({ username: username.trim(), password })
|
||||
login(res.token, res.account)
|
||||
router.replace('/')
|
||||
} catch (err) {
|
||||
if (err instanceof ApiRequestError) {
|
||||
setError(err.body.message || '登录失败,请检查用户名和密码')
|
||||
} else {
|
||||
setError('网络错误,请稍后重试')
|
||||
}
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen">
|
||||
{/* 左侧品牌区域 */}
|
||||
<div className="hidden lg:flex lg:w-1/2 relative overflow-hidden bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900">
|
||||
{/* 装饰性背景 */}
|
||||
<div className="absolute inset-0">
|
||||
<div className="absolute top-1/4 left-1/4 w-96 h-96 bg-green-500/5 rounded-full blur-3xl" />
|
||||
<div className="absolute bottom-1/4 right-1/4 w-64 h-64 bg-green-500/8 rounded-full blur-3xl" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[600px] h-[600px] border border-green-500/10 rounded-full" />
|
||||
<div className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 w-[400px] h-[400px] border border-green-500/10 rounded-full" />
|
||||
</div>
|
||||
|
||||
{/* 品牌内容 */}
|
||||
<div className="relative z-10 flex flex-col items-center justify-center w-full p-12">
|
||||
<div className="text-center">
|
||||
<h1 className="text-6xl font-bold tracking-tight text-foreground mb-4">
|
||||
ZCLAW
|
||||
</h1>
|
||||
<p className="text-xl text-muted-foreground font-light">
|
||||
AI Agent 管理平台
|
||||
</p>
|
||||
<div className="mt-8 flex items-center justify-center gap-2">
|
||||
<div className="h-px w-12 bg-green-500/50" />
|
||||
<div className="w-2 h-2 rounded-full bg-green-500" />
|
||||
<div className="h-px w-12 bg-green-500/50" />
|
||||
</div>
|
||||
<p className="mt-6 text-sm text-muted-foreground/60 max-w-sm">
|
||||
统一管理 AI 服务商、模型配置、API 密钥、用量监控与系统配置
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右侧登录表单 */}
|
||||
<div className="flex w-full lg:w-1/2 items-center justify-center p-8">
|
||||
<div className="w-full max-w-sm space-y-8">
|
||||
{/* 移动端 Logo */}
|
||||
<div className="lg:hidden text-center">
|
||||
<h1 className="text-4xl font-bold tracking-tight text-foreground mb-2">
|
||||
ZCLAW
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">AI Agent 管理平台</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 className="text-2xl font-semibold text-foreground">登录</h2>
|
||||
<p className="mt-2 text-sm text-muted-foreground">
|
||||
输入您的账号信息以继续
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
{/* 用户名 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="username"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
用户名
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
id="username"
|
||||
type="text"
|
||||
placeholder="请输入用户名"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-3 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoComplete="username"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 密码 */}
|
||||
<div className="space-y-2">
|
||||
<label
|
||||
htmlFor="password"
|
||||
className="text-sm font-medium text-foreground"
|
||||
>
|
||||
密码
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
placeholder="请输入密码"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
className="flex h-10 w-full rounded-md border border-input bg-transparent pl-10 pr-10 py-2 text-sm shadow-sm transition-colors duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring"
|
||||
autoComplete="current-password"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword(!showPassword)}
|
||||
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground transition-colors duration-200 cursor-pointer"
|
||||
>
|
||||
{showPassword ? (
|
||||
<EyeOff className="h-4 w-4" />
|
||||
) : (
|
||||
<Eye className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 记住我 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
id="remember"
|
||||
type="checkbox"
|
||||
checked={remember}
|
||||
onChange={(e) => setRemember(e.target.checked)}
|
||||
className="h-4 w-4 rounded border-input bg-transparent accent-primary cursor-pointer"
|
||||
/>
|
||||
<label
|
||||
htmlFor="remember"
|
||||
className="text-sm text-muted-foreground cursor-pointer select-none"
|
||||
>
|
||||
记住我
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{/* 错误信息 */}
|
||||
{error && (
|
||||
<div className="rounded-md bg-destructive/10 border border-destructive/20 px-4 py-3 text-sm text-destructive">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 登录按钮 */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex h-10 w-full items-center justify-center rounded-md bg-primary text-primary-foreground font-medium text-sm shadow-sm transition-colors duration-200 hover:bg-primary-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50 cursor-pointer"
|
||||
>
|
||||
{loading ? (
|
||||
<>
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
登录中...
|
||||
</>
|
||||
) : (
|
||||
'登录'
|
||||
)}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,48 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState, type ReactNode } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { isAuthenticated, getAccount } from '@/lib/auth'
|
||||
import type { AccountPublic } from '@/lib/types'
|
||||
|
||||
interface AuthGuardProps {
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AuthGuard({ children }: AuthGuardProps) {
|
||||
const router = useRouter()
|
||||
const [authorized, setAuthorized] = useState(false)
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!isAuthenticated()) {
|
||||
router.replace('/login')
|
||||
return
|
||||
}
|
||||
setAccount(getAccount())
|
||||
setAuthorized(true)
|
||||
}, [router])
|
||||
|
||||
if (!authorized) {
|
||||
return (
|
||||
<div className="flex h-screen w-screen items-center justify-center bg-background">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-primary border-t-transparent" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <>{children}</>
|
||||
}
|
||||
|
||||
export function useAuth() {
|
||||
const [account, setAccount] = useState<AccountPublic | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
const acc = getAccount()
|
||||
setAccount(acc)
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
return { account, loading, isAuthenticated: isAuthenticated() }
|
||||
}
|
||||
@@ -1,42 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors duration-200 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'border-transparent bg-primary/15 text-primary',
|
||||
secondary:
|
||||
'border-transparent bg-muted text-muted-foreground',
|
||||
destructive:
|
||||
'border-transparent bg-destructive/15 text-destructive',
|
||||
outline:
|
||||
'text-foreground border-border',
|
||||
success:
|
||||
'border-transparent bg-green-500/15 text-green-400',
|
||||
warning:
|
||||
'border-transparent bg-yellow-500/15 text-yellow-400',
|
||||
info:
|
||||
'border-transparent bg-blue-500/15 text-blue-400',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface BadgeProps
|
||||
extends React.HTMLAttributes<HTMLDivElement>,
|
||||
VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return (
|
||||
<div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
)
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md text-sm font-medium transition-colors duration-200 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background disabled:pointer-events-none disabled:opacity-50',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default:
|
||||
'bg-primary text-primary-foreground hover:bg-primary-hover shadow-sm',
|
||||
secondary:
|
||||
'bg-muted text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
destructive:
|
||||
'bg-destructive text-destructive-foreground hover:bg-red-600 shadow-sm',
|
||||
outline:
|
||||
'border border-border bg-transparent hover:bg-accent hover:text-accent-foreground',
|
||||
ghost:
|
||||
'hover:bg-accent hover:text-accent-foreground',
|
||||
link:
|
||||
'text-primary underline-offset-4 hover:underline',
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default',
|
||||
},
|
||||
},
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {}
|
||||
|
||||
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||
({ className, variant, size, ...props }, ref) => {
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
@@ -1,75 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'rounded-lg border border-border bg-card text-card-foreground shadow-sm',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex flex-col space-y-1.5 p-6', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h3
|
||||
ref={ref}
|
||||
className={cn('font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<p
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
))
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn('flex items-center p-6 pt-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }
|
||||
@@ -1,118 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { X } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Overlay>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/60 backdrop-blur-sm',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%]',
|
||||
'gap-4 border border-border bg-card p-6 shadow-lg duration-200',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
'data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%]',
|
||||
'data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||
'rounded-lg',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
))
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div
|
||||
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Title>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = React.forwardRef<
|
||||
React.ElementRef<typeof DialogPrimitive.Description>,
|
||||
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<DialogPrimitive.Description
|
||||
ref={ref}
|
||||
className={cn('text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogPortal,
|
||||
DialogOverlay,
|
||||
DialogClose,
|
||||
DialogTrigger,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogFooter,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface InputProps
|
||||
extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||
|
||||
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||
({ className, type, ...props }, ref) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors duration-200',
|
||||
'file:border-0 file:bg-transparent file:text-sm file:font-medium',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
className,
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
@@ -1,23 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface LabelProps
|
||||
extends React.LabelHTMLAttributes<HTMLLabelElement> {}
|
||||
|
||||
const Label = React.forwardRef<HTMLLabelElement, LabelProps>(
|
||||
({ className, ...props }, ref) => {
|
||||
return (
|
||||
<label
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
},
|
||||
)
|
||||
Label.displayName = 'Label'
|
||||
|
||||
export { Label }
|
||||
@@ -1,100 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||
import { Check, ChevronDown } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Select = SelectPrimitive.Root
|
||||
const SelectGroup = SelectPrimitive.Group
|
||||
const SelectValue = SelectPrimitive.Value
|
||||
|
||||
const SelectTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background',
|
||||
'placeholder:text-muted-foreground',
|
||||
'focus:outline-none focus:ring-1 focus:ring-ring',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'[&>span]:line-clamp-1',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
))
|
||||
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||
|
||||
const SelectContent = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border border-border bg-card text-foreground shadow-md',
|
||||
'data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||
'data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
'data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95',
|
||||
position === 'popper' &&
|
||||
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||
className,
|
||||
)}
|
||||
position={position}
|
||||
{...props}
|
||||
>
|
||||
<SelectPrimitive.Viewport
|
||||
className={cn(
|
||||
'p-1',
|
||||
position === 'popper' &&
|
||||
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]',
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</SelectPrimitive.Viewport>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
))
|
||||
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||
|
||||
const SelectItem = React.forwardRef<
|
||||
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||
>(({ className, children, ...props }, ref) => (
|
||||
<SelectPrimitive.Item
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'relative flex w-full cursor-pointer select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none',
|
||||
'focus:bg-accent focus:text-accent-foreground',
|
||||
'data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||
<SelectPrimitive.ItemIndicator>
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</span>
|
||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||
</SelectPrimitive.Item>
|
||||
))
|
||||
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||
|
||||
export {
|
||||
Select,
|
||||
SelectGroup,
|
||||
SelectValue,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
}
|
||||
@@ -1,30 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = 'horizontal', decorative = true, ...props },
|
||||
ref,
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
'shrink-0 bg-border',
|
||||
orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
@@ -1,32 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as SwitchPrimitive from '@radix-ui/react-switch'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Switch = React.forwardRef<
|
||||
React.ElementRef<typeof SwitchPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SwitchPrimitive.Root>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<SwitchPrimitive.Root
|
||||
className={cn(
|
||||
'peer inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border-2 border-transparent shadow-sm transition-colors duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 focus-visible:ring-offset-background',
|
||||
'disabled:cursor-not-allowed disabled:opacity-50',
|
||||
'data-[state=checked]:bg-primary data-[state=unchecked]:bg-input',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
ref={ref}
|
||||
>
|
||||
<SwitchPrimitive.Thumb
|
||||
className={cn(
|
||||
'pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform duration-200',
|
||||
'data-[state=checked]:translate-x-4 data-[state=unchecked]:translate-x-0',
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitive.Root>
|
||||
))
|
||||
Switch.displayName = SwitchPrimitive.Root.displayName
|
||||
|
||||
export { Switch }
|
||||
@@ -1,119 +0,0 @@
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = React.forwardRef<
|
||||
HTMLTableElement,
|
||||
React.HTMLAttributes<HTMLTableElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div className="relative w-full overflow-auto scrollbar-thin">
|
||||
<table
|
||||
ref={ref}
|
||||
className={cn('w-full caption-bottom text-sm', className)}
|
||||
{...props}
|
||||
/>
|
||||
</div>
|
||||
))
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
))
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tbody
|
||||
ref={ref}
|
||||
className={cn('[&_tr:last-child]:border-0', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = React.forwardRef<
|
||||
HTMLTableSectionElement,
|
||||
React.HTMLAttributes<HTMLTableSectionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tfoot
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-t bg-muted/50 font-medium [&>tr]:last:border-b-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = React.forwardRef<
|
||||
HTMLTableRowElement,
|
||||
React.HTMLAttributes<HTMLTableRowElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'border-b border-border transition-colors duration-200 hover:bg-muted/50',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.ThHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-4 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = React.forwardRef<
|
||||
HTMLTableCellElement,
|
||||
React.TdHTMLAttributes<HTMLTableCellElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'p-4 align-middle [&:has([role=checkbox])]:pr-0',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = React.forwardRef<
|
||||
HTMLTableCaptionElement,
|
||||
React.HTMLAttributes<HTMLTableCaptionElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<caption
|
||||
ref={ref}
|
||||
className={cn('mt-4 text-sm text-muted-foreground', className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export {
|
||||
Table,
|
||||
TableHeader,
|
||||
TableBody,
|
||||
TableFooter,
|
||||
TableHead,
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableCaption,
|
||||
}
|
||||
@@ -1,57 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TabsPrimitive from '@radix-ui/react-tabs'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Tabs = TabsPrimitive.Root
|
||||
|
||||
const TabsList = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.List>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.List>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.List
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsList.displayName = TabsPrimitive.List.displayName
|
||||
|
||||
const TabsTrigger = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Trigger>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Trigger>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Trigger
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all duration-200',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
'disabled:pointer-events-none disabled:opacity-50',
|
||||
'data-[state=active]:bg-card data-[state=active]:text-foreground data-[state=active]:shadow',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsTrigger.displayName = TabsPrimitive.Trigger.displayName
|
||||
|
||||
const TabsContent = React.forwardRef<
|
||||
React.ElementRef<typeof TabsPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TabsPrimitive.Content>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<TabsPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TabsContent.displayName = TabsPrimitive.Content.displayName
|
||||
|
||||
export { Tabs, TabsList, TabsTrigger, TabsContent }
|
||||
@@ -1,31 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import * as TooltipPrimitive from '@radix-ui/react-tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const TooltipProvider = TooltipPrimitive.Provider
|
||||
const Tooltip = TooltipPrimitive.Root
|
||||
const TooltipTrigger = TooltipPrimitive.Trigger
|
||||
|
||||
const TooltipContent = React.forwardRef<
|
||||
React.ElementRef<typeof TooltipPrimitive.Content>,
|
||||
React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content>
|
||||
>(({ className, sideOffset = 4, ...props }, ref) => (
|
||||
<TooltipPrimitive.Content
|
||||
ref={ref}
|
||||
sideOffset={sideOffset}
|
||||
className={cn(
|
||||
'z-50 overflow-hidden rounded-md bg-card border border-border px-3 py-1.5 text-sm text-foreground shadow-md',
|
||||
'animate-in fade-in-0 zoom-in-95',
|
||||
'data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95',
|
||||
'data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2',
|
||||
'data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
TooltipContent.displayName = TooltipPrimitive.Content.displayName
|
||||
|
||||
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }
|
||||
@@ -1,284 +0,0 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — 类型化 HTTP 客户端
|
||||
// ============================================================
|
||||
|
||||
import { getToken, logout } from './auth'
|
||||
import type {
|
||||
AccountPublic,
|
||||
ApiError,
|
||||
ConfigItem,
|
||||
CreateTokenRequest,
|
||||
DashboardStats,
|
||||
LoginRequest,
|
||||
LoginResponse,
|
||||
Model,
|
||||
OperationLog,
|
||||
PaginatedResponse,
|
||||
Provider,
|
||||
RelayTask,
|
||||
TokenInfo,
|
||||
UsageByModel,
|
||||
UsageRecord,
|
||||
} from './types'
|
||||
|
||||
// ── 错误类 ────────────────────────────────────────────────
|
||||
|
||||
export class ApiRequestError extends Error {
|
||||
constructor(
|
||||
public status: number,
|
||||
public body: ApiError,
|
||||
) {
|
||||
super(body.message || `Request failed with status ${status}`)
|
||||
this.name = 'ApiRequestError'
|
||||
}
|
||||
}
|
||||
|
||||
// ── 基础请求 ──────────────────────────────────────────────
|
||||
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_SAAS_API_URL || 'http://localhost:8080'
|
||||
|
||||
async function request<T>(
|
||||
method: string,
|
||||
path: string,
|
||||
body?: unknown,
|
||||
): Promise<T> {
|
||||
const token = getToken()
|
||||
const headers: Record<string, string> = {
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
if (token) {
|
||||
headers['Authorization'] = `Bearer ${token}`
|
||||
}
|
||||
|
||||
const res = await fetch(`${BASE_URL}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
})
|
||||
|
||||
if (res.status === 401) {
|
||||
logout()
|
||||
if (typeof window !== 'undefined') {
|
||||
window.location.href = '/login'
|
||||
}
|
||||
throw new ApiRequestError(401, { error: 'unauthorized', message: '登录已过期,请重新登录' })
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
let errorBody: ApiError
|
||||
try {
|
||||
errorBody = await res.json()
|
||||
} catch {
|
||||
errorBody = { error: 'unknown', message: `请求失败 (${res.status})` }
|
||||
}
|
||||
throw new ApiRequestError(res.status, errorBody)
|
||||
}
|
||||
|
||||
// 204 No Content
|
||||
if (res.status === 204) {
|
||||
return undefined as T
|
||||
}
|
||||
|
||||
return res.json() as Promise<T>
|
||||
}
|
||||
|
||||
// ── API 客户端 ────────────────────────────────────────────
|
||||
|
||||
export const api = {
|
||||
// ── 认证 ──────────────────────────────────────────────
|
||||
auth: {
|
||||
async login(data: LoginRequest): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/login', data)
|
||||
},
|
||||
|
||||
async register(data: {
|
||||
username: string
|
||||
password: string
|
||||
email: string
|
||||
display_name?: string
|
||||
}): Promise<LoginResponse> {
|
||||
return request<LoginResponse>('POST', '/api/auth/register', data)
|
||||
},
|
||||
|
||||
async me(): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', '/api/auth/me')
|
||||
},
|
||||
},
|
||||
|
||||
// ── 账号管理 ──────────────────────────────────────────
|
||||
accounts: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
search?: string
|
||||
role?: string
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<AccountPublic>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<AccountPublic>>('GET', `/api/accounts${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('GET', `/api/accounts/${id}`)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Pick<AccountPublic, 'display_name' | 'email' | 'role'>>,
|
||||
): Promise<AccountPublic> {
|
||||
return request<AccountPublic>('PATCH', `/api/accounts/${id}`, data)
|
||||
},
|
||||
|
||||
async updateStatus(
|
||||
id: string,
|
||||
data: { status: AccountPublic['status'] },
|
||||
): Promise<void> {
|
||||
return request<void>('PATCH', `/api/accounts/${id}/status`, data)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 服务商管理 ────────────────────────────────────────
|
||||
providers: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<Provider>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Provider>>('GET', `/api/providers${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>): Promise<Provider> {
|
||||
return request<Provider>('POST', '/api/providers', data)
|
||||
},
|
||||
|
||||
async update(
|
||||
id: string,
|
||||
data: Partial<Omit<Provider, 'id' | 'created_at' | 'updated_at'>>,
|
||||
): Promise<Provider> {
|
||||
return request<Provider>('PATCH', `/api/providers/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/providers/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 模型管理 ──────────────────────────────────────────
|
||||
models: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
provider_id?: string
|
||||
}): Promise<PaginatedResponse<Model>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<Model>>('GET', `/api/models${qs}`)
|
||||
},
|
||||
|
||||
async create(data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('POST', '/api/models', data)
|
||||
},
|
||||
|
||||
async update(id: string, data: Partial<Omit<Model, 'id'>>): Promise<Model> {
|
||||
return request<Model>('PATCH', `/api/models/${id}`, data)
|
||||
},
|
||||
|
||||
async delete(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/models/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── API 密钥 ──────────────────────────────────────────
|
||||
tokens: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
}): Promise<PaginatedResponse<TokenInfo>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<TokenInfo>>('GET', `/api/tokens${qs}`)
|
||||
},
|
||||
|
||||
async create(data: CreateTokenRequest): Promise<TokenInfo> {
|
||||
return request<TokenInfo>('POST', '/api/tokens', data)
|
||||
},
|
||||
|
||||
async revoke(id: string): Promise<void> {
|
||||
return request<void>('DELETE', `/api/tokens/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 用量统计 ──────────────────────────────────────────
|
||||
usage: {
|
||||
async daily(params?: { days?: number }): Promise<UsageRecord[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<UsageRecord[]>('GET', `/api/usage/daily${qs}`)
|
||||
},
|
||||
|
||||
async byModel(params?: { days?: number }): Promise<UsageByModel[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<UsageByModel[]>('GET', `/api/usage/by-model${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 中转任务 ──────────────────────────────────────────
|
||||
relay: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
status?: string
|
||||
}): Promise<PaginatedResponse<RelayTask>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<RelayTask>>('GET', `/api/relay/tasks${qs}`)
|
||||
},
|
||||
|
||||
async get(id: string): Promise<RelayTask> {
|
||||
return request<RelayTask>('GET', `/api/relay/tasks/${id}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 系统配置 ──────────────────────────────────────────
|
||||
config: {
|
||||
async list(params?: {
|
||||
category?: string
|
||||
}): Promise<ConfigItem[]> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<ConfigItem[]>('GET', `/api/config${qs}`)
|
||||
},
|
||||
|
||||
async update(id: string, data: { value: string | number | boolean }): Promise<ConfigItem> {
|
||||
return request<ConfigItem>('PATCH', `/api/config/${id}`, data)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 操作日志 ──────────────────────────────────────────
|
||||
logs: {
|
||||
async list(params?: {
|
||||
page?: number
|
||||
page_size?: number
|
||||
action?: string
|
||||
}): Promise<PaginatedResponse<OperationLog>> {
|
||||
const qs = buildQueryString(params)
|
||||
return request<PaginatedResponse<OperationLog>>('GET', `/api/logs${qs}`)
|
||||
},
|
||||
},
|
||||
|
||||
// ── 仪表盘 ────────────────────────────────────────────
|
||||
stats: {
|
||||
async dashboard(): Promise<DashboardStats> {
|
||||
return request<DashboardStats>('GET', '/api/stats/dashboard')
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// ── 工具函数 ──────────────────────────────────────────────
|
||||
|
||||
function buildQueryString(params?: Record<string, unknown>): string {
|
||||
if (!params) return ''
|
||||
const entries = Object.entries(params).filter(
|
||||
([, v]) => v !== undefined && v !== null && v !== '',
|
||||
)
|
||||
if (entries.length === 0) return ''
|
||||
const qs = entries
|
||||
.map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(String(v))}`)
|
||||
.join('&')
|
||||
return `?${qs}`
|
||||
}
|
||||
@@ -1,45 +0,0 @@
|
||||
// ============================================================
|
||||
// ZCLAW SaaS Admin — JWT Token 管理
|
||||
// ============================================================
|
||||
|
||||
import type { AccountPublic } from './types'
|
||||
|
||||
const TOKEN_KEY = 'zclaw_admin_token'
|
||||
const ACCOUNT_KEY = 'zclaw_admin_account'
|
||||
|
||||
/** 保存登录凭证 */
|
||||
export function login(token: string, account: AccountPublic): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.setItem(TOKEN_KEY, token)
|
||||
localStorage.setItem(ACCOUNT_KEY, JSON.stringify(account))
|
||||
}
|
||||
|
||||
/** 清除登录凭证 */
|
||||
export function logout(): void {
|
||||
if (typeof window === 'undefined') return
|
||||
localStorage.removeItem(TOKEN_KEY)
|
||||
localStorage.removeItem(ACCOUNT_KEY)
|
||||
}
|
||||
|
||||
/** 获取 JWT token */
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
return localStorage.getItem(TOKEN_KEY)
|
||||
}
|
||||
|
||||
/** 获取当前登录用户信息 */
|
||||
export function getAccount(): AccountPublic | null {
|
||||
if (typeof window === 'undefined') return null
|
||||
const raw = localStorage.getItem(ACCOUNT_KEY)
|
||||
if (!raw) return null
|
||||
try {
|
||||
return JSON.parse(raw) as AccountPublic
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/** 是否已认证 */
|
||||
export function isAuthenticated(): boolean {
|
||||
return !!getToken()
|
||||
}
|
||||
@@ -1,34 +0,0 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
|
||||
export function formatDate(date: string | Date): string {
|
||||
const d = new Date(date)
|
||||
return d.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
second: '2-digit',
|
||||
})
|
||||
}
|
||||
|
||||
export function formatNumber(n: number): string {
|
||||
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`
|
||||
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`
|
||||
return n.toLocaleString()
|
||||
}
|
||||
|
||||
export function maskApiKey(key?: string): string {
|
||||
if (!key) return '-'
|
||||
if (key.length <= 8) return '****'
|
||||
return `${key.slice(0, 4)}${'*'.repeat(key.length - 8)}${key.slice(-4)}`
|
||||
}
|
||||
|
||||
export function sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms))
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
import type { Config } from 'tailwindcss'
|
||||
|
||||
const config: Config = {
|
||||
darkMode: 'class',
|
||||
content: [
|
||||
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
'./src/app/**/*.{js,ts,jsx,tsx,mdx}',
|
||||
],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
background: '#020617',
|
||||
foreground: '#F8FAFC',
|
||||
card: {
|
||||
DEFAULT: '#0F172A',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
primary: {
|
||||
DEFAULT: '#22C55E',
|
||||
foreground: '#020617',
|
||||
hover: '#16A34A',
|
||||
},
|
||||
muted: {
|
||||
DEFAULT: '#1E293B',
|
||||
foreground: '#94A3B8',
|
||||
},
|
||||
accent: {
|
||||
DEFAULT: '#334155',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
destructive: {
|
||||
DEFAULT: '#EF4444',
|
||||
foreground: '#F8FAFC',
|
||||
},
|
||||
border: '#1E293B',
|
||||
input: '#1E293B',
|
||||
ring: '#22C55E',
|
||||
},
|
||||
fontFamily: {
|
||||
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
|
||||
mono: ['JetBrains Mono', 'Fira Code', 'monospace'],
|
||||
},
|
||||
keyframes: {
|
||||
'fade-in': {
|
||||
'0%': { opacity: '0', transform: 'translateY(4px)' },
|
||||
'100%': { opacity: '1', transform: 'translateY(0)' },
|
||||
},
|
||||
'slide-in': {
|
||||
'0%': { opacity: '0', transform: 'translateX(-8px)' },
|
||||
'100%': { opacity: '1', transform: 'translateX(0)' },
|
||||
},
|
||||
},
|
||||
animation: {
|
||||
'fade-in': 'fade-in 0.2s ease-out',
|
||||
'slide-in': 'slide-in 0.2s ease-out',
|
||||
},
|
||||
},
|
||||
},
|
||||
plugins: [],
|
||||
}
|
||||
export default config
|
||||
@@ -1,21 +0,0 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "es2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "preserve",
|
||||
"incremental": true,
|
||||
"plugins": [{ "name": "next" }],
|
||||
"paths": { "@/*": ["./src/*"] }
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
33
config/saas-development.toml
Normal file
33
config/saas-development.toml
Normal file
@@ -0,0 +1,33 @@
|
||||
# ZCLAW SaaS 开发环境配置
|
||||
# 通过 ZCLAW_ENV=development 或默认使用此配置
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
cors_origins = [] # 空 = 开发模式允许所有来源
|
||||
|
||||
[database]
|
||||
url = "postgres://postgres:123123@localhost:5432/zclaw"
|
||||
|
||||
[auth]
|
||||
jwt_expiration_hours = 24
|
||||
totp_issuer = "ZCLAW SaaS (dev)"
|
||||
refresh_token_hours = 168
|
||||
|
||||
[relay]
|
||||
max_queue_size = 1000
|
||||
max_concurrent_per_provider = 5
|
||||
batch_window_ms = 50
|
||||
retry_delay_ms = 1000
|
||||
max_attempts = 3
|
||||
|
||||
[rate_limit]
|
||||
requests_per_minute = 120
|
||||
burst = 20
|
||||
|
||||
[scheduler]
|
||||
jobs = [
|
||||
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
|
||||
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
|
||||
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = false },
|
||||
]
|
||||
35
config/saas-production.toml
Normal file
35
config/saas-production.toml
Normal file
@@ -0,0 +1,35 @@
|
||||
# ZCLAW SaaS 生产环境配置
|
||||
# 通过 ZCLAW_ENV=production 使用此配置
|
||||
|
||||
[server]
|
||||
host = "0.0.0.0"
|
||||
port = 8080
|
||||
# 生产环境必须配置 CORS 白名单
|
||||
cors_origins = ["https://admin.zclaw.ai", "https://zclaw.ai"]
|
||||
|
||||
[database]
|
||||
# 生产环境通过 ZCLAW_DATABASE_URL 环境变量覆盖,此处为占位
|
||||
url = "postgres://zclaw:CHANGE_ME@db:5432/zclaw"
|
||||
|
||||
[auth]
|
||||
jwt_expiration_hours = 12
|
||||
totp_issuer = "ZCLAW SaaS"
|
||||
refresh_token_hours = 168
|
||||
|
||||
[relay]
|
||||
max_queue_size = 5000
|
||||
max_concurrent_per_provider = 10
|
||||
batch_window_ms = 50
|
||||
retry_delay_ms = 2000
|
||||
max_attempts = 3
|
||||
|
||||
[rate_limit]
|
||||
requests_per_minute = 60
|
||||
burst = 10
|
||||
|
||||
[scheduler]
|
||||
jobs = [
|
||||
{ name = "cleanup_rate_limit", interval = "5m", task = "cleanup_rate_limit", run_on_start = false },
|
||||
{ name = "cleanup_refresh_tokens", interval = "1h", task = "cleanup_refresh_tokens", run_on_start = false },
|
||||
{ name = "cleanup_devices", interval = "24h", task = "cleanup_devices", run_on_start = true },
|
||||
]
|
||||
31
config/saas-test.toml
Normal file
31
config/saas-test.toml
Normal file
@@ -0,0 +1,31 @@
|
||||
# ZCLAW SaaS 测试环境配置
|
||||
# 通过 ZCLAW_ENV=test 使用此配置
|
||||
|
||||
[server]
|
||||
host = "127.0.0.1"
|
||||
port = 8090
|
||||
cors_origins = []
|
||||
|
||||
[database]
|
||||
# 测试环境使用独立数据库
|
||||
url = "postgres://postgres:123123@localhost:5432/zclaw_test"
|
||||
|
||||
[auth]
|
||||
jwt_expiration_hours = 1
|
||||
totp_issuer = "ZCLAW SaaS (test)"
|
||||
refresh_token_hours = 24
|
||||
|
||||
[relay]
|
||||
max_queue_size = 100
|
||||
max_concurrent_per_provider = 2
|
||||
batch_window_ms = 10
|
||||
retry_delay_ms = 100
|
||||
max_attempts = 2
|
||||
|
||||
[rate_limit]
|
||||
requests_per_minute = 200
|
||||
burst = 50
|
||||
|
||||
[scheduler]
|
||||
# 测试环境不启动定时任务
|
||||
jobs = []
|
||||
@@ -1,21 +0,0 @@
|
||||
[package]
|
||||
name = "zclaw-channels"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
repository.workspace = true
|
||||
rust-version.workspace = true
|
||||
description = "ZCLAW Channels - external platform adapters"
|
||||
|
||||
[dependencies]
|
||||
zclaw-types = { workspace = true }
|
||||
|
||||
tokio = { workspace = true }
|
||||
serde = { workspace = true }
|
||||
serde_json = { workspace = true }
|
||||
thiserror = { workspace = true }
|
||||
tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
|
||||
reqwest = { workspace = true }
|
||||
chrono = { workspace = true }
|
||||
@@ -1,71 +0,0 @@
|
||||
//! Console channel adapter for testing
|
||||
|
||||
use async_trait::async_trait;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::mpsc;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Channel, ChannelConfig, ChannelStatus, IncomingMessage, OutgoingMessage};
|
||||
|
||||
/// Console channel adapter (for testing)
|
||||
pub struct ConsoleChannel {
|
||||
config: ChannelConfig,
|
||||
status: Arc<tokio::sync::RwLock<ChannelStatus>>,
|
||||
}
|
||||
|
||||
impl ConsoleChannel {
|
||||
pub fn new(config: ChannelConfig) -> Self {
|
||||
Self {
|
||||
config,
|
||||
status: Arc::new(tokio::sync::RwLock::new(ChannelStatus::Disconnected)),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Channel for ConsoleChannel {
|
||||
fn config(&self) -> &ChannelConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn connect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Connected;
|
||||
tracing::info!("Console channel connected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn disconnect(&self) -> Result<()> {
|
||||
let mut status = self.status.write().await;
|
||||
*status = ChannelStatus::Disconnected;
|
||||
tracing::info!("Console channel disconnected");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn status(&self) -> ChannelStatus {
|
||||
self.status.read().await.clone()
|
||||
}
|
||||
|
||||
async fn send(&self, message: OutgoingMessage) -> Result<String> {
|
||||
// Print to console for testing
|
||||
let msg_id = format!("console_{}", chrono::Utc::now().timestamp());
|
||||
|
||||
match &message.content {
|
||||
crate::MessageContent::Text { text } => {
|
||||
tracing::info!("[Console] To {}: {}", message.conversation_id, text);
|
||||
}
|
||||
_ => {
|
||||
tracing::info!("[Console] To {}: {:?}", message.conversation_id, message.content);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(msg_id)
|
||||
}
|
||||
|
||||
async fn receive(&self) -> Result<mpsc::Receiver<IncomingMessage>> {
|
||||
let (_tx, rx) = mpsc::channel(100);
|
||||
// Console channel doesn't receive messages automatically
|
||||
// Messages would need to be injected via a separate method
|
||||
Ok(rx)
|
||||
}
|
||||
}
|
||||
@@ -1,5 +0,0 @@
|
||||
//! Channel adapters
|
||||
|
||||
mod console;
|
||||
|
||||
pub use console::ConsoleChannel;
|
||||
@@ -1,94 +0,0 @@
|
||||
//! Channel bridge manager
|
||||
//!
|
||||
//! Coordinates multiple channel adapters and routes messages.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::sync::RwLock;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use super::{Channel, ChannelConfig, OutgoingMessage};
|
||||
|
||||
/// Channel bridge manager
|
||||
pub struct ChannelBridge {
|
||||
channels: RwLock<HashMap<String, Arc<dyn Channel>>>,
|
||||
configs: RwLock<HashMap<String, ChannelConfig>>,
|
||||
}
|
||||
|
||||
impl ChannelBridge {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
channels: RwLock::new(HashMap::new()),
|
||||
configs: RwLock::new(HashMap::new()),
|
||||
}
|
||||
}
|
||||
|
||||
/// Register a channel adapter
|
||||
pub async fn register(&self, channel: Arc<dyn Channel>) {
|
||||
let config = channel.config().clone();
|
||||
let mut channels = self.channels.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
channels.insert(config.id.clone(), channel);
|
||||
configs.insert(config.id.clone(), config);
|
||||
}
|
||||
|
||||
/// Get a channel by ID
|
||||
pub async fn get(&self, id: &str) -> Option<Arc<dyn Channel>> {
|
||||
let channels = self.channels.read().await;
|
||||
channels.get(id).cloned()
|
||||
}
|
||||
|
||||
/// Get channel configuration
|
||||
pub async fn get_config(&self, id: &str) -> Option<ChannelConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.get(id).cloned()
|
||||
}
|
||||
|
||||
/// List all channels
|
||||
pub async fn list(&self) -> Vec<ChannelConfig> {
|
||||
let configs = self.configs.read().await;
|
||||
configs.values().cloned().collect()
|
||||
}
|
||||
|
||||
/// Connect all channels
|
||||
pub async fn connect_all(&self) -> Result<()> {
|
||||
let channels = self.channels.read().await;
|
||||
for channel in channels.values() {
|
||||
channel.connect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Disconnect all channels
|
||||
pub async fn disconnect_all(&self) -> Result<()> {
|
||||
let channels = self.channels.read().await;
|
||||
for channel in channels.values() {
|
||||
channel.disconnect().await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send message through a specific channel
|
||||
pub async fn send(&self, channel_id: &str, message: OutgoingMessage) -> Result<String> {
|
||||
let channel = self.get(channel_id).await
|
||||
.ok_or_else(|| zclaw_types::ZclawError::NotFound(format!("Channel not found: {}", channel_id)))?;
|
||||
|
||||
channel.send(message).await
|
||||
}
|
||||
|
||||
/// Remove a channel
|
||||
pub async fn remove(&self, id: &str) {
|
||||
let mut channels = self.channels.write().await;
|
||||
let mut configs = self.configs.write().await;
|
||||
|
||||
channels.remove(id);
|
||||
configs.remove(id);
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ChannelBridge {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
@@ -1,109 +0,0 @@
|
||||
//! Channel trait and types
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use zclaw_types::{Result, AgentId};
|
||||
|
||||
/// Channel configuration
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ChannelConfig {
|
||||
/// Unique channel identifier
|
||||
pub id: String,
|
||||
/// Channel type (telegram, discord, slack, etc.)
|
||||
pub channel_type: String,
|
||||
/// Human-readable name
|
||||
pub name: String,
|
||||
/// Whether the channel is enabled
|
||||
#[serde(default = "default_enabled")]
|
||||
pub enabled: bool,
|
||||
/// Channel-specific configuration
|
||||
#[serde(default)]
|
||||
pub config: serde_json::Value,
|
||||
/// Associated agent for this channel
|
||||
pub agent_id: Option<AgentId>,
|
||||
}
|
||||
|
||||
fn default_enabled() -> bool { true }
|
||||
|
||||
/// Incoming message from a channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct IncomingMessage {
|
||||
/// Message ID from the platform
|
||||
pub platform_id: String,
|
||||
/// Channel/conversation ID
|
||||
pub conversation_id: String,
|
||||
/// Sender information
|
||||
pub sender: MessageSender,
|
||||
/// Message content
|
||||
pub content: MessageContent,
|
||||
/// Timestamp
|
||||
pub timestamp: i64,
|
||||
/// Reply-to message ID if any
|
||||
pub reply_to: Option<String>,
|
||||
}
|
||||
|
||||
/// Message sender information
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct MessageSender {
|
||||
pub id: String,
|
||||
pub name: Option<String>,
|
||||
pub username: Option<String>,
|
||||
pub is_bot: bool,
|
||||
}
|
||||
|
||||
/// Message content types
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "snake_case")]
|
||||
pub enum MessageContent {
|
||||
Text { text: String },
|
||||
Image { url: String, caption: Option<String> },
|
||||
File { url: String, filename: String },
|
||||
Audio { url: String },
|
||||
Video { url: String },
|
||||
Location { latitude: f64, longitude: f64 },
|
||||
Sticker { emoji: Option<String>, url: Option<String> },
|
||||
}
|
||||
|
||||
/// Outgoing message to a channel
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct OutgoingMessage {
|
||||
/// Conversation/channel ID to send to
|
||||
pub conversation_id: String,
|
||||
/// Message content
|
||||
pub content: MessageContent,
|
||||
/// Reply-to message ID if any
|
||||
pub reply_to: Option<String>,
|
||||
/// Whether to send silently (no notification)
|
||||
pub silent: bool,
|
||||
}
|
||||
|
||||
/// Channel connection status
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
|
||||
pub enum ChannelStatus {
|
||||
Disconnected,
|
||||
Connecting,
|
||||
Connected,
|
||||
Error(String),
|
||||
}
|
||||
|
||||
/// Channel trait for platform adapters
|
||||
#[async_trait]
|
||||
pub trait Channel: Send + Sync {
|
||||
/// Get channel configuration
|
||||
fn config(&self) -> &ChannelConfig;
|
||||
|
||||
/// Connect to the platform
|
||||
async fn connect(&self) -> Result<()>;
|
||||
|
||||
/// Disconnect from the platform
|
||||
async fn disconnect(&self) -> Result<()>;
|
||||
|
||||
/// Get current connection status
|
||||
async fn status(&self) -> ChannelStatus;
|
||||
|
||||
/// Send a message
|
||||
async fn send(&self, message: OutgoingMessage) -> Result<String>;
|
||||
|
||||
/// Receive incoming messages (streaming)
|
||||
async fn receive(&self) -> Result<tokio::sync::mpsc::Receiver<IncomingMessage>>;
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user