增加额度查询
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
use async_trait::async_trait;
|
||||
use anyhow::{anyhow, Result};
|
||||
use reqwest::Client;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use serde_json::{json, Value};
|
||||
use std::collections::HashMap;
|
||||
use std::time::{Duration, Instant};
|
||||
@@ -986,4 +987,139 @@ pub async fn relay_station_delete_token(
|
||||
log::error!("Failed to delete token: {}", e);
|
||||
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 {
|
||||
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 auth_method_str: String = row.get("auth_method")?;
|
||||
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_test_connection, relay_station_get_usage_logs, relay_station_list_tokens,
|
||||
relay_station_create_token, relay_station_update_token, relay_station_delete_token,
|
||||
packycode_get_user_quota,
|
||||
};
|
||||
use commands::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_update_token,
|
||||
relay_station_delete_token,
|
||||
packycode_get_user_quota,
|
||||
|
||||
// PackyCode Nodes
|
||||
test_all_packycode_nodes,
|
||||
|
@@ -24,7 +24,7 @@ import {
|
||||
UpdateRelayStationRequest,
|
||||
RelayStationAdapter,
|
||||
AuthMethod,
|
||||
ConnectionTestResult,
|
||||
PackycodeUserQuota,
|
||||
api
|
||||
} from '@/lib/api';
|
||||
import {
|
||||
@@ -32,10 +32,6 @@ import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Globe,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Wifi,
|
||||
WifiOff,
|
||||
Server,
|
||||
ArrowLeft,
|
||||
Settings,
|
||||
@@ -54,12 +50,14 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
||||
const [showEditDialog, setShowEditDialog] = useState(false);
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
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 [currentConfig, setCurrentConfig] = useState<Record<string, string | null>>({});
|
||||
const [loadingConfig, setLoadingConfig] = useState(false);
|
||||
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();
|
||||
|
||||
@@ -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) {
|
||||
showToast(t('relayStation.connectionSuccess'), "success");
|
||||
} else {
|
||||
showToast(result.message, "error");
|
||||
}
|
||||
// 查询 PackyCode 额度
|
||||
const fetchPackycodeQuota = async (stationId: string) => {
|
||||
try {
|
||||
setLoadingQuota(prev => ({ ...prev, [stationId]: true }));
|
||||
const quota = await api.getPackycodeUserQuota(stationId);
|
||||
setQuotaData(prev => ({ ...prev, [stationId]: quota }));
|
||||
} catch (error) {
|
||||
console.error('Connection test failed:', error);
|
||||
showToast(t('relayStation.connectionFailed'), "error");
|
||||
console.error('Failed to fetch PackyCode quota:', error);
|
||||
// 不显示错误 Toast,因为可能是出租车服务或 Token 无效
|
||||
} finally {
|
||||
setTestingConnections(prev => ({ ...prev, [stationId]: false }));
|
||||
setLoadingQuota(prev => ({ ...prev, [stationId]: false }));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,6 +200,15 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
||||
loadCurrentConfig();
|
||||
}, []);
|
||||
|
||||
// 当中转站加载完成后,自动获取所有 PackyCode 站点的额度
|
||||
useEffect(() => {
|
||||
stations.forEach(station => {
|
||||
if (station.adapter === 'packycode') {
|
||||
fetchPackycodeQuota(station.id);
|
||||
}
|
||||
});
|
||||
}, [stations]);
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
{/* 页面标题 */}
|
||||
@@ -336,43 +338,123 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
||||
</p>
|
||||
)}
|
||||
|
||||
{connectionTests[station.id] && (
|
||||
<div className="flex items-center text-sm">
|
||||
{connectionTests[station.id].success ? (
|
||||
<CheckCircle className="mr-2 h-4 w-4 text-green-500" />
|
||||
{/* PackyCode 额度显示 */}
|
||||
{station.adapter === 'packycode' && (
|
||||
<div className="mt-3 p-3 bg-blue-50 dark:bg-blue-950/30 rounded-lg border border-blue-200 dark:border-blue-900">
|
||||
{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 className="flex justify-between">
|
||||
<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">
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
@@ -396,7 +478,6 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -492,9 +573,6 @@ const CreateStationDialog: React.FC<{
|
||||
const [formToast, setFormToast] = useState<{ message: string; type: "success" | "error" } | null>(null);
|
||||
const [packycodeService, setPackycodeService] = useState<string>('bus'); // 默认公交车
|
||||
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();
|
||||
|
||||
@@ -506,7 +584,7 @@ const CreateStationDialog: React.FC<{
|
||||
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
||||
api_url: packycodeService === 'taxi'
|
||||
? 'https://share-api.packycode.com'
|
||||
: (packycodeNode === 'auto' ? 'https://api.packycode.com' : packycodeNode)
|
||||
: packycodeNode
|
||||
}));
|
||||
} else if (formData.adapter === 'custom') {
|
||||
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) => {
|
||||
e.preventDefault();
|
||||
@@ -706,21 +750,12 @@ const CreateStationDialog: React.FC<{
|
||||
<div className="flex-1">
|
||||
<Select
|
||||
value={packycodeNode}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === 'auto') {
|
||||
autoSelectBestNode();
|
||||
} else {
|
||||
setPackycodeNode(value);
|
||||
}
|
||||
}}
|
||||
onValueChange={(value: string) => setPackycodeNode(value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('relayStation.selectNode')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
⚡ {t('relayStation.autoSelect')}
|
||||
</SelectItem>
|
||||
<SelectItem value="https://api.packycode.com">
|
||||
🚌 直连1(默认公交车)
|
||||
</SelectItem>
|
||||
@@ -751,52 +786,10 @@ const CreateStationDialog: React.FC<{
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
{/* 显示测速结果 */}
|
||||
{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">
|
||||
{autoSelectingNode
|
||||
? t('relayStation.selectingBestNode')
|
||||
: packycodeNode === 'auto'
|
||||
? t('relayStation.autoSelectDesc')
|
||||
: t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||
{t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -978,9 +971,6 @@ const EditStationDialog: React.FC<{
|
||||
}
|
||||
return 'https://api.packycode.com';
|
||||
});
|
||||
const [testingNodes, setTestingNodes] = useState(false);
|
||||
const [nodeTestResults, setNodeTestResults] = useState<any[]>([]);
|
||||
const [autoSelectingNode, setAutoSelectingNode] = useState(false);
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
@@ -992,7 +982,7 @@ const EditStationDialog: React.FC<{
|
||||
auth_method: 'api_key', // PackyCode 固定使用 API Key
|
||||
api_url: packycodeService === 'taxi'
|
||||
? 'https://share-api.packycode.com'
|
||||
: (packycodeNode === 'auto' ? 'https://api.packycode.com' : packycodeNode)
|
||||
: packycodeNode
|
||||
}));
|
||||
} else if (formData.adapter === 'custom') {
|
||||
setFormData(prev => ({
|
||||
@@ -1007,39 +997,6 @@ const EditStationDialog: React.FC<{
|
||||
}
|
||||
}, [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) => {
|
||||
e.preventDefault();
|
||||
@@ -1177,21 +1134,14 @@ const EditStationDialog: React.FC<{
|
||||
<Select
|
||||
value={packycodeNode}
|
||||
onValueChange={(value: string) => {
|
||||
if (value === 'auto') {
|
||||
autoSelectBestNode();
|
||||
} else {
|
||||
setPackycodeNode(value);
|
||||
setFormData(prev => ({ ...prev, api_url: value }));
|
||||
}
|
||||
setPackycodeNode(value);
|
||||
setFormData(prev => ({ ...prev, api_url: value }));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('relayStation.selectNode')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="auto">
|
||||
⚡ {t('relayStation.autoSelect')}
|
||||
</SelectItem>
|
||||
<SelectItem value="https://api.packycode.com">
|
||||
🚌 直连1(默认公交车)
|
||||
</SelectItem>
|
||||
@@ -1213,50 +1163,10 @@ const EditStationDialog: React.FC<{
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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>
|
||||
|
||||
{/* 显示测速结果 */}
|
||||
{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">
|
||||
{autoSelectingNode
|
||||
? t('relayStation.selectingBestNode')
|
||||
: t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||
{t('relayStation.selectedNode') + ': ' + packycodeNode}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -577,6 +577,20 @@ export interface NodeSpeedTestResult {
|
||||
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
|
||||
*/
|
||||
@@ -2372,5 +2386,19 @@ export const api = {
|
||||
console.error("Failed to get PackyCode nodes:", 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