forked from admin/hair-keeper
297 lines
11 KiB
TypeScript
297 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { useState, useEffect, useCallback } from "react";
|
||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||
import { Badge } from "@/components/ui/badge";
|
||
import { Package, FileCode, Code, FileClock } from "lucide-react";
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||
import { SearchInput } from "@/components/common/search-input";
|
||
import { ResponsiveTabs, type ResponsiveTabItem } from "@/components/common/responsive-tabs";
|
||
import { trpc } from "@/lib/trpc";
|
||
import { Skeleton } from "@/components/ui/skeleton";
|
||
import { PackageAnalyzeDialog, PackageAnalyzeTrigger } from "./components/PackageAnalyzeDialog";
|
||
import { PackageDetailSheet } from "./components/PackageDetailSheet";
|
||
import { toast } from 'sonner'
|
||
import type { PackageData } from "@/server/routers/dev/arch";
|
||
import { formatDate } from "@/lib/format";
|
||
|
||
// 依赖包卡片组件
|
||
function PackageCard({ pkg, onClick }: { pkg: PackageData; onClick: () => void }) {
|
||
return (
|
||
<Card
|
||
className="shadow-sm hover:shadow-md transition-all duration-200 hover:border-primary/20 flex flex-col gap-4 cursor-pointer"
|
||
onClick={onClick}
|
||
>
|
||
<CardHeader className="pb-3">
|
||
<div className="flex items-start justify-between gap-2 min-w-0">
|
||
<CardTitle className="flex items-center gap-2 text-xl mb-1.5 min-w-0">
|
||
<Package className="size-4.5 shrink-0 text-primary" />
|
||
{pkg.homepage ? (
|
||
<a
|
||
href={pkg.homepage}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="truncate font-semibold bg-gradient-to-r from-purple-600 via-violet-600 to-indigo-600 dark:from-purple-400 dark:via-violet-400 dark:to-indigo-400 bg-clip-text text-transparent hover:opacity-80 transition-opacity"
|
||
>
|
||
{pkg.name}
|
||
</a>
|
||
) : (
|
||
<span className="truncate font-semibold bg-gradient-to-r from-purple-600 via-violet-600 to-indigo-600 dark:from-purple-400 dark:via-violet-400 dark:to-indigo-400 bg-clip-text text-transparent">
|
||
{pkg.name}
|
||
</span>
|
||
)}
|
||
{pkg.repositoryUrl && (
|
||
<a
|
||
href={pkg.repositoryUrl}
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="flex items-center gap-1 text-xs text-primary hover:text-primary/80 transition-colors font-medium"
|
||
>
|
||
<Code className="size-3" />
|
||
</a>
|
||
)}
|
||
</CardTitle>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<Badge variant="primary" size="sm" appearance="light" className="shrink-0 text-xs cursor-help">
|
||
v{pkg.version}
|
||
</Badge>
|
||
</TooltipTrigger>
|
||
<TooltipContent variant="light">
|
||
<div className="text-xs">
|
||
<div className="font-medium">{pkg.name} v{pkg.version}</div>
|
||
<div className="text-muted-foreground">更新于 {formatDate(pkg.modifiedAt)}</div>
|
||
</div>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
<CardDescription className="line-clamp-3 text-xs leading-snug">
|
||
{pkg.description}
|
||
</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="pt-0 flex flex-col flex-1">
|
||
<div className="bg-muted/30 rounded-md p-2.5 mb-3">
|
||
<div className="text-xs font-medium text-foreground mb-1 flex items-center gap-1.5">
|
||
<span className="size-1 rounded-full bg-primary" />
|
||
核心功能
|
||
</div>
|
||
<p className="text-xs leading-relaxed text-muted-foreground">{pkg.projectRoleSummary}</p>
|
||
</div>
|
||
<div className="flex items-center justify-between pt-2 border-t mt-auto">
|
||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||
<FileCode className="size-3 text-primary" />
|
||
<span className="font-medium">{pkg.relatedFileCount}</span>
|
||
<span>个文件</span>
|
||
</div>
|
||
<Tooltip>
|
||
<TooltipTrigger asChild>
|
||
<div className="flex items-center gap-1 text-xs text-muted-foreground cursor-help">
|
||
<FileClock className="size-3" />
|
||
<span>{formatDate(pkg.lastAnalyzedAt)}</span>
|
||
</div>
|
||
</TooltipTrigger>
|
||
<TooltipContent variant="light">
|
||
<div className="text-xs">最近分析时间</div>
|
||
</TooltipContent>
|
||
</Tooltip>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
);
|
||
}
|
||
|
||
export default function ArchPackagePage() {
|
||
const [searchQuery, setSearchQuery] = useState('');
|
||
|
||
// 用于刷新数据的 utils
|
||
const utils = trpc.useUtils()
|
||
|
||
// 获取所有包类型
|
||
const { data: pkgTypes, isLoading: isLoadingTypes } = trpc.devArch!.getAllPkgTypes.useQuery();
|
||
|
||
// 获取所有依赖包数据
|
||
const { data: packagesByType, isLoading: isLoadingPackages } = trpc.devArch!.getAllPackages.useQuery();
|
||
|
||
// 刷新依赖包列表
|
||
const handleRefreshPackages = useCallback(() => {
|
||
utils.devArch!.getAllPackages.invalidate()
|
||
utils.devArch!.getAllPkgTypes.invalidate()
|
||
}, [utils])
|
||
|
||
// 使用第一个包类型作为默认激活标签
|
||
const [activeTab, setActiveTab] = useState<string>('');
|
||
|
||
// 分析对话框状态
|
||
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false)
|
||
const [analyzeJobId, setAnalyzeJobId] = useState<string | null>(null)
|
||
|
||
// 详情Sheet状态
|
||
const [selectedPackage, setSelectedPackage] = useState<PackageData | null>(null)
|
||
const [isDetailSheetOpen, setIsDetailSheetOpen] = useState(false)
|
||
|
||
// 处理卡片点击
|
||
const handleCardClick = useCallback((pkg: PackageData) => {
|
||
setSelectedPackage(pkg)
|
||
setIsDetailSheetOpen(true)
|
||
}, [])
|
||
|
||
// 启动依赖包分析 mutation
|
||
const analyzeMutation = trpc.devArch!.startAnalyzePackages.useMutation({
|
||
onSuccess: (data) => {
|
||
// 打开进度对话框
|
||
setAnalyzeJobId(String(data.jobId))
|
||
setIsAnalyzeDialogOpen(true)
|
||
},
|
||
onError: (error) => {
|
||
toast.error(error.message || '启动依赖包分析失败')
|
||
},
|
||
})
|
||
|
||
// 启动分析
|
||
const handleStartAnalyze = () => {
|
||
analyzeMutation.mutate()
|
||
}
|
||
|
||
// 当包类型加载完成后,设置默认激活标签
|
||
useEffect(() => {
|
||
if (pkgTypes && pkgTypes.length > 0 && !activeTab) {
|
||
setActiveTab(pkgTypes[0].id);
|
||
}
|
||
}, [pkgTypes, activeTab]);
|
||
|
||
const isLoading = isLoadingTypes || isLoadingPackages;
|
||
|
||
// 按优先级搜索过滤:name > description > projectRoleSummary > primaryUsagePattern
|
||
const getFilteredPackages = useCallback((typeId: string) => {
|
||
const packages = packagesByType?.[typeId] || [];
|
||
if (!searchQuery) return packages;
|
||
|
||
const query = searchQuery.toLowerCase();
|
||
|
||
// 计算每个包的匹配优先级
|
||
const packagesWithPriority = packages.map((pkg) => {
|
||
let priority = 0;
|
||
if (pkg.name.toLowerCase().includes(query)) {
|
||
priority = 4;
|
||
}
|
||
else if (pkg.description.toLowerCase().includes(query)) {
|
||
priority = 3;
|
||
}
|
||
else if (pkg.projectRoleSummary.toLowerCase().includes(query)) {
|
||
priority = 2;
|
||
}
|
||
else if (pkg.primaryUsagePattern.toLowerCase().includes(query)) {
|
||
priority = 1;
|
||
}
|
||
return { pkg, priority };
|
||
});
|
||
|
||
// 过滤出有匹配的包,并按优先级排序
|
||
return packagesWithPriority
|
||
.filter(({ priority }) => priority > 0)
|
||
.sort((a, b) => b.priority - a.priority)
|
||
.map(({ pkg }) => pkg);
|
||
}, [packagesByType, searchQuery]);
|
||
|
||
// 将包类型转换为标签项
|
||
const tabItems: ResponsiveTabItem[] = pkgTypes?.map((type) => ({
|
||
id: type.id,
|
||
name: type.name,
|
||
description: type.description,
|
||
count: packagesByType?.[type.id]?.length || 0,
|
||
})) || [];
|
||
|
||
// 仅当pkgTypes完成加载且为空时才显示"暂无依赖包数据"
|
||
if (!isLoading && (!pkgTypes || pkgTypes.length === 0)) {
|
||
return (
|
||
<div className="text-center py-12 text-muted-foreground">
|
||
<Package className="size-10 mx-auto mb-2 opacity-20" />
|
||
<p className="text-sm">暂无依赖包数据</p>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
{isLoading ? (
|
||
<ResponsiveTabs.Skeleton className="pb-6">
|
||
<Skeleton className="h-10 w-full" />
|
||
<div className="grid gap-4 md:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4">
|
||
{[1, 2, 3].map((i) => (
|
||
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||
))}
|
||
</div>
|
||
</ResponsiveTabs.Skeleton>
|
||
) : (
|
||
<ResponsiveTabs
|
||
items={tabItems}
|
||
value={activeTab}
|
||
onValueChange={setActiveTab}
|
||
className="pb-6"
|
||
showIdBadge
|
||
showCountBadge
|
||
>
|
||
{tabItems.map((item) => {
|
||
const filteredPackages = getFilteredPackages(item.id);
|
||
|
||
return (
|
||
<ResponsiveTabs.Content key={item.id} value={item.id}>
|
||
<div className="space-y-4">
|
||
{/* 搜索栏和操作按钮 */}
|
||
<div className="flex items-center gap-3">
|
||
<SearchInput
|
||
value={searchQuery}
|
||
onChange={setSearchQuery}
|
||
placeholder="搜索依赖包..."
|
||
className="w-80"
|
||
/>
|
||
<div className="flex-1" />
|
||
<PackageAnalyzeTrigger
|
||
onStartAnalyze={handleStartAnalyze}
|
||
isStarting={analyzeMutation.isPending}
|
||
/>
|
||
</div>
|
||
|
||
{/* 包列表 */}
|
||
{filteredPackages.length > 0 ? (
|
||
<div className="grid gap-4 md:grid-cols-1 xl:grid-cols-2 2xl:grid-cols-4">
|
||
{filteredPackages.map((pkg) => (
|
||
<PackageCard
|
||
key={pkg.name}
|
||
pkg={pkg}
|
||
onClick={() => handleCardClick(pkg)}
|
||
/>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="text-center py-12 text-muted-foreground">
|
||
<Package className="size-10 mx-auto mb-2 opacity-20" />
|
||
<p className="text-sm">
|
||
{searchQuery ? '未找到匹配的依赖包' : '暂无依赖包数据'}
|
||
</p>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</ResponsiveTabs.Content>
|
||
);
|
||
})}
|
||
</ResponsiveTabs>
|
||
)}
|
||
|
||
{/* 依赖包分析进度对话框 */}
|
||
<PackageAnalyzeDialog
|
||
open={isAnalyzeDialogOpen}
|
||
onOpenChange={setIsAnalyzeDialogOpen}
|
||
jobId={analyzeJobId}
|
||
onAnalyzeCompleted={handleRefreshPackages}
|
||
/>
|
||
|
||
{/* 依赖包详情Sheet */}
|
||
<PackageDetailSheet
|
||
pkg={selectedPackage}
|
||
open={isDetailSheetOpen}
|
||
onOpenChange={setIsDetailSheetOpen}
|
||
/>
|
||
</>
|
||
);
|
||
} |