diff --git a/src-tauri/src/commands/relay_adapters.rs b/src-tauri/src/commands/relay_adapters.rs index ed55766..15e32ae 100644 --- a/src-tauri/src/commands/relay_adapters.rs +++ b/src-tauri/src/commands/relay_adapters.rs @@ -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, // 用户名 + pub email: Option, // 邮箱 +} + +/// 获取 PackyCode 用户信息(额度等) +#[command] +pub async fn packycode_get_user_quota(station_id: String, db: State<'_, AgentDb>) -> Result { + // 先从数据库获取中转站信息,然后释放锁 + 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::().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::().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::().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::().ok()) + .unwrap_or(0.0), + balance_usd: response_data.get("balance_usd") + .and_then(|v| v.as_str()) + .and_then(|s| s.parse::().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::().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) } \ No newline at end of file diff --git a/src-tauri/src/commands/relay_stations.rs b/src-tauri/src/commands/relay_stations.rs index ad2229f..675fbb1 100644 --- a/src-tauri/src/commands/relay_stations.rs +++ b/src-tauri/src/commands/relay_stations.rs @@ -145,7 +145,7 @@ pub struct TokenPaginationResponse { } impl RelayStation { - fn from_row(row: &Row) -> Result { + pub fn from_row(row: &Row) -> Result { let adapter_str: String = row.get("adapter")?; let auth_method_str: String = row.get("auth_method")?; let adapter_config_str: Option = row.get("adapter_config")?; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index 2a1a589..6d0fbd1 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -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, diff --git a/src/components/RelayStationManager.tsx b/src/components/RelayStationManager.tsx index 818ad0c..e643d63 100644 --- a/src/components/RelayStationManager.tsx +++ b/src/components/RelayStationManager.tsx @@ -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 = ({ onBack }) => const [showEditDialog, setShowEditDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [stationToDelete, setStationToDelete] = useState(null); - const [connectionTests, setConnectionTests] = useState>({}); - const [testingConnections, setTestingConnections] = useState>({}); const [togglingEnable, setTogglingEnable] = useState>({}); const [currentConfig, setCurrentConfig] = useState>({}); const [loadingConfig, setLoadingConfig] = useState(false); const [toast, setToast] = useState<{ message: string; type: "success" | "error" } | null>(null); + + // PackyCode 额度相关状态 + const [quotaData, setQuotaData] = useState>({}); + const [loadingQuota, setLoadingQuota] = useState>({}); const { t } = useTranslation(); @@ -107,23 +105,18 @@ const RelayStationManager: React.FC = ({ 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 = ({ onBack }) => loadCurrentConfig(); }, []); + // 当中转站加载完成后,自动获取所有 PackyCode 站点的额度 + useEffect(() => { + stations.forEach(station => { + if (station.adapter === 'packycode') { + fetchPackycodeQuota(station.id); + } + }); + }, [stations]); + return (
{/* 页面标题 */} @@ -336,43 +338,123 @@ const RelayStationManager: React.FC = ({ onBack }) =>

)} - {connectionTests[station.id] && ( -
- {connectionTests[station.id].success ? ( - + {/* PackyCode 额度显示 */} + {station.adapter === 'packycode' && ( +
+ {loadingQuota[station.id] ? ( +
+
+ 加载额度中... +
+ ) : quotaData[station.id] ? ( +
+ {/* 用户信息和计划 */} +
+
+ {quotaData[station.id].username && ( + {quotaData[station.id].username} + )} + + {quotaData[station.id].plan_type.toUpperCase()} + +
+ {quotaData[station.id].plan_expires_at && ( + + 到期: {new Date(quotaData[station.id].plan_expires_at).toLocaleDateString()} + + )} +
+ + {/* 账户余额 */} +
+ 账户余额: + + ${quotaData[station.id].balance_usd.toFixed(2)} + +
+ + {/* 日额度 */} +
+
+ 日额度: +
+ quotaData[station.id].daily_budget_usd * 0.8 ? 'text-orange-600' : 'text-green-600'}> + ${quotaData[station.id].daily_spent_usd.toFixed(2)} + + / + ${quotaData[station.id].daily_budget_usd.toFixed(2)} +
+
+
+
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)}%` }} + /> +
+
+ + {/* 月额度 */} +
+
+ 月额度: +
+ quotaData[station.id].monthly_budget_usd * 0.8 ? 'text-orange-600' : 'text-green-600'}> + ${quotaData[station.id].monthly_spent_usd.toFixed(2)} + + / + ${quotaData[station.id].monthly_budget_usd.toFixed(2)} +
+
+
+
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)}%` }} + /> +
+
+ + {/* 总消费 */} +
+ 总消费: ${quotaData[station.id].total_spent_usd.toFixed(2)} + +
+
) : ( - +
+ +
)} - - {connectionTests[station.id].message} - {connectionTests[station.id].response_time !== undefined && connectionTests[station.id].response_time !== null && ( - - ({connectionTests[station.id].response_time}ms) - - )} -
)} -
- - -
+
-
@@ -492,9 +573,6 @@ const CreateStationDialog: React.FC<{ const [formToast, setFormToast] = useState<{ message: string; type: "success" | "error" } | null>(null); const [packycodeService, setPackycodeService] = useState('bus'); // 默认公交车 const [packycodeNode, setPackycodeNode] = useState('https://api.packycode.com'); // 默认节点(公交车用) - const [testingNodes, setTestingNodes] = useState(false); - const [nodeTestResults, setNodeTestResults] = useState([]); - 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<{
-
- {/* 显示测速结果 */} - {nodeTestResults.length > 0 && ( -
-

{t('relayStation.testResults')}:

-
- {nodeTestResults.map((result, index) => ( -
- - {result.success ? ( - - ) : ( - - )} - {result.node.name} - - - {result.success ? `${result.response_time}ms` : t('relayStation.failed')} - -
- ))} -
-
- )} -

- {autoSelectingNode - ? t('relayStation.selectingBestNode') - : packycodeNode === 'auto' - ? t('relayStation.autoSelectDesc') - : t('relayStation.selectedNode') + ': ' + packycodeNode} + {t('relayStation.selectedNode') + ': ' + packycodeNode}

@@ -978,9 +971,6 @@ const EditStationDialog: React.FC<{ } return 'https://api.packycode.com'; }); - const [testingNodes, setTestingNodes] = useState(false); - const [nodeTestResults, setNodeTestResults] = useState([]); - 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<{
- - {/* 显示测速结果 */} - {nodeTestResults.length > 0 && ( -
-

{t('relayStation.testResults')}:

-
- {nodeTestResults.map((result, index) => ( -
- - {result.success ? ( - - ) : ( - - )} - {result.node.name} - - - {result.success ? `${result.response_time}ms` : t('relayStation.failed')} - -
- ))} -
-
- )} -

- {autoSelectingNode - ? t('relayStation.selectingBestNode') - : t('relayStation.selectedNode') + ': ' + packycodeNode} + {t('relayStation.selectedNode') + ': ' + packycodeNode}

diff --git a/src/lib/api.ts b/src/lib/api.ts index 8bb9629..845ea7d 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -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 { + try { + return await invoke("packycode_get_user_quota", { stationId }); + } catch (error) { + console.error("Failed to get PackyCode user quota:", error); + throw error; + } } };