Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions apps/src-tauri/src/commands/aggregate_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,12 @@ pub async fn service_aggregate_api_create(
model_override: Option<String>,
username: Option<String>,
password: Option<String>,
balance_query_enabled: Option<bool>,
balance_query_template: Option<String>,
balance_query_base_url: Option<String>,
balance_query_access_token: Option<String>,
balance_query_user_id: Option<String>,
balance_query_config_json: Option<String>,
) -> Result<serde_json::Value, String> {
let params = serde_json::json!({
"providerType": provider_type,
Expand All @@ -65,6 +71,12 @@ pub async fn service_aggregate_api_create(
"modelOverride": model_override,
"username": username,
"password": password,
"balanceQueryEnabled": balance_query_enabled,
"balanceQueryTemplate": balance_query_template,
"balanceQueryBaseUrl": balance_query_base_url,
"balanceQueryAccessToken": balance_query_access_token,
"balanceQueryUserId": balance_query_user_id,
"balanceQueryConfigJson": balance_query_config_json,
});
rpc_call_in_background("aggregateApi/create", addr, Some(params)).await
}
Expand Down Expand Up @@ -104,6 +116,12 @@ pub async fn service_aggregate_api_update(
model_override: Option<String>,
username: Option<String>,
password: Option<String>,
balance_query_enabled: Option<bool>,
balance_query_template: Option<String>,
balance_query_base_url: Option<String>,
balance_query_access_token: Option<String>,
balance_query_user_id: Option<String>,
balance_query_config_json: Option<String>,
) -> Result<serde_json::Value, String> {
let params = serde_json::json!({
"id": id,
Expand All @@ -121,6 +139,12 @@ pub async fn service_aggregate_api_update(
"modelOverride": model_override,
"username": username,
"password": password,
"balanceQueryEnabled": balance_query_enabled,
"balanceQueryTemplate": balance_query_template,
"balanceQueryBaseUrl": balance_query_base_url,
"balanceQueryAccessToken": balance_query_access_token,
"balanceQueryUserId": balance_query_user_id,
"balanceQueryConfigJson": balance_query_config_json,
});
rpc_call_in_background("aggregateApi/update", addr, Some(params)).await
}
Expand Down Expand Up @@ -187,3 +211,12 @@ pub async fn service_aggregate_api_test_connection(
let params = serde_json::json!({ "id": id });
rpc_call_in_background("aggregateApi/testConnection", addr, Some(params)).await
}

#[tauri::command]
pub async fn service_aggregate_api_refresh_balance(
addr: Option<String>,
id: String,
) -> Result<serde_json::Value, String> {
let params = serde_json::json!({ "id": id });
rpc_call_in_background("aggregateApi/refreshBalance", addr, Some(params)).await
}
1 change: 1 addition & 0 deletions apps/src-tauri/src/commands/registry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ macro_rules! invoke_handler {
crate::commands::aggregate_api::service_aggregate_api_update,
crate::commands::aggregate_api::service_aggregate_api_delete,
crate::commands::aggregate_api::service_aggregate_api_test_connection,
crate::commands::aggregate_api::service_aggregate_api_refresh_balance,
crate::commands::apikey::service_apikey_list,
crate::commands::apikey::service_apikey_read_secret,
crate::commands::apikey::service_apikey_create,
Expand Down
246 changes: 244 additions & 2 deletions apps/src/app/aggregate-api/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,11 @@ import { useDeferredDesktopActivation } from "@/hooks/useDeferredDesktopActivati
import { usePageTransitionReady } from "@/hooks/usePageTransitionReady";
import { useRuntimeCapabilities } from "@/hooks/useRuntimeCapabilities";
import { useI18n } from "@/lib/i18n/provider";
import { AggregateApi, AggregateApiSecretResult } from "@/types";
import {
AggregateApi,
AggregateApiBalanceSnapshot,
AggregateApiSecretResult,
} from "@/types";

type TranslateFn = (key: string, values?: Record<string, string | number>) => string;

Expand All @@ -72,6 +76,43 @@ const AGGREGATE_API_PROVIDER_FILTER_LABELS: Record<string, string> = {
claude: "Claude",
};

function parseBalanceSnapshot(api: AggregateApi): AggregateApiBalanceSnapshot | null {
const raw = String(api.lastBalanceJson || "").trim();
if (!raw) return null;
try {
const parsed = JSON.parse(raw) as Partial<AggregateApiBalanceSnapshot>;
return {
isValid: parsed.isValid ?? true,
invalidMessage: parsed.invalidMessage ?? null,
remaining: typeof parsed.remaining === "number" ? parsed.remaining : null,
unit: typeof parsed.unit === "string" ? parsed.unit : null,
planName: typeof parsed.planName === "string" ? parsed.planName : null,
total: typeof parsed.total === "number" ? parsed.total : null,
used: typeof parsed.used === "number" ? parsed.used : null,
extra:
parsed.extra && typeof parsed.extra === "object"
? (parsed.extra as Record<string, unknown>)
: null,
};
} catch {
return null;
}
}

function formatBalanceAmount(snapshot: AggregateApiBalanceSnapshot | null) {
if (!snapshot || typeof snapshot.remaining !== "number") {
return "-";
}
const unit = String(snapshot.unit || "").trim();
const value = Number.isInteger(snapshot.remaining)
? String(snapshot.remaining)
: snapshot.remaining.toFixed(2);
if (unit.toUpperCase() === "USD") {
return `$${value}`;
}
return unit ? `${value} ${unit}` : value;
}

/**
* 函数 `getTestBadge`
*
Expand Down Expand Up @@ -123,6 +164,8 @@ export default function AggregateApiPage() {
const [loadingSecretId, setLoadingSecretId] = useState<string | null>(null);
const [testingApiId, setTestingApiId] = useState<string | null>(null);
const [testingAll, setTestingAll] = useState(false);
const [refreshingBalanceId, setRefreshingBalanceId] = useState<string | null>(null);
const [refreshingBalances, setRefreshingBalances] = useState(false);
const [togglingApiId, setTogglingApiId] = useState<string | null>(null);
const [statusOverrides, setStatusOverrides] = useState<Record<string, boolean>>(
{},
Expand Down Expand Up @@ -223,6 +266,67 @@ export default function AggregateApiPage() {
);
};

const renderBalanceStatus = (api: AggregateApi) => {
if (!api.balanceQueryEnabled) {
return <Badge variant="secondary">{t("未启用")}</Badge>;
}

const snapshot = parseBalanceSnapshot(api);
if (api.lastBalanceStatus === "success" && snapshot) {
const badge = (
<Badge className="border-emerald-500/20 bg-emerald-500/10 text-emerald-600">
{formatBalanceAmount(snapshot)}
</Badge>
);
const details = [
snapshot.planName ? `${t("套餐")}: ${snapshot.planName}` : null,
typeof snapshot.used === "number"
? `${t("已用")}: ${snapshot.used.toFixed(2)}`
: null,
typeof snapshot.total === "number"
? `${t("总额")}: ${snapshot.total.toFixed(2)}`
: null,
].filter(Boolean);

if (details.length === 0) {
return badge;
}
return (
<Tooltip>
<TooltipTrigger render={<div />} className="inline-flex cursor-help">
{badge}
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{details.join("\n")}
</TooltipContent>
</Tooltip>
);
}

if (api.lastBalanceStatus === "failed") {
const badge = (
<Badge className="border-red-500/20 bg-red-500/10 text-red-500">
{t("查询失败")}
</Badge>
);
if (!api.lastBalanceError) {
return badge;
}
return (
<Tooltip>
<TooltipTrigger render={<div />} className="inline-flex cursor-help">
{badge}
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{api.lastBalanceError}
</TooltipContent>
</Tooltip>
);
}

return <Badge variant="secondary">{t("未查询")}</Badge>;
};

const testMutation = useMutation({
mutationFn: (apiId: string) =>
accountClient.testAggregateApiConnection(apiId),
Expand Down Expand Up @@ -285,6 +389,66 @@ export default function AggregateApiPage() {
},
});

const refreshBalanceMutation = useMutation({
mutationFn: (apiId: string) => accountClient.refreshAggregateApiBalance(apiId),
onMutate: async (apiId) => {
setRefreshingBalanceId(apiId);
},
onSuccess: async (result) => {
if (result.ok) {
toast.success(t("余额已刷新"));
return;
}
toast.warning(
t("余额查询失败 {reason}", {
reason: result.message || t("未返回具体错误信息"),
}),
);
},
onSettled: async (_result, _error, apiId) => {
await queryClient.invalidateQueries({ queryKey: ["aggregate-apis"] });
setRefreshingBalanceId((current) => (current === apiId ? null : current));
},
onError: (error: unknown) => {
toast.error(`${t("余额查询失败")}: ${error instanceof Error ? error.message : String(error)}`);
},
});

const refreshAllBalancesMutation = useMutation({
mutationFn: async (apiIds: string[]) => {
const results = await Promise.allSettled(
apiIds.map((id) => accountClient.refreshAggregateApiBalance(id))
);
return results;
},
onMutate: async () => {
setRefreshingBalances(true);
},
onSuccess: async (results) => {
const successCount = results.filter(
(r) => r.status === "fulfilled" && r.value.ok
).length;
const failCount = results.length - successCount;
if (failCount === 0) {
toast.success(t("余额刷新完成:{count} 个成功", { count: successCount }));
} else {
toast.warning(
t("余额刷新完成:{success} 个成功,{fail} 个失败", {
success: successCount,
fail: failCount,
})
);
}
},
onSettled: async () => {
await queryClient.invalidateQueries({ queryKey: ["aggregate-apis"] });
setRefreshingBalances(false);
},
onError: (error: unknown) => {
toast.error(`${t("批量刷新余额失败")}: ${error instanceof Error ? error.message : String(error)}`);
},
});

const deleteMutation = useMutation({
mutationFn: (apiId: string) => accountClient.deleteAggregateApi(apiId),
onSuccess: async () => {
Expand Down Expand Up @@ -631,6 +795,28 @@ export default function AggregateApiPage() {
<RefreshCw className={testingAll ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{t("测试全部")}
</Button>
<Button
variant="outline"
className="h-10 gap-2"
onClick={() => {
const apiIds = filteredAggregateApis
.filter((api) => api.balanceQueryEnabled)
.map((api) => api.id);
if (apiIds.length === 0) {
toast.info(t("暂无已启用余额检测的聚合 API"));
return;
}
refreshAllBalancesMutation.mutate(apiIds);
}}
disabled={
!isServiceReady ||
refreshingBalances ||
filteredAggregateApis.every((api) => !api.balanceQueryEnabled)
}
>
<RefreshCw className={refreshingBalances ? "h-4 w-4 animate-spin" : "h-4 w-4"} />
{t("刷新余额")}
</Button>
<Button
className="h-10 gap-2 shadow-lg shadow-primary/20"
onClick={openCreateModal}
Expand All @@ -653,6 +839,7 @@ export default function AggregateApiPage() {
<TableHead className="w-[148px]">{t("密钥")}</TableHead>
<TableHead className="w-[64px] text-center">{t("顺序")}</TableHead>
<TableHead className="w-[130px]">{t("测试连通性")}</TableHead>
<TableHead className="w-[150px]">{t("余额")}</TableHead>
<TableHead className="w-[112px] text-right pr-4">{t("状态")}</TableHead>
<TableHead className="table-sticky-action-head w-[112px] text-center">
{t("操作")}
Expand All @@ -678,6 +865,9 @@ export default function AggregateApiPage() {
<TableCell>
<Skeleton className="h-6 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-24 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-16 rounded-full" />
</TableCell>
Expand All @@ -688,7 +878,7 @@ export default function AggregateApiPage() {
))
) : filteredAggregateApis.length === 0 ? (
<TableRow>
<TableCell colSpan={7} className="h-48 text-center">
<TableCell colSpan={8} className="h-48 text-center">
<div className="flex flex-col items-center justify-center gap-2 text-muted-foreground">
<ShieldCheck className="h-8 w-8 opacity-20" />
<p>
Expand Down Expand Up @@ -893,6 +1083,58 @@ export default function AggregateApiPage() {
</Tooltip>
) : null}
</TableCell>
<TableCell className="whitespace-nowrap align-middle">
<div className="flex items-center gap-2">
{renderBalanceStatus(api)}
<Tooltip>
<TooltipTrigger render={<span />} className="inline-flex">
<Button
variant="outline"
size="icon"
className="h-7 w-7"
disabled={
!isServiceReady ||
!api.balanceQueryEnabled ||
refreshingBalanceId === api.id ||
refreshingBalances
}
onClick={() =>
refreshBalanceMutation.mutate(api.id)
}
>
<RefreshCw
className={
refreshingBalanceId === api.id
? "h-3.5 w-3.5 animate-spin"
: "h-3.5 w-3.5"
}
/>
</Button>
</TooltipTrigger>
<TooltipContent>{t("刷新余额")}</TooltipContent>
</Tooltip>
</div>
{api.lastBalanceAt ? (
<p className="mt-1 text-[10px] text-muted-foreground">
{formatTsFromSeconds(api.lastBalanceAt, t("未知时间"))}
</p>
) : null}
{api.lastBalanceStatus === "failed" && api.lastBalanceError ? (
<Tooltip>
<TooltipTrigger
render={<div />}
className="mt-1 block max-w-full cursor-help text-left"
>
<p className="max-w-[180px] truncate text-[10px] text-red-500/90">
{api.lastBalanceError}
</p>
</TooltipTrigger>
<TooltipContent className="max-w-sm whitespace-pre-wrap break-words">
{api.lastBalanceError}
</TooltipContent>
</Tooltip>
) : null}
</TableCell>
<TableCell className="align-middle pr-4">
<div className="flex items-center justify-end gap-2">
<Switch
Expand Down
Loading