Files
mai-bot/dashboard/src/routes/config/pack-detail.tsx
2026-03-14 21:06:36 +08:00

933 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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,
DialogBody,
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 aria-label="API 提供商配置列表">
<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 aria-label="模型配置列表">
<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" confirmOnEnter>
<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>
<DialogBody>
{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>
)}
</>
)}
</DialogBody>
<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 data-dialog-action="confirm" onClick={() => setStep(step + 1)} disabled={detectingConflicts}>
</Button>
) : (
<Button data-dialog-action="confirm" 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>
)
}