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; } 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(() => defaultConfig(provider)); const [showToken, setShowToken] = useState(false); const [showApiKey, setShowApiKey] = useState(false); const [testing, setTesting] = useState(false); const [testResult, setTestResult] = useState(null); const [testOk, setTestOk] = useState(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("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(
{/* header */}

{t("usageConfigTitle", { name: provider.name })}

{/* body */}
{/* enable toggle */}
{t("usageEnable")}
{cfg.enabled && ( <> {/* template picker */}
{isManaged ? (
{t("usageOAuthManagedHint")}
) : ( <>
{TEMPLATES.map((tpl) => ( ))}

{cfg.templateType === "newapi" ? t("usageNewapiHint") : t("usageAutoDetectHint")}

)} {/* supported variables (read-only, cc-switch 风格) */}
{t("usageSupportedVars")}
{"{{baseUrl}}"} = {cfg.templateType === "newapi" ? cfg.baseUrl || provider.base_url || "-" : provider.base_url || "-"}
{"{{apiKey}}"} = {provider.api_key ? showApiKey ? provider.api_key : "••••••••" : "-"} {provider.api_key && ( )}
{/* auto-detect: show resolved kinds as read-only chips */} {!isManaged && cfg.templateType === "auto" && (
{detectedKinds.length === 0 ? (

{t("usageNoKindsHint")}

) : (
{detectedKinds.map((k) => ( {k} ))}
)}
)} {/* newapi creds */} {!isManaged && cfg.templateType === "newapi" && (
setCfg({ ...cfg, baseUrl: e.target.value || undefined })} />
setCfg({ ...cfg, accessToken: e.target.value || undefined })} />

{t("usageAccessTokenHint")}

setCfg({ ...cfg, userId: e.target.value || undefined })} />

{t("usageUserIdHint")}

)}
{/* timeout + auto query interval */}
{ const n = Math.max(0, parseInt(e.target.value, 10) || 0); setCfg({ ...cfg, timeoutSeconds: n }); }} />

{t("usageTimeoutHint")}

{ const n = Math.max(0, parseInt(e.target.value, 10) || 0); setCfg({ ...cfg, autoQueryIntervalMinutes: n }); }} />

{t("usageAutoIntervalHint")}

)} {/* test result - sticky bottom inside scroll container so it's always visible above the footer */}
{testResult && (
{testResult}
)}
{/* footer */}
, document.body ); }