增加额度查询
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
use async_trait::async_trait;
|
use async_trait::async_trait;
|
||||||
use anyhow::{anyhow, Result};
|
use anyhow::{anyhow, Result};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
@@ -986,4 +987,139 @@ pub async fn relay_station_delete_token(
|
|||||||
log::error!("Failed to delete token: {}", e);
|
log::error!("Failed to delete token: {}", e);
|
||||||
i18n::t("relay_adapter.delete_token_failed")
|
i18n::t("relay_adapter.delete_token_failed")
|
||||||
})
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/// PackyCode 用户额度信息
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct PackycodeUserQuota {
|
||||||
|
pub daily_budget_usd: f64, // 日预算(美元)
|
||||||
|
pub daily_spent_usd: f64, // 日已使用(美元)
|
||||||
|
pub monthly_budget_usd: f64, // 月预算(美元)
|
||||||
|
pub monthly_spent_usd: f64, // 月已使用(美元)
|
||||||
|
pub balance_usd: f64, // 账户余额(美元)
|
||||||
|
pub total_spent_usd: f64, // 总消费(美元)
|
||||||
|
pub plan_type: String, // 计划类型 (pro, basic, etc.)
|
||||||
|
pub plan_expires_at: String, // 计划过期时间
|
||||||
|
pub username: Option<String>, // 用户名
|
||||||
|
pub email: Option<String>, // 邮箱
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 获取 PackyCode 用户信息(额度等)
|
||||||
|
#[command]
|
||||||
|
pub async fn packycode_get_user_quota(station_id: String, db: State<'_, AgentDb>) -> Result<PackycodeUserQuota, String> {
|
||||||
|
// 先从数据库获取中转站信息,然后释放锁
|
||||||
|
let station = {
|
||||||
|
let conn = db.0.lock().map_err(|e| {
|
||||||
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
|
i18n::t("database.lock_failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 获取中转站信息
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM relay_stations WHERE id = ?1")
|
||||||
|
.map_err(|e| {
|
||||||
|
log::error!("Failed to prepare statement: {}", e);
|
||||||
|
i18n::t("database.query_failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
stmt.query_row([&station_id], |row| {
|
||||||
|
use crate::commands::relay_stations::RelayStation;
|
||||||
|
RelayStation::from_row(row)
|
||||||
|
}).map_err(|e| {
|
||||||
|
log::error!("Failed to get relay station: {}", e);
|
||||||
|
i18n::t("relay_station.not_found")
|
||||||
|
})?
|
||||||
|
}; // 这里释放数据库连接
|
||||||
|
|
||||||
|
// 只有 PackyCode 适配器支持此功能
|
||||||
|
if station.adapter.as_str() != "packycode" {
|
||||||
|
return Err("此功能仅支持 PackyCode 中转站".to_string());
|
||||||
|
}
|
||||||
|
|
||||||
|
// 根据服务类型构建不同的 URL
|
||||||
|
let url = if station.api_url.contains("share-api") || station.api_url.contains("share.packycode") {
|
||||||
|
// 滴滴车服务
|
||||||
|
"https://share.packycode.com/api/backend/users/info"
|
||||||
|
} else {
|
||||||
|
// 公交车服务
|
||||||
|
"https://www.packycode.com/api/backend/users/info"
|
||||||
|
};
|
||||||
|
|
||||||
|
// 创建 HTTP 客户端
|
||||||
|
let client = Client::builder()
|
||||||
|
.timeout(Duration::from_secs(10))
|
||||||
|
.build()
|
||||||
|
.map_err(|e| format!("创建 HTTP 客户端失败: {}", e))?;
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
let response = client
|
||||||
|
.get(url)
|
||||||
|
.header("Authorization", format!("Bearer {}", station.system_token))
|
||||||
|
.header("User-Agent", "Apifox/1.0.0 (https://apifox.com)")
|
||||||
|
.header("Accept", "*/*")
|
||||||
|
.header("Host", if url.contains("share.packycode.com") { "share.packycode.com" } else { "www.packycode.com" })
|
||||||
|
.header("Connection", "keep-alive")
|
||||||
|
.send()
|
||||||
|
.await
|
||||||
|
.map_err(|e| format!("请求失败: {}", e))?;
|
||||||
|
|
||||||
|
// 检查响应状态
|
||||||
|
if !response.status().is_success() {
|
||||||
|
let status = response.status();
|
||||||
|
let error_text = response.text().await.unwrap_or_default();
|
||||||
|
|
||||||
|
return Err(match status.as_u16() {
|
||||||
|
401 => "Token 无效或已过期".to_string(),
|
||||||
|
403 => "权限不足".to_string(),
|
||||||
|
400 => format!("请求参数错误: {}", error_text),
|
||||||
|
_ => format!("请求失败 ({}): {}", status, error_text),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析响应
|
||||||
|
let response_data: serde_json::Value = response.json().await
|
||||||
|
.map_err(|e| format!("解析响应失败: {}", e))?;
|
||||||
|
|
||||||
|
// 提取额度信息
|
||||||
|
let quota = PackycodeUserQuota {
|
||||||
|
daily_budget_usd: response_data.get("daily_budget_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
daily_spent_usd: response_data.get("daily_spent_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
monthly_budget_usd: response_data.get("monthly_budget_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
monthly_spent_usd: response_data.get("monthly_spent_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
balance_usd: response_data.get("balance_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
total_spent_usd: response_data.get("total_spent_usd")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.and_then(|s| s.parse::<f64>().ok())
|
||||||
|
.unwrap_or(0.0),
|
||||||
|
plan_type: response_data.get("plan_type")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "unknown".to_string()),
|
||||||
|
plan_expires_at: response_data.get("plan_expires_at")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.unwrap_or_else(|| "".to_string()),
|
||||||
|
username: response_data.get("username")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
email: response_data.get("email")
|
||||||
|
.and_then(|v| v.as_str())
|
||||||
|
.map(|s| s.to_string()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(quota)
|
||||||
}
|
}
|
@@ -145,7 +145,7 @@ pub struct TokenPaginationResponse {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl RelayStation {
|
impl RelayStation {
|
||||||
fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
|
pub fn from_row(row: &Row) -> Result<Self, rusqlite::Error> {
|
||||||
let adapter_str: String = row.get("adapter")?;
|
let adapter_str: String = row.get("adapter")?;
|
||||||
let auth_method_str: String = row.get("auth_method")?;
|
let auth_method_str: String = row.get("auth_method")?;
|
||||||
let adapter_config_str: Option<String> = row.get("adapter_config")?;
|
let adapter_config_str: Option<String> = row.get("adapter_config")?;
|
||||||
|
@@ -55,6 +55,7 @@ use commands::relay_adapters::{
|
|||||||
relay_station_get_info, relay_station_get_user_info,
|
relay_station_get_info, relay_station_get_user_info,
|
||||||
relay_station_test_connection, relay_station_get_usage_logs, relay_station_list_tokens,
|
relay_station_test_connection, relay_station_get_usage_logs, relay_station_list_tokens,
|
||||||
relay_station_create_token, relay_station_update_token, relay_station_delete_token,
|
relay_station_create_token, relay_station_update_token, relay_station_delete_token,
|
||||||
|
packycode_get_user_quota,
|
||||||
};
|
};
|
||||||
use commands::packycode_nodes::{
|
use commands::packycode_nodes::{
|
||||||
test_all_packycode_nodes, auto_select_best_node, get_packycode_nodes,
|
test_all_packycode_nodes, auto_select_best_node, get_packycode_nodes,
|
||||||
@@ -289,6 +290,7 @@ fn main() {
|
|||||||
relay_station_create_token,
|
relay_station_create_token,
|
||||||
relay_station_update_token,
|
relay_station_update_token,
|
||||||
relay_station_delete_token,
|
relay_station_delete_token,
|
||||||
|
packycode_get_user_quota,
|
||||||
|
|
||||||
// PackyCode Nodes
|
// PackyCode Nodes
|
||||||
test_all_packycode_nodes,
|
test_all_packycode_nodes,
|
||||||
|
@@ -24,7 +24,7 @@ import {
|
|||||||
UpdateRelayStationRequest,
|
UpdateRelayStationRequest,
|
||||||
RelayStationAdapter,
|
RelayStationAdapter,
|
||||||
AuthMethod,
|
AuthMethod,
|
||||||
ConnectionTestResult,
|
PackycodeUserQuota,
|
||||||
api
|
api
|
||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
@@ -32,10 +32,6 @@ import {
|
|||||||
Edit,
|
Edit,
|
||||||
Trash2,
|
Trash2,
|
||||||
Globe,
|
Globe,
|
||||||
CheckCircle,
|
|
||||||
XCircle,
|
|
||||||
Wifi,
|
|
||||||
WifiOff,
|
|
||||||
Server,
|
Server,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -54,12 +50,14 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [stationToDelete, setStationToDelete] = useState<RelayStation | null>(null);
|
const [stationToDelete, setStationToDelete] = useState<RelayStation | null>(null);
|
||||||
const [connectionTests, setConnectionTests] = useState<Record<string, ConnectionTestResult>>({});
|
|
||||||
const [testingConnections, setTestingConnections] = useState<Record<string, boolean>>({});
|
|
||||||
const [togglingEnable, setTogglingEnable] = useState<Record<string, boolean>>({});
|
const [togglingEnable, setTogglingEnable] = useState<Record<string, boolean>>({});
|
||||||
const [currentConfig, setCurrentConfig] = useState<Record<string, string | null>>({});
|
const [currentConfig, setCurrentConfig] = useState<Record<string, string | null>>({});
|
||||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||||
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
|
|
||||||
|
// PackyCode 额度相关状态
|
||||||
|
const [quotaData, setQuotaData] = useState<Record<string, PackycodeUserQuota>>({});
|
||||||
|
const [loadingQuota, setLoadingQuota] = useState<Record<string, boolean>>({});
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -107,23 +105,18 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 测试连接
|
|
||||||
const testConnection = async (stationId: string) => {
|
|
||||||
try {
|
|
||||||
setTestingConnections(prev => ({ ...prev, [stationId]: true }));
|
|
||||||
const result = await api.relayStationTestConnection(stationId);
|
|
||||||
setConnectionTests(prev => ({ ...prev, [stationId]: result }));
|
|
||||||
|
|
||||||
if (result.success) {
|
// 查询 PackyCode 额度
|
||||||
showToast(t('relayStation.connectionSuccess'), "success");
|
const fetchPackycodeQuota = async (stationId: string) => {
|
||||||
} else {
|
try {
|
||||||
showToast(result.message, "error");
|
setLoadingQuota(prev => ({ ...prev, [stationId]: true }));
|
||||||
}
|
const quota = await api.getPackycodeUserQuota(stationId);
|
||||||
|
setQuotaData(prev => ({ ...prev, [stationId]: quota }));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Connection test failed:', error);
|
console.error('Failed to fetch PackyCode quota:', error);
|
||||||
showToast(t('relayStation.connectionFailed'), "error");
|
// 不显示错误 Toast,因为可能是出租车服务或 Token 无效
|
||||||
} finally {
|
} finally {
|
||||||
setTestingConnections(prev => ({ ...prev, [stationId]: false }));
|
setLoadingQuota(prev => ({ ...prev, [stationId]: false }));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -207,6 +200,15 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
loadCurrentConfig();
|
loadCurrentConfig();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// 当中转站加载完成后,自动获取所有 PackyCode 站点的额度
|
||||||
|
useEffect(() => {
|
||||||
|
stations.forEach(station => {
|
||||||
|
if (station.adapter === 'packycode') {
|
||||||
|
fetchPackycodeQuota(station.id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, [stations]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="container mx-auto p-6 space-y-6">
|
<div className="container mx-auto p-6 space-y-6">
|
||||||
{/* 页面标题 */}
|
{/* 页面标题 */}
|
||||||
@@ -336,43 +338,123 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{connectionTests[station.id] && (
|
{/* PackyCode 额度显示 */}
|
||||||
<div className="flex items-center text-sm">
|
{station.adapter === 'packycode' && (
|
||||||
{connectionTests[station.id].success ? (
|
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg border border-blue-200 dark:border-blue-900">
|
||||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
{loadingQuota[station.id] ? (
|
||||||
|
<div className="flex items-center justify-center py-2">
|
||||||
|
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">加载额度中...</span>
|
||||||
|
</div>
|
||||||
|
) : quotaData[station.id] ? (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* 用户信息和计划 */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{quotaData[station.id].username && (
|
||||||
|
<span className="text-muted-foreground">{quotaData[station.id].username}</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{quotaData[station.id].plan_type.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
{quotaData[station.id].plan_expires_at && (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
到期: {new Date(quotaData[station.id].plan_expires_at).toLocaleDateString()}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 账户余额 */}
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">账户余额:</span>
|
||||||
|
<span className="font-semibold text-blue-600">
|
||||||
|
${quotaData[station.id].balance_usd.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日额度 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">日额度:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={quotaData[station.id].daily_spent_usd > quotaData[station.id].daily_budget_usd * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
||||||
|
${quotaData[station.id].daily_spent_usd.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-muted-foreground">${quotaData[station.id].daily_budget_usd.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${
|
||||||
|
quotaData[station.id].daily_spent_usd / quotaData[station.id].daily_budget_usd > 0.8
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((quotaData[station.id].daily_spent_usd / quotaData[station.id].daily_budget_usd) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 月额度 */}
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-muted-foreground">月额度:</span>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className={quotaData[station.id].monthly_spent_usd > quotaData[station.id].monthly_budget_usd * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
||||||
|
${quotaData[station.id].monthly_spent_usd.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-muted-foreground">${quotaData[station.id].monthly_budget_usd.toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="w-full h-1.5 bg-gray-200 dark:bg-gray-700 rounded-full overflow-hidden">
|
||||||
|
<div
|
||||||
|
className={`h-full transition-all ${
|
||||||
|
quotaData[station.id].monthly_spent_usd / quotaData[station.id].monthly_budget_usd > 0.8
|
||||||
|
? 'bg-orange-500'
|
||||||
|
: 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min((quotaData[station.id].monthly_spent_usd / quotaData[station.id].monthly_budget_usd) * 100, 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总消费 */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
||||||
|
<span>总消费: ${quotaData[station.id].total_spent_usd.toFixed(2)}</span>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="h-5 px-2 text-xs"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
fetchPackycodeQuota(station.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
刷新
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<XCircle className="mr-2 h-4 w-4 text-red-500" />
|
<div className="text-center py-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
fetchPackycodeQuota(station.id);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
查询额度
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
<span>
|
|
||||||
{connectionTests[station.id].message}
|
|
||||||
{connectionTests[station.id].response_time !== undefined && connectionTests[station.id].response_time !== null && (
|
|
||||||
<span className="ml-2 text-muted-foreground">
|
|
||||||
({connectionTests[station.id].response_time}ms)
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex justify-between">
|
<div className="flex justify-end">
|
||||||
<Button
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
testConnection(station.id);
|
|
||||||
}}
|
|
||||||
disabled={testingConnections[station.id]}
|
|
||||||
>
|
|
||||||
{testingConnections[station.id] ? (
|
|
||||||
<WifiOff className="mr-2 h-4 w-4 animate-spin" />
|
|
||||||
) : (
|
|
||||||
<Wifi className="mr-2 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
{t('relayStation.testConnection')}
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
<div className="flex space-x-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -396,7 +478,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
<Trash2 className="h-4 w-4" />
|
<Trash2 className="h-4 w-4" />
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
@@ -492,9 +573,6 @@ const CreateStationDialog: React.FC<{
|
|||||||
const [formToast, setFormToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
const [formToast, setFormToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||||
const [packycodeService, setPackycodeService] = useState<string>('bus'); // 默认公交车
|
const [packycodeService, setPackycodeService] = useState<string>('bus'); // 默认公交车
|
||||||
const [packycodeNode, setPackycodeNode] = useState<string>('https://api.packycode.com'); // 默认节点(公交车用)
|
const [packycodeNode, setPackycodeNode] = useState<string>('https://api.packycode.com'); // 默认节点(公交车用)
|
||||||
const [testingNodes, setTestingNodes] = useState(false);
|
|
||||||
const [nodeTestResults, setNodeTestResults] = useState<any[]>([]);
|
|
||||||
const [autoSelectingNode, setAutoSelectingNode] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -506,7 +584,7 @@ const CreateStationDialog: React.FC<{
|
|||||||
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
||||||
api_url: packycodeService === 'taxi'
|
api_url: packycodeService === 'taxi'
|
||||||
? 'https://share-api.packycode.com'
|
? 'https://share-api.packycode.com'
|
||||||
: (packycodeNode === 'auto' ? 'https://api.packycode.com' : packycodeNode)
|
: packycodeNode
|
||||||
}));
|
}));
|
||||||
} else if (formData.adapter === 'custom') {
|
} else if (formData.adapter === 'custom') {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -538,40 +616,6 @@ const CreateStationDialog: React.FC<{
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// 测试所有节点速度(仅公交车服务需要)
|
|
||||||
const testAllNodes = async () => {
|
|
||||||
setTestingNodes(true);
|
|
||||||
try {
|
|
||||||
// 不需要 token,只测试网络延时
|
|
||||||
const results = await api.testAllPackycodeNodes('dummy_token');
|
|
||||||
setNodeTestResults(results);
|
|
||||||
setFormToast({ message: t('relayStation.testCompleted'), type: "success" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to test nodes:', error);
|
|
||||||
setFormToast({ message: t('relayStation.testFailed'), type: "error" });
|
|
||||||
} finally {
|
|
||||||
setTestingNodes(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自动选择最快节点(仅公交车服务需要)
|
|
||||||
const autoSelectBestNode = async () => {
|
|
||||||
setAutoSelectingNode(true);
|
|
||||||
try {
|
|
||||||
// 不需要 token,只测试网络延时
|
|
||||||
const bestNode = await api.autoSelectBestNode('dummy_token');
|
|
||||||
setPackycodeNode(bestNode.url);
|
|
||||||
setFormToast({
|
|
||||||
message: `${t('relayStation.autoSelectedNode')}: ${bestNode.name} (${bestNode.response_time}ms)`,
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to auto-select node:', error);
|
|
||||||
setFormToast({ message: t('relayStation.autoSelectFailed'), type: "error" });
|
|
||||||
} finally {
|
|
||||||
setAutoSelectingNode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -706,21 +750,12 @@ const CreateStationDialog: React.FC<{
|
|||||||
<div className="flex-1">
|
<div className="flex-1">
|
||||||
<Select
|
<Select
|
||||||
value={packycodeNode}
|
value={packycodeNode}
|
||||||
onValueChange={(value: string) => {
|
onValueChange={(value: string) => setPackycodeNode(value)}
|
||||||
if (value === 'auto') {
|
|
||||||
autoSelectBestNode();
|
|
||||||
} else {
|
|
||||||
setPackycodeNode(value);
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('relayStation.selectNode')} />
|
<SelectValue placeholder={t('relayStation.selectNode')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">
|
|
||||||
⚡ {t('relayStation.autoSelect')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="https://api.packycode.com">
|
<SelectItem value="https://api.packycode.com">
|
||||||
🚌 直连1(默认公交车)
|
🚌 直连1(默认公交车)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -751,52 +786,10 @@ const CreateStationDialog: React.FC<{
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={testAllNodes}
|
|
||||||
disabled={testingNodes}
|
|
||||||
>
|
|
||||||
{testingNodes ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-current" />
|
|
||||||
) : (
|
|
||||||
<Wifi className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-2">{t('relayStation.testSpeed')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 显示测速结果 */}
|
|
||||||
{nodeTestResults.length > 0 && (
|
|
||||||
<div className="mt-3 p-3 bg-muted rounded-lg max-h-48 overflow-y-auto">
|
|
||||||
<p className="text-sm font-medium mb-2">{t('relayStation.testResults')}:</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{nodeTestResults.map((result, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{result.success ? (
|
|
||||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-3 w-3 text-red-500" />
|
|
||||||
)}
|
|
||||||
{result.node.name}
|
|
||||||
</span>
|
|
||||||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
|
||||||
{result.success ? `${result.response_time}ms` : t('relayStation.failed')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{autoSelectingNode
|
{t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||||
? t('relayStation.selectingBestNode')
|
|
||||||
: packycodeNode === 'auto'
|
|
||||||
? t('relayStation.autoSelectDesc')
|
|
||||||
: t('relayStation.selectedNode') + ': ' + packycodeNode}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -978,9 +971,6 @@ const EditStationDialog: React.FC<{
|
|||||||
}
|
}
|
||||||
return 'https://api.packycode.com';
|
return 'https://api.packycode.com';
|
||||||
});
|
});
|
||||||
const [testingNodes, setTestingNodes] = useState(false);
|
|
||||||
const [nodeTestResults, setNodeTestResults] = useState<any[]>([]);
|
|
||||||
const [autoSelectingNode, setAutoSelectingNode] = useState(false);
|
|
||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@@ -992,7 +982,7 @@ const EditStationDialog: React.FC<{
|
|||||||
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
||||||
api_url: packycodeService === 'taxi'
|
api_url: packycodeService === 'taxi'
|
||||||
? 'https://share-api.packycode.com'
|
? 'https://share-api.packycode.com'
|
||||||
: (packycodeNode === 'auto' ? 'https://api.packycode.com' : packycodeNode)
|
: packycodeNode
|
||||||
}));
|
}));
|
||||||
} else if (formData.adapter === 'custom') {
|
} else if (formData.adapter === 'custom') {
|
||||||
setFormData(prev => ({
|
setFormData(prev => ({
|
||||||
@@ -1007,39 +997,6 @@ const EditStationDialog: React.FC<{
|
|||||||
}
|
}
|
||||||
}, [formData.adapter, packycodeService, packycodeNode]);
|
}, [formData.adapter, packycodeService, packycodeNode]);
|
||||||
|
|
||||||
// 测试所有节点速度(仅公交车服务需要)
|
|
||||||
const testAllNodes = async () => {
|
|
||||||
setTestingNodes(true);
|
|
||||||
try {
|
|
||||||
const results = await api.testAllPackycodeNodes('dummy_token');
|
|
||||||
setNodeTestResults(results);
|
|
||||||
setFormToast({ message: t('relayStation.testCompleted'), type: "success" });
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to test nodes:', error);
|
|
||||||
setFormToast({ message: t('relayStation.testFailed'), type: "error" });
|
|
||||||
} finally {
|
|
||||||
setTestingNodes(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 自动选择最快节点(仅公交车服务需要)
|
|
||||||
const autoSelectBestNode = async () => {
|
|
||||||
setAutoSelectingNode(true);
|
|
||||||
try {
|
|
||||||
const bestNode = await api.autoSelectBestNode('dummy_token');
|
|
||||||
setPackycodeNode(bestNode.url);
|
|
||||||
setFormData(prev => ({ ...prev, api_url: bestNode.url }));
|
|
||||||
setFormToast({
|
|
||||||
message: `${t('relayStation.autoSelectedNode')}: ${bestNode.name} (${bestNode.response_time}ms)`,
|
|
||||||
type: "success"
|
|
||||||
});
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Failed to auto-select node:', error);
|
|
||||||
setFormToast({ message: t('relayStation.autoSelectFailed'), type: "error" });
|
|
||||||
} finally {
|
|
||||||
setAutoSelectingNode(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1177,21 +1134,14 @@ const EditStationDialog: React.FC<{
|
|||||||
<Select
|
<Select
|
||||||
value={packycodeNode}
|
value={packycodeNode}
|
||||||
onValueChange={(value: string) => {
|
onValueChange={(value: string) => {
|
||||||
if (value === 'auto') {
|
setPackycodeNode(value);
|
||||||
autoSelectBestNode();
|
setFormData(prev => ({ ...prev, api_url: value }));
|
||||||
} else {
|
|
||||||
setPackycodeNode(value);
|
|
||||||
setFormData(prev => ({ ...prev, api_url: value }));
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<SelectTrigger>
|
<SelectTrigger>
|
||||||
<SelectValue placeholder={t('relayStation.selectNode')} />
|
<SelectValue placeholder={t('relayStation.selectNode')} />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
<SelectItem value="auto">
|
|
||||||
⚡ {t('relayStation.autoSelect')}
|
|
||||||
</SelectItem>
|
|
||||||
<SelectItem value="https://api.packycode.com">
|
<SelectItem value="https://api.packycode.com">
|
||||||
🚌 直连1(默认公交车)
|
🚌 直连1(默认公交车)
|
||||||
</SelectItem>
|
</SelectItem>
|
||||||
@@ -1213,50 +1163,10 @@ const EditStationDialog: React.FC<{
|
|||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Button
|
|
||||||
type="button"
|
|
||||||
variant="outline"
|
|
||||||
size="sm"
|
|
||||||
onClick={testAllNodes}
|
|
||||||
disabled={testingNodes}
|
|
||||||
>
|
|
||||||
{testingNodes ? (
|
|
||||||
<div className="h-4 w-4 animate-spin rounded-full border-b-2 border-current" />
|
|
||||||
) : (
|
|
||||||
<Wifi className="h-4 w-4" />
|
|
||||||
)}
|
|
||||||
<span className="ml-2">{t('relayStation.testSpeed')}</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* 显示测速结果 */}
|
|
||||||
{nodeTestResults.length > 0 && (
|
|
||||||
<div className="mt-3 p-3 bg-muted rounded-lg max-h-48 overflow-y-auto">
|
|
||||||
<p className="text-sm font-medium mb-2">{t('relayStation.testResults')}:</p>
|
|
||||||
<div className="space-y-1">
|
|
||||||
{nodeTestResults.map((result, index) => (
|
|
||||||
<div key={index} className="flex items-center justify-between text-xs">
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
{result.success ? (
|
|
||||||
<CheckCircle className="h-3 w-3 text-green-500" />
|
|
||||||
) : (
|
|
||||||
<XCircle className="h-3 w-3 text-red-500" />
|
|
||||||
)}
|
|
||||||
{result.node.name}
|
|
||||||
</span>
|
|
||||||
<span className={result.success ? 'text-green-600' : 'text-red-600'}>
|
|
||||||
{result.success ? `${result.response_time}ms` : t('relayStation.failed')}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{autoSelectingNode
|
{t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||||
? t('relayStation.selectingBestNode')
|
|
||||||
: t('relayStation.selectedNode') + ': ' + packycodeNode}
|
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -577,6 +577,20 @@ export interface NodeSpeedTestResult {
|
|||||||
error?: string; // 错误信息
|
error?: string; // 错误信息
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** PackyCode 用户额度信息 */
|
||||||
|
export interface PackycodeUserQuota {
|
||||||
|
daily_budget_usd: number; // 日预算(美元)
|
||||||
|
daily_spent_usd: number; // 日已使用(美元)
|
||||||
|
monthly_budget_usd: number; // 月预算(美元)
|
||||||
|
monthly_spent_usd: number; // 月已使用(美元)
|
||||||
|
balance_usd: number; // 账户余额(美元)
|
||||||
|
total_spent_usd: number; // 总消费(美元)
|
||||||
|
plan_type: string; // 计划类型 (pro, basic, etc.)
|
||||||
|
plan_expires_at: string; // 计划过期时间
|
||||||
|
username?: string; // 用户名
|
||||||
|
email?: string; // 邮箱
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* API client for interacting with the Rust backend
|
* API client for interacting with the Rust backend
|
||||||
*/
|
*/
|
||||||
@@ -2372,5 +2386,19 @@ export const api = {
|
|||||||
console.error("Failed to get PackyCode nodes:", error);
|
console.error("Failed to get PackyCode nodes:", error);
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gets PackyCode user quota information
|
||||||
|
* @param stationId - The relay station ID
|
||||||
|
* @returns Promise resolving to the user quota information
|
||||||
|
*/
|
||||||
|
async getPackycodeUserQuota(stationId: string): Promise<PackycodeUserQuota> {
|
||||||
|
try {
|
||||||
|
return await invoke<PackycodeUserQuota>("packycode_get_user_quota", { stationId });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to get PackyCode user quota:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
Reference in New Issue
Block a user