增加全平台测速
This commit is contained in:
401
src-tauri/src/commands/api_nodes.rs
Normal file
401
src-tauri/src/commands/api_nodes.rs
Normal file
@@ -0,0 +1,401 @@
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{params, Connection};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
use uuid::Uuid;
|
||||
|
||||
/// API 节点数据结构
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ApiNode {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub adapter: String,
|
||||
pub description: Option<String>,
|
||||
pub enabled: bool,
|
||||
pub is_default: bool,
|
||||
pub created_at: String,
|
||||
pub updated_at: String,
|
||||
}
|
||||
|
||||
/// 创建节点请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CreateApiNodeRequest {
|
||||
pub name: String,
|
||||
pub url: String,
|
||||
pub adapter: String,
|
||||
pub description: Option<String>,
|
||||
}
|
||||
|
||||
/// 更新节点请求
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct UpdateApiNodeRequest {
|
||||
pub name: Option<String>,
|
||||
pub url: Option<String>,
|
||||
pub description: Option<String>,
|
||||
pub enabled: Option<bool>,
|
||||
}
|
||||
|
||||
/// 节点测试结果
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct NodeTestResult {
|
||||
pub node_id: String,
|
||||
pub url: String,
|
||||
pub name: String,
|
||||
pub response_time: Option<u64>,
|
||||
pub status: String,
|
||||
pub error: Option<String>,
|
||||
}
|
||||
|
||||
/// 获取数据库连接
|
||||
fn get_connection() -> Result<Connection> {
|
||||
let db_path = get_nodes_db_path()?;
|
||||
let conn = Connection::open(&db_path)
|
||||
.context(format!("Failed to open database at {:?}", db_path))?;
|
||||
Ok(conn)
|
||||
}
|
||||
|
||||
/// 获取节点数据库路径
|
||||
fn get_nodes_db_path() -> Result<PathBuf> {
|
||||
let home = dirs::home_dir().context("Could not find home directory")?;
|
||||
let db_dir = home.join(".claudia");
|
||||
std::fs::create_dir_all(&db_dir).context("Failed to create database directory")?;
|
||||
Ok(db_dir.join("api_nodes.db"))
|
||||
}
|
||||
|
||||
/// 初始化数据库表
|
||||
pub fn init_nodes_db() -> Result<()> {
|
||||
let conn = get_connection()?;
|
||||
|
||||
conn.execute(
|
||||
"CREATE TABLE IF NOT EXISTS api_nodes (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
url TEXT NOT NULL UNIQUE,
|
||||
adapter TEXT NOT NULL,
|
||||
description TEXT,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
is_default INTEGER DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
// 创建索引
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_nodes_adapter ON api_nodes(adapter)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_nodes_enabled ON api_nodes(enabled)",
|
||||
[],
|
||||
)?;
|
||||
conn.execute(
|
||||
"CREATE INDEX IF NOT EXISTS idx_api_nodes_is_default ON api_nodes(is_default)",
|
||||
[],
|
||||
)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 预设节点配置
|
||||
const DEFAULT_NODES: &[(&str, &str, &str, &str)] = &[
|
||||
// PackyCode
|
||||
("🚌 默认节点", "https://www.packyapi.com", "packycode", "PackyCode 默认节点"),
|
||||
("⚖️ 负载均衡", "https://api-slb.packyapi.com", "packycode", "PackyCode 负载均衡节点"),
|
||||
|
||||
// DeepSeek
|
||||
("默认节点", "https://api.deepseek.com/anthropic", "deepseek", "DeepSeek 官方节点"),
|
||||
|
||||
// GLM
|
||||
("默认节点", "https://open.bigmodel.cn/api/anthropic", "glm", "智谱 GLM 官方节点"),
|
||||
|
||||
// Qwen
|
||||
("默认节点", "https://dashscope.aliyuncs.com/api/v2/apps/claude-code-proxy", "qwen", "通义千问官方节点"),
|
||||
|
||||
// Kimi
|
||||
("默认节点", "https://api.moonshot.cn/anthropic", "kimi", "Moonshot Kimi 官方节点"),
|
||||
|
||||
// MiniMax
|
||||
("默认节点", "https://api.minimaxi.com/anthropic", "minimax", "MiniMax 官方节点"),
|
||||
("备用节点", "https://api.minimaxi.io/anthropic", "minimax", "MiniMax 备用节点"),
|
||||
];
|
||||
|
||||
/// 初始化预设节点
|
||||
#[tauri::command]
|
||||
pub async fn init_default_nodes() -> Result<(), String> {
|
||||
let conn = get_connection().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
for (name, url, adapter, description) in DEFAULT_NODES {
|
||||
// 检查是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM api_nodes WHERE url = ?1",
|
||||
params![url],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !exists {
|
||||
let id = Uuid::new_v4().to_string();
|
||||
conn.execute(
|
||||
"INSERT INTO api_nodes (id, name, url, adapter, description, enabled, is_default, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, ?6, ?7)",
|
||||
params![id, name, url, adapter, description, now, now],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 获取节点列表
|
||||
#[tauri::command]
|
||||
pub async fn list_api_nodes(
|
||||
adapter: Option<String>,
|
||||
enabled_only: Option<bool>,
|
||||
) -> Result<Vec<ApiNode>, String> {
|
||||
let conn = get_connection().map_err(|e| e.to_string())?;
|
||||
|
||||
let mut sql = "SELECT id, name, url, adapter, description, enabled, is_default, created_at, updated_at FROM api_nodes WHERE 1=1".to_string();
|
||||
|
||||
if let Some(adapter_filter) = &adapter {
|
||||
sql.push_str(&format!(" AND adapter = '{}'", adapter_filter));
|
||||
}
|
||||
|
||||
if enabled_only.unwrap_or(false) {
|
||||
sql.push_str(" AND enabled = 1");
|
||||
}
|
||||
|
||||
sql.push_str(" ORDER BY is_default DESC, created_at ASC");
|
||||
|
||||
let mut stmt = conn.prepare(&sql).map_err(|e| e.to_string())?;
|
||||
let nodes = stmt
|
||||
.query_map([], |row| {
|
||||
Ok(ApiNode {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url: row.get(2)?,
|
||||
adapter: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
enabled: row.get::<_, i32>(5)? != 0,
|
||||
is_default: row.get::<_, i32>(6)? != 0,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
})
|
||||
.map_err(|e| e.to_string())?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(nodes)
|
||||
}
|
||||
|
||||
/// 创建节点
|
||||
#[tauri::command]
|
||||
pub async fn create_api_node(request: CreateApiNodeRequest) -> Result<ApiNode, String> {
|
||||
let conn = get_connection().map_err(|e| e.to_string())?;
|
||||
let id = Uuid::new_v4().to_string();
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 检查 URL 是否已存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM api_nodes WHERE url = ?1",
|
||||
params![&request.url],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if exists {
|
||||
return Err("节点 URL 已存在".to_string());
|
||||
}
|
||||
|
||||
conn.execute(
|
||||
"INSERT INTO api_nodes (id, name, url, adapter, description, enabled, is_default, created_at, updated_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, 1, 0, ?6, ?7)",
|
||||
params![
|
||||
&id,
|
||||
&request.name,
|
||||
&request.url,
|
||||
&request.adapter,
|
||||
&request.description,
|
||||
&now,
|
||||
&now
|
||||
],
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(ApiNode {
|
||||
id,
|
||||
name: request.name,
|
||||
url: request.url,
|
||||
adapter: request.adapter,
|
||||
description: request.description,
|
||||
enabled: true,
|
||||
is_default: false,
|
||||
created_at: now.clone(),
|
||||
updated_at: now,
|
||||
})
|
||||
}
|
||||
|
||||
/// 更新节点
|
||||
#[tauri::command]
|
||||
pub async fn update_api_node(id: String, request: UpdateApiNodeRequest) -> Result<ApiNode, String> {
|
||||
let conn = get_connection().map_err(|e| e.to_string())?;
|
||||
let now = chrono::Utc::now().to_rfc3339();
|
||||
|
||||
// 检查节点是否存在
|
||||
let exists: bool = conn
|
||||
.query_row(
|
||||
"SELECT COUNT(*) > 0 FROM api_nodes WHERE id = ?1",
|
||||
params![&id],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
if !exists {
|
||||
return Err("节点不存在".to_string());
|
||||
}
|
||||
|
||||
// 构建动态 SQL
|
||||
let mut updates = Vec::new();
|
||||
let mut params_vec: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
|
||||
|
||||
if let Some(name) = &request.name {
|
||||
updates.push("name = ?");
|
||||
params_vec.push(Box::new(name.clone()));
|
||||
}
|
||||
if let Some(url) = &request.url {
|
||||
updates.push("url = ?");
|
||||
params_vec.push(Box::new(url.clone()));
|
||||
}
|
||||
if let Some(description) = &request.description {
|
||||
updates.push("description = ?");
|
||||
params_vec.push(Box::new(description.clone()));
|
||||
}
|
||||
if let Some(enabled) = request.enabled {
|
||||
updates.push("enabled = ?");
|
||||
params_vec.push(Box::new(if enabled { 1 } else { 0 }));
|
||||
}
|
||||
|
||||
updates.push("updated_at = ?");
|
||||
params_vec.push(Box::new(now.clone()));
|
||||
params_vec.push(Box::new(id.clone()));
|
||||
|
||||
let sql = format!(
|
||||
"UPDATE api_nodes SET {} WHERE id = ?",
|
||||
updates.join(", ")
|
||||
);
|
||||
|
||||
let params_refs: Vec<&dyn rusqlite::ToSql> = params_vec.iter().map(|p| p.as_ref()).collect();
|
||||
conn.execute(&sql, params_refs.as_slice())
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 获取更新后的节点
|
||||
let node = conn
|
||||
.query_row(
|
||||
"SELECT id, name, url, adapter, description, enabled, is_default, created_at, updated_at FROM api_nodes WHERE id = ?1",
|
||||
params![&id],
|
||||
|row| {
|
||||
Ok(ApiNode {
|
||||
id: row.get(0)?,
|
||||
name: row.get(1)?,
|
||||
url: row.get(2)?,
|
||||
adapter: row.get(3)?,
|
||||
description: row.get(4)?,
|
||||
enabled: row.get::<_, i32>(5)? != 0,
|
||||
is_default: row.get::<_, i32>(6)? != 0,
|
||||
created_at: row.get(7)?,
|
||||
updated_at: row.get(8)?,
|
||||
})
|
||||
},
|
||||
)
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(node)
|
||||
}
|
||||
|
||||
/// 删除节点
|
||||
#[tauri::command]
|
||||
pub async fn delete_api_node(id: String) -> Result<(), String> {
|
||||
let conn = get_connection().map_err(|e| e.to_string())?;
|
||||
|
||||
conn.execute("DELETE FROM api_nodes WHERE id = ?1", params![&id])
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// 测试单个节点
|
||||
#[tauri::command]
|
||||
pub async fn test_api_node(url: String, timeout_ms: Option<u64>) -> Result<NodeTestResult, String> {
|
||||
let timeout = std::time::Duration::from_millis(timeout_ms.unwrap_or(5000));
|
||||
let start = std::time::Instant::now();
|
||||
|
||||
let client = reqwest::Client::builder()
|
||||
.timeout(timeout)
|
||||
.build()
|
||||
.map_err(|e| e.to_string())?;
|
||||
|
||||
// 使用 HEAD 请求测试连通性,更轻量且不会触发 API 调用
|
||||
match client.head(&url).send().await {
|
||||
Ok(response) => {
|
||||
let response_time = start.elapsed().as_millis() as u64;
|
||||
// 允许 2xx, 3xx, 4xx 状态码,说明服务器在线(5xx 视为失败)
|
||||
let status_code = response.status();
|
||||
let status = if status_code.is_success()
|
||||
|| status_code.is_redirection()
|
||||
|| status_code.is_client_error() {
|
||||
"success"
|
||||
} else {
|
||||
"failed"
|
||||
};
|
||||
|
||||
Ok(NodeTestResult {
|
||||
node_id: String::new(),
|
||||
url: url.clone(),
|
||||
name: String::new(),
|
||||
response_time: Some(response_time),
|
||||
status: status.to_string(),
|
||||
error: if status == "failed" {
|
||||
Some(format!("HTTP {}", status_code))
|
||||
} else {
|
||||
None
|
||||
},
|
||||
})
|
||||
}
|
||||
Err(e) => Ok(NodeTestResult {
|
||||
node_id: String::new(),
|
||||
url: url.clone(),
|
||||
name: String::new(),
|
||||
response_time: None,
|
||||
status: "failed".to_string(),
|
||||
error: Some(e.to_string()),
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// 批量测试节点
|
||||
#[tauri::command]
|
||||
pub async fn test_all_api_nodes(
|
||||
adapter: Option<String>,
|
||||
timeout_ms: Option<u64>,
|
||||
) -> Result<Vec<NodeTestResult>, String> {
|
||||
let nodes = list_api_nodes(adapter, Some(true)).await?;
|
||||
let mut results = Vec::new();
|
||||
|
||||
for node in nodes {
|
||||
let result = test_api_node(node.url.clone(), timeout_ms).await?;
|
||||
results.push(NodeTestResult {
|
||||
node_id: node.id.clone(),
|
||||
name: node.name.clone(),
|
||||
..result
|
||||
});
|
||||
}
|
||||
|
||||
Ok(results)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
pub mod agents;
|
||||
pub mod api_nodes;
|
||||
pub mod ccr;
|
||||
pub mod claude;
|
||||
pub mod filesystem;
|
||||
|
||||
@@ -224,6 +224,10 @@ fn main() {
|
||||
// Initialize agents database
|
||||
let conn = init_database(&app.handle()).expect("Failed to initialize agents database");
|
||||
|
||||
// Initialize API nodes database
|
||||
commands::api_nodes::init_nodes_db()
|
||||
.expect("Failed to initialize API nodes database");
|
||||
|
||||
// Load and apply proxy settings from the database
|
||||
{
|
||||
let db = AgentDb(Mutex::new(conn));
|
||||
@@ -500,6 +504,14 @@ fn main() {
|
||||
test_all_packycode_nodes,
|
||||
auto_select_best_node,
|
||||
get_packycode_nodes,
|
||||
// API Nodes Management
|
||||
commands::api_nodes::init_default_nodes,
|
||||
commands::api_nodes::list_api_nodes,
|
||||
commands::api_nodes::create_api_node,
|
||||
commands::api_nodes::update_api_node,
|
||||
commands::api_nodes::delete_api_node,
|
||||
commands::api_nodes::test_api_node,
|
||||
commands::api_nodes::test_all_api_nodes,
|
||||
// File System
|
||||
read_directory_tree,
|
||||
search_files_by_name,
|
||||
|
||||
625
src/components/NodeManager/index.tsx
Normal file
625
src/components/NodeManager/index.tsx
Normal file
@@ -0,0 +1,625 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertCircle,
|
||||
CheckCircle2,
|
||||
Settings,
|
||||
Plus,
|
||||
Edit,
|
||||
Trash2,
|
||||
Zap,
|
||||
Loader2,
|
||||
} from 'lucide-react';
|
||||
import * as api from '@/lib/api';
|
||||
import type { ApiNode, CreateApiNodeRequest, NodeTestResult } from '@/lib/api';
|
||||
|
||||
interface NodeSelectorProps {
|
||||
adapter: api.RelayStationAdapter;
|
||||
value?: string;
|
||||
onChange: (url: string, node?: ApiNode) => void;
|
||||
allowManualInput?: boolean;
|
||||
showToast?: (message: string, type: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点选择器组件
|
||||
* 用于中转站表单中选择API节点
|
||||
*/
|
||||
export const NodeSelector: React.FC<NodeSelectorProps> = ({
|
||||
adapter,
|
||||
value = '',
|
||||
onChange,
|
||||
allowManualInput = true,
|
||||
showToast = (msg, _type) => alert(msg), // 默认使用 alert
|
||||
}) => {
|
||||
const [showDialog, setShowDialog] = useState(false);
|
||||
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
||||
const [currentNode, setCurrentNode] = useState<ApiNode | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
loadNodes();
|
||||
}, [adapter]);
|
||||
|
||||
useEffect(() => {
|
||||
if (value && nodes.length > 0) {
|
||||
const node = nodes.find(n => n.url === value);
|
||||
setCurrentNode(node || null);
|
||||
}
|
||||
}, [value, nodes]);
|
||||
|
||||
const loadNodes = async () => {
|
||||
try {
|
||||
const allNodes = await api.listApiNodes(adapter, true);
|
||||
setNodes(allNodes);
|
||||
} catch (error) {
|
||||
console.error('Failed to load nodes:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSelectNode = (node: ApiNode) => {
|
||||
onChange(node.url, node);
|
||||
setShowDialog(false);
|
||||
};
|
||||
|
||||
const handleSaveCustomNode = async () => {
|
||||
if (!value.trim() || value.startsWith('http') === false) {
|
||||
showToast('请输入有效的 URL', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// 检查是否已存在
|
||||
const existingNode = nodes.find(n => n.url === value);
|
||||
if (existingNode) {
|
||||
showToast('该节点已存在', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await api.createApiNode({
|
||||
name: `自定义节点 - ${new URL(value).hostname}`,
|
||||
url: value,
|
||||
adapter: adapter,
|
||||
description: '用户手动添加的节点',
|
||||
});
|
||||
showToast('节点保存成功', 'success');
|
||||
loadNodes();
|
||||
} catch (error) {
|
||||
showToast('保存失败', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<Label>节点地址 *</Label>
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={value}
|
||||
onChange={(e) => allowManualInput && onChange(e.target.value)}
|
||||
placeholder="https://api.example.com"
|
||||
readOnly={!allowManualInput}
|
||||
className="flex-1"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowDialog(true)}
|
||||
title="管理节点"
|
||||
>
|
||||
<Settings className="h-4 w-4" />
|
||||
</Button>
|
||||
{allowManualInput && value && !currentNode && value.startsWith('http') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={handleSaveCustomNode}
|
||||
title="保存为节点"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
{currentNode && adapter !== 'custom' && (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<span>📍 当前节点:{currentNode.name}</span>
|
||||
{currentNode.is_default && (
|
||||
<Badge variant="secondary" className="text-xs">预设</Badge>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<NodeManagerDialog
|
||||
open={showDialog}
|
||||
onOpenChange={setShowDialog}
|
||||
adapter={adapter}
|
||||
onSelectNode={handleSelectNode}
|
||||
currentUrl={value}
|
||||
showToast={showToast}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
interface NodeManagerDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
adapter?: api.RelayStationAdapter;
|
||||
onSelectNode?: (node: ApiNode) => void;
|
||||
currentUrl?: string;
|
||||
showToast?: (message: string, type: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从中间截断 URL,保留开头和结尾
|
||||
*/
|
||||
const truncateUrl = (url: string, maxLength: number = 50): string => {
|
||||
if (url.length <= maxLength) return url;
|
||||
|
||||
const start = Math.floor(maxLength * 0.6);
|
||||
const end = Math.floor(maxLength * 0.4);
|
||||
|
||||
return url.substring(0, start) + '...' + url.substring(url.length - end);
|
||||
};
|
||||
|
||||
/**
|
||||
* 节点管理弹窗
|
||||
* 支持增删改查和测速功能
|
||||
*/
|
||||
const NodeManagerDialog: React.FC<NodeManagerDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
adapter: filterAdapter,
|
||||
onSelectNode,
|
||||
currentUrl,
|
||||
showToast = (msg) => alert(msg),
|
||||
}) => {
|
||||
const [nodes, setNodes] = useState<ApiNode[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResults, setTestResults] = useState<Record<string, NodeTestResult>>({});
|
||||
const [editingNode, setEditingNode] = useState<ApiNode | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [enabledOnly, setEnabledOnly] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
loadNodes();
|
||||
// 首次打开时初始化预设节点
|
||||
api.initDefaultNodes().catch(console.error);
|
||||
}
|
||||
}, [open, filterAdapter, enabledOnly]);
|
||||
|
||||
const loadNodes = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const allNodes = await api.listApiNodes(filterAdapter, enabledOnly);
|
||||
setNodes(allNodes);
|
||||
} catch (error) {
|
||||
showToast('加载节点失败', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestAll = async () => {
|
||||
setTesting(true);
|
||||
setTestResults({});
|
||||
try {
|
||||
const results = await api.testAllApiNodes(filterAdapter, 5000);
|
||||
const resultsMap: Record<string, NodeTestResult> = {};
|
||||
results.forEach(r => {
|
||||
resultsMap[r.node_id] = r;
|
||||
});
|
||||
setTestResults(resultsMap);
|
||||
} catch (error) {
|
||||
showToast('测速失败', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleTestOne = async (node: ApiNode) => {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[node.id]: { ...testResults[node.id], status: 'testing' } as NodeTestResult,
|
||||
}));
|
||||
try {
|
||||
const result = await api.testApiNode(node.url, 5000);
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[node.id]: { ...result, node_id: node.id, name: node.name },
|
||||
}));
|
||||
} catch (error) {
|
||||
setTestResults(prev => ({
|
||||
...prev,
|
||||
[node.id]: {
|
||||
node_id: node.id,
|
||||
url: node.url,
|
||||
name: node.name,
|
||||
response_time: null,
|
||||
status: 'failed',
|
||||
error: String(error),
|
||||
},
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (node: ApiNode) => {
|
||||
if (!confirm(`确定要删除节点 "${node.name}" 吗?`)) return;
|
||||
|
||||
try {
|
||||
await api.deleteApiNode(node.id);
|
||||
showToast('删除成功', 'success');
|
||||
loadNodes();
|
||||
} catch (error) {
|
||||
showToast('删除失败', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleEnable = async (node: ApiNode) => {
|
||||
try {
|
||||
await api.updateApiNode(node.id, { enabled: !node.enabled });
|
||||
loadNodes();
|
||||
} catch (error) {
|
||||
showToast('更新失败', 'error');
|
||||
console.error(error);
|
||||
}
|
||||
};
|
||||
|
||||
const getStatusBadge = (node: ApiNode) => {
|
||||
const result = testResults[node.id];
|
||||
if (!result) {
|
||||
return <Badge variant="outline" className="text-xs">未测试</Badge>;
|
||||
}
|
||||
if (result.status === 'testing') {
|
||||
return (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
<Loader2 className="h-3 w-3 animate-spin mr-1" />
|
||||
测试中
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
if (result.status === 'success') {
|
||||
return (
|
||||
<Badge variant="default" className="text-xs bg-green-600">
|
||||
<CheckCircle2 className="h-3 w-3 mr-1" />
|
||||
{result.response_time}ms
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
<AlertCircle className="h-3 w-3 mr-1" />
|
||||
失败
|
||||
</Badge>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[800px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<DialogTitle>节点管理</DialogTitle>
|
||||
<DialogDescription>管理 API 节点,支持增删改查和测速</DialogDescription>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTestAll}
|
||||
disabled={testing || nodes.length === 0}
|
||||
>
|
||||
<Zap className="h-4 w-4 mr-2" />
|
||||
{testing ? '测速中...' : '全部测速'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 工具栏 */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNode(null);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
添加节点
|
||||
</Button>
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Switch
|
||||
checked={enabledOnly}
|
||||
onCheckedChange={setEnabledOnly}
|
||||
/>
|
||||
<Label className="text-sm">只看启用</Label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 节点列表 */}
|
||||
{loading ? (
|
||||
<div className="text-center py-8">
|
||||
<Loader2 className="h-8 w-8 animate-spin mx-auto text-muted-foreground" />
|
||||
</div>
|
||||
) : nodes.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
暂无节点
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{nodes.map((node) => (
|
||||
<div
|
||||
key={node.id}
|
||||
className={`p-3 border rounded-lg flex items-center justify-between transition-all ${
|
||||
currentUrl === node.url
|
||||
? 'ring-2 ring-blue-500 bg-blue-50/50 dark:bg-blue-950/20'
|
||||
: 'hover:bg-muted/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={(e) => {
|
||||
// 如果点击的是操作按钮区域,不触发选择
|
||||
if ((e.target as HTMLElement).closest('.action-buttons')) {
|
||||
return;
|
||||
}
|
||||
if (onSelectNode) {
|
||||
onSelectNode(node);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center gap-3 flex-1 min-w-0">
|
||||
<div onClick={(e) => e.stopPropagation()}>
|
||||
<Switch
|
||||
checked={node.enabled}
|
||||
onCheckedChange={() => handleToggleEnable(node)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium">{node.name}</span>
|
||||
{node.is_default && (
|
||||
<Badge variant="secondary" className="text-xs">预设</Badge>
|
||||
)}
|
||||
{getStatusBadge(node)}
|
||||
</div>
|
||||
<div
|
||||
className="text-sm text-muted-foreground font-mono"
|
||||
title={node.url}
|
||||
>
|
||||
{truncateUrl(node.url, 60)}
|
||||
</div>
|
||||
{node.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1">{node.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1 action-buttons" onClick={(e) => e.stopPropagation()}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleTestOne(node)}
|
||||
disabled={testResults[node.id]?.status === 'testing'}
|
||||
title="测速"
|
||||
>
|
||||
<Zap className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditingNode(node);
|
||||
setShowForm(true);
|
||||
}}
|
||||
title="编辑"
|
||||
>
|
||||
<Edit className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(node)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
title="删除"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 添加/编辑表单对话框 */}
|
||||
<NodeFormDialog
|
||||
open={showForm}
|
||||
onOpenChange={setShowForm}
|
||||
node={editingNode}
|
||||
defaultAdapter={filterAdapter}
|
||||
onSuccess={() => {
|
||||
setShowForm(false);
|
||||
setEditingNode(null);
|
||||
loadNodes();
|
||||
}}
|
||||
showToast={showToast}
|
||||
/>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
interface NodeFormDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
node?: ApiNode | null;
|
||||
defaultAdapter?: api.RelayStationAdapter;
|
||||
onSuccess: () => void;
|
||||
showToast?: (message: string, type: 'success' | 'error') => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点添加/编辑表单
|
||||
*/
|
||||
const NodeFormDialog: React.FC<NodeFormDialogProps> = ({
|
||||
open,
|
||||
onOpenChange,
|
||||
node,
|
||||
defaultAdapter,
|
||||
onSuccess,
|
||||
showToast = (msg) => alert(msg),
|
||||
}) => {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [formData, setFormData] = useState<CreateApiNodeRequest>({
|
||||
name: '',
|
||||
url: '',
|
||||
adapter: defaultAdapter || 'packycode',
|
||||
description: '',
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (node) {
|
||||
setFormData({
|
||||
name: node.name,
|
||||
url: node.url,
|
||||
adapter: node.adapter,
|
||||
description: node.description || '',
|
||||
});
|
||||
} else {
|
||||
setFormData({
|
||||
name: '',
|
||||
url: '',
|
||||
adapter: defaultAdapter || 'packycode',
|
||||
description: '',
|
||||
});
|
||||
}
|
||||
}, [node, defaultAdapter, open]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
if (node) {
|
||||
await api.updateApiNode(node.id, {
|
||||
name: formData.name,
|
||||
url: formData.url,
|
||||
description: formData.description,
|
||||
});
|
||||
showToast('更新成功', 'success');
|
||||
} else {
|
||||
await api.createApiNode(formData);
|
||||
showToast('创建成功', 'success');
|
||||
}
|
||||
onSuccess();
|
||||
} catch (error) {
|
||||
showToast(node ? '更新失败' : '创建失败', 'error');
|
||||
console.error(error);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{node ? '编辑节点' : '添加节点'}</DialogTitle>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="name">节点名称 *</Label>
|
||||
<Input
|
||||
id="name"
|
||||
value={formData.name}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, name: e.target.value }))}
|
||||
placeholder="例如:🚀 我的自定义节点"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="url">节点地址 *</Label>
|
||||
<Input
|
||||
id="url"
|
||||
type="url"
|
||||
value={formData.url}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, url: e.target.value }))}
|
||||
placeholder="https://api.example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
{!node && (
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="adapter">适配器类型 *</Label>
|
||||
<Select
|
||||
value={formData.adapter}
|
||||
onValueChange={(value) => setFormData(prev => ({ ...prev, adapter: value as api.RelayStationAdapter }))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="packycode">PackyCode</SelectItem>
|
||||
<SelectItem value="deepseek">DeepSeek</SelectItem>
|
||||
<SelectItem value="glm">智谱 GLM</SelectItem>
|
||||
<SelectItem value="qwen">通义千问</SelectItem>
|
||||
<SelectItem value="kimi">Moonshot Kimi</SelectItem>
|
||||
<SelectItem value="minimax">MiniMax</SelectItem>
|
||||
<SelectItem value="custom">自定义</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="description">描述(可选)</Label>
|
||||
<Input
|
||||
id="description"
|
||||
value={formData.description}
|
||||
onChange={(e) => setFormData(prev => ({ ...prev, description: e.target.value }))}
|
||||
placeholder="节点描述信息"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? '保存中...' : '保存'}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default NodeSelector;
|
||||
File diff suppressed because it is too large
Load Diff
@@ -515,6 +515,7 @@ export type RelayStationAdapter =
|
||||
| 'glm' // 智谱GLM
|
||||
| 'qwen' // 千问Qwen
|
||||
| 'kimi' // Kimi k2
|
||||
| 'minimax' // MiniMax M2
|
||||
| 'custom'; // 自定义简单配置
|
||||
|
||||
/** 认证方式 */
|
||||
@@ -3250,3 +3251,97 @@ export interface SmartSession {
|
||||
/** 会话类型 */
|
||||
session_type: string;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// API Nodes Management
|
||||
// ============================================================
|
||||
|
||||
export interface ApiNode {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
adapter: RelayStationAdapter;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateApiNodeRequest {
|
||||
name: string;
|
||||
url: string;
|
||||
adapter: RelayStationAdapter;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface UpdateApiNodeRequest {
|
||||
name?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface NodeTestResult {
|
||||
node_id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
response_time: number | null;
|
||||
status: 'testing' | 'success' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化预设节点
|
||||
*/
|
||||
export async function initDefaultNodes(): Promise<void> {
|
||||
return invoke('init_default_nodes');
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取节点列表
|
||||
*/
|
||||
export async function listApiNodes(
|
||||
adapter?: RelayStationAdapter,
|
||||
enabledOnly?: boolean
|
||||
): Promise<ApiNode[]> {
|
||||
return invoke('list_api_nodes', { adapter, enabledOnly });
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建节点
|
||||
*/
|
||||
export async function createApiNode(request: CreateApiNodeRequest): Promise<ApiNode> {
|
||||
return invoke('create_api_node', { request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点
|
||||
*/
|
||||
export async function updateApiNode(id: string, request: UpdateApiNodeRequest): Promise<ApiNode> {
|
||||
return invoke('update_api_node', { id, request });
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除节点(预设节点不可删除)
|
||||
*/
|
||||
export async function deleteApiNode(id: string): Promise<void> {
|
||||
return invoke('delete_api_node', { id });
|
||||
}
|
||||
|
||||
/**
|
||||
* 测试单个节点
|
||||
*/
|
||||
export async function testApiNode(url: string, timeoutMs?: number): Promise<NodeTestResult> {
|
||||
return invoke('test_api_node', { url, timeoutMs });
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量测试节点
|
||||
*/
|
||||
export async function testAllApiNodes(
|
||||
adapter?: RelayStationAdapter,
|
||||
timeoutMs?: number
|
||||
): Promise<NodeTestResult[]> {
|
||||
return invoke('test_all_api_nodes', { adapter, timeoutMs });
|
||||
}
|
||||
|
||||
48
src/types/api-nodes.ts
Normal file
48
src/types/api-nodes.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
import { RelayStationAdapter } from '@/lib/api';
|
||||
|
||||
/**
|
||||
* API 节点数据结构
|
||||
*/
|
||||
export interface ApiNode {
|
||||
id: string;
|
||||
name: string;
|
||||
url: string;
|
||||
adapter: RelayStationAdapter;
|
||||
description?: string;
|
||||
enabled: boolean;
|
||||
is_default: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 创建节点请求
|
||||
*/
|
||||
export interface CreateApiNodeRequest {
|
||||
name: string;
|
||||
url: string;
|
||||
adapter: RelayStationAdapter;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 更新节点请求
|
||||
*/
|
||||
export interface UpdateApiNodeRequest {
|
||||
name?: string;
|
||||
url?: string;
|
||||
description?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 节点测试结果
|
||||
*/
|
||||
export interface NodeTestResult {
|
||||
node_id: string;
|
||||
url: string;
|
||||
name: string;
|
||||
response_time: number | null;
|
||||
status: 'testing' | 'success' | 'failed';
|
||||
error?: string;
|
||||
}
|
||||
Reference in New Issue
Block a user