新增中转站模块可拖拽
This commit is contained in:
@@ -57,6 +57,7 @@ pub struct RelayStation {
|
|||||||
pub user_id: Option<String>, // 用户 ID(可选)
|
pub user_id: Option<String>, // 用户 ID(可选)
|
||||||
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
|
pub adapter_config: Option<HashMap<String, serde_json::Value>>, // 适配器特定配置
|
||||||
pub enabled: bool, // 启用状态
|
pub enabled: bool, // 启用状态
|
||||||
|
pub display_order: i32, // 显示顺序
|
||||||
pub created_at: i64, // 创建时间
|
pub created_at: i64, // 创建时间
|
||||||
pub updated_at: i64, // 更新时间
|
pub updated_at: i64, // 更新时间
|
||||||
}
|
}
|
||||||
@@ -180,6 +181,7 @@ impl RelayStation {
|
|||||||
user_id: row.get("user_id")?,
|
user_id: row.get("user_id")?,
|
||||||
adapter_config,
|
adapter_config,
|
||||||
enabled: row.get::<_, i32>("enabled")? == 1,
|
enabled: row.get::<_, i32>("enabled")? == 1,
|
||||||
|
display_order: row.get::<_, i32>("display_order").unwrap_or(0),
|
||||||
created_at: row.get("created_at")?,
|
created_at: row.get("created_at")?,
|
||||||
updated_at: row.get("updated_at")?,
|
updated_at: row.get("updated_at")?,
|
||||||
})
|
})
|
||||||
@@ -202,6 +204,7 @@ pub fn init_relay_stations_tables(conn: &Connection) -> Result<()> {
|
|||||||
user_id TEXT,
|
user_id TEXT,
|
||||||
adapter_config TEXT,
|
adapter_config TEXT,
|
||||||
enabled INTEGER NOT NULL DEFAULT 1,
|
enabled INTEGER NOT NULL DEFAULT 1,
|
||||||
|
display_order INTEGER NOT NULL DEFAULT 0,
|
||||||
created_at INTEGER NOT NULL,
|
created_at INTEGER NOT NULL,
|
||||||
updated_at INTEGER NOT NULL
|
updated_at INTEGER NOT NULL
|
||||||
)
|
)
|
||||||
@@ -244,7 +247,13 @@ pub async fn relay_stations_list(db: State<'_, AgentDb>) -> Result<Vec<RelayStat
|
|||||||
i18n::t("database.init_failed")
|
i18n::t("database.init_failed")
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY created_at DESC")
|
// 添加 display_order 列(如果不存在)
|
||||||
|
let _ = conn.execute(
|
||||||
|
"ALTER TABLE relay_stations ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0",
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut stmt = conn.prepare("SELECT * FROM relay_stations ORDER BY display_order ASC, created_at DESC")
|
||||||
.map_err(|e| {
|
.map_err(|e| {
|
||||||
log::error!("Failed to prepare statement: {}", e);
|
log::error!("Failed to prepare statement: {}", e);
|
||||||
i18n::t("database.query_failed")
|
i18n::t("database.query_failed")
|
||||||
@@ -375,6 +384,7 @@ pub async fn relay_station_create(
|
|||||||
user_id: request.user_id,
|
user_id: request.user_id,
|
||||||
adapter_config: request.adapter_config,
|
adapter_config: request.adapter_config,
|
||||||
enabled: request.enabled,
|
enabled: request.enabled,
|
||||||
|
display_order: 0,
|
||||||
created_at: now,
|
created_at: now,
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -463,6 +473,7 @@ pub async fn relay_station_update(
|
|||||||
user_id: request.user_id,
|
user_id: request.user_id,
|
||||||
adapter_config: request.adapter_config,
|
adapter_config: request.adapter_config,
|
||||||
enabled: request.enabled,
|
enabled: request.enabled,
|
||||||
|
display_order: 0, // 保持原有顺序
|
||||||
created_at: 0, // 不重要,前端可以重新获取
|
created_at: 0, // 不重要,前端可以重新获取
|
||||||
updated_at: now,
|
updated_at: now,
|
||||||
};
|
};
|
||||||
@@ -908,3 +919,42 @@ pub async fn relay_stations_import(
|
|||||||
message,
|
message,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 更新中转站排序
|
||||||
|
/// @author yovinchen
|
||||||
|
#[command]
|
||||||
|
pub async fn relay_station_update_order(
|
||||||
|
station_ids: Vec<String>,
|
||||||
|
db: State<'_, AgentDb>
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let conn = db.0.lock().map_err(|e| {
|
||||||
|
log::error!("Failed to acquire database lock: {}", e);
|
||||||
|
i18n::t("database.lock_failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 开始事务
|
||||||
|
let tx = conn.unchecked_transaction().map_err(|e| {
|
||||||
|
log::error!("Failed to start transaction: {}", e);
|
||||||
|
i18n::t("database.transaction_failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
// 更新每个中转站的排序
|
||||||
|
for (index, station_id) in station_ids.iter().enumerate() {
|
||||||
|
tx.execute(
|
||||||
|
"UPDATE relay_stations SET display_order = ?1, updated_at = ?2 WHERE id = ?3",
|
||||||
|
params![index as i32, Utc::now().timestamp(), station_id],
|
||||||
|
).map_err(|e| {
|
||||||
|
log::error!("Failed to update station order: {}", e);
|
||||||
|
i18n::t("database.update_failed")
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 提交事务
|
||||||
|
tx.commit().map_err(|e| {
|
||||||
|
log::error!("Failed to commit transaction: {}", e);
|
||||||
|
i18n::t("database.transaction_failed")
|
||||||
|
})?;
|
||||||
|
|
||||||
|
log::info!("Updated display order for {} relay stations", station_ids.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -59,7 +59,7 @@ use commands::relay_stations::{
|
|||||||
relay_stations_list, relay_station_get, relay_station_create, relay_station_update,
|
relay_stations_list, relay_station_get, relay_station_create, relay_station_update,
|
||||||
relay_station_delete, relay_station_toggle_enable, relay_station_sync_config,
|
relay_station_delete, relay_station_toggle_enable, relay_station_sync_config,
|
||||||
relay_station_restore_config, relay_station_get_current_config,
|
relay_station_restore_config, relay_station_get_current_config,
|
||||||
relay_stations_export, relay_stations_import,
|
relay_stations_export, relay_stations_import, relay_station_update_order,
|
||||||
};
|
};
|
||||||
use commands::relay_adapters::{
|
use commands::relay_adapters::{
|
||||||
relay_station_get_info, relay_station_get_user_info,
|
relay_station_get_info, relay_station_get_user_info,
|
||||||
@@ -452,6 +452,7 @@ fn main() {
|
|||||||
relay_station_get_current_config,
|
relay_station_get_current_config,
|
||||||
relay_stations_export,
|
relay_stations_export,
|
||||||
relay_stations_import,
|
relay_stations_import,
|
||||||
|
relay_station_update_order,
|
||||||
relay_station_get_info,
|
relay_station_get_info,
|
||||||
relay_station_get_user_info,
|
relay_station_get_user_info,
|
||||||
relay_station_test_connection,
|
relay_station_test_connection,
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import React, { useState, useEffect } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { open } from '@tauri-apps/plugin-shell';
|
import { open } from '@tauri-apps/plugin-shell';
|
||||||
import MonacoEditor from '@monaco-editor/react';
|
import MonacoEditor from '@monaco-editor/react';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
|
||||||
import { Progress } from '@/components/ui/progress';
|
import { Progress } from '@/components/ui/progress';
|
||||||
import { Alert, AlertDescription } from '@/components/ui/alert';
|
import { Alert, AlertDescription } from '@/components/ui/alert';
|
||||||
import {
|
import {
|
||||||
@@ -34,9 +33,6 @@ import {
|
|||||||
} from '@/lib/api';
|
} from '@/lib/api';
|
||||||
import {
|
import {
|
||||||
Plus,
|
Plus,
|
||||||
Edit,
|
|
||||||
Trash2,
|
|
||||||
Globe,
|
|
||||||
Server,
|
Server,
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
Settings,
|
Settings,
|
||||||
@@ -49,6 +45,22 @@ import {
|
|||||||
Download,
|
Download,
|
||||||
Upload
|
Upload
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
DndContext,
|
||||||
|
closestCenter,
|
||||||
|
KeyboardSensor,
|
||||||
|
PointerSensor,
|
||||||
|
useSensor,
|
||||||
|
useSensors,
|
||||||
|
DragEndEvent,
|
||||||
|
} from '@dnd-kit/core';
|
||||||
|
import {
|
||||||
|
arrayMove,
|
||||||
|
SortableContext,
|
||||||
|
sortableKeyboardCoordinates,
|
||||||
|
verticalListSortingStrategy,
|
||||||
|
} from '@dnd-kit/sortable';
|
||||||
|
import { SortableStationItem } from './SortableStationItem';
|
||||||
|
|
||||||
interface RelayStationManagerProps {
|
interface RelayStationManagerProps {
|
||||||
onBack: () => void;
|
onBack: () => void;
|
||||||
@@ -83,6 +95,38 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
|
// 拖拽传感器配置
|
||||||
|
const sensors = useSensors(
|
||||||
|
useSensor(PointerSensor),
|
||||||
|
useSensor(KeyboardSensor, {
|
||||||
|
coordinateGetter: sortableKeyboardCoordinates,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// 拖拽结束处理
|
||||||
|
const handleDragEnd = async (event: DragEndEvent) => {
|
||||||
|
const { active, over } = event;
|
||||||
|
|
||||||
|
if (over && active.id !== over.id) {
|
||||||
|
const oldIndex = stations.findIndex(station => station.id === active.id);
|
||||||
|
const newIndex = stations.findIndex(station => station.id === over.id);
|
||||||
|
|
||||||
|
if (oldIndex !== -1 && newIndex !== -1) {
|
||||||
|
const newStations = arrayMove(stations, oldIndex, newIndex);
|
||||||
|
setStations(newStations);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await api.relayStationUpdateOrder(newStations.map(s => s.id));
|
||||||
|
showToast('排序已更新', 'success');
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to update station order:', error);
|
||||||
|
showToast('更新排序失败', 'error');
|
||||||
|
setStations(stations);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// Token 脱敏函数
|
// Token 脱敏函数
|
||||||
const maskToken = (token: string): string => {
|
const maskToken = (token: string): string => {
|
||||||
if (!token || token.length <= 8) {
|
if (!token || token.length <= 8) {
|
||||||
@@ -665,6 +709,15 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
{/* 中转站列表 */}
|
{/* 中转站列表 */}
|
||||||
|
<DndContext
|
||||||
|
sensors={sensors}
|
||||||
|
collisionDetection={closestCenter}
|
||||||
|
onDragEnd={handleDragEnd}
|
||||||
|
>
|
||||||
|
<SortableContext
|
||||||
|
items={stations.map(s => s.id)}
|
||||||
|
strategy={verticalListSortingStrategy}
|
||||||
|
>
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<div className="col-span-full text-center py-12">
|
<div className="col-span-full text-center py-12">
|
||||||
@@ -682,209 +735,21 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
stations.map((station) => (
|
stations.map((station) => <SortableStationItem
|
||||||
<Card key={station.id} className="relative">
|
key={station.id}
|
||||||
<CardHeader className="pb-2 pt-3 px-3">
|
station={station}
|
||||||
<div className="flex justify-between items-center">
|
getStatusBadge={getStatusBadge}
|
||||||
<div className="flex-1 min-w-0 mr-2">
|
getAdapterDisplayName={getAdapterDisplayName}
|
||||||
<CardTitle className="text-sm font-medium">{station.name}</CardTitle>
|
setSelectedStation={setSelectedStation}
|
||||||
<CardDescription className="text-xs mt-0.5">
|
setShowEditDialog={setShowEditDialog}
|
||||||
{getAdapterDisplayName(station.adapter)}
|
openDeleteDialog={openDeleteDialog}
|
||||||
</CardDescription>
|
quotaData={quotaData}
|
||||||
</div>
|
loadingQuota={loadingQuota}
|
||||||
<div className="flex items-center gap-1">
|
/>)
|
||||||
{getStatusBadge(station)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
setSelectedStation(station);
|
|
||||||
setShowEditDialog(true);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Edit className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="h-8 w-8 text-red-500 hover:text-red-700"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
openDeleteDialog(station);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Trash2 className="h-4 w-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent className="pt-1 pb-3 px-3">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center text-xs text-muted-foreground">
|
|
||||||
<Globe className="mr-1.5 h-3 w-3" />
|
|
||||||
<span className="truncate">{station.api_url}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{station.description && (
|
|
||||||
<p className="text-xs text-muted-foreground line-clamp-2">
|
|
||||||
{station.description}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* PackyCode 额度显示 */}
|
|
||||||
{station.adapter === 'packycode' && (
|
|
||||||
<div className="mt-2 p-2 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-1">
|
|
||||||
<div className="h-3 w-3 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
|
||||||
<span className="ml-2 text-xs text-muted-foreground">加载中...</span>
|
|
||||||
</div>
|
|
||||||
) : quotaData[station.id] ? (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{/* 用户信息和计划 */}
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<div className="flex items-center gap-1.5">
|
|
||||||
{quotaData[station.id].username && (
|
|
||||||
<span className="text-muted-foreground">{quotaData[station.id].username}</span>
|
|
||||||
)}
|
|
||||||
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
|
||||||
{quotaData[station.id].plan_type.toUpperCase()}
|
|
||||||
</Badge>
|
|
||||||
{quotaData[station.id].opus_enabled && (
|
|
||||||
<Badge variant="default" className="text-xs h-5 px-1.5 bg-purple-600">
|
|
||||||
Opus
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 账户余额 */}
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">余额:</span>
|
|
||||||
<span className="font-medium text-blue-600">
|
|
||||||
${Number(quotaData[station.id].balance_usd).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 日额度 */}
|
|
||||||
<div className="space-y-0.5">
|
|
||||||
<div className="flex items-center justify-between text-xs">
|
|
||||||
<span className="text-muted-foreground">日额度:</span>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
{(() => {
|
|
||||||
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
|
||||||
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={daily_spent > daily_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
|
||||||
${daily_spent.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">/ ${daily_budget.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 ${
|
|
||||||
(() => {
|
|
||||||
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
|
||||||
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
|
||||||
return daily_spent / daily_budget > 0.8;
|
|
||||||
})() ? 'bg-orange-500' : 'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(
|
|
||||||
(() => {
|
|
||||||
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
|
||||||
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
|
||||||
return (daily_spent / daily_budget) * 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">
|
|
||||||
{(() => {
|
|
||||||
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
|
||||||
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<span className={monthly_spent > monthly_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
|
||||||
${monthly_spent.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground">/</span>
|
|
||||||
<span className="text-muted-foreground">${monthly_budget.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 ${
|
|
||||||
(() => {
|
|
||||||
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
|
||||||
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
|
||||||
return monthly_spent / monthly_budget > 0.8;
|
|
||||||
})() ? 'bg-orange-500' : 'bg-green-500'
|
|
||||||
}`}
|
|
||||||
style={{ width: `${Math.min(
|
|
||||||
(() => {
|
|
||||||
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
|
||||||
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
|
||||||
return (monthly_spent / monthly_budget) * 100;
|
|
||||||
})(), 100)}%` }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 总消费 */}
|
|
||||||
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
|
||||||
<span>总消费: ${Number(quotaData[station.id].total_spent_usd).toFixed(2)}</span>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
className="h-6 px-2 text-xs"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
fetchPackycodeQuota(station.id);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
刷新
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="text-center py-1">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="sm"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
fetchPackycodeQuota(station.id);
|
|
||||||
}}
|
|
||||||
className="h-7 text-xs px-2"
|
|
||||||
>
|
|
||||||
查询额度
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
</SortableContext>
|
||||||
|
</DndContext>
|
||||||
|
|
||||||
{/* 编辑对话框 */}
|
{/* 编辑对话框 */}
|
||||||
{selectedStation && (
|
{selectedStation && (
|
||||||
|
|||||||
248
src/components/SortableStationItem.tsx
Normal file
248
src/components/SortableStationItem.tsx
Normal file
@@ -0,0 +1,248 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { useSortable } from '@dnd-kit/sortable';
|
||||||
|
import { CSS } from '@dnd-kit/utilities';
|
||||||
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
|
import {
|
||||||
|
Edit,
|
||||||
|
Trash2,
|
||||||
|
Globe,
|
||||||
|
GripVertical,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import {
|
||||||
|
RelayStation,
|
||||||
|
RelayStationAdapter,
|
||||||
|
PackycodeUserQuota,
|
||||||
|
} from '@/lib/api';
|
||||||
|
|
||||||
|
interface SortableStationItemProps {
|
||||||
|
station: RelayStation;
|
||||||
|
getStatusBadge: (station: RelayStation) => React.ReactNode;
|
||||||
|
getAdapterDisplayName: (adapter: RelayStationAdapter) => string;
|
||||||
|
setSelectedStation: (station: RelayStation) => void;
|
||||||
|
setShowEditDialog: (show: boolean) => void;
|
||||||
|
openDeleteDialog: (station: RelayStation) => void;
|
||||||
|
quotaData: Record<string, PackycodeUserQuota>;
|
||||||
|
loadingQuota: Record<string, boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 可排序的中转站卡片组件
|
||||||
|
* @author yovinchen
|
||||||
|
*/
|
||||||
|
export const SortableStationItem: React.FC<SortableStationItemProps> = ({
|
||||||
|
station,
|
||||||
|
getStatusBadge,
|
||||||
|
getAdapterDisplayName,
|
||||||
|
setSelectedStation,
|
||||||
|
setShowEditDialog,
|
||||||
|
openDeleteDialog,
|
||||||
|
quotaData,
|
||||||
|
loadingQuota,
|
||||||
|
}) => {
|
||||||
|
const {
|
||||||
|
attributes,
|
||||||
|
listeners,
|
||||||
|
setNodeRef,
|
||||||
|
transform,
|
||||||
|
transition,
|
||||||
|
isDragging,
|
||||||
|
} = useSortable({ id: station.id });
|
||||||
|
|
||||||
|
const style = {
|
||||||
|
transform: CSS.Transform.toString(transform),
|
||||||
|
transition,
|
||||||
|
opacity: isDragging ? 0.5 : 1,
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card ref={setNodeRef} style={style} className="relative">
|
||||||
|
<CardHeader className="pb-2 pt-3 px-3">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<div className="flex items-center flex-1 min-w-0 mr-2">
|
||||||
|
<button
|
||||||
|
className="cursor-grab active:cursor-grabbing mr-2 touch-none"
|
||||||
|
{...attributes}
|
||||||
|
{...listeners}
|
||||||
|
>
|
||||||
|
<GripVertical className="h-4 w-4 text-muted-foreground hover:text-foreground transition-colors" />
|
||||||
|
</button>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<CardTitle className="text-sm font-medium">{station.name}</CardTitle>
|
||||||
|
<CardDescription className="text-xs mt-0.5">
|
||||||
|
{getAdapterDisplayName(station.adapter)}
|
||||||
|
</CardDescription>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{getStatusBadge(station)}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
setSelectedStation(station);
|
||||||
|
setShowEditDialog(true);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-700"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
openDeleteDialog(station);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="pt-1 pb-3 px-3">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center text-xs text-muted-foreground">
|
||||||
|
<Globe className="mr-1.5 h-3 w-3" />
|
||||||
|
<span className="truncate">{station.api_url}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{station.description && (
|
||||||
|
<p className="text-xs text-muted-foreground line-clamp-2">
|
||||||
|
{station.description}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* PackyCode 额度显示 */}
|
||||||
|
{station.adapter === 'packycode' && (
|
||||||
|
<div className="mt-2 p-2 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-1">
|
||||||
|
<div className="h-3 w-3 animate-spin rounded-full border-b-2 border-blue-600"></div>
|
||||||
|
<span className="ml-2 text-xs text-muted-foreground">加载中...</span>
|
||||||
|
</div>
|
||||||
|
) : quotaData[station.id] ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* 用户信息和计划 */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
{quotaData[station.id].username && (
|
||||||
|
<span className="text-muted-foreground">{quotaData[station.id].username}</span>
|
||||||
|
)}
|
||||||
|
<Badge variant="secondary" className="text-xs h-5 px-1.5">
|
||||||
|
{quotaData[station.id].plan_type.toUpperCase()}
|
||||||
|
</Badge>
|
||||||
|
{quotaData[station.id].opus_enabled && (
|
||||||
|
<Badge variant="default" className="text-xs h-5 px-1.5 bg-purple-600">
|
||||||
|
Opus
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 账户余额 */}
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">余额:</span>
|
||||||
|
<span className="font-medium text-blue-600">
|
||||||
|
${Number(quotaData[station.id].balance_usd).toFixed(2)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 日额度 */}
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs">
|
||||||
|
<span className="text-muted-foreground">日额度:</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{(() => {
|
||||||
|
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
||||||
|
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={daily_spent > daily_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
||||||
|
${daily_spent.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/ ${daily_budget.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 ${
|
||||||
|
(() => {
|
||||||
|
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
||||||
|
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
||||||
|
return daily_spent / daily_budget > 0.8;
|
||||||
|
})() ? 'bg-orange-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(
|
||||||
|
(() => {
|
||||||
|
const daily_spent = Number(quotaData[station.id].daily_spent_usd);
|
||||||
|
const daily_budget = Number(quotaData[station.id].daily_budget_usd);
|
||||||
|
return (daily_spent / daily_budget) * 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">
|
||||||
|
{(() => {
|
||||||
|
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
||||||
|
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<span className={monthly_spent > monthly_budget * 0.8 ? 'text-orange-600' : 'text-green-600'}>
|
||||||
|
${monthly_spent.toFixed(2)}
|
||||||
|
</span>
|
||||||
|
<span className="text-muted-foreground">/</span>
|
||||||
|
<span className="text-muted-foreground">${monthly_budget.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 ${
|
||||||
|
(() => {
|
||||||
|
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
||||||
|
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
||||||
|
return monthly_spent / monthly_budget > 0.8;
|
||||||
|
})() ? 'bg-orange-500' : 'bg-green-500'
|
||||||
|
}`}
|
||||||
|
style={{ width: `${Math.min(
|
||||||
|
(() => {
|
||||||
|
const monthly_spent = Number(quotaData[station.id].monthly_spent_usd);
|
||||||
|
const monthly_budget = Number(quotaData[station.id].monthly_budget_usd);
|
||||||
|
return (monthly_spent / monthly_budget) * 100;
|
||||||
|
})(), 100)}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 总消费 */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-muted-foreground pt-2 border-t">
|
||||||
|
<span>总消费:</span>
|
||||||
|
<span className="font-medium">${Number(quotaData[station.id].total_spent_usd).toFixed(2)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-xs text-center text-muted-foreground py-2">
|
||||||
|
额度信息加载失败
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
};
|
||||||
@@ -2478,6 +2478,21 @@ export const api = {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Updates the display order of relay stations
|
||||||
|
* @author yovinchen
|
||||||
|
* @param stationIds - Array of station IDs in the new order
|
||||||
|
* @returns Promise resolving when order is updated
|
||||||
|
*/
|
||||||
|
async relayStationUpdateOrder(stationIds: string[]): Promise<void> {
|
||||||
|
try {
|
||||||
|
return await invoke<void>("relay_station_update_order", { stationIds });
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update relay station order:", error);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
// ============= PackyCode Nodes =============
|
// ============= PackyCode Nodes =============
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
Reference in New Issue
Block a user