Files
stu-ai-demo/src/app/(main)/dev/arch/package/page.dev.tsx

297 lines
11 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.
"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}
/>
</>
);
}