feat(ui): 添加选择组件并优化现有组件样式
This commit is contained in:
1 parent
e5363843af
commit
f0e3a349eb
7 files changed
+200
-42
No files matched your search
@@ -27,6 +27,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-select": "^2.2.6",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/postcss": "^4.2.2",
|
||||
"@tanstack/react-query": "^5.97.0",
|
||||
|
||||
@@ -12,7 +12,7 @@ const buttonVariants = cva(
|
||||
destructive:
|
||||
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
|
||||
outline:
|
||||
"border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent-foreground/20",
|
||||
"border border-input shadow-sm hover:bg-accent hover:text-accent-foreground hover:border-accent-foreground/20",
|
||||
secondary:
|
||||
"bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80",
|
||||
ghost: "hover:bg-accent hover:text-accent-foreground",
|
||||
|
||||
@@ -7,7 +7,7 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex h-10 w-full rounded-lg border border-input px-3 py-2 text-sm shadow-sm transition-all duration-200 file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -1,21 +1,187 @@
|
||||
import * as React from "react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import * as React from "react";
|
||||
import * as SelectPrimitive from "@radix-ui/react-select";
|
||||
import { Check, ChevronDown, ChevronUp } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
const Select = React.forwardRef<
|
||||
HTMLSelectElement,
|
||||
React.ComponentProps<"select">
|
||||
>(({ className, ...props }, ref) => {
|
||||
return (
|
||||
<select
|
||||
className={cn(
|
||||
"flex h-10 w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm transition-all duration-200 focus-visible:outline-none focus-visible:border-primary/50 hover:border-muted-foreground/30 cursor-pointer disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
})
|
||||
Select.displayName = "Select"
|
||||
type NativeSelectProps = Omit<React.ComponentProps<"select">, "children" | "onChange" | "size">;
|
||||
|
||||
export { Select }
|
||||
interface SelectProps extends NativeSelectProps {
|
||||
children: React.ReactNode;
|
||||
onChange?: (event: React.ChangeEvent<HTMLSelectElement>) => void;
|
||||
placeholder?: string;
|
||||
contentClassName?: string;
|
||||
}
|
||||
|
||||
interface SelectOption {
|
||||
value: string;
|
||||
label: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
}
|
||||
|
||||
const TRIGGER_BASE_CLASS =
|
||||
"group relative flex h-10 w-full items-center rounded-lg border border-input/90 bg-card/90 px-3 py-2 pr-12 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-primary/25 hover:bg-card focus-visible:border-primary/55 focus-visible:ring-4 focus-visible:ring-primary/10 disabled:cursor-not-allowed disabled:opacity-50 data-[placeholder]:text-muted-foreground";
|
||||
|
||||
const CONTENT_BASE_CLASS =
|
||||
"z-50 max-h-72 min-w-[var(--radix-select-trigger-width)] overflow-hidden rounded-lg border border-border/70 bg-popover/95 p-1.5 text-popover-foreground shadow-md backdrop-blur-xl data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=top]:slide-in-from-bottom-2 dark:shadow-[0_24px_70px_-24px_hsl(0_0%_0%_/_0.8)]";
|
||||
|
||||
const ITEM_BASE_CLASS =
|
||||
"relative flex w-full select-none items-center rounded-lg px-3 py-2 pr-9 text-xs text-foreground outline-none transition-all duration-150 data-[disabled]:pointer-events-none data-[disabled]:opacity-45 data-[highlighted]:bg-primary/10 data-[highlighted]:text-foreground data-[state=checked]:bg-primary/8";
|
||||
|
||||
const isElementOfType = (
|
||||
child: React.ReactNode,
|
||||
type: "option" | "optgroup",
|
||||
): child is React.ReactElement<React.HTMLAttributes<HTMLElement>> => React.isValidElement(child) && child.type === type;
|
||||
|
||||
const extractOptions = (children: React.ReactNode): SelectOption[] => {
|
||||
const options: SelectOption[] = [];
|
||||
|
||||
React.Children.forEach(children, (child) => {
|
||||
if (!child) return;
|
||||
|
||||
if (React.isValidElement(child) && child.type === React.Fragment) {
|
||||
options.push(...extractOptions(child.props.children));
|
||||
return;
|
||||
}
|
||||
|
||||
if (isElementOfType(child, "optgroup")) {
|
||||
options.push(...extractOptions(child.props.children));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isElementOfType(child, "option")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rawValue = child.props.value;
|
||||
if (rawValue == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
options.push({
|
||||
value: String(rawValue),
|
||||
label: child.props.children,
|
||||
disabled: Boolean(child.props.disabled),
|
||||
});
|
||||
});
|
||||
|
||||
return options;
|
||||
};
|
||||
|
||||
const Select = React.forwardRef<HTMLButtonElement, SelectProps>(
|
||||
(
|
||||
{
|
||||
children,
|
||||
className,
|
||||
contentClassName,
|
||||
disabled,
|
||||
form,
|
||||
id,
|
||||
name,
|
||||
required,
|
||||
autoComplete,
|
||||
dir,
|
||||
value,
|
||||
defaultValue,
|
||||
onChange,
|
||||
onBlur,
|
||||
onFocus,
|
||||
onKeyDown,
|
||||
placeholder = "请选择",
|
||||
"aria-label": ariaLabel,
|
||||
"aria-describedby": ariaDescribedBy,
|
||||
"aria-labelledby": ariaLabelledBy,
|
||||
},
|
||||
ref,
|
||||
) => {
|
||||
const options = React.useMemo(() => extractOptions(children), [children]);
|
||||
|
||||
const normalizedValue = typeof value === "string" || typeof value === "number" ? String(value) : undefined;
|
||||
const normalizedDefaultValue =
|
||||
typeof defaultValue === "string" || typeof defaultValue === "number" ? String(defaultValue) : undefined;
|
||||
|
||||
const handleValueChange = React.useCallback(
|
||||
(nextValue: string) => {
|
||||
if (!onChange) return;
|
||||
|
||||
const syntheticEvent = {
|
||||
target: { value: nextValue, name },
|
||||
currentTarget: { value: nextValue, name },
|
||||
} as React.ChangeEvent<HTMLSelectElement>;
|
||||
|
||||
onChange(syntheticEvent);
|
||||
},
|
||||
[name, onChange],
|
||||
);
|
||||
|
||||
return (
|
||||
<SelectPrimitive.Root
|
||||
value={normalizedValue}
|
||||
defaultValue={normalizedDefaultValue}
|
||||
onValueChange={handleValueChange}
|
||||
disabled={disabled}
|
||||
name={name}
|
||||
autoComplete={autoComplete}
|
||||
required={required}
|
||||
form={form}
|
||||
dir={dir}
|
||||
>
|
||||
<SelectPrimitive.Trigger
|
||||
ref={ref}
|
||||
id={id}
|
||||
className={cn(TRIGGER_BASE_CLASS, className)}
|
||||
aria-label={ariaLabel}
|
||||
aria-describedby={ariaDescribedBy}
|
||||
aria-labelledby={ariaLabelledBy}
|
||||
onBlur={onBlur}
|
||||
onFocus={onFocus}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
<SelectPrimitive.Value placeholder={placeholder} />
|
||||
<SelectPrimitive.Icon asChild>
|
||||
<span className="pointer-events-none absolute right-2.5 top-1/2 flex h-6 w-6 -translate-y-1/2 items-center justify-center text-muted-foreground transition-all duration-200 ">
|
||||
<ChevronDown className="h-5 w-5 transition-transform duration-200 group-data-[state=open]:rotate-180" />
|
||||
</span>
|
||||
</SelectPrimitive.Icon>
|
||||
</SelectPrimitive.Trigger>
|
||||
|
||||
<SelectPrimitive.Portal>
|
||||
<SelectPrimitive.Content
|
||||
className={cn(CONTENT_BASE_CLASS, contentClassName)}
|
||||
position="popper"
|
||||
sideOffset={10}
|
||||
collisionPadding={12}
|
||||
>
|
||||
<SelectPrimitive.ScrollUpButton className="flex h-8 cursor-default items-center justify-center rounded-md text-muted-foreground">
|
||||
<ChevronUp className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollUpButton>
|
||||
|
||||
<SelectPrimitive.Viewport className="p-0.5">
|
||||
{options.map((option) => (
|
||||
<SelectPrimitive.Item
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
disabled={option.disabled}
|
||||
className={ITEM_BASE_CLASS}
|
||||
textValue={typeof option.label === "string" ? option.label : option.value}
|
||||
>
|
||||
<SelectPrimitive.ItemText>{option.label}</SelectPrimitive.ItemText>
|
||||
<SelectPrimitive.ItemIndicator className="absolute right-3 inline-flex items-center justify-center text-primary">
|
||||
<Check className="h-4 w-4" />
|
||||
</SelectPrimitive.ItemIndicator>
|
||||
</SelectPrimitive.Item>
|
||||
))}
|
||||
</SelectPrimitive.Viewport>
|
||||
|
||||
<SelectPrimitive.ScrollDownButton className="flex h-8 cursor-default items-center justify-center rounded-md text-muted-foreground">
|
||||
<ChevronDown className="h-4 w-4" />
|
||||
</SelectPrimitive.ScrollDownButton>
|
||||
</SelectPrimitive.Content>
|
||||
</SelectPrimitive.Portal>
|
||||
</SelectPrimitive.Root>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Select.displayName = "Select";
|
||||
|
||||
export { Select };
|
||||
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input bg-background px-3 py-2 text-sm shadow-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
"flex min-h-[80px] w-full rounded-lg border border-input px-3 py-2 text-sm shadow-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:outline-none focus-visible:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
|
||||
@@ -814,7 +814,7 @@ export function Channels() {
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">自动重试</div>
|
||||
<p className="text-xs text-muted-foreground">单个密钥遇可重试错误时,自动重试 x3</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">单个密钥遇可重试错误时,自动重试 x3</p>
|
||||
</div>
|
||||
</label>
|
||||
<label className="flex items-start gap-3 p-4 rounded-lg border bg-muted/30 cursor-pointer">
|
||||
@@ -825,7 +825,7 @@ export function Channels() {
|
||||
/>
|
||||
<div>
|
||||
<div className="text-sm font-medium">自动轮换</div>
|
||||
<p className="text-xs text-muted-foreground">单个密钥多次失败后,自动轮换密钥 x3</p>
|
||||
<p className="mt-1 text-xs text-muted-foreground">单个密钥多次失败后,自动轮换密钥 x3</p>
|
||||
</div>
|
||||
</label>
|
||||
</div>
|
||||
@@ -869,7 +869,7 @@ export function Channels() {
|
||||
|
||||
{modelEditorMode === 'visual' ? (
|
||||
<div className="space-y-2">
|
||||
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_56px] md:gap-2 px-3 text-xs font-medium text-muted-foreground">
|
||||
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_56px] md:gap-2 text-xs font-medium text-muted-foreground">
|
||||
<div>模型 ID</div>
|
||||
<div>模型名称</div>
|
||||
<div className="text-center">删除</div>
|
||||
@@ -878,18 +878,18 @@ export function Channels() {
|
||||
const canDelete = !isEmptyModelRow(row) || modelRows.length > 1
|
||||
|
||||
return (
|
||||
<div key={index} className="grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_56px] gap-2 p-3 bg-muted/40 rounded-lg">
|
||||
<div key={index} className="grid grid-cols-1 md:grid-cols-[minmax(0,1fr)_minmax(0,1fr)_56px] gap-2">
|
||||
<Input
|
||||
value={row.id}
|
||||
onChange={(e) => updateModelRow(index, 'id', e.target.value)}
|
||||
placeholder="模型ID,例如:gpt-4.1-mini"
|
||||
className="bg-background text-sm"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Input
|
||||
value={row.name}
|
||||
onChange={(e) => updateModelRow(index, 'name', e.target.value)}
|
||||
placeholder="模型名称,默认等于模型ID"
|
||||
className="bg-background text-sm"
|
||||
className="text-sm"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -181,7 +181,7 @@ export function Pricing() {
|
||||
return (
|
||||
<PageContainer
|
||||
title="定价管理"
|
||||
description="配置模型使用成本倍率"
|
||||
description="配置模型使用成本倍率,基础配额单位:1M tokens = $1.00"
|
||||
actions={
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => refetch()} disabled={isFetching}>
|
||||
@@ -198,15 +198,6 @@ export function Pricing() {
|
||||
</div>
|
||||
}
|
||||
>
|
||||
{/* Info */}
|
||||
<div className="mb-4 px-4 py-3 rounded-lg bg-muted/50 text-sm text-muted-foreground flex items-start gap-2.5">
|
||||
<Info className="h-4 w-4 mt-0.5 flex-shrink-0 text-primary" />
|
||||
<span>
|
||||
基础配额单位:<span className="font-mono text-foreground">1M tokens = $1.00</span>。
|
||||
倍率表示相对成本,如输入倍率 30 = 每百万 token 消耗 $30 配额。`按次` 为固定扣费,同样使用该配额单位,例如 15000 = $0.015 / 次。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{editMode === 'table' ? (
|
||||
isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
@@ -216,12 +207,12 @@ export function Pricing() {
|
||||
</div>
|
||||
</div>
|
||||
) : pricingRows.length === 0 ? (
|
||||
<div className="rounded-xl border bg-white shadow-sm flex flex-col items-center justify-center py-16">
|
||||
<div className="w-16 h-16 rounded-2xl bg-muted flex items-center justify-center mb-4">
|
||||
<DollarSign className="h-8 w-8 text-muted-foreground" />
|
||||
<div className="rounded-xl border bg-card shadow-sm flex flex-col items-center justify-center py-16">
|
||||
<div className="w-14 h-14 rounded-2xl bg-primary/10 flex items-center justify-center mb-4">
|
||||
<DollarSign className="h-7 w-7 text-primary" />
|
||||
</div>
|
||||
<h3 className="font-semibold text-lg mb-1">暂无定价配置</h3>
|
||||
<p className="text-muted-foreground text-sm mb-4">添加模型定价倍率</p>
|
||||
<p className="text-muted-foreground text-sm text-center max-w-sm mb-6">可调整倍率控制输入/输出成本,或者设置按次扣费。</p>
|
||||
<Button onClick={addRow}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
添加模型
|
||||
|
||||
Reference in new issue
Block a user