diff --git a/apps/src-tauri/src/commands/aggregate_api.rs b/apps/src-tauri/src/commands/aggregate_api.rs index 2b8563b89..8327d42d2 100644 --- a/apps/src-tauri/src/commands/aggregate_api.rs +++ b/apps/src-tauri/src/commands/aggregate_api.rs @@ -50,6 +50,12 @@ pub async fn service_aggregate_api_create( model_override: Option, username: Option, password: Option, + balance_query_enabled: Option, + balance_query_template: Option, + balance_query_base_url: Option, + balance_query_access_token: Option, + balance_query_user_id: Option, + balance_query_config_json: Option, ) -> Result { let params = serde_json::json!({ "providerType": provider_type, @@ -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 } @@ -104,6 +116,12 @@ pub async fn service_aggregate_api_update( model_override: Option, username: Option, password: Option, + balance_query_enabled: Option, + balance_query_template: Option, + balance_query_base_url: Option, + balance_query_access_token: Option, + balance_query_user_id: Option, + balance_query_config_json: Option, ) -> Result { let params = serde_json::json!({ "id": id, @@ -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 } @@ -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, + id: String, +) -> Result { + let params = serde_json::json!({ "id": id }); + rpc_call_in_background("aggregateApi/refreshBalance", addr, Some(params)).await +} diff --git a/apps/src-tauri/src/commands/registry.rs b/apps/src-tauri/src/commands/registry.rs index 5d0bd9eeb..a027a1b0c 100644 --- a/apps/src-tauri/src/commands/registry.rs +++ b/apps/src-tauri/src/commands/registry.rs @@ -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, diff --git a/apps/src/app/aggregate-api/page.tsx b/apps/src/app/aggregate-api/page.tsx index e240b0de3..36d2f4080 100644 --- a/apps/src/app/aggregate-api/page.tsx +++ b/apps/src/app/aggregate-api/page.tsx @@ -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; @@ -72,6 +76,43 @@ const AGGREGATE_API_PROVIDER_FILTER_LABELS: Record = { 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; + 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) + : 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` * @@ -123,6 +164,8 @@ export default function AggregateApiPage() { const [loadingSecretId, setLoadingSecretId] = useState(null); const [testingApiId, setTestingApiId] = useState(null); const [testingAll, setTestingAll] = useState(false); + const [refreshingBalanceId, setRefreshingBalanceId] = useState(null); + const [refreshingBalances, setRefreshingBalances] = useState(false); const [togglingApiId, setTogglingApiId] = useState(null); const [statusOverrides, setStatusOverrides] = useState>( {}, @@ -223,6 +266,67 @@ export default function AggregateApiPage() { ); }; + const renderBalanceStatus = (api: AggregateApi) => { + if (!api.balanceQueryEnabled) { + return {t("未启用")}; + } + + const snapshot = parseBalanceSnapshot(api); + if (api.lastBalanceStatus === "success" && snapshot) { + const badge = ( + + {formatBalanceAmount(snapshot)} + + ); + 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 ( + + } className="inline-flex cursor-help"> + {badge} + + + {details.join("\n")} + + + ); + } + + if (api.lastBalanceStatus === "failed") { + const badge = ( + + {t("查询失败")} + + ); + if (!api.lastBalanceError) { + return badge; + } + return ( + + } className="inline-flex cursor-help"> + {badge} + + + {api.lastBalanceError} + + + ); + } + + return {t("未查询")}; + }; + const testMutation = useMutation({ mutationFn: (apiId: string) => accountClient.testAggregateApiConnection(apiId), @@ -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 () => { @@ -631,6 +795,28 @@ export default function AggregateApiPage() { {t("测试全部")} + + + {t("刷新余额")} + + + {api.lastBalanceAt ? ( +

+ {formatTsFromSeconds(api.lastBalanceAt, t("未知时间"))} +

+ ) : null} + {api.lastBalanceStatus === "failed" && api.lastBalanceError ? ( + + } + className="mt-1 block max-w-full cursor-help text-left" + > +

+ {api.lastBalanceError} +

+
+ + {api.lastBalanceError} + +
+ ) : null} +
(null); @@ -269,6 +272,7 @@ export function AppBootstrap({ children }: { children: React.ReactNode }) { setIsInitializing(false); return; } + unsupportedRuntimeRetryCountRef.current = 0; const settings = await appClient.getSettings(); const addr = normalizeServiceAddr(settings.serviceAddr || DEFAULT_SERVICE_ADDR); @@ -421,6 +425,26 @@ export function AppBootstrap({ children }: { children: React.ReactNode }) { void init(); }, [init]); + useEffect(() => { + if ( + hasInitializedOnce.current || + isInitializing || + !error || + !isUnsupportedWebRuntime || + typeof window === "undefined" || + unsupportedRuntimeRetryCountRef.current >= + UNSUPPORTED_RUNTIME_AUTO_RETRY_LIMIT + ) { + return; + } + + const retryId = window.setTimeout(() => { + unsupportedRuntimeRetryCountRef.current += 1; + void init(); + }, UNSUPPORTED_RUNTIME_AUTO_RETRY_DELAY_MS); + return () => window.clearTimeout(retryId); + }, [error, init, isInitializing, isUnsupportedWebRuntime]); + useEffect(() => { if (isDesktopRuntime || typeof window === "undefined") { return; diff --git a/apps/src/components/modals/aggregate-api-modal.tsx b/apps/src/components/modals/aggregate-api-modal.tsx index f91933eee..1cb715219 100644 --- a/apps/src/components/modals/aggregate-api-modal.tsx +++ b/apps/src/components/modals/aggregate-api-modal.tsx @@ -41,6 +41,44 @@ const AGGREGATE_API_URL_PLACEHOLDERS: Record = { claude: "例如:https://api.anthropic.com/v1", }; +type BalanceQueryTemplate = "generic" | "new_api" | "custom"; +type BalanceCustomAuth = "provider_bearer" | "balance_bearer" | "none"; + +interface BalanceCustomConfig { + path?: unknown; + auth?: unknown; + remainingPath?: unknown; + unit?: unknown; + multiplier?: unknown; + totalPath?: unknown; + usedPath?: unknown; + planPath?: unknown; + validPath?: unknown; + invalidMessagePath?: unknown; +} + +const parseBalanceCustomConfig = ( + value: string | null | undefined +): BalanceCustomConfig => { + if (!value) return {}; + try { + const parsed = JSON.parse(value); + return parsed && typeof parsed === "object" + ? (parsed as BalanceCustomConfig) + : {}; + } catch { + return {}; + } +}; + +const stringConfigValue = (value: unknown, fallback = "") => + typeof value === "string" ? value : fallback; + +const normalizeBalanceCustomAuth = (value: unknown): BalanceCustomAuth => + value === "balance_bearer" || value === "none" + ? value + : "provider_bearer"; + interface AggregateApiModalProps { open: boolean; onOpenChange: (open: boolean) => void; @@ -93,6 +131,27 @@ export function AggregateApiModal({ const [actionCustomEnabled, setActionCustomEnabled] = useState(false); const [action, setAction] = useState(""); const [modelOverride, setModelOverride] = useState(""); + const [balanceQueryEnabled, setBalanceQueryEnabled] = useState(false); + const [balanceQueryTemplate, setBalanceQueryTemplate] = + useState("generic"); + const [balanceQueryBaseUrl, setBalanceQueryBaseUrl] = useState(""); + const [balanceQueryAccessToken, setBalanceQueryAccessToken] = useState(""); + const [balanceQueryUserId, setBalanceQueryUserId] = useState(""); + const [balanceCustomPath, setBalanceCustomPath] = useState("/v1/usage"); + const [balanceCustomAuth, setBalanceCustomAuth] = + useState("provider_bearer"); + const [balanceCustomRemainingPath, setBalanceCustomRemainingPath] = + useState("remaining"); + const [balanceCustomUnit, setBalanceCustomUnit] = useState("USD"); + const [balanceCustomMultiplier, setBalanceCustomMultiplier] = useState("1"); + const [balanceCustomTotalPath, setBalanceCustomTotalPath] = useState(""); + const [balanceCustomUsedPath, setBalanceCustomUsedPath] = useState(""); + const [balanceCustomPlanPath, setBalanceCustomPlanPath] = useState(""); + const [balanceCustomValidPath, setBalanceCustomValidPath] = useState(""); + const [ + balanceCustomInvalidMessagePath, + setBalanceCustomInvalidMessagePath, + ] = useState(""); const [key, setKey] = useState(""); const [generatedKey, setGeneratedKey] = useState(""); const [isLoading, setIsLoading] = useState(false); @@ -156,6 +215,39 @@ export function AggregateApiModal({ setAction(nextAction); setActionCustomEnabled(aggregateApi?.action !== null && aggregateApi?.action !== undefined); setModelOverride(aggregateApi?.modelOverride || ""); + setBalanceQueryEnabled(Boolean(aggregateApi?.balanceQueryEnabled)); + const nextBalanceQueryTemplate = + aggregateApi?.balanceQueryTemplate === "new_api" + ? "new_api" + : aggregateApi?.balanceQueryTemplate === "custom" + ? "custom" + : "generic"; + setBalanceQueryTemplate(nextBalanceQueryTemplate); + setBalanceQueryBaseUrl(aggregateApi?.balanceQueryBaseUrl || ""); + setBalanceQueryAccessToken(""); + setBalanceQueryUserId(aggregateApi?.balanceQueryUserId || ""); + const customConfig = parseBalanceCustomConfig( + aggregateApi?.balanceQueryConfigJson + ); + setBalanceCustomPath(stringConfigValue(customConfig.path, "/v1/usage")); + setBalanceCustomAuth(normalizeBalanceCustomAuth(customConfig.auth)); + setBalanceCustomRemainingPath( + stringConfigValue(customConfig.remainingPath, "remaining") + ); + setBalanceCustomUnit(stringConfigValue(customConfig.unit, "USD")); + setBalanceCustomMultiplier( + typeof customConfig.multiplier === "number" && + Number.isFinite(customConfig.multiplier) + ? String(customConfig.multiplier) + : "1" + ); + setBalanceCustomTotalPath(stringConfigValue(customConfig.totalPath)); + setBalanceCustomUsedPath(stringConfigValue(customConfig.usedPath)); + setBalanceCustomPlanPath(stringConfigValue(customConfig.planPath)); + setBalanceCustomValidPath(stringConfigValue(customConfig.validPath)); + setBalanceCustomInvalidMessagePath( + stringConfigValue(customConfig.invalidMessagePath) + ); setKey(""); setUsername(""); setPassword(""); @@ -265,6 +357,46 @@ export function AggregateApiModal({ } } } + let balanceQueryConfigJson: string | null = null; + if (balanceQueryTemplate === "custom") { + const customPath = balanceCustomPath.trim(); + const remainingPath = balanceCustomRemainingPath.trim(); + const multiplierText = balanceCustomMultiplier.trim() || "1"; + const multiplier = Number(multiplierText); + if (!customPath) { + toast.error(t("请输入自定义余额查询路径")); + return; + } + if (!remainingPath) { + toast.error(t("请输入余额字段路径")); + return; + } + if (!Number.isFinite(multiplier) || multiplier <= 0) { + toast.error(t("余额倍率必须大于 0")); + return; + } + const config: Record = { + method: "GET", + path: customPath, + auth: balanceCustomAuth, + remainingPath, + unit: balanceCustomUnit.trim() || "USD", + multiplier, + }; + const totalPath = balanceCustomTotalPath.trim(); + const usedPath = balanceCustomUsedPath.trim(); + const planPath = balanceCustomPlanPath.trim(); + const validPath = balanceCustomValidPath.trim(); + const invalidMessagePath = balanceCustomInvalidMessagePath.trim(); + if (totalPath) config.totalPath = totalPath; + if (usedPath) config.usedPath = usedPath; + if (planPath) config.planPath = planPath; + if (validPath) config.validPath = validPath; + if (invalidMessagePath) { + config.invalidMessagePath = invalidMessagePath; + } + balanceQueryConfigJson = JSON.stringify(config); + } setIsLoading(true); try { if (aggregateApi?.id) { @@ -282,6 +414,12 @@ export function AggregateApiModal({ modelOverride: modelOverride.trim(), username: authType === "userpass" ? username.trim() || null : null, password: authType === "userpass" ? password.trim() || null : null, + balanceQueryEnabled, + balanceQueryTemplate, + balanceQueryBaseUrl: balanceQueryBaseUrl.trim(), + balanceQueryAccessToken: balanceQueryAccessToken.trim() || null, + balanceQueryUserId: balanceQueryUserId.trim(), + balanceQueryConfigJson, }); toast.success(t("聚合 API 已更新")); await Promise.all([ @@ -307,6 +445,12 @@ export function AggregateApiModal({ modelOverride: modelOverride.trim(), username: authType === "userpass" ? username.trim() : null, password: authType === "userpass" ? password.trim() : null, + balanceQueryEnabled, + balanceQueryTemplate, + balanceQueryBaseUrl: balanceQueryBaseUrl.trim(), + balanceQueryAccessToken: balanceQueryAccessToken.trim() || null, + balanceQueryUserId: balanceQueryUserId.trim(), + balanceQueryConfigJson, }); setGeneratedKey(result.key); toast.success(t("聚合 API 已创建")); @@ -715,6 +859,295 @@ export function AggregateApiModal({
+
+
+
+ +

+ {t("开启后可在聚合 API 列表手动刷新并显示余额。")} +

+
+ + setBalanceQueryEnabled(Boolean(checked)) + } + /> +
+ + {balanceQueryEnabled ? ( +
+
+ + +
+ +
+ + + setBalanceQueryBaseUrl(event.target.value) + } + /> +
+ + {balanceQueryTemplate === "custom" ? ( + <> +
+ + + setBalanceCustomPath(event.target.value) + } + /> +
+
+ + +
+
+ + + setBalanceCustomRemainingPath(event.target.value) + } + /> +
+
+ + + setBalanceCustomMultiplier(event.target.value) + } + /> +
+
+ + + setBalanceCustomUnit(event.target.value) + } + /> +
+
+ + + setBalanceCustomTotalPath(event.target.value) + } + /> +
+
+ + + setBalanceCustomUsedPath(event.target.value) + } + /> +
+
+ + + setBalanceCustomPlanPath(event.target.value) + } + /> +
+
+ + + setBalanceCustomValidPath(event.target.value) + } + /> +
+
+ + + setBalanceCustomInvalidMessagePath( + event.target.value + ) + } + /> +
+ {balanceCustomAuth === "balance_bearer" ? ( +
+ + + setBalanceQueryAccessToken(event.target.value) + } + /> +
+ ) : null} + + ) : null} + + {balanceQueryTemplate === "new_api" ? ( + <> +
+ + + setBalanceQueryAccessToken(event.target.value) + } + /> +
+
+ + + setBalanceQueryUserId(event.target.value) + } + /> +
+ + ) : null} +
+ ) : null} +
+ {generatedKey ? (