feat: 新增部分套餐账单查询(v0.6.4)
- 账单/用量查询:balance:kimi(Moonshot 开放平台余额)、NewAPI 中转站余额(accessToken+userId)、Kimi/智谱/MiniMax 套餐档位(5小时/7天) - managed:kimi-code 走 Kimi Code OAuth 登录凭据查询订阅用量,access token 过期自动 refresh 并写回凭据(单 flight 锁 + 写回前重读防 CLI 竞态) - 全屏「配置用量查询」页面:启用开关 / 自动检测 vs NewAPI 模板 / 超时与自动查询间隔 / 测试查询 - 供应商卡片紧凑用量行(切换使用左侧)+ 多档明细行;managed 供应商列表置顶(仅展示层) - 用量显示中英文适配(five_hour→5小时、weekly_limit→7天,Rust 错误串本地化映射) - 修复:usage_kinds serde rename 导致账单入口不显示;测试查询因 save 触发 loading 翻页卸载面板导致结果丢失(silent save)
This commit is contained in:
1 parent
12dcb17771
commit
ace9842556
26 files changed
+1822
-60
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2 } from "lucide-react";
|
||||
import { Pencil, Copy, Activity, Loader2, Trash2, BarChart3 } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { ProviderIcon } from "./ProviderIcon";
|
||||
import { UsageFooter } from "./UsageFooter";
|
||||
@@ -28,6 +28,7 @@ interface ProviderListProps {
|
||||
onDuplicate: (name: string) => void;
|
||||
onAdd: () => void;
|
||||
onSwitchProvider: (name: string) => void;
|
||||
onConfigureUsage: (name: string) => void;
|
||||
agent: Agent;
|
||||
}
|
||||
|
||||
@@ -43,11 +44,17 @@ export function ProviderList({
|
||||
onDuplicate,
|
||||
onAdd,
|
||||
onSwitchProvider,
|
||||
onConfigureUsage,
|
||||
agent,
|
||||
}: ProviderListProps) {
|
||||
const { t } = useTranslation();
|
||||
const [testState, setTestState] = useState<Record<string, TestState>>({});
|
||||
|
||||
// managed(OAuth 登录托管)供应商排在最前;其余保持原有顺序(稳定排序)。
|
||||
const sortedProviders = [...providers].sort(
|
||||
(a, b) => Number(b.managed === true) - Number(a.managed === true)
|
||||
);
|
||||
|
||||
const handleTest = async (provider: Provider) => {
|
||||
setTestState((s) => ({ ...s, [provider.name]: { status: "testing" } }));
|
||||
try {
|
||||
@@ -132,7 +139,7 @@ export function ProviderList({
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-3 w-full">
|
||||
{providers.map((provider) => {
|
||||
{sortedProviders.map((provider) => {
|
||||
const providerModels = Object.values(models).filter(
|
||||
(m) => m.provider === provider.name
|
||||
);
|
||||
@@ -217,6 +224,19 @@ export function ProviderList({
|
||||
{ts.status === "ok" ? `${ts.latency}ms` : "✕"}
|
||||
</span>
|
||||
)}
|
||||
{/* compact usage summary — sits left of the switch button,
|
||||
mirroring cc-switch's card layout (usage → action buttons) */}
|
||||
{(provider.usageKinds?.length ?? 0) > 0 && (
|
||||
<UsageFooter
|
||||
agent={agent}
|
||||
providerName={provider.name}
|
||||
usageKinds={provider.usageKinds}
|
||||
variant="compact"
|
||||
autoIntervalMinutes={
|
||||
provider.usageConfig?.autoQueryIntervalMinutes
|
||||
}
|
||||
/>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -283,6 +303,18 @@ export function ProviderList({
|
||||
<Activity className="w-4 h-4" />
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onConfigureUsage(provider.name);
|
||||
}}
|
||||
title={t("usageConfigBtn")}
|
||||
className={iconBtn}
|
||||
aria-label={t("usageConfigBtn")}
|
||||
>
|
||||
<BarChart3 className="w-4 h-4" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
@@ -301,6 +333,7 @@ export function ProviderList({
|
||||
agent={agent}
|
||||
providerName={provider.name}
|
||||
usageKinds={provider.usageKinds}
|
||||
variant="detail"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { ArrowLeft, Eye, EyeOff, Loader2, Play } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import type { TranslationKey } from "../i18n/zh";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent, Provider, UsageConfig } from "../types";
|
||||
|
||||
interface UsageResult {
|
||||
success: boolean;
|
||||
data?: Array<{
|
||||
planName?: string | null;
|
||||
remaining?: number | null;
|
||||
total?: number | null;
|
||||
used?: number | null;
|
||||
unit?: string | null;
|
||||
}> | null;
|
||||
error?: string | null;
|
||||
}
|
||||
|
||||
interface UsageConfigModalProps {
|
||||
open: boolean;
|
||||
agent: Agent;
|
||||
provider: Provider;
|
||||
onClose: () => void;
|
||||
/** Persist the edited config (goes through updateConfig → silent save).
|
||||
* Awaited by the panel's test query so the backend sees the same config. */
|
||||
onSave: (providerName: string, config: UsageConfig) => Promise<void> | void;
|
||||
}
|
||||
|
||||
const TEMPLATES: ReadonlyArray<{
|
||||
id: "auto" | "newapi";
|
||||
labelKey: TranslationKey;
|
||||
hintKey: TranslationKey;
|
||||
}> = [
|
||||
{ id: "auto", labelKey: "usageTemplateAuto", hintKey: "usageAutoDetectHint" },
|
||||
{ id: "newapi", labelKey: "usageTemplateNewapi", hintKey: "usageNewapiHint" },
|
||||
];
|
||||
|
||||
function defaultConfig(provider: Provider): UsageConfig {
|
||||
return (
|
||||
provider.usageConfig ?? {
|
||||
enabled: true,
|
||||
templateType: "auto",
|
||||
baseUrl: provider.base_url ?? undefined,
|
||||
autoQueryIntervalMinutes: 0,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/** cc-switch FullScreenPanel 行为:焦点在输入框/文本域时 ESC 不关闭面板。 */
|
||||
function isTextEditableTarget(target: EventTarget | null): boolean {
|
||||
if (!(target instanceof HTMLElement)) return false;
|
||||
const tag = target.tagName;
|
||||
return (
|
||||
tag === "INPUT" || tag === "TEXTAREA" || target.isContentEditable
|
||||
);
|
||||
}
|
||||
|
||||
export function UsageConfigModal({ open, agent, provider, onClose, onSave }: UsageConfigModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [cfg, setCfg] = useState<UsageConfig>(() => defaultConfig(provider));
|
||||
const [showToken, setShowToken] = useState(false);
|
||||
const [showApiKey, setShowApiKey] = useState(false);
|
||||
const [testing, setTesting] = useState(false);
|
||||
const [testResult, setTestResult] = useState<string | null>(null);
|
||||
const [testOk, setTestOk] = useState<boolean | null>(null);
|
||||
|
||||
// Re-seed when switching provider or reopening.
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setCfg(defaultConfig(provider));
|
||||
setTestResult(null);
|
||||
setTestOk(null);
|
||||
setShowToken(false);
|
||||
setShowApiKey(false);
|
||||
}
|
||||
}, [open, provider.name]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape" && !isTextEditableTarget(e.target)) onClose();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
window.removeEventListener("keydown", onKey);
|
||||
};
|
||||
}, [open, onClose]);
|
||||
|
||||
const isManaged = provider.managed === true;
|
||||
const detectedKinds = useMemo(() => provider.usageKinds ?? [], [provider.usageKinds]);
|
||||
|
||||
if (!open) return null;
|
||||
|
||||
const handleSave = () => {
|
||||
void onSave(provider.name, {
|
||||
...cfg,
|
||||
templateType: isManaged ? "auto" : cfg.templateType,
|
||||
});
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleTest = async () => {
|
||||
setTesting(true);
|
||||
setTestResult(null);
|
||||
setTestOk(null);
|
||||
|
||||
// 短路:自动模板 + 无识别类型 → 不发请求,直接给出本地化提示 + 切换入口。
|
||||
// managed 供应商除外:它们走 OAuth 查询,与 detectedKinds 无关。
|
||||
if (!isManaged && cfg.templateType === "auto" && detectedKinds.length === 0) {
|
||||
setTestOk(false);
|
||||
setTestResult(t("usageUnsupportedProvider"));
|
||||
setTesting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// Persist first (awaited) so the backend query sees the same config
|
||||
// the user is testing.
|
||||
await onSave(provider.name, cfg);
|
||||
const result = await invoke<UsageResult>("query_provider_usage", {
|
||||
agent,
|
||||
providerName: provider.name,
|
||||
forceRefresh: true,
|
||||
});
|
||||
if (result.success) {
|
||||
setTestOk(true);
|
||||
const summary = (result.data ?? [])
|
||||
.map((d) => {
|
||||
const parts: string[] = [];
|
||||
if (d.planName) parts.push(planLabel(d.planName, t));
|
||||
if (d.remaining != null) parts.push(`${t("usageRemaining")} ${d.remaining}${d.unit ?? ""}`);
|
||||
if (d.used != null && d.total != null && d.total > 0)
|
||||
parts.push(`${Math.round((d.used / d.total) * 100)}%`);
|
||||
return parts.join(" ");
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
setTestResult(summary || t("usageQueryOk"));
|
||||
} else {
|
||||
setTestOk(false);
|
||||
setTestResult(result.error ? localizeUsageError(result.error, t) : t("usageQueryFailed"));
|
||||
}
|
||||
} catch {
|
||||
setTestOk(false);
|
||||
// Transient failure (network/timeout) — same label as the card footer.
|
||||
setTestResult(t("usageNetworkError"));
|
||||
} finally {
|
||||
setTesting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return createPortal(
|
||||
<div className="fixed inset-0 z-[9999] flex flex-col bg-app text-content-primary">
|
||||
{/* header */}
|
||||
<div className="h-16 shrink-0 flex items-center gap-4 px-6 border-b border-border">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
aria-label={t("usageBack")}
|
||||
className="w-8 h-8 flex items-center justify-center rounded-lg text-content-muted hover:text-content-primary hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</button>
|
||||
<h2 className="text-lg font-semibold">
|
||||
{t("usageConfigTitle", { name: provider.name })}
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{/* body */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
<div className="max-w-3xl mx-auto px-6 py-6 space-y-5">
|
||||
{/* enable toggle */}
|
||||
<div className="flex items-center justify-between rounded-lg border border-border bg-hover/40 px-4 py-3">
|
||||
<span className="text-sm text-content-primary">{t("usageEnable")}</span>
|
||||
<button
|
||||
type="button"
|
||||
role="switch"
|
||||
aria-checked={cfg.enabled}
|
||||
onClick={() => setCfg({ ...cfg, enabled: !cfg.enabled })}
|
||||
className={`relative w-10 h-5 rounded-full transition-colors ${
|
||||
cfg.enabled ? "bg-blue-600" : "bg-hover-2"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`absolute top-0.5 w-4 h-4 rounded-full bg-white transition-transform ${
|
||||
cfg.enabled ? "left-5" : "left-0.5"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{cfg.enabled && (
|
||||
<>
|
||||
{/* template picker */}
|
||||
<div className="rounded-lg border border-border px-4 py-4 space-y-3">
|
||||
<label className="block text-sm font-medium text-content-primary">{t("usageTemplate")}</label>
|
||||
{isManaged ? (
|
||||
<div className="text-sm text-content-muted rounded-lg border border-border bg-hover/40 px-4 py-3">
|
||||
{t("usageOAuthManagedHint")}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{TEMPLATES.map((tpl) => (
|
||||
<button
|
||||
key={tpl.id}
|
||||
type="button"
|
||||
onClick={() => setCfg({ ...cfg, templateType: tpl.id })}
|
||||
className={`px-3 py-1.5 text-sm rounded-lg border transition-colors ${
|
||||
cfg.templateType === tpl.id
|
||||
? "bg-blue-600 text-white border-blue-600"
|
||||
: "border-border text-content-primary hover:bg-hover-2"
|
||||
}`}
|
||||
>
|
||||
{t(tpl.labelKey)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs text-content-muted">
|
||||
{cfg.templateType === "newapi"
|
||||
? t("usageNewapiHint")
|
||||
: t("usageAutoDetectHint")}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* supported variables (read-only, cc-switch 风格) */}
|
||||
<div className="pt-1 space-y-1.5">
|
||||
<span className="block text-xs text-content-muted">{t("usageSupportedVars")}</span>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-blue-500">{"{{baseUrl}}"}</span>
|
||||
<span className="text-content-muted">=</span>
|
||||
<span className="text-content-primary break-all">
|
||||
{cfg.templateType === "newapi"
|
||||
? cfg.baseUrl || provider.base_url || "-"
|
||||
: provider.base_url || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs font-mono">
|
||||
<span className="text-blue-500">{"{{apiKey}}"}</span>
|
||||
<span className="text-content-muted">=</span>
|
||||
<span className="text-content-primary break-all">
|
||||
{provider.api_key
|
||||
? showApiKey
|
||||
? provider.api_key
|
||||
: "••••••••"
|
||||
: "-"}
|
||||
</span>
|
||||
{provider.api_key && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowApiKey((v) => !v)}
|
||||
className="text-content-muted hover:text-content-primary"
|
||||
>
|
||||
{showApiKey ? <EyeOff className="w-3.5 h-3.5" /> : <Eye className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* auto-detect: show resolved kinds as read-only chips */}
|
||||
{!isManaged && cfg.templateType === "auto" && (
|
||||
<div className="space-y-2">
|
||||
<label className="block text-sm text-content-muted">{t("usageDetectedKinds")}</label>
|
||||
{detectedKinds.length === 0 ? (
|
||||
<div className="rounded-lg border border-amber-300/40 bg-amber-50/40 dark:bg-amber-500/10 px-3 py-2.5 space-y-2">
|
||||
<p className="text-xs text-content-primary">{t("usageNoKindsHint")}</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCfg({ ...cfg, templateType: "newapi" })}
|
||||
className="text-xs px-2.5 py-1 rounded-md bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t("usageSwitchToNewapi")}
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{detectedKinds.map((k) => (
|
||||
<span
|
||||
key={k}
|
||||
className="text-xs px-2 py-0.5 rounded-full bg-hover-2 text-content-muted border border-border font-mono"
|
||||
>
|
||||
{k}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* newapi creds */}
|
||||
{!isManaged && cfg.templateType === "newapi" && (
|
||||
<div className="space-y-3">
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageBaseUrl")}</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.baseUrl ?? ""}
|
||||
placeholder={provider.base_url ?? "https://your-newapi-site.com"}
|
||||
onChange={(e) => setCfg({ ...cfg, baseUrl: e.target.value || undefined })}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">
|
||||
{t("usageAccessToken")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
type={showToken ? "text" : "password"}
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 pr-10 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.accessToken ?? ""}
|
||||
placeholder={t("usageAccessTokenPlaceholder")}
|
||||
onChange={(e) => setCfg({ ...cfg, accessToken: e.target.value || undefined })}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowToken((v) => !v)}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 text-content-muted hover:text-content-primary"
|
||||
>
|
||||
{showToken ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageAccessTokenHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">
|
||||
{t("usageUserId")} <span className="text-red-500">*</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="w-full bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.userId ?? ""}
|
||||
placeholder={t("usageUserIdPlaceholder")}
|
||||
onChange={(e) => setCfg({ ...cfg, userId: e.target.value || undefined })}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageUserIdHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* timeout + auto query interval */}
|
||||
<div className="rounded-lg border border-border px-4 py-4 grid grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageTimeout")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.timeoutSeconds ?? 0}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
|
||||
setCfg({ ...cfg, timeoutSeconds: n });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageTimeoutHint")}</p>
|
||||
</div>
|
||||
<div>
|
||||
<label className="block text-sm text-content-muted mb-1.5">{t("usageAutoInterval")}</label>
|
||||
<input
|
||||
type="number"
|
||||
min={0}
|
||||
className="w-32 bg-input border border-border rounded-lg px-3 py-2 text-sm focus:ring-2 focus:ring-blue-500 focus:outline-none"
|
||||
value={cfg.autoQueryIntervalMinutes ?? 0}
|
||||
onChange={(e) => {
|
||||
const n = Math.max(0, parseInt(e.target.value, 10) || 0);
|
||||
setCfg({ ...cfg, autoQueryIntervalMinutes: n });
|
||||
}}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-content-muted">{t("usageAutoIntervalHint")}</p>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* test result - sticky bottom inside scroll container so it's always visible above the footer */}
|
||||
</div>
|
||||
{testResult && (
|
||||
<div className="sticky bottom-0">
|
||||
<div className="max-w-3xl mx-auto px-6 pb-6">
|
||||
<div
|
||||
className={`text-sm rounded-lg border px-4 py-3 shadow-lg backdrop-blur-sm ${
|
||||
testOk
|
||||
? "border-green-300 dark:border-green-500/30 bg-green-50/95 dark:bg-green-900/30 text-green-700 dark:text-green-300"
|
||||
: "border-red-300 dark:border-red-500/30 bg-red-50/95 dark:bg-red-900/30 text-red-600 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResult}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* footer */}
|
||||
<div className="shrink-0 border-t border-border">
|
||||
<div className="max-w-3xl mx-auto px-6 py-4 flex items-center justify-end gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTest}
|
||||
disabled={testing || !cfg.enabled}
|
||||
className="flex items-center gap-1.5 px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{testing ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
{t("usageTestQuery")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
className="px-3 py-1.5 text-sm rounded-lg border border-border text-content-primary hover:bg-hover-2 transition-colors"
|
||||
>
|
||||
{t("cancel")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleSave}
|
||||
className="px-3 py-1.5 text-sm rounded-lg bg-blue-600 text-white hover:bg-blue-700 transition-colors"
|
||||
>
|
||||
{t("saveConfig")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { invoke } from "@tauri-apps/api/core";
|
||||
import { Loader2, RefreshCw } from "lucide-react";
|
||||
import { useTranslation } from "../i18n";
|
||||
import { localizeUsageError, planLabel } from "../lib/usage-display";
|
||||
import type { Agent } from "../types";
|
||||
|
||||
interface UsageData {
|
||||
@@ -31,7 +32,8 @@ interface CacheEntry {
|
||||
}
|
||||
|
||||
// Module-level cache shared across mounts: re-entering the list within the
|
||||
// stale TTL shows the last result without firing new requests.
|
||||
// stale TTL shows the last result without firing new requests. Both the
|
||||
// compact (card header) and detail (footer) variants read this same cache.
|
||||
const STALE_TTL_MS = 5 * 60 * 1000;
|
||||
const cache = new Map<string, CacheEntry>();
|
||||
|
||||
@@ -54,9 +56,21 @@ interface UsageFooterProps {
|
||||
agent: Agent;
|
||||
providerName: string;
|
||||
usageKinds?: string[];
|
||||
/** "detail" (default) renders the multi-line footer; "compact" renders a
|
||||
* one-line summary + last-updated + refresh button for the card header. */
|
||||
variant?: "detail" | "compact";
|
||||
/** Auto query interval in minutes; only wired on the compact variant so
|
||||
* both variants do not double-fire. 0/undefined = manual only. */
|
||||
autoIntervalMinutes?: number;
|
||||
}
|
||||
|
||||
export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProps) {
|
||||
export function UsageFooter({
|
||||
agent,
|
||||
providerName,
|
||||
usageKinds,
|
||||
variant = "detail",
|
||||
autoIntervalMinutes,
|
||||
}: UsageFooterProps) {
|
||||
const { t } = useTranslation();
|
||||
const supported = (usageKinds?.length ?? 0) > 0;
|
||||
// Cache key includes the agent so a Kimi Code provider and a Pi provider
|
||||
@@ -67,6 +81,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
const [data, setData] = useState<UsageData[]>([]);
|
||||
/** undefined = no error; null = network error; string = Rust error text. */
|
||||
const [error, setError] = useState<string | null | undefined>(undefined);
|
||||
const [updatedAt, setUpdatedAt] = useState<number | null>(null);
|
||||
const [now, setNow] = useState(() => Date.now());
|
||||
// Generation counter: stale responses (unmounted / superseded query) are ignored.
|
||||
const genRef = useRef(0);
|
||||
@@ -82,6 +97,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
setStatus(entry.status);
|
||||
setData(entry.data);
|
||||
setError(entry.error);
|
||||
setUpdatedAt(entry.updatedAt);
|
||||
};
|
||||
try {
|
||||
await acquireSlot();
|
||||
@@ -133,6 +149,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
setStatus(cached.status);
|
||||
setData(cached.data);
|
||||
setError(cached.error);
|
||||
setUpdatedAt(cached.updatedAt);
|
||||
if (Date.now() - cached.updatedAt < STALE_TTL_MS) return;
|
||||
}
|
||||
void runQuery(false);
|
||||
@@ -145,7 +162,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Tick the reset countdown once a minute while showing data.
|
||||
// Auto query interval (compact variant only, to avoid double-firing).
|
||||
useEffect(() => {
|
||||
if (variant !== "compact" || !supported) return;
|
||||
const mins = autoIntervalMinutes ?? 0;
|
||||
if (mins <= 0) return;
|
||||
const id = setInterval(() => void runQuery(false), mins * 60_000);
|
||||
return () => clearInterval(id);
|
||||
}, [variant, supported, autoIntervalMinutes, runQuery]);
|
||||
|
||||
// Tick relative times once a minute while showing data.
|
||||
useEffect(() => {
|
||||
if (status !== "success") return;
|
||||
const id = setInterval(() => setNow(Date.now()), 60_000);
|
||||
@@ -185,6 +211,16 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
return t("usageResetIn", { time: `${Math.floor(hours / 24)}d` });
|
||||
};
|
||||
|
||||
const formatAgo = (ts: number | null): string | null => {
|
||||
if (ts == null) return null;
|
||||
const mins = Math.floor((now - ts) / 60_000);
|
||||
if (mins < 1) return t("usageUpdatedAgo", { time: "1m" });
|
||||
if (mins < 60) return t("usageMinAgo", { n: mins });
|
||||
const hours = Math.floor(mins / 60);
|
||||
if (hours < 24) return t("usageHourAgo", { n: hours });
|
||||
return t("usageDayAgo", { n: Math.floor(hours / 24) });
|
||||
};
|
||||
|
||||
const refreshBtn = (
|
||||
<button
|
||||
type="button"
|
||||
@@ -195,7 +231,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
disabled={status === "loading"}
|
||||
title={t("usageRefresh")}
|
||||
aria-label={t("usageRefresh")}
|
||||
className="ml-auto w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
className="w-6 h-6 flex items-center justify-center rounded text-content-muted hover:text-content-primary hover:bg-hover-2 disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{status === "loading" ? (
|
||||
<Loader2 className="w-3.5 h-3.5 animate-spin" />
|
||||
@@ -205,6 +241,73 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
</button>
|
||||
);
|
||||
|
||||
/** One-line summary of the primary entry (first tier / balance). */
|
||||
const compactSummary = (): { icon: string; text: string; color: string } | null => {
|
||||
const d = data[0];
|
||||
if (!d) return null;
|
||||
const isPlan = !!d.planName && d.planName !== "NewAPI";
|
||||
if (d.isValid === false) {
|
||||
return { icon: "⚡", text: t("usageInvalidKey"), color: "text-red-500 dark:text-red-400" };
|
||||
}
|
||||
if (d.planName === "NewAPI" && d.remaining == null && d.total == null) {
|
||||
return { icon: "💰", text: `${t("usageBalance")} ∞`, color: "text-green-600 dark:text-green-400" };
|
||||
}
|
||||
if (isPlan) {
|
||||
const pct = percentOf(d);
|
||||
const color =
|
||||
pct == null
|
||||
? "text-content-muted"
|
||||
: pct < 70
|
||||
? "text-green-600 dark:text-green-400"
|
||||
: pct < 90
|
||||
? "text-orange-600 dark:text-orange-400"
|
||||
: "text-red-500 dark:text-red-400";
|
||||
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} ` : "";
|
||||
return {
|
||||
icon: "⚡",
|
||||
text: pct != null ? `${label}${Math.round(pct)}%` : `${label}—`,
|
||||
color,
|
||||
};
|
||||
}
|
||||
return {
|
||||
icon: "💰",
|
||||
text: formatAmount(d.remaining, d.unit),
|
||||
color: "text-content-primary",
|
||||
};
|
||||
};
|
||||
|
||||
if (variant === "compact") {
|
||||
if (status === "loading" && data.length === 0) {
|
||||
return <div className="h-3.5 w-20 rounded bg-hover-2 animate-pulse" />;
|
||||
}
|
||||
if (status === "error" && data.length === 0) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-xs text-red-500 dark:text-red-400">
|
||||
<span className="truncate max-w-[120px]" title={error ?? t("usageNetworkError")}>
|
||||
{error == null ? t("usageNetworkError") : localizeUsageError(error, t)}
|
||||
</span>
|
||||
{refreshBtn}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
const s = compactSummary();
|
||||
const ghost = status !== "success";
|
||||
const ago = formatAgo(updatedAt);
|
||||
return (
|
||||
<span className={`flex items-center gap-1.5 text-xs tabular-nums ${ghost ? "opacity-50" : ""}`}>
|
||||
{s && (
|
||||
<>
|
||||
<span aria-hidden="true">{s.icon}</span>
|
||||
<span className={`font-medium ${s.color}`}>{s.text}</span>
|
||||
</>
|
||||
)}
|
||||
{ago && <span className="text-content-muted">{ago}</span>}
|
||||
{refreshBtn}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
// ── detail variant ──
|
||||
// Loading with no prior data: skeleton bar.
|
||||
if (status === "loading" && data.length === 0) {
|
||||
return (
|
||||
@@ -224,7 +327,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
: /401|api[ -]?key|invalid key/i.test(error)
|
||||
? t("usageInvalidKey")
|
||||
: error
|
||||
? `${t("usageQueryFailed")} · ${error}`
|
||||
? `${t("usageQueryFailed")} · ${localizeUsageError(error, t)}`
|
||||
: t("usageQueryFailed");
|
||||
return (
|
||||
<div className="w-full border-t border-border pt-2 mt-1 flex items-center gap-2 text-xs">
|
||||
@@ -246,6 +349,9 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
}
|
||||
|
||||
// success, or error/loading with last-good data (ghost).
|
||||
// Compact header already shows the primary entry; detail only adds value
|
||||
// when there are multiple tiers (e.g. 5h + weekly) or on error ghost.
|
||||
if (data.length <= 1 && status === "success") return null;
|
||||
const ghost = status !== "success";
|
||||
return (
|
||||
<div
|
||||
@@ -270,7 +376,7 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
if (d.isValid === false) {
|
||||
main = t("usageInvalidKey");
|
||||
} else if (isPlan) {
|
||||
const label = data.length > 1 && d.planName ? `${d.planName} · ` : "";
|
||||
const label = data.length > 1 && d.planName ? `${planLabel(d.planName, t)} · ` : "";
|
||||
main =
|
||||
pct != null
|
||||
? `${label}${Math.round(pct)}% ${t("usageUsed")}`
|
||||
@@ -295,14 +401,12 @@ export function UsageFooter({ agent, providerName, usageKinds }: UsageFooterProp
|
||||
{resetText && (
|
||||
<span className="text-content-muted shrink-0">{resetText}</span>
|
||||
)}
|
||||
{i === data.length - 1 && refreshBtn}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{data.length === 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-content-muted">—</span>
|
||||
{refreshBtn}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
Reference in new issue
Block a user