增加全平台测速
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 agents;
|
||||||
|
pub mod api_nodes;
|
||||||
pub mod ccr;
|
pub mod ccr;
|
||||||
pub mod claude;
|
pub mod claude;
|
||||||
pub mod filesystem;
|
pub mod filesystem;
|
||||||
|
|||||||
@@ -224,6 +224,10 @@ fn main() {
|
|||||||
// Initialize agents database
|
// Initialize agents database
|
||||||
let conn = init_database(&app.handle()).expect("Failed to 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
|
// Load and apply proxy settings from the database
|
||||||
{
|
{
|
||||||
let db = AgentDb(Mutex::new(conn));
|
let db = AgentDb(Mutex::new(conn));
|
||||||
@@ -500,6 +504,14 @@ fn main() {
|
|||||||
test_all_packycode_nodes,
|
test_all_packycode_nodes,
|
||||||
auto_select_best_node,
|
auto_select_best_node,
|
||||||
get_packycode_nodes,
|
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
|
// File System
|
||||||
read_directory_tree,
|
read_directory_tree,
|
||||||
search_files_by_name,
|
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
|
| 'glm' // 智谱GLM
|
||||||
| 'qwen' // 千问Qwen
|
| 'qwen' // 千问Qwen
|
||||||
| 'kimi' // Kimi k2
|
| 'kimi' // Kimi k2
|
||||||
|
| 'minimax' // MiniMax M2
|
||||||
| 'custom'; // 自定义简单配置
|
| 'custom'; // 自定义简单配置
|
||||||
|
|
||||||
/** 认证方式 */
|
/** 认证方式 */
|
||||||
@@ -3250,3 +3251,97 @@ export interface SmartSession {
|
|||||||
/** 会话类型 */
|
/** 会话类型 */
|
||||||
session_type: string;
|
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