From 828f839083ca224de0dab22a60cc9896e541f640 Mon Sep 17 00:00:00 2001 From: YoVinchen Date: Mon, 26 Jan 2026 01:40:49 +0800 Subject: [PATCH] feat(ui): add pricing config UI and usage log enhancements - Add pricing config section to provider advanced settings - Refactor PricingConfigPanel to compact table layout - Display all three apps (Claude/Codex/Gemini) in one view - Add multiplier column and request model display to logs - Add frontend API wrappers for pricing config --- .../forms/ProviderAdvancedConfig.tsx | 165 ++++++ .../providers/forms/ProviderForm.tsx | 39 +- src/components/usage/PricingConfigPanel.tsx | 498 +++++++++++++----- src/components/usage/RequestLogTable.tsx | 40 +- src/lib/api/proxy.ts | 22 + src/types.ts | 4 + src/types/usage.ts | 2 + 7 files changed, 626 insertions(+), 144 deletions(-) diff --git a/src/components/providers/forms/ProviderAdvancedConfig.tsx b/src/components/providers/forms/ProviderAdvancedConfig.tsx index 47f727bf6..6d7585a6d 100644 --- a/src/components/providers/forms/ProviderAdvancedConfig.tsx +++ b/src/components/providers/forms/ProviderAdvancedConfig.tsx @@ -5,6 +5,7 @@ import { ChevronRight, FlaskConical, Globe, + Coins, Eye, EyeOff, X, @@ -13,14 +14,31 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Switch } from "@/components/ui/switch"; import { Button } from "@/components/ui/button"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { cn } from "@/lib/utils"; import type { ProviderTestConfig, ProviderProxyConfig } from "@/types"; +export type PricingModelSourceOption = "inherit" | "request" | "response"; + +interface ProviderPricingConfig { + enabled: boolean; + costMultiplier?: string; + pricingModelSource: PricingModelSourceOption; +} + interface ProviderAdvancedConfigProps { testConfig: ProviderTestConfig; proxyConfig: ProviderProxyConfig; + pricingConfig: ProviderPricingConfig; onTestConfigChange: (config: ProviderTestConfig) => void; onProxyConfigChange: (config: ProviderProxyConfig) => void; + onPricingConfigChange: (config: ProviderPricingConfig) => void; } /** 从 ProviderProxyConfig 构建完整 URL */ @@ -71,14 +89,19 @@ function parseProxyUrl(url: string): Partial { export function ProviderAdvancedConfig({ testConfig, proxyConfig, + pricingConfig, onTestConfigChange, onProxyConfigChange, + onPricingConfigChange, }: ProviderAdvancedConfigProps) { const { t } = useTranslation(); const [isTestConfigOpen, setIsTestConfigOpen] = useState(testConfig.enabled); const [isProxyConfigOpen, setIsProxyConfigOpen] = useState( proxyConfig.enabled, ); + const [isPricingConfigOpen, setIsPricingConfigOpen] = useState( + pricingConfig.enabled, + ); const [showPassword, setShowPassword] = useState(false); // 代理 URL 输入状态(仅在初始化时从 proxyConfig 构建) @@ -97,6 +120,11 @@ export function ProviderAdvancedConfig({ setIsProxyConfigOpen(proxyConfig.enabled); }, [proxyConfig.enabled]); + // 同步外部 pricingConfig.enabled 变化到展开状态 + useEffect(() => { + setIsPricingConfigOpen(pricingConfig.enabled); + }, [pricingConfig.enabled]); + // 仅在外部 proxyConfig 变化且非用户输入时同步(如:重置表单、加载数据) useEffect(() => { if (!isUserTyping) { @@ -450,6 +478,143 @@ export function ProviderAdvancedConfig({ + + {/* 计费配置 */} +
+ +
+
+

+ {t("providerAdvanced.pricingConfigDesc", { + defaultValue: + "为此供应商配置单独的计费参数,不启用时使用全局默认配置。", + })} +

+
+
+ + + onPricingConfigChange({ + ...pricingConfig, + costMultiplier: e.target.value || undefined, + }) + } + placeholder={t("providerAdvanced.costMultiplierPlaceholder", { + defaultValue: "留空使用全局默认(1)", + })} + disabled={!pricingConfig.enabled} + /> +

+ {t("providerAdvanced.costMultiplierHint", { + defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5", + })} +

+
+
+ + +

+ {t("providerAdvanced.pricingModelSourceHint", { + defaultValue: "选择按请求模型还是返回模型进行定价匹配", + })} +

+
+
+
+
+
); } diff --git a/src/components/providers/forms/ProviderForm.tsx b/src/components/providers/forms/ProviderForm.tsx index d2cc70efa..f81b8e758 100644 --- a/src/components/providers/forms/ProviderForm.tsx +++ b/src/components/providers/forms/ProviderForm.tsx @@ -46,7 +46,10 @@ import { BasicFormFields } from "./BasicFormFields"; import { ClaudeFormFields } from "./ClaudeFormFields"; import { CodexFormFields } from "./CodexFormFields"; import { GeminiFormFields } from "./GeminiFormFields"; -import { ProviderAdvancedConfig } from "./ProviderAdvancedConfig"; +import { + ProviderAdvancedConfig, + type PricingModelSourceOption, +} from "./ProviderAdvancedConfig"; import { useProviderCategory, useApiKeyState, @@ -121,6 +124,11 @@ interface ProviderFormProps { showButtons?: boolean; } +const normalizePricingSource = ( + value?: string, +): PricingModelSourceOption => + value === "request" || value === "response" ? value : "inherit"; + export function ProviderForm({ appId, providerId, @@ -168,6 +176,19 @@ export function ProviderForm({ const [proxyConfig, setProxyConfig] = useState( () => initialData?.meta?.proxyConfig ?? { enabled: false }, ); + const [pricingConfig, setPricingConfig] = useState<{ + enabled: boolean; + costMultiplier?: string; + pricingModelSource: PricingModelSourceOption; + }>(() => ({ + enabled: + initialData?.meta?.costMultiplier !== undefined || + initialData?.meta?.pricingModelSource !== undefined, + costMultiplier: initialData?.meta?.costMultiplier, + pricingModelSource: normalizePricingSource( + initialData?.meta?.pricingModelSource, + ), + })); // 使用 category hook const { category } = useProviderCategory({ @@ -188,6 +209,15 @@ export function ProviderForm({ setEndpointAutoSelect(initialData?.meta?.endpointAutoSelect ?? true); setTestConfig(initialData?.meta?.testConfig ?? { enabled: false }); setProxyConfig(initialData?.meta?.proxyConfig ?? { enabled: false }); + setPricingConfig({ + enabled: + initialData?.meta?.costMultiplier !== undefined || + initialData?.meta?.pricingModelSource !== undefined, + costMultiplier: initialData?.meta?.costMultiplier, + pricingModelSource: normalizePricingSource( + initialData?.meta?.pricingModelSource, + ), + }); }, [appId, initialData]); const defaultValues: ProviderFormData = useMemo( @@ -940,6 +970,11 @@ export function ProviderForm({ // 添加高级配置 testConfig: testConfig.enabled ? testConfig : undefined, proxyConfig: proxyConfig.enabled ? proxyConfig : undefined, + costMultiplier: pricingConfig.enabled ? pricingConfig.costMultiplier : undefined, + pricingModelSource: + pricingConfig.enabled && pricingConfig.pricingModelSource !== "inherit" + ? pricingConfig.pricingModelSource + : undefined, }; onSubmit(payload); @@ -1464,8 +1499,10 @@ export function ProviderForm({ {showButtons && ( diff --git a/src/components/usage/PricingConfigPanel.tsx b/src/components/usage/PricingConfigPanel.tsx index 2f0372102..e6fbc4c23 100644 --- a/src/components/usage/PricingConfigPanel.tsx +++ b/src/components/usage/PricingConfigPanel.tsx @@ -1,6 +1,5 @@ -import { useState } from "react"; +import { useState, useEffect } from "react"; import { useTranslation } from "react-i18next"; -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import { Table, TableBody, @@ -19,10 +18,31 @@ import { DialogHeader, DialogTitle, } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; import { useModelPricing, useDeleteModelPricing } from "@/lib/query/usage"; import { PricingEditModal } from "./PricingEditModal"; import type { ModelPricing } from "@/types/usage"; -import { Plus, Pencil, Trash2, ChevronDown, ChevronRight } from "lucide-react"; +import { Plus, Pencil, Trash2, Loader2 } from "lucide-react"; +import { toast } from "sonner"; +import { proxyApi } from "@/lib/api/proxy"; + +const PRICING_APPS = ["claude", "codex", "gemini"] as const; +type PricingApp = (typeof PRICING_APPS)[number]; +type PricingModelSource = "request" | "response"; + +interface AppConfig { + multiplier: string; + source: PricingModelSource; +} + +type AppConfigState = Record; export function PricingConfigPanel() { const { t } = useTranslation(); @@ -31,13 +51,132 @@ export function PricingConfigPanel() { const [editingModel, setEditingModel] = useState(null); const [isAddingNew, setIsAddingNew] = useState(false); const [deleteConfirm, setDeleteConfirm] = useState(null); - const [isExpanded, setIsExpanded] = useState(false); + + // 三个应用的配置状态 + const [appConfigs, setAppConfigs] = useState({ + claude: { multiplier: "1", source: "response" }, + codex: { multiplier: "1", source: "response" }, + gemini: { multiplier: "1", source: "response" }, + }); + const [originalConfigs, setOriginalConfigs] = useState( + null, + ); + const [isConfigLoading, setIsConfigLoading] = useState(true); + const [isSaving, setIsSaving] = useState(false); + + // 检查是否有改动 + const isDirty = + originalConfigs !== null && + PRICING_APPS.some( + (app) => + appConfigs[app].multiplier !== originalConfigs[app].multiplier || + appConfigs[app].source !== originalConfigs[app].source, + ); + + // 加载所有应用的配置 + useEffect(() => { + let isMounted = true; + + const loadAllConfigs = async () => { + setIsConfigLoading(true); + try { + const results = await Promise.all( + PRICING_APPS.map(async (app) => { + const [multiplier, source] = await Promise.all([ + proxyApi.getDefaultCostMultiplier(app), + proxyApi.getPricingModelSource(app), + ]); + return { + app, + multiplier, + source: (source === "request" ? "request" : "response") as PricingModelSource, + }; + }), + ); + + if (!isMounted) return; + + const newState: AppConfigState = { + claude: { multiplier: "1", source: "response" }, + codex: { multiplier: "1", source: "response" }, + gemini: { multiplier: "1", source: "response" }, + }; + for (const result of results) { + newState[result.app] = { + multiplier: result.multiplier, + source: result.source, + }; + } + setAppConfigs(newState); + setOriginalConfigs(newState); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; + toast.error( + t("settings.globalProxy.pricingLoadFailed", { error: message }), + ); + } finally { + if (isMounted) setIsConfigLoading(false); + } + }; + + loadAllConfigs(); + return () => { + isMounted = false; + }; + }, [t]); + + // 保存所有配置 + const handleSaveAll = async () => { + // 验证所有倍率 + for (const app of PRICING_APPS) { + const trimmed = appConfigs[app].multiplier.trim(); + if (!trimmed) { + toast.error( + `${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierRequired")}`, + ); + return; + } + if (!/^-?\d+(?:\.\d+)?$/.test(trimmed)) { + toast.error( + `${t(`apps.${app}`)}: ${t("settings.globalProxy.defaultCostMultiplierInvalid")}`, + ); + return; + } + } + + setIsSaving(true); + try { + await Promise.all( + PRICING_APPS.flatMap((app) => [ + proxyApi.setDefaultCostMultiplier(app, appConfigs[app].multiplier.trim()), + proxyApi.setPricingModelSource(app, appConfigs[app].source), + ]), + ); + toast.success(t("settings.globalProxy.pricingSaved")); + setOriginalConfigs({ ...appConfigs }); + } catch (error) { + const message = + error instanceof Error + ? error.message + : typeof error === "string" + ? error + : "Unknown error"; + toast.error( + t("settings.globalProxy.pricingSaveFailed", { error: message }), + ); + } finally { + setIsSaving(false); + } + }; const handleDelete = (modelId: string) => { deleteMutation.mutate(modelId, { - onSuccess: () => { - setDeleteConfirm(null); - }, + onSuccess: () => setDeleteConfirm(null), }); }; @@ -55,151 +194,238 @@ export function PricingConfigPanel() { if (isLoading) { return ( - - setIsExpanded(!isExpanded)} - > -
- - - {t("usage.modelPricing")} - -
-
-
+
+ +
); } if (error) { return ( - - setIsExpanded(!isExpanded)} - > -
- {isExpanded ? ( - - ) : ( - - )} - - {t("usage.modelPricing")} - -
-
- {isExpanded && ( - - - - {t("usage.loadPricingError")}: {String(error)} - - - - )} -
+ + + {t("usage.loadPricingError")}: {String(error)} + + ); } return ( -
-
-

- {t("usage.modelPricingDesc")} {t("usage.perMillion")} -

- -
+
+ {/* 全局计费默认配置 - 紧凑表格布局 */} +
+
+
+

+ {t("settings.globalProxy.pricingDefaultsTitle")} +

+

+ {t("settings.globalProxy.pricingDefaultsDescription")} +

+
+ +
-
- {!pricing || pricing.length === 0 ? ( - - {t("usage.noPricingData")} - + {isConfigLoading ? ( +
+ +
) : ( -
- - - - {t("usage.model")} - {t("usage.displayName")} - - {t("usage.inputCost")} - - - {t("usage.outputCost")} - - - {t("usage.cacheReadCost")} - - - {t("usage.cacheWriteCost")} - - - {t("common.actions")} - - - - - {pricing.map((model) => ( - - - {model.modelId} - - {model.displayName} - - ${model.inputCostPerMillion} - - - ${model.outputCostPerMillion} - - - ${model.cacheReadCostPerMillion} - - - ${model.cacheCreationCostPerMillion} - - -
- - -
-
-
+
+
+ + + + + + + + + {PRICING_APPS.map((app, idx) => ( + + + + + ))} - -
+ {t("settings.globalProxy.pricingAppLabel")} + + {t("settings.globalProxy.defaultCostMultiplierLabel")} + + {t("settings.globalProxy.pricingModelSourceLabel")} +
+ {t(`apps.${app}`)} + + + setAppConfigs((prev) => ({ + ...prev, + [app]: { ...prev[app], multiplier: e.target.value }, + })) + } + disabled={isSaving} + placeholder="1" + className="h-7 w-24" + /> + + +
+ +
)}
+ {/* 分隔线 */} +
+ + {/* 模型定价配置 */} +
+
+

+ {t("usage.modelPricingDesc")} {t("usage.perMillion")} +

+ +
+ +
+ {!pricing || pricing.length === 0 ? ( + + {t("usage.noPricingData")} + + ) : ( +
+ + + + {t("usage.model")} + {t("usage.displayName")} + + {t("usage.inputCost")} + + + {t("usage.outputCost")} + + + {t("usage.cacheReadCost")} + + + {t("usage.cacheWriteCost")} + + + {t("common.actions")} + + + + + {pricing.map((model) => ( + + + {model.modelId} + + {model.displayName} + + ${model.inputCostPerMillion} + + + ${model.outputCostPerMillion} + + + ${model.cacheReadCostPerMillion} + + + ${model.cacheCreationCostPerMillion} + + +
+ + +
+
+
+ ))} +
+
+
+ )} +
+
+ {editingModel && ( {t("usage.provider")} - + {t("usage.billingModel")} @@ -265,6 +265,9 @@ export function RequestLogTable() { {t("usage.cacheCreationTokens")} + + {t("usage.multiplier")} + {t("usage.totalCost")} @@ -280,7 +283,7 @@ export function RequestLogTable() { {logs.length === 0 ? ( {t("usage.noData")} @@ -297,11 +300,25 @@ export function RequestLogTable() { {log.providerName || t("usage.unknownProvider")} - - {log.model} + +
+ {log.model} +
+ {log.requestModel && log.requestModel !== log.model && ( +
+ ← {log.requestModel} +
+ )}
{log.inputTokens.toLocaleString()} @@ -315,6 +332,15 @@ export function RequestLogTable() { {log.cacheCreationTokens.toLocaleString()} + + {parseFloat(log.costMultiplier) !== 1 ? ( + + ×{log.costMultiplier} + + ) : ( + ×1 + )} + ${parseFloat(log.totalCostUsd).toFixed(6)} diff --git a/src/lib/api/proxy.ts b/src/lib/api/proxy.ts index 6009fe745..7dd5b98d7 100644 --- a/src/lib/api/proxy.ts +++ b/src/lib/api/proxy.ts @@ -92,4 +92,26 @@ export const proxyApi = { async updateProxyConfigForApp(config: AppProxyConfig): Promise { return invoke("update_proxy_config_for_app", { config }); }, + + // ========== 计费默认配置 API ========== + + // 获取默认成本倍率 + async getDefaultCostMultiplier(appType: string): Promise { + return invoke("get_default_cost_multiplier", { appType }); + }, + + // 设置默认成本倍率 + async setDefaultCostMultiplier(appType: string, value: string): Promise { + return invoke("set_default_cost_multiplier", { appType, value }); + }, + + // 获取计费模式来源 + async getPricingModelSource(appType: string): Promise { + return invoke("get_pricing_model_source", { appType }); + }, + + // 设置计费模式来源 + async setPricingModelSource(appType: string, value: string): Promise { + return invoke("set_pricing_model_source", { appType, value }); + }, }; diff --git a/src/types.ts b/src/types.ts index c4fba8fb7..c911f98bd 100644 --- a/src/types.ts +++ b/src/types.ts @@ -135,6 +135,10 @@ export interface ProviderMeta { testConfig?: ProviderTestConfig; // 供应商单独的代理配置 proxyConfig?: ProviderProxyConfig; + // 供应商成本倍率 + costMultiplier?: string; + // 供应商计费模式来源 + pricingModelSource?: string; } // Skill 同步方式 diff --git a/src/types/usage.ts b/src/types/usage.ts index 687fdfb2b..61dc8774f 100644 --- a/src/types/usage.ts +++ b/src/types/usage.ts @@ -13,6 +13,8 @@ export interface RequestLog { providerName?: string; appType: string; model: string; + requestModel?: string; + costMultiplier: string; inputTokens: number; outputTokens: number; cacheReadTokens: number;