diff --git a/frontend/src/components/ui/autocomplete.tsx b/frontend/src/components/ui/autocomplete.tsx index 1440754..19254e7 100644 --- a/frontend/src/components/ui/autocomplete.tsx +++ b/frontend/src/components/ui/autocomplete.tsx @@ -1,40 +1,46 @@ -import * as React from 'react' -import { Input } from '@/components/ui/input' -import { cn } from '@/lib/utils' +import * as React from "react"; +import { Input } from "@/components/ui/input"; +import { cn } from "@/lib/utils"; +import { Check } from "lucide-react"; export type AutoCompleteOption = { - value: string - label?: string - description?: string - keywords?: string[] -} + value: string; + label?: string; + description?: string; + keywords?: string[]; +}; -type AutoCompleteProps = Omit, 'value' | 'onChange'> & { - value: string - onChange: (value: string) => void - options: AutoCompleteOption[] - maxOptions?: number - emptyText?: string - dropdownClassName?: string - inputClassName?: string -} +type AutoCompleteProps = Omit, "value" | "onChange"> & { + value: string; + onChange: (value: string) => void; + options: AutoCompleteOption[]; + maxOptions?: number; + emptyText?: string; + dropdownClassName?: string; + inputClassName?: string; +}; -const normalizeText = (value: string): string => value.trim().toLowerCase() +type MultiSelectAutoCompleteProps = Omit, "value" | "onChange"> & { + values: string[]; + onChange: (values: string[]) => void; + options: AutoCompleteOption[]; + maxOptions?: number; + emptyText?: string; + dropdownClassName?: string; + inputClassName?: string; +}; + +const normalizeText = (value: string): string => value.trim().toLowerCase(); const matchesOption = (option: AutoCompleteOption, query: string): boolean => { - if (!query) return true + if (!query) return true; - const haystack = [ - option.value, - option.label || '', - option.description || '', - ...(option.keywords || []), - ] - .join(' ') - .toLowerCase() + const haystack = [option.value, option.label || "", option.description || "", ...(option.keywords || [])] + .join(" ") + .toLowerCase(); - return haystack.includes(query) -} + return haystack.includes(query); +}; export const AutoCompleteInput = React.forwardRef( ( @@ -43,7 +49,7 @@ export const AutoCompleteInput = React.forwardRef { - const containerRef = React.useRef(null) - const [isOpen, setIsOpen] = React.useState(false) - const [searchQuery, setSearchQuery] = React.useState('') + const containerRef = React.useRef(null); + const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); React.useEffect(() => { const handlePointerDown = (event: MouseEvent) => { if (!containerRef.current?.contains(event.target as Node)) { - setIsOpen(false) + setIsOpen(false); } - } + }; - document.addEventListener('mousedown', handlePointerDown) - return () => document.removeEventListener('mousedown', handlePointerDown) - }, []) + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, []); React.useEffect(() => { if (!isOpen) { - setSearchQuery('') + setSearchQuery(""); } - }, [isOpen]) + }, [isOpen]); - const query = normalizeText(searchQuery) + const query = normalizeText(searchQuery); const dedupedOptions = options.filter((option, index) => { - return options.findIndex((candidate) => candidate.value === option.value) === index - }) - const matchedOptions = dedupedOptions.filter((option) => matchesOption(option, query)) - const filteredOptions = typeof maxOptions === 'number' ? matchedOptions.slice(0, maxOptions) : matchedOptions + return options.findIndex((candidate) => candidate.value === option.value) === index; + }); + const matchedOptions = dedupedOptions.filter((option) => matchesOption(option, query)); + const filteredOptions = typeof maxOptions === "number" ? matchedOptions.slice(0, maxOptions) : matchedOptions; const handleSelect = (selectedValue: string) => { - onChange(selectedValue) - setIsOpen(false) - } + onChange(selectedValue); + setIsOpen(false); + }; return ( -
+
{ - setSearchQuery('') - setIsOpen(true) - onClick?.(event) + setSearchQuery(""); + setIsOpen(true); + onClick?.(event); }} onFocus={(event) => { - setSearchQuery('') - setIsOpen(true) - onFocus?.(event) + setSearchQuery(""); + setIsOpen(true); + onFocus?.(event); }} onBlur={(event) => { - onBlur?.(event) + onBlur?.(event); }} onChange={(event) => { - onChange(event.target.value) - setSearchQuery(event.target.value) - setIsOpen(true) + onChange(event.target.value); + setSearchQuery(event.target.value); + setIsOpen(true); }} className={inputClassName} /> @@ -119,15 +125,15 @@ export const AutoCompleteInput = React.forwardRef {filteredOptions.length > 0 ? (
{filteredOptions.map((option) => { - const label = option.label || option.value - const showDescription = option.description && option.description !== label + const label = option.label || option.value; + const showDescription = option.description && option.description !== label; return ( - ) + ); })}
) : ( @@ -151,8 +155,195 @@ export const AutoCompleteInput = React.forwardRef )}
- ) - } -) + ); + }, +); -AutoCompleteInput.displayName = 'AutoCompleteInput' +AutoCompleteInput.displayName = "AutoCompleteInput"; + +export const MultiSelectAutoCompleteInput = React.forwardRef( + ( + { + values, + onChange, + options, + maxOptions, + emptyText = "无匹配结果", + className, + dropdownClassName, + inputClassName, + onClick, + onFocus, + onBlur, + onKeyDown, + disabled, + placeholder, + ...props + }, + ref, + ) => { + const containerRef = React.useRef(null); + const inputRef = React.useRef(null); + const [isOpen, setIsOpen] = React.useState(false); + const [searchQuery, setSearchQuery] = React.useState(""); + + React.useImperativeHandle(ref, () => inputRef.current as HTMLInputElement); + + React.useEffect(() => { + const handlePointerDown = (event: MouseEvent) => { + if (!containerRef.current?.contains(event.target as Node)) { + setIsOpen(false); + } + }; + + document.addEventListener("mousedown", handlePointerDown); + return () => document.removeEventListener("mousedown", handlePointerDown); + }, []); + + React.useEffect(() => { + if (!isOpen) { + setSearchQuery(""); + } + }, [isOpen]); + + const selectedSet = React.useMemo(() => new Set(values), [values]); + const dedupedOptions = React.useMemo( + () => + options.filter((option, index) => options.findIndex((candidate) => candidate.value === option.value) === index), + [options], + ); + const optionMap = React.useMemo( + () => new Map(dedupedOptions.map((option) => [option.value, option])), + [dedupedOptions], + ); + const selectedOptions = React.useMemo( + () => values.map((value) => optionMap.get(value) || { value }), + [optionMap, values], + ); + const selectedSummary = React.useMemo( + () => selectedOptions.map((option) => option.label || option.value).join(", "), + [selectedOptions], + ); + const query = normalizeText(searchQuery); + const matchedOptions = React.useMemo( + () => dedupedOptions.filter((option) => matchesOption(option, query)), + [dedupedOptions, query], + ); + const filteredOptions = typeof maxOptions === "number" ? matchedOptions.slice(0, maxOptions) : matchedOptions; + + const toggleValue = (selectedValue: string) => { + const nextValues = selectedSet.has(selectedValue) + ? values.filter((value) => value !== selectedValue) + : [...values, selectedValue]; + + onChange(nextValues); + setSearchQuery(""); + setIsOpen(true); + requestAnimationFrame(() => { + inputRef.current?.focus(); + }); + }; + + return ( +
+
+ { + setIsOpen(true); + onClick?.(event); + }} + onFocus={(event) => { + setIsOpen(true); + onFocus?.(event); + }} + onBlur={(event) => { + onBlur?.(event); + }} + onChange={(event) => { + setSearchQuery(event.target.value); + setIsOpen(true); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + if (filteredOptions[0]) { + toggleValue(filteredOptions[0].value); + return; + } + } + + if (event.key === "Backspace" && !searchQuery && values.length > 0) { + onChange(values.slice(0, -1)); + } + + if (event.key === "Escape") { + setIsOpen(false); + } + + onKeyDown?.(event); + }} + className={cn(inputClassName, !searchQuery && selectedSummary && "text-transparent caret-foreground")} + /> + + {!searchQuery && selectedSummary && ( +
+ {selectedSummary} +
+ )} +
+ + {isOpen && !disabled && ( +
+ {filteredOptions.length > 0 ? ( +
+ {filteredOptions.map((option) => { + const label = option.label || option.value; + const showDescription = option.description && option.description !== label; + const isSelected = selectedSet.has(option.value); + + return ( + + ); + })} +
+ ) : ( +
{emptyText}
+ )} +
+ )} +
+ ); + }, +); + +MultiSelectAutoCompleteInput.displayName = "MultiSelectAutoCompleteInput"; diff --git a/frontend/src/pages/Pricing.tsx b/frontend/src/pages/Pricing.tsx index 1c803d5..8e93ca4 100644 --- a/frontend/src/pages/Pricing.tsx +++ b/frontend/src/pages/Pricing.tsx @@ -1,35 +1,124 @@ -import { useState, useEffect } from "react"; -import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useDeferredValue, useEffect, useMemo, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { apiClient } from "@/api/client"; -import { Channel, PricingConfig } from "@/types"; -import { AutoCompleteInput, type AutoCompleteOption } from "@/components/ui/autocomplete"; +import { Channel, PricingConfig, PricingModel, Token } from "@/types"; +import { MultiSelectAutoCompleteInput, type AutoCompleteOption } from "@/components/ui/autocomplete"; +import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Textarea } from "@/components/ui/textarea"; import { useToast } from "@/components/ui/use-toast"; -import { BILLING_CONFIG_QUERY_KEY, useBillingConfig } from "@/hooks/use-billing-config"; -import { normalizeBillingDisplayDecimals } from "@/lib/billing"; +import { + getChannelModels, + isChannelEnabled, + isChannelModelEnabled, + parseChannelConfig, + parseTokenConfig, +} from "@/lib/channel-models"; import { cn } from "@/lib/utils"; -import { getUniqueModelNamesFromChannels } from "@/lib/channel-models"; -import { Plus, RefreshCw, Trash2, FileJson, FileText, DollarSign, Check, Search } from "lucide-react"; +import { Check, FileJson, FileText, RefreshCw, Search } from "lucide-react"; import { PageContainer } from "@/components/ui/page-container"; -import { Card } from "@/components/ui/card"; -type EditMode = "table" | "json"; +type EditMode = "cards" | "json"; +type PricingField = "input" | "output" | "cache" | "request"; +type PricingRow = { + model: string; + input: number; + output: number; + cache: number; + request: number; +}; +type ChannelRef = { + key: string; + label: string; + type: string; + keywords: string[]; +}; +type TokenRef = { + key: string; + label: string; + keywords: string[]; +}; const PRICING_DECIMALS = 6; const PRICING_INPUT_STEP = "0.000001"; - -const normalizePricingNumber = (value: number): number => { - if (!Number.isFinite(value)) { - return 0; - } - - return Number(value.toFixed(PRICING_DECIMALS)); +const DEFAULT_REQUEST_VALUE = 1; +type BuildPricingRowsOptions = { + sortConfiguredFirst?: boolean; }; -const normalizePricingConfig = (config: PricingConfig): PricingConfig => { +const FIELD_META: Array<{ + key: PricingField; + label: string; + placeholder: string; +}> = [ + { + key: "input", + label: "输入倍率", + placeholder: "0.000000", + }, + { + key: "output", + label: "输出倍率", + placeholder: "0.000000", + }, + { + key: "cache", + label: "缓存倍率", + placeholder: "0.000000", + }, + { + key: "request", + label: "按次", + placeholder: "1.000000", + }, +]; + +const normalizePricingNumber = (value: unknown, fallback = 0): number => { + const parsed = + typeof value === "number" && Number.isFinite(value) + ? value + : typeof value === "string" && value.trim().length > 0 + ? Number(value) + : fallback; + + if (!Number.isFinite(parsed)) { + return fallback; + } + + return Number(parsed.toFixed(PRICING_DECIMALS)); +}; + +const normalizePricingEntry = (pricing?: Partial | null): PricingRow => ({ + model: "", + input: normalizePricingNumber(pricing?.input, 0), + output: normalizePricingNumber(pricing?.output, 0), + cache: normalizePricingNumber(pricing?.cache, 0), + request: normalizePricingNumber(pricing?.request, DEFAULT_REQUEST_VALUE), +}); + +const sanitizePricingEntry = (pricing: PricingRow): Partial | null => { + const normalized = { + input: normalizePricingNumber(pricing.input, 0), + output: normalizePricingNumber(pricing.output, 0), + cache: normalizePricingNumber(pricing.cache, 0), + request: normalizePricingNumber(pricing.request, 0), + }; + + const filtered = Object.fromEntries( + Object.entries(normalized).filter(([, value]) => value > 0), + ) as Partial; + + return Object.keys(filtered).length > 0 ? filtered : null; +}; + +const normalizePricingConfig = (config?: PricingConfig | null): PricingConfig => { + if (!config) { + return {}; + } + return Object.fromEntries( Object.entries(config) .map(([model, pricing]) => { @@ -38,221 +127,462 @@ const normalizePricingConfig = (config: PricingConfig): PricingConfig => { return null; } - return [ - normalizedModel, - { - input: normalizePricingNumber(pricing.input), - output: normalizePricingNumber(pricing.output), - cache: normalizePricingNumber(pricing.cache || 0), - request: normalizePricingNumber(pricing.request || 0), - }, - ] as const; + const sanitized = sanitizePricingEntry({ + model: normalizedModel, + ...normalizePricingEntry(pricing), + }); + + if (!sanitized) { + return null; + } + + return [normalizedModel, sanitized] as const; }) - .filter((entry): entry is readonly [string, PricingConfig[string]] => entry !== null), + .filter((entry): entry is readonly [string, Partial] => entry !== null), ); }; -export function Pricing() { - const [editMode, setEditMode] = useState("table"); - const [jsonValue, setJsonValue] = useState(""); - const [pricingRows, setPricingRows] = useState< - Array<{ model: string; input: number; output: number; cache: number; request: number }> - >([]); - const [searchQuery, setSearchQuery] = useState(""); - const [modelOptions, setModelOptions] = useState([]); - const [billingDisplayDecimals, setBillingDisplayDecimals] = useState(PRICING_DECIMALS); +const createDefaultPricingRow = (model: string, pricing?: Partial | null): PricingRow => { + const normalized = normalizePricingEntry(pricing); + return { + model, + input: normalized.input, + output: normalized.output, + cache: normalized.cache, + request: normalized.request, + }; +}; +const buildModelChannelMap = (channels: Channel[]): Map => { + const channelMap = new Map(); + + channels.forEach((channel) => { + const config = parseChannelConfig(channel); + if (!isChannelEnabled(config)) { + return; + } + + const label = config.name?.trim() || channel.key; + const type = config.type?.trim() || ""; + const channelRef: ChannelRef = { + key: channel.key, + label, + type, + keywords: [channel.key, label, type].filter(Boolean), + }; + + getChannelModels(config) + .filter((model) => isChannelModelEnabled(model)) + .forEach((model) => { + const next = [...(channelMap.get(model.name) || [])]; + if (!next.some((item) => item.key === channel.key)) { + next.push(channelRef); + next.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")); + channelMap.set(model.name, next); + } + }); + }); + + return channelMap; +}; + +const buildModelTokenMap = (tokens: Token[], modelChannelMap: Map): Map => { + const tokenMap = new Map(); + + tokens.forEach((token) => { + const config = parseTokenConfig(token); + const allowedChannelKeys = (config.channel_keys || []).filter(Boolean); + const allowedSet = new Set(allowedChannelKeys); + const label = config.name?.trim() || token.key; + const tokenRef: TokenRef = { + key: token.key, + label, + keywords: [token.key, label, ...allowedChannelKeys].filter(Boolean), + }; + + modelChannelMap.forEach((channels, model) => { + if (channels.length === 0) { + return; + } + + const canAccess = allowedSet.size === 0 ? true : channels.some((channel) => allowedSet.has(channel.key)); + + if (!canAccess) { + return; + } + + const next = [...(tokenMap.get(model) || [])]; + if (!next.some((item) => item.key === token.key)) { + next.push(tokenRef); + next.sort((left, right) => left.label.localeCompare(right.label, "zh-CN")); + tokenMap.set(model, next); + } + }); + }); + + return tokenMap; +}; + +const buildPricingRows = ( + modelChannelMap: Map, + config: PricingConfig, + options: BuildPricingRowsOptions = {}, +): PricingRow[] => { + const { sortConfiguredFirst = true } = options; + const systemModels = Array.from(modelChannelMap.keys()); + const storedModels = Object.keys(config); + const modelNames = Array.from(new Set([...systemModels, ...storedModels])); + const configuredModels = new Set(storedModels); + + return modelNames + .sort((left, right) => { + if (sortConfiguredFirst) { + const leftConfigured = configuredModels.has(left); + const rightConfigured = configuredModels.has(right); + if (leftConfigured !== rightConfigured) { + return leftConfigured ? -1 : 1; + } + } + + const leftIsSystem = modelChannelMap.has(left); + const rightIsSystem = modelChannelMap.has(right); + if (leftIsSystem !== rightIsSystem) { + return leftIsSystem ? -1 : 1; + } + return left.localeCompare(right, "zh-CN"); + }) + .map((model) => createDefaultPricingRow(model, config[model])); +}; + +const isCustomPricingRow = (row: PricingRow): boolean => { + return row.input > 0 || row.output > 0 || row.cache > 0 || row.request !== DEFAULT_REQUEST_VALUE; +}; + +type MultiSelectAutocompleteFieldProps = { + label: string; + placeholder: string; + selectedValues: string[]; + onChange: (values: string[]) => void; + options: AutoCompleteOption[]; + emptyText: string; +}; + +function MultiSelectAutocompleteField({ + label, + placeholder, + selectedValues, + onChange, + options, + emptyText, +}: MultiSelectAutocompleteFieldProps) { + return ( +
+
+ + {selectedValues.length > 0 && ( + + )} +
+ + +
+ ); +} + +export function Pricing() { + const [editMode, setEditMode] = useState("cards"); + const [jsonValue, setJsonValue] = useState(""); + const [pricingRows, setPricingRows] = useState([]); + const [searchQuery, setSearchQuery] = useState(""); + const [selectedChannels, setSelectedChannels] = useState([]); + const [selectedTokens, setSelectedTokens] = useState([]); + const [selectedTypes, setSelectedTypes] = useState([]); + + const deferredSearchQuery = useDeferredValue(searchQuery); const { addToast } = useToast(); const queryClient = useQueryClient(); - const billingConfigQuery = useBillingConfig(); - const { data, isLoading, isFetching, refetch } = useQuery({ + const pricingQuery = useQuery({ queryKey: ["pricing"], queryFn: async () => { const response = await apiClient.getPricing(); - return response.data as PricingConfig; + return normalizePricingConfig(response.data as PricingConfig | undefined); }, }); - useEffect(() => { - const loadChannelModels = async () => { - try { - const response = await apiClient.getChannels(); - const channels = response.data as Channel[]; - const modelNames = getUniqueModelNamesFromChannels(channels); - setModelOptions(modelNames.map((model) => ({ value: model }))); - } catch (error) { - console.error("Failed to load channels for pricing suggestions:", error); - } - }; + const channelsQuery = useQuery({ + queryKey: ["channels", "pricing"], + queryFn: async () => { + const response = await apiClient.getChannels(); + return response.data as Channel[]; + }, + }); - loadChannelModels(); - }, []); + const tokensQuery = useQuery({ + queryKey: ["tokens", "pricing"], + queryFn: async () => { + const response = await apiClient.getTokens(); + return response.data as Token[]; + }, + }); + + const modelChannelMap = useMemo(() => buildModelChannelMap(channelsQuery.data || []), [channelsQuery.data]); + const modelTokenMap = useMemo( + () => buildModelTokenMap(tokensQuery.data || [], modelChannelMap), + [modelChannelMap, tokensQuery.data], + ); + + const baseRows = useMemo( + () => buildPricingRows(modelChannelMap, pricingQuery.data || {}, { sortConfiguredFirst: true }), + [modelChannelMap, pricingQuery.data], + ); useEffect(() => { - if (data) { - const normalizedConfig = normalizePricingConfig(data); - const rows = Object.entries(normalizedConfig).map(([model, pricing]) => ({ - model, - input: pricing.input, - output: pricing.output, - cache: pricing.cache || 0, - request: pricing.request || 0, - })); - setPricingRows(rows); - setJsonValue(JSON.stringify(normalizedConfig, null, 2)); - } - }, [data]); - - useEffect(() => { - if (billingConfigQuery.data) { - setBillingDisplayDecimals(billingConfigQuery.data.displayDecimals); - } - }, [billingConfigQuery.data]); + setPricingRows(baseRows); + setJsonValue(JSON.stringify(pricingQuery.data || {}, null, 2)); + }, [baseRows, pricingQuery.data]); const saveMutation = useMutation({ - mutationFn: async (config: PricingConfig) => { - return apiClient.savePricing(config); - }, + mutationFn: async (config: PricingConfig) => apiClient.savePricing(config), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["pricing"] }); addToast("定价配置已保存", "success"); }, onError: (error: Error) => { - addToast("保存失败:" + error.message, "error"); + addToast(`保存失败:${error.message}`, "error"); }, }); - const billingConfigMutation = useMutation({ - mutationFn: async (displayDecimals: number) => { - return apiClient.saveBillingConfig({ - displayDecimals: normalizeBillingDisplayDecimals(displayDecimals), - }); - }, - onSuccess: () => { - queryClient.invalidateQueries({ queryKey: BILLING_CONFIG_QUERY_KEY }); - addToast("金额显示精度已保存", "success"); - }, - onError: (error: Error) => { - addToast("保存显示精度失败:" + error.message, "error"); - }, - }); + const channelOptions = useMemo(() => { + return (channelsQuery.data || []) + .map((channel) => { + const config = parseChannelConfig(channel); + if (!isChannelEnabled(config)) { + return null; + } + + const label = config.name?.trim() || channel.key; + return { + value: channel.key, + label, + description: label === channel.key ? undefined : channel.key, + keywords: [channel.key, label, config.type || ""].filter(Boolean), + } satisfies AutoCompleteOption; + }) + .filter((option): option is AutoCompleteOption => option !== null) + .sort((left, right) => (left.label || left.value).localeCompare(right.label || right.value, "zh-CN")); + }, [channelsQuery.data]); + + const tokenOptions = useMemo(() => { + return (tokensQuery.data || []) + .map((token) => { + const config = parseTokenConfig(token); + const label = config.name?.trim() || token.key; + const channelKeys = config.channel_keys || []; + + return { + value: token.key, + label, + description: label === token.key ? undefined : token.key, + keywords: [token.key, label, ...channelKeys].filter(Boolean), + } satisfies AutoCompleteOption; + }) + .sort((left, right) => (left.label || left.value).localeCompare(right.label || right.value, "zh-CN")); + }, [tokensQuery.data]); + + const typeOptions = useMemo(() => { + const typeMap = new Map(); + + (channelsQuery.data || []).forEach((channel) => { + const config = parseChannelConfig(channel); + if (!isChannelEnabled(config)) { + return; + } + + const type = config.type?.trim(); + if (!type) { + return; + } + + const label = type; + const channelLabel = config.name?.trim() || channel.key; + const current = typeMap.get(type) || { label, channelLabels: [] }; + + if (!current.channelLabels.includes(channelLabel)) { + current.channelLabels.push(channelLabel); + current.channelLabels.sort((left, right) => left.localeCompare(right, "zh-CN")); + } + + typeMap.set(type, current); + }); + + return Array.from(typeMap.entries()) + .map(([type, meta]) => ({ + value: type, + label: meta.label, + description: meta.channelLabels.join("、"), + keywords: [type, meta.label, ...meta.channelLabels], + })) + .sort((left, right) => (left.label || left.value).localeCompare(right.label || right.value, "zh-CN")); + }, [channelsQuery.data]); + + const pricingCards = useMemo(() => { + return pricingRows.map((row) => { + const channels = modelChannelMap.get(row.model) || []; + const tokens = modelTokenMap.get(row.model) || []; + const types = Array.from(new Set(channels.map((channel) => channel.type).filter(Boolean))).sort((left, right) => + left.localeCompare(right, "zh-CN"), + ); + const searchTarget = [ + row.model, + ...channels.flatMap((channel) => channel.keywords), + ...tokens.flatMap((token) => token.keywords), + ...types, + ] + .join(" ") + .toLowerCase(); + + return { + ...row, + channels, + tokens, + types, + isCustomized: isCustomPricingRow(row), + searchTarget, + }; + }); + }, [modelChannelMap, modelTokenMap, pricingRows]); + + useEffect(() => { + setSelectedChannels((current) => + current.filter((value) => channelOptions.some((option) => option.value === value)), + ); + }, [channelOptions]); + + useEffect(() => { + setSelectedTokens((current) => current.filter((value) => tokenOptions.some((option) => option.value === value))); + }, [tokenOptions]); + + useEffect(() => { + setSelectedTypes((current) => current.filter((value) => typeOptions.some((option) => option.value === value))); + }, [typeOptions]); + + const filteredCards = useMemo(() => { + const normalizedSearchQuery = deferredSearchQuery.trim().toLowerCase(); + + return pricingCards.filter((card) => { + if (selectedChannels.length > 0 && !card.channels.some((channel) => selectedChannels.includes(channel.key))) { + return false; + } + + if (selectedTokens.length > 0 && !card.tokens.some((token) => selectedTokens.includes(token.key))) { + return false; + } + + if (selectedTypes.length > 0 && !card.types.some((type) => selectedTypes.includes(type))) { + return false; + } + + if (normalizedSearchQuery && !card.searchTarget.includes(normalizedSearchQuery)) { + return false; + } + + return true; + }); + }, [deferredSearchQuery, pricingCards, selectedChannels, selectedTokens, selectedTypes]); + + const handleRefresh = () => { + pricingQuery.refetch(); + channelsQuery.refetch(); + tokensQuery.refetch(); + }; + + const buildConfigFromRows = (rows: PricingRow[]): PricingConfig => { + return Object.fromEntries( + rows + .map((row) => { + const normalizedModel = row.model.trim(); + if (!normalizedModel) { + return null; + } + + const sanitized = sanitizePricingEntry(row); + if (!sanitized) { + return null; + } + + return [normalizedModel, sanitized] as const; + }) + .filter((entry): entry is readonly [string, Partial] => entry !== null), + ); + }; const handleSave = () => { - let config: PricingConfig; - - if (editMode === "table") { - if (pricingRows.length === 0) { - addToast("请至少添加一个模型定价", "error"); - return; - } - - config = {}; - pricingRows.forEach((row) => { - if (row.model) { - config[row.model] = { - input: normalizePricingNumber(row.input || 0), - output: normalizePricingNumber(row.output || 0), - cache: normalizePricingNumber(row.cache || 0), - request: normalizePricingNumber(row.request || 0), - }; - } - }); - } else { - try { - config = JSON.parse(jsonValue); - } catch { - addToast("JSON格式错误", "error"); - return; - } + if (editMode === "cards") { + saveMutation.mutate(buildConfigFromRows(pricingRows)); + return; } - saveMutation.mutate(normalizePricingConfig(config)); + try { + const parsed = JSON.parse(jsonValue) as PricingConfig; + saveMutation.mutate(normalizePricingConfig(parsed)); + } catch { + addToast("JSON 格式错误", "error"); + } }; const toggleEditMode = () => { - if (editMode === "table") { - const config: PricingConfig = {}; - pricingRows.forEach((row) => { - if (row.model) { - config[row.model] = { - input: normalizePricingNumber(row.input || 0), - output: normalizePricingNumber(row.output || 0), - cache: normalizePricingNumber(row.cache || 0), - request: normalizePricingNumber(row.request || 0), - }; - } - }); - setJsonValue(JSON.stringify(normalizePricingConfig(config), null, 2)); + if (editMode === "cards") { + setJsonValue(JSON.stringify(buildConfigFromRows(pricingRows), null, 2)); setEditMode("json"); - } else { - try { - const config = normalizePricingConfig(JSON.parse(jsonValue)); - const rows = Object.entries(config).map(([model, pricing]: [string, unknown]) => ({ - model, - input: normalizePricingNumber((pricing as { input: number }).input || 0), - output: normalizePricingNumber((pricing as { output: number }).output || 0), - cache: normalizePricingNumber((pricing as { cache?: number }).cache || 0), - request: normalizePricingNumber((pricing as { request?: number }).request || 0), - })); - setPricingRows(rows); - setEditMode("table"); - } catch { - addToast("JSON格式错误", "error"); - } + return; + } + + try { + const parsed = normalizePricingConfig(JSON.parse(jsonValue)); + setPricingRows(buildPricingRows(modelChannelMap, parsed, { sortConfiguredFirst: false })); + setEditMode("cards"); + } catch { + addToast("JSON 格式错误", "error"); } }; - const addRow = () => { - setPricingRows([...pricingRows, { model: "", input: 0, output: 0, cache: 0, request: 0 }]); + const updateRow = (model: string, field: PricingField, nextValue: string) => { + const normalizedValue = normalizePricingNumber(nextValue, 0); + + setPricingRows((current) => + current.map((row) => (row.model === model ? { ...row, [field]: normalizedValue } : row)), + ); }; - const removeRow = (index: number) => { - setPricingRows(pricingRows.filter((_, i) => i !== index)); - }; - - const updateRow = ( - index: number, - field: "model" | "input" | "output" | "cache" | "request", - value: string | number, - ) => { - const newRows = [...pricingRows]; - (newRows[index] as any)[field] = value; - setPricingRows(newRows); - }; - - const filteredRows = pricingRows - .map((row, index) => ({ ...row, _i: index })) - .filter((row) => row.model.toLowerCase().includes(searchQuery.toLowerCase())); - - const pricingModelOptions = [ - ...pricingRows - .map((row) => row.model.trim()) - .filter((model) => model.length > 0) - .map((model) => ({ value: model })), - ...modelOptions, - ].filter((option, index, options) => options.findIndex((candidate) => candidate.value === option.value) === index); + const isFetching = pricingQuery.isFetching || channelsQuery.isFetching || tokensQuery.isFetching; return ( -
} > - -
-
- - setBillingDisplayDecimals(normalizeBillingDisplayDecimals(Number(e.target.value)))} - className="w-32" +
+ +
+ -

- 默认 6 位。只影响前端展示,不改变 API 返回的原始数值。 -

-
- -
- - {editMode === "table" ? ( - isLoading ? ( -
-
-
- 加载中... -
-
- ) : pricingRows.length === 0 ? ( - -
- -
-

暂无定价配置

-

- 可调整倍率控制输入/输出成本,或者设置按次扣费;金额展示精度单独配置。 -

- -
- ) : ( -
- {/* Search */} - {pricingRows.length > 5 && ( + + + + +
+
- + setSearchQuery(e.target.value)} - className="pl-9" + onChange={(event) => setSearchQuery(event.target.value)} + placeholder="搜索模型、渠道、令牌或类型" + className="h-10 pl-9" />
- )} +
+
- {/* Table */} - - {/* Header */} -
- 模型名称 - 输入倍率 - 输出倍率 - 缓存倍率 - 按次 - + + + {editMode === "cards" ? ( + pricingQuery.isLoading || channelsQuery.isLoading || tokensQuery.isLoading ? ( +
+
+
+ 正在汇总模型、渠道与令牌信息...
- - {/* Rows */} -
- {filteredRows.map((row) => ( -
- updateRow(row._i, "model", value)} - placeholder="模型名称" - inputClassName="font-mono text-sm h-8" - options={pricingModelOptions} - emptyText="没有匹配的模型" - /> - updateRow(row._i, "input", parseFloat(e.target.value) || 0)} - step={PRICING_INPUT_STEP} - min="0" - placeholder="0.000000" - className="font-mono text-sm h-8" - /> - updateRow(row._i, "output", parseFloat(e.target.value) || 0)} - step={PRICING_INPUT_STEP} - min="0" - placeholder="0.000000" - className="font-mono text-sm h-8" - /> - updateRow(row._i, "cache", parseFloat(e.target.value) || 0)} - step={PRICING_INPUT_STEP} - min="0" - placeholder="0.000000" - className="font-mono text-sm h-8" - /> - updateRow(row._i, "request", parseFloat(e.target.value) || 0)} - step={PRICING_INPUT_STEP} - min="0" - placeholder="0.000000" - className="font-mono text-sm h-8" - /> - -
- ))} -
- - {filteredRows.length === 0 && searchQuery && ( -
没有匹配的模型
- )} +
+ ) : pricingCards.length === 0 ? ( + + +
暂无可管理模型
+

+ 先在渠道管理中同步模型列表,或者切换到 JSON 模式手动补充价格配置。 +

+
+ ) : filteredCards.length === 0 ? ( + + +
没有匹配的模型卡片
+

+ 当前筛选条件过严,建议放宽渠道、令牌、类型或关键词。 +

+
+
+ ) : ( +
+ {filteredCards.map((card) => ( + + +
+
+ {card.model} +
+ +
+
- {/* Add */} - -
- ) - ) : ( -
-
- -