增加全平台测速

This commit is contained in:
2025-10-26 16:10:01 +08:00
parent f04594e56f
commit 4654c996a7
7 changed files with 1433 additions and 1135 deletions

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

View File

@@ -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;

View File

@@ -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,

View 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

View File

@@ -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
View 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;
}