上传完整的WebUI前端仓库

This commit is contained in:
墨梓柒
2026-01-13 06:24:35 +08:00
parent a9187dc312
commit 812296590e
184 changed files with 47854 additions and 1 deletions

View File

@@ -0,0 +1,929 @@
/**
* Pack 详情页面
*
* 查看 Pack 详情并应用到本地配置
*/
import { useState, useEffect, useCallback } from 'react'
import { useNavigate } from '@tanstack/react-router'
import { packDetailRoute } from '@/router'
import {
Package,
ArrowLeft,
Download,
Heart,
Clock,
User,
Server,
Layers,
ListChecks,
Tag,
Check,
AlertTriangle,
Info,
ChevronRight,
Key,
Settings,
Loader2,
} from 'lucide-react'
import { Button } from '@/components/ui/button'
import { Badge } from '@/components/ui/badge'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Checkbox } from '@/components/ui/checkbox'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { Skeleton } from '@/components/ui/skeleton'
import { ScrollArea } from '@/components/ui/scroll-area'
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import {
Accordion,
AccordionContent,
AccordionItem,
AccordionTrigger,
} from '@/components/ui/accordion'
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table'
import { Separator } from '@/components/ui/separator'
import { toast } from '@/hooks/use-toast'
import {
getPack,
recordPackDownload,
togglePackLike,
checkPackLike,
detectPackConflicts,
applyPack,
getPackUserId,
type ModelPack,
type ApplyPackOptions,
type ApplyPackConflicts,
} from '@/lib/pack-api'
// 任务类型名称映射
const TASK_TYPE_NAMES: Record<string, string> = {
utils: '通用工具',
utils_small: '轻量工具',
tool_use: '工具调用',
replyer: '回复生成',
planner: '规划推理',
vlm: '视觉模型',
voice: '语音处理',
embedding: '向量嵌入',
lpmm_entity_extract: '实体提取',
lpmm_rdf_build: 'RDF构建',
lpmm_qa: '问答模型',
}
export default function PackDetailPage() {
const { packId } = packDetailRoute.useParams()
const navigate = useNavigate()
const [pack, setPack] = useState<ModelPack | null>(null)
const [loading, setLoading] = useState(true)
const [liked, setLiked] = useState(false)
const [liking, setLiking] = useState(false)
// 应用向导状态
const [showApplyDialog, setShowApplyDialog] = useState(false)
const [applyStep, setApplyStep] = useState(1)
const [conflicts, setConflicts] = useState<ApplyPackConflicts | null>(null)
const [detectingConflicts, setDetectingConflicts] = useState(false)
const [applying, setApplying] = useState(false)
// 应用选项
const [applyOptions, setApplyOptions] = useState<ApplyPackOptions>({
apply_providers: true,
apply_models: true,
apply_task_config: true,
task_mode: 'append',
selected_providers: undefined,
selected_models: undefined,
selected_tasks: undefined,
})
// 提供商映射和 API Key
const [providerMapping, setProviderMapping] = useState<Record<string, string>>({})
const [newProviderApiKeys, setNewProviderApiKeys] = useState<Record<string, string>>({})
const userId = getPackUserId()
// 加载 Pack
const loadPack = useCallback(async () => {
if (!packId) return
setLoading(true)
try {
const data = await getPack(packId)
setPack(data)
const isLiked = await checkPackLike(packId, userId)
setLiked(isLiked)
} catch (error) {
console.error('加载 Pack 失败:', error)
toast({ title: '加载模板失败', variant: 'destructive' })
} finally {
setLoading(false)
}
}, [packId, userId])
useEffect(() => {
loadPack()
}, [loadPack])
// 点赞
const handleLike = async () => {
if (!packId || liking) return
setLiking(true)
try {
const result = await togglePackLike(packId, userId)
setLiked(result.liked)
if (pack) {
setPack({ ...pack, likes: result.likes })
}
} catch (error) {
console.error('点赞失败:', error)
toast({ title: '点赞失败', variant: 'destructive' })
} finally {
setLiking(false)
}
}
// 开始应用流程
const startApply = async () => {
if (!pack) return
setShowApplyDialog(true)
setApplyStep(1)
setDetectingConflicts(true)
try {
const detected = await detectPackConflicts(pack)
setConflicts(detected)
// 初始化提供商映射(已存在的提供商默认使用第一个匹配的本地提供商)
const mapping: Record<string, string> = {}
for (const c of detected.existing_providers) {
mapping[c.pack_provider.name] = c.local_providers[0].name
}
setProviderMapping(mapping)
// 初始化新提供商的 API Key
const keys: Record<string, string> = {}
for (const p of detected.new_providers) {
keys[p.name] = ''
}
setNewProviderApiKeys(keys)
} catch (error) {
console.error('检测冲突失败:', error)
toast({ title: '检测配置冲突失败', variant: 'destructive' })
setShowApplyDialog(false)
} finally {
setDetectingConflicts(false)
}
}
// 执行应用
const executeApply = async () => {
if (!pack) return
// 验证新提供商都有 API Key
if (applyOptions.apply_providers && conflicts) {
for (const p of conflicts.new_providers) {
if (!newProviderApiKeys[p.name]) {
toast({ title: `请填写提供商 "${p.name}" 的 API Key`, variant: 'destructive' })
return
}
}
}
setApplying(true)
try {
await applyPack(pack, applyOptions, providerMapping, newProviderApiKeys)
// 记录下载
await recordPackDownload(pack.id, userId)
// 更新下载数
setPack({ ...pack, downloads: pack.downloads + 1 })
toast({ title: '配置模板应用成功!' })
setShowApplyDialog(false)
} catch (error) {
console.error('应用 Pack 失败:', error)
toast({ title: error instanceof Error ? error.message : '应用配置失败', variant: 'destructive' })
} finally {
setApplying(false)
}
}
// 格式化日期
const formatDate = (dateStr: string) => {
const date = new Date(dateStr)
return date.toLocaleDateString('zh-CN', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
if (loading) {
return <PackDetailSkeleton />
}
if (!pack) {
return (
<div className="text-center py-12">
<Package className="w-16 h-16 mx-auto mb-4 opacity-50" />
<h2 className="text-xl font-semibold"></h2>
<p className="text-muted-foreground mt-2"></p>
<Button className="mt-4" onClick={() => navigate({ to: '/config/pack-market' })}>
<ArrowLeft className="w-4 h-4 mr-2" />
</Button>
</div>
)
}
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
<ScrollArea className="flex-1">
<div className="space-y-4 sm:space-y-6">
{/* 返回按钮 */}
<Button variant="ghost" size="sm" onClick={() => navigate({ to: '/config/pack-market' })} className="gap-2">
<ArrowLeft className="w-4 h-4" />
</Button>
{/* 头部信息 */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="flex items-start gap-3">
<Package className="w-10 h-10 text-primary mt-1" />
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
{pack.name}
<Badge variant="secondary">v{pack.version}</Badge>
</h1>
<p className="text-muted-foreground mt-1">{pack.description}</p>
</div>
</div>
{/* 元信息 */}
<div className="flex flex-wrap gap-4 text-sm text-muted-foreground">
<span className="flex items-center gap-1">
<User className="w-4 h-4" />
{pack.author}
</span>
<span className="flex items-center gap-1">
<Clock className="w-4 h-4" />
{formatDate(pack.created_at)}
</span>
<span className="flex items-center gap-1">
<Download className="w-4 h-4" />
{pack.downloads}
</span>
<span className="flex items-center gap-1">
<Heart className={`w-4 h-4 ${liked ? 'fill-red-500 text-red-500' : ''}`} />
{pack.likes}
</span>
</div>
{/* 标签 */}
{pack.tags && pack.tags.length > 0 && (
<div className="flex flex-wrap gap-2">
{pack.tags.map(tag => (
<Badge key={tag} variant="outline">
<Tag className="w-3 h-3 mr-1" />
{tag}
</Badge>
))}
</div>
)}
</div>
{/* 操作按钮 */}
<div className="flex flex-col gap-2 min-w-[160px]">
<Button size="lg" onClick={startApply}>
<Download className="w-4 h-4 mr-2" />
</Button>
<Button
variant="outline"
onClick={handleLike}
disabled={liking}
className={liked ? 'text-red-500 border-red-200' : ''}
>
<Heart className={`w-4 h-4 mr-2 ${liked ? 'fill-current' : ''}`} />
{liked ? '已点赞' : '点赞'}
</Button>
</div>
</div>
<Separator />
{/* 内容统计 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Card>
<CardContent className="flex items-center gap-3 py-4">
<Server className="w-8 h-8 text-blue-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{pack.providers.length}</p>
<p className="text-sm text-muted-foreground">API </p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<Layers className="w-8 h-8 text-green-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{pack.models.length}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
<Card>
<CardContent className="flex items-center gap-3 py-4">
<ListChecks className="w-8 h-8 text-purple-500 flex-shrink-0" />
<div>
<p className="text-2xl font-bold">{Object.keys(pack.task_config).length}</p>
<p className="text-sm text-muted-foreground"></p>
</div>
</CardContent>
</Card>
</div>
{/* 详细内容 */}
<Tabs defaultValue="providers" className="space-y-4">
<TabsList className="w-full sm:w-auto grid grid-cols-3 sm:flex">
<TabsTrigger value="providers" className="gap-1 sm:gap-2">
<Server className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({pack.providers.length})</span>
</TabsTrigger>
<TabsTrigger value="models" className="gap-1 sm:gap-2">
<Layers className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({pack.models.length})</span>
</TabsTrigger>
<TabsTrigger value="tasks" className="gap-1 sm:gap-2">
<ListChecks className="w-4 h-4" />
<span className="hidden sm:inline"></span>
<span className="sm:hidden"></span>
<span className="hidden sm:inline">({Object.keys(pack.task_config).length})</span>
</TabsTrigger>
</TabsList>
<TabsContent value="providers">
<Card>
<CardHeader>
<CardTitle>API </CardTitle>
<CardDescription> API API Key</CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead>Base URL</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pack.providers.map(provider => (
<TableRow key={provider.name}>
<TableCell className="font-medium whitespace-nowrap">{provider.name}</TableCell>
<TableCell className="text-muted-foreground font-mono text-sm max-w-[200px] truncate">
{provider.base_url}
</TableCell>
<TableCell>
<Badge variant="outline">{provider.client_type}</Badge>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="models">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<Table>
<TableHeader>
<TableRow>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead className="text-right"> (/)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{pack.models.map(model => (
<TableRow key={model.name}>
<TableCell className="font-medium whitespace-nowrap">{model.name}</TableCell>
<TableCell className="text-muted-foreground font-mono text-sm max-w-[150px] truncate">
{model.model_identifier}
</TableCell>
<TableCell className="whitespace-nowrap">{model.api_provider}</TableCell>
<TableCell className="text-right text-muted-foreground whitespace-nowrap">
¥{model.price_in} / ¥{model.price_out}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
</CardContent>
</Card>
</TabsContent>
<TabsContent value="tasks">
<Card>
<CardHeader>
<CardTitle></CardTitle>
<CardDescription></CardDescription>
</CardHeader>
<CardContent>
<Accordion type="multiple" className="w-full">
{Object.entries(pack.task_config).map(([taskKey, config]) => (
<AccordionItem key={taskKey} value={taskKey}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Settings className="w-4 h-4" />
{TASK_TYPE_NAMES[taskKey] || taskKey}
<Badge variant="secondary" className="ml-2">
{config.model_list.length}
</Badge>
</div>
</AccordionTrigger>
<AccordionContent>
<div className="space-y-2 pl-6">
<div className="text-sm text-muted-foreground">
</div>
<div className="flex flex-wrap gap-2">
{config.model_list.map((model: string) => (
<Badge key={model} variant="outline">{model}</Badge>
))}
</div>
{config.temperature !== undefined && (
<div className="text-sm">
Temperature: <span className="font-mono">{config.temperature}</span>
</div>
)}
{config.max_tokens !== undefined && (
<div className="text-sm">
Max Tokens: <span className="font-mono">{config.max_tokens}</span>
</div>
)}
</div>
</AccordionContent>
</AccordionItem>
))}
</Accordion>
</CardContent>
</Card>
</TabsContent>
</Tabs>
{/* 应用向导对话框 */}
<ApplyDialog
open={showApplyDialog}
onOpenChange={setShowApplyDialog}
pack={pack}
step={applyStep}
setStep={setApplyStep}
conflicts={conflicts}
detectingConflicts={detectingConflicts}
applying={applying}
options={applyOptions}
setOptions={setApplyOptions}
_providerMapping={providerMapping}
_setProviderMapping={setProviderMapping}
newProviderApiKeys={newProviderApiKeys}
setNewProviderApiKeys={setNewProviderApiKeys}
onApply={executeApply}
/>
</div>
</ScrollArea>
</div>
)
}
// 应用向导对话框
function ApplyDialog({
open,
onOpenChange,
pack,
step,
setStep,
conflicts,
detectingConflicts,
applying,
options,
setOptions,
_providerMapping,
_setProviderMapping,
newProviderApiKeys,
setNewProviderApiKeys,
onApply,
}: {
open: boolean
onOpenChange: (open: boolean) => void
pack: ModelPack
step: number
setStep: (step: number) => void
conflicts: ApplyPackConflicts | null
detectingConflicts: boolean
applying: boolean
options: ApplyPackOptions
setOptions: (options: ApplyPackOptions) => void
_providerMapping: Record<string, string>
_setProviderMapping: (mapping: Record<string, string>) => void
newProviderApiKeys: Record<string, string>
setNewProviderApiKeys: (keys: Record<string, string>) => void
onApply: () => void
}) {
const totalSteps = 3
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Package className="w-5 h-5" />
</DialogTitle>
<DialogDescription>
{step} / {totalSteps}
{step === 1 && '选择要应用的内容'}
{step === 2 && '配置提供商映射'}
{step === 3 && '确认并应用'}
</DialogDescription>
</DialogHeader>
{detectingConflicts ? (
<div className="py-8 text-center">
<Loader2 className="w-8 h-8 mx-auto animate-spin text-primary" />
<p className="mt-4 text-muted-foreground">...</p>
</div>
) : (
<>
{/* 步骤 1: 选择内容 */}
{step === 1 && (
<div className="space-y-4">
<div className="space-y-3">
<div className="flex items-center space-x-2">
<Checkbox
id="apply_providers"
checked={options.apply_providers}
onCheckedChange={checked =>
setOptions({ ...options, apply_providers: checked as boolean })
}
/>
<Label htmlFor="apply_providers" className="flex items-center gap-2">
<Server className="w-4 h-4" />
({pack.providers.length} )
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="apply_models"
checked={options.apply_models}
onCheckedChange={checked =>
setOptions({ ...options, apply_models: checked as boolean })
}
/>
<Label htmlFor="apply_models" className="flex items-center gap-2">
<Layers className="w-4 h-4" />
({pack.models.length} )
</Label>
</div>
<div className="flex items-center space-x-2">
<Checkbox
id="apply_task_config"
checked={options.apply_task_config}
onCheckedChange={checked =>
setOptions({ ...options, apply_task_config: checked as boolean })
}
/>
<Label htmlFor="apply_task_config" className="flex items-center gap-2">
<ListChecks className="w-4 h-4" />
({Object.keys(pack.task_config).length} )
</Label>
</div>
</div>
{options.apply_task_config && (
<div className="pl-6 space-y-2 border-l-2 border-muted">
<Label className="text-sm font-medium"></Label>
<RadioGroup
value={options.task_mode}
onValueChange={value =>
setOptions({ ...options, task_mode: value as 'replace' | 'append' })
}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="append" id="mode_append" />
<Label htmlFor="mode_append" className="font-normal">
-
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="replace" id="mode_replace" />
<Label htmlFor="mode_replace" className="font-normal">
-
</Label>
</div>
</RadioGroup>
</div>
)}
</div>
)}
{/* 步骤 2: 提供商映射 */}
{step === 2 && conflicts && (
<div className="space-y-4">
{/* 已存在的提供商 */}
{options.apply_providers && conflicts.existing_providers.length > 0 && (
<div className="space-y-3">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
URL 使
</AlertDescription>
</Alert>
<div className="space-y-2">
{conflicts.existing_providers.map(({ pack_provider, local_providers }) => (
<div
key={pack_provider.name}
className="flex items-center gap-2 p-3 bg-muted rounded-lg"
>
<Check className="w-4 h-4 text-green-500 flex-shrink-0" />
<span className="font-medium flex-shrink-0">{pack_provider.name}</span>
<ChevronRight className="w-4 h-4 text-muted-foreground flex-shrink-0" />
{local_providers.length === 1 ? (
<>
<span className="text-muted-foreground">{local_providers[0].name}</span>
<Badge variant="outline" className="ml-auto">URL </Badge>
</>
) : (
<>
<Select
value={_providerMapping[pack_provider.name] || local_providers[0].name}
onValueChange={value =>
_setProviderMapping({
..._providerMapping,
[pack_provider.name]: value,
})
}
>
<SelectTrigger className="w-[200px]">
<SelectValue />
</SelectTrigger>
<SelectContent>
{local_providers.map(p => (
<SelectItem key={p.name} value={p.name}>
{p.name}
</SelectItem>
))}
</SelectContent>
</Select>
<Badge variant="outline" className="ml-auto">
{local_providers.length}
</Badge>
</>
)}
</div>
))}
</div>
</div>
)}
{/* 新提供商 */}
{options.apply_providers && conflicts.new_providers.length > 0 && (
<div className="space-y-3">
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertTitle> API Key</AlertTitle>
<AlertDescription>
API Key
</AlertDescription>
</Alert>
<div className="space-y-4">
{conflicts.new_providers.map(provider => (
<div key={provider.name} className="space-y-2">
<div className="flex items-center gap-2">
<Key className="w-4 h-4 text-amber-500" />
<span className="font-medium">{provider.name}</span>
<span className="text-xs text-muted-foreground">
({provider.base_url})
</span>
</div>
<Input
type="password"
placeholder={`输入 ${provider.name} 的 API Key`}
value={newProviderApiKeys[provider.name] || ''}
onChange={e =>
setNewProviderApiKeys({
...newProviderApiKeys,
[provider.name]: e.target.value,
})
}
/>
</div>
))}
</div>
</div>
)}
{(!options.apply_providers || (conflicts.existing_providers.length === 0 && conflicts.new_providers.length === 0)) && (
<Alert>
<Check className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
)}
</div>
)}
{/* 步骤 3: 确认 */}
{step === 3 && (
<div className="space-y-4">
<Alert>
<Info className="h-4 w-4" />
<AlertTitle></AlertTitle>
<AlertDescription>
</AlertDescription>
</Alert>
<div className="space-y-2">
{options.apply_providers && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<Server className="w-4 h-4" />
<span> {pack.providers.length} </span>
</div>
)}
{options.apply_models && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<Layers className="w-4 h-4" />
<span> {pack.models.length} </span>
</div>
)}
{options.apply_task_config && (
<div className="flex items-center gap-2 text-sm">
<Check className="w-4 h-4 text-green-500" />
<ListChecks className="w-4 h-4" />
<span>
{options.task_mode === 'append' ? '追加' : '替换'} {Object.keys(pack.task_config).length}
</span>
</div>
)}
</div>
{conflicts && conflicts.new_providers.length > 0 && (
<Alert variant="destructive">
<AlertTriangle className="h-4 w-4" />
<AlertDescription>
{conflicts.new_providers.length} API Key
</AlertDescription>
</Alert>
)}
</div>
)}
</>
)}
<DialogFooter className="flex justify-between">
<div>
{step > 1 && !detectingConflicts && (
<Button variant="outline" onClick={() => setStep(step - 1)} disabled={applying}>
</Button>
)}
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={applying}>
</Button>
{step < totalSteps ? (
<Button onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
</Button>
) : (
<Button onClick={onApply} disabled={applying}>
{applying && <Loader2 className="w-4 h-4 mr-2 animate-spin" />}
</Button>
)}
</div>
</DialogFooter>
</DialogContent>
</Dialog>
)
}
// 加载骨架
function PackDetailSkeleton() {
return (
<div className="h-[calc(100vh-4rem)] flex flex-col p-4 sm:p-6">
<ScrollArea className="flex-1">
<div className="space-y-4 sm:space-y-6">
{/* 返回按钮 */}
<Skeleton className="h-9 w-24" />
{/* 头部信息 */}
<div className="flex flex-col md:flex-row gap-6">
<div className="flex-1 space-y-4">
<div className="flex items-start gap-3">
<Skeleton className="w-10 h-10" />
<div className="flex-1 space-y-2">
<Skeleton className="h-8 w-2/3" />
<Skeleton className="h-4 w-full" />
</div>
</div>
{/* 元信息 */}
<div className="flex flex-wrap gap-4">
<Skeleton className="h-4 w-24" />
<Skeleton className="h-4 w-32" />
<Skeleton className="h-4 w-28" />
<Skeleton className="h-4 w-20" />
</div>
{/* 标签 */}
<div className="flex flex-wrap gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-6 w-24" />
<Skeleton className="h-6 w-16" />
</div>
</div>
{/* 操作按钮 */}
<div className="flex flex-col gap-2 min-w-[160px]">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</div>
</div>
<Skeleton className="h-px w-full" />
{/* 内容统计卡片 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
{/* Tabs */}
<div className="space-y-4">
<div className="flex gap-2">
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
<Skeleton className="h-10 w-32" />
</div>
<Skeleton className="h-96 w-full" />
</div>
</div>
</ScrollArea>
</div>
)
}