mirror of
https://github.com/farion1231/cc-switch.git
synced 2026-05-19 19:50:26 +08:00
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
This commit is contained in:
@@ -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<ProviderProxyConfig> {
|
||||
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({
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 计费配置 */}
|
||||
<div className="rounded-lg border border-border/50 bg-muted/20">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-between p-4 hover:bg-muted/30 transition-colors"
|
||||
onClick={() => setIsPricingConfigOpen(!isPricingConfigOpen)}
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<Coins className="h-4 w-4 text-muted-foreground" />
|
||||
<span className="font-medium">
|
||||
{t("providerAdvanced.pricingConfig", {
|
||||
defaultValue: "计费配置",
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className="flex items-center gap-2"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<Label
|
||||
htmlFor="pricing-config-enabled"
|
||||
className="text-sm text-muted-foreground"
|
||||
>
|
||||
{t("providerAdvanced.useCustomPricing", {
|
||||
defaultValue: "使用单独配置",
|
||||
})}
|
||||
</Label>
|
||||
<Switch
|
||||
id="pricing-config-enabled"
|
||||
checked={pricingConfig.enabled}
|
||||
onCheckedChange={(checked) => {
|
||||
onPricingConfigChange({ ...pricingConfig, enabled: checked });
|
||||
if (checked) setIsPricingConfigOpen(true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{isPricingConfigOpen ? (
|
||||
<ChevronDown className="h-4 w-4 text-muted-foreground" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4 text-muted-foreground" />
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-hidden transition-all duration-200",
|
||||
isPricingConfigOpen
|
||||
? "max-h-[500px] opacity-100"
|
||||
: "max-h-0 opacity-0",
|
||||
)}
|
||||
>
|
||||
<div className="border-t border-border/50 p-4 space-y-4">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t("providerAdvanced.pricingConfigDesc", {
|
||||
defaultValue:
|
||||
"为此供应商配置单独的计费参数,不启用时使用全局默认配置。",
|
||||
})}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="cost-multiplier">
|
||||
{t("providerAdvanced.costMultiplier", {
|
||||
defaultValue: "成本倍率",
|
||||
})}
|
||||
</Label>
|
||||
<Input
|
||||
id="cost-multiplier"
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={pricingConfig.costMultiplier || ""}
|
||||
onChange={(e) =>
|
||||
onPricingConfigChange({
|
||||
...pricingConfig,
|
||||
costMultiplier: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder={t("providerAdvanced.costMultiplierPlaceholder", {
|
||||
defaultValue: "留空使用全局默认(1)",
|
||||
})}
|
||||
disabled={!pricingConfig.enabled}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerAdvanced.costMultiplierHint", {
|
||||
defaultValue: "实际成本 = 基础成本 × 倍率,支持小数如 1.5",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="pricing-model-source">
|
||||
{t("providerAdvanced.pricingModelSourceLabel", {
|
||||
defaultValue: "计费模式",
|
||||
})}
|
||||
</Label>
|
||||
<Select
|
||||
value={pricingConfig.pricingModelSource}
|
||||
onValueChange={(value) =>
|
||||
onPricingConfigChange({
|
||||
...pricingConfig,
|
||||
pricingModelSource: value as PricingModelSourceOption,
|
||||
})
|
||||
}
|
||||
disabled={!pricingConfig.enabled}
|
||||
>
|
||||
<SelectTrigger id="pricing-model-source">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="inherit">
|
||||
{t("providerAdvanced.pricingModelSourceInherit", {
|
||||
defaultValue: "继承全局默认",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="request">
|
||||
{t("providerAdvanced.pricingModelSourceRequest", {
|
||||
defaultValue: "请求模型",
|
||||
})}
|
||||
</SelectItem>
|
||||
<SelectItem value="response">
|
||||
{t("providerAdvanced.pricingModelSourceResponse", {
|
||||
defaultValue: "返回模型",
|
||||
})}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("providerAdvanced.pricingModelSourceHint", {
|
||||
defaultValue: "选择按请求模型还是返回模型进行定价匹配",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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<ProviderProxyConfig>(
|
||||
() => 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({
|
||||
<ProviderAdvancedConfig
|
||||
testConfig={testConfig}
|
||||
proxyConfig={proxyConfig}
|
||||
pricingConfig={pricingConfig}
|
||||
onTestConfigChange={setTestConfig}
|
||||
onProxyConfigChange={setProxyConfig}
|
||||
onPricingConfigChange={setPricingConfig}
|
||||
/>
|
||||
|
||||
{showButtons && (
|
||||
|
||||
@@ -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<PricingApp, AppConfig>;
|
||||
|
||||
export function PricingConfigPanel() {
|
||||
const { t } = useTranslation();
|
||||
@@ -31,13 +51,132 @@ export function PricingConfigPanel() {
|
||||
const [editingModel, setEditingModel] = useState<ModelPricing | null>(null);
|
||||
const [isAddingNew, setIsAddingNew] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<string | null>(null);
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// 三个应用的配置状态
|
||||
const [appConfigs, setAppConfigs] = useState<AppConfigState>({
|
||||
claude: { multiplier: "1", source: "response" },
|
||||
codex: { multiplier: "1", source: "response" },
|
||||
gemini: { multiplier: "1", source: "response" },
|
||||
});
|
||||
const [originalConfigs, setOriginalConfigs] = useState<AppConfigState | null>(
|
||||
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 (
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
<div className="flex items-center justify-center p-4">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<Card className="border rounded-lg">
|
||||
<CardHeader
|
||||
className="cursor-pointer"
|
||||
onClick={() => setIsExpanded(!isExpanded)}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
{isExpanded ? (
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
) : (
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
)}
|
||||
<CardTitle className="text-base">
|
||||
{t("usage.modelPricing")}
|
||||
</CardTitle>
|
||||
</div>
|
||||
</CardHeader>
|
||||
{isExpanded && (
|
||||
<CardContent>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</CardContent>
|
||||
)}
|
||||
</Card>
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>
|
||||
{t("usage.loadPricingError")}: {String(error)}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="space-y-6">
|
||||
{/* 全局计费默认配置 - 紧凑表格布局 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-medium">
|
||||
{t("settings.globalProxy.pricingDefaultsTitle")}
|
||||
</h4>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingDefaultsDescription")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSaveAll}
|
||||
disabled={isConfigLoading || isSaving || !isDirty}
|
||||
size="sm"
|
||||
>
|
||||
{isSaving ? (
|
||||
<>
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
{t("common.saving")}
|
||||
</>
|
||||
) : (
|
||||
t("common.save")
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
{isConfigLoading ? (
|
||||
<div className="flex items-center justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
<div className="rounded-md border border-border/50 overflow-hidden">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-border/50 bg-muted/30">
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground w-24">
|
||||
{t("settings.globalProxy.pricingAppLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.defaultCostMultiplierLabel")}
|
||||
</th>
|
||||
<th className="px-3 py-2 text-left font-medium text-muted-foreground">
|
||||
{t("settings.globalProxy.pricingModelSourceLabel")}
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{PRICING_APPS.map((app, idx) => (
|
||||
<tr
|
||||
key={app}
|
||||
className={
|
||||
idx < PRICING_APPS.length - 1
|
||||
? "border-b border-border/30"
|
||||
: ""
|
||||
}
|
||||
>
|
||||
<td className="px-3 py-1.5 font-medium">
|
||||
{t(`apps.${app}`)}
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
inputMode="decimal"
|
||||
value={appConfigs[app].multiplier}
|
||||
onChange={(e) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: { ...prev[app], multiplier: e.target.value },
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
placeholder="1"
|
||||
className="h-7 w-24"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-1.5">
|
||||
<Select
|
||||
value={appConfigs[app].source}
|
||||
onValueChange={(value) =>
|
||||
setAppConfigs((prev) => ({
|
||||
...prev,
|
||||
[app]: {
|
||||
...prev[app],
|
||||
source: value as PricingModelSource,
|
||||
},
|
||||
}))
|
||||
}
|
||||
disabled={isSaving}
|
||||
>
|
||||
<SelectTrigger className="h-7 w-28">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="response">
|
||||
{t("settings.globalProxy.pricingModelSourceResponse")}
|
||||
</SelectItem>
|
||||
<SelectItem value="request">
|
||||
{t("settings.globalProxy.pricingModelSourceRequest")}
|
||||
</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 分隔线 */}
|
||||
<div className="border-t border-border/50" />
|
||||
|
||||
{/* 模型定价配置 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="text-sm font-medium text-muted-foreground">
|
||||
{t("usage.modelPricingDesc")} {t("usage.perMillion")}
|
||||
</h4>
|
||||
<Button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleAddNew();
|
||||
}}
|
||||
size="sm"
|
||||
>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
{t("common.add")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
{!pricing || pricing.length === 0 ? (
|
||||
<Alert>
|
||||
<AlertDescription>{t("usage.noPricingData")}</AlertDescription>
|
||||
</Alert>
|
||||
) : (
|
||||
<div className="rounded-md bg-card/60 shadow-sm">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("usage.model")}</TableHead>
|
||||
<TableHead>{t("usage.displayName")}</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.inputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.outputCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheReadCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("usage.cacheWriteCost")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right">
|
||||
{t("common.actions")}
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{pricing.map((model) => (
|
||||
<TableRow key={model.modelId}>
|
||||
<TableCell className="font-mono text-sm">
|
||||
{model.modelId}
|
||||
</TableCell>
|
||||
<TableCell>{model.displayName}</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.inputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.outputCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheReadCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-sm">
|
||||
${model.cacheCreationCostPerMillion}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
<div className="flex justify-end gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => {
|
||||
setIsAddingNew(false);
|
||||
setEditingModel(model);
|
||||
}}
|
||||
title={t("common.edit")}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setDeleteConfirm(model.modelId)}
|
||||
title={t("common.delete")}
|
||||
className="text-destructive hover:text-destructive"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{editingModel && (
|
||||
<PricingEditModal
|
||||
open={!!editingModel}
|
||||
|
||||
@@ -250,7 +250,7 @@ export function RequestLogTable() {
|
||||
<TableHead className="whitespace-nowrap">
|
||||
{t("usage.provider")}
|
||||
</TableHead>
|
||||
<TableHead className="min-w-[280px] whitespace-nowrap">
|
||||
<TableHead className="min-w-[200px] whitespace-nowrap">
|
||||
{t("usage.billingModel")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
@@ -265,6 +265,9 @@ export function RequestLogTable() {
|
||||
<TableHead className="text-right min-w-[90px] whitespace-nowrap">
|
||||
{t("usage.cacheCreationTokens")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.multiplier")}
|
||||
</TableHead>
|
||||
<TableHead className="text-right whitespace-nowrap">
|
||||
{t("usage.totalCost")}
|
||||
</TableHead>
|
||||
@@ -280,7 +283,7 @@ export function RequestLogTable() {
|
||||
{logs.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={10}
|
||||
colSpan={11}
|
||||
className="text-center text-muted-foreground"
|
||||
>
|
||||
{t("usage.noData")}
|
||||
@@ -297,11 +300,25 @@ export function RequestLogTable() {
|
||||
<TableCell>
|
||||
{log.providerName || t("usage.unknownProvider")}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="font-mono text-sm max-w-[280px] truncate"
|
||||
title={log.model}
|
||||
>
|
||||
{log.model}
|
||||
<TableCell className="font-mono text-xs max-w-[200px]">
|
||||
<div
|
||||
className="truncate"
|
||||
title={
|
||||
log.requestModel && log.requestModel !== log.model
|
||||
? `${t("usage.requestModel")}: ${log.requestModel}\n${t("usage.responseModel")}: ${log.model}`
|
||||
: log.model
|
||||
}
|
||||
>
|
||||
{log.model}
|
||||
</div>
|
||||
{log.requestModel && log.requestModel !== log.model && (
|
||||
<div
|
||||
className="truncate text-muted-foreground text-[10px]"
|
||||
title={log.requestModel}
|
||||
>
|
||||
← {log.requestModel}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{log.inputTokens.toLocaleString()}
|
||||
@@ -315,6 +332,15 @@ export function RequestLogTable() {
|
||||
<TableCell className="text-right">
|
||||
{log.cacheCreationTokens.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-mono text-xs">
|
||||
{parseFloat(log.costMultiplier) !== 1 ? (
|
||||
<span className="text-orange-600">
|
||||
×{log.costMultiplier}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-muted-foreground">×1</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
${parseFloat(log.totalCostUsd).toFixed(6)}
|
||||
</TableCell>
|
||||
|
||||
@@ -92,4 +92,26 @@ export const proxyApi = {
|
||||
async updateProxyConfigForApp(config: AppProxyConfig): Promise<void> {
|
||||
return invoke("update_proxy_config_for_app", { config });
|
||||
},
|
||||
|
||||
// ========== 计费默认配置 API ==========
|
||||
|
||||
// 获取默认成本倍率
|
||||
async getDefaultCostMultiplier(appType: string): Promise<string> {
|
||||
return invoke("get_default_cost_multiplier", { appType });
|
||||
},
|
||||
|
||||
// 设置默认成本倍率
|
||||
async setDefaultCostMultiplier(appType: string, value: string): Promise<void> {
|
||||
return invoke("set_default_cost_multiplier", { appType, value });
|
||||
},
|
||||
|
||||
// 获取计费模式来源
|
||||
async getPricingModelSource(appType: string): Promise<string> {
|
||||
return invoke("get_pricing_model_source", { appType });
|
||||
},
|
||||
|
||||
// 设置计费模式来源
|
||||
async setPricingModelSource(appType: string, value: string): Promise<void> {
|
||||
return invoke("set_pricing_model_source", { appType, value });
|
||||
},
|
||||
};
|
||||
|
||||
@@ -135,6 +135,10 @@ export interface ProviderMeta {
|
||||
testConfig?: ProviderTestConfig;
|
||||
// 供应商单独的代理配置
|
||||
proxyConfig?: ProviderProxyConfig;
|
||||
// 供应商成本倍率
|
||||
costMultiplier?: string;
|
||||
// 供应商计费模式来源
|
||||
pricingModelSource?: string;
|
||||
}
|
||||
|
||||
// Skill 同步方式
|
||||
|
||||
@@ -13,6 +13,8 @@ export interface RequestLog {
|
||||
providerName?: string;
|
||||
appType: string;
|
||||
model: string;
|
||||
requestModel?: string;
|
||||
costMultiplier: string;
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadTokens: number;
|
||||
|
||||
Reference in New Issue
Block a user