新增中转站模块可拖拽

This commit is contained in:
2025-10-11 12:22:00 +08:00
parent e05a286653
commit 4046140413
5 changed files with 405 additions and 226 deletions

View File

@@ -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(())
}

View File

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

View File

@@ -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,226 +709,47 @@ const RelayStationManager: React.FC<RelayStationManagerProps> = ({ onBack }) =>
</Card> </Card>
{/* 中转站列表 */} {/* 中转站列表 */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6"> <DndContext
{loading ? ( sensors={sensors}
<div className="col-span-full text-center py-12"> collisionDetection={closestCenter}
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div> onDragEnd={handleDragEnd}
<p className="mt-2 text-muted-foreground">{t('common.loading')}</p> >
<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">
{loading ? (
<div className="col-span-full text-center py-12">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto"></div>
<p className="mt-2 text-muted-foreground">{t('common.loading')}</p>
</div>
) : stations.length === 0 ? (
<div className="col-span-full text-center py-12">
<Server className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t('relayStation.noStations')}</h3>
<p className="text-muted-foreground mb-4">{t('relayStation.noStationsDesc')}</p>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('relayStation.createFirst')}
</Button>
</div>
) : (
stations.map((station) => <SortableStationItem
key={station.id}
station={station}
getStatusBadge={getStatusBadge}
getAdapterDisplayName={getAdapterDisplayName}
setSelectedStation={setSelectedStation}
setShowEditDialog={setShowEditDialog}
openDeleteDialog={openDeleteDialog}
quotaData={quotaData}
loadingQuota={loadingQuota}
/>)
)}
</div> </div>
) : stations.length === 0 ? ( </SortableContext>
<div className="col-span-full text-center py-12"> </DndContext>
<Server className="mx-auto h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-semibold mb-2">{t('relayStation.noStations')}</h3>
<p className="text-muted-foreground mb-4">{t('relayStation.noStationsDesc')}</p>
<Button onClick={() => setShowCreateDialog(true)}>
<Plus className="mr-2 h-4 w-4" />
{t('relayStation.createFirst')}
</Button>
</div>
) : (
stations.map((station) => (
<Card key={station.id} className="relative">
<CardHeader className="pb-2 pt-3 px-3">
<div className="flex justify-between items-center">
<div className="flex-1 min-w-0 mr-2">
<CardTitle className="text-sm font-medium">{station.name}</CardTitle>
<CardDescription className="text-xs mt-0.5">
{getAdapterDisplayName(station.adapter)}
</CardDescription>
</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>总消费: ${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>
{/* 编辑对话框 */} {/* 编辑对话框 */}
{selectedStation && ( {selectedStation && (

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

View File

@@ -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 =============
/** /**