feat(ui): 添加选择组件并优化现有组件样式

This commit is contained in:
Jeff Gu committed 2026-04-09 21:47:25 +08:00
1 parent e5363843af
commit f0e3a349eb
7 files changed
+200 -42

No files matched your search

+1
View File
@@ -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",
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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}
+185 -19
View File
@@ -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 };
+1 -1
View File
@@ -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}
+6 -6
View File
@@ -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"
+5 -14
View File
@@ -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" />
添加模型