feat(pricing): 重构定价管理页面,支持卡片视图和多选筛选功能

- 更新定价模型,支持可选输入和输出字段
- 增加多选自动完成输入组件,允许按渠道、令牌和类型筛选
- 重新设计定价行的构建逻辑,优化数据处理
- 提升用户体验,支持 JSON 配置与卡片视图之间的切换
- 增强 API 端点,确保定价配置的有效性和一致性
This commit is contained in:
Jeff Gu committed 2026-04-13 17:54:30 +08:00
1 parent f226f47545
commit 1c9bd91aed
5 files changed
+983 -442

No files matched your search

+263 -72
View File
@@ -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<React.InputHTMLAttributes<HTMLInputElement>, 'value' | 'onChange'> & {
value: string
onChange: (value: string) => void
options: AutoCompleteOption[]
maxOptions?: number
emptyText?: string
dropdownClassName?: string
inputClassName?: string
}
type AutoCompleteProps = Omit<React.InputHTMLAttributes<HTMLInputElement>, "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<React.InputHTMLAttributes<HTMLInputElement>, "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<HTMLInputElement, AutoCompleteProps>(
(
@@ -43,7 +49,7 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
onChange,
options,
maxOptions,
emptyText = '无匹配结果',
emptyText = "无匹配结果",
className,
dropdownClassName,
inputClassName,
@@ -53,65 +59,65 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
disabled,
...props
},
ref
ref,
) => {
const containerRef = React.useRef<HTMLDivElement | null>(null)
const [isOpen, setIsOpen] = React.useState(false)
const [searchQuery, setSearchQuery] = React.useState('')
const containerRef = React.useRef<HTMLDivElement | null>(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 (
<div ref={containerRef} className={cn('relative', className)}>
<div ref={containerRef} className={cn("relative", className)}>
<Input
{...props}
ref={ref}
disabled={disabled}
value={value}
onClick={(event) => {
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<HTMLInputElement, AutoComplete
{isOpen && !disabled && (
<div
className={cn(
'absolute z-20 mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-lg',
dropdownClassName
"absolute z-20 mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-lg",
dropdownClassName,
)}
>
{filteredOptions.length > 0 ? (
<div className="max-h-72 overflow-y-auto py-1">
{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 (
<button
@@ -138,11 +144,9 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
onClick={() => handleSelect(option.value)}
>
<span className="text-sm font-medium">{label}</span>
{showDescription && (
<span className="text-xs text-muted-foreground">{option.description}</span>
)}
{showDescription && <span className="text-xs text-muted-foreground">{option.description}</span>}
</button>
)
);
})}
</div>
) : (
@@ -151,8 +155,195 @@ export const AutoCompleteInput = React.forwardRef<HTMLInputElement, AutoComplete
</div>
)}
</div>
)
}
)
);
},
);
AutoCompleteInput.displayName = 'AutoCompleteInput'
AutoCompleteInput.displayName = "AutoCompleteInput";
export const MultiSelectAutoCompleteInput = React.forwardRef<HTMLInputElement, MultiSelectAutoCompleteProps>(
(
{
values,
onChange,
options,
maxOptions,
emptyText = "无匹配结果",
className,
dropdownClassName,
inputClassName,
onClick,
onFocus,
onBlur,
onKeyDown,
disabled,
placeholder,
...props
},
ref,
) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const inputRef = React.useRef<HTMLInputElement | null>(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 (
<div ref={containerRef} className={cn("relative", className)}>
<div className="relative">
<Input
{...props}
ref={inputRef}
disabled={disabled}
value={searchQuery}
placeholder={selectedSummary ? undefined : placeholder}
onClick={(event) => {
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 && (
<div className="pointer-events-none absolute inset-x-3 top-1/2 -translate-y-1/2 truncate text-sm text-foreground">
{selectedSummary}
</div>
)}
</div>
{isOpen && !disabled && (
<div
className={cn(
"absolute z-20 mt-1 w-full overflow-hidden rounded-md border bg-popover shadow-lg",
dropdownClassName,
)}
>
{filteredOptions.length > 0 ? (
<div className="max-h-72 overflow-y-auto py-1">
{filteredOptions.map((option) => {
const label = option.label || option.value;
const showDescription = option.description && option.description !== label;
const isSelected = selectedSet.has(option.value);
return (
<button
key={option.value}
type="button"
aria-pressed={isSelected}
className={cn(
"flex w-full items-start gap-3 px-3 py-2 text-left transition-colors",
isSelected ? "bg-primary/5 hover:bg-primary/10" : "hover:bg-muted/80",
)}
onMouseDown={(event) => event.preventDefault()}
onClick={() => toggleValue(option.value)}
>
<span className="mt-0.5 inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-sm border border-input text-muted-foreground">
{isSelected && <Check className="h-3 w-3" />}
</span>
<span className="min-w-0">
<span className="block truncate text-sm font-medium">{label}</span>
{showDescription && (
<span className="block truncate text-xs text-muted-foreground">{option.description}</span>
)}
</span>
</button>
);
})}
</div>
) : (
<div className="px-3 py-2 text-sm text-muted-foreground">{emptyText}</div>
)}
</div>
)}
</div>
);
},
);
MultiSelectAutoCompleteInput.displayName = "MultiSelectAutoCompleteInput";
File diff suppressed because it is too large. Load diff
+2 -2
View File
@@ -43,8 +43,8 @@ export interface BillingConfig {
}
export interface PricingModel {
input: number
output: number
input?: number
output?: number
cache?: number
request?: number
}
+65 -15
View File
@@ -5,18 +5,72 @@ import { getJsonSetting, saveSetting } from "../utils";
import { CONSTANTS } from "../constants";
import { CommonErrorResponse, CommonSuccessfulResponse } from "../model";
const pricingModelSchema = z.object({
input: z.number().optional(),
output: z.number().optional(),
cache: z.number().optional(),
request: z.number().optional(),
});
const toPositiveNumber = (value: unknown): number | undefined => {
if (typeof value !== "number" || !Number.isFinite(value)) {
return undefined;
}
return value > 0 ? value : undefined;
};
const sanitizePricingConfig = (
config: Record<string, Partial<ModelPricing>> | null | undefined
): Record<string, Partial<ModelPricing>> => {
if (!config || typeof config !== "object") {
return {};
}
const normalizedEntries: Array<[string, Partial<ModelPricing>]> = [];
Object.entries(config).forEach(([model, pricing]) => {
const normalizedModel = model.trim();
if (!normalizedModel || !pricing || typeof pricing !== "object") {
return;
}
const normalizedPricing: Partial<ModelPricing> = {};
const input = toPositiveNumber(pricing.input);
const output = toPositiveNumber(pricing.output);
const cache = toPositiveNumber(pricing.cache);
const request = toPositiveNumber(pricing.request);
if (input !== undefined) {
normalizedPricing.input = input;
}
if (output !== undefined) {
normalizedPricing.output = output;
}
if (cache !== undefined) {
normalizedPricing.cache = cache;
}
if (request !== undefined) {
normalizedPricing.request = request;
}
if (Object.keys(normalizedPricing).length === 0) {
return;
}
normalizedEntries.push([normalizedModel, normalizedPricing]);
});
return Object.fromEntries(normalizedEntries);
};
// Pricing 获取配置 API
export class PricingGetEndpoint extends OpenAPIRoute {
schema = {
tags: ['Admin API'],
summary: 'Get global pricing configuration',
responses: {
...CommonSuccessfulResponse(z.record(z.string(), z.object({
input: z.number(),
output: z.number(),
cache: z.number().optional(),
request: z.number().optional(),
}))),
...CommonSuccessfulResponse(z.record(z.string(), pricingModelSchema)),
...CommonErrorResponse,
},
};
@@ -29,7 +83,7 @@ export class PricingGetEndpoint extends OpenAPIRoute {
return {
success: true,
data: pricingConfig || {}
data: sanitizePricingConfig(pricingConfig)
} as CommonResponse;
}
}
@@ -43,12 +97,7 @@ export class PricingUpdateEndpoint extends OpenAPIRoute {
body: {
content: {
'application/json': {
schema: z.record(z.string(), z.object({
input: z.number(),
output: z.number(),
cache: z.number().optional(),
request: z.number().optional(),
})),
schema: z.record(z.string(), pricingModelSchema),
}
}
}
@@ -60,12 +109,13 @@ export class PricingUpdateEndpoint extends OpenAPIRoute {
};
async handle(c: Context<HonoCustomType>) {
const body = await c.req.json<Record<string, ModelPricing>>();
const body = await c.req.json<Record<string, Partial<ModelPricing>>>();
const normalizedConfig = sanitizePricingConfig(body);
await saveSetting(
c,
CONSTANTS.MODEL_PRICING_KEY,
JSON.stringify(body)
JSON.stringify(normalizedConfig)
);
return {
Vendored
+2 -2
View File
@@ -110,8 +110,8 @@ type CommonResponse = {
}
type ModelPricing = {
input: number;
output: number;
input?: number;
output?: number;
cache?: number;
request?: number;
}