style(ui): 优化组件样式和代码格式

This commit is contained in:
Jeff Gu committed 2026-04-12 17:15:13 +08:00
1 parent 45fd61e285
commit 37cbaa4283
9 files changed
+152 -160

No files matched your search

+19 -29
View File
@@ -1,23 +1,20 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
import * as React from "react";
import { Slot } from "@radix-ui/react-slot";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all duration-200 focus-visible:outline-none focus-visible:shadow-md disabled:pointer-events-none disabled:opacity-50 disabled:cursor-not-allowed cursor-pointer active:scale-[0.97] [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground shadow-sm hover:bg-primary/90 hover:shadow-md",
destructive:
"bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90",
outline:
"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",
default: "bg-primary text-primary-foreground hover:bg-primary/90 hover:shadow-md",
destructive: "bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline: "border border-input hover:bg-accent hover:text-accent-foreground hover:border-accent-foreground/20",
secondary: "border border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
success: "bg-success text-success-foreground shadow-sm hover:bg-success/90",
success: "bg-success text-success-foreground hover:bg-success/90",
},
size: {
default: "h-10 px-4 py-2",
@@ -30,27 +27,20 @@ const buttonVariants = cva(
variant: "default",
size: "default",
},
}
)
},
);
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
extends React.ButtonHTMLAttributes<HTMLButtonElement>, VariantProps<typeof buttonVariants> {
asChild?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
const Comp = asChild ? Slot : "button";
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />;
},
);
Button.displayName = "Button";
export { Button, buttonVariants }
export { Button, buttonVariants };
+1 -1
View File
@@ -8,7 +8,7 @@ const Card = React.forwardRef<
<div
ref={ref}
className={cn(
"rounded-md border bg-card text-card-foreground shadow-soft transition-shadow duration-200",
"rounded-md bg-card text-card-foreground shadow-soft transition-shadow duration-200",
className
)}
{...props}
+1 -1
View File
@@ -35,7 +35,7 @@ const DialogContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-2xl duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-card p-6 shadow-2xl duration-200 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-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
+9 -9
View File
@@ -1,5 +1,5 @@
import * as React from "react"
import { cn } from "@/lib/utils"
import * as React from "react";
import { cn } from "@/lib/utils";
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
@@ -7,15 +7,15 @@ const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md 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
"flex h-10 w-full rounded-md border border-input bg-card px-3 py-2 text-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:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
);
},
);
Input.displayName = "Input";
export { Input }
export { Input };
+1 -1
View File
@@ -18,7 +18,7 @@ interface SelectOption {
}
const TRIGGER_BASE_CLASS =
'group relative flex h-10 w-full items-center rounded-md border border-input bg-transparent px-3 py-2 pr-10 text-sm text-foreground shadow-sm outline-none transition-all duration-200 hover:border-muted-foreground/30 focus-visible:border-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50'
'group relative flex h-10 w-full items-center rounded-md border border-input bg-transparent px-3 py-2 pr-10 text-sm text-foreground transition-all duration-200 hover:border-muted-foreground/30 focus-visible:border-primary/50 disabled:cursor-not-allowed disabled:opacity-50'
const CONTENT_BASE_CLASS =
'absolute z-50 mt-1 w-full overflow-hidden rounded-md border border-border bg-popover text-popover-foreground shadow-lg'
+1 -1
View File
@@ -8,7 +8,7 @@ const Textarea = React.forwardRef<
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md 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",
"flex min-h-[80px] w-full rounded-md border border-input px-3 py-2 text-sm transition-all duration-200 placeholder:text-muted-foreground focus-visible:border-primary/50 hover:border-muted-foreground/30 disabled:cursor-not-allowed disabled:opacity-50",
className
)}
ref={ref}
+10 -10
View File
@@ -55,23 +55,23 @@
}
.dark {
--color-background: hsl(224 71% 4%);
--color-background: #0e0f11;
--color-foreground: hsl(210 20% 98%);
--color-card: hsl(224 50% 8%);
--color-card: #131416;
--color-card-foreground: hsl(210 20% 98%);
--color-popover: hsl(224 50% 8%);
--color-popover: #0e0f11;
--color-popover-foreground: hsl(210 20% 98%);
--color-primary: hsl(234 89% 67%);
--color-primary: #1c1e20;
--color-primary-foreground: hsl(0 0% 100%);
--color-secondary: hsl(224 40% 12%);
--color-secondary: #1c1e20;
--color-secondary-foreground: hsl(210 20% 98%);
--color-muted: hsl(224 40% 12%);
--color-muted: #1c1e20;
--color-muted-foreground: hsl(220 9% 56%);
--color-accent: hsl(224 40% 12%);
--color-accent: #191b1e;
--color-accent-foreground: hsl(210 20% 98%);
--color-destructive: hsl(0 72% 51%);
@@ -86,13 +86,13 @@
--color-info: hsl(199 89% 42%);
--color-info-foreground: hsl(0 0% 98%);
--color-border: hsl(224 30% 16%);
--color-input: hsl(224 30% 16%);
--color-border: #1c1d20;
--color-input: #1c1d20;
--color-sidebar: hsl(224 71% 3%);
--color-sidebar-foreground: hsl(210 20% 98%);
--color-sidebar-muted: hsl(224 30% 10%);
--color-sidebar-accent: hsl(234 89% 67%);
--color-sidebar-accent: #363738;
}
@layer base {
+1 -1
View File
@@ -1245,7 +1245,7 @@ export function Channels({ createMode = false, editRoute = false }: { createMode
</div>
<div className="">
<Label className="text-sm">渠道状态</Label>
<div className="h-10 flex items-center gap-3 mb-2 rounded-md px-3 border border-input shadow-sm">
<div className="h-10 flex items-center gap-3 mb-2 rounded-md px-3 border border-input hover:border-muted-foreground/30">
<span
className={cn(
"text-sm font-medium",
+109 -107
View File
@@ -1,64 +1,55 @@
import { useState, useEffect } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { apiClient } from '@/api/client'
import { Channel, PricingConfig } from '@/types'
import { AutoCompleteInput, type AutoCompleteOption } from '@/components/ui/autocomplete'
import { Button } from '@/components/ui/button'
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 { cn } from '@/lib/utils'
import { getUniqueModelNamesFromChannels } from '@/lib/channel-models'
import {
Plus,
RefreshCw,
Trash2,
FileJson,
FileText,
DollarSign,
Check,
Info,
Search,
} from 'lucide-react'
import { PageContainer } from '@/components/ui/page-container'
import { useState, useEffect } from "react";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { apiClient } from "@/api/client";
import { Channel, PricingConfig } from "@/types";
import { AutoCompleteInput, type AutoCompleteOption } from "@/components/ui/autocomplete";
import { Button } from "@/components/ui/button";
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 { cn } from "@/lib/utils";
import { getUniqueModelNamesFromChannels } from "@/lib/channel-models";
import { Plus, RefreshCw, Trash2, FileJson, FileText, DollarSign, Check, Info, Search } from "lucide-react";
import { PageContainer } from "@/components/ui/page-container";
import { Card } from "@/components/ui/card";
type EditMode = 'table' | 'json'
type EditMode = "table" | "json";
export function Pricing() {
const [editMode, setEditMode] = useState<EditMode>('table')
const [jsonValue, setJsonValue] = useState('')
const [editMode, setEditMode] = useState<EditMode>("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<AutoCompleteOption[]>([])
>([]);
const [searchQuery, setSearchQuery] = useState("");
const [modelOptions, setModelOptions] = useState<AutoCompleteOption[]>([]);
const { addToast } = useToast()
const queryClient = useQueryClient()
const { addToast } = useToast();
const queryClient = useQueryClient();
const { data, isLoading, isFetching, refetch } = useQuery({
queryKey: ['pricing'],
queryKey: ["pricing"],
queryFn: async () => {
const response = await apiClient.getPricing()
return response.data as PricingConfig
const response = await apiClient.getPricing();
return response.data as PricingConfig;
},
})
});
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 })))
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)
}
console.error("Failed to load channels for pricing suggestions:", error);
}
};
loadChannelModels()
}, [])
loadChannelModels();
}, []);
useEffect(() => {
if (data) {
@@ -68,35 +59,35 @@ export function Pricing() {
output: pricing.output,
cache: pricing.cache || 0,
request: pricing.request || 0,
}))
setPricingRows(rows)
setJsonValue(JSON.stringify(data, null, 2))
}));
setPricingRows(rows);
setJsonValue(JSON.stringify(data, null, 2));
}
}, [data])
}, [data]);
const saveMutation = useMutation({
mutationFn: async (config: PricingConfig) => {
return apiClient.savePricing(config)
return apiClient.savePricing(config);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['pricing'] })
addToast('定价配置已保存', 'success')
queryClient.invalidateQueries({ queryKey: ["pricing"] });
addToast("定价配置已保存", "success");
},
onError: (error: Error) => {
addToast('保存失败:' + error.message, 'error')
addToast("保存失败:" + error.message, "error");
},
})
});
const handleSave = () => {
let config: PricingConfig
let config: PricingConfig;
if (editMode === 'table') {
if (editMode === "table") {
if (pricingRows.length === 0) {
addToast('请至少添加一个模型定价', 'error')
return
addToast("请至少添加一个模型定价", "error");
return;
}
config = {}
config = {};
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
@@ -104,24 +95,24 @@ export function Pricing() {
output: row.output || 0,
cache: row.cache || 0,
request: row.request || 0,
};
}
}
})
});
} else {
try {
config = JSON.parse(jsonValue)
config = JSON.parse(jsonValue);
} catch {
addToast('JSON格式错误', 'error')
return
addToast("JSON格式错误", "error");
return;
}
}
saveMutation.mutate(config)
}
saveMutation.mutate(config);
};
const toggleEditMode = () => {
if (editMode === 'table') {
const config: PricingConfig = {}
if (editMode === "table") {
const config: PricingConfig = {};
pricingRows.forEach((row) => {
if (row.model) {
config[row.model] = {
@@ -129,46 +120,50 @@ export function Pricing() {
output: row.output || 0,
cache: row.cache || 0,
request: row.request || 0,
};
}
}
})
setJsonValue(JSON.stringify(config, null, 2))
setEditMode('json')
});
setJsonValue(JSON.stringify(config, null, 2));
setEditMode("json");
} else {
try {
const config = JSON.parse(jsonValue)
const config = JSON.parse(jsonValue);
const rows = Object.entries(config).map(([model, pricing]: [string, unknown]) => ({
model,
input: (pricing as { input: number }).input || 0,
output: (pricing as { output: number }).output || 0,
cache: (pricing as { cache?: number }).cache || 0,
request: (pricing as { request?: number }).request || 0,
}))
setPricingRows(rows)
setEditMode('table')
}));
setPricingRows(rows);
setEditMode("table");
} catch {
addToast('JSON格式错误', 'error')
}
addToast("JSON格式错误", "error");
}
}
};
const addRow = () => {
setPricingRows([...pricingRows, { model: '', input: 0, output: 0, cache: 0, request: 0 }])
}
setPricingRows([...pricingRows, { model: "", input: 0, output: 0, cache: 0, request: 0 }]);
};
const removeRow = (index: number) => {
setPricingRows(pricingRows.filter((_, i) => i !== index))
}
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 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()))
.filter((row) => row.model.toLowerCase().includes(searchQuery.toLowerCase()));
const pricingModelOptions = [
...pricingRows
@@ -176,7 +171,7 @@ export function Pricing() {
.filter((model) => model.length > 0)
.map((model) => ({ value: model })),
...modelOptions,
].filter((option, index, options) => options.findIndex((candidate) => candidate.value === option.value) === index)
].filter((option, index, options) => options.findIndex((candidate) => candidate.value === option.value) === index);
return (
<PageContainer
@@ -188,8 +183,8 @@ export function Pricing() {
<RefreshCw className={cn("h-4 w-4", isFetching && "animate-spin")} />
</Button>
<Button variant="outline" size="sm" onClick={toggleEditMode}>
{editMode === 'table' ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
<span className="hidden sm:inline ml-1">{editMode === 'table' ? 'JSON' : '表格'}</span>
{editMode === "table" ? <FileJson className="h-4 w-4" /> : <FileText className="h-4 w-4" />}
<span className="hidden sm:inline ml-1">{editMode === "table" ? "JSON" : "表格"}</span>
</Button>
<Button size="sm" onClick={handleSave} disabled={saveMutation.isPending}>
<Check className="h-4 w-4 mr-1" />
@@ -198,7 +193,7 @@ export function Pricing() {
</div>
}
>
{editMode === 'table' ? (
{editMode === "table" ? (
isLoading ? (
<div className="flex items-center justify-center py-20">
<div className="flex flex-col items-center gap-3">
@@ -207,17 +202,19 @@ export function Pricing() {
</div>
</div>
) : pricingRows.length === 0 ? (
<div className="rounded-md border bg-card shadow-sm flex flex-col items-center justify-center py-16">
<Card className="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 text-center max-w-sm mb-6">可调整倍率控制输入/输出成本,或者设置按次扣费。</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" />
添加模型
</Button>
</div>
</Card>
) : (
<div className="space-y-3">
{/* Search */}
@@ -234,9 +231,12 @@ export function Pricing() {
)}
{/* Table */}
<div className="rounded-md border bg-card">
<Card>
{/* Header */}
<div className="grid gap-2 px-4 py-2.5 border-b bg-muted/30" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
<div
className="grid gap-2 px-4 py-2.5 border-b bg-muted/30"
style={{ gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 40px" }}
>
<span className="text-xs font-medium text-muted-foreground">模型名称</span>
<span className="text-xs font-medium text-muted-foreground">输入倍率</span>
<span className="text-xs font-medium text-muted-foreground">输出倍率</span>
@@ -248,10 +248,14 @@ export function Pricing() {
{/* Rows */}
<div className="divide-y">
{filteredRows.map((row) => (
<div key={row._i} className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors" style={{ gridTemplateColumns: '2fr 1fr 1fr 1fr 1fr 40px' }}>
<div
key={row._i}
className="grid gap-2 px-4 py-2 items-center hover:bg-muted/20 transition-colors"
style={{ gridTemplateColumns: "2fr 1fr 1fr 1fr 1fr 40px" }}
>
<AutoCompleteInput
value={row.model}
onChange={(value) => updateRow(row._i, 'model', value)}
onChange={(value) => updateRow(row._i, "model", value)}
placeholder="模型名称"
inputClassName="font-mono text-sm h-8"
options={pricingModelOptions}
@@ -260,7 +264,7 @@ export function Pricing() {
<Input
type="number"
value={row.input}
onChange={(e) => updateRow(row._i, 'input', parseFloat(e.target.value) || 0)}
onChange={(e) => updateRow(row._i, "input", parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
@@ -268,7 +272,7 @@ export function Pricing() {
<Input
type="number"
value={row.output}
onChange={(e) => updateRow(row._i, 'output', parseFloat(e.target.value) || 0)}
onChange={(e) => updateRow(row._i, "output", parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
@@ -276,7 +280,7 @@ export function Pricing() {
<Input
type="number"
value={row.cache}
onChange={(e) => updateRow(row._i, 'cache', parseFloat(e.target.value) || 0)}
onChange={(e) => updateRow(row._i, "cache", parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
@@ -284,7 +288,7 @@ export function Pricing() {
<Input
type="number"
value={row.request}
onChange={(e) => updateRow(row._i, 'request', parseFloat(e.target.value) || 0)}
onChange={(e) => updateRow(row._i, "request", parseFloat(e.target.value) || 0)}
step="0.001"
min="0"
className="font-mono text-sm h-8"
@@ -303,11 +307,9 @@ export function Pricing() {
</div>
{filteredRows.length === 0 && searchQuery && (
<div className="py-8 text-center text-sm text-muted-foreground">
没有匹配的模型
</div>
<div className="py-8 text-center text-sm text-muted-foreground">没有匹配的模型</div>
)}
</div>
</Card>
{/* Add */}
<button
@@ -338,5 +340,5 @@ export function Pricing() {
</div>
)}
</PageContainer>
)
);
}