forked from admin/hair-keeper
Hair Keeper v1.0.0:一个高度集成、深度定制、约定优于配置的全栈Web应用模板,旨在保持灵活性的同时提供一套基于成熟架构的开发底座,自带身份认证、权限控制、丰富前端组件、文件上传、后台任务、智能体开发等丰富功能,提供AI开发辅助,免于纠结功能如何实现,可快速上手专注于业务逻辑
This commit is contained in:
122
src/app/(auth)/login/page.tsx
Normal file
122
src/app/(auth)/login/page.tsx
Normal file
@@ -0,0 +1,122 @@
|
||||
'use client'
|
||||
|
||||
import { useForm } from "react-hook-form"
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { signIn } from "next-auth/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import { z } from "zod"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Form, FormControl, FormField, FormItem, FormLabel, FormMessage } from "@/components/ui/form"
|
||||
|
||||
// 登录表单验证 schema
|
||||
const loginSchema = z.object({
|
||||
id: z.string().min(1, "请输入用户ID"),
|
||||
password: z.string().min(1, "请输入密码"),
|
||||
})
|
||||
|
||||
type LoginFormData = z.infer<typeof loginSchema>
|
||||
|
||||
export default function LoginPage() {
|
||||
const router = useRouter()
|
||||
|
||||
const form = useForm<LoginFormData>({
|
||||
resolver: zodResolver(loginSchema),
|
||||
defaultValues: {
|
||||
id: "",
|
||||
password: "",
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async (data: LoginFormData) => {
|
||||
try {
|
||||
const result = await signIn("credentials", {
|
||||
id: data.id,
|
||||
password: data.password,
|
||||
redirect: false,
|
||||
})
|
||||
|
||||
if (result?.error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "用户ID或密码错误"
|
||||
})
|
||||
} else if (result?.ok) {
|
||||
// 登录成功,重定向到首页
|
||||
router.push("/")
|
||||
router.refresh()
|
||||
}
|
||||
} catch (error) {
|
||||
form.setError("root", {
|
||||
type: "manual",
|
||||
message: "登录失败,请重试"
|
||||
})
|
||||
console.error("Login error:", error)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-gray-50">
|
||||
<Card className="w-full max-w-md">
|
||||
<CardHeader className="space-y-1">
|
||||
<CardTitle className="text-2xl text-center">登录</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>用户ID</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="请输入用户ID"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="password"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>密码</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
type="password"
|
||||
placeholder="请输入密码"
|
||||
disabled={form.formState.isSubmitting}
|
||||
{...field}
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
{form.formState.errors.root && (
|
||||
<div className="text-sm text-red-600 text-center">
|
||||
{form.formState.errors.root.message}
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
className="w-full"
|
||||
disabled={form.formState.isSubmitting}
|
||||
>
|
||||
{form.formState.isSubmitting ? "登录中..." : "登录"}
|
||||
</Button>
|
||||
</form>
|
||||
</Form>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/[...notFound]/page.tsx
Normal file
5
src/app/(main)/[...notFound]/page.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { notFound } from 'next/navigation'
|
||||
|
||||
export default function NotFoundCatchAll() {
|
||||
notFound()
|
||||
}
|
||||
9
src/app/(main)/dev/arch/layout.dev.tsx
Normal file
9
src/app/(main)/dev/arch/layout.dev.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
|
||||
|
||||
export default function ArchLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SubMenuLayout parentHref="/dev/arch">{children}</SubMenuLayout>;
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
|
||||
import type { AnalyzePackagesProgress } from '@/server/queues'
|
||||
|
||||
/**
|
||||
* 扩展的分析进度类型
|
||||
*/
|
||||
interface AnalyzeProgress extends BaseTaskProgress, AnalyzePackagesProgress {}
|
||||
|
||||
interface PackageAnalyzeDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
jobId: string | null
|
||||
onAnalyzeCompleted: () => void
|
||||
}
|
||||
|
||||
interface PackageAnalyzeTriggerProps {
|
||||
onStartAnalyze: () => void
|
||||
isStarting: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包分析触发器按钮
|
||||
*/
|
||||
export function PackageAnalyzeTrigger({
|
||||
onStartAnalyze,
|
||||
isStarting
|
||||
}: PackageAnalyzeTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onStartAnalyze}
|
||||
disabled={isStarting}
|
||||
>
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
{isStarting ? '启动中...' : '依赖包分析'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包分析进度对话框
|
||||
*/
|
||||
export function PackageAnalyzeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
jobId,
|
||||
onAnalyzeCompleted
|
||||
}: PackageAnalyzeDialogProps) {
|
||||
// 停止分析任务 mutation
|
||||
const cancelMutation = trpc.devArch!.cancelAnalyzePackagesJob.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('已发送停止请求')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '停止任务失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 停止任务
|
||||
const handleCancelTask = async (taskJobId: string) => {
|
||||
await cancelMutation.mutateAsync({ jobId: taskJobId })
|
||||
}
|
||||
|
||||
// 自定义状态消息渲染
|
||||
const renderStatusMessage = (progress: AnalyzeProgress) => {
|
||||
if (progress.state === 'waiting') {
|
||||
return '任务等待中...'
|
||||
} else if (progress.state === 'active') {
|
||||
if (progress.currentPackage) {
|
||||
return `正在分析: ${progress.currentPackage}`
|
||||
}
|
||||
return '正在分析依赖包...'
|
||||
} else if (progress.state === 'completed') {
|
||||
const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0)
|
||||
const failedCount = progress.failedPackages || 0
|
||||
const skippedCount = progress.skippedPackages || 0
|
||||
|
||||
const parts = [`成功 ${successCount} 个`]
|
||||
if (failedCount > 0) {
|
||||
parts.push(`失败 ${failedCount} 个`)
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
parts.push(`跳过 ${skippedCount} 个`)
|
||||
}
|
||||
parts.push(`共 ${progress.totalPackages || 0} 个依赖包`)
|
||||
|
||||
return `分析完成!${parts.join(',')}`
|
||||
} else if (progress.state === 'failed') {
|
||||
return progress.error || '分析失败'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 自定义详细信息渲染
|
||||
const renderDetails = (progress: AnalyzeProgress) => {
|
||||
if (progress.totalPackages === undefined && progress.analyzedPackages === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const successCount = (progress.analyzedPackages || 0) - (progress.failedPackages || 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 进度统计 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{progress.totalPackages !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">依赖包总数:</span>
|
||||
<span className="ml-1 font-medium">{progress.totalPackages}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.analyzedPackages !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">已分析:</span>
|
||||
<span className="ml-1 font-medium">{progress.analyzedPackages}</span>
|
||||
</div>
|
||||
)}
|
||||
{successCount > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">成功:</span>
|
||||
<span className="ml-1 font-medium text-green-600">{successCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.failedPackages !== undefined && progress.failedPackages > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">失败:</span>
|
||||
<span className="ml-1 font-medium text-red-600">{progress.failedPackages}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.skippedPackages !== undefined && progress.skippedPackages > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">跳过:</span>
|
||||
<span className="ml-1 font-medium text-blue-600">{progress.skippedPackages}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前处理的依赖包 */}
|
||||
{progress.currentPackage && progress.state === 'active' && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<div className="text-muted-foreground mb-1">当前依赖包:</div>
|
||||
<div className="font-mono text-xs break-all">{progress.currentPackage}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近的错误信息 */}
|
||||
{progress.recentErrors && progress.recentErrors.length > 0 && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
|
||||
<div className="text-red-800 font-medium mb-2">最近的错误 (最多显示10条):</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{progress.recentErrors.map((err, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="font-mono text-red-700 break-all">{err.packageName}</div>
|
||||
<div className="text-red-600 mt-1">{err.error}</div>
|
||||
{index < progress.recentErrors!.length - 1 && (
|
||||
<div className="border-t border-red-200 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskDialog<AnalyzeProgress>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
useSubscription={trpc.jobs.subscribeAnalyzePackagesProgress.useSubscription}
|
||||
jobId={jobId}
|
||||
title="依赖包分析进度"
|
||||
description="正在使用AI分析项目依赖包,请稍候..."
|
||||
onCancelTask={handleCancelTask}
|
||||
onTaskCompleted={onAnalyzeCompleted}
|
||||
isCancelling={cancelMutation.isPending}
|
||||
renderStatusMessage={renderStatusMessage}
|
||||
renderDetails={renderDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Package, Link as LinkIcon, Code2, FileCode, Globe, Code } from 'lucide-react'
|
||||
import {
|
||||
DetailSheet,
|
||||
DetailHeader,
|
||||
DetailSection,
|
||||
DetailField,
|
||||
DetailFieldGroup,
|
||||
DetailList,
|
||||
DetailCopyable,
|
||||
} from '@/components/data-details'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import type { PackageData } from "@/server/routers/dev/arch";
|
||||
|
||||
export interface PackageDetailSheetProps {
|
||||
pkg: PackageData | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 依赖包详情展示Sheet
|
||||
* 使用通用详情展示框架展示DevAnalyzedPkg对象的完整信息
|
||||
*/
|
||||
export function PackageDetailSheet({
|
||||
pkg,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: PackageDetailSheetProps) {
|
||||
if (!pkg) return null
|
||||
|
||||
// 处理关联文件列表
|
||||
const relatedFileItems = (pkg.relatedFiles || []).map((filePath, index) => ({
|
||||
id: `file-${index}`,
|
||||
label: filePath,
|
||||
icon: FileCode,
|
||||
}))
|
||||
|
||||
// 解析主要使用模式(按行分割)
|
||||
const usagePatterns = pkg.primaryUsagePattern
|
||||
.split('\n')
|
||||
.filter(line => line.trim())
|
||||
.map((pattern, index) => ({
|
||||
id: `pattern-${index}`,
|
||||
label: pattern.trim(),
|
||||
}))
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
width="xl"
|
||||
header={
|
||||
<SheetTitle title={pkg.name}>
|
||||
<DetailHeader
|
||||
title={pkg.name}
|
||||
subtitle={
|
||||
<>
|
||||
<Badge variant="primary" appearance="light" className="text-xs">
|
||||
v{pkg.version}
|
||||
</Badge>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{pkg.pkgType.name}
|
||||
</Badge>
|
||||
</>
|
||||
}
|
||||
icon={<Package className="h-6 w-6" />}
|
||||
/>
|
||||
</SheetTitle>
|
||||
}
|
||||
description={<VisuallyHidden><SheetDescription>{pkg.description}</SheetDescription></VisuallyHidden>}
|
||||
>
|
||||
{/* 基本信息 */}
|
||||
<DetailSection title="基本信息" icon={Package}>
|
||||
<DetailFieldGroup columns={2}>
|
||||
<DetailField
|
||||
label="包名"
|
||||
value={<DetailCopyable value={pkg.name} />}
|
||||
/>
|
||||
<DetailField
|
||||
label="版本号"
|
||||
value={
|
||||
<Badge variant="primary" appearance="light">
|
||||
v{pkg.version}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label="包类型"
|
||||
value={
|
||||
<div className="space-y-1">
|
||||
<Badge variant="outline">{pkg.pkgType.name}</Badge>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label="发布时间"
|
||||
value={formatDate(pkg.modifiedAt, "PPP")}
|
||||
/>
|
||||
<DetailField
|
||||
label="最后分析时间"
|
||||
value={formatDate(pkg.lastAnalyzedAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
<DetailField
|
||||
label="创建时间"
|
||||
value={formatDate(pkg.createdAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
</DetailFieldGroup>
|
||||
</DetailSection>
|
||||
|
||||
{/* 官方描述 */}
|
||||
<DetailSection title="官方描述" icon={FileCode}>
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{pkg.description}
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* 项目中的角色 */}
|
||||
<DetailSection title="项目中的角色" icon={Code2}>
|
||||
<div className="space-y-4">
|
||||
<DetailField
|
||||
label="核心功能"
|
||||
value={
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{pkg.projectRoleSummary}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* 主要使用模式 */}
|
||||
{usagePatterns.length > 0 && (
|
||||
<DetailSection
|
||||
title="主要使用模式"
|
||||
description={`共 ${usagePatterns.length} 种使用模式`}
|
||||
icon={Code2}
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
>
|
||||
<DetailList
|
||||
items={usagePatterns}
|
||||
maxHeight="300px"
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* 关联文件 */}
|
||||
{relatedFileItems.length > 0 && (
|
||||
<DetailSection
|
||||
title="关联文件"
|
||||
description={`共 ${relatedFileItems.length} 个文件直接或有可能间接使用了此包`}
|
||||
icon={FileCode}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<DetailList
|
||||
items={relatedFileItems}
|
||||
searchable
|
||||
maxHeight="400px"
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* 链接信息 */}
|
||||
<DetailSection title="链接信息" icon={LinkIcon}>
|
||||
<DetailFieldGroup columns={1}>
|
||||
{pkg.homepage && (
|
||||
<DetailField
|
||||
label="主页"
|
||||
value={
|
||||
<a
|
||||
href={pkg.homepage}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Globe className="size-4" />
|
||||
<span className="text-sm underline">{pkg.homepage}</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
{pkg.repositoryUrl && (
|
||||
<DetailField
|
||||
label="仓库地址"
|
||||
value={
|
||||
<a
|
||||
href={pkg.repositoryUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center gap-2 text-primary hover:text-primary/80 transition-colors"
|
||||
>
|
||||
<Code className="size-4" />
|
||||
<span className="text-sm underline break-all">{pkg.repositoryUrl}</span>
|
||||
</a>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</DetailFieldGroup>
|
||||
</DetailSection>
|
||||
</DetailSheet>
|
||||
)
|
||||
}
|
||||
297
src/app/(main)/dev/arch/package/page.dev.tsx
Normal file
297
src/app/(main)/dev/arch/package/page.dev.tsx
Normal file
@@ -0,0 +1,297 @@
|
||||
"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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
5
src/app/(main)/dev/arch/page.dev.tsx
Normal file
5
src/app/(main)/dev/arch/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function ArchPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/arch" />;
|
||||
}
|
||||
68
src/app/(main)/dev/dev-theme.css
Normal file
68
src/app/(main)/dev/dev-theme.css
Normal file
@@ -0,0 +1,68 @@
|
||||
:root {
|
||||
--radius: 0.65rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.141 0.005 285.823);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.141 0.005 285.823);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.141 0.005 285.823);
|
||||
--primary: oklch(0.623 0.214 259.815);
|
||||
--primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--secondary: oklch(0.967 0.001 286.375);
|
||||
--secondary-foreground: oklch(0.21 0.006 285.885);
|
||||
--muted: oklch(0.967 0.001 286.375);
|
||||
--muted-foreground: oklch(0.552 0.016 285.938);
|
||||
--accent: oklch(0.967 0.001 286.375);
|
||||
--accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.92 0.004 286.32);
|
||||
--input: oklch(0.92 0.004 286.32);
|
||||
--ring: oklch(0.623 0.214 259.815);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.141 0.005 285.823);
|
||||
--sidebar-primary: oklch(0.623 0.214 259.815);
|
||||
--sidebar-primary-foreground: oklch(0.97 0.014 254.604);
|
||||
--sidebar-accent: oklch(0.967 0.001 286.375);
|
||||
--sidebar-accent-foreground: oklch(0.21 0.006 285.885);
|
||||
--sidebar-border: oklch(0.92 0.004 286.32);
|
||||
--sidebar-ring: oklch(0.623 0.214 259.815);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.141 0.005 285.823);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.21 0.006 285.885);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.21 0.006 285.885);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.546 0.245 262.881);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.274 0.006 286.033);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.274 0.006 286.033);
|
||||
--muted-foreground: oklch(0.705 0.015 286.067);
|
||||
--accent: oklch(0.274 0.006 286.033);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.488 0.243 264.376);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.21 0.006 285.885);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.546 0.245 262.881);
|
||||
--sidebar-primary-foreground: oklch(0.379 0.146 265.522);
|
||||
--sidebar-accent: oklch(0.274 0.006 286.033);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.488 0.243 264.376);
|
||||
}
|
||||
387
src/app/(main)/dev/file/columns.tsx
Normal file
387
src/app/(main)/dev/file/columns.tsx
Normal file
@@ -0,0 +1,387 @@
|
||||
'use client'
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import { Eye } from 'lucide-react'
|
||||
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
|
||||
import { Option } from '@/types/data-table'
|
||||
import { SourceFileIcon } from '@/components/icons/code-lang'
|
||||
|
||||
export interface DevAnalyzedFileColumnsOptions {
|
||||
fileTypes: Array<Option>
|
||||
commitIds: Array<Option>
|
||||
tagsStats: Array<Option>
|
||||
pkgDependencyStats: Array<Option>
|
||||
onViewDetail?: (file: DevAnalyzedFile) => void
|
||||
}
|
||||
|
||||
// 创建文件表格列定义
|
||||
export const createDevAnalyzedFileColumns = (
|
||||
options: DevAnalyzedFileColumnsOptions
|
||||
): ColumnDef<DevAnalyzedFile>[] => [
|
||||
{
|
||||
id: 'select',
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && 'indeterminate')
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'path',
|
||||
accessorKey: 'path',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="文件路径" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="line-clamp-4 text-sm whitespace-normal break-words font-mono text-xs" title={row.original.path}>
|
||||
{row.original.path}
|
||||
</div>
|
||||
),
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
label: '文件路径',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fileName',
|
||||
accessorKey: 'fileName',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="文件名" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const fileName = row.original.fileName
|
||||
const extension = fileName.split('.').pop() || ''
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<SourceFileIcon extension={extension} className="shrink-0" color="currentColor" />
|
||||
<div className="text-md line-clamp-2 whitespace-normal break-words">{fileName}</div>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 180,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '文件名',
|
||||
filter: {
|
||||
placeholder: '请输入文件路径或文件名',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'commitId',
|
||||
accessorKey: 'commitId',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="Commit ID" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const commitId = row.original.commitId
|
||||
if (!commitId) {
|
||||
return <div className="text-sm text-muted-foreground">无提交</div>
|
||||
}
|
||||
const isModified = commitId.endsWith('*')
|
||||
return (
|
||||
<div className="flex items-center gap-1">
|
||||
<code className="text-sm font-mono px-1.5 py-0.5 rounded">
|
||||
{isModified ? (
|
||||
<>
|
||||
{commitId.slice(0, -1)}
|
||||
<span className="text-red-500">*</span>
|
||||
</>
|
||||
) : (
|
||||
commitId
|
||||
)}
|
||||
</code>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: true,
|
||||
meta: {
|
||||
label: 'Commit ID',
|
||||
filter: {
|
||||
variant: 'select',
|
||||
options: options.commitIds,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'fileTypeId',
|
||||
accessorKey: 'fileTypeId',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="文件类型" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const fileTypeName = row.original.fileType?.name || row.original.fileTypeId
|
||||
return (
|
||||
<Badge variant="outline">
|
||||
{fileTypeName}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
size: 120,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '文件类型',
|
||||
filter: {
|
||||
variant: 'select',
|
||||
options: options.fileTypes,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'summary',
|
||||
accessorKey: 'summary',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="功能摘要" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="line-clamp-4 text-sm whitespace-normal break-words"
|
||||
title={row.original.summary}
|
||||
>
|
||||
{row.original.summary}
|
||||
</div>
|
||||
),
|
||||
size: 200,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '功能摘要',
|
||||
filter: {
|
||||
placeholder: '搜索功能摘要或详细描述...',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'description',
|
||||
accessorKey: 'description',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="详细描述" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div
|
||||
className="line-clamp-4 text-sm whitespace-normal break-words"
|
||||
title={row.original.description}
|
||||
>
|
||||
{row.original.description}
|
||||
</div>
|
||||
),
|
||||
size: 400,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '详细描述',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'exportedMembers',
|
||||
accessorKey: 'exportedMembers',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="导出成员" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const members = row.original.exportedMembers as Array<{ name: string; type: string }> | null
|
||||
if (!members || members.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">无</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{members.map((member, index) => (
|
||||
<Badge key={index} variant="outline" className="text-xs">
|
||||
{member.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: '导出成员'
|
||||
},
|
||||
size: 240,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'dependencies',
|
||||
accessorKey: 'dependencies',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="依赖文件" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const deps = row.original.dependencies || []
|
||||
if (deps.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">无</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{deps.map((dep, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
appearance="light"
|
||||
className="text-xs"
|
||||
title={dep.usageDescription || dep.targetFilePath}
|
||||
>
|
||||
{dep.targetFilePath.split('/').pop()}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: '依赖文件'
|
||||
},
|
||||
size: 200,
|
||||
enableColumnFilter: false,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'pkgDependencies',
|
||||
accessorKey: 'pkgDependencies',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="包依赖" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const pkgDeps = row.original.pkgDependencies || []
|
||||
if (pkgDeps.length === 0) {
|
||||
return <div className="text-xs text-muted-foreground">无</div>
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{pkgDeps.map((dep, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant="secondary"
|
||||
appearance="outline"
|
||||
className="text-xs"
|
||||
title={dep.usageDescription || dep.packageName}
|
||||
>
|
||||
{dep.packageName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
meta: {
|
||||
label: '包依赖',
|
||||
filter: {
|
||||
variant: 'multiSelect',
|
||||
options: options.pkgDependencyStats,
|
||||
}
|
||||
},
|
||||
size: 200,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
},
|
||||
{
|
||||
id: 'tags',
|
||||
accessorKey: 'tags',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="标签" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const tags = row.original.tags || []
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{tags.map((tag, index) => (
|
||||
<Badge key={index} variant="secondary" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
size: 200,
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
label: '标签',
|
||||
filter: {
|
||||
variant: 'multiSelect',
|
||||
options: options.tagsStats,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="创建时间" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
{formatDate(row.original.createdAt)}
|
||||
</div>
|
||||
),
|
||||
size: 120,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '创建时间',
|
||||
filter: {
|
||||
variant: 'dateRange',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lastAnalyzedAt',
|
||||
accessorKey: 'lastAnalyzedAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="分析时间" />
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<div className="text-sm">
|
||||
{formatDate(row.original.lastAnalyzedAt)}
|
||||
</div>
|
||||
),
|
||||
size: 120,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '分析时间',
|
||||
filter: {
|
||||
variant: 'dateRange',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
header: '操作',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => options.onViewDetail?.(row.original)}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1" />
|
||||
查看详情
|
||||
</Button>
|
||||
),
|
||||
size: 120,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
192
src/app/(main)/dev/file/components/FileAnalyzeDialog.tsx
Normal file
192
src/app/(main)/dev/file/components/FileAnalyzeDialog.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { FileSearch } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
|
||||
import type { AnalyzeFilesProgress } from '@/server/queues'
|
||||
|
||||
/**
|
||||
* 扩展的分析进度类型
|
||||
*/
|
||||
interface AnalyzeProgress extends BaseTaskProgress, AnalyzeFilesProgress {}
|
||||
|
||||
interface FileAnalyzeDialogProps {
|
||||
onAnalyzeCompleted: () => void
|
||||
}
|
||||
|
||||
export function FileAnalyzeDialog({ onAnalyzeCompleted }: FileAnalyzeDialogProps) {
|
||||
const [isProgressDialogOpen, setIsProgressDialogOpen] = useState(false)
|
||||
const [jobId, setJobId] = useState<string | null>(null)
|
||||
|
||||
// 启动文件分析 mutation
|
||||
const analyzeMutation = trpc.devFile!.startAnalyzeFiles.useMutation({
|
||||
onSuccess: (data) => {
|
||||
// 打开进度对话框
|
||||
setJobId(String(data.jobId))
|
||||
setIsProgressDialogOpen(true)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '启动文件分析失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 停止分析任务 mutation
|
||||
const cancelMutation = trpc.devFile!.cancelAnalyzeFilesJob.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('已发送停止请求')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '停止任务失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 启动分析
|
||||
const handleStartAnalyze = () => {
|
||||
analyzeMutation.mutate()
|
||||
}
|
||||
|
||||
// 停止任务
|
||||
const handleCancelTask = async (taskJobId: string) => {
|
||||
await cancelMutation.mutateAsync({ jobId: taskJobId })
|
||||
}
|
||||
|
||||
// 自定义状态消息渲染
|
||||
const renderStatusMessage = (progress: AnalyzeProgress) => {
|
||||
if (progress.state === 'waiting') {
|
||||
return '任务等待中...'
|
||||
} else if (progress.state === 'active') {
|
||||
if (progress.currentFile) {
|
||||
return `正在分析: ${progress.currentFile}`
|
||||
}
|
||||
return '正在分析文件...'
|
||||
} else if (progress.state === 'completed') {
|
||||
const successCount = (progress.analyzedFiles || 0) - (progress.failedFiles || 0)
|
||||
const failedCount = progress.failedFiles || 0
|
||||
const skippedCount = progress.skippedFiles || 0
|
||||
|
||||
const parts = [`成功 ${successCount} 个`]
|
||||
if (failedCount > 0) {
|
||||
parts.push(`失败 ${failedCount} 个`)
|
||||
}
|
||||
if (skippedCount > 0) {
|
||||
parts.push(`跳过 ${skippedCount} 个`)
|
||||
}
|
||||
parts.push(`共 ${progress.totalFiles || 0} 个文件`)
|
||||
|
||||
return `分析完成!${parts.join(',')}`
|
||||
} else if (progress.state === 'failed') {
|
||||
return progress.error || '分析失败'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 自定义详细信息渲染
|
||||
const renderDetails = (progress: AnalyzeProgress) => {
|
||||
if (progress.totalFiles === undefined && progress.analyzedFiles === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const successCount = (progress.analyzedFiles || 0) - (progress.failedFiles || 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 进度统计 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{progress.totalFiles !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">文件总数:</span>
|
||||
<span className="ml-1 font-medium">{progress.totalFiles}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.analyzedFiles !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">已分析:</span>
|
||||
<span className="ml-1 font-medium">{progress.analyzedFiles}</span>
|
||||
</div>
|
||||
)}
|
||||
{successCount > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">成功:</span>
|
||||
<span className="ml-1 font-medium text-green-600">{successCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.failedFiles !== undefined && progress.failedFiles > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">失败:</span>
|
||||
<span className="ml-1 font-medium text-red-600">{progress.failedFiles}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.skippedFiles !== undefined && progress.skippedFiles > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">跳过:</span>
|
||||
<span className="ml-1 font-medium text-blue-600">{progress.skippedFiles}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前处理的文件 */}
|
||||
{progress.currentFile && progress.state === 'active' && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<div className="text-muted-foreground mb-1">当前文件:</div>
|
||||
<div className="font-mono text-xs break-all">{progress.currentFile}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近的错误信息 */}
|
||||
{progress.recentErrors && progress.recentErrors.length > 0 && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
|
||||
<div className="text-red-800 font-medium mb-2">最近的错误 (最多显示10条):</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{progress.recentErrors.map((err, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="font-mono text-red-700 break-all">{err.filePath}</div>
|
||||
<div className="text-red-600 mt-1">{err.error}</div>
|
||||
{index < progress.recentErrors!.length - 1 && (
|
||||
<div className="border-t border-red-200 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* 启动按钮 */}
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={handleStartAnalyze}
|
||||
disabled={analyzeMutation.isPending}
|
||||
>
|
||||
<FileSearch className="mr-2 h-4 w-4" />
|
||||
{analyzeMutation.isPending ? '启动中...' : '文件分析'}
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
|
||||
{/* 进度显示对话框 */}
|
||||
<TaskDialog<AnalyzeProgress>
|
||||
open={isProgressDialogOpen}
|
||||
onOpenChange={setIsProgressDialogOpen}
|
||||
useSubscription={trpc.jobs.subscribeAnalyzeFilesProgress.useSubscription}
|
||||
jobId={jobId}
|
||||
title="文件分析进度"
|
||||
description="正在使用AI分析项目文件,请稍候..."
|
||||
onCancelTask={handleCancelTask}
|
||||
onTaskCompleted={onAnalyzeCompleted}
|
||||
isCancelling={cancelMutation.isPending}
|
||||
renderStatusMessage={renderStatusMessage}
|
||||
renderDetails={renderDetails}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal file
210
src/app/(main)/dev/file/components/FileDependencyGraph.tsx
Normal file
@@ -0,0 +1,210 @@
|
||||
'use client'
|
||||
|
||||
import React, { useMemo } from 'react'
|
||||
import { Node, Handle, Position } from '@xyflow/react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { FileCode, FileX } from 'lucide-react'
|
||||
import { AdaptiveGraph } from '@/components/features/adaptive-graph'
|
||||
|
||||
// 节点数据类型
|
||||
interface GraphNodeData {
|
||||
id: string
|
||||
path: string
|
||||
fileName: string
|
||||
fileTypeId: string
|
||||
fileTypeName: string
|
||||
summary: string | null
|
||||
dependencyCount: number
|
||||
isDeleted: boolean
|
||||
}
|
||||
|
||||
// 组件 Props
|
||||
interface FileDependencyGraphProps {
|
||||
nodes: GraphNodeData[]
|
||||
edges: Array<{ source: string; target: string; label?: string }>
|
||||
onNodeClick?: (node: GraphNodeData) => void
|
||||
}
|
||||
|
||||
// 自定义节点组件
|
||||
const CustomNode = ({ data }: { data: GraphNodeData & { isHighlighted?: boolean; isDimmed?: boolean } }) => {
|
||||
const bgColor = data.isDeleted
|
||||
? 'bg-red-50 dark:bg-red-950/20'
|
||||
: data.isHighlighted
|
||||
? 'bg-blue-50 dark:bg-blue-950/50'
|
||||
: data.isDimmed
|
||||
? 'bg-gray-50 dark:bg-gray-900/30'
|
||||
: 'bg-white dark:bg-gray-800'
|
||||
|
||||
const borderColor = data.isDeleted
|
||||
? 'border-red-300 dark:border-red-700'
|
||||
: data.isHighlighted
|
||||
? 'border-blue-500 dark:border-blue-400'
|
||||
: 'border-gray-200 dark:border-gray-700'
|
||||
|
||||
const opacity = data.isDimmed ? 'opacity-40' : 'opacity-100'
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`px-3 py-2 rounded-lg border-2 shadow-sm transition-all ${bgColor} ${borderColor} ${opacity} min-w-[180px] max-w-[220px]`}
|
||||
>
|
||||
{/* Handle 组件用于连接边,没有Handle看不见连边,!opacity-0用来隐藏卡片上用来连接的小点 */}
|
||||
<Handle type="target" position={Position.Top} className="!opacity-0" />
|
||||
<div className="flex items-start gap-2">
|
||||
{data.isDeleted ? (
|
||||
<FileX className="h-4 w-4 text-red-500 flex-shrink-0 mt-0.5" />
|
||||
) : (
|
||||
<FileCode className="h-4 w-4 text-blue-500 flex-shrink-0 mt-0.5" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium text-sm truncate" title={data.fileName}>
|
||||
{data.fileName}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground truncate" title={data.path}>
|
||||
{data.path}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 mt-1">
|
||||
<Badge variant="outline" className="text-xs px-1 py-0">
|
||||
{data.fileTypeName}
|
||||
</Badge>
|
||||
{data.dependencyCount > 0 && (
|
||||
<Badge variant="secondary" className="text-xs px-1 py-0">
|
||||
{data.dependencyCount} 依赖
|
||||
</Badge>
|
||||
)}
|
||||
{data.isDeleted && (
|
||||
<Badge variant="destructive" className="text-xs px-1 py-0">
|
||||
已删除
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/* Handle 组件用于连接边,没有Handle看不见连边,!opacity-0用来隐藏卡片上用来连接的小点 */}
|
||||
<Handle type="source" position={Position.Bottom} className="!opacity-0" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// 节点类型定义
|
||||
const nodeTypes = {
|
||||
custom: CustomNode,
|
||||
}
|
||||
|
||||
export function FileDependencyGraph({ nodes: rawNodes, edges: rawEdges, onNodeClick }: FileDependencyGraphProps) {
|
||||
// 构建依赖关系映射
|
||||
const dependencyMap = useMemo(() => {
|
||||
const map = new Map<string, Set<string>>()
|
||||
const reverseDependencyMap = new Map<string, Set<string>>()
|
||||
|
||||
rawEdges.forEach((edge) => {
|
||||
// 正向依赖:source 依赖 target
|
||||
if (!map.has(edge.source)) {
|
||||
map.set(edge.source, new Set())
|
||||
}
|
||||
map.get(edge.source)!.add(edge.target)
|
||||
|
||||
// 反向依赖:target 被 source 依赖
|
||||
if (!reverseDependencyMap.has(edge.target)) {
|
||||
reverseDependencyMap.set(edge.target, new Set())
|
||||
}
|
||||
reverseDependencyMap.get(edge.target)!.add(edge.source)
|
||||
})
|
||||
|
||||
return { dependencies: map, reverseDependencies: reverseDependencyMap }
|
||||
}, [rawEdges])
|
||||
|
||||
// 过滤函数:根据搜索查询返回过滤后的节点和高亮节点
|
||||
const handleFilter = useMemo(
|
||||
() => (nodes: GraphNodeData[], query: string) => {
|
||||
if (!query.trim()) {
|
||||
return {
|
||||
filteredNodeIds: new Set(nodes.map((n) => n.id)),
|
||||
highlightedNodeIds: new Set<string>(),
|
||||
}
|
||||
}
|
||||
|
||||
const lowerQuery = query.toLowerCase()
|
||||
const matchedNodes = nodes.filter(
|
||||
(node) =>
|
||||
node.fileName.toLowerCase().includes(lowerQuery) ||
|
||||
node.path.toLowerCase().includes(lowerQuery) ||
|
||||
node.summary?.toLowerCase().includes(lowerQuery)
|
||||
)
|
||||
|
||||
// 包含匹配的节点及其依赖和被依赖的节点
|
||||
const resultSet = new Set<string>()
|
||||
matchedNodes.forEach((node) => {
|
||||
resultSet.add(node.id)
|
||||
|
||||
// 添加该节点依赖的节点
|
||||
const deps = dependencyMap.dependencies.get(node.id)
|
||||
if (deps) {
|
||||
deps.forEach((depId) => resultSet.add(depId))
|
||||
}
|
||||
|
||||
// 添加依赖该节点的节点
|
||||
const reverseDeps = dependencyMap.reverseDependencies.get(node.id)
|
||||
if (reverseDeps) {
|
||||
reverseDeps.forEach((depId) => resultSet.add(depId))
|
||||
}
|
||||
})
|
||||
|
||||
return {
|
||||
filteredNodeIds: resultSet,
|
||||
highlightedNodeIds: new Set(matchedNodes.map((n) => n.id)),
|
||||
}
|
||||
},
|
||||
[dependencyMap]
|
||||
)
|
||||
|
||||
// 节点转换函数
|
||||
const transformNode = (
|
||||
node: GraphNodeData,
|
||||
options: { isHighlighted: boolean; isDimmed: boolean }
|
||||
): Node => ({
|
||||
id: node.id,
|
||||
type: 'custom',
|
||||
data: {
|
||||
...node,
|
||||
isHighlighted: options.isHighlighted,
|
||||
isDimmed: options.isDimmed,
|
||||
},
|
||||
position: { x: 0, y: 0 }, // 将由布局算法设置
|
||||
})
|
||||
|
||||
// MiniMap 节点颜色函数
|
||||
const getNodeColor = (node: Node) => {
|
||||
const data = node.data as unknown as GraphNodeData & { isHighlighted?: boolean; isDeleted?: boolean }
|
||||
if (data.isDeleted) return '#fca5a5'
|
||||
if (data.isHighlighted) return '#60a5fa'
|
||||
return '#cbd5e1'
|
||||
}
|
||||
|
||||
// 统计信息渲染
|
||||
const renderStats = (filteredCount: number, totalCount: number, matchedCount: number) => (
|
||||
<div className="mt-2 text-xs text-muted-foreground">
|
||||
显示 {filteredCount} / {totalCount} 个文件
|
||||
{matchedCount > 0 && ` (${matchedCount} 个匹配)`}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<AdaptiveGraph
|
||||
nodes={rawNodes}
|
||||
edges={rawEdges}
|
||||
nodeTypes={nodeTypes}
|
||||
onFilter={handleFilter}
|
||||
transformNode={transformNode}
|
||||
getNodeColor={getNodeColor}
|
||||
onNodeClick={onNodeClick}
|
||||
searchPlaceholder="搜索文件名、路径或摘要..."
|
||||
renderStats={renderStats}
|
||||
className="h-[800px] max-h-[calc(100vh-12rem)]"
|
||||
reactFlowProps={{
|
||||
nodesDraggable: true,
|
||||
nodesConnectable: false,
|
||||
elementsSelectable: true,
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
432
src/app/(main)/dev/file/components/FileDetailPanel.tsx
Normal file
432
src/app/(main)/dev/file/components/FileDetailPanel.tsx
Normal file
@@ -0,0 +1,432 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FileCode, Tag, Link as LinkIcon, Code2, Package, GitCommit } from 'lucide-react';
|
||||
import {
|
||||
DetailSection,
|
||||
DetailField,
|
||||
DetailFieldGroup,
|
||||
DetailBadgeList,
|
||||
DetailList,
|
||||
DetailCodeBlock,
|
||||
DetailCopyable,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineNode,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineTitleArea,
|
||||
TimelineTitle,
|
||||
TimelineBadge,
|
||||
TimelineActions,
|
||||
TimelineTimestamp,
|
||||
TimelineDescription,
|
||||
TimelineEmpty,
|
||||
} from '@/components/data-details';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn, getLanguageFromPath } from '@/lib/utils';
|
||||
import { toast } from 'sonner';
|
||||
import { GitCommitViewDialog } from './GitCommitViewDialog';
|
||||
|
||||
export interface FileDetailPanelProps {
|
||||
/** 文件ID */
|
||||
fileId: number;
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 文件名称 */
|
||||
name: string;
|
||||
/** 根容器样式类 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
// 定义 action 的标签和样式映射
|
||||
const gitActionLabels = {
|
||||
added: '新增',
|
||||
modified: '修改',
|
||||
renamed: '重命名',
|
||||
deleted: '删除',
|
||||
} as const;
|
||||
|
||||
const gitActionVariants = {
|
||||
added: 'default' as const,
|
||||
modified: 'secondary' as const,
|
||||
renamed: 'outline' as const,
|
||||
deleted: 'destructive' as const,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* 文件详情面板组件
|
||||
* 显示文件的详细信息,包括从数据库获取的分析结果
|
||||
*/
|
||||
export function FileDetailPanel({
|
||||
fileId,
|
||||
path,
|
||||
name,
|
||||
className
|
||||
}: FileDetailPanelProps) {
|
||||
const [shouldLoadContent, setShouldLoadContent] = React.useState(false);
|
||||
const [shouldLoadGitHistory, setShouldLoadGitHistory] = React.useState(false);
|
||||
const [commitViewDialog, setCommitViewDialog] = React.useState<{
|
||||
open: boolean;
|
||||
commitId: string;
|
||||
previousCommitId?: string;
|
||||
}>({
|
||||
open: false,
|
||||
commitId: '',
|
||||
});
|
||||
|
||||
// 查询文件的详细信息
|
||||
const { data: fileDetail, isLoading, isError, error } = trpc.devFile!.getFileById.useQuery(
|
||||
{ id: fileId },
|
||||
{ enabled: !!path }
|
||||
);
|
||||
|
||||
const { data: fileContent, isLoading: isContentLoading, isError: isContentError, error: contentError } = trpc.devFile!.getFileContent.useQuery(
|
||||
{ id: fileId },
|
||||
{ enabled: shouldLoadContent && !!fileId }
|
||||
);
|
||||
|
||||
const { data: gitHistory, isLoading: isGitHistoryLoading, isError: isGitHistoryError, error: gitHistoryError } = trpc.devFile!.getFileGitHistory.useQuery(
|
||||
{ id: fileId },
|
||||
{ enabled: shouldLoadGitHistory && !!fileId }
|
||||
);
|
||||
|
||||
|
||||
|
||||
if (error) {
|
||||
toast.error("获取文件详情失败:" + error.toString().substring(0, 100))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className={cn("p-6 space-y-6 w-full", className)}>
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-24 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !fileDetail) {
|
||||
return (
|
||||
<div className={cn("p-6 space-y-4 w-full", className)}>
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode className="size-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">{name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">类型</h3>
|
||||
<Badge variant="outline">文件</Badge>
|
||||
</div>
|
||||
|
||||
{isError && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无详细分析信息
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 处理导出成员数据
|
||||
const exportedMembers = (fileDetail.exportedMembers as Array<{ name: string; type: string }> | null) || []
|
||||
|
||||
// 按类型分组导出成员
|
||||
const groupedMembers = exportedMembers.reduce((acc, member) => {
|
||||
const type = member.type || '其他'
|
||||
if (!acc[type]) {
|
||||
acc[type] = []
|
||||
}
|
||||
acc[type].push({ label: member.name, variant: 'outline' as const })
|
||||
return acc
|
||||
}, {} as Record<string, Array<{ label: string; variant: 'outline' }>>)
|
||||
|
||||
// 处理依赖列表
|
||||
const dependencyItems = (fileDetail.dependencies || []).map((dep, index) => ({
|
||||
id: `dep-${index}`,
|
||||
label: dep.targetFilePath,
|
||||
description: dep.usageDescription || undefined,
|
||||
icon: LinkIcon,
|
||||
}))
|
||||
|
||||
// 处理包依赖列表
|
||||
const pkgDependencyItems = (fileDetail.pkgDependencies || []).map((dep, index) => ({
|
||||
id: `pkg-${index}`,
|
||||
label: dep.packageName,
|
||||
description: dep.usageDescription || undefined,
|
||||
icon: Package,
|
||||
}))
|
||||
|
||||
return (
|
||||
<div className={cn("p-6 space-y-6 w-full", className)}>
|
||||
{/* 基本信息 */}
|
||||
<DetailSection title="基本信息" icon={FileCode}>
|
||||
<DetailFieldGroup columns={2}>
|
||||
<DetailField
|
||||
label="文件路径"
|
||||
value={<DetailCopyable value={fileDetail.path} truncate maxLength={100} />}
|
||||
/>
|
||||
<DetailField
|
||||
label="文件名"
|
||||
value={fileDetail.fileName}
|
||||
copyable
|
||||
/>
|
||||
<DetailField
|
||||
label="文件类型"
|
||||
value={
|
||||
<Badge variant="outline">
|
||||
{fileDetail.fileType?.name || fileDetail.fileTypeId}
|
||||
</Badge>
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label="Commit ID"
|
||||
value={
|
||||
fileDetail.commitId ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<code className={cn("text-xs font-mono px-2 py-1 rounded", fileDetail.commitId.endsWith('*') ? "bg-default" : "bg-secondary")}>
|
||||
{fileDetail.commitId}
|
||||
</code>
|
||||
{fileDetail.commitId.endsWith('*') && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已修改
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">无提交</span>
|
||||
)
|
||||
}
|
||||
/>
|
||||
<DetailField
|
||||
label="创建时间"
|
||||
value={formatDate(fileDetail.createdAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
<DetailField
|
||||
label="最后分析时间"
|
||||
value={formatDate(fileDetail.lastAnalyzedAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
</DetailFieldGroup>
|
||||
</DetailSection>
|
||||
|
||||
{/* 功能描述 */}
|
||||
<DetailSection title="功能描述" icon={FileCode}>
|
||||
<div className="space-y-4">
|
||||
<DetailField
|
||||
label="功能摘要"
|
||||
value={fileDetail.summary}
|
||||
/>
|
||||
<DetailField
|
||||
label="详细描述"
|
||||
value={
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{fileDetail.description}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
|
||||
{/* 导出成员 */}
|
||||
{exportedMembers.length > 0 && (
|
||||
<DetailSection
|
||||
title="导出成员"
|
||||
description={`共 ${exportedMembers.length} 个导出成员`}
|
||||
icon={Code2}
|
||||
collapsible
|
||||
defaultOpen={true}
|
||||
>
|
||||
<DetailBadgeList
|
||||
items={[]}
|
||||
grouped
|
||||
groups={groupedMembers}
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* 依赖文件 */}
|
||||
{fileDetail.dependencies && fileDetail.dependencies.length > 0 && (
|
||||
<DetailSection
|
||||
title="依赖文件"
|
||||
description={`共 ${fileDetail.dependencies.length} 个依赖`}
|
||||
icon={LinkIcon}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<DetailList
|
||||
items={dependencyItems}
|
||||
searchable
|
||||
maxHeight="300px"
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* 包依赖 */}
|
||||
{fileDetail.pkgDependencies && fileDetail.pkgDependencies.length > 0 && (
|
||||
<DetailSection
|
||||
title="包依赖"
|
||||
description={`共 ${fileDetail.pkgDependencies.length} 个依赖包`}
|
||||
icon={Package}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
>
|
||||
<DetailList
|
||||
items={pkgDependencyItems}
|
||||
searchable
|
||||
maxHeight="300px"
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* 标签 */}
|
||||
{fileDetail.tags && fileDetail.tags.length > 0 && (
|
||||
<DetailSection
|
||||
title="标签"
|
||||
icon={Tag}
|
||||
>
|
||||
<DetailBadgeList
|
||||
items={fileDetail.tags.map(tag => ({
|
||||
label: tag,
|
||||
variant: 'secondary' as const,
|
||||
}))}
|
||||
/>
|
||||
</DetailSection>
|
||||
)}
|
||||
|
||||
{/* Git变更历史 */}
|
||||
<DetailSection
|
||||
title="Git变更历史"
|
||||
description="实时获取文件的所有Git提交记录"
|
||||
icon={GitCommit}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
onOpenChange={(isOpen) => {
|
||||
// 当展开时,启用历史加载
|
||||
if (isOpen && !shouldLoadGitHistory) {
|
||||
setShouldLoadGitHistory(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isGitHistoryLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
) : isGitHistoryError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
加载失败: {gitHistoryError?.message || '未知错误'}
|
||||
</div>
|
||||
) : !gitHistory || gitHistory.length === 0 ? (
|
||||
<Timeline>
|
||||
<TimelineEmpty>暂无Git变更记录</TimelineEmpty>
|
||||
</Timeline>
|
||||
) : (
|
||||
<Timeline className="break-all">
|
||||
{gitHistory.map((item, index) => {
|
||||
// 获取上一个commit ID(用于对比)
|
||||
const previousCommitId = index < gitHistory.length - 1 ? gitHistory[index + 1]?.commitId : undefined;
|
||||
|
||||
return (
|
||||
<TimelineItem key={item.commitId}>
|
||||
<TimelineConnector />
|
||||
<TimelineNode icon={GitCommit} />
|
||||
<TimelineContent>
|
||||
<TimelineHeader>
|
||||
<TimelineTitleArea>
|
||||
<TimelineTitle>Commit {item.commitId}</TimelineTitle>
|
||||
<TimelineBadge variant={gitActionVariants[item.action]}>
|
||||
{gitActionLabels[item.action]}
|
||||
</TimelineBadge>
|
||||
</TimelineTitleArea>
|
||||
<TimelineActions>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setCommitViewDialog({
|
||||
open: true,
|
||||
commitId: item.commitId,
|
||||
previousCommitId,
|
||||
});
|
||||
}}
|
||||
className="h-7 text-xs"
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
</TimelineActions>
|
||||
</TimelineHeader>
|
||||
<TimelineTimestamp timestamp={item.timestamp} />
|
||||
{item.oldPath && (
|
||||
<TimelineDescription>
|
||||
从 {item.oldPath} 重命名
|
||||
</TimelineDescription>
|
||||
)}
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
);
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* 文件内容 */}
|
||||
<DetailSection
|
||||
title="文件内容"
|
||||
description="文件的完整源代码内容"
|
||||
icon={Code2}
|
||||
collapsible
|
||||
defaultOpen={false}
|
||||
onOpenChange={(isOpen) => {
|
||||
// 当展开时,启用内容加载
|
||||
if (isOpen && !shouldLoadContent) {
|
||||
setShouldLoadContent(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isContentLoading ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
加载中...
|
||||
</div>
|
||||
) : isContentError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
加载失败: {contentError?.message || '未知错误'}
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<DetailCodeBlock
|
||||
code={fileContent}
|
||||
language={getLanguageFromPath(path)}
|
||||
title={name}
|
||||
maxHeight="1000px"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
暂无内容
|
||||
</div>
|
||||
)}
|
||||
</DetailSection>
|
||||
|
||||
{/* Git Commit查看对话框 */}
|
||||
<GitCommitViewDialog
|
||||
open={commitViewDialog.open}
|
||||
onOpenChange={(open) => setCommitViewDialog(prev => ({ ...prev, open }))}
|
||||
fileId={fileId}
|
||||
filePath={path}
|
||||
commitId={commitViewDialog.commitId}
|
||||
previousCommitId={commitViewDialog.previousCommitId}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
66
src/app/(main)/dev/file/components/FileDetailSheet.tsx
Normal file
66
src/app/(main)/dev/file/components/FileDetailSheet.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { FileCode } from 'lucide-react'
|
||||
import {
|
||||
DetailSheet,
|
||||
DetailHeader,
|
||||
} from '@/components/data-details'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
|
||||
import { SheetDescription, SheetTitle } from '@/components/ui/sheet'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { FileDetailPanel } from './FileDetailPanel'
|
||||
|
||||
|
||||
export interface FileDetailSheetProps {
|
||||
file: DevAnalyzedFile | null
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件详情展示Sheet
|
||||
* 使用通用详情展示框架展示DevAnalyzedFile对象的完整信息
|
||||
*/
|
||||
export function FileDetailSheet({
|
||||
file,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FileDetailSheetProps) {
|
||||
if (!file) return null
|
||||
|
||||
return (
|
||||
<DetailSheet
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
width="xl"
|
||||
header={
|
||||
<SheetTitle title={file.fileName}>
|
||||
<DetailHeader
|
||||
title={file.fileName}
|
||||
subtitle={
|
||||
<>
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{file.fileType?.name || file.fileTypeId}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
ID: {file.id}
|
||||
</span>
|
||||
</>
|
||||
}
|
||||
icon={<FileCode className="h-6 w-6" />}
|
||||
/>
|
||||
</SheetTitle>
|
||||
}
|
||||
description={<VisuallyHidden><SheetDescription>{ file.path }</SheetDescription></VisuallyHidden>}
|
||||
>
|
||||
<FileDetailPanel
|
||||
fileId={file.id}
|
||||
path={file.path}
|
||||
name={file.fileName}
|
||||
className='p-0 space-y-4'
|
||||
/>
|
||||
</DetailSheet>
|
||||
)
|
||||
}
|
||||
129
src/app/(main)/dev/file/components/GitCommitViewDialog.tsx
Normal file
129
src/app/(main)/dev/file/components/GitCommitViewDialog.tsx
Normal file
@@ -0,0 +1,129 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { DetailCodeBlock } from '@/components/data-details'
|
||||
import { getLanguageFromPath } from '@/lib/utils'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export interface GitCommitViewDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
fileId: number
|
||||
filePath: string
|
||||
commitId: string
|
||||
previousCommitId?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Git Commit查看对话框
|
||||
* 显示指定commit的文件内容和与上个版本的差异
|
||||
*/
|
||||
export function GitCommitViewDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
fileId,
|
||||
filePath,
|
||||
commitId,
|
||||
previousCommitId,
|
||||
}: GitCommitViewDialogProps) {
|
||||
// 获取当前commit的文件内容
|
||||
const { data: fileContent, isLoading: isContentLoading, isError: isContentError, error: contentError } =
|
||||
trpc.devFile!.getFileContentAtCommit.useQuery(
|
||||
{ id: fileId, commitId },
|
||||
{ enabled: open }
|
||||
)
|
||||
|
||||
// 获取与上个版本的差异
|
||||
const { data: fileDiff, isLoading: isDiffLoading, isError: isDiffError, error: diffError } =
|
||||
trpc.devFile!.getFileDiffBetweenCommits.useQuery(
|
||||
{
|
||||
id: fileId,
|
||||
oldCommitId: previousCommitId || '',
|
||||
newCommitId: commitId
|
||||
},
|
||||
{ enabled: open && !!previousCommitId }
|
||||
)
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-5xl max-h-[90vh] flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle>查看 Commit {commitId}</DialogTitle>
|
||||
<DialogDescription>{filePath}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs defaultValue="content" className="flex-1 flex flex-col min-h-0">
|
||||
<TabsList className="grid w-full max-w-md grid-cols-2">
|
||||
<TabsTrigger value="content">文件内容</TabsTrigger>
|
||||
<TabsTrigger value="diff" disabled={!previousCommitId}>
|
||||
变更对比
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="content" className="flex-1 overflow-auto mt-4">
|
||||
{isContentLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : isContentError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
加载失败: {contentError?.message || '未知错误'}
|
||||
</div>
|
||||
) : fileContent ? (
|
||||
<DetailCodeBlock
|
||||
code={fileContent}
|
||||
language={getLanguageFromPath(filePath)}
|
||||
title={`${filePath} @ ${commitId}`}
|
||||
maxHeight="600px"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
文件在此版本不存在
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="diff" className="flex-1 overflow-auto mt-4">
|
||||
{!previousCommitId ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
这是第一个版本,没有可对比的内容
|
||||
</div>
|
||||
) : isDiffLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
</div>
|
||||
) : isDiffError ? (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-destructive">
|
||||
加载失败: {diffError?.message || '未知错误'}
|
||||
</div>
|
||||
) : fileDiff ? (
|
||||
<DetailCodeBlock
|
||||
code={fileDiff}
|
||||
language="diff"
|
||||
title={`${previousCommitId}...${commitId}`}
|
||||
maxHeight="600px"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center py-8 text-sm text-muted-foreground">
|
||||
无变更
|
||||
</div>
|
||||
)}
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FolderSearch } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { TaskDialog, BaseTaskProgress } from '@/components/common/task-dialog'
|
||||
import type { AnalyzeFoldersProgress } from '@/server/queues'
|
||||
|
||||
/**
|
||||
* 扩展的分析进度类型
|
||||
*/
|
||||
interface AnalyzeProgress extends BaseTaskProgress, AnalyzeFoldersProgress {}
|
||||
|
||||
interface FolderAnalyzeDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
jobId: string | null
|
||||
onAnalyzeCompleted: () => void
|
||||
}
|
||||
|
||||
interface FolderAnalyzeTriggerProps {
|
||||
onStartAnalyze: () => void
|
||||
isStarting: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分析触发器按钮
|
||||
*/
|
||||
export function FolderAnalyzeTrigger({
|
||||
onStartAnalyze,
|
||||
isStarting
|
||||
}: FolderAnalyzeTriggerProps) {
|
||||
return (
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={onStartAnalyze}
|
||||
disabled={isStarting}
|
||||
className="w-full"
|
||||
>
|
||||
<FolderSearch className="mr-2 h-4 w-4" />
|
||||
{isStarting ? '启动中...' : '启动文件夹分析'}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹分析进度对话框
|
||||
*/
|
||||
export function FolderAnalyzeDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
jobId,
|
||||
onAnalyzeCompleted
|
||||
}: FolderAnalyzeDialogProps) {
|
||||
// 停止分析任务 mutation
|
||||
const cancelMutation = trpc.devFile!.cancelAnalyzeFoldersJob.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('已发送停止请求')
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '停止任务失败')
|
||||
},
|
||||
})
|
||||
|
||||
// 停止任务
|
||||
const handleCancelTask = async (taskJobId: string) => {
|
||||
await cancelMutation.mutateAsync({ jobId: taskJobId })
|
||||
}
|
||||
|
||||
// 自定义状态消息渲染
|
||||
const renderStatusMessage = (progress: AnalyzeProgress) => {
|
||||
if (progress.state === 'waiting') {
|
||||
return '任务等待中...'
|
||||
} else if (progress.state === 'active') {
|
||||
if (progress.currentFolder) {
|
||||
return `正在分析: ${progress.currentFolder}`
|
||||
}
|
||||
return '正在分析文件夹...'
|
||||
} else if (progress.state === 'completed') {
|
||||
const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0)
|
||||
const failedCount = progress.failedFolders || 0
|
||||
|
||||
const parts = [`成功 ${successCount} 个`]
|
||||
if (failedCount > 0) {
|
||||
parts.push(`失败 ${failedCount} 个`)
|
||||
}
|
||||
parts.push(`共 ${progress.totalFolders || 0} 个文件夹`)
|
||||
|
||||
return `分析完成!${parts.join(',')}`
|
||||
} else if (progress.state === 'failed') {
|
||||
return progress.error || '分析失败'
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
// 自定义详细信息渲染
|
||||
const renderDetails = (progress: AnalyzeProgress) => {
|
||||
if (progress.totalFolders === undefined && progress.analyzedFolders === undefined) {
|
||||
return null
|
||||
}
|
||||
|
||||
const successCount = (progress.analyzedFolders || 0) - (progress.failedFolders || 0)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 进度统计 */}
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
{progress.totalFolders !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">文件夹总数:</span>
|
||||
<span className="ml-1 font-medium">{progress.totalFolders}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.analyzedFolders !== undefined && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">已分析:</span>
|
||||
<span className="ml-1 font-medium">{progress.analyzedFolders}</span>
|
||||
</div>
|
||||
)}
|
||||
{successCount > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">成功:</span>
|
||||
<span className="ml-1 font-medium text-green-600">{successCount}</span>
|
||||
</div>
|
||||
)}
|
||||
{progress.failedFolders !== undefined && progress.failedFolders > 0 && (
|
||||
<div>
|
||||
<span className="text-muted-foreground">失败:</span>
|
||||
<span className="ml-1 font-medium text-red-600">{progress.failedFolders}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前处理的文件夹 */}
|
||||
{progress.currentFolder && progress.state === 'active' && (
|
||||
<div className="rounded-md bg-muted p-3 text-sm">
|
||||
<div className="text-muted-foreground mb-1">当前文件夹:</div>
|
||||
<div className="font-mono text-xs break-all">{progress.currentFolder}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 最近的错误信息 */}
|
||||
{progress.recentErrors && progress.recentErrors.length > 0 && (
|
||||
<div className="rounded-md border border-red-200 bg-red-50 p-3 text-sm">
|
||||
<div className="text-red-800 font-medium mb-2">最近的错误 (最多显示10条):</div>
|
||||
<div className="space-y-2 max-h-40 overflow-y-auto">
|
||||
{progress.recentErrors.map((err, index) => (
|
||||
<div key={index} className="text-xs">
|
||||
<div className="font-mono text-red-700 break-all">{err.folderPath}</div>
|
||||
<div className="text-red-600 mt-1">{err.error}</div>
|
||||
{index < progress.recentErrors!.length - 1 && (
|
||||
<div className="border-t border-red-200 mt-2" />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<TaskDialog<AnalyzeProgress>
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
useSubscription={trpc.jobs.subscribeAnalyzeFoldersProgress.useSubscription}
|
||||
jobId={jobId}
|
||||
title="文件夹分析进度"
|
||||
description="正在使用AI分析项目文件夹,请稍候..."
|
||||
onCancelTask={handleCancelTask}
|
||||
onTaskCompleted={onAnalyzeCompleted}
|
||||
isCancelling={cancelMutation.isPending}
|
||||
renderStatusMessage={renderStatusMessage}
|
||||
renderDetails={renderDetails}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FolderIcon, Calendar, FileText } from 'lucide-react';
|
||||
import {
|
||||
DetailSection,
|
||||
DetailField,
|
||||
DetailFieldGroup,
|
||||
} from '@/components/data-details';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { formatDate } from '@/lib/format';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export interface FolderDetailPanelProps {
|
||||
/** 文件夹路径 */
|
||||
path: string;
|
||||
/** 文件夹名称 */
|
||||
name: string;
|
||||
/** 子项数量 */
|
||||
childrenCount?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件夹详情面板组件
|
||||
* 显示文件夹的详细信息,包括从数据库获取的分析结果
|
||||
*/
|
||||
export function FolderDetailPanel({
|
||||
path,
|
||||
name,
|
||||
childrenCount = 0,
|
||||
}: FolderDetailPanelProps) {
|
||||
// 查询文件夹的详细信息
|
||||
const { data: folderDetail, isLoading, isError, error } = trpc.devFile!.getFolderDetail.useQuery(
|
||||
{ path },
|
||||
{ enabled: !!path }
|
||||
);
|
||||
if (error) {
|
||||
toast.error("获取文件夹详情失败:" + error.toString().substring(0, 100))
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-2xl w-full">
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-8 w-64" />
|
||||
<Skeleton className="h-4 w-full" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError || !folderDetail) {
|
||||
return (
|
||||
<div className="p-8 space-y-4 max-w-2xl w-full">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderIcon className="size-6 text-muted-foreground" />
|
||||
<h2 className="text-2xl font-bold">{name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">类型</h3>
|
||||
<Badge variant="outline">文件夹</Badge>
|
||||
</div>
|
||||
|
||||
{childrenCount > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold text-muted-foreground">
|
||||
子项数量
|
||||
</h3>
|
||||
<p className="text-sm">{childrenCount} 项</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isError && (
|
||||
<div className="mt-4 p-4 rounded-lg bg-muted/50">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
暂无详细分析信息
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-8 space-y-6 max-w-2xl w-full">
|
||||
{/* 标题区域 */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<FolderIcon className="size-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold">{name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono">
|
||||
{path}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 基本信息 */}
|
||||
<DetailSection title="基本信息" icon={FolderIcon}>
|
||||
<DetailFieldGroup columns={2}>
|
||||
<DetailField
|
||||
label="类型"
|
||||
value={<Badge variant="outline">文件夹</Badge>}
|
||||
/>
|
||||
{childrenCount > 0 && (
|
||||
<DetailField
|
||||
label="子项数量"
|
||||
value={`${childrenCount} 项`}
|
||||
/>
|
||||
)}
|
||||
<DetailField
|
||||
label="创建时间"
|
||||
value={formatDate(folderDetail.createdAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
<DetailField
|
||||
label="最后分析时间"
|
||||
value={formatDate(folderDetail.lastAnalyzedAt, "PPP HH:mm:ss")}
|
||||
/>
|
||||
</DetailFieldGroup>
|
||||
</DetailSection>
|
||||
|
||||
{/* 功能描述 */}
|
||||
<DetailSection title="功能描述" icon={FileText}>
|
||||
<div className="space-y-4">
|
||||
<DetailField
|
||||
label="功能摘要"
|
||||
value={folderDetail.summary}
|
||||
/>
|
||||
<DetailField
|
||||
label="详细描述"
|
||||
value={
|
||||
<div className="text-sm whitespace-pre-wrap leading-relaxed text-foreground/90">
|
||||
{folderDetail.description}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</DetailSection>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,312 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { Tree, TreeItem, TreeItemLabel } from '@/components/ui/tree';
|
||||
import { hotkeysCoreFeature, syncDataLoaderFeature, searchFeature, expandAllFeature } from '@headless-tree/core';
|
||||
import { useTree } from '@headless-tree/react';
|
||||
import { FolderIcon, FolderOpenIcon, FileIcon } from 'lucide-react';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { SourceFileIcon } from '@/components/icons/code-lang';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDebouncedCallback } from '@/hooks/use-debounced-callback';
|
||||
import type { FileTreeItem } from '@/server/routers/dev/file';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
|
||||
// 将树形结构扁平化为 Record 格式
|
||||
function flattenTree(item: FileTreeItem, items: Record<string, FileTreeItem> = {}): Record<string, FileTreeItem> {
|
||||
items[item.path] = item;
|
||||
if (item.children) {
|
||||
item.children.forEach(child => flattenTree(child, items));
|
||||
}
|
||||
return items;
|
||||
}
|
||||
|
||||
// 获取文件扩展名
|
||||
function getFileExtension(filename: string): string {
|
||||
const lastDot = filename.lastIndexOf('.');
|
||||
return lastDot > 0 ? filename.substring(lastDot + 1) : '';
|
||||
}
|
||||
|
||||
const indent = 20;
|
||||
|
||||
export interface SearchDirectoryTreeProps {
|
||||
/** 树形数据根节点 */
|
||||
data: FileTreeItem;
|
||||
/** 初始展开的节点路径列表 */
|
||||
initialExpandedItems?: string[];
|
||||
/** 是否显示摘要 */
|
||||
showSummary?: boolean;
|
||||
/** 列表项被选中时的回调(点击即选中) */
|
||||
onItemSelect?: (item: FileTreeItem) => void;
|
||||
/** 当前选中项的路径 */
|
||||
selectedItemPath?: string | null;
|
||||
/** 自定义类名 */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 可搜索的目录树组件
|
||||
*
|
||||
* 功能特性:
|
||||
* - 支持搜索文件名、路径和摘要
|
||||
* - 搜索时自动展开匹配项的父节点
|
||||
* - 支持显示/隐藏摘要信息
|
||||
* - 支持点击选中回调
|
||||
* - 文件图标根据扩展名自动显示
|
||||
*/
|
||||
export function SearchDirectoryTree({
|
||||
data,
|
||||
initialExpandedItems = ['', 'src', 'src/app', 'src/components', 'src/server'],
|
||||
showSummary: showSummaryProp = false,
|
||||
onItemSelect,
|
||||
selectedItemPath,
|
||||
className,
|
||||
}: SearchDirectoryTreeProps) {
|
||||
const [showSummary, setShowSummary] = useState(showSummaryProp);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [debouncedSearchValue, setDebouncedSearchValue] = useState('');
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>(initialExpandedItems);
|
||||
const [, setSavedExpandedItems] = useState<string[] | null>(null);
|
||||
|
||||
// 扁平化树形数据
|
||||
const flatItems = useMemo(() => flattenTree(data), [data]);
|
||||
|
||||
// 搜索匹配逻辑(可复用)
|
||||
const isItemMatching = React.useCallback((itemData: FileTreeItem, search: string) => {
|
||||
const lowerSearch = search.toLowerCase();
|
||||
return (
|
||||
itemData.name.toLowerCase().includes(lowerSearch) ||
|
||||
itemData.path.toLowerCase().includes(lowerSearch) ||
|
||||
itemData.summary?.toLowerCase().includes(lowerSearch) || false
|
||||
);
|
||||
}, []);
|
||||
|
||||
// 在所有项中搜索(包括未展开的)
|
||||
const searchAllItems = React.useCallback((search: string) => {
|
||||
if (!search) return [];
|
||||
|
||||
const matchedPaths: string[] = [];
|
||||
Object.values(flatItems).forEach(item => {
|
||||
if (isItemMatching(item, search)) {
|
||||
matchedPaths.push(item.path);
|
||||
}
|
||||
});
|
||||
|
||||
return matchedPaths;
|
||||
}, [flatItems, isItemMatching]);
|
||||
|
||||
// 获取所有匹配项及其父节点路径
|
||||
const getMatchedItemsAndParents = React.useCallback((search: string) => {
|
||||
if (!search) return new Set<string>();
|
||||
|
||||
const matchedPaths = new Set<string>();
|
||||
|
||||
// 找到所有匹配的项
|
||||
Object.values(flatItems).forEach(item => {
|
||||
if (isItemMatching(item, search)) {
|
||||
matchedPaths.add(item.path);
|
||||
|
||||
// 添加所有父节点路径
|
||||
const parts = item.path.split('/').filter(Boolean);
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = parts.slice(0, i).join('/');
|
||||
matchedPaths.add(parentPath);
|
||||
}
|
||||
// 添加根节点
|
||||
if (parts.length > 0) {
|
||||
matchedPaths.add('');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return matchedPaths;
|
||||
}, [flatItems, isItemMatching]);
|
||||
|
||||
// 防抖更新搜索值
|
||||
const debouncedSetSearchValue = useDebouncedCallback((value: string) => {
|
||||
setDebouncedSearchValue(value);
|
||||
}, 300);
|
||||
|
||||
// 初始化树
|
||||
const tree = useTree<FileTreeItem>({
|
||||
state: {
|
||||
expandedItems,
|
||||
},
|
||||
setExpandedItems,
|
||||
indent,
|
||||
rootItemId: data.path,
|
||||
getItemName: (item) => item.getItemData().name,
|
||||
isItemFolder: (item) => item.getItemData().isFolder,
|
||||
// 自定义搜索匹配逻辑
|
||||
isSearchMatchingItem: (search: string, item) => {
|
||||
return isItemMatching(item.getItemData(), search);
|
||||
},
|
||||
dataLoader: {
|
||||
getItem: (itemId) => flatItems[itemId],
|
||||
getChildren: (itemId) => {
|
||||
const item = flatItems[itemId];
|
||||
if (!item) return [];
|
||||
return item.children?.map(child => child.path) ?? [];
|
||||
},
|
||||
},
|
||||
features: [syncDataLoaderFeature, hotkeysCoreFeature, searchFeature, expandAllFeature],
|
||||
});
|
||||
|
||||
// 当防抖后的搜索值变化时,处理展开状态
|
||||
React.useEffect(() => {
|
||||
if (debouncedSearchValue) {
|
||||
// 开始搜索时,保存当前展开状态
|
||||
setSavedExpandedItems(prev => {
|
||||
if (prev === null) {
|
||||
return expandedItems;
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
// 在所有项中搜索(包括未展开的)
|
||||
const matchedPaths = searchAllItems(debouncedSearchValue);
|
||||
const itemsToExpand = new Set<string>();
|
||||
|
||||
// 收集所有匹配项的父节点路径
|
||||
matchedPaths.forEach(path => {
|
||||
const parts = path.split('/').filter(Boolean);
|
||||
|
||||
// 构建所有父路径
|
||||
for (let i = 1; i < parts.length; i++) {
|
||||
const parentPath = parts.slice(0, i).join('/');
|
||||
itemsToExpand.add(parentPath);
|
||||
}
|
||||
});
|
||||
|
||||
setExpandedItems(Array.from(itemsToExpand));
|
||||
} else {
|
||||
// 搜索值为空时,恢复之前保存的展开状态
|
||||
setSavedExpandedItems(prev => {
|
||||
if (prev !== null) {
|
||||
setExpandedItems(prev);
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [debouncedSearchValue, searchAllItems]);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col h-full', className)}>
|
||||
{/* 搜索和选项区域 */}
|
||||
<div className="p-4 border-b flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1 relative">
|
||||
<input
|
||||
{...tree.getSearchInputElementProps()}
|
||||
value={searchValue}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value;
|
||||
setSearchValue(value);
|
||||
debouncedSetSearchValue(value);
|
||||
tree.getSearchInputElementProps().onChange?.(e);
|
||||
}}
|
||||
placeholder="搜索文件、路径或摘要..."
|
||||
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm transition-colors 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:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
|
||||
/>
|
||||
{tree.isSearchOpen() && debouncedSearchValue && (
|
||||
<span className="absolute right-3 top-1/2 -translate-y-1/2 text-xs text-muted-foreground">
|
||||
{searchAllItems(debouncedSearchValue).length} 个匹配
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-2 flex-shrink-0">
|
||||
<Checkbox
|
||||
id="show-summary"
|
||||
checked={showSummary}
|
||||
onCheckedChange={(checked) => setShowSummary(checked === true)}
|
||||
/>
|
||||
<Label
|
||||
htmlFor="show-summary"
|
||||
className="text-sm font-normal cursor-pointer whitespace-nowrap"
|
||||
>
|
||||
显示摘要
|
||||
</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 树形列表 */}
|
||||
<ScrollArea>
|
||||
<div className="flex-1 p-4">
|
||||
<Tree
|
||||
className="relative before:absolute before:inset-0 before:-ms-1 before:bg-[repeating-linear-gradient(to_right,transparent_0,transparent_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)-1px),var(--border)_calc(var(--tree-indent)))]"
|
||||
indent={indent}
|
||||
tree={tree}
|
||||
>
|
||||
{tree.getItems().map((item) => {
|
||||
const itemData = item.getItemData();
|
||||
const isMatched = item.isMatchingSearch();
|
||||
const extension = !itemData.isFolder ? getFileExtension(itemData.name) : '';
|
||||
|
||||
// 搜索时隐藏不匹配的节点(但保留匹配节点的父节点)
|
||||
const matchedItemsAndParents = getMatchedItemsAndParents(debouncedSearchValue);
|
||||
const shouldHide = debouncedSearchValue && !matchedItemsAndParents.has(item.getId());
|
||||
|
||||
if (shouldHide) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const isSelected = selectedItemPath === item.getId();
|
||||
|
||||
return (
|
||||
<TreeItem key={item.getId()} item={item} data-search-match={undefined}>{/* data-search-match 默认样式比较丑,这里自己实现了就不要了这个data slot了 */}
|
||||
<TreeItemLabel
|
||||
className={cn(
|
||||
'before:bg-background relative before:absolute before:inset-x-0 before:-inset-y-0.5 before:-z-10',
|
||||
isMatched && 'bg-blue-50 dark:bg-blue-950/30',
|
||||
isSelected && 'bg-primary/10 dark:bg-primary/20 font-semibold'
|
||||
)}
|
||||
onClick={() => {
|
||||
// 点击时触发选中回调
|
||||
if (onItemSelect) {
|
||||
onItemSelect(itemData);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex items-start gap-2 flex-1 min-w-0 group">
|
||||
<div className="flex items-center gap-2 flex-shrink-0">
|
||||
{itemData.isFolder ? (
|
||||
item.isExpanded() ? (
|
||||
<FolderOpenIcon className="text-muted-foreground size-4" />
|
||||
) : (
|
||||
<FolderIcon className="text-muted-foreground size-4" />
|
||||
)
|
||||
) : extension ? (
|
||||
<SourceFileIcon
|
||||
extension={extension}
|
||||
className="size-4"
|
||||
color="currentColor"
|
||||
/>
|
||||
) : (
|
||||
<FileIcon className="text-muted-foreground size-4" />
|
||||
)}
|
||||
<span className="font-medium">{itemData.name}</span>
|
||||
</div>
|
||||
|
||||
{itemData.summary && (
|
||||
<span className={cn(
|
||||
"text-xs text-muted-foreground truncate flex-1 min-w-0",
|
||||
!showSummary && "hidden group-hover:inline"
|
||||
)}>
|
||||
{itemData.summary}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</TreeItemLabel>
|
||||
</TreeItem>
|
||||
);
|
||||
})}
|
||||
</Tree>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
169
src/app/(main)/dev/file/directory-tree/page.dev.tsx
Normal file
169
src/app/(main)/dev/file/directory-tree/page.dev.tsx
Normal file
@@ -0,0 +1,169 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { FileCode, FileIcon, Loader2 } from 'lucide-react';
|
||||
import type { FileTreeItem } from '@/server/routers/dev/file';
|
||||
import { SearchDirectoryTree } from './components/SearchDirectoryTree';
|
||||
import { CarouselLayout, CarouselColumn } from '@/components/layout/carousel-layout';
|
||||
import { FolderAnalyzeTrigger, FolderAnalyzeDialog } from './components/FolderAnalyzeDialog';
|
||||
import { FolderDetailPanel } from './components/FolderDetailPanel';
|
||||
import { FileDetailPanel } from '../components/FileDetailPanel';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
export default function DirectoryTreePage() {
|
||||
const [selectedItem, setSelectedItem] = useState<FileTreeItem | null>(null);
|
||||
const [isAnalyzeDialogOpen, setIsAnalyzeDialogOpen] = useState(false);
|
||||
const [analyzeJobId, setAnalyzeJobId] = useState<string | null>(null);
|
||||
|
||||
// 获取目录树数据
|
||||
const { data: fileTree, isLoading, error, refetch } = trpc.devFile!.getDirectoryTree.useQuery();
|
||||
if (error) {
|
||||
toast.error("获取目录树失败:" + error.toString().substring(0, 100))
|
||||
}
|
||||
|
||||
// 启动文件夹分析任务
|
||||
const startAnalyzeMutation = trpc.devFile!.startAnalyzeFolders.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setAnalyzeJobId(data.jobId as string);
|
||||
setIsAnalyzeDialogOpen(true);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '启动文件夹分析失败');
|
||||
},
|
||||
});
|
||||
|
||||
// 处理列表项选中
|
||||
const handleItemSelect = (item: FileTreeItem) => {
|
||||
setSelectedItem(item);
|
||||
};
|
||||
|
||||
// 启动分析
|
||||
const handleStartAnalyze = () => {
|
||||
startAnalyzeMutation.mutate();
|
||||
};
|
||||
|
||||
// 分析完成回调
|
||||
const handleAnalyzeCompleted = () => {
|
||||
console.log('文件夹分析完成');
|
||||
// 重新获取目录树数据
|
||||
refetch();
|
||||
};
|
||||
|
||||
// 详情内容组件
|
||||
const DetailContent = () => {
|
||||
if (!selectedItem) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="text-center space-y-4">
|
||||
<FileIcon className="size-16 text-muted-foreground mx-auto" />
|
||||
<p className="text-muted-foreground text-sm">
|
||||
选择一个文件或文件夹查看详情
|
||||
</p>
|
||||
{/* 文件夹分析按钮 */}
|
||||
<div className="pt-4 border-t">
|
||||
<FolderAnalyzeTrigger
|
||||
onStartAnalyze={handleStartAnalyze}
|
||||
isStarting={startAnalyzeMutation.isPending}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground mt-2 text-center">
|
||||
使用 AI 分析项目文件夹结构和用途
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 根据选中项类型显示对应的详情组件
|
||||
if (selectedItem.isFolder) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-full py-8 shadow-inner">
|
||||
<FolderDetailPanel
|
||||
path={selectedItem.path}
|
||||
name={selectedItem.name}
|
||||
childrenCount={selectedItem.children?.length || 0}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
} else {
|
||||
return (<div className='shadow-inner'>
|
||||
<div className="space-y-2 p-6">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileCode className="size-6 text-primary" />
|
||||
<h2 className="text-2xl font-bold">{selectedItem.name}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground font-mono break-all">
|
||||
{selectedItem.path}
|
||||
</p>
|
||||
</div>
|
||||
<FileDetailPanel
|
||||
fileId={selectedItem.fileId!}
|
||||
path={selectedItem.path}
|
||||
name={selectedItem.name}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
// 配置列
|
||||
const columns: CarouselColumn[] = [
|
||||
{
|
||||
id: 'tree',
|
||||
title: '目录树',
|
||||
content: isLoading ? (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<Loader2 className="size-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : error ? (
|
||||
<div className="flex flex-col items-center justify-center h-full space-y-4 p-4">
|
||||
<p className="text-destructive text-sm">加载失败: {error.message}</p>
|
||||
<button
|
||||
onClick={() => refetch()}
|
||||
className="text-sm text-primary hover:underline"
|
||||
>
|
||||
重试
|
||||
</button>
|
||||
</div>
|
||||
) : fileTree ? (
|
||||
<SearchDirectoryTree
|
||||
data={fileTree}
|
||||
onItemSelect={handleItemSelect}
|
||||
selectedItemPath={selectedItem?.path ?? null}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-muted-foreground text-sm">暂无数据</p>
|
||||
</div>
|
||||
),
|
||||
desktopClassName: 'w-1/3 border-r',
|
||||
mobileClassName: '',
|
||||
},
|
||||
{
|
||||
id: 'detail',
|
||||
title: '详情',
|
||||
content: <DetailContent />,
|
||||
desktopClassName: 'w-2/3 bg-muted/20',
|
||||
mobileClassName: 'bg-muted/20',
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<>
|
||||
<CarouselLayout
|
||||
columns={columns}
|
||||
defaultActiveIndex={0}
|
||||
className="h-[calc(100vh-14rem)]"
|
||||
/>
|
||||
|
||||
{/* 文件夹分析进度对话框 */}
|
||||
<FolderAnalyzeDialog
|
||||
open={isAnalyzeDialogOpen}
|
||||
onOpenChange={setIsAnalyzeDialogOpen}
|
||||
jobId={analyzeJobId}
|
||||
onAnalyzeCompleted={handleAnalyzeCompleted}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
61
src/app/(main)/dev/file/graph/page.dev.tsx
Normal file
61
src/app/(main)/dev/file/graph/page.dev.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { FileDetailSheet } from '../components/FileDetailSheet'
|
||||
import { FileDependencyGraph } from '../components/FileDependencyGraph'
|
||||
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
|
||||
import { toast } from 'sonner'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
|
||||
export default function FileGraphPage() {
|
||||
// 用于刷新数据的 utils
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 详情Sheet状态
|
||||
const [selectedFile, setSelectedFile] = useState<DevAnalyzedFile | null>(null)
|
||||
const [detailSheetOpen, setDetailSheetOpen] = useState(false)
|
||||
|
||||
// 获取依赖图数据
|
||||
const { data: graphData, isLoading: isGraphLoading } = trpc.devFile!.getDependencyGraph.useQuery()
|
||||
|
||||
// 处理依赖图节点点击
|
||||
const handleGraphNodeClick = useCallback(async (node: { id: string; path: string }) => {
|
||||
try {
|
||||
const fileId = parseInt(node.id)
|
||||
// 通过 tRPC 查询文件详情
|
||||
const fileDetail = await utils.devFile!.getFileById.fetch({ id: fileId })
|
||||
setSelectedFile(fileDetail as DevAnalyzedFile)
|
||||
setDetailSheetOpen(true)
|
||||
} catch (error) {
|
||||
toast.error(`获取文件详情失败: ${error instanceof Error ? error.message : '未知错误'}`)
|
||||
}
|
||||
}, [utils])
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGraphLoading ? (
|
||||
<div className="space-y-4">
|
||||
<Skeleton className="h-[800px] max-h-[calc(100vh-12rem)] w-full" />
|
||||
</div>
|
||||
) : graphData ? (
|
||||
<FileDependencyGraph
|
||||
nodes={graphData.nodes}
|
||||
edges={graphData.edges}
|
||||
onNodeClick={handleGraphNodeClick}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-[800px] max-h-[calc(100vh-12rem)] border rounded-lg bg-muted/50">
|
||||
<p className="text-muted-foreground">暂无依赖关系数据</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件详情Sheet */}
|
||||
<FileDetailSheet
|
||||
file={selectedFile}
|
||||
open={detailSheetOpen}
|
||||
onOpenChange={setDetailSheetOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
9
src/app/(main)/dev/file/layout.dev.tsx
Normal file
9
src/app/(main)/dev/file/layout.dev.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
|
||||
|
||||
export default function FilePageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SubMenuLayout parentHref="/dev/file">{children}</SubMenuLayout>;
|
||||
}
|
||||
289
src/app/(main)/dev/file/list/page.dev.tsx
Normal file
289
src/app/(main)/dev/file/list/page.dev.tsx
Normal file
@@ -0,0 +1,289 @@
|
||||
'use client'
|
||||
|
||||
import React, { useCallback, useMemo, useState, Suspense } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { FileAnalyzeDialog } from '../components/FileAnalyzeDialog'
|
||||
import { FileDetailSheet } from '../components/FileDetailSheet'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { DataTableToolbar } from '@/components/data-table/toolbar'
|
||||
import { createDevAnalyzedFileColumns, type DevAnalyzedFileColumnsOptions } from '../columns'
|
||||
import type { DevAnalyzedFile } from '@/server/routers/dev/file'
|
||||
import { useDataTable } from '@/hooks/use-data-table'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { DataTableSortList } from '@/components/data-table/sort-list'
|
||||
import { toast } from 'sonner'
|
||||
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { FileText, GitCommit, Package, Clock } from 'lucide-react'
|
||||
import { format } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { StatsCardGroup, StatsCardWrapper, type StatsCardItem } from '@/components/common/stats-card-group'
|
||||
|
||||
// 计算相对时间描述
|
||||
function getRelativeTimeLabel(date: Date | string | null | undefined): string {
|
||||
if (!date) return '-'
|
||||
|
||||
const now = new Date()
|
||||
const targetDate = new Date(date)
|
||||
const diffMs = now.getTime() - targetDate.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (diffMs <= 1000 * 60 * 5) return '刚刚'
|
||||
if (diffMs <= 1000 * 60 * 60) return '一小时内'
|
||||
if (diffDays === 0) return '一天内'
|
||||
if (diffDays === 1) return '两天内'
|
||||
if (diffDays === 2) return '三天前'
|
||||
if (diffDays > 7) return '超过一星期前'
|
||||
if (diffDays > 30) return '超过一个月前'
|
||||
|
||||
return '未来...?'
|
||||
}
|
||||
|
||||
// 统计概览组件
|
||||
function StatsOverview() {
|
||||
const { data: latestAnalyzedTime, isLoading: isLoadingLatestTime } = trpc.devFile!.getLatestAnalyzedTime.useQuery()
|
||||
const { data: fileTypeStats, isLoading: isLoadingFileTypes } = trpc.devFile!.getFileTypeStats.useQuery()
|
||||
const { data: commitIdStats, isLoading: isLoadingCommits } = trpc.devFile!.getCommitIdStats.useQuery()
|
||||
const { data: pkgDependencyStats, isLoading: isLoadingPkgs } = trpc.devFile!.getPkgDependencyStats.useQuery()
|
||||
|
||||
const totalFiles = useMemo(() => {
|
||||
if (!fileTypeStats) return 0
|
||||
return fileTypeStats.reduce((sum, item) => sum + item.count, 0)
|
||||
}, [fileTypeStats])
|
||||
|
||||
const latestCommit = useMemo(() => {
|
||||
if (!commitIdStats || commitIdStats.length === 0) return null
|
||||
return commitIdStats[0]
|
||||
}, [commitIdStats])
|
||||
|
||||
const totalDependencies = useMemo(() => {
|
||||
if (!pkgDependencyStats) return 0
|
||||
return pkgDependencyStats.length
|
||||
}, [pkgDependencyStats])
|
||||
|
||||
// 计算相对时间标题
|
||||
const relativeTimeTitle = useMemo(() =>
|
||||
getRelativeTimeLabel(latestAnalyzedTime),
|
||||
[latestAnalyzedTime]
|
||||
)
|
||||
|
||||
// 构建统计卡片数据
|
||||
const statsCards: StatsCardItem[] = useMemo(() => [
|
||||
{
|
||||
id: 'latest-analyzed-time',
|
||||
title: '最近分析时间',
|
||||
icon: Clock,
|
||||
content: (
|
||||
<StatsCardWrapper>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">最近分析时间</CardTitle>
|
||||
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingLatestTime ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">
|
||||
{latestAnalyzedTime ? relativeTimeTitle : '-'}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{latestAnalyzedTime
|
||||
? format(new Date(latestAnalyzedTime), 'yyyy-MM-dd HH:mm:ss', { locale: zhCN })
|
||||
: '暂无数据'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</StatsCardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'latest-commit',
|
||||
title: '最新提交',
|
||||
icon: GitCommit,
|
||||
content: (
|
||||
<StatsCardWrapper>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">最新提交</CardTitle>
|
||||
<GitCommit className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingCommits ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{latestCommit?.name.substring(0, 7) || '-'}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">
|
||||
{latestCommit?.minAnalyzedAt
|
||||
? format(new Date(latestCommit.minAnalyzedAt), 'yyyy-MM-dd HH:mm', { locale: zhCN })
|
||||
: '暂无数据'}
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</StatsCardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'total-files',
|
||||
title: '文件总数',
|
||||
icon: FileText,
|
||||
content: (
|
||||
<StatsCardWrapper>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">文件总数</CardTitle>
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingFileTypes ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{totalFiles}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">已分析的源代码文件</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</StatsCardWrapper>
|
||||
),
|
||||
},
|
||||
{
|
||||
id: 'total-dependencies',
|
||||
title: '依赖包数',
|
||||
icon: Package,
|
||||
content: (
|
||||
<StatsCardWrapper>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">依赖包数</CardTitle>
|
||||
<Package className="h-4 w-4 text-muted-foreground" />
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoadingPkgs ? (
|
||||
<Skeleton className="h-8 w-24" />
|
||||
) : (
|
||||
<>
|
||||
<div className="text-2xl font-bold">{totalDependencies}</div>
|
||||
<p className="text-xs text-muted-foreground mt-1">项目使用的包</p>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</StatsCardWrapper>
|
||||
),
|
||||
},
|
||||
], [relativeTimeTitle, latestAnalyzedTime, latestCommit, totalFiles, totalDependencies, isLoadingLatestTime, isLoadingFileTypes, isLoadingCommits, isLoadingPkgs])
|
||||
|
||||
return <StatsCardGroup items={statsCards} gridClassName="md:grid-cols-2 lg:grid-cols-4" />
|
||||
}
|
||||
|
||||
interface DevFilePageDataTableProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
function DevFilePageDataTable({ children }: DevFilePageDataTableProps) {
|
||||
// 详情Sheet状态
|
||||
const [selectedFile, setSelectedFile] = useState<DevAnalyzedFile | null>(null)
|
||||
const [detailSheetOpen, setDetailSheetOpen] = useState(false)
|
||||
|
||||
const { data: fileTypeStats } = trpc.devFile!.getFileTypeStats.useQuery()
|
||||
const { data: commitIdStats } = trpc.devFile!.getCommitIdStats.useQuery()
|
||||
const { data: tagsStats } = trpc.devFile!.getTagsStats.useQuery()
|
||||
const { data: pkgDependencyStats } = trpc.devFile!.getPkgDependencyStats.useQuery()
|
||||
|
||||
// 处理查看详情
|
||||
const handleViewDetail = useCallback((file: DevAnalyzedFile) => {
|
||||
setSelectedFile(file)
|
||||
setDetailSheetOpen(true)
|
||||
}, [])
|
||||
|
||||
// 创建表格列定义选项
|
||||
const columnsOptions: DevAnalyzedFileColumnsOptions = useMemo(() => ({
|
||||
fileTypes: fileTypeStats || [],
|
||||
commitIds: commitIdStats || [],
|
||||
tagsStats: tagsStats || [],
|
||||
pkgDependencyStats: pkgDependencyStats || [],
|
||||
onViewDetail: handleViewDetail,
|
||||
}), [fileTypeStats, commitIdStats, tagsStats, pkgDependencyStats, handleViewDetail])
|
||||
|
||||
// 创建表格列定义
|
||||
const columns = useMemo(() => createDevAnalyzedFileColumns(columnsOptions), [columnsOptions])
|
||||
|
||||
// 使用 useDataTable hook
|
||||
const { table, queryResult } = useDataTable<DevAnalyzedFile>({
|
||||
columns,
|
||||
initialState: {
|
||||
pagination: { pageIndex: 1, pageSize: 10 },
|
||||
columnPinning: { left: ['select'], right: ['actions'] },
|
||||
sorting: [ { id: 'lastAnalyzedAt', desc: true } ] ,
|
||||
columnVisibility: {
|
||||
path: false,
|
||||
description: false,
|
||||
exportedMembers: false,
|
||||
dependencies: false,
|
||||
pkgDependencies: true,
|
||||
}
|
||||
},
|
||||
getRowId: (row) => String(row.id),
|
||||
queryFn: useCallback((params) => {
|
||||
const result = trpc.devFile!.listAnalyzedFiles.useQuery(params, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.error('获取文件数据失败:' + result.error.toString().substring(0, 100))
|
||||
}
|
||||
return result
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<DataTable table={table} tableClassName={queryResult?.data?.total ? "table-fixed" : "table-auto"} isLoading={queryResult.isLoading}>
|
||||
<DataTableToolbar table={table}>
|
||||
{children}
|
||||
<DataTableSortList table={table} />
|
||||
</DataTableToolbar>
|
||||
</DataTable>
|
||||
|
||||
{/* 文件详情Sheet */}
|
||||
<FileDetailSheet
|
||||
file={selectedFile}
|
||||
open={detailSheetOpen}
|
||||
onOpenChange={setDetailSheetOpen}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default function FileListPage() {
|
||||
// 用于刷新数据的 utils
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 刷新文件列表
|
||||
const handleRefreshFiles = useCallback(() => {
|
||||
utils.devFile!.listAnalyzedFiles.invalidate()
|
||||
utils.devFile!.getLatestAnalyzedTime.invalidate()
|
||||
utils.devFile!.getFileTypeStats.invalidate()
|
||||
utils.devFile!.getCommitIdStats.invalidate()
|
||||
utils.devFile!.getTagsStats.invalidate()
|
||||
utils.devFile!.getPkgDependencyStats.invalidate()
|
||||
}, [utils])
|
||||
|
||||
return (
|
||||
<div className="space-y-2 md:space-y-6">
|
||||
{/* 统计概览区域 */}
|
||||
<StatsOverview />
|
||||
|
||||
{/* 文件列表表格 */}
|
||||
<Card className='py-2 xl:py-4 2xl:py-6'>
|
||||
<CardContent>
|
||||
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={10} />}>
|
||||
<DevFilePageDataTable>
|
||||
<FileAnalyzeDialog onAnalyzeCompleted={handleRefreshFiles} />
|
||||
</DevFilePageDataTable>
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/dev/file/page.dev.tsx
Normal file
5
src/app/(main)/dev/file/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function FilePage() {
|
||||
return <SubMenuRedirect parentHref="/dev/file" />;
|
||||
}
|
||||
9
src/app/(main)/dev/frontend-design/layout.dev.tsx
Normal file
9
src/app/(main)/dev/frontend-design/layout.dev.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
|
||||
|
||||
export default function FrontendDesignLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SubMenuLayout parentHref="/dev/frontend-design">{children}</SubMenuLayout>;
|
||||
}
|
||||
5
src/app/(main)/dev/frontend-design/page.dev.tsx
Normal file
5
src/app/(main)/dev/frontend-design/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function FrontendDesignPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/frontend-design" />;
|
||||
}
|
||||
19
src/app/(main)/dev/frontend-design/page/page.dev.tsx
Normal file
19
src/app/(main)/dev/frontend-design/page/page.dev.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
export default function PageTestPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">页面测试</h2>
|
||||
<p className="text-muted-foreground">
|
||||
在这里测试和展示完整的页面布局和功能
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 这里可以添加完整页面的测试和展示 */}
|
||||
<div className="space-y-4">
|
||||
{/* 示例区域 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { Search, Loader2, Package, Sparkles, Eye, ExternalLink } from "lucide-react";
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from "@/components/ui/sheet";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { CardSelect } from "@/components/common/card-select";
|
||||
import { ComponentDetailDialog } from "./ComponentDetailDialog";
|
||||
|
||||
interface AddComponentSheetProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加组件的Sheet组件
|
||||
* 包含registry列表、搜索栏和搜索结果展示
|
||||
*/
|
||||
export function AddComponentSheet({ open, onOpenChange }: AddComponentSheetProps) {
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [selectedRegistries, setSelectedRegistries] = useState<string[]>([]);
|
||||
const [searchResults, setSearchResults] = useState<Array<{
|
||||
registry: string;
|
||||
items: Array<{
|
||||
name: string;
|
||||
description?: string;
|
||||
type?: string;
|
||||
addCommandArgument?: string;
|
||||
}>;
|
||||
error?: string;
|
||||
}>>([]);
|
||||
|
||||
// 组件详情对话框状态
|
||||
const [detailDialogOpen, setDetailDialogOpen] = useState(false);
|
||||
const [selectedComponentName, setSelectedComponentName] = useState("");
|
||||
|
||||
// 搜索框ref,用于自动聚焦
|
||||
const searchInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 获取registry列表
|
||||
const { data: registriesData, isLoading: isLoadingRegistries } = trpc.devFrontendDesign!.getRegistries.useQuery();
|
||||
|
||||
// 在registry列表中添加shadcn官方仓库,并放在第一项
|
||||
const registries = registriesData ? [
|
||||
{
|
||||
name: '@shadcn',
|
||||
url: 'https://registry.shadcn.com/{name}.json',
|
||||
websiteUrl: 'https://ui.shadcn.com',
|
||||
},
|
||||
...registriesData.filter(r => r.name !== '@shadcn'), // 避免重复
|
||||
] : undefined;
|
||||
|
||||
// 当registry列表加载完成后,默认选中@shadcn
|
||||
const [hasInitialized, setHasInitialized] = useState(false);
|
||||
if (registries && !hasInitialized && selectedRegistries.length === 0) {
|
||||
setSelectedRegistries(['@shadcn']);
|
||||
setHasInitialized(true);
|
||||
}
|
||||
|
||||
// 当Sheet打开时,自动聚焦到搜索框
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// 使用setTimeout确保Sheet动画完成后再聚焦
|
||||
const timer = setTimeout(() => {
|
||||
searchInputRef.current?.focus();
|
||||
}, 100);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 搜索组件
|
||||
const searchMutation = trpc.devFrontendDesign!.searchComponents.useMutation({
|
||||
onSuccess: (data) => {
|
||||
setSearchResults(data);
|
||||
const totalItems = data.reduce((sum, r) => sum + r.items.length, 0);
|
||||
const errorCount = data.filter(r => r.error).length;
|
||||
|
||||
if (totalItems === 0 && errorCount === 0) {
|
||||
toast.info("未找到匹配的组件");
|
||||
} else if (errorCount > 0) {
|
||||
toast.warning(`搜索完成,但有 ${errorCount} 个registry查询失败`);
|
||||
} else {
|
||||
toast.success(`找到 ${totalItems} 个组件`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`搜索失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSearch = () => {
|
||||
if (!searchQuery.trim()) {
|
||||
toast.error("请输入搜索关键词");
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedRegistries.length === 0) {
|
||||
toast.error("请至少选择一个registry");
|
||||
return;
|
||||
}
|
||||
|
||||
searchMutation.mutate({
|
||||
registries: selectedRegistries,
|
||||
query: searchQuery.trim(),
|
||||
});
|
||||
};
|
||||
|
||||
const handleRegistryChange = (value: (string | number)[]) => {
|
||||
setSelectedRegistries(value as string[]);
|
||||
};
|
||||
|
||||
const selectAllRegistries = () => {
|
||||
if (registries) {
|
||||
setSelectedRegistries(registries.map(r => r.name));
|
||||
}
|
||||
};
|
||||
|
||||
const clearSelection = () => {
|
||||
setSelectedRegistries([]);
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent className="w-full sm:max-w-2xl overflow-y-auto">
|
||||
<SheetHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<SheetTitle className="text-xl">添加组件</SheetTitle>
|
||||
<SheetDescription className="text-xs">
|
||||
从第三方registry搜索并添加新的UI组件到项目中
|
||||
</SheetDescription>
|
||||
</div>
|
||||
</div>
|
||||
</SheetHeader>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="px-4 space-y-3">
|
||||
{/* Registry列表 */}
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="space-y-1">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Sparkles className="h-4 w-4 text-primary" />
|
||||
选择Registry
|
||||
</Label>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
已选择 {selectedRegistries.length} / {registries?.length || 0} 个源
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={selectAllRegistries}
|
||||
disabled={isLoadingRegistries}
|
||||
className="h-8"
|
||||
>
|
||||
全选
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={clearSelection}
|
||||
disabled={selectedRegistries.length === 0}
|
||||
className="h-8"
|
||||
>
|
||||
清空
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoadingRegistries ? (
|
||||
<div className="space-y-2">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<Skeleton key={i} className="h-16 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : registries && registries.length > 0 ? (
|
||||
<CardSelect
|
||||
value={selectedRegistries}
|
||||
onChange={handleRegistryChange}
|
||||
options={registries.map(r => ({
|
||||
id: r.name,
|
||||
name: r.name,
|
||||
description: r.url,
|
||||
websiteUrl: r.websiteUrl
|
||||
}))}
|
||||
showCheckbox={true}
|
||||
showExternalLink={true}
|
||||
disabled={isLoadingRegistries}
|
||||
enablePagination={true}
|
||||
pageSize={3}
|
||||
showPaginationInfo={true}
|
||||
className="min-h-61"
|
||||
/>
|
||||
) : (
|
||||
<div className="text-center py-8 px-4 border rounded-lg bg-muted/30">
|
||||
<Package className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
未找到可用的registry
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 搜索框 */}
|
||||
<div className="space-y-3">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Search className="h-4 w-4 text-primary" />
|
||||
搜索组件
|
||||
</Label>
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
ref={searchInputRef}
|
||||
placeholder="输入组件名称或关键词,如 full screen..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "Enter" && !searchMutation.isPending) {
|
||||
handleSearch();
|
||||
}
|
||||
}}
|
||||
className="pl-9"
|
||||
disabled={searchMutation.isPending}
|
||||
/>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleSearch}
|
||||
disabled={searchMutation.isPending}
|
||||
className="px-6"
|
||||
>
|
||||
{searchMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="h-4 w-4 animate-spin mr-2" />
|
||||
搜索中
|
||||
</>
|
||||
) : (
|
||||
"搜索"
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 搜索结果 */}
|
||||
{searchResults.length > 0 && (
|
||||
<div className="space-y-4">
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Label className="text-base font-semibold flex items-center gap-2">
|
||||
<Package className="h-4 w-4 text-primary" />
|
||||
搜索结果
|
||||
</Label>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{searchResults.reduce((sum, r) => sum + r.items.length, 0)} 个组件
|
||||
</Badge>
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
{searchResults.map((result) => {
|
||||
// 查找对应registry的websiteUrl
|
||||
const registryInfo = registries?.find(r => r.name === result.registry);
|
||||
const websiteUrl = registryInfo?.websiteUrl;
|
||||
|
||||
return (
|
||||
<div key={result.registry} className="space-y-3">
|
||||
<div className="flex items-center gap-2 px-2">
|
||||
<Badge variant="outline" className="font-mono">
|
||||
{result.registry}
|
||||
</Badge>
|
||||
{result.error ? (
|
||||
<span className="text-xs text-destructive flex items-center gap-1">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-destructive" />
|
||||
{result.error}
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{result.items.length} 个结果
|
||||
</span>
|
||||
)}
|
||||
{websiteUrl && (
|
||||
<a
|
||||
href={websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="ml-auto text-muted-foreground hover:text-primary transition-colors"
|
||||
title={`访问 ${result.registry} 官网`}
|
||||
>
|
||||
<ExternalLink className="h-4 w-4" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{result.items.length > 0 && (
|
||||
<CardSelect
|
||||
value={[]}
|
||||
onChange={() => {}}
|
||||
options={result.items.map((item, index) => ({
|
||||
id: `${result.registry}-${item.name}-${index}`,
|
||||
name: item.name,
|
||||
description: item.description,
|
||||
type: item.type,
|
||||
addCommandArgument: item.addCommandArgument
|
||||
}))}
|
||||
showCheckbox={false}
|
||||
showBadge={true}
|
||||
containerClassName="space-y-2 pl-4 border-l-2 border-primary/20"
|
||||
className="group p-4 rounded-lg border bg-card hover:shadow-md hover:border-primary/50 transition-all"
|
||||
renderExtra={(option) => (
|
||||
option.addCommandArgument ? (
|
||||
<div className="flex items-center gap-2 mt-2">
|
||||
<code className="text-xs bg-muted px-2 py-1 rounded font-mono border">
|
||||
{option.addCommandArgument}
|
||||
</code>
|
||||
</div>
|
||||
) : null
|
||||
)}
|
||||
renderActions={(option) => (
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="opacity-70 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => {
|
||||
if (option.addCommandArgument) {
|
||||
setSelectedComponentName(option.addCommandArgument);
|
||||
setDetailDialogOpen(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Eye className="h-4 w-4 mr-1.5" />
|
||||
查看详情
|
||||
</Button>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</SheetContent>
|
||||
|
||||
{/* 组件详情对话框 */}
|
||||
<ComponentDetailDialog
|
||||
open={detailDialogOpen}
|
||||
onOpenChange={setDetailDialogOpen}
|
||||
componentName={selectedComponentName}
|
||||
/>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { Loader2, Package, FileCode, Terminal } from "lucide-react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
import { DetailCodeBlock } from "@/components/data-details/detail-code-block";
|
||||
import { DetailCopyable } from "@/components/data-details/detail-copyable";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
|
||||
interface ComponentDetailDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
componentName: string; // 如 @shadcn/tabs
|
||||
}
|
||||
|
||||
/**
|
||||
* 组件详情对话框
|
||||
* 展示组件的详细信息,包括依赖、文件内容等
|
||||
*/
|
||||
export function ComponentDetailDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
componentName,
|
||||
}: ComponentDetailDialogProps) {
|
||||
// 获取组件详情
|
||||
const viewComponentMutation = trpc.devFrontendDesign!.viewComponent.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(`获取组件详情失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
// 当对话框打开时,触发查询
|
||||
const [hasQueried, setHasQueried] = useState(false);
|
||||
if (open && !hasQueried && !viewComponentMutation.isPending) {
|
||||
viewComponentMutation.mutate({ componentName });
|
||||
setHasQueried(true);
|
||||
}
|
||||
|
||||
// 当对话框关闭时,重置状态
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (!newOpen) {
|
||||
setHasQueried(false);
|
||||
}
|
||||
onOpenChange(newOpen);
|
||||
};
|
||||
|
||||
// 解析组件详情数据
|
||||
const componentInfo = viewComponentMutation.data?.[0];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className="max-w-4xl max-h-[90vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div>
|
||||
<DialogTitle className="text-xl flex items-center gap-2">
|
||||
{componentName}
|
||||
{componentInfo?.type && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{componentInfo.type}
|
||||
</Badge>
|
||||
)}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
组件详细信息
|
||||
</DialogDescription>
|
||||
</div>
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{viewComponentMutation.isPending ? (
|
||||
<div className="space-y-4 py-4">
|
||||
<Skeleton className="h-8 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
) : viewComponentMutation.error ? (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-destructive">{viewComponentMutation.error.message}</p>
|
||||
</div>
|
||||
) : componentInfo ? (
|
||||
<div className="space-y-4">
|
||||
{/* 添加组件命令 */}
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Terminal className="h-4 w-4" />
|
||||
添加组件命令
|
||||
</h3>
|
||||
<DetailCopyable
|
||||
value={`npx shadcn@latest add ${componentName}`}
|
||||
className="bg-muted/30 p-3 rounded-lg border"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* 依赖信息 */}
|
||||
{componentInfo.dependencies && componentInfo.dependencies.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<Package className="h-4 w-4" />
|
||||
依赖项
|
||||
</h3>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{componentInfo.dependencies.map((dep: string) => (
|
||||
<Badge key={dep} variant="outline" className="font-mono text-xs">
|
||||
{dep}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 文件内容 */}
|
||||
{componentInfo.files && componentInfo.files.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
||||
<FileCode className="h-4 w-4" />
|
||||
文件内容
|
||||
</h3>
|
||||
{componentInfo.files.length === 1 ? (
|
||||
<DetailCodeBlock
|
||||
code={componentInfo.files[0].content}
|
||||
language="tsx"
|
||||
title={componentInfo.files[0].path}
|
||||
maxHeight="500px"
|
||||
/>
|
||||
) : (
|
||||
<Tabs defaultValue="0" className="w-full">
|
||||
<TabsList className="w-full justify-start overflow-x-auto flex-wrap">
|
||||
{componentInfo.files.map((file: any, index: number) => (
|
||||
<TabsTrigger key={index} value={String(index)} className="whitespace-nowrap">
|
||||
{file.path.split('/').pop()}
|
||||
</TabsTrigger>
|
||||
))}
|
||||
</TabsList>
|
||||
{componentInfo.files.map((file: any, index: number) => (
|
||||
<TabsContent key={index} value={String(index)}>
|
||||
<DetailCodeBlock
|
||||
code={file.content}
|
||||
language="tsx"
|
||||
title={file.path}
|
||||
maxHeight="500px"
|
||||
/>
|
||||
</TabsContent>
|
||||
))}
|
||||
</Tabs>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="py-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">未找到组件信息</p>
|
||||
</div>
|
||||
)}
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
411
src/app/(main)/dev/frontend-design/ui/page.dev.tsx
Normal file
411
src/app/(main)/dev/frontend-design/ui/page.dev.tsx
Normal file
@@ -0,0 +1,411 @@
|
||||
"use client";
|
||||
|
||||
import { useState, useMemo } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Loader2, Plus, Package, Sparkles, Code2, Eye, Search, X } from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import React from 'react';
|
||||
import { useForm } from "react-hook-form"
|
||||
import { useDataTable } from "@/hooks/use-data-table";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { blobUrlToBase64 } from "@/lib/format";
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputBody,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
PromptInputSubmit,
|
||||
PromptInputAttachments,
|
||||
PromptInputAttachment,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionAddAttachments,
|
||||
type PromptInputMessage,
|
||||
} from "@/components/ai-elements/prompt-input";
|
||||
import { AddComponentSheet } from "./components/AddComponentSheet";
|
||||
import { CardSelect } from "@/components/common/card-select";
|
||||
import { CodeEditorPreview } from "@/components/features/code-editor-preview";
|
||||
|
||||
/**
|
||||
* 动态导入组件模块
|
||||
*/
|
||||
async function importComponentModule(path: string) {
|
||||
// 清理路径:移除 src/components/ 前缀和 .tsx/.ts 后缀
|
||||
const cleanPath = path
|
||||
.replace(/^src\/components\//, '')
|
||||
.replace(/\.(tsx|ts)$/, '');
|
||||
|
||||
try {
|
||||
const importedModule = await import(`@/components/${cleanPath}`);
|
||||
return importedModule;
|
||||
} catch (error) {
|
||||
console.error(`Failed to import component from ${path}:`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export default function UIComponentsPage() {
|
||||
const [selectedComponents, setSelectedComponents] = useState<string[]>([]);
|
||||
const [generatedCode, setGeneratedCode] = useState<string | null>(null);
|
||||
const [componentScope, setComponentScope] = useState<Record<string, any>>({});
|
||||
const [searchQuery, setSearchQuery] = useState("");
|
||||
const [status, setStatus] = useState<"submitted" | "streaming" | "ready" | "error">("ready");
|
||||
const [addSheetOpen, setAddSheetOpen] = useState(false);
|
||||
|
||||
// 获取UI组件列表
|
||||
const { data: components, isLoading: isLoadingComponents } = trpc.devFrontendDesign!.getUIComponents.useQuery();
|
||||
|
||||
// 筛选和排序组件:选中的组件排在前面
|
||||
const filteredComponents = useMemo(() => {
|
||||
if (!components) return [];
|
||||
|
||||
let filtered = components;
|
||||
|
||||
// 如果有搜索关键词,先筛选并计算匹配优先级
|
||||
if (searchQuery.trim()) {
|
||||
const query = searchQuery.toLowerCase();
|
||||
|
||||
// 为每个组件计算匹配优先级:1=fileName, 2=path, 3=summary, 0=不匹配
|
||||
const componentsWithPriority = components.map(c => {
|
||||
const fileNameMatch = c.fileName.toLowerCase().includes(query);
|
||||
const pathMatch = c.path.toLowerCase().includes(query);
|
||||
const summaryMatch = c.summary.toLowerCase().includes(query);
|
||||
|
||||
let priority = 0;
|
||||
if (fileNameMatch) priority = 1;
|
||||
else if (pathMatch) priority = 2;
|
||||
else if (summaryMatch) priority = 3;
|
||||
|
||||
return { component: c, priority };
|
||||
});
|
||||
|
||||
// 过滤掉不匹配的,并按优先级排序
|
||||
filtered = componentsWithPriority
|
||||
.filter(item => item.priority > 0)
|
||||
.sort((a, b) => a.priority - b.priority)
|
||||
.map(item => item.component);
|
||||
}
|
||||
|
||||
// 将选中的组件排到前面
|
||||
return filtered.sort((a, b) => {
|
||||
const aSelected = selectedComponents.includes(a.path);
|
||||
const bSelected = selectedComponents.includes(b.path);
|
||||
|
||||
if (aSelected && !bSelected) return -1;
|
||||
if (!aSelected && bSelected) return 1;
|
||||
return 0;
|
||||
});
|
||||
}, [components, searchQuery, selectedComponents]);
|
||||
|
||||
// 生成演示代码
|
||||
const generateMutation = trpc.devFrontendDesign!.generateComponentDemo.useMutation({
|
||||
onSuccess: async (data) => {
|
||||
// 动态构建scope
|
||||
const scope: Record<string, any> = {
|
||||
React,
|
||||
useState: React.useState,
|
||||
useEffect: React.useEffect,
|
||||
useMemo: React.useMemo,
|
||||
useCallback: React.useCallback,
|
||||
useRef: React.useRef,
|
||||
useForm: useForm,
|
||||
useDataTable: useDataTable,
|
||||
cn,
|
||||
};
|
||||
|
||||
// 导入所有选中的组件
|
||||
for (const component of data.components) {
|
||||
const importedModule = await importComponentModule(component.path);
|
||||
if (importedModule) {
|
||||
// 将所有导出的成员添加到scope
|
||||
for (const member of component.exportedMembers) {
|
||||
if (importedModule[member.name]) {
|
||||
scope[member.name] = importedModule[member.name];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setComponentScope(scope);
|
||||
setGeneratedCode(data.code);
|
||||
setStatus("ready");
|
||||
toast.success("代码生成成功!");
|
||||
},
|
||||
onError: (error) => {
|
||||
setStatus("error");
|
||||
toast.error(`生成失败: ${error.message}`);
|
||||
},
|
||||
});
|
||||
|
||||
const handleSubmit = async (message: PromptInputMessage) => {
|
||||
if (status === "streaming") { // 还在生成结果,这时候用户再点提交就reset
|
||||
generateMutation.reset();
|
||||
setStatus("ready");
|
||||
return;
|
||||
}
|
||||
if (!message.text) {
|
||||
toast.error("请输入提示词");
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("submitted");
|
||||
// 将图片转换为base64
|
||||
const images: Array<{ url: string; mediaType: string }> = [];
|
||||
if (message.files?.length) {
|
||||
for (const file of message.files) {
|
||||
if (file.url && file.mediaType?.startsWith('image/')) {
|
||||
try {
|
||||
const base64 = await blobUrlToBase64(file.url);
|
||||
images.push({ url: base64, mediaType: file.mediaType });
|
||||
} catch (error) {
|
||||
console.error('转换图片失败:', error);
|
||||
toast.error('图片处理失败');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
setTimeout(() => {
|
||||
setStatus("streaming");
|
||||
generateMutation.mutate({
|
||||
componentPaths: selectedComponents,
|
||||
prompt: message.text!.trim(),
|
||||
images: images.length > 0 ? images : undefined,
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
const handleComponentChange = (value: (string | number)[]) => {
|
||||
setSelectedComponents(value as string[]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-[calc(100vh-14rem)] flex flex-col">
|
||||
<div className="flex-1 grid grid-cols-1 lg:grid-cols-3 gap-4 lg:min-h-0 min-h-200">
|
||||
{/* 左侧:组件选择 */}
|
||||
<Card className="lg:col-span-1 flex flex-col min-h-0 shadow-lg border-2 border-border/50 min-h-160">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl">选择组件</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
从项目中选择需要使用的UI组件
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setAddSheetOpen(true)}
|
||||
className="h-8 gap-1.5"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
添加
|
||||
</Button>
|
||||
</div>
|
||||
<Separator />
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
已选择 {selectedComponents.length} 个
|
||||
</Badge>
|
||||
{filteredComponents.length < (components?.length || 0) && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
显示 {filteredComponents.length}/{components?.length || 0}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 flex flex-col min-h-0 space-y-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Search className="h-3.5 w-3.5 text-primary" />
|
||||
搜索组件
|
||||
</Label>
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="输入组件名称、路径或描述..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 h-7 w-7 p-0"
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 组件列表区域 - 可滚动 */}
|
||||
<div className="flex-1 flex flex-col min-h-0 space-y-3">
|
||||
{isLoadingComponents ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center space-y-3">
|
||||
<Loader2 className="h-8 w-8 animate-spin text-primary mx-auto" />
|
||||
<p className="text-sm text-muted-foreground">加载组件列表...</p>
|
||||
</div>
|
||||
</div>
|
||||
) : filteredComponents.length > 0 ? (
|
||||
<>
|
||||
<Label className="text-sm font-medium flex items-center gap-2 flex-shrink-0">
|
||||
<Package className="h-3.5 w-3.5 text-primary" />
|
||||
组件列表
|
||||
</Label>
|
||||
<div className="flex-1 min-h-0 overflow-y-auto -mx-6 px-6">
|
||||
<CardSelect
|
||||
value={selectedComponents}
|
||||
onChange={handleComponentChange}
|
||||
options={filteredComponents.map(c => ({
|
||||
id: c.path,
|
||||
name: c.fileName,
|
||||
description: c.summary,
|
||||
url: c.path
|
||||
}))}
|
||||
showCheckbox={true}
|
||||
containerClassName="space-y-1.5"
|
||||
className="group p-3 border-l-2 border-l-transparent hover:border-l-primary hover:bg-accent/50 transition-all"
|
||||
renderExtra={(option) => (
|
||||
<p className="text-xs text-muted-foreground/70 mt-1.5 break-all line-clamp-2 font-mono">
|
||||
{option.url}
|
||||
</p>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : searchQuery ? (
|
||||
<div className="text-center py-12 px-4 bg-muted/20">
|
||||
<Search className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-foreground mb-1">未找到匹配的组件</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
尝试使用其他关键词搜索
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-center py-12 px-4 bg-muted/20">
|
||||
<Package className="h-12 w-12 text-muted-foreground/50 mx-auto mb-3" />
|
||||
<p className="text-sm font-medium text-foreground mb-1">暂无可用的UI组件</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
点击右上角“添加”按钮从registry导入组件
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="flex-shrink-0" />
|
||||
|
||||
{/* AI 提示词输入框 */}
|
||||
<div className="space-y-3 flex-shrink-0">
|
||||
<Label className="text-sm font-medium flex items-center gap-2">
|
||||
<Sparkles className="h-3.5 w-3.5 text-primary" />
|
||||
AI 生成提示
|
||||
</Label>
|
||||
<PromptInput
|
||||
multiple
|
||||
accept="image/*"
|
||||
onSubmit={handleSubmit}
|
||||
className="w-full"
|
||||
>
|
||||
<PromptInputBody>
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
<PromptInputTextarea
|
||||
placeholder="描述你想要的UI效果,例如:创建一个登录表单,包含用户名和密码输入框,以及一个提交按钮..."
|
||||
rows={4}
|
||||
className="resize-none"
|
||||
/>
|
||||
</PromptInputBody>
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools>
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments label="添加图片" />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
</PromptInputTools>
|
||||
<PromptInputSubmit status={status} />
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 右侧:代码预览 */}
|
||||
<div className="lg:col-span-2 flex flex-col min-h-0">
|
||||
<Card className="flex-1 flex flex-col min-h-0 shadow-lg border-2 border-border/50 min-h-160">
|
||||
<CardHeader className="space-y-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-lg bg-primary/10">
|
||||
<Code2 className="h-5 w-5 text-primary" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<CardTitle className="text-xl">代码预览</CardTitle>
|
||||
<CardDescription className="text-xs">
|
||||
实时编辑和预览生成的组件效果
|
||||
</CardDescription>
|
||||
</div>
|
||||
{generatedCode && (
|
||||
<Badge variant="secondary" className="text-xs gap-1">
|
||||
<Eye className="h-3 w-3" />
|
||||
实时预览
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Separator />
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 min-h-0 overflow-hidden">
|
||||
{status === "streaming" || generatedCode ? (
|
||||
<CodeEditorPreview
|
||||
code={generatedCode || undefined}
|
||||
scope={componentScope}
|
||||
editorTitle="代码编辑器"
|
||||
previewTitle="实时预览"
|
||||
enableFullscreen={true}
|
||||
loading={status === "streaming"}
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center bg-muted/20">
|
||||
<div className="text-center space-y-4 px-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
||||
<Sparkles className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-sm font-medium text-foreground">
|
||||
准备开始创建
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
选择需要使用的组件,然后在左侧输入提示词,AI将为你生成可预览的代码
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 添加组件Sheet */}
|
||||
<AddComponentSheet open={addSheetOpen} onOpenChange={setAddSheetOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
15
src/app/(main)/dev/layout.dev.tsx
Normal file
15
src/app/(main)/dev/layout.dev.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import "./dev-theme.css";
|
||||
|
||||
export default function DevLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
);
|
||||
}
|
||||
88
src/app/(main)/dev/panel/agents-config.tsx
Normal file
88
src/app/(main)/dev/panel/agents-config.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
/**
|
||||
* 智能体配置
|
||||
*/
|
||||
|
||||
export interface AgentTool {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
}
|
||||
|
||||
export interface AgentModel {
|
||||
id: string
|
||||
name: string
|
||||
logo: string
|
||||
}
|
||||
|
||||
export interface AgentType {
|
||||
id: string
|
||||
name: string
|
||||
description: string
|
||||
defaultModel: string
|
||||
defaultTools: string[]
|
||||
availableTools: AgentTool[]
|
||||
}
|
||||
|
||||
// 可用的模型列表
|
||||
export const AVAILABLE_MODELS: AgentModel[] = [
|
||||
{ id: 'claude-sonnet-4-5-20250929', name: 'Claude Sonnet 4.5', logo: 'anthropic' },
|
||||
{ id: 'claude-sonnet-4-5-20250929:thinking', name: 'Claude Sonnet 4.5 深度思考', logo: 'anthropic' },
|
||||
{ id: 'gpt-4.1', name: 'GPT-4.1', logo: 'openai' },
|
||||
]
|
||||
|
||||
// 智能体类型配置
|
||||
export const AGENT_TYPES: AgentType[] = [
|
||||
{
|
||||
id: 'project-assistant',
|
||||
name: '项目管家',
|
||||
description: '(功能开发中,暂不可用)帮助您了解和规划项目',
|
||||
defaultModel: 'claude-sonnet-4-5-20250929:thinking',
|
||||
defaultTools: ['read-project-files'],
|
||||
availableTools: [
|
||||
// { id: 'read-project-files', name: '读取项目文件', description: '读取项目文件及其文件分析数据' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'casual-chat',
|
||||
name: '随便聊聊',
|
||||
description: '适用于和项目关联不大的一般性问题和对话',
|
||||
defaultModel: 'gpt-4.1',
|
||||
defaultTools: [],
|
||||
availableTools: [
|
||||
],
|
||||
},
|
||||
// {
|
||||
// id: 'tech-selection',
|
||||
// name: '技术选型',
|
||||
// description: '帮助进行技术栈选择和架构设计',
|
||||
// defaultModel: 'gpt-4.1',
|
||||
// defaultTools: ['web-search', 'tech-analyzer'],
|
||||
// availableTools: [
|
||||
// { id: 'web-search', name: '网络搜索', description: '搜索技术文档和最佳实践' },
|
||||
// { id: 'tech-analyzer', name: '技术分析器', description: '分析技术栈的优劣' },
|
||||
// { id: 'benchmark-tool', name: '性能基准', description: '对比不同技术的性能' },
|
||||
// ],
|
||||
// },
|
||||
// {
|
||||
// id: 'requirement',
|
||||
// name: '需求沟通',
|
||||
// description: '协助需求分析和功能设计',
|
||||
// defaultModel: 'gpt-4.1',
|
||||
// defaultTools: ['diagram-generator', 'requirement-analyzer'],
|
||||
// availableTools: [
|
||||
// { id: 'diagram-generator', name: '图表生成器', description: '生成流程图和架构图' },
|
||||
// { id: 'requirement-analyzer', name: '需求分析器', description: '分析和拆解需求' },
|
||||
// { id: 'user-story-writer', name: '用户故事编写', description: '编写用户故事' },
|
||||
// ],
|
||||
// },
|
||||
]
|
||||
|
||||
// 根据智能体类型ID获取配置
|
||||
export function getAgentTypeById(id: string): AgentType | undefined {
|
||||
return AGENT_TYPES.find((type) => type.id === id)
|
||||
}
|
||||
|
||||
// 根据模型ID获取模型信息
|
||||
export function getModelById(id: string): AgentModel | undefined {
|
||||
return AVAILABLE_MODELS.find((model) => model.id === id)
|
||||
}
|
||||
584
src/app/(main)/dev/panel/components/version-control.tsx
Normal file
584
src/app/(main)/dev/panel/components/version-control.tsx
Normal file
@@ -0,0 +1,584 @@
|
||||
import { GitBranch, GitCommit as GitCommitIcon, CornerRightUp, RotateCcw, AlertTriangle, RefreshCw, GitCommit } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { toast } from 'sonner'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import {
|
||||
Timeline,
|
||||
TimelineEmpty,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineNode,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineTitleArea,
|
||||
TimelineTitle,
|
||||
TimelineBadge,
|
||||
TimelineActions,
|
||||
TimelineTimestamp,
|
||||
TimelineDescription,
|
||||
TimelineMetadata,
|
||||
} from '@/components/data-details'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectItemList,
|
||||
SelectedName,
|
||||
} from '@/components/common/advanced-select'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import React from 'react'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
|
||||
|
||||
/**
|
||||
* 版本控制组件
|
||||
*/
|
||||
export function VersionControl({ isOpen }: { isOpen: boolean }) {
|
||||
const [commitMessage, setCommitMessage] = React.useState('')
|
||||
const [selectedBranch, setSelectedBranch] = React.useState<string>('')
|
||||
const [showCommitDialog, setShowCommitDialog] = React.useState(false)
|
||||
const [commitLimit, setCommitLimit] = React.useState(10)
|
||||
const [isInitialLoad, setIsInitialLoad] = React.useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = React.useState(false)
|
||||
const [commitType, setCommitType] = React.useState<'normal' | 'amend' | null>(null)
|
||||
const [confirmAction, setConfirmAction] = React.useState<{
|
||||
type: 'checkout' | 'checkout-branch' | 'revert' | 'reset'
|
||||
commitId?: string
|
||||
message?: string
|
||||
title?: string
|
||||
description?: string
|
||||
} | null>(null)
|
||||
const scrollViewportRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
// 查询分支列表
|
||||
const { data: branches, refetch: refetchBranches, isLoading: branchesLoading } = trpc.devPanel!.getBranches.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// 查询当前分支
|
||||
const { data: currentBranchData, isLoading: currentBranchLoading } = trpc.devPanel!.getCurrentBranch.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// 初始化选中的分支:优先级为 isCurrent > master > main > 第一个分支
|
||||
React.useEffect(() => {
|
||||
if (!branches || branches.length === 0 || selectedBranch) return
|
||||
|
||||
const initialBranch =
|
||||
branches.find(b => b.isCurrent)?.name ||
|
||||
branches.find(b => b.name === 'master')?.name ||
|
||||
branches.find(b => b.name === 'main')?.name ||
|
||||
branches[0].name
|
||||
|
||||
setSelectedBranch(initialBranch)
|
||||
}, [branches, selectedBranch])
|
||||
|
||||
// 查询提交历史(根据选中的分支)
|
||||
const { data: commits, refetch: refetchCommits, isLoading: commitsLoading, isFetching } = trpc.devPanel!.getCommitHistory.useQuery(
|
||||
{ limit: commitLimit, branchName: selectedBranch },
|
||||
{
|
||||
enabled: isOpen,
|
||||
placeholderData: keepPreviousData
|
||||
}
|
||||
)
|
||||
|
||||
// 初始加载完成后设置标志
|
||||
React.useEffect(() => {
|
||||
if (!commitsLoading && commits) {
|
||||
setIsInitialLoad(false)
|
||||
}
|
||||
}, [commitsLoading, commits])
|
||||
|
||||
// 查询是否有未提交的更改
|
||||
const { data: hasChangesData, refetch: refetchHasChanges, isLoading: hasChangesLoading } = trpc.devPanel!.hasUncommittedChanges.useQuery(undefined, {
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// 创建提交mutation
|
||||
const createCommitMutation = trpc.devPanel!.createCommit.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
setCommitMessage('')
|
||||
setShowCommitDialog(false)
|
||||
setCommitType(null)
|
||||
refetchCommits()
|
||||
refetchHasChanges()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
setCommitType(null)
|
||||
},
|
||||
})
|
||||
|
||||
// 切换到指定提交mutation
|
||||
const checkoutCommitMutation = trpc.devPanel!.checkoutCommit.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
refetchBranches()
|
||||
refetchCommits()
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 切换到指定分支mutation
|
||||
const checkoutBranchMutation = trpc.devPanel!.checkoutBranch.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
refetchBranches()
|
||||
refetchCommits()
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 反转提交mutation
|
||||
const revertCommitMutation = trpc.devPanel!.revertCommit.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
refetchCommits()
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 强制回滚mutation
|
||||
const resetToCommitMutation = trpc.devPanel!.resetToCommit.useMutation({
|
||||
onSuccess: (data) => {
|
||||
toast.success(data.message)
|
||||
refetchCommits()
|
||||
setConfirmAction(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message)
|
||||
},
|
||||
})
|
||||
|
||||
// 处理分支选择(仅用于查看历史,不切换实际分支)
|
||||
const handleBranchChange = (branchName: string | null) => {
|
||||
if (!branchName) return
|
||||
setSelectedBranch(branchName)
|
||||
}
|
||||
|
||||
// 处理提交
|
||||
const handleCommit = (amend: boolean = false) => {
|
||||
if (!commitMessage.trim()) {
|
||||
toast.error('请输入提交信息')
|
||||
return
|
||||
}
|
||||
setCommitType(amend ? 'amend' : 'normal')
|
||||
createCommitMutation.mutate({ message: commitMessage, amend })
|
||||
}
|
||||
|
||||
// 处理切换到指定提交
|
||||
const handleCheckoutCommit = (commitId: string, message: string, isFirstCommit: boolean = false) => {
|
||||
// 如果是第一个节点,显示特殊提示,并且切换到这个分支
|
||||
if (isFirstCommit) {
|
||||
setConfirmAction({
|
||||
type: 'checkout-branch',
|
||||
commitId,
|
||||
message,
|
||||
title: '切换到此分支',
|
||||
description: '这是该分支的最新版本。切换到此分支后,您可以继续进行开发和提交新的更改。',
|
||||
})
|
||||
} else {
|
||||
setConfirmAction({
|
||||
type: 'checkout',
|
||||
commitId,
|
||||
message,
|
||||
title: '切换到指定提交',
|
||||
description: '确定要切换到此提交吗?这将使代码回到该提交时的状态,但如果要继续编写代码和提交请切换回最新的提交节点。',
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 处理反转提交
|
||||
const handleRevert = (commitId: string, message: string) => {
|
||||
setConfirmAction({
|
||||
type: 'revert',
|
||||
commitId,
|
||||
message,
|
||||
title: '反转提交',
|
||||
description: '确定要反转该提交吗?这将创建一个与该提交操作相反的提交。',
|
||||
})
|
||||
}
|
||||
|
||||
// 处理强制回滚
|
||||
const handleReset = (commitId: string, message: string) => {
|
||||
setConfirmAction({
|
||||
type: 'reset',
|
||||
commitId,
|
||||
message,
|
||||
title: '强制回滚到指定提交',
|
||||
description: '⚠️ 警告:确定要强制回滚到此提交吗?这将永久删除该提交之后的所有提交,此操作不可恢复!',
|
||||
})
|
||||
}
|
||||
|
||||
// 处理滚动事件
|
||||
const handleScroll = React.useCallback(() => {
|
||||
const viewport = scrollViewportRef.current
|
||||
if (!viewport || isFetching || isLoadingMore) return
|
||||
|
||||
const { scrollTop, scrollHeight, clientHeight } = viewport
|
||||
// 当滚动到底部附近100px时加载更多
|
||||
if (scrollTop + clientHeight >= scrollHeight - 100) {
|
||||
if (commits && commits.length >= commitLimit) {
|
||||
setIsLoadingMore(true)
|
||||
setCommitLimit(prev => prev + 10)
|
||||
}
|
||||
}
|
||||
}, [isFetching, commits, commitLimit, isLoadingMore])
|
||||
|
||||
// 监听加载状态变化,加载完成后重置isLoadingMore
|
||||
React.useEffect(() => {
|
||||
if (!isFetching && isLoadingMore) {
|
||||
setIsLoadingMore(false)
|
||||
}
|
||||
}, [isFetching, isLoadingMore])
|
||||
|
||||
// 监听滚动事件
|
||||
React.useEffect(() => {
|
||||
const viewport = scrollViewportRef.current
|
||||
if (!viewport) return
|
||||
|
||||
viewport.addEventListener('scroll', handleScroll)
|
||||
return () => viewport.removeEventListener('scroll', handleScroll)
|
||||
}, [handleScroll])
|
||||
|
||||
// 执行确认的操作
|
||||
const executeConfirmedAction = () => {
|
||||
if (!confirmAction) return
|
||||
|
||||
switch (confirmAction.type) {
|
||||
case 'checkout':
|
||||
if (confirmAction.commitId) {
|
||||
checkoutCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||
}
|
||||
break
|
||||
case 'checkout-branch':
|
||||
// 切换到分支(使用选中的分支名称)
|
||||
checkoutBranchMutation.mutate({ branchName: selectedBranch })
|
||||
break
|
||||
case 'revert':
|
||||
if (confirmAction.commitId) {
|
||||
revertCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||
}
|
||||
break
|
||||
case 'reset':
|
||||
if (confirmAction.commitId) {
|
||||
resetToCommitMutation.mutate({ commitId: confirmAction.commitId })
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
// 手动刷新所有数据
|
||||
const handleRefresh = () => {
|
||||
refetchBranches()
|
||||
refetchCommits()
|
||||
refetchHasChanges()
|
||||
}
|
||||
|
||||
const hasChanges = hasChangesData?.hasChanges
|
||||
const isLoading = branchesLoading || currentBranchLoading || commitsLoading || hasChangesLoading
|
||||
const branchOptions = React.useMemo(
|
||||
() =>
|
||||
branches?.map((b) => ({
|
||||
id: b.name,
|
||||
name: b.isCurrent ? `${b.name} (当前分支)` : b.name,
|
||||
})) || [],
|
||||
[branches]
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col space-y-4">
|
||||
{/* 分支选择器和操作按钮 */}
|
||||
<div className="space-y-2 shrink-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 左半部分:分支选择器 */}
|
||||
<div className="flex items-center gap-2 flex-1">
|
||||
<Label className="shrink-0 flex-none">分支</Label>
|
||||
<div className="flex-1 min-w-0">
|
||||
{branchesLoading ? (
|
||||
<Skeleton className="h-10 w-full" />
|
||||
) : (
|
||||
<AdvancedSelect
|
||||
value={selectedBranch}
|
||||
onChange={handleBranchChange}
|
||||
options={branchOptions}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="选择分支">
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItemList emptyText="未找到分支" />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 右半部分:Commit按钮和刷新按钮 */}
|
||||
<div className="flex items-center gap-2 flex-1 justify-end">
|
||||
<Button
|
||||
onClick={() => setShowCommitDialog(true)}
|
||||
disabled={!hasChanges || hasChangesLoading}
|
||||
variant="default"
|
||||
>
|
||||
<GitCommit className="mr-2 h-4 w-4" />
|
||||
提交更改
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<RefreshCw className={cn("h-4 w-4", isLoading && "animate-spin")} />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{hasChanges && (
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
<span>有未提交的更改</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedBranch !== currentBranchData?.branch && (
|
||||
<div className="flex items-center gap-2 text-xs text-muted-foreground">
|
||||
<span>当前查看 <strong>{selectedBranch}</strong> 分支的提交历史</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Separator className="shrink-0" />
|
||||
|
||||
{/* 提交历史 */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<ScrollArea
|
||||
className="h-full pr-4"
|
||||
viewportRef={scrollViewportRef}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
>
|
||||
{isInitialLoad && commitsLoading ? (
|
||||
<div className="space-y-4">
|
||||
{[1, 2, 3].map((i) => (
|
||||
<div key={i} className="space-y-2">
|
||||
<Skeleton className="h-4 w-3/4" />
|
||||
<Skeleton className="h-3 w-1/2" />
|
||||
<Skeleton className="h-16 w-full" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (!commits || commits.length === 0) ? (
|
||||
<TimelineEmpty>暂无提交记录</TimelineEmpty>
|
||||
) : (
|
||||
<Timeline>
|
||||
{commits.map((commit, index) => {
|
||||
const isAfterHead = commit.isAfterHead
|
||||
const isFirstCommit = index === 0
|
||||
|
||||
return (
|
||||
<TimelineItem key={commit.shortId} className='select-text'>
|
||||
<TimelineConnector className={cn(isAfterHead && 'bg-muted-foreground/30')} />
|
||||
|
||||
<TimelineNode
|
||||
icon={GitCommitIcon}
|
||||
className={cn(isAfterHead && 'border-muted-foreground/30')}
|
||||
iconClassName={cn(isAfterHead && 'text-muted-foreground/50')}
|
||||
/>
|
||||
|
||||
<TimelineContent>
|
||||
<TimelineHeader>
|
||||
<TimelineTitleArea className={cn(isAfterHead && 'opacity-50')}>
|
||||
<TimelineTitle className="leading-tight whitespace-normal">{commit.message}</TimelineTitle>
|
||||
</TimelineTitleArea>
|
||||
|
||||
<TimelineActions>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs">
|
||||
操作
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleCheckoutCommit(commit.shortId, commit.message, isFirstCommit)}
|
||||
disabled={hasChanges}
|
||||
>
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
{isFirstCommit ? '切换到此分支' : '切换到此提交'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem
|
||||
onClick={() => handleRevert(commit.shortId, commit.message)}
|
||||
>
|
||||
<CornerRightUp className="mr-2 h-4 w-4" />
|
||||
反转提交
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => handleReset(commit.shortId, commit.message)}
|
||||
>
|
||||
<RotateCcw className="mr-2 h-4 w-4" />
|
||||
强制回滚
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TimelineActions>
|
||||
</TimelineHeader>
|
||||
|
||||
<TimelineTimestamp timestamp={commit.date} className={cn(isAfterHead && 'opacity-50')} />
|
||||
|
||||
<TimelineDescription className={cn(isAfterHead && 'opacity-50')}>
|
||||
Commit ID: <TimelineBadge variant="secondary">{commit.shortId}</TimelineBadge>
|
||||
</TimelineDescription>
|
||||
|
||||
<TimelineMetadata
|
||||
className={cn("flex flex-row items-center gap-4 space-y-0", isAfterHead && 'opacity-50')}
|
||||
items={[
|
||||
{ label: '文件变更', value: `${commit.filesChanged}个` },
|
||||
{
|
||||
label: '新增行',
|
||||
value: `+${commit.insertions}`,
|
||||
valueClassName: 'text-green-600',
|
||||
},
|
||||
{
|
||||
label: '删除行',
|
||||
value: `-${commit.deletions}`,
|
||||
valueClassName: 'text-red-600',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</TimelineContent>
|
||||
</TimelineItem>
|
||||
)
|
||||
})}
|
||||
</Timeline>
|
||||
)}
|
||||
{/* 加载更多指示器 */}
|
||||
{isLoadingMore && !isInitialLoad && (
|
||||
<div className="flex items-center justify-center py-4 text-muted-foreground">
|
||||
<RefreshCw className="h-4 w-4 animate-spin mr-2" />
|
||||
<span className="text-sm">加载更多...</span>
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
</div>
|
||||
|
||||
{/* Commit对话框 */}
|
||||
<Dialog open={showCommitDialog} onOpenChange={setShowCommitDialog}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>提交更改</DialogTitle>
|
||||
<DialogDescription>
|
||||
请输入提交信息来描述本次更改
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 py-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="commit-message-dialog">提交信息</Label>
|
||||
<Textarea
|
||||
id="commit-message-dialog"
|
||||
placeholder="输入提交信息..."
|
||||
value={commitMessage}
|
||||
onChange={(e) => setCommitMessage(e.target.value)}
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter className="flex-row justify-end space-x-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
setShowCommitDialog(false)
|
||||
setCommitMessage('')
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => handleCommit(true)}
|
||||
disabled={!commitMessage.trim() || createCommitMutation.isPending}
|
||||
>
|
||||
{createCommitMutation.isPending && commitType === 'amend' ? '修订提交中...' : '修订提交'}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => handleCommit(false)}
|
||||
disabled={!commitMessage.trim() || createCommitMutation.isPending}
|
||||
>
|
||||
{createCommitMutation.isPending && commitType === 'normal' ? '提交中...' : '确认提交'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 确认对话框 */}
|
||||
<AlertDialog open={!!confirmAction} onOpenChange={() => setConfirmAction(null)}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmAction?.title}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmAction?.commitId && confirmAction?.message && (
|
||||
<>
|
||||
<span className="font-bold block mb-2">
|
||||
{confirmAction.commitId}: {confirmAction.message}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
{confirmAction?.description}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={executeConfirmedAction}
|
||||
className={confirmAction?.type === 'reset' ? 'bg-destructive hover:bg-destructive/90' : ''}
|
||||
>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal file
407
src/app/(main)/dev/panel/dev-ai-chat.tsx
Normal file
@@ -0,0 +1,407 @@
|
||||
'use client'
|
||||
|
||||
import { toast } from 'sonner'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import {
|
||||
Conversation,
|
||||
ConversationContent,
|
||||
ConversationEmptyState,
|
||||
ConversationScrollButton,
|
||||
} from '@/components/ai-elements/conversation'
|
||||
import { Message, MessageContent, MessageResponse, MessageAttachments, MessageAttachment } from '@/components/ai-elements/message'
|
||||
import { Actions, Action } from '@/components/ai-elements/actions'
|
||||
import {
|
||||
PromptInput,
|
||||
PromptInputTextarea,
|
||||
PromptInputToolbar,
|
||||
PromptInputTools,
|
||||
PromptInputSubmit,
|
||||
PromptInputAttachments,
|
||||
PromptInputAttachment,
|
||||
PromptInputActionMenu,
|
||||
PromptInputActionMenuTrigger,
|
||||
PromptInputActionMenuContent,
|
||||
PromptInputActionAddAttachments,
|
||||
PromptInputButton,
|
||||
} from '@/components/ai-elements/prompt-input'
|
||||
import {
|
||||
ModelSelector,
|
||||
ModelSelectorContent,
|
||||
ModelSelectorEmpty,
|
||||
ModelSelectorGroup,
|
||||
ModelSelectorInput,
|
||||
ModelSelectorItem,
|
||||
ModelSelectorList,
|
||||
ModelSelectorLogo,
|
||||
ModelSelectorName,
|
||||
ModelSelectorTrigger,
|
||||
} from '@/components/ai-elements/model-selector'
|
||||
import { MessageSquareIcon, BotIcon, CopyIcon, RefreshCcwIcon, CheckIcon, WrenchIcon } from 'lucide-react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useChat } from '@ai-sdk/react'
|
||||
import { DefaultChatTransport } from 'ai'
|
||||
import { useState, useEffect } from 'react'
|
||||
import { AGENT_TYPES, AVAILABLE_MODELS, getAgentTypeById, getModelById } from './agents-config'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Command, CommandEmpty, CommandGroup, CommandItem, CommandList } from '@/components/ui/command'
|
||||
import { DialogDescription } from '@/components/ui/dialog'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { Reasoning, ReasoningContent, ReasoningTrigger } from '@/components/ai-elements/reasoning'
|
||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||
|
||||
export function DevAIChat() {
|
||||
// 智能体、模型和工具的状态管理
|
||||
const [selectedAgent, setSelectedAgent] = useState(AGENT_TYPES[0].id)
|
||||
const [selectedModel, setSelectedModel] = useState(AGENT_TYPES[0].defaultModel)
|
||||
const [selectedTools, setSelectedTools] = useState<string[]>(AGENT_TYPES[0].defaultTools)
|
||||
const [agentSelectorOpen, setAgentSelectorOpen] = useState(false)
|
||||
const [modelSelectorOpen, setModelSelectorOpen] = useState(false)
|
||||
const [toolSelectorOpen, setToolSelectorOpen] = useState(false)
|
||||
|
||||
const { messages, status, sendMessage, regenerate } = useChat({
|
||||
transport: new DefaultChatTransport({
|
||||
api: '/api/dev/ai-chat',
|
||||
}),
|
||||
})
|
||||
|
||||
// 获取当前选中的智能体配置
|
||||
const currentAgent = getAgentTypeById(selectedAgent)
|
||||
const currentModel = getModelById(selectedModel)
|
||||
|
||||
// 当智能体切换时,自动选中默认的模型和工具
|
||||
useEffect(() => {
|
||||
const agent = getAgentTypeById(selectedAgent)
|
||||
if (agent) {
|
||||
setSelectedModel(agent.defaultModel)
|
||||
setSelectedTools(agent.defaultTools)
|
||||
}
|
||||
}, [selectedAgent])
|
||||
|
||||
// 切换工具选择
|
||||
const toggleTool = (toolId: string) => {
|
||||
setSelectedTools(prev =>
|
||||
prev.includes(toolId)
|
||||
? prev.filter(id => id !== toolId)
|
||||
: [...prev, toolId]
|
||||
)
|
||||
}
|
||||
|
||||
// 复制文本到剪贴板
|
||||
const handleCopy = (text: string) => {
|
||||
const success = copy(text)
|
||||
if (success) {
|
||||
toast.success('已复制到剪贴板')
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 重新生成回复
|
||||
const handleRegenerate = () => {
|
||||
regenerate({
|
||||
body: {
|
||||
agent: selectedAgent,
|
||||
model: selectedModel,
|
||||
tools: selectedTools,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-[98%] flex-col">
|
||||
{/* 消息列表区域 - 占据剩余空间 */}
|
||||
<ScrollArea className="h-full" >
|
||||
<Conversation className="flex-1 min-h-0">
|
||||
<ConversationContent>
|
||||
{messages.length === 0 ? (
|
||||
<ConversationEmptyState
|
||||
title="开始对话"
|
||||
description="与AI助手对话完成各种开发任务"
|
||||
icon={<MessageSquareIcon className="size-8" />}
|
||||
/>
|
||||
) : (
|
||||
messages.map((message, messageIndex) => {
|
||||
const isLastMessage = messageIndex === messages.length - 1
|
||||
const isAssistant = message.role === 'assistant'
|
||||
const messageText = message.parts
|
||||
.filter(part => part.type === 'text')
|
||||
.map(part => part.type === 'text' ? part.text : '')
|
||||
.join('')
|
||||
|
||||
return (
|
||||
<div key={message.id} className="group">
|
||||
<Message from={message.role}>
|
||||
<MessageContent className='select-text'>
|
||||
{/* 先渲染附件 */}
|
||||
{message.parts.some(part => part.type === 'file') && (
|
||||
<MessageAttachments>
|
||||
{message.parts
|
||||
.filter(part => part.type === 'file')
|
||||
.map((part, i) => (
|
||||
<MessageAttachment
|
||||
key={`${message.id}-file-${i}`}
|
||||
data={part}
|
||||
/>
|
||||
))}
|
||||
</MessageAttachments>
|
||||
)}
|
||||
|
||||
{/* 然后渲染其他内容 */}
|
||||
{message.parts.map((part, i) => {
|
||||
switch (part.type) {
|
||||
case 'text':
|
||||
return (
|
||||
<MessageResponse key={`${message.id}-${i}`}>
|
||||
{part.text}
|
||||
</MessageResponse>
|
||||
);
|
||||
case 'reasoning':
|
||||
return (
|
||||
<Reasoning
|
||||
key={`${message.id}-${i}`}
|
||||
className="w-full"
|
||||
isStreaming={status === 'streaming' && i === message.parts.length - 1 && message.id === messages.at(-1)?.id}
|
||||
>
|
||||
<ReasoningTrigger />
|
||||
<ReasoningContent>{part.text}</ReasoningContent>
|
||||
</Reasoning>
|
||||
);
|
||||
case 'file':
|
||||
// 文件已经在上面单独渲染了
|
||||
return null;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
})}
|
||||
</MessageContent>
|
||||
</Message>
|
||||
|
||||
{/* 操作按钮 - 悬停时显示 */}
|
||||
<Actions className={cn(
|
||||
"mt-2 opacity-0 group-hover:opacity-100 transition-opacity",
|
||||
message.role === 'user' ? 'mr-1 justify-end' : 'ml-1'
|
||||
)}>
|
||||
{/* 复制按钮 - 所有消息都有 */}
|
||||
<Action
|
||||
tooltip="复制"
|
||||
label="复制"
|
||||
onClick={() => handleCopy(messageText)}
|
||||
>
|
||||
<CopyIcon className="size-4" />
|
||||
</Action>
|
||||
|
||||
{/* 重新生成按钮 - 仅最后一条AI消息显示 */}
|
||||
{isAssistant && isLastMessage && (
|
||||
<Action
|
||||
tooltip="重新生成"
|
||||
label="重新生成"
|
||||
onClick={handleRegenerate}
|
||||
disabled={status === 'streaming'}
|
||||
>
|
||||
<RefreshCcwIcon className="size-4" />
|
||||
</Action>
|
||||
)}
|
||||
</Actions>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</ConversationContent>
|
||||
<ConversationScrollButton />
|
||||
</Conversation>
|
||||
</ScrollArea>
|
||||
|
||||
{/* 输入框区域 - 固定在底部 */}
|
||||
<div className="border-t bg-background">
|
||||
<PromptInput
|
||||
accept="image/*"
|
||||
multiple
|
||||
maxFiles={5}
|
||||
maxFileSize={50 * 1024 * 1024} // 50MB
|
||||
onError={(error) => {
|
||||
if ('message' in error) {
|
||||
console.error('文件上传错误:', error.message)
|
||||
|
||||
// 根据错误类型显示不同的提示
|
||||
switch (error.code) {
|
||||
case 'max_file_size':
|
||||
toast.error('文件过大', {
|
||||
description: '单个文件大小不能超过 10MB,请压缩后重试'
|
||||
})
|
||||
break
|
||||
case 'max_files':
|
||||
toast.error('文件数量超限', {
|
||||
description: '最多只能上传 5 个文件'
|
||||
})
|
||||
break
|
||||
case 'accept':
|
||||
toast.error('文件类型不支持', {
|
||||
description: '仅支持图片文件'
|
||||
})
|
||||
break
|
||||
default:
|
||||
toast.error('文件上传失败', {
|
||||
description: error.message
|
||||
})
|
||||
}
|
||||
}
|
||||
}}
|
||||
onSubmit={async (message) => {
|
||||
sendMessage(
|
||||
{
|
||||
text: message.text || '',
|
||||
files: message.files,
|
||||
},
|
||||
{
|
||||
body: {
|
||||
agent: selectedAgent,
|
||||
model: selectedModel,
|
||||
tools: selectedTools,
|
||||
},
|
||||
}
|
||||
)
|
||||
}}
|
||||
className="max-h-[50vh]"
|
||||
>
|
||||
{/* 附件预览区域 */}
|
||||
<PromptInputAttachments>
|
||||
{(attachment) => <PromptInputAttachment data={attachment} />}
|
||||
</PromptInputAttachments>
|
||||
|
||||
{/* 文本输入框 */}
|
||||
<PromptInputTextarea
|
||||
placeholder="输入消息或上传图片..."
|
||||
className="min-h-[60px] max-h-[40vh] resize-none"
|
||||
/>
|
||||
|
||||
{/* 工具栏 */}
|
||||
<PromptInputToolbar>
|
||||
<PromptInputTools className='flex-wrap'>
|
||||
{/* 智能体选择器 */}
|
||||
<Popover open={agentSelectorOpen} onOpenChange={setAgentSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PromptInputButton>
|
||||
<BotIcon className="size-4 text-muted-foreground" />
|
||||
<span>{currentAgent?.name}</span>
|
||||
</PromptInputButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[300px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>未找到智能体</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{AGENT_TYPES.map((agent) => (
|
||||
<CommandItem
|
||||
key={agent.id}
|
||||
value={agent.id}
|
||||
onSelect={() => {
|
||||
setSelectedAgent(agent.id)
|
||||
setAgentSelectorOpen(false)
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium text-sm">{agent.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{agent.description}</span>
|
||||
</div>
|
||||
{selectedAgent === agent.id && (
|
||||
<CheckIcon className="ml-2 size-4" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* 模型选择器 */}
|
||||
<ModelSelector open={modelSelectorOpen} onOpenChange={setModelSelectorOpen}>
|
||||
<ModelSelectorTrigger asChild>
|
||||
<PromptInputButton>
|
||||
<ModelSelectorLogo provider={currentModel?.logo || 'unknown'} />
|
||||
<ModelSelectorName>{currentModel?.name}</ModelSelectorName>
|
||||
</PromptInputButton>
|
||||
</ModelSelectorTrigger>
|
||||
<ModelSelectorContent title="选择模型">
|
||||
<VisuallyHidden><DialogDescription>选择模型</DialogDescription></VisuallyHidden>
|
||||
<ModelSelectorInput placeholder="搜索模型..." />
|
||||
<ModelSelectorList>
|
||||
<ModelSelectorEmpty>未找到模型</ModelSelectorEmpty>
|
||||
<ModelSelectorGroup>
|
||||
{AVAILABLE_MODELS.map((model) => (
|
||||
<ModelSelectorItem
|
||||
key={model.id}
|
||||
value={model.id}
|
||||
onSelect={() => {
|
||||
setSelectedModel(model.id)
|
||||
setModelSelectorOpen(false)
|
||||
}}
|
||||
>
|
||||
<ModelSelectorLogo provider={model.logo} />
|
||||
<ModelSelectorName>{model.name}</ModelSelectorName>
|
||||
{selectedModel === model.id && (
|
||||
<CheckIcon className="ml-auto size-4" />
|
||||
)}
|
||||
</ModelSelectorItem>
|
||||
))}
|
||||
</ModelSelectorGroup>
|
||||
</ModelSelectorList>
|
||||
</ModelSelectorContent>
|
||||
</ModelSelector>
|
||||
|
||||
{/* 工具选择器 */}
|
||||
{currentAgent && currentAgent.availableTools.length > 0 && (
|
||||
<Popover open={toolSelectorOpen} onOpenChange={setToolSelectorOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<PromptInputButton>
|
||||
<WrenchIcon className="size-3 text-muted-foreground" />
|
||||
<span>{selectedTools.length} 个工具</span>
|
||||
</PromptInputButton>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-[350px] p-0" align="start">
|
||||
<Command>
|
||||
<CommandList>
|
||||
<CommandEmpty>未找到工具</CommandEmpty>
|
||||
<CommandGroup heading="可用工具">
|
||||
{currentAgent.availableTools.map((tool) => (
|
||||
<CommandItem
|
||||
key={tool.id}
|
||||
value={tool.id}
|
||||
onSelect={() => toggleTool(tool.id)}
|
||||
>
|
||||
<div className="flex flex-1 flex-col">
|
||||
<span className="font-medium text-sm">{tool.name}</span>
|
||||
<span className="text-muted-foreground text-xs">{tool.description}</span>
|
||||
</div>
|
||||
{selectedTools.includes(tool.id) && (
|
||||
<CheckIcon className="ml-2 size-4 text-primary" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</PromptInputTools>
|
||||
|
||||
{/* 右侧按钮组 */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 附件上传菜单 */}
|
||||
<PromptInputActionMenu>
|
||||
<PromptInputActionMenuTrigger />
|
||||
<PromptInputActionMenuContent>
|
||||
<PromptInputActionAddAttachments label="添加图片" />
|
||||
</PromptInputActionMenuContent>
|
||||
</PromptInputActionMenu>
|
||||
|
||||
{/* 发送按钮 */}
|
||||
<PromptInputSubmit status={status} />
|
||||
</div>
|
||||
</PromptInputToolbar>
|
||||
</PromptInput>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
30
src/app/(main)/dev/panel/dev-checklist.tsx
Normal file
30
src/app/(main)/dev/panel/dev-checklist.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { GitBranch } from 'lucide-react'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { VersionControl } from './components/version-control'
|
||||
|
||||
|
||||
/**
|
||||
* 开发清单组件
|
||||
* 包含版本控制功能
|
||||
*/
|
||||
export function DevChecklist({ isOpen }: { isOpen: boolean }) {
|
||||
return (
|
||||
<Tabs defaultValue="version-control" className="flex h-[98%] flex-col">
|
||||
<TabsList className="w-fit">
|
||||
<TabsTrigger value="version-control">
|
||||
<GitBranch className="mr-2 h-4 w-4" />
|
||||
版本控制
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="version-control" className="flex-1 min-h-0 mt-4">
|
||||
<VersionControl isOpen={isOpen} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
}
|
||||
|
||||
59
src/app/(main)/dev/panel/dev-panel-provider.tsx
Normal file
59
src/app/(main)/dev/panel/dev-panel-provider.tsx
Normal file
@@ -0,0 +1,59 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
|
||||
/**
|
||||
* DevPanel Context 类型定义
|
||||
*/
|
||||
interface DevPanelContextType {
|
||||
terminalLoaded: boolean
|
||||
setTerminalLoaded: (loaded: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* DevPanel Context
|
||||
*/
|
||||
const DevPanelContext = React.createContext<DevPanelContextType | undefined>(
|
||||
undefined
|
||||
)
|
||||
|
||||
/**
|
||||
* DevPanel Provider Props
|
||||
*/
|
||||
interface DevPanelProviderProps {
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* DevPanel Provider
|
||||
* 管理开发面板的全局状态,包括终端加载状态等
|
||||
*/
|
||||
export function DevPanelProvider({ children }: DevPanelProviderProps) {
|
||||
const [terminalLoaded, setTerminalLoaded] = React.useState(false)
|
||||
|
||||
const value = React.useMemo(
|
||||
() => ({
|
||||
terminalLoaded,
|
||||
setTerminalLoaded,
|
||||
}),
|
||||
[terminalLoaded]
|
||||
)
|
||||
|
||||
return (
|
||||
<DevPanelContext.Provider value={value}>
|
||||
{children}
|
||||
</DevPanelContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 使用 DevPanel Context 的 Hook
|
||||
* @throws {Error} 如果在 DevPanelProvider 外部使用
|
||||
*/
|
||||
export function useDevPanel() {
|
||||
const context = React.useContext(DevPanelContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useDevPanel must be used within a DevPanelProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
47
src/app/(main)/dev/panel/dev-panel.tsx
Normal file
47
src/app/(main)/dev/panel/dev-panel.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { Code2 } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { TripleColumnAdaptiveDrawer } from '@/components/common/triple-column-adaptive-drawer'
|
||||
import { DevTools } from './dev-tools'
|
||||
import { DevPanelProvider } from './dev-panel-provider'
|
||||
import { DevAIChat } from './dev-ai-chat'
|
||||
import { DevChecklist } from './dev-checklist'
|
||||
|
||||
export function DevPanel() {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DevPanelProvider>
|
||||
<TripleColumnAdaptiveDrawer
|
||||
trigger={
|
||||
<Button variant="ghost" size="icon" title="开发者工具">
|
||||
<Code2 className="h-5 w-5" />
|
||||
</Button>
|
||||
}
|
||||
drawerTitle="开发者工具"
|
||||
drawerDescription="开发环境专用工具面板"
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
columns={[
|
||||
{
|
||||
id: 'checklist',
|
||||
title: '开发清单',
|
||||
content: <DevChecklist isOpen={open} />,
|
||||
},
|
||||
{
|
||||
id: 'ai-chat',
|
||||
title: '对话',
|
||||
content: <DevAIChat />,
|
||||
},
|
||||
{
|
||||
id: 'dev-tools',
|
||||
title: '开发工具',
|
||||
content: <DevTools />,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</DevPanelProvider>
|
||||
)
|
||||
}
|
||||
252
src/app/(main)/dev/panel/dev-tools.tsx
Normal file
252
src/app/(main)/dev/panel/dev-tools.tsx
Normal file
@@ -0,0 +1,252 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Terminal, Maximize2, Plug, Plus, ChevronLeft, ChevronRight, X, Columns2, ArrowLeftRight, List, Copy, LucideIcon, HelpCircle, ExternalLink } from 'lucide-react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { useDevPanel } from './dev-panel-provider'
|
||||
import { Kbd } from '@/components/ui/kbd'
|
||||
import { PreviewCard } from '@/components/common/preview-card'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// 终端操作按钮配置
|
||||
interface TerminalAction {
|
||||
label: string
|
||||
description: string
|
||||
icon: LucideIcon
|
||||
command: string
|
||||
key?: string
|
||||
iconRotate?: boolean
|
||||
}
|
||||
|
||||
const WINDOW_ACTIONS: TerminalAction[] = [
|
||||
{ label: '新建窗口', description: '在当前session中创建一个新的窗口', icon: Plus, command: 'new-window', key: 'C' },
|
||||
{ label: '上一个', description: '切换到上一个窗口', icon: ChevronLeft, command: 'previous-window', key: 'P' },
|
||||
{ label: '下一个', description: '切换到下一个窗口', icon: ChevronRight, command: 'next-window', key: 'N' },
|
||||
]
|
||||
|
||||
const PANE_ACTIONS: TerminalAction[] = [
|
||||
{ label: '水平分割', description: '将当前面板水平分割为上下两个面板', icon: Columns2, command: 'split-window -v', key: '"', iconRotate: true },
|
||||
{ label: '垂直分割', description: '将当前面板垂直分割为左右两个面板', icon: Columns2, command: 'split-window -h', key: '%' },
|
||||
{ label: '切换面板', description: '在多个面板之间循环切换', icon: ArrowLeftRight, command: 'send-keys "tmux select-pane -t :.+" ^M', key: 'O' },
|
||||
{ label: '复制模式', description: '进入复制模式,可以滚动查看历史输出,按 q 退出', icon: Copy, command: 'copy-mode', key: '[' },
|
||||
]
|
||||
|
||||
const HELP_ACTIONS: TerminalAction[] = [
|
||||
{ label: '列出会话', description: '显示所有tmux会话列表', icon: List, command: 'send-keys "tmux ls" ^M' },
|
||||
{ label: '列出窗口', description: '显示当前会话的所有窗口', icon: List, command: 'send-keys "tmux list-windows" ^M', key: 'W' },
|
||||
{ label: '列出快捷键', description: '显示所有tmux快捷键绑定', icon: HelpCircle, command: 'send-keys "tmux list-keys" ^M' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 开发工具组件
|
||||
* 提供终端等开发工具的标签页界面,支持全屏展示
|
||||
*/
|
||||
export function DevTools() {
|
||||
const [fullscreenOpen, setFullscreenOpen] = React.useState(false)
|
||||
const [hoveredAction, setHoveredAction] = React.useState<TerminalAction | null>(null)
|
||||
const { terminalLoaded, setTerminalLoaded } = useDevPanel()
|
||||
|
||||
// 发送tmux命令的mutation
|
||||
const sendCommand = trpc.devPanel!.sendTmuxCommand.useMutation({
|
||||
onError: (error) => {
|
||||
toast.error(`命令执行失败: ${error.message}`)
|
||||
},
|
||||
})
|
||||
|
||||
// 执行tmux命令
|
||||
const executeTmuxCommand = (command?: string) => {
|
||||
if (command) {
|
||||
sendCommand.mutate({ command })
|
||||
}
|
||||
}
|
||||
|
||||
// 渲染操作按钮
|
||||
const renderActionButton = (action: TerminalAction) => {
|
||||
const Icon = action.icon
|
||||
return (
|
||||
<Button
|
||||
key={action.command}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => executeTmuxCommand(action.command)}
|
||||
onMouseEnter={() => setHoveredAction(action)}
|
||||
onMouseLeave={() => setHoveredAction(null)}
|
||||
disabled={!terminalLoaded || sendCommand.isPending}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Icon className={`h-3.5 w-3.5 ${action.iconRotate ? 'rotate-90' : ''}`} />
|
||||
{action.label}
|
||||
{action.key && <Kbd className="ml-1">{action.key}</Kbd>}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 终端控制面板内容
|
||||
const terminalControlsContent = (
|
||||
<div className="space-y-3">
|
||||
{/* 提示信息 */}
|
||||
<div className="text-xs text-muted-foreground">
|
||||
使用 <Kbd>Ctrl</Kbd> + <Kbd>B</Kbd> 进入tmux控制模式,或点击下面的按钮进行快捷操作。
|
||||
</div>
|
||||
|
||||
{/* 窗口管理 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">窗口管理</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{WINDOW_ACTIONS.map(renderActionButton)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 面板管理 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">面板管理</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{PANE_ACTIONS.map(renderActionButton)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 帮助 */}
|
||||
<div className="space-y-2">
|
||||
<div className="text-sm font-medium">帮助</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{HELP_ACTIONS.map(renderActionButton)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const port = process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'
|
||||
window.open(`http://localhost:${port}`, '_blank')
|
||||
}}
|
||||
disabled={!terminalLoaded}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
独立页面中打开
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 实时描述区域 */}
|
||||
<div className="pt-2 border-t border-border">
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{hoveredAction ? (
|
||||
<div className="space-y-1">
|
||||
<div>{hoveredAction.description}</div>
|
||||
</div>
|
||||
) : (
|
||||
<div>将鼠标悬停在按钮上查看详细说明</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
// 渲染工具内容
|
||||
const renderToolsContent = (isFullscreen = false) => (
|
||||
<Tabs defaultValue="terminal" className="w-full h-full flex flex-col">
|
||||
<div className="flex items-center justify-between">
|
||||
<TabsList>
|
||||
<PreviewCard
|
||||
title="终端控制"
|
||||
description={terminalControlsContent}
|
||||
side="top"
|
||||
align="start"
|
||||
className="w-[500px]"
|
||||
disabled={!terminalLoaded}
|
||||
>
|
||||
<TabsTrigger value="terminal" className={terminalLoaded ? "font-bold" : ""}>
|
||||
{terminalLoaded ? (
|
||||
<Plug className="mr-2 h-4 w-4" />
|
||||
) : (
|
||||
<Terminal className="mr-2 h-4 w-4" />
|
||||
)}
|
||||
<span className={terminalLoaded ? "bg-gradient-to-r from-blue-400 to-blue-500 bg-clip-text text-transparent" : ""}>
|
||||
终端
|
||||
</span>
|
||||
</TabsTrigger>
|
||||
</PreviewCard>
|
||||
</TabsList>
|
||||
{!isFullscreen && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
全屏
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<TabsContent value="terminal" className="flex-1 mt-0 pt-4 h-full">
|
||||
<div className="w-full h-full">
|
||||
{terminalLoaded ? (
|
||||
<iframe
|
||||
src={`http://localhost:${process.env.NEXT_PUBLIC_DEV_TERMINAL_DEFAULT_PORT || '7681'}`}
|
||||
className="w-full h-full border-0 rounded-md bg-black"
|
||||
title="开发终端"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-full flex items-center justify-center bg-muted/20 rounded-md">
|
||||
<div className="text-center space-y-4 px-4">
|
||||
<div className="flex h-16 w-16 items-center justify-center rounded-full bg-primary/10 mx-auto">
|
||||
<Terminal className="h-8 w-8 text-primary" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs text-muted-foreground max-w-md">
|
||||
点击下方按钮连接Web终端,可以在浏览器中直接执行命令
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
onClick={() => setTerminalLoaded(true)}
|
||||
className="gap-2"
|
||||
>
|
||||
<Plug className='w-4 h-4' />
|
||||
连接终端
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderToolsContent()}
|
||||
|
||||
{/* 全屏对话框 */}
|
||||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||||
<DialogContent className="p-0" variant="fullscreen">
|
||||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||||
<DialogTitle className="px-6 text-base">开发工具</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
全屏查看开发工具
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="overflow-hidden p-6">
|
||||
{renderToolsContent(true)}
|
||||
</DialogBody>
|
||||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
5
src/app/(main)/dev/panel/index.ts
Normal file
5
src/app/(main)/dev/panel/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* 开发者面板本质是个drawer组件而不是单独的页面,在这个文件中导出以便使用
|
||||
*/
|
||||
|
||||
export { DevPanel } from './dev-panel'
|
||||
19
src/app/(main)/dev/run/container/page.dev.tsx
Normal file
19
src/app/(main)/dev/run/container/page.dev.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
"use client";
|
||||
|
||||
export default function ContainerPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h2 className="text-2xl font-bold mb-4">容器管理</h2>
|
||||
<p className="text-muted-foreground">
|
||||
TODO
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 这里可以添加完整页面的测试和展示 */}
|
||||
<div className="space-y-4">
|
||||
{/* 示例区域 */}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
9
src/app/(main)/dev/run/layout.dev.tsx
Normal file
9
src/app/(main)/dev/run/layout.dev.tsx
Normal file
@@ -0,0 +1,9 @@
|
||||
import { SubMenuLayout } from "@/components/layout/sub-menu-layout";
|
||||
|
||||
export default function RunPageLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return <SubMenuLayout parentHref="/dev/run">{children}</SubMenuLayout>;
|
||||
}
|
||||
5
src/app/(main)/dev/run/page.dev.tsx
Normal file
5
src/app/(main)/dev/run/page.dev.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
import { SubMenuRedirect } from "@/components/layout/sub-menu-redirect";
|
||||
|
||||
export default function RunPage() {
|
||||
return <SubMenuRedirect parentHref="/dev/run" />;
|
||||
}
|
||||
26
src/app/(main)/error/403/page.tsx
Normal file
26
src/app/(main)/error/403/page.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { signOut } from 'next-auth/react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
export default function ForbiddenPage() {
|
||||
const router = useRouter()
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-3xl font-bold mb-4">403 权限不足</h1>
|
||||
<p className="mb-6">您没有访问此页面的权限。您可以尝试重新登录或联系系统管理员。</p>
|
||||
<Button
|
||||
onClick={async() => {
|
||||
// 重定向到登录页
|
||||
await signOut({ redirect: false })
|
||||
router.push('/login')
|
||||
}}
|
||||
className="px-4 py-2 bg-primary text-white rounded"
|
||||
>
|
||||
返回登录
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
32
src/app/(main)/layout.tsx
Normal file
32
src/app/(main)/layout.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
// app/(main)/layout.tsx
|
||||
import { MainLayout } from '@/components/layout/main-layout'
|
||||
import { getServerSession } from 'next-auth'
|
||||
import { authOptions } from '@/server/auth'
|
||||
import { menuItems, filterMenuItemsByPermission } from '@/constants/menu'
|
||||
|
||||
export default async function MainAppLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
// 在服务器端获取用户会话和权限
|
||||
const session = await getServerSession(authOptions)
|
||||
const userPermissions = session?.user?.permissions ?? []
|
||||
const isSuperAdmin = session?.user?.isSuperAdmin ?? false
|
||||
|
||||
// 在服务器端过滤菜单项
|
||||
const filteredMenuItems = filterMenuItemsByPermission(
|
||||
menuItems,
|
||||
userPermissions,
|
||||
isSuperAdmin
|
||||
)
|
||||
|
||||
return (
|
||||
<MainLayout
|
||||
user={session?.user}
|
||||
menuItems={filteredMenuItems}
|
||||
>
|
||||
{children}
|
||||
</MainLayout>
|
||||
)
|
||||
}
|
||||
26
src/app/(main)/not-found.tsx
Normal file
26
src/app/(main)/not-found.tsx
Normal file
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { useRouter } from 'next/navigation'
|
||||
|
||||
/**
|
||||
* 404 Not Found 页面UI组件
|
||||
* 当调用notFound()时会自动渲染此组件,并返回404状态码
|
||||
*/
|
||||
export default function NotFound() {
|
||||
const router = useRouter()
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full">
|
||||
<h1 className="text-3xl font-bold mb-4">404 页面未找到</h1>
|
||||
<p className="mb-6">您访问的页面不存在。您可以返回首页或联系系统管理员。</p>
|
||||
<Button
|
||||
onClick={() => router.push('/')}
|
||||
className="px-4 py-2 bg-primary text-white rounded"
|
||||
>
|
||||
返回首页
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
375
src/app/(main)/page.tsx
Normal file
375
src/app/(main)/page.tsx
Normal file
@@ -0,0 +1,375 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import {
|
||||
Sparkles,
|
||||
Zap,
|
||||
Shield,
|
||||
Layers,
|
||||
Rocket,
|
||||
Database,
|
||||
Palette,
|
||||
FileCode,
|
||||
Terminal,
|
||||
CheckCircle2,
|
||||
ArrowRight,
|
||||
BookOpen,
|
||||
Upload,
|
||||
Users,
|
||||
Github,
|
||||
} from 'lucide-react'
|
||||
import { motion } from 'framer-motion'
|
||||
import { SITE_NAME, SITE_VERSION, SITE_DESCRIPTION } from '@/constants/site'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { WelcomeDialog } from './welcome'
|
||||
|
||||
const features = [
|
||||
{
|
||||
icon: Shield,
|
||||
title: '身份认证与权限',
|
||||
description: '内置完整的用户认证系统和细粒度权限控制,支持角色管理和复杂权限表达式',
|
||||
color: 'text-blue-500',
|
||||
bgColor: 'bg-blue-500/10'
|
||||
},
|
||||
{
|
||||
icon: Palette,
|
||||
title: '丰富UI组件',
|
||||
description: '基于 shadcn/ui 和 Radix UI,提供50+高质量组件,支持深色模式和响应式设计',
|
||||
color: 'text-purple-500',
|
||||
bgColor: 'bg-purple-500/10'
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
title: '数据层完整方案',
|
||||
description: 'Prisma ORM + PostgreSQL + Redis + MinIO,提供完整的数据存储和文件管理方案',
|
||||
color: 'text-green-500',
|
||||
bgColor: 'bg-green-500/10'
|
||||
},
|
||||
{
|
||||
icon: Zap,
|
||||
title: '后台任务队列',
|
||||
description: '基于 BullMQ 的任务队列系统,支持任务进度订阅和实时状态更新',
|
||||
color: 'text-yellow-500',
|
||||
bgColor: 'bg-yellow-500/10'
|
||||
},
|
||||
{
|
||||
icon: Sparkles,
|
||||
title: 'AI 智能体开发',
|
||||
description: '集成 AI SDK,提供对话组件和智能体开发工具,快速构建AI驱动的应用',
|
||||
color: 'text-pink-500',
|
||||
bgColor: 'bg-pink-500/10'
|
||||
},
|
||||
{
|
||||
icon: Upload,
|
||||
title: '文件上传管理',
|
||||
description: '客户端直传架构,基于MinIO的文件存储方案,支持预签名URL和权限控制',
|
||||
color: 'text-orange-500',
|
||||
bgColor: 'bg-orange-500/10'
|
||||
}
|
||||
]
|
||||
|
||||
const techStack = [
|
||||
{ category: '前端框架', items: ['Next.js', 'React', 'TypeScript'] },
|
||||
{ category: 'UI组件', items: ['Tailwind CSS', 'Radix UI', 'shadcn/ui', 'Framer Motion'] },
|
||||
{ category: '后端架构', items: ['tRPC', 'Prisma', 'NextAuth'] },
|
||||
{ category: '数据存储', items: ['PostgreSQL', 'Redis', 'MinIO'] },
|
||||
{ category: '任务队列', items: ['BullMQ'] },
|
||||
{ category: 'AI集成', items: ['AI SDK', 'ai-elements'] }
|
||||
]
|
||||
|
||||
const quickStartSteps = [
|
||||
{
|
||||
icon: Terminal,
|
||||
title: '克隆项目',
|
||||
description: '从仓库克隆模板项目到本地',
|
||||
code: 'git clone <repository-url>'
|
||||
},
|
||||
{
|
||||
icon: FileCode,
|
||||
title: '安装依赖',
|
||||
description: '使用 pnpm 安装项目依赖',
|
||||
code: 'pnpm install'
|
||||
},
|
||||
{
|
||||
icon: Database,
|
||||
title: '配置数据库',
|
||||
description: '配置环境变量并初始化数据库',
|
||||
code: 'pnpm run db:seed'
|
||||
},
|
||||
{
|
||||
icon: Rocket,
|
||||
title: '启动开发',
|
||||
description: '启动开发服务器,开始构建应用',
|
||||
code: 'pnpm run dev'
|
||||
}
|
||||
]
|
||||
|
||||
const highlights = [
|
||||
{ icon: CheckCircle2, text: '开箱即用的完整功能' },
|
||||
{ icon: CheckCircle2, text: '类型安全的全栈开发' },
|
||||
{ icon: CheckCircle2, text: '现代化的开发体验' },
|
||||
{ icon: CheckCircle2, text: '丰富的开发辅助工具' },
|
||||
{ icon: CheckCircle2, text: '完整的用户管理系统' },
|
||||
{ icon: CheckCircle2, text: '灵活的权限控制机制' }
|
||||
]
|
||||
|
||||
export default function HomePage() {
|
||||
const [showWelcome, setShowWelcome] = useState(false)
|
||||
|
||||
// 检查是否已显示过欢迎对话框
|
||||
const { data: welcomeStatus } = trpc.global.checkWelcomeShown.useQuery()
|
||||
const markWelcomeShown = trpc.global.markWelcomeShown.useMutation()
|
||||
|
||||
useEffect(() => {
|
||||
if (welcomeStatus && !welcomeStatus.shown) {
|
||||
setShowWelcome(true)
|
||||
}
|
||||
}, [welcomeStatus])
|
||||
|
||||
const handleWelcomeClose = async (open: boolean) => {
|
||||
if (!open) {
|
||||
setShowWelcome(false)
|
||||
await markWelcomeShown.mutateAsync()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<WelcomeDialog open={showWelcome} onOpenChange={handleWelcomeClose} />
|
||||
{/* Hero Section */}
|
||||
<section className="relative overflow-hidden bg-gradient-to-b from-background to-muted/20 px-4 py-20 md:py-32">
|
||||
<div className="absolute inset-0 bg-grid-white/10 [mask-image:radial-gradient(white,transparent_85%)]" />
|
||||
<div className="relative mx-auto max-w-6xl text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h1 className="mb-6 text-4xl font-bold tracking-tight sm:text-5xl md:text-6xl lg:text-7xl">
|
||||
<span className="bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
|
||||
{SITE_NAME}
|
||||
</span>
|
||||
<sup className="ml-2 text-base font-normal text-muted-foreground md:text-lg">
|
||||
{SITE_VERSION}
|
||||
</sup>
|
||||
</h1>
|
||||
<p className="mx-auto mb-8 max-w-3xl text-lg text-muted-foreground md:text-xl">
|
||||
{SITE_DESCRIPTION}
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Button size="lg" className="gap-2">
|
||||
<Rocket className="h-5 w-5" />
|
||||
快速开始
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<BookOpen className="h-5 w-5" />
|
||||
查看文档
|
||||
</Button>
|
||||
<Button size="lg" variant="ghost" className="gap-2">
|
||||
<Github className="h-5 w-5" />
|
||||
GitHub
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
|
||||
{/* Highlights */}
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ duration: 0.5, delay: 0.2 }}
|
||||
className="mt-16 grid grid-cols-2 gap-4 md:grid-cols-3 lg:grid-cols-6"
|
||||
>
|
||||
{highlights.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-2 rounded-lg bg-background/50 p-3 text-sm backdrop-blur"
|
||||
>
|
||||
<item.icon className="h-4 w-4 shrink-0 text-primary" />
|
||||
<span className="text-left">{item.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Features Section */}
|
||||
<section className="px-4 py-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold md:text-4xl">核心特性</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
一站式全栈开发解决方案,让你专注于业务逻辑
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="flex"
|
||||
>
|
||||
<Card className="flex-1 transition-all hover:shadow-lg">
|
||||
<CardHeader>
|
||||
<div className={`mb-4 inline-flex h-12 w-12 items-center justify-center rounded-lg ${feature.bgColor}`}>
|
||||
<feature.icon className={`h-6 w-6 ${feature.color}`} />
|
||||
</div>
|
||||
<CardTitle>{feature.title}</CardTitle>
|
||||
<CardDescription>{feature.description}</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Tech Stack Section */}
|
||||
<section className="bg-muted/30 px-4 py-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold md:text-4xl">技术栈</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
基于业界最佳实践,精选成熟稳定的技术组合
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">
|
||||
{techStack.map((stack, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="flex"
|
||||
>
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Layers className="h-5 w-5 text-primary" />
|
||||
{stack.category}
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{stack.items.map((item, i) => (
|
||||
<Badge key={i} variant="secondary">
|
||||
{item}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick Start Section */}
|
||||
<section className="px-4 py-20">
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
className="mb-12 text-center"
|
||||
>
|
||||
<h2 className="mb-4 text-3xl font-bold md:text-4xl">快速开始</h2>
|
||||
<p className="text-lg text-muted-foreground">
|
||||
四步即可启动你的项目
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid gap-6 md:grid-cols-2 lg:grid-cols-4">
|
||||
{quickStartSteps.map((step, index) => (
|
||||
<motion.div
|
||||
key={index}
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5, delay: index * 0.1 }}
|
||||
className="flex"
|
||||
>
|
||||
<Card className="flex-1">
|
||||
<CardHeader>
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary text-lg font-bold text-primary-foreground">
|
||||
{index + 1}
|
||||
</div>
|
||||
<step.icon className="h-6 w-6 text-primary" />
|
||||
</div>
|
||||
<CardTitle className="text-lg">{step.title}</CardTitle>
|
||||
<CardDescription>{step.description}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<code className="block rounded-md bg-muted p-3 text-sm">
|
||||
{step.code}
|
||||
</code>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* CTA Section */}
|
||||
<section className="bg-gradient-to-r from-primary/10 via-primary/5 to-primary/10 px-4 py-20">
|
||||
<div className="mx-auto max-w-4xl text-center">
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
whileInView={{ opacity: 1, y: 0 }}
|
||||
viewport={{ once: true }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<h2 className="mb-6 text-3xl font-bold md:text-4xl">
|
||||
准备好开始构建了吗?
|
||||
</h2>
|
||||
<p className="mb-8 text-lg text-muted-foreground">
|
||||
立即使用 Hair Keeper 模板,加速你的全栈应用开发
|
||||
</p>
|
||||
<div className="flex flex-wrap items-center justify-center gap-4">
|
||||
<Button size="lg" className="gap-2">
|
||||
开始使用
|
||||
<ArrowRight className="h-5 w-5" />
|
||||
</Button>
|
||||
<Button size="lg" variant="outline" className="gap-2">
|
||||
<Users className="h-5 w-5" />
|
||||
加入社区
|
||||
</Button>
|
||||
</div>
|
||||
</motion.div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Footer */}
|
||||
<footer className="border-t px-4 py-8">
|
||||
<div className="mx-auto max-w-6xl text-center text-sm text-muted-foreground">
|
||||
<p>© 2025 {SITE_NAME}. 基于成熟架构的全栈Web应用模板</p>
|
||||
</div>
|
||||
</footer>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
35
src/app/(main)/settings/page.tsx
Normal file
35
src/app/(main)/settings/page.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { Settings } from 'lucide-react'
|
||||
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold tracking-tight">系统设置</h1>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
系统配置和全局参数设置
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center space-x-2">
|
||||
<Settings className="w-5 h-5" />
|
||||
<span>系统设置</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center justify-center h-64 text-muted-foreground">
|
||||
<div className="text-center">
|
||||
<Settings className="w-12 h-12 mx-auto mb-4 opacity-50" />
|
||||
<p className="text-lg">系统设置页面</p>
|
||||
<p className="text-sm">在这里实现系统管理功能</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
260
src/app/(main)/users/columns.tsx
Normal file
260
src/app/(main)/users/columns.tsx
Normal file
@@ -0,0 +1,260 @@
|
||||
'use client'
|
||||
|
||||
import { ColumnDef } from '@tanstack/react-table'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Edit, Trash2, MoreHorizontal } from 'lucide-react'
|
||||
import { formatDate } from '@/lib/format'
|
||||
import { userStatusOptions } from '@/lib/schema/user'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { DataTableColumnHeader } from '@/components/data-table/column-header'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import type { User } from '@/server/routers/users'
|
||||
|
||||
// 操作回调类型
|
||||
export type UserActions = {
|
||||
onEdit: (userId: string) => void
|
||||
onDelete: (userId: string) => void
|
||||
}
|
||||
|
||||
// 列定义选项类型
|
||||
export type UserColumnsOptions = {
|
||||
roles?: Array<{ id: number; name: string }>
|
||||
permissions?: Array<{ id: number; name: string }>
|
||||
depts?: Array<{ code: string; name: string; fullName: string }>
|
||||
}
|
||||
|
||||
// 创建用户表格列定义
|
||||
export const createUserColumns = (
|
||||
actions: UserActions,
|
||||
options: UserColumnsOptions = {}
|
||||
): ColumnDef<User>[] => [
|
||||
{
|
||||
id: "select",
|
||||
header: ({ table }) => (
|
||||
<Checkbox
|
||||
checked={
|
||||
table.getIsAllPageRowsSelected() ||
|
||||
(table.getIsSomePageRowsSelected() && "indeterminate")
|
||||
}
|
||||
onCheckedChange={(value) =>
|
||||
table.toggleAllPageRowsSelected(!!value)
|
||||
}
|
||||
aria-label="Select all"
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
checked={row.getIsSelected()}
|
||||
onCheckedChange={(value) => row.toggleSelected(!!value)}
|
||||
aria-label="Select row"
|
||||
/>
|
||||
),
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
{
|
||||
id: 'id',
|
||||
accessorKey: 'id',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="用户ID" />
|
||||
),
|
||||
cell: ({ row }) => <div className="font-medium">{row.original.id}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '用户ID',
|
||||
filter: {
|
||||
placeholder: '请输入用户ID',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'name',
|
||||
accessorKey: 'name',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="姓名" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.name || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '姓名',
|
||||
filter: {
|
||||
placeholder: '请输入姓名',
|
||||
variant: 'text',
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'status',
|
||||
accessorKey: 'status',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="状态" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const status = row.original.status
|
||||
return (
|
||||
<Badge variant={status === '在校' ? 'default' : 'secondary'}>
|
||||
{status || '未知'}
|
||||
</Badge>
|
||||
)
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '状态',
|
||||
filter: {
|
||||
variant: 'select',
|
||||
options: userStatusOptions,
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'dept',
|
||||
accessorKey: 'dept',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="所属院系" />
|
||||
),
|
||||
cell: ({ row }) => <div>{row.original.dept?.name || '-'}</div>,
|
||||
enableColumnFilter: true,
|
||||
meta: {
|
||||
label: '所属院系',
|
||||
filter: {
|
||||
variant: 'multiSelect',
|
||||
options: options.depts?.map(dept => ({
|
||||
id: dept.code,
|
||||
name: dept.fullName,
|
||||
})) || [],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'roles',
|
||||
accessorKey: 'roles',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="角色" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const roles = row.original.roles
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{roles.map((role) => (
|
||||
<Badge key={role.id} variant="secondary">
|
||||
{role.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
label: '角色',
|
||||
filter: {
|
||||
variant: 'select',
|
||||
options: options.roles?.map(role => ({
|
||||
id: role.id.toString(),
|
||||
name: role.name,
|
||||
})) || [],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'permissions',
|
||||
accessorFn: row => Array.from(
|
||||
new Set(
|
||||
row.roles.flatMap((role) => role.permissions.map((p) => p.name))
|
||||
)
|
||||
),
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="权限" />
|
||||
),
|
||||
cell: ({ getValue }) => {
|
||||
return (
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{getValue<string[]>().map((permName) => (
|
||||
<Badge key={permName} variant="outline" className="text-xs">
|
||||
{permName}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
},
|
||||
enableColumnFilter: true,
|
||||
enableSorting: false,
|
||||
meta: {
|
||||
label: '权限',
|
||||
filter: {
|
||||
variant: 'select',
|
||||
options: options.permissions?.map(permission => ({
|
||||
id: permission.id.toString(),
|
||||
name: permission.name,
|
||||
})) || [],
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'lastLoginAt',
|
||||
accessorKey: 'lastLoginAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="最后登录" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
const lastLoginAt = row.original.lastLoginAt as Date | null
|
||||
return <div>{lastLoginAt ? formatDate(lastLoginAt) : '从未登录'}</div>
|
||||
},
|
||||
meta: {
|
||||
label: '最后登录',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'createdAt',
|
||||
accessorKey: 'createdAt',
|
||||
header: ({ column }) => (
|
||||
<DataTableColumnHeader column={column} title="创建时间" />
|
||||
),
|
||||
cell: ({ row }) => {
|
||||
return <div>{formatDate(row.original.createdAt) || '-'}</div>
|
||||
},
|
||||
meta: {
|
||||
label: '创建时间',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'actions',
|
||||
cell: ({ row }) => {
|
||||
const user = row.original
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6 md:w-9">
|
||||
<MoreHorizontal className="h-4 w-4" />
|
||||
<span className="sr-only">打开菜单</span>
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => actions.onEdit(user.id)}>
|
||||
<Edit className="h-4 w-4" />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => actions.onDelete(user.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
},
|
||||
size: 32,
|
||||
enableSorting: false,
|
||||
enableHiding: false,
|
||||
},
|
||||
]
|
||||
199
src/app/(main)/users/components/BatchAuthorizationDialog.tsx
Normal file
199
src/app/(main)/users/components/BatchAuthorizationDialog.tsx
Normal file
@@ -0,0 +1,199 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { z } from 'zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction } from '@/components/common/form-dialog'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedName,
|
||||
SelectedBadges
|
||||
} from '@/components/common/advanced-select'
|
||||
import { Users } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
// 定义表单数据结构
|
||||
const batchAuthorizationSchema = z.object({
|
||||
roleId: z.number({ message: '请选择角色' }),
|
||||
deptCodes: z.array(z.string()).optional(),
|
||||
})
|
||||
|
||||
type BatchAuthorizationFormData = z.input<typeof batchAuthorizationSchema>
|
||||
|
||||
export const BatchAuthorizationDialog = function BatchAuthorizationDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [currentAction, setCurrentAction] = useState<'grant' | 'revoke' | null>(null)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 获取部门列表和角色列表
|
||||
const { data: depts = [] } = trpc.common.getDepts.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
})
|
||||
const { data: roles = [] } = trpc.users.getRoles.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
})
|
||||
|
||||
// 初始化表单
|
||||
const form = useForm<BatchAuthorizationFormData>({
|
||||
resolver: zodResolver(batchAuthorizationSchema),
|
||||
defaultValues: {
|
||||
roleId: undefined,
|
||||
deptCodes: [],
|
||||
},
|
||||
})
|
||||
|
||||
// 批量更新角色mutation
|
||||
const batchUpdateRoleMutation = trpc.users.batchUpdateRole.useMutation({
|
||||
onSuccess: (result: { count: number }, variables) => {
|
||||
const action = variables.action === 'grant' ? '授予' : '撤销'
|
||||
toast.success(`成功为 ${result.count} 个用户${action}角色`)
|
||||
utils.users.list.invalidate()
|
||||
setCurrentAction(null)
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: (error: { message?: string }) => {
|
||||
toast.error(error.message || '批量操作失败')
|
||||
setCurrentAction(null)
|
||||
}
|
||||
})
|
||||
|
||||
// 处理授权
|
||||
const handleGrant = async (values: BatchAuthorizationFormData) => {
|
||||
setCurrentAction('grant')
|
||||
batchUpdateRoleMutation.mutate({
|
||||
roleId: values.roleId,
|
||||
deptCodes: values.deptCodes && values.deptCodes.length > 0 ? values.deptCodes : undefined,
|
||||
action: 'grant'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理撤销
|
||||
const handleRevoke = async (values: BatchAuthorizationFormData) => {
|
||||
setCurrentAction('revoke')
|
||||
batchUpdateRoleMutation.mutate({
|
||||
roleId: values.roleId,
|
||||
deptCodes: values.deptCodes && values.deptCodes.length > 0 ? values.deptCodes : undefined,
|
||||
action: 'revoke'
|
||||
})
|
||||
}
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleClose = () => {
|
||||
setIsOpen(false)
|
||||
setCurrentAction(null)
|
||||
}
|
||||
|
||||
// 处理对话框打开
|
||||
const handleOpen = () => {
|
||||
setIsOpen(true)
|
||||
}
|
||||
|
||||
const isLoading = batchUpdateRoleMutation.isPending
|
||||
|
||||
// 定义表单字段配置
|
||||
const fields = [
|
||||
{
|
||||
name: 'roleId',
|
||||
label: '选择角色',
|
||||
required: true,
|
||||
render: ({ field }: any) => (
|
||||
<div className="space-y-2">
|
||||
<AdvancedSelect
|
||||
options={roles.map(role => ({ ...role, id: role.id.toString() }))}
|
||||
value={field.value?.toString() || ''}
|
||||
onChange={(value) => field.onChange(value ? Number(value) : undefined)}
|
||||
disabled={isLoading}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择角色" clearable>
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索角色..." />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
必选项,选择要授予或撤销的角色
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'deptCodes',
|
||||
label: '选择院系(可选)',
|
||||
render: ({ field }: any) => (
|
||||
<div className="space-y-2">
|
||||
<AdvancedSelect
|
||||
options={depts.map(dept => ({ id: dept.code, name: dept.name }))}
|
||||
value={field.value || []}
|
||||
onChange={(value) => field.onChange(value)}
|
||||
disabled={isLoading}
|
||||
multiple={{ enable: true }}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="不选择则针对所有用户" clearable>
|
||||
<SelectedBadges maxDisplay={3} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索院系..." />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
不选择院系时,将对所有用户进行操作
|
||||
</p>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button variant="outline" className="flex items-center gap-2" onClick={handleOpen}>
|
||||
<Users className="h-4 w-4" />
|
||||
批量授权
|
||||
</Button>
|
||||
|
||||
<FormDialog
|
||||
isOpen={isOpen}
|
||||
title="批量授权"
|
||||
description="为指定范围的用户批量授予或撤销角色"
|
||||
form={form}
|
||||
fields={fields}
|
||||
onClose={handleClose}
|
||||
className="max-w-md"
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction
|
||||
onSubmit={handleRevoke}
|
||||
variant="destructive"
|
||||
isSubmitting={batchUpdateRoleMutation.isPending}
|
||||
showSpinningLoader={currentAction === 'revoke'}
|
||||
>
|
||||
撤销权限
|
||||
</FormSubmitAction>
|
||||
<FormSubmitAction
|
||||
onSubmit={handleGrant}
|
||||
isSubmitting={batchUpdateRoleMutation.isPending}
|
||||
showSpinningLoader={currentAction === 'grant'}
|
||||
>
|
||||
授权
|
||||
</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
397
src/app/(main)/users/components/RoleManagementDialog.tsx
Normal file
397
src/app/(main)/users/components/RoleManagementDialog.tsx
Normal file
@@ -0,0 +1,397 @@
|
||||
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedBadges
|
||||
} from '@/components/common/advanced-select'
|
||||
import { Settings, Edit, Trash2, Save, X, Plus, Check } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
interface RoleData {
|
||||
id: number
|
||||
name: string
|
||||
userCount: number
|
||||
permissions: Array<{ id: number; name: string }>
|
||||
}
|
||||
|
||||
interface EditingRole {
|
||||
id: number | null
|
||||
name: string
|
||||
permissionIds: number[]
|
||||
}
|
||||
|
||||
export function RoleManagementDialog() {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const [editingRole, setEditingRole] = useState<EditingRole | null>(null)
|
||||
const [isAddingNew, setIsAddingNew] = useState(false)
|
||||
const [deleteConfirmOpen, setDeleteConfirmOpen] = useState<number | null>(null)
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 获取角色列表和权限列表
|
||||
const { data: roles = [], refetch: refetchRoles } = trpc.users.getRolesWithStats.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
})
|
||||
const { data: permissions = [] } = trpc.users.getPermissions.useQuery(undefined, {
|
||||
enabled: isOpen
|
||||
})
|
||||
|
||||
// 创建角色
|
||||
const createRoleMutation = trpc.users.createRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('角色创建成功')
|
||||
refetchRoles()
|
||||
utils.users.getRoles.invalidate()
|
||||
setIsAddingNew(false)
|
||||
setEditingRole(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '创建角色失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 更新角色
|
||||
const updateRoleMutation = trpc.users.updateRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('角色更新成功')
|
||||
refetchRoles()
|
||||
utils.users.getRoles.invalidate()
|
||||
setEditingRole(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '更新角色失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 删除角色
|
||||
const deleteRoleMutation = trpc.users.deleteRole.useMutation({
|
||||
onSuccess: () => {
|
||||
toast.success('角色删除成功')
|
||||
refetchRoles()
|
||||
utils.users.getRoles.invalidate()
|
||||
setDeleteConfirmOpen(null)
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '删除角色失败')
|
||||
}
|
||||
})
|
||||
|
||||
// 开始编辑角色
|
||||
const handleEditRole = (role: RoleData) => {
|
||||
setEditingRole({
|
||||
id: role.id,
|
||||
name: role.name,
|
||||
permissionIds: role.permissions.map(p => p.id)
|
||||
})
|
||||
setIsAddingNew(false)
|
||||
}
|
||||
|
||||
// 开始新增角色
|
||||
const handleAddNewRole = () => {
|
||||
setEditingRole({
|
||||
id: null,
|
||||
name: '',
|
||||
permissionIds: []
|
||||
})
|
||||
setIsAddingNew(true)
|
||||
}
|
||||
|
||||
// 取消编辑
|
||||
const handleCancelEdit = () => {
|
||||
setEditingRole(null)
|
||||
setIsAddingNew(false)
|
||||
}
|
||||
|
||||
// 保存角色
|
||||
const handleSaveRole = () => {
|
||||
if (!editingRole) return
|
||||
|
||||
if (!editingRole.name.trim()) {
|
||||
toast.error('角色名称不能为空')
|
||||
return
|
||||
}
|
||||
|
||||
if (editingRole.id === null) {
|
||||
// 新增角色
|
||||
createRoleMutation.mutate({
|
||||
name: editingRole.name.trim(),
|
||||
permissionIds: editingRole.permissionIds
|
||||
})
|
||||
} else {
|
||||
// 更新角色
|
||||
updateRoleMutation.mutate({
|
||||
id: editingRole.id,
|
||||
name: editingRole.name.trim(),
|
||||
permissionIds: editingRole.permissionIds
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// 删除角色
|
||||
const handleDeleteRole = (roleId: number) => {
|
||||
deleteRoleMutation.mutate({ id: roleId })
|
||||
}
|
||||
|
||||
// 处理权限选择变化
|
||||
const handlePermissionChange = (permissionIds: string | undefined | string[]) => {
|
||||
if (!editingRole) return
|
||||
|
||||
const ids = Array.isArray(permissionIds) ? permissionIds : []
|
||||
setEditingRole(prev => {
|
||||
if (!prev) return prev
|
||||
return { ...prev, permissionIds: ids.map(Number) }
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" className="flex items-center gap-2">
|
||||
<Settings className="h-4 w-4" />
|
||||
角色管理
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-5xl sm:max-w-5xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>角色管理</DialogTitle>
|
||||
<DialogDescription>
|
||||
管理系统中的角色和权限分配
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-16">ID</TableHead>
|
||||
<TableHead className="w-48">角色名称</TableHead>
|
||||
<TableHead className="w-24">用户数量</TableHead>
|
||||
<TableHead className="w-96">权限</TableHead>
|
||||
<TableHead className="w-32">操作</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{roles.map((role) => (
|
||||
<TableRow key={role.id}>
|
||||
<TableCell>{role.id}</TableCell>
|
||||
<TableCell>
|
||||
{editingRole?.id === role.id ? (
|
||||
<Input
|
||||
value={editingRole.name}
|
||||
onChange={(e) =>
|
||||
setEditingRole(prev => prev ? { ...prev, name: e.target.value } : null)
|
||||
}
|
||||
placeholder="输入角色名称"
|
||||
className="w-full"
|
||||
/>
|
||||
) : (
|
||||
role.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>{role.userCount}</TableCell>
|
||||
<TableCell>
|
||||
{editingRole?.id === role.id ? (
|
||||
<AdvancedSelect
|
||||
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
|
||||
value={editingRole.permissionIds.map(String)}
|
||||
onChange={handlePermissionChange}
|
||||
multiple={{ enable: true, limit: 1 }}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="选择权限">
|
||||
<SelectedBadges maxDisplay={2} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索权限..." />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{role.permissions.map((perm) => (
|
||||
<Badge key={perm.id} variant="outline" className="text-xs">
|
||||
{perm.name}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{editingRole?.id === role.id ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveRole}
|
||||
disabled={updateRoleMutation.isPending}
|
||||
className="text-green-600 hover:text-green-700 hover:bg-green-50 p-2"
|
||||
>
|
||||
<Save className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleEditRole(role)}
|
||||
disabled={editingRole !== null || isAddingNew}
|
||||
className="p-2"
|
||||
>
|
||||
<Edit className="h-3 w-3" />
|
||||
</Button>
|
||||
{role.userCount === 0 && (
|
||||
<Popover
|
||||
open={deleteConfirmOpen === role.id}
|
||||
onOpenChange={(open) => setDeleteConfirmOpen(open ? role.id : null)}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={deleteRoleMutation.isPending}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
|
||||
>
|
||||
<Trash2 className="h-3 w-3" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-80">
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<h4 className="font-medium leading-none">确认删除</h4>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
确定要删除角色 "{role.name}" 吗?
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => setDeleteConfirmOpen(null)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteRole(role.id)}
|
||||
disabled={deleteRoleMutation.isPending}
|
||||
>
|
||||
确认删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
|
||||
{/* 新增角色行 */}
|
||||
<TableRow>
|
||||
{isAddingNew && editingRole ? (
|
||||
<>
|
||||
<TableCell>
|
||||
<Plus className="h-4 w-4 text-gray-400" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={editingRole.name}
|
||||
onChange={(e) =>
|
||||
setEditingRole(prev => prev ? { ...prev, name: e.target.value } : null)
|
||||
}
|
||||
placeholder="输入角色名称"
|
||||
className="w-full"
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>0</TableCell>
|
||||
<TableCell>
|
||||
<AdvancedSelect
|
||||
options={permissions.map(p => ({ ...p, id: p.id.toString() }))}
|
||||
value={editingRole.permissionIds.map(String)}
|
||||
onChange={handlePermissionChange}
|
||||
multiple={{ enable: true }}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="选择权限">
|
||||
<SelectedBadges maxDisplay={2} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索权限..." />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleSaveRole}
|
||||
disabled={createRoleMutation.isPending}
|
||||
className="bg-green-600 hover:bg-green-700 text-white p-2"
|
||||
>
|
||||
<Check className="h-3 w-3" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCancelEdit}
|
||||
className="text-red-600 hover:text-red-700 hover:bg-red-50 p-2"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</TableCell>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<TableCell>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleAddNewRole}
|
||||
disabled={editingRole !== null}
|
||||
className="p-2 hover:bg-gray-100"
|
||||
>
|
||||
<Plus className="h-4 w-4 text-gray-600" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
<TableCell className="text-gray-400">点击+新增角色</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
<TableCell>-</TableCell>
|
||||
</>
|
||||
)}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
214
src/app/(main)/users/components/UserCreateDialog.tsx
Normal file
214
src/app/(main)/users/components/UserCreateDialog.tsx
Normal file
@@ -0,0 +1,214 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { createUserSchema, userStatusOptions } from '@/lib/schema/user'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Dialog, DialogTrigger } from '@/components/ui/dialog'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
|
||||
import { CheckboxGroup } from '@/components/common/checkbox-group'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedName
|
||||
} from '@/components/common/advanced-select'
|
||||
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
|
||||
|
||||
type CreateUserInput = z.infer<typeof createUserSchema>
|
||||
|
||||
const createUserDefaultValues: CreateUserInput = {
|
||||
id: '',
|
||||
name: '',
|
||||
status: '',
|
||||
deptCode: '',
|
||||
password: '',
|
||||
roleIds: [],
|
||||
isSuperAdmin: false,
|
||||
}
|
||||
|
||||
interface UserCreateDialogProps {
|
||||
onUserCreated: () => void
|
||||
}
|
||||
|
||||
export function UserCreateDialog({ onUserCreated }: UserCreateDialogProps) {
|
||||
// 表单 dialog 控制
|
||||
const [isCreateDialogOpen, setIsCreateDialogOpen] = useState(false)
|
||||
|
||||
// react-hook-form 管理创建表单
|
||||
const createForm = useForm<CreateUserInput>({
|
||||
resolver: zodResolver(createUserSchema),
|
||||
defaultValues: createUserDefaultValues,
|
||||
})
|
||||
|
||||
// 获取角色列表和院系列表
|
||||
const { data: roles } = trpc.users.getRoles.useQuery()
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
const deptOptions = depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || []
|
||||
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
|
||||
options: deptOptions,
|
||||
context: 'user.create.dept',
|
||||
scope: 'personal',
|
||||
})
|
||||
|
||||
// 创建用户 mutation
|
||||
const createUserMutation = trpc.users.create.useMutation({
|
||||
onSuccess: () => {
|
||||
setIsCreateDialogOpen(false)
|
||||
createForm.reset(createUserDefaultValues)
|
||||
toast.success('用户创建成功')
|
||||
onUserCreated()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '创建用户失败')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 定义字段配置
|
||||
const formFields: FormFieldConfig[] = React.useMemo(() => [
|
||||
{
|
||||
name: 'id',
|
||||
label: '用户ID',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入用户ID(职工号)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '姓名',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入姓名" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: '状态',
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={userStatusOptions}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择状态">
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'deptCode',
|
||||
label: '所属院系',
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={sortedDeptOptions}
|
||||
onChange={(value) => { logDeptSelection(value); field.onChange(value) }}
|
||||
filterFunction={(option, searchValue) => {
|
||||
const search = searchValue.toLowerCase()
|
||||
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
|
||||
(option.shortName && option.shortName.toLowerCase().includes(search))
|
||||
}}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择院系" clearable>
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索院系名称/代码" />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: '密码',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="password" placeholder="请输入密码(至少6位)" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: '角色',
|
||||
render: ({ field }) => (
|
||||
<CheckboxGroup
|
||||
{...field}
|
||||
options={roles || []}
|
||||
idPrefix="role"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'isSuperAdmin',
|
||||
label: '超级管理员',
|
||||
render: ({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isSuperAdmin"
|
||||
checked={field.value || false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Label htmlFor="isSuperAdmin" className="text-sm">
|
||||
超级管理员
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [sortedDeptOptions, logDeptSelection, roles])
|
||||
|
||||
const handleSubmit = async (data: CreateUserInput) => {
|
||||
createUserMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setIsCreateDialogOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isCreateDialogOpen} onOpenChange={setIsCreateDialogOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="flex items-center gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
创建用户
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
</Dialog>
|
||||
|
||||
<FormDialog
|
||||
isOpen={isCreateDialogOpen}
|
||||
title="创建新用户"
|
||||
description="请填写用户信息"
|
||||
form={createForm}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={createUserMutation.isPending}>创建</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
87
src/app/(main)/users/components/UserDeleteDialog.tsx
Normal file
87
src/app/(main)/users/components/UserDeleteDialog.tsx
Normal file
@@ -0,0 +1,87 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { toast } from 'sonner'
|
||||
import { AlertTriangle } from 'lucide-react'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
|
||||
interface UserDeleteDialogProps {
|
||||
userId: string | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUserDeleted: () => void
|
||||
}
|
||||
|
||||
export function UserDeleteDialog({
|
||||
userId,
|
||||
isOpen,
|
||||
onClose,
|
||||
onUserDeleted,
|
||||
}: UserDeleteDialogProps) {
|
||||
// 获取用户信息
|
||||
const { data: user } = trpc.users.getById.useQuery(
|
||||
{ id: userId! },
|
||||
{ enabled: !!userId }
|
||||
)
|
||||
|
||||
// 删除用户 mutation
|
||||
const deleteUserMutation = trpc.users.delete.useMutation({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
toast.success('用户删除成功')
|
||||
onUserDeleted()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '删除用户失败')
|
||||
},
|
||||
})
|
||||
|
||||
const handleConfirmDelete = () => {
|
||||
if (userId) {
|
||||
deleteUserMutation.mutate({ id: userId })
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<AlertDialog open={isOpen} onOpenChange={onClose}>
|
||||
<AlertDialogContent className="sm:max-w-md">
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle className="flex items-center gap-2 text-red-600">
|
||||
<AlertTriangle className="h-5 w-5" />
|
||||
确认删除用户
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription asChild>
|
||||
<div className="text-left">
|
||||
您确定要删除用户 <span className="font-semibold">{user?.id}</span>{' '}
|
||||
{user?.name && `(${user.name})`} 吗?
|
||||
<br />
|
||||
<span className="text-red-600 font-medium">此操作不可撤销!</span>
|
||||
</div>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter className="flex gap-2 sm:gap-2">
|
||||
<AlertDialogCancel disabled={deleteUserMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-red-600 hover:bg-red-700 focus:ring-red-600"
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={deleteUserMutation.isPending}
|
||||
>
|
||||
{deleteUserMutation.isPending ? '删除中...' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
)
|
||||
}
|
||||
229
src/app/(main)/users/components/UserUpdateDialog.tsx
Normal file
229
src/app/(main)/users/components/UserUpdateDialog.tsx
Normal file
@@ -0,0 +1,229 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect } from 'react'
|
||||
import { useForm } from 'react-hook-form'
|
||||
import { z } from 'zod'
|
||||
import { zodResolver } from '@hookform/resolvers/zod'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { updateUserSchema, userStatusOptions } from '@/lib/schema/user'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { toast } from 'sonner'
|
||||
import { FormDialog, FormActionBar, FormGridContent, FormCancelAction, FormSubmitAction, type FormFieldConfig } from '@/components/common/form-dialog'
|
||||
import { CheckboxGroup } from '@/components/common/checkbox-group'
|
||||
import {
|
||||
AdvancedSelect,
|
||||
SelectPopover,
|
||||
SelectTrigger,
|
||||
SelectContent,
|
||||
SelectInput,
|
||||
SelectItemList,
|
||||
SelectedName
|
||||
} from '@/components/common/advanced-select'
|
||||
import { useSmartSelectOptions } from '@/hooks/use-smart-select-options'
|
||||
|
||||
type UpdateUserInput = z.infer<typeof updateUserSchema>
|
||||
|
||||
const updateUserDefaultValues: UpdateUserInput = {
|
||||
id: '',
|
||||
name: '',
|
||||
status: '',
|
||||
deptCode: '',
|
||||
password: '',
|
||||
roleIds: [],
|
||||
isSuperAdmin: false,
|
||||
}
|
||||
|
||||
interface UserUpdateDialogProps {
|
||||
userId: string | null
|
||||
isOpen: boolean
|
||||
onClose: () => void
|
||||
onUserUpdated: () => void
|
||||
}
|
||||
|
||||
export function UserUpdateDialog({ userId, isOpen, onClose, onUserUpdated }: UserUpdateDialogProps) {
|
||||
// react-hook-form 管理更新表单
|
||||
const updateForm = useForm<UpdateUserInput>({
|
||||
resolver: zodResolver(updateUserSchema),
|
||||
defaultValues: updateUserDefaultValues,
|
||||
})
|
||||
|
||||
// 获取用户详情
|
||||
const { data: user, isLoading: isLoadingUser } = trpc.users.getById.useQuery(
|
||||
{ id: userId! },
|
||||
{ enabled: !!userId && isOpen }
|
||||
)
|
||||
|
||||
// 获取角色列表和院系列表
|
||||
const { data: roles } = trpc.users.getRoles.useQuery()
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
const deptOptions = React.useMemo(() => depts?.map(dept => ({ id: dept.code, name: dept.fullName, shortName: dept.name })) || [], [depts])
|
||||
const { sortedOptions: sortedDeptOptions, logSelection: logDeptSelection } = useSmartSelectOptions({
|
||||
options: deptOptions,
|
||||
context: 'user.update.dept',
|
||||
scope: 'personal',
|
||||
})
|
||||
|
||||
// 更新用户 mutation
|
||||
const updateUserMutation = trpc.users.update.useMutation({
|
||||
onSuccess: () => {
|
||||
onClose()
|
||||
toast.success('用户更新成功')
|
||||
onUserUpdated()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error.message || '更新用户失败')
|
||||
},
|
||||
})
|
||||
|
||||
|
||||
// 定义字段配置
|
||||
const formFields: FormFieldConfig[] = React.useMemo(() => [
|
||||
{
|
||||
name: 'id',
|
||||
label: '用户ID',
|
||||
required: true,
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入用户ID(职工号)" disabled />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'name',
|
||||
label: '姓名',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} placeholder="请输入姓名" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'status',
|
||||
label: '状态',
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={userStatusOptions}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择状态">
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'deptCode',
|
||||
label: '所属院系',
|
||||
render: ({ field }) => (
|
||||
<AdvancedSelect
|
||||
{...field}
|
||||
options={sortedDeptOptions}
|
||||
onChange={(value) => {logDeptSelection(value); field.onChange(value)}}
|
||||
filterFunction={(option, searchValue) => {
|
||||
const search = searchValue.toLowerCase()
|
||||
return option.id.includes(search) || option.name.toLowerCase().includes(search) ||
|
||||
(option.shortName && option.shortName.toLowerCase().includes(search))
|
||||
}}
|
||||
>
|
||||
<SelectPopover>
|
||||
<SelectTrigger placeholder="请选择院系" clearable>
|
||||
<SelectedName />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectInput placeholder="搜索院系名称/代码" />
|
||||
<SelectItemList />
|
||||
</SelectContent>
|
||||
</SelectPopover>
|
||||
</AdvancedSelect>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'password',
|
||||
label: '新密码',
|
||||
render: ({ field }) => (
|
||||
<Input {...field} type="password" placeholder="留空则不修改密码" />
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: '角色',
|
||||
render: ({ field }) => (
|
||||
<CheckboxGroup
|
||||
{...field}
|
||||
options={roles || []}
|
||||
idPrefix="role"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
name: 'isSuperAdmin',
|
||||
label: '超级管理员',
|
||||
render: ({ field }) => (
|
||||
<div className="flex items-center space-x-2">
|
||||
<Checkbox
|
||||
id="isSuperAdmin"
|
||||
checked={field.value || false}
|
||||
onCheckedChange={field.onChange}
|
||||
/>
|
||||
<Label htmlFor="isSuperAdmin" className="text-sm">
|
||||
超级管理员
|
||||
</Label>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
], [sortedDeptOptions, logDeptSelection, roles])
|
||||
|
||||
// 当用户数据加载完成时,重置表单
|
||||
useEffect(() => {
|
||||
if (user && isOpen) {
|
||||
const defaultValues: UpdateUserInput = {
|
||||
id: user.id,
|
||||
name: user.name || '',
|
||||
status: user.status || '',
|
||||
deptCode: user.deptCode || '',
|
||||
password: '', // 密码字段默认为空,只有填写时才更新
|
||||
roleIds: user.roles?.map((role) => role.id) || [],
|
||||
isSuperAdmin: user.isSuperAdmin || false,
|
||||
}
|
||||
updateForm.reset(defaultValues)
|
||||
}
|
||||
}, [user, isOpen, updateForm])
|
||||
|
||||
// 当对话框关闭时,清理状态
|
||||
useEffect(() => {
|
||||
if (!isOpen) {
|
||||
updateForm.reset()
|
||||
}
|
||||
}, [isOpen, updateForm])
|
||||
|
||||
const handleSubmit = async (data: UpdateUserInput) => {
|
||||
updateUserMutation.mutate(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
if (!isOpen) return null
|
||||
|
||||
return (
|
||||
<FormDialog
|
||||
isOpen={isOpen}
|
||||
title="编辑用户"
|
||||
description="请填写用户信息"
|
||||
form={updateForm}
|
||||
fields={formFields}
|
||||
onClose={handleClose}
|
||||
>
|
||||
<FormGridContent />
|
||||
<FormActionBar>
|
||||
<FormCancelAction />
|
||||
<FormSubmitAction onSubmit={handleSubmit} isSubmitting={updateUserMutation.isPending}>更新</FormSubmitAction>
|
||||
</FormActionBar>
|
||||
</FormDialog>
|
||||
)
|
||||
}
|
||||
150
src/app/(main)/users/page.tsx
Normal file
150
src/app/(main)/users/page.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useCallback, useMemo, Suspense } from 'react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import { UserCreateDialog } from './components/UserCreateDialog'
|
||||
import { UserUpdateDialog } from './components/UserUpdateDialog'
|
||||
import { UserDeleteDialog } from './components/UserDeleteDialog'
|
||||
import { RoleManagementDialog } from './components/RoleManagementDialog'
|
||||
import { BatchAuthorizationDialog } from './components/BatchAuthorizationDialog'
|
||||
import { DataTable } from '@/components/data-table/data-table'
|
||||
import { DataTableToolbar } from '@/components/data-table/toolbar'
|
||||
import { createUserColumns, type UserColumnsOptions } from './columns'
|
||||
import type { User } from '@/server/routers/users'
|
||||
import { useDataTable } from '@/hooks/use-data-table'
|
||||
import { keepPreviousData } from '@tanstack/react-query'
|
||||
import { DataTableSortList } from '@/components/data-table/sort-list'
|
||||
import { toast } from 'sonner'
|
||||
import { DataTableSkeleton } from '@/components/data-table/table-skeleton'
|
||||
|
||||
interface UsersPageDataTableProps {
|
||||
onEdit: (userId: string) => void
|
||||
onDelete: (userId: string) => void
|
||||
}
|
||||
|
||||
function UsersPageDataTable({ onEdit, onDelete }: UsersPageDataTableProps) {
|
||||
// 获取角色、权限和部门列表用于过滤器选项
|
||||
const { data: roles } = trpc.users.getRoles.useQuery()
|
||||
const { data: permissions } = trpc.users.getPermissions.useQuery()
|
||||
const { data: depts } = trpc.common.getDepts.useQuery()
|
||||
|
||||
// 创建表格列定义选项
|
||||
const columnsOptions: UserColumnsOptions = useMemo(() => ({
|
||||
roles: roles || [],
|
||||
permissions: permissions || [],
|
||||
depts: depts || [],
|
||||
}), [roles, permissions, depts])
|
||||
|
||||
// 创建表格列定义
|
||||
const columns = useMemo(() => createUserColumns({
|
||||
onEdit,
|
||||
onDelete,
|
||||
}, columnsOptions), [onEdit, onDelete, columnsOptions])
|
||||
|
||||
// 使用 useDataTable hook,传入 queryFn
|
||||
const { table, queryResult } = useDataTable<User>({
|
||||
columns,
|
||||
initialState: {
|
||||
pagination: { pageIndex: 1, pageSize: 10 },
|
||||
columnPinning: { left: ["select"], right: ["actions"] },
|
||||
},
|
||||
getRowId: (row) => row.id,
|
||||
queryFn: useCallback((params) => {
|
||||
const result = trpc.users.list.useQuery(params, {
|
||||
placeholderData: keepPreviousData,
|
||||
})
|
||||
if (result.error) {
|
||||
toast.error("获取用户数据失败:" + result.error.toString().substring(0, 100))
|
||||
}
|
||||
return result
|
||||
}, []),
|
||||
})
|
||||
|
||||
return (
|
||||
<DataTable table={table} isLoading={queryResult.isLoading}>
|
||||
<DataTableToolbar table={table}>
|
||||
<DataTableSortList table={table} />
|
||||
</DataTableToolbar>
|
||||
</DataTable>
|
||||
)
|
||||
}
|
||||
|
||||
export default function UsersPage() {
|
||||
// 更新用户对话框状态
|
||||
const [updateUserId, setUpdateUserId] = useState<string | null>(null)
|
||||
const [isUpdateDialogOpen, setIsUpdateDialogOpen] = useState(false)
|
||||
|
||||
// 删除用户对话框状态
|
||||
const [deleteUserId, setDeleteUserId] = useState<string | null>(null)
|
||||
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false)
|
||||
|
||||
// 用于刷新数据的 utils
|
||||
const utils = trpc.useUtils()
|
||||
|
||||
// 处理编辑用户
|
||||
const handleEditUser = useCallback((userId: string) => {
|
||||
setUpdateUserId(userId)
|
||||
setIsUpdateDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// 关闭更新对话框
|
||||
const handleCloseUpdateDialog = useCallback(() => {
|
||||
setIsUpdateDialogOpen(false)
|
||||
setUpdateUserId(null)
|
||||
}, [])
|
||||
|
||||
// 处理删除用户
|
||||
const handleDeleteUser = useCallback((userId: string) => {
|
||||
setDeleteUserId(userId)
|
||||
setIsDeleteDialogOpen(true)
|
||||
}, [])
|
||||
|
||||
// 关闭删除对话框
|
||||
const handleCloseDeleteDialog = useCallback(() => {
|
||||
setIsDeleteDialogOpen(false)
|
||||
setDeleteUserId(null)
|
||||
}, [])
|
||||
|
||||
// 刷新用户列表
|
||||
const handleRefreshUsers = useCallback(() => {
|
||||
utils.users.list.invalidate()
|
||||
}, [utils])
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* 用户列表和创建按钮 */}
|
||||
<Card>
|
||||
<CardHeader className="flex items-center justify-between">
|
||||
<CardTitle className="text-lg">用户列表</CardTitle>
|
||||
<div className="flex items-center gap-2">
|
||||
<RoleManagementDialog />
|
||||
<BatchAuthorizationDialog />
|
||||
<UserCreateDialog onUserCreated={handleRefreshUsers} />
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Suspense fallback={<DataTableSkeleton columnCount={8} rowCount={10} />}>
|
||||
<UsersPageDataTable onEdit={handleEditUser} onDelete={handleDeleteUser} />
|
||||
</Suspense>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 更新用户对话框 */}
|
||||
<UserUpdateDialog
|
||||
userId={updateUserId}
|
||||
isOpen={isUpdateDialogOpen}
|
||||
onClose={handleCloseUpdateDialog}
|
||||
onUserUpdated={handleRefreshUsers}
|
||||
/>
|
||||
|
||||
{/* 删除用户对话框 */}
|
||||
<UserDeleteDialog
|
||||
userId={deleteUserId}
|
||||
isOpen={isDeleteDialogOpen}
|
||||
onClose={handleCloseDeleteDialog}
|
||||
onUserDeleted={handleRefreshUsers}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
43
src/app/(main)/welcome.tsx
Normal file
43
src/app/(main)/welcome.tsx
Normal file
@@ -0,0 +1,43 @@
|
||||
'use client'
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Sparkles } from 'lucide-react'
|
||||
|
||||
interface WelcomeDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 欢迎对话框组件
|
||||
* 用于在首次进入系统时显示欢迎信息
|
||||
*/
|
||||
export function WelcomeDialog({ open, onOpenChange }: WelcomeDialogProps) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="h-6 w-6 text-primary" />
|
||||
<DialogTitle className="text-2xl">欢迎您,开发者</DialogTitle>
|
||||
</div>
|
||||
<DialogDescription className="pt-4 text-base">
|
||||
{/* 内容暂时没想好,先不实现 */}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="flex justify-end gap-2 pt-4">
|
||||
<Button onClick={() => onOpenChange(false)}>
|
||||
开始使用
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
6
src/app/api/auth/[...nextauth]/route.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import NextAuth from "next-auth"
|
||||
import { authOptions } from "@/server/auth"
|
||||
|
||||
const handler = NextAuth(authOptions)
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
165
src/app/api/dev/ai-chat/route.dev.ts
Normal file
165
src/app/api/dev/ai-chat/route.dev.ts
Normal file
@@ -0,0 +1,165 @@
|
||||
import { AnthropicProviderOptions, createAnthropic } from '@ai-sdk/anthropic'
|
||||
import { createOpenAI } from '@ai-sdk/openai'
|
||||
import { convertToModelMessages, stepCountIs, streamText, tool, LanguageModel } from 'ai'
|
||||
import type { UIMessage } from 'ai'
|
||||
|
||||
// 创建 Anthropic 客户端
|
||||
const anthropic = createAnthropic({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/anthropic/v1',
|
||||
})
|
||||
|
||||
// 创建 OpenAI 客户端
|
||||
const openai = createOpenAI({
|
||||
apiKey: process.env.PKUAI_API_KEY,
|
||||
baseURL: process.env.PKUAI_API_BASE + 'api/openai/v1',
|
||||
})
|
||||
|
||||
/**
|
||||
* 根据模型ID获取对应的LLM实例和providerOptions
|
||||
*/
|
||||
function getModelConfig(modelId: string): {
|
||||
model: LanguageModel
|
||||
providerOptions?: Record<string, any>
|
||||
} {
|
||||
// Claude Sonnet 4.5 标准版
|
||||
if (modelId === 'claude-sonnet-4-5-20250929') {
|
||||
return {
|
||||
model: anthropic('claude-sonnet-4-5-20250929'),
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
// 标准版不启用thinking
|
||||
} satisfies AnthropicProviderOptions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Claude Sonnet 4.5 深度思考版
|
||||
if (modelId === 'claude-sonnet-4-5-20250929:thinking') {
|
||||
return {
|
||||
model: anthropic('claude-sonnet-4-5-20250929'),
|
||||
providerOptions: {
|
||||
anthropic: {
|
||||
thinking: { type: 'enabled', budgetTokens: 12000 },
|
||||
} satisfies AnthropicProviderOptions,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GPT-4.1
|
||||
if (modelId === 'gpt-4.1') {
|
||||
return {
|
||||
model: openai.chat('gpt-4.1'),
|
||||
providerOptions: {},
|
||||
}
|
||||
}
|
||||
|
||||
// 默认返回 Claude Sonnet 4.5
|
||||
return {
|
||||
model: anthropic('claude-sonnet-4-5-20250929'),
|
||||
providerOptions: {
|
||||
anthropic: {} satisfies AnthropicProviderOptions,
|
||||
},
|
||||
}
|
||||
}
|
||||
/**
|
||||
* 根据智能体类型获取系统提示词
|
||||
*/
|
||||
function getSystemPrompt(agentId: string): string {
|
||||
switch (agentId) {
|
||||
case 'casual-chat':
|
||||
return "你是一个友好的AI助手,可以帮助用户解答问题,如果用户询问有关当前项目的具体问题(如项目结构、代码实现、项目管理等),你应该明确表示无法回答这类问题,建议用户切换到“项目管家”智能体获取帮助"
|
||||
|
||||
case 'project-assistant':
|
||||
return `你是一个专业的项目管家助手,致力于帮助用户了解和管理当前项目。
|
||||
|
||||
你的职责:
|
||||
- 帮助用户了解项目的整体结构、技术栈和实现细节
|
||||
- 协助用户进行项目管理工作(如Git提交、代码整理等)
|
||||
- 回答关于项目的各种问题
|
||||
- 帮助用户理清想法,明确具体要做什么、怎么做
|
||||
|
||||
你的工作方式:
|
||||
- 当用户表达一个想法或需求时,帮助他们分析和拆解任务
|
||||
- 提供清晰的步骤和建议,让用户知道如何实现目标
|
||||
- 对于项目管理任务(如Git提交),先了解用户的意图,然后提供具体的操作建议
|
||||
- 整理项目概要时,从多个维度分析项目(技术栈、架构、功能模块等)
|
||||
|
||||
交互原则:
|
||||
- 主动询问必要的信息,确保理解用户的真实需求
|
||||
- 提供具体可行的建议,而不是模糊的指导
|
||||
- 对于复杂任务,分步骤说明,让用户清楚每一步的目的
|
||||
- 始终站在用户的角度思考,帮助他们更高效地管理项目
|
||||
|
||||
请以专业、友好的态度协助用户管理项目。`
|
||||
|
||||
default:
|
||||
return '你是一个友好的AI助手,可以帮助用户解答问题。'
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const { messages, agent, model, tools }: {
|
||||
messages: UIMessage[]
|
||||
agent?: string
|
||||
model?: string
|
||||
tools?: string[]
|
||||
} = await req.json()
|
||||
|
||||
// 验证必需参数
|
||||
if (!agent) {
|
||||
return new Response(JSON.stringify({ error: '缺少必需参数', details: 'agent 参数是必需的' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
if (!model) {
|
||||
return new Response(JSON.stringify({ error: '缺少必需参数', details: 'model 参数是必需的' }), {
|
||||
status: 400, headers: { 'Content-Type': 'application/json' }
|
||||
})
|
||||
}
|
||||
|
||||
// 根据选择的模型获取对应的LLM实例和配置
|
||||
const modelConfig = getModelConfig(model)
|
||||
|
||||
// 根据智能体类型获取系统提示词
|
||||
const systemPrompt = getSystemPrompt(agent)
|
||||
|
||||
// 使用 streamText 生成流式响应
|
||||
const result = streamText({
|
||||
model: modelConfig.model,
|
||||
messages: convertToModelMessages(messages),
|
||||
system: systemPrompt,
|
||||
stopWhen: stepCountIs(10), // 每次工具调用都算是一个step,这个参数可以让模型在调用完工具后根据结果继续生成回复
|
||||
providerOptions: modelConfig.providerOptions,
|
||||
// TODO: 根据 tools 参数添加工具调用功能
|
||||
})
|
||||
|
||||
// 返回 UI 消息流响应,与 useChat 钩子完美配合
|
||||
return result.toUIMessageStreamResponse({
|
||||
originalMessages: messages,
|
||||
// 错误处理
|
||||
onError: (error) => {
|
||||
console.error('聊天流错误:', error)
|
||||
if (error instanceof Error) {
|
||||
return error.message
|
||||
}
|
||||
return '处理请求时发生未知错误'
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('聊天API错误:', error)
|
||||
return new Response(
|
||||
JSON.stringify({
|
||||
error: '处理请求时发生错误',
|
||||
details: error instanceof Error ? error.message : '未知错误'
|
||||
}),
|
||||
{
|
||||
status: 500,
|
||||
headers: { 'Content-Type': 'application/json' }
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
13
src/app/api/trpc/[trpc]/route.ts
Normal file
13
src/app/api/trpc/[trpc]/route.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { fetchRequestHandler } from '@trpc/server/adapters/fetch'
|
||||
import { appRouter } from '@/server/routers/_app'
|
||||
import { createTRPCContext } from '@/server/trpc'
|
||||
|
||||
const handler = (req: Request) =>
|
||||
fetchRequestHandler({
|
||||
endpoint: '/api/trpc',
|
||||
req,
|
||||
router: appRouter,
|
||||
createContext: createTRPCContext,
|
||||
})
|
||||
|
||||
export { handler as GET, handler as POST }
|
||||
BIN
src/app/favicon.ico
Normal file
BIN
src/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 32 KiB |
176
src/app/globals.css
Normal file
176
src/app/globals.css
Normal file
@@ -0,0 +1,176 @@
|
||||
@import "tailwindcss";
|
||||
@import "tw-animate-css";
|
||||
@source "../node_modules/streamdown/dist/index.js";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-ring: var(--ring);
|
||||
--color-input: var(--input);
|
||||
--color-border: var(--border);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-card: var(--card);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
}
|
||||
|
||||
:root {
|
||||
--radius: 0.625rem;
|
||||
--background: oklch(1 0 0);
|
||||
--foreground: oklch(0.145 0 0);
|
||||
--card: oklch(1 0 0);
|
||||
--card-foreground: oklch(0.145 0 0);
|
||||
--popover: oklch(1 0 0);
|
||||
--popover-foreground: oklch(0.145 0 0);
|
||||
--primary: oklch(0.35 0.18 18);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.97 0 0);
|
||||
--secondary-foreground: oklch(0.205 0 0);
|
||||
--muted: oklch(0.97 0 0);
|
||||
--muted-foreground: oklch(0.556 0 0);
|
||||
--accent: oklch(0.97 0 0);
|
||||
--accent-foreground: oklch(0.205 0 0);
|
||||
--destructive: oklch(0.577 0.245 27.325);
|
||||
--border: oklch(0.922 0 0);
|
||||
--input: oklch(0.922 0 0);
|
||||
--ring: oklch(0.708 0 0);
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.35 0.18 18);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
.dark {
|
||||
--background: oklch(0.145 0 0);
|
||||
--foreground: oklch(0.985 0 0);
|
||||
--card: oklch(0.205 0 0);
|
||||
--card-foreground: oklch(0.985 0 0);
|
||||
--popover: oklch(0.205 0 0);
|
||||
--popover-foreground: oklch(0.985 0 0);
|
||||
--primary: oklch(0.45 0.18 18);
|
||||
--primary-foreground: oklch(0.985 0 0);
|
||||
--secondary: oklch(0.269 0 0);
|
||||
--secondary-foreground: oklch(0.985 0 0);
|
||||
--muted: oklch(0.269 0 0);
|
||||
--muted-foreground: oklch(0.708 0 0);
|
||||
--accent: oklch(0.269 0 0);
|
||||
--accent-foreground: oklch(0.985 0 0);
|
||||
--destructive: oklch(0.704 0.191 22.216);
|
||||
--border: oklch(1 0 0 / 10%);
|
||||
--input: oklch(1 0 0 / 15%);
|
||||
--ring: oklch(0.556 0 0);
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.45 0.18 18);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(1 0 0 / 10%);
|
||||
--sidebar-ring: oklch(0.556 0 0);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
@layer utilities {
|
||||
/* 隐藏滚动条但保持滚动功能 */
|
||||
.scrollbar-hide {
|
||||
-ms-overflow-style: none; /* IE and Edge */
|
||||
scrollbar-width: none; /* Firefox */
|
||||
}
|
||||
.scrollbar-hide::-webkit-scrollbar {
|
||||
display: none; /* Chrome, Safari and Opera */
|
||||
}
|
||||
|
||||
/* 淡色细滚动条,不占宽度或占很小宽度 */
|
||||
.scrollbar-muted {
|
||||
scrollbar-width: thin; /* Firefox: 使用细滚动条 */
|
||||
scrollbar-color: oklch(0.708 0 0 / 0.3) transparent; /* Firefox: 滑块颜色和轨道颜色 */
|
||||
}
|
||||
|
||||
/* Chrome, Safari and Opera */
|
||||
.scrollbar-muted::-webkit-scrollbar {
|
||||
width: 6px; /* 垂直滚动条宽度 */
|
||||
height: 6px; /* 水平滚动条高度 */
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar-track {
|
||||
background: transparent; /* 轨道透明 */
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.708 0 0 / 0.3); /* 滑块颜色:淡灰色,30%透明度 */
|
||||
border-radius: 3px; /* 圆角滑块 */
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.708 0 0 / 0.5); /* 悬停时稍微深一点 */
|
||||
}
|
||||
|
||||
.scrollbar-muted::-webkit-scrollbar-button {
|
||||
display: none; /* 隐藏滚动条两端的端点按钮 */
|
||||
}
|
||||
|
||||
/* 暗色模式下的滚动条 */
|
||||
.dark .scrollbar-muted {
|
||||
scrollbar-color: oklch(0.556 0 0 / 0.3) transparent;
|
||||
}
|
||||
|
||||
.dark .scrollbar-muted::-webkit-scrollbar-thumb {
|
||||
background-color: oklch(0.556 0 0 / 0.3);
|
||||
}
|
||||
|
||||
.dark .scrollbar-muted::-webkit-scrollbar-thumb:hover {
|
||||
background-color: oklch(0.556 0 0 / 0.5);
|
||||
}
|
||||
}
|
||||
51
src/app/layout.tsx
Normal file
51
src/app/layout.tsx
Normal file
@@ -0,0 +1,51 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import "./globals.css";
|
||||
import { TRPCProvider } from "@/components/providers/trpc-provider";
|
||||
import { SessionProvider } from "@/components/providers/session-provider";
|
||||
import { Toaster } from "sonner";
|
||||
import { NuqsAdapter } from 'nuqs/adapters/next/app'
|
||||
import { AppThemeProvider } from "@/components/providers/theme-provider";
|
||||
import { SITE_NAME, SITE_DESCRIPTION } from "@/constants/site";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
subsets: ["latin"],
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: SITE_NAME,
|
||||
description: SITE_DESCRIPTION,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
// next-themes推荐用suppressHydrationWarning避免服务器和客户端html标签不一致报错
|
||||
return (
|
||||
<html lang="zh-CN" suppressHydrationWarning>
|
||||
<body
|
||||
className={`${geistSans.variable} ${geistMono.variable} antialiased`}
|
||||
>
|
||||
<AppThemeProvider>
|
||||
<SessionProvider>
|
||||
<TRPCProvider>
|
||||
<NuqsAdapter>
|
||||
{children}
|
||||
</NuqsAdapter>
|
||||
</TRPCProvider>
|
||||
</SessionProvider>
|
||||
</AppThemeProvider>
|
||||
<Toaster />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
65
src/components/ai-elements/actions.tsx
Normal file
65
src/components/ai-elements/actions.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ComponentProps } from "react";
|
||||
|
||||
export type ActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const Actions = ({ className, children, ...props }: ActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const Action = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
className,
|
||||
variant = "ghost",
|
||||
size = "sm",
|
||||
...props
|
||||
}: ActionProps) => {
|
||||
const button = (
|
||||
<Button
|
||||
className={cn(
|
||||
"relative size-9 p-1.5 text-muted-foreground hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
size={size}
|
||||
type="button"
|
||||
variant={variant}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
97
src/components/ai-elements/conversation.tsx
Normal file
97
src/components/ai-elements/conversation.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { ArrowDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { StickToBottom, useStickToBottomContext } from "use-stick-to-bottom";
|
||||
|
||||
export type ConversationProps = ComponentProps<typeof StickToBottom>;
|
||||
|
||||
export const Conversation = ({ className, ...props }: ConversationProps) => (
|
||||
<StickToBottom
|
||||
className={cn("relative flex-1 overflow-y-auto", className)}
|
||||
initial="smooth"
|
||||
resize="smooth"
|
||||
role="log"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ConversationContentProps = ComponentProps<
|
||||
typeof StickToBottom.Content
|
||||
>;
|
||||
|
||||
export const ConversationContent = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationContentProps) => (
|
||||
<StickToBottom.Content className={cn("p-4", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ConversationEmptyStateProps = ComponentProps<"div"> & {
|
||||
title?: string;
|
||||
description?: string;
|
||||
icon?: React.ReactNode;
|
||||
};
|
||||
|
||||
export const ConversationEmptyState = ({
|
||||
className,
|
||||
title = "No messages yet",
|
||||
description = "Start a conversation to see messages here",
|
||||
icon,
|
||||
children,
|
||||
...props
|
||||
}: ConversationEmptyStateProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"flex size-full flex-col items-center justify-center gap-3 p-8 text-center",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
{icon && <div className="text-muted-foreground">{icon}</div>}
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-medium text-sm">{title}</h3>
|
||||
{description && (
|
||||
<p className="text-muted-foreground text-sm">{description}</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type ConversationScrollButtonProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const ConversationScrollButton = ({
|
||||
className,
|
||||
...props
|
||||
}: ConversationScrollButtonProps) => {
|
||||
const { isAtBottom, scrollToBottom } = useStickToBottomContext();
|
||||
|
||||
const handleScrollToBottom = useCallback(() => {
|
||||
scrollToBottom();
|
||||
}, [scrollToBottom]);
|
||||
|
||||
return (
|
||||
!isAtBottom && (
|
||||
<Button
|
||||
className={cn(
|
||||
"absolute bottom-4 left-[50%] translate-x-[-50%] rounded-full",
|
||||
className
|
||||
)}
|
||||
onClick={handleScrollToBottom}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...props}
|
||||
>
|
||||
<ArrowDownIcon className="size-4" />
|
||||
</Button>
|
||||
)
|
||||
);
|
||||
};
|
||||
451
src/components/ai-elements/message.tsx
Normal file
451
src/components/ai-elements/message.tsx
Normal file
@@ -0,0 +1,451 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
ButtonGroup,
|
||||
ButtonGroupText,
|
||||
} from "@/components/ui/button-group";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { FileUIPart, UIMessage } from "ai";
|
||||
import {
|
||||
ChevronLeftIcon,
|
||||
ChevronRightIcon,
|
||||
PaperclipIcon,
|
||||
XIcon,
|
||||
} from "lucide-react";
|
||||
import type { ComponentProps, HTMLAttributes, ReactElement } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState, useMemo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const Message = ({ className, from, ...props }: MessageProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"group flex w-full max-w-[80%] flex-col gap-2",
|
||||
from === "user" ? "is-user ml-auto justify-end" : "is-assistant",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type MessageContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageContent = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageContentProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"is-user:dark flex w-fit flex-col gap-2 overflow-hidden text-sm",
|
||||
"group-[.is-user]:ml-auto group-[.is-user]:rounded-lg group-[.is-user]:bg-secondary group-[.is-user]:px-4 group-[.is-user]:py-3 group-[.is-user]:text-foreground",
|
||||
"group-[.is-assistant]:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionsProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageActions = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageActionsProps) => (
|
||||
<div className={cn("flex items-center gap-1", className)} {...props}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
|
||||
export type MessageActionProps = ComponentProps<typeof Button> & {
|
||||
tooltip?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
export const MessageAction = ({
|
||||
tooltip,
|
||||
children,
|
||||
label,
|
||||
variant = "ghost",
|
||||
size = "icon",
|
||||
...props
|
||||
}: MessageActionProps) => {
|
||||
const button = (
|
||||
<Button size={size} type="button" variant={variant} {...props}>
|
||||
{children}
|
||||
<span className="sr-only">{label || tooltip}</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (tooltip) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{button}</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
return button;
|
||||
};
|
||||
|
||||
type MessageBranchContextType = {
|
||||
currentBranch: number;
|
||||
totalBranches: number;
|
||||
goToPrevious: () => void;
|
||||
goToNext: () => void;
|
||||
branches: ReactElement[];
|
||||
setBranches: (branches: ReactElement[]) => void;
|
||||
};
|
||||
|
||||
const MessageBranchContext = createContext<MessageBranchContextType | null>(
|
||||
null
|
||||
);
|
||||
|
||||
const useMessageBranch = () => {
|
||||
const context = useContext(MessageBranchContext);
|
||||
|
||||
if (!context) {
|
||||
throw new Error(
|
||||
"MessageBranch components must be used within MessageBranch"
|
||||
);
|
||||
}
|
||||
|
||||
return context;
|
||||
};
|
||||
|
||||
export type MessageBranchProps = HTMLAttributes<HTMLDivElement> & {
|
||||
defaultBranch?: number;
|
||||
onBranchChange?: (branchIndex: number) => void;
|
||||
};
|
||||
|
||||
export const MessageBranch = ({
|
||||
defaultBranch = 0,
|
||||
onBranchChange,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchProps) => {
|
||||
const [currentBranch, setCurrentBranch] = useState(defaultBranch);
|
||||
const [branches, setBranches] = useState<ReactElement[]>([]);
|
||||
|
||||
const handleBranchChange = (newBranch: number) => {
|
||||
setCurrentBranch(newBranch);
|
||||
onBranchChange?.(newBranch);
|
||||
};
|
||||
|
||||
const goToPrevious = () => {
|
||||
const newBranch =
|
||||
currentBranch > 0 ? currentBranch - 1 : branches.length - 1;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const goToNext = () => {
|
||||
const newBranch =
|
||||
currentBranch < branches.length - 1 ? currentBranch + 1 : 0;
|
||||
handleBranchChange(newBranch);
|
||||
};
|
||||
|
||||
const contextValue: MessageBranchContextType = {
|
||||
currentBranch,
|
||||
totalBranches: branches.length,
|
||||
goToPrevious,
|
||||
goToNext,
|
||||
branches,
|
||||
setBranches,
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageBranchContext.Provider value={contextValue}>
|
||||
<div
|
||||
className={cn("grid w-full gap-2 [&>div]:pb-0", className)}
|
||||
{...props}
|
||||
/>
|
||||
</MessageBranchContext.Provider>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchContentProps = HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export const MessageBranchContent = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchContentProps) => {
|
||||
const { currentBranch, setBranches, branches } = useMessageBranch();
|
||||
const childrenArray = useMemo(
|
||||
() => (Array.isArray(children) ? children : [children]),
|
||||
[children]
|
||||
);
|
||||
|
||||
// Use useEffect to update branches when they change
|
||||
useEffect(() => {
|
||||
if (branches.length !== childrenArray.length) {
|
||||
setBranches(childrenArray);
|
||||
}
|
||||
}, [childrenArray, branches, setBranches]);
|
||||
|
||||
return childrenArray.map((branch, index) => (
|
||||
<div
|
||||
className={cn(
|
||||
"grid gap-2 overflow-hidden [&>div]:pb-0",
|
||||
index === currentBranch ? "block" : "hidden"
|
||||
)}
|
||||
key={branch.key}
|
||||
{...props}
|
||||
>
|
||||
{branch}
|
||||
</div>
|
||||
));
|
||||
};
|
||||
|
||||
export type MessageBranchSelectorProps = HTMLAttributes<HTMLDivElement> & {
|
||||
from: UIMessage["role"];
|
||||
};
|
||||
|
||||
export const MessageBranchSelector = ({
|
||||
className,
|
||||
from,
|
||||
...props
|
||||
}: MessageBranchSelectorProps) => {
|
||||
const { totalBranches } = useMessageBranch();
|
||||
|
||||
// Don't render if there's only one branch
|
||||
if (totalBranches <= 1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<ButtonGroup
|
||||
className="[&>*:not(:first-child)]:rounded-l-md [&>*:not(:last-child)]:rounded-r-md"
|
||||
orientation="horizontal"
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPreviousProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchPrevious = ({
|
||||
children,
|
||||
...props
|
||||
}: MessageBranchPreviousProps) => {
|
||||
const { goToPrevious, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Previous branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToPrevious}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronLeftIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchNextProps = ComponentProps<typeof Button>;
|
||||
|
||||
export const MessageBranchNext = ({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchNextProps) => {
|
||||
const { goToNext, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<Button
|
||||
aria-label="Next branch"
|
||||
disabled={totalBranches <= 1}
|
||||
onClick={goToNext}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
{...props}
|
||||
>
|
||||
{children ?? <ChevronRightIcon size={14} />}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageBranchPageProps = HTMLAttributes<HTMLSpanElement>;
|
||||
|
||||
export const MessageBranchPage = ({
|
||||
className,
|
||||
...props
|
||||
}: MessageBranchPageProps) => {
|
||||
const { currentBranch, totalBranches } = useMessageBranch();
|
||||
|
||||
return (
|
||||
<ButtonGroupText
|
||||
className={cn(
|
||||
"border-none bg-transparent text-muted-foreground shadow-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{currentBranch + 1} of {totalBranches}
|
||||
</ButtonGroupText>
|
||||
);
|
||||
};
|
||||
|
||||
export type MessageResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const MessageResponse = memo(
|
||||
({ className, ...props }: MessageResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
MessageResponse.displayName = "MessageResponse";
|
||||
|
||||
export type MessageAttachmentProps = HTMLAttributes<HTMLDivElement> & {
|
||||
data: FileUIPart;
|
||||
className?: string;
|
||||
onRemove?: () => void;
|
||||
};
|
||||
|
||||
export function MessageAttachment({
|
||||
data,
|
||||
className,
|
||||
onRemove,
|
||||
...props
|
||||
}: MessageAttachmentProps) {
|
||||
const filename = data.filename || "";
|
||||
const mediaType =
|
||||
data.mediaType?.startsWith("image/") && data.url ? "image" : "file";
|
||||
const isImage = mediaType === "image";
|
||||
const attachmentLabel = filename || (isImage ? "Image" : "Attachment");
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"group relative size-24 overflow-hidden rounded-lg",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{isImage ? (
|
||||
<>
|
||||
<img
|
||||
alt={filename || "attachment"}
|
||||
className="size-full object-cover"
|
||||
height={100}
|
||||
src={data.url}
|
||||
width={100}
|
||||
/>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="absolute top-2 right-2 size-6 rounded-full bg-background/80 p-0 opacity-0 backdrop-blur-sm transition-opacity hover:bg-background group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<div className="flex size-full shrink-0 items-center justify-center rounded-lg bg-muted text-muted-foreground">
|
||||
<PaperclipIcon className="size-4" />
|
||||
</div>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>
|
||||
<p>{attachmentLabel}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{onRemove && (
|
||||
<Button
|
||||
aria-label="Remove attachment"
|
||||
className="size-6 shrink-0 rounded-full p-0 opacity-0 transition-opacity hover:bg-accent group-hover:opacity-100 [&>svg]:size-3"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onRemove();
|
||||
}}
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<XIcon />
|
||||
<span className="sr-only">Remove</span>
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageAttachmentsProps = ComponentProps<"div">;
|
||||
|
||||
export function MessageAttachments({
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: MessageAttachmentsProps) {
|
||||
if (!children) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"ml-auto flex w-fit flex-wrap items-start gap-2",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export type MessageToolbarProps = ComponentProps<"div">;
|
||||
|
||||
export const MessageToolbar = ({
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: MessageToolbarProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"mt-4 flex w-full items-center justify-between gap-4",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
205
src/components/ai-elements/model-selector.tsx
Normal file
205
src/components/ai-elements/model-selector.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
import {
|
||||
Command,
|
||||
CommandDialog,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
CommandSeparator,
|
||||
CommandShortcut,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog";
|
||||
import { cn } from "@/lib/utils";
|
||||
import type { ReactNode, ComponentProps } from "react";
|
||||
|
||||
export type ModelSelectorProps = ComponentProps<typeof Dialog>;
|
||||
|
||||
export const ModelSelector = (props: ModelSelectorProps) => (
|
||||
<Dialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorTriggerProps = ComponentProps<typeof DialogTrigger>;
|
||||
|
||||
export const ModelSelectorTrigger = (props: ModelSelectorTriggerProps) => (
|
||||
<DialogTrigger {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorContentProps = ComponentProps<typeof DialogContent> & {
|
||||
title?: ReactNode;
|
||||
};
|
||||
|
||||
export const ModelSelectorContent = ({
|
||||
className,
|
||||
children,
|
||||
title = "Model Selector",
|
||||
...props
|
||||
}: ModelSelectorContentProps) => (
|
||||
<DialogContent className={cn("p-0", className)} {...props}>
|
||||
<DialogTitle className="sr-only">{title}</DialogTitle>
|
||||
<Command className="**:data-[slot=command-input-wrapper]:h-auto">
|
||||
{children}
|
||||
</Command>
|
||||
</DialogContent>
|
||||
);
|
||||
|
||||
export type ModelSelectorDialogProps = ComponentProps<typeof CommandDialog>;
|
||||
|
||||
export const ModelSelectorDialog = (props: ModelSelectorDialogProps) => (
|
||||
<CommandDialog {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorInputProps = ComponentProps<typeof CommandInput>;
|
||||
|
||||
export const ModelSelectorInput = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorInputProps) => (
|
||||
<CommandInput className={cn("h-auto py-3.5", className)} {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorListProps = ComponentProps<typeof CommandList>;
|
||||
|
||||
export const ModelSelectorList = (props: ModelSelectorListProps) => (
|
||||
<CommandList {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorEmptyProps = ComponentProps<typeof CommandEmpty>;
|
||||
|
||||
export const ModelSelectorEmpty = (props: ModelSelectorEmptyProps) => (
|
||||
<CommandEmpty {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorGroupProps = ComponentProps<typeof CommandGroup>;
|
||||
|
||||
export const ModelSelectorGroup = (props: ModelSelectorGroupProps) => (
|
||||
<CommandGroup {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorItemProps = ComponentProps<typeof CommandItem>;
|
||||
|
||||
export const ModelSelectorItem = (props: ModelSelectorItemProps) => (
|
||||
<CommandItem {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorShortcutProps = ComponentProps<typeof CommandShortcut>;
|
||||
|
||||
export const ModelSelectorShortcut = (props: ModelSelectorShortcutProps) => (
|
||||
<CommandShortcut {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorSeparatorProps = ComponentProps<
|
||||
typeof CommandSeparator
|
||||
>;
|
||||
|
||||
export const ModelSelectorSeparator = (props: ModelSelectorSeparatorProps) => (
|
||||
<CommandSeparator {...props} />
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoProps = Omit<
|
||||
ComponentProps<"img">,
|
||||
"src" | "alt"
|
||||
> & {
|
||||
provider:
|
||||
| "moonshotai-cn"
|
||||
| "lucidquery"
|
||||
| "moonshotai"
|
||||
| "zai-coding-plan"
|
||||
| "alibaba"
|
||||
| "xai"
|
||||
| "vultr"
|
||||
| "nvidia"
|
||||
| "upstage"
|
||||
| "groq"
|
||||
| "github-copilot"
|
||||
| "mistral"
|
||||
| "vercel"
|
||||
| "nebius"
|
||||
| "deepseek"
|
||||
| "alibaba-cn"
|
||||
| "google-vertex-anthropic"
|
||||
| "venice"
|
||||
| "chutes"
|
||||
| "cortecs"
|
||||
| "github-models"
|
||||
| "togetherai"
|
||||
| "azure"
|
||||
| "baseten"
|
||||
| "huggingface"
|
||||
| "opencode"
|
||||
| "fastrouter"
|
||||
| "google"
|
||||
| "google-vertex"
|
||||
| "cloudflare-workers-ai"
|
||||
| "inception"
|
||||
| "wandb"
|
||||
| "openai"
|
||||
| "zhipuai-coding-plan"
|
||||
| "perplexity"
|
||||
| "openrouter"
|
||||
| "zenmux"
|
||||
| "v0"
|
||||
| "iflowcn"
|
||||
| "synthetic"
|
||||
| "deepinfra"
|
||||
| "zhipuai"
|
||||
| "submodel"
|
||||
| "zai"
|
||||
| "inference"
|
||||
| "requesty"
|
||||
| "morph"
|
||||
| "lmstudio"
|
||||
| "anthropic"
|
||||
| "aihubmix"
|
||||
| "fireworks-ai"
|
||||
| "modelscope"
|
||||
| "llama"
|
||||
| "scaleway"
|
||||
| "amazon-bedrock"
|
||||
| "cerebras"
|
||||
| (string & {});
|
||||
};
|
||||
|
||||
export const ModelSelectorLogo = ({
|
||||
provider,
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoProps) => (
|
||||
<img
|
||||
{...props}
|
||||
alt={`${provider} logo`}
|
||||
className={cn("size-3", className)}
|
||||
height={12}
|
||||
src={`https://models.dev/logos/${provider}.svg`}
|
||||
width={12}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorLogoGroupProps = ComponentProps<"div">;
|
||||
|
||||
export const ModelSelectorLogoGroup = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorLogoGroupProps) => (
|
||||
<div
|
||||
className={cn(
|
||||
"-space-x-1 flex shrink-0 items-center [&>img]:rounded-full [&>img]:bg-background [&>img]:p-px [&>img]:ring-1 [&>img]:ring-border",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
|
||||
export type ModelSelectorNameProps = ComponentProps<"span">;
|
||||
|
||||
export const ModelSelectorName = ({
|
||||
className,
|
||||
...props
|
||||
}: ModelSelectorNameProps) => (
|
||||
<span className={cn("flex-1 truncate text-left", className)} {...props} />
|
||||
);
|
||||
1190
src/components/ai-elements/prompt-input.tsx
Normal file
1190
src/components/ai-elements/prompt-input.tsx
Normal file
File diff suppressed because it is too large
Load Diff
178
src/components/ai-elements/reasoning.tsx
Normal file
178
src/components/ai-elements/reasoning.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import { useControllableState } from "@radix-ui/react-use-controllable-state";
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from "@/components/ui/collapsible";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { BrainIcon, ChevronDownIcon } from "lucide-react";
|
||||
import type { ComponentProps } from "react";
|
||||
import { createContext, memo, useContext, useEffect, useState } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
import { Shimmer } from "./shimmer";
|
||||
|
||||
type ReasoningContextValue = {
|
||||
isStreaming: boolean;
|
||||
isOpen: boolean;
|
||||
setIsOpen: (open: boolean) => void;
|
||||
duration: number | undefined;
|
||||
};
|
||||
|
||||
const ReasoningContext = createContext<ReasoningContextValue | null>(null);
|
||||
|
||||
const useReasoning = () => {
|
||||
const context = useContext(ReasoningContext);
|
||||
if (!context) {
|
||||
throw new Error("Reasoning components must be used within Reasoning");
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
export type ReasoningProps = ComponentProps<typeof Collapsible> & {
|
||||
isStreaming?: boolean;
|
||||
open?: boolean;
|
||||
defaultOpen?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
duration?: number;
|
||||
};
|
||||
|
||||
const AUTO_CLOSE_DELAY = 1000;
|
||||
const MS_IN_S = 1000;
|
||||
|
||||
export const Reasoning = memo(
|
||||
({
|
||||
className,
|
||||
isStreaming = false,
|
||||
open,
|
||||
defaultOpen = true,
|
||||
onOpenChange,
|
||||
duration: durationProp,
|
||||
children,
|
||||
...props
|
||||
}: ReasoningProps) => {
|
||||
const [isOpen, setIsOpen] = useControllableState({
|
||||
prop: open,
|
||||
defaultProp: defaultOpen,
|
||||
onChange: onOpenChange,
|
||||
});
|
||||
const [duration, setDuration] = useControllableState({
|
||||
prop: durationProp,
|
||||
defaultProp: undefined,
|
||||
});
|
||||
|
||||
const [hasAutoClosed, setHasAutoClosed] = useState(false);
|
||||
const [startTime, setStartTime] = useState<number | null>(null);
|
||||
|
||||
// Track duration when streaming starts and ends
|
||||
useEffect(() => {
|
||||
if (isStreaming) {
|
||||
if (startTime === null) {
|
||||
setStartTime(Date.now());
|
||||
}
|
||||
} else if (startTime !== null) {
|
||||
setDuration(Math.ceil((Date.now() - startTime) / MS_IN_S));
|
||||
setStartTime(null);
|
||||
}
|
||||
}, [isStreaming, startTime, setDuration]);
|
||||
|
||||
// Auto-open when streaming starts, auto-close when streaming ends (once only)
|
||||
useEffect(() => {
|
||||
if (defaultOpen && !isStreaming && isOpen && !hasAutoClosed) {
|
||||
// Add a small delay before closing to allow user to see the content
|
||||
const timer = setTimeout(() => {
|
||||
setIsOpen(false);
|
||||
setHasAutoClosed(true);
|
||||
}, AUTO_CLOSE_DELAY);
|
||||
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [isStreaming, isOpen, defaultOpen, setIsOpen, hasAutoClosed]);
|
||||
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
setIsOpen(newOpen);
|
||||
};
|
||||
|
||||
return (
|
||||
<ReasoningContext.Provider
|
||||
value={{ isStreaming, isOpen, setIsOpen, duration }}
|
||||
>
|
||||
<Collapsible
|
||||
className={cn("not-prose mb-4", className)}
|
||||
onOpenChange={handleOpenChange}
|
||||
open={isOpen}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</Collapsible>
|
||||
</ReasoningContext.Provider>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningTriggerProps = ComponentProps<typeof CollapsibleTrigger>;
|
||||
|
||||
const getThinkingMessage = (isStreaming: boolean, duration?: number) => {
|
||||
if (isStreaming || duration === 0) {
|
||||
return <Shimmer duration={1}>思考中...</Shimmer>;
|
||||
}
|
||||
if (duration === undefined) {
|
||||
return <p>正在推理</p>;
|
||||
}
|
||||
return <p>推理时间: {duration} 秒</p>;
|
||||
};
|
||||
|
||||
export const ReasoningTrigger = memo(
|
||||
({ className, children, ...props }: ReasoningTriggerProps) => {
|
||||
const { isStreaming, isOpen, duration } = useReasoning();
|
||||
|
||||
return (
|
||||
<CollapsibleTrigger
|
||||
className={cn(
|
||||
"flex w-full items-center gap-2 text-muted-foreground text-sm transition-colors hover:text-foreground",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children ?? (
|
||||
<>
|
||||
<BrainIcon className="size-4" />
|
||||
{getThinkingMessage(isStreaming, duration)}
|
||||
<ChevronDownIcon
|
||||
className={cn(
|
||||
"size-4 transition-transform",
|
||||
isOpen ? "rotate-180" : "rotate-0"
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</CollapsibleTrigger>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
export type ReasoningContentProps = ComponentProps<
|
||||
typeof CollapsibleContent
|
||||
> & {
|
||||
children: string;
|
||||
};
|
||||
|
||||
export const ReasoningContent = memo(
|
||||
({ className, children, ...props }: ReasoningContentProps) => (
|
||||
<CollapsibleContent
|
||||
className={cn(
|
||||
"mt-4 text-sm",
|
||||
"data-[state=closed]:fade-out-0 data-[state=closed]:slide-out-to-top-2 data-[state=open]:slide-in-from-top-2 text-muted-foreground outline-none data-[state=closed]:animate-out data-[state=open]:animate-in",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<Streamdown {...props}>{children}</Streamdown>
|
||||
</CollapsibleContent>
|
||||
)
|
||||
);
|
||||
|
||||
Reasoning.displayName = "Reasoning";
|
||||
ReasoningTrigger.displayName = "ReasoningTrigger";
|
||||
ReasoningContent.displayName = "ReasoningContent";
|
||||
22
src/components/ai-elements/response.tsx
Normal file
22
src/components/ai-elements/response.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { type ComponentProps, memo } from "react";
|
||||
import { Streamdown } from "streamdown";
|
||||
|
||||
type ResponseProps = ComponentProps<typeof Streamdown>;
|
||||
|
||||
export const Response = memo(
|
||||
({ className, ...props }: ResponseProps) => (
|
||||
<Streamdown
|
||||
className={cn(
|
||||
"size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
),
|
||||
(prevProps, nextProps) => prevProps.children === nextProps.children
|
||||
);
|
||||
|
||||
Response.displayName = "Response";
|
||||
64
src/components/ai-elements/shimmer.tsx
Normal file
64
src/components/ai-elements/shimmer.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
"use client";
|
||||
|
||||
import { cn } from "@/lib/utils";
|
||||
import { motion } from "motion/react";
|
||||
import {
|
||||
type CSSProperties,
|
||||
type ElementType,
|
||||
type JSX,
|
||||
memo,
|
||||
useMemo,
|
||||
} from "react";
|
||||
|
||||
export type TextShimmerProps = {
|
||||
children: string;
|
||||
as?: ElementType;
|
||||
className?: string;
|
||||
duration?: number;
|
||||
spread?: number;
|
||||
};
|
||||
|
||||
const ShimmerComponent = ({
|
||||
children,
|
||||
as: Component = "p",
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 2,
|
||||
}: TextShimmerProps) => {
|
||||
const MotionComponent = motion.create(
|
||||
Component as keyof JSX.IntrinsicElements
|
||||
);
|
||||
|
||||
const dynamicSpread = useMemo(
|
||||
() => (children?.length ?? 0) * spread,
|
||||
[children, spread]
|
||||
);
|
||||
|
||||
return (
|
||||
<MotionComponent
|
||||
animate={{ backgroundPosition: "0% center" }}
|
||||
className={cn(
|
||||
"relative inline-block bg-[length:250%_100%,auto] bg-clip-text text-transparent",
|
||||
"[--bg:linear-gradient(90deg,#0000_calc(50%-var(--spread)),var(--color-background),#0000_calc(50%+var(--spread)))] [background-repeat:no-repeat,padding-box]",
|
||||
className
|
||||
)}
|
||||
initial={{ backgroundPosition: "100% center" }}
|
||||
style={
|
||||
{
|
||||
"--spread": `${dynamicSpread}px`,
|
||||
backgroundImage:
|
||||
"var(--bg), linear-gradient(var(--color-muted-foreground), var(--color-muted-foreground))",
|
||||
} as CSSProperties
|
||||
}
|
||||
transition={{
|
||||
repeat: Number.POSITIVE_INFINITY,
|
||||
duration,
|
||||
ease: "linear",
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</MotionComponent>
|
||||
);
|
||||
};
|
||||
|
||||
export const Shimmer = memo(ShimmerComponent);
|
||||
215
src/components/common/advanced-select-provider.tsx
Normal file
215
src/components/common/advanced-select-provider.tsx
Normal file
@@ -0,0 +1,215 @@
|
||||
"use client"
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, useMemo, useEffect, type ReactNode } from 'react'
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/** 选项接口 */
|
||||
export interface SelectOption {
|
||||
id: string
|
||||
name: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
/** Context 值类型定义 */
|
||||
export interface AdvancedSelectContextValue {
|
||||
// 状态
|
||||
value: string[]
|
||||
open: boolean
|
||||
searchValue: string
|
||||
displayCount: number
|
||||
disabled: boolean
|
||||
singleSelectMode: boolean
|
||||
|
||||
// 选项相关
|
||||
options: SelectOption[]
|
||||
filteredOptions: SelectOption[]
|
||||
displayedOptions: SelectOption[]
|
||||
selectedOptions: SelectOption[]
|
||||
|
||||
// 配置
|
||||
limit: number
|
||||
replaceOnLimit: boolean
|
||||
|
||||
// 操作函数
|
||||
setValue: (value: string[]) => void
|
||||
setOpen: (open: boolean) => void
|
||||
setSearchValue: (searchValue: string) => void
|
||||
setDisplayCount: (count: number | ((prev: number) => number)) => void
|
||||
select: (value: string) => void
|
||||
remove: (value: string) => void
|
||||
clear: () => void
|
||||
|
||||
// 状态查询函数
|
||||
isSelected: (value: string) => boolean
|
||||
isLimitReached: () => boolean
|
||||
}
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
const AdvancedSelectContext = createContext<AdvancedSelectContextValue | undefined>(undefined)
|
||||
|
||||
export const useAdvancedSelectContext = () => {
|
||||
const context = useContext(AdvancedSelectContext)
|
||||
if (!context) {
|
||||
throw new Error('AdvancedSelect 子组件必须在 AdvancedSelectProvider 组件内使用')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// ==================== Provider 组件 ====================
|
||||
|
||||
export interface AdvancedSelectProviderProps {
|
||||
value?: string[]
|
||||
onChange?: (value: string[]) => void
|
||||
options?: SelectOption[]
|
||||
filterFunction?: (option: SelectOption, searchValue: string) => boolean
|
||||
initialDisplayCount?: number
|
||||
limit?: number // 最大选择数量,0 表示无限制
|
||||
replaceOnLimit?: boolean // 当达到 limit 时,是否用新值替换旧值
|
||||
disabled?: boolean // 是否禁用
|
||||
children: ReactNode
|
||||
}
|
||||
|
||||
export function AdvancedSelectProvider({
|
||||
value = [],
|
||||
onChange,
|
||||
options = [],
|
||||
filterFunction,
|
||||
initialDisplayCount = 99999999, // 默认无限制
|
||||
limit = 0, // 默认无限制
|
||||
replaceOnLimit = false, // 默认不替换
|
||||
disabled = false, // 默认不禁用
|
||||
children,
|
||||
}: AdvancedSelectProviderProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [searchValue, setSearchValue] = useState("")
|
||||
const [displayCount, setDisplayCount] = useState(initialDisplayCount)
|
||||
const singleSelectMode = limit === 1 && replaceOnLimit === true
|
||||
|
||||
// 默认筛选函数:搜索name字段
|
||||
const defaultFilterFunction = useCallback((option: SelectOption, search: string) => {
|
||||
return option.name.toLowerCase().includes(search.toLowerCase())
|
||||
}, [])
|
||||
|
||||
// 筛选选项
|
||||
const filteredOptions = useMemo(() => {
|
||||
if (!searchValue) return options
|
||||
const filter = filterFunction || defaultFilterFunction
|
||||
return options.filter(option => filter(option, searchValue))
|
||||
}, [options, searchValue, filterFunction, defaultFilterFunction])
|
||||
|
||||
// 当前显示的选项(限制数量)
|
||||
const displayedOptions = useMemo(() => {
|
||||
return filteredOptions.slice(0, displayCount)
|
||||
}, [filteredOptions, displayCount])
|
||||
|
||||
// 获取当前选中的选项列表
|
||||
const selectedOptions = useMemo(() => {
|
||||
const optionMap = new Map(options.map(opt => [opt.id, opt]))
|
||||
return value
|
||||
.map(id => optionMap.get(id))
|
||||
.filter((option): option is SelectOption => option !== undefined)
|
||||
}, [options, value])
|
||||
|
||||
// 判断是否已选中
|
||||
const isSelected = useCallback((optionValue: string) => {
|
||||
return value.includes(optionValue)
|
||||
}, [value])
|
||||
|
||||
// 判断是否达到选择上限
|
||||
const isLimitReached = useCallback(() => {
|
||||
// limit 为 0 表示无限制
|
||||
if (limit === 0) return false
|
||||
return value.length >= limit
|
||||
}, [value.length, limit])
|
||||
|
||||
// 处理选择/取消选择
|
||||
const handleSelect = useCallback((selectedValue: string) => {
|
||||
const option = options.find(opt => opt.id === selectedValue)
|
||||
if (!option || disabled) return
|
||||
|
||||
const isCurrentlySelected = isSelected(option.id)
|
||||
|
||||
if (isCurrentlySelected) {
|
||||
// 取消选择
|
||||
if (!singleSelectMode) {
|
||||
onChange?.(value.filter(v => v !== option.id))
|
||||
}
|
||||
} else {
|
||||
// 添加选择
|
||||
if (limit === 0) {
|
||||
// 无限制
|
||||
onChange?.([...value, option.id])
|
||||
} else if (value.length < limit) {
|
||||
// 未达到限制
|
||||
onChange?.([...value, option.id])
|
||||
} else if (replaceOnLimit) {
|
||||
// 达到限制且启用替换:移除第一个,添加新的
|
||||
onChange?.([...value.slice(1), option.id])
|
||||
}
|
||||
// 达到限制且不替换:不做任何操作
|
||||
}
|
||||
|
||||
// 单选模式(limit=1)时自动关闭
|
||||
if (singleSelectMode) {
|
||||
setOpen(false)
|
||||
}
|
||||
}, [options, disabled, isSelected, singleSelectMode, onChange, value, limit, replaceOnLimit])
|
||||
|
||||
// 处理移除单个选项
|
||||
const handleRemove = useCallback((optionValue: string) => {
|
||||
if (!disabled) {
|
||||
onChange?.(value.filter(v => v !== optionValue))
|
||||
}
|
||||
}, [disabled, onChange, value])
|
||||
|
||||
// 处理清空所有
|
||||
const handleClear = useCallback(() => {
|
||||
if (!disabled) {
|
||||
onChange?.([])
|
||||
}
|
||||
}, [disabled, onChange])
|
||||
|
||||
// 重置搜索时重置显示数量
|
||||
useEffect(() => {
|
||||
setDisplayCount(initialDisplayCount)
|
||||
}, [searchValue, initialDisplayCount])
|
||||
|
||||
const contextValue: AdvancedSelectContextValue = {
|
||||
// 状态
|
||||
value,
|
||||
open,
|
||||
searchValue,
|
||||
displayCount,
|
||||
singleSelectMode,
|
||||
|
||||
// 选项相关
|
||||
options,
|
||||
filteredOptions,
|
||||
displayedOptions,
|
||||
selectedOptions,
|
||||
|
||||
// 配置
|
||||
limit,
|
||||
replaceOnLimit,
|
||||
disabled,
|
||||
|
||||
// 操作函数
|
||||
setValue: onChange || (() => {}),
|
||||
setOpen,
|
||||
setSearchValue,
|
||||
setDisplayCount,
|
||||
select: handleSelect,
|
||||
remove: handleRemove,
|
||||
clear: handleClear,
|
||||
isSelected,
|
||||
isLimitReached
|
||||
}
|
||||
|
||||
return (
|
||||
<AdvancedSelectContext.Provider value={contextValue}>
|
||||
{children}
|
||||
</AdvancedSelectContext.Provider>
|
||||
)
|
||||
}
|
||||
399
src/components/common/advanced-select.tsx
Normal file
399
src/components/common/advanced-select.tsx
Normal file
@@ -0,0 +1,399 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckIcon, ChevronsUpDownIcon, X } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import {
|
||||
useAdvancedSelectContext,
|
||||
AdvancedSelectProvider,
|
||||
type SelectOption,
|
||||
} from "./advanced-select-provider"
|
||||
|
||||
// ==================== Popover 组件 ====================
|
||||
export interface SelectPopoverProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function SelectPopover({
|
||||
children,
|
||||
}: SelectPopoverProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
return (
|
||||
<Popover open={context.open} onOpenChange={context.setOpen}>
|
||||
{children}
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 选项列表触发器组件 ====================
|
||||
export interface SelectTriggerProps {
|
||||
placeholder?: string
|
||||
className?: string
|
||||
clearable?: boolean
|
||||
onClear?: () => void
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const SelectTrigger = React.forwardRef<HTMLButtonElement, SelectTriggerProps>(
|
||||
({ placeholder = "请选择", className, clearable = false, onClear, children }, ref) => {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
const handleClear = React.useCallback((e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
onClear?.()
|
||||
context.clear()
|
||||
}, [onClear, context])
|
||||
|
||||
const hasValue = context.value.length > 0
|
||||
|
||||
return (
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
ref={ref}
|
||||
type="button"
|
||||
variant="outline"
|
||||
role="combobox"
|
||||
aria-expanded={context.open}
|
||||
className={cn("w-full justify-between", className)}
|
||||
disabled={context.disabled}
|
||||
onClick={() => context.setOpen(!context.open)}
|
||||
>
|
||||
<span className={cn(!hasValue && "opacity-60", "truncate")}>
|
||||
{hasValue ? children : placeholder}
|
||||
</span>
|
||||
|
||||
<div className="ml-2 flex items-center gap-1 shrink-0">
|
||||
<ChevronsUpDownIcon className="h-4 w-4 opacity-50" />
|
||||
{clearable && hasValue && (
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={handleClear}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleClear(e)
|
||||
}
|
||||
}}
|
||||
className="h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 cursor-pointer"
|
||||
aria-label="清空"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectTrigger.displayName = "SelectTrigger"
|
||||
|
||||
// ==================== 选项列表展示容器组件 ====================
|
||||
export interface SelectContentProps {
|
||||
className?: string
|
||||
align?: "start" | "center" | "end"
|
||||
children: React.ReactNode
|
||||
}
|
||||
|
||||
export const SelectContent = React.forwardRef<HTMLDivElement, SelectContentProps>(
|
||||
({ className, align = "start", children }, ref) => {
|
||||
return (
|
||||
<PopoverContent
|
||||
ref={ref}
|
||||
className={cn("p-0 z-[60] min-w-[12.5rem] max-w-[min(30rem,80vw)]", className)}
|
||||
align={align}
|
||||
onWheel={(e) => {
|
||||
// 确保滚动行为独立,不影响背后的页面。
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
{children}
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
)
|
||||
}
|
||||
)
|
||||
SelectContent.displayName = "SelectContent"
|
||||
|
||||
// ==================== 选项列表过滤器类型组件 ====================
|
||||
export interface SelectInputProps {
|
||||
placeholder?: string
|
||||
}
|
||||
|
||||
export function SelectInput({
|
||||
placeholder = "搜索...",
|
||||
}: SelectInputProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
return (
|
||||
<CommandInput
|
||||
placeholder={placeholder}
|
||||
value={context.searchValue}
|
||||
onValueChange={context.setSearchValue}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 选项列表展示类组件 ====================
|
||||
export interface SelectItemListProps {
|
||||
emptyText?: React.ReactNode
|
||||
className?: string
|
||||
stepDisplayCount?: number
|
||||
children?: (option: SelectOption) => React.ReactNode
|
||||
}
|
||||
|
||||
export function SelectItemList({
|
||||
emptyText = "未找到相关选项",
|
||||
className,
|
||||
stepDisplayCount = 20,
|
||||
children
|
||||
}: SelectItemListProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
|
||||
const handleScroll = React.useCallback((e: React.UIEvent<HTMLDivElement>) => {
|
||||
const target = e.currentTarget
|
||||
const { scrollTop, scrollHeight, clientHeight } = target
|
||||
|
||||
// 当滚动到底部附近时加载更多(距离底部50px时触发)
|
||||
if (scrollHeight - scrollTop - clientHeight < 50 && context.displayedOptions.length < context.filteredOptions.length) {
|
||||
context.setDisplayCount((prev: number) => Math.min(prev + stepDisplayCount, context.filteredOptions.length))
|
||||
}
|
||||
}, [stepDisplayCount, context])
|
||||
|
||||
if (context.filteredOptions.length === 0) {
|
||||
return (
|
||||
<CommandList>
|
||||
<CommandEmpty>{emptyText}</CommandEmpty>
|
||||
</CommandList>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<CommandList
|
||||
className={cn("max-h-[200px] overflow-auto", className)}
|
||||
onScroll={handleScroll}
|
||||
onWheel={(e) => {
|
||||
e.stopPropagation()
|
||||
}}
|
||||
>
|
||||
<CommandGroup>
|
||||
{context.displayedOptions.map((option) => {
|
||||
const isSelected = context.isSelected(option.id)
|
||||
const shouldDisable = context.disabled || (context.isLimitReached() && !context.replaceOnLimit && !isSelected)
|
||||
|
||||
return (
|
||||
<CommandItem
|
||||
key={option.id}
|
||||
value={option.id}
|
||||
onSelect={context.select}
|
||||
disabled={shouldDisable}
|
||||
>
|
||||
{children ? children(option) : option.name}
|
||||
<CheckIcon
|
||||
className={cn(
|
||||
"ml-auto",
|
||||
isSelected ? "opacity-100" : "opacity-0"
|
||||
)}
|
||||
/>
|
||||
</CommandItem>
|
||||
)
|
||||
})}
|
||||
{context.displayedOptions.length < context.filteredOptions.length && (
|
||||
<div className="px-2 py-1 text-xs text-muted-foreground text-center">
|
||||
继续滚动查看更多选项...
|
||||
</div>
|
||||
)}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== Badge 选项展示组件 ====================
|
||||
export interface SelectedBadgesProps {
|
||||
maxDisplay?: number
|
||||
onRemove?: (id: string) => void
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SelectedBadges({
|
||||
maxDisplay = 3,
|
||||
onRemove,
|
||||
className,
|
||||
}: SelectedBadgesProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
const displayedOptions = context.selectedOptions.slice(0, maxDisplay)
|
||||
const remainingCount = context.selectedOptions.length - maxDisplay
|
||||
|
||||
const handleRemove = React.useCallback((optionId: string, e: React.MouseEvent | React.KeyboardEvent) => {
|
||||
e.stopPropagation()
|
||||
context.remove(optionId)
|
||||
onRemove?.(optionId)
|
||||
}, [context, onRemove])
|
||||
|
||||
return (
|
||||
<div className={cn("flex flex-wrap gap-1", className)}>
|
||||
{displayedOptions.map((option) => (
|
||||
<Badge
|
||||
key={option.id}
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="gap-1"
|
||||
>
|
||||
<span className="truncate max-w-[120px]">{option.name}</span>
|
||||
<span
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => handleRemove(option.id, e)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
handleRemove(option.id, e)
|
||||
}
|
||||
}}
|
||||
className="ml-0.5 rounded-sm opacity-70 hover:opacity-100 focus:outline-none cursor-pointer"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</span>
|
||||
</Badge>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="secondary" size="sm">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 字符串拼接选项展示组件 ====================
|
||||
export interface SelectedNameProps {
|
||||
separator?: string
|
||||
maxLength?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function SelectedName({
|
||||
separator = ", ",
|
||||
maxLength,
|
||||
className,
|
||||
}: SelectedNameProps) {
|
||||
const context = useAdvancedSelectContext()
|
||||
const names = context.selectedOptions.map(option => option.name).join(separator)
|
||||
const displayText = maxLength && names.length > maxLength
|
||||
? `${names.slice(0, maxLength)}...`
|
||||
: names
|
||||
|
||||
return (
|
||||
<span className={cn("truncate", className)}>
|
||||
{displayText}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== 组合式 API 组件,封装 Provider,提供简单易用的API ====================
|
||||
export interface AdvancedSelectProps {
|
||||
value?: string | number | string[] | number[] | null
|
||||
onChange?: (value: any) => void
|
||||
options?: SelectOption[]
|
||||
disabled?: boolean
|
||||
multiple?: {
|
||||
enable?: boolean // 多选模式,默认为单选
|
||||
limit?: number // 多选模式下,限制选择上限,0表示不显示
|
||||
replaceOnLimit?: boolean // 多选模式下,选择达到上限时,新的选项会替换掉最开始选择的
|
||||
}
|
||||
filterFunction?: (option: SelectOption, searchValue: string) => boolean
|
||||
initialDisplayCount?: number, // 初始显示的选项数量,默认最多显示50个
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function AdvancedSelect({
|
||||
value,
|
||||
onChange,
|
||||
options = [],
|
||||
disabled = false,
|
||||
multiple = {},
|
||||
filterFunction,
|
||||
initialDisplayCount = 50,
|
||||
children,
|
||||
}: AdvancedSelectProps) {
|
||||
const { limit, replaceOnLimit } = multiple.enable ?
|
||||
{ limit: multiple.limit || 0, replaceOnLimit: !!multiple.replaceOnLimit } :
|
||||
{ limit: 1, replaceOnLimit: true }
|
||||
const singleSelectMode = !multiple.enable
|
||||
|
||||
// 标准化 value 为字符串数组格式(context 使用),context内部统一为字符串数组
|
||||
const normalizedValue = React.useMemo(() => {
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(v => String(v))
|
||||
}
|
||||
return value !== undefined && value !== null && value !== "" ? [String(value)] : []
|
||||
}, [value])
|
||||
|
||||
// 标准化 onChange 为适配原始类型
|
||||
const normalizedOnChange = React.useCallback((newValue: string[]) => {
|
||||
if (!onChange) return
|
||||
if (singleSelectMode) {
|
||||
// 单选模式:返回单个值或 undefined
|
||||
if (newValue.length === 0) {
|
||||
onChange(null) // react-hook-form中null表示清空,undefined表示重置
|
||||
return
|
||||
}
|
||||
|
||||
// 根据原始 value 的类型返回对应类型
|
||||
if (typeof value === 'number') {
|
||||
onChange(Number(newValue[0]))
|
||||
} else {
|
||||
onChange(newValue[0])
|
||||
}
|
||||
} else {
|
||||
// 多选模式:返回数组
|
||||
if (newValue.length === 0) {
|
||||
onChange(null)
|
||||
return
|
||||
}
|
||||
|
||||
// 根据原始 value 的类型返回对应类型的数组
|
||||
if (Array.isArray(value) && value.length > 0 && typeof value[0] === 'number') {
|
||||
onChange(newValue.map(v => Number(v)))
|
||||
} else {
|
||||
onChange(newValue)
|
||||
}
|
||||
}
|
||||
}, [onChange, singleSelectMode, value])
|
||||
|
||||
return (
|
||||
<AdvancedSelectProvider
|
||||
value={normalizedValue}
|
||||
onChange={normalizedOnChange}
|
||||
options={options}
|
||||
filterFunction={filterFunction}
|
||||
initialDisplayCount={initialDisplayCount}
|
||||
limit={limit}
|
||||
replaceOnLimit={replaceOnLimit}
|
||||
disabled={disabled}
|
||||
>
|
||||
{children}
|
||||
</AdvancedSelectProvider>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出类型
|
||||
export type { SelectOption }
|
||||
271
src/components/common/card-select.tsx
Normal file
271
src/components/common/card-select.tsx
Normal file
@@ -0,0 +1,271 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { CheckCircle2, ExternalLink, ChevronLeft, ChevronRight } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
|
||||
// 卡片选项接口
|
||||
export interface CardSelectOption {
|
||||
id: string | number
|
||||
name: string
|
||||
description?: string
|
||||
url?: string
|
||||
websiteUrl?: string
|
||||
type?: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
// 卡片选项项组件属性
|
||||
export interface CardSelectItemProps {
|
||||
option: CardSelectOption
|
||||
selected?: boolean
|
||||
onSelect?: (id: string | number) => void
|
||||
showCheckbox?: boolean
|
||||
showExternalLink?: boolean
|
||||
showBadge?: boolean
|
||||
renderExtra?: (option: CardSelectOption) => React.ReactNode
|
||||
renderActions?: (option: CardSelectOption) => React.ReactNode
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片选项项组件
|
||||
* 用于展示单个卡片选项,支持复选框、外部链接、徽章等
|
||||
*/
|
||||
export function CardSelectItem({
|
||||
option,
|
||||
selected = false,
|
||||
onSelect,
|
||||
showCheckbox = false,
|
||||
showExternalLink = false,
|
||||
showBadge = false,
|
||||
renderExtra,
|
||||
renderActions,
|
||||
className,
|
||||
disabled = false
|
||||
}: CardSelectItemProps) {
|
||||
const handleClick = React.useCallback(() => {
|
||||
if (!disabled) {
|
||||
onSelect?.(option.id)
|
||||
}
|
||||
}, [onSelect, option.id, disabled])
|
||||
|
||||
const handleCheckboxChange = React.useCallback(() => {
|
||||
if (!disabled) {
|
||||
onSelect?.(option.id)
|
||||
}
|
||||
}, [onSelect, option.id, disabled])
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
"flex items-start space-x-3 p-3 rounded-lg border bg-background transition-all",
|
||||
showCheckbox && "cursor-pointer hover:shadow-sm hover:border-primary/50",
|
||||
selected && "border-primary bg-primary/5 shadow-sm",
|
||||
disabled && "opacity-50 cursor-not-allowed",
|
||||
className
|
||||
)}
|
||||
onClick={showCheckbox ? handleClick : undefined}
|
||||
>
|
||||
{showCheckbox && (
|
||||
<Checkbox
|
||||
id={`card-select-${option.id}`}
|
||||
checked={selected}
|
||||
onCheckedChange={handleCheckboxChange}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="mt-0.5"
|
||||
disabled={disabled}
|
||||
/>
|
||||
)}
|
||||
<div className="flex-1 space-y-1.5 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label
|
||||
htmlFor={showCheckbox ? `card-select-${option.id}` : undefined}
|
||||
className={cn(
|
||||
"text-sm font-medium",
|
||||
showCheckbox && "cursor-pointer"
|
||||
)}
|
||||
onClick={(e) => showCheckbox && e.stopPropagation()}
|
||||
>
|
||||
{option.name}
|
||||
</Label>
|
||||
{selected && showCheckbox && (
|
||||
<CheckCircle2 className="h-3.5 w-3.5 text-primary flex-shrink-0" />
|
||||
)}
|
||||
{showBadge && option.type && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{option.type}
|
||||
</Badge>
|
||||
)}
|
||||
{showExternalLink && option.websiteUrl && (
|
||||
<a
|
||||
href={option.websiteUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="text-muted-foreground hover:text-primary transition-colors ml-auto"
|
||||
>
|
||||
<ExternalLink className="h-3.5 w-3.5" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
{option.description && (
|
||||
<p className="text-xs text-muted-foreground break-all line-clamp-3">
|
||||
{option.description}
|
||||
</p>
|
||||
)}
|
||||
{option.url && !option.description && (
|
||||
<p className="text-xs text-muted-foreground break-all line-clamp-2">
|
||||
{option.url}
|
||||
</p>
|
||||
)}
|
||||
{renderExtra?.(option)}
|
||||
</div>
|
||||
{renderActions && (
|
||||
<div className="shrink-0">
|
||||
{renderActions(option)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface CardSelectProps {
|
||||
value?: (string | number)[]
|
||||
onChange?: (value: (string | number)[]) => void
|
||||
options?: CardSelectOption[]
|
||||
className?: string
|
||||
containerClassName?: string
|
||||
disabled?: boolean
|
||||
multiple?: boolean
|
||||
showCheckbox?: boolean
|
||||
showExternalLink?: boolean
|
||||
showBadge?: boolean
|
||||
renderExtra?: (option: CardSelectOption) => React.ReactNode
|
||||
renderActions?: (option: CardSelectOption) => React.ReactNode
|
||||
maxHeight?: string
|
||||
// 分页相关属性
|
||||
enablePagination?: boolean
|
||||
pageSize?: number
|
||||
showPaginationInfo?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 卡片选择组件
|
||||
* 支持单选和多选模式,支持分页展示
|
||||
*/
|
||||
export function CardSelect({
|
||||
value = [],
|
||||
onChange,
|
||||
options = [],
|
||||
className,
|
||||
containerClassName = "space-y-2 max-h-64 overflow-y-auto rounded-lg border bg-muted/30 p-3",
|
||||
disabled = false,
|
||||
multiple = true,
|
||||
showCheckbox = true,
|
||||
showExternalLink = false,
|
||||
showBadge = false,
|
||||
renderExtra,
|
||||
renderActions,
|
||||
maxHeight,
|
||||
enablePagination = false,
|
||||
pageSize = 3,
|
||||
showPaginationInfo = true
|
||||
}: CardSelectProps) {
|
||||
const [currentPage, setCurrentPage] = React.useState(1)
|
||||
|
||||
const handleSelect = React.useCallback((id: string | number) => {
|
||||
if (disabled) return
|
||||
|
||||
if (multiple) {
|
||||
const newValue = value.includes(id)
|
||||
? value.filter(v => v !== id)
|
||||
: [...value, id]
|
||||
onChange?.(newValue)
|
||||
} else {
|
||||
onChange?.([id])
|
||||
}
|
||||
}, [value, onChange, disabled, multiple])
|
||||
|
||||
// 计算分页数据
|
||||
const totalPages = enablePagination ? Math.ceil(options.length / pageSize) : 1
|
||||
const startIndex = enablePagination ? (currentPage - 1) * pageSize : 0
|
||||
const endIndex = enablePagination ? startIndex + pageSize : options.length
|
||||
const displayOptions = enablePagination ? options.slice(startIndex, endIndex) : options
|
||||
|
||||
// 重置页码当选项变化时
|
||||
React.useEffect(() => {
|
||||
if (enablePagination && currentPage > totalPages && totalPages > 0) {
|
||||
setCurrentPage(1)
|
||||
}
|
||||
}, [options.length, enablePagination, currentPage, totalPages])
|
||||
|
||||
const handlePrevPage = React.useCallback(() => {
|
||||
setCurrentPage(prev => Math.max(1, prev - 1))
|
||||
}, [])
|
||||
|
||||
const handleNextPage = React.useCallback(() => {
|
||||
setCurrentPage(prev => Math.min(totalPages, prev + 1))
|
||||
}, [totalPages])
|
||||
|
||||
const containerStyle = maxHeight ? { maxHeight } : undefined
|
||||
|
||||
return (
|
||||
<div className="space-y-3 overflow-auto">
|
||||
<div className={cn(containerClassName, className)} style={containerStyle}>
|
||||
{displayOptions.map((option) => (
|
||||
<CardSelectItem
|
||||
key={option.id}
|
||||
option={option}
|
||||
selected={value.includes(option.id)}
|
||||
onSelect={handleSelect}
|
||||
showCheckbox={showCheckbox}
|
||||
showExternalLink={showExternalLink}
|
||||
showBadge={showBadge}
|
||||
renderExtra={renderExtra}
|
||||
renderActions={renderActions}
|
||||
disabled={disabled}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{enablePagination && totalPages > 1 && (
|
||||
<div className="flex items-center justify-between px-2">
|
||||
{showPaginationInfo && (
|
||||
<div className="text-xs text-muted-foreground">
|
||||
第 {startIndex + 1}-{Math.min(endIndex, options.length)} 项,共 {options.length} 项
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2 ml-auto">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handlePrevPage}
|
||||
disabled={currentPage === 1}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<div className="text-xs text-muted-foreground min-w-[60px] text-center">
|
||||
{currentPage} / {totalPages}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleNextPage}
|
||||
disabled={currentPage === totalPages}
|
||||
className="h-8 w-8 p-0"
|
||||
>
|
||||
<ChevronRight className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/common/checkbox-group.tsx
Normal file
67
src/components/common/checkbox-group.tsx
Normal file
@@ -0,0 +1,67 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Label } from '@/components/ui/label'
|
||||
|
||||
export interface CheckboxOption {
|
||||
id: number | string
|
||||
name: string
|
||||
[key: string]: any // 允许额外的属性
|
||||
}
|
||||
|
||||
export interface CheckboxGroupProps {
|
||||
options: CheckboxOption[]
|
||||
value?: (number | string)[]
|
||||
onChange?: (value: (number | string)[]) => void
|
||||
className?: string
|
||||
itemClassName?: string
|
||||
labelClassName?: string
|
||||
idPrefix?: string
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
export function CheckboxGroup({
|
||||
options,
|
||||
value = [],
|
||||
onChange,
|
||||
className = "space-y-2",
|
||||
itemClassName = "flex items-center space-x-2",
|
||||
labelClassName = "text-sm",
|
||||
idPrefix = "checkbox",
|
||||
disabled = false,
|
||||
}: CheckboxGroupProps) {
|
||||
const handleToggle = (optionId: number | string, checked: boolean) => {
|
||||
const newValue = checked
|
||||
? [...value, optionId]
|
||||
: value.filter((id) => id !== optionId)
|
||||
onChange?.(newValue)
|
||||
}
|
||||
|
||||
if (!options || options.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{options.map((option) => (
|
||||
<div key={option.id} className={itemClassName}>
|
||||
<Checkbox
|
||||
id={`${idPrefix}-${option.id}`}
|
||||
checked={value.includes(option.id)}
|
||||
onCheckedChange={(checked) =>
|
||||
handleToggle(option.id, checked as boolean)
|
||||
}
|
||||
disabled={disabled}
|
||||
/>
|
||||
<Label
|
||||
htmlFor={`${idPrefix}-${option.id}`}
|
||||
className={labelClassName}
|
||||
>
|
||||
{option.name}
|
||||
</Label>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
121
src/components/common/date-picker.tsx
Normal file
121
src/components/common/date-picker.tsx
Normal file
@@ -0,0 +1,121 @@
|
||||
'use client'
|
||||
|
||||
import * as React from "react"
|
||||
import { format, parse, isValid } from "date-fns"
|
||||
import { Calendar as CalendarIcon } from "lucide-react"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Calendar } from "@/components/ui/calendar"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover"
|
||||
|
||||
export interface DatePickerProps {
|
||||
value?: Date
|
||||
onChange?: (date: Date | undefined) => void
|
||||
placeholder?: string
|
||||
disabled?: boolean
|
||||
className?: string
|
||||
buttonClassName?: string
|
||||
inputClassName?: string
|
||||
popoverClassName?: string
|
||||
calendarClassName?: string
|
||||
formatString?: string
|
||||
inputFormat?: string
|
||||
}
|
||||
|
||||
export function DatePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = "选择日期",
|
||||
disabled = false,
|
||||
className,
|
||||
buttonClassName,
|
||||
inputClassName,
|
||||
popoverClassName,
|
||||
calendarClassName,
|
||||
inputFormat = "yyyy-MM-dd"
|
||||
}: DatePickerProps) {
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
|
||||
// 同步外部value到input
|
||||
React.useEffect(() => {
|
||||
if (value) {
|
||||
setInputValue(format(value, inputFormat))
|
||||
} else {
|
||||
setInputValue("")
|
||||
}
|
||||
}, [value, inputFormat])
|
||||
|
||||
const handleInputChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newValue = e.target.value
|
||||
setInputValue(newValue)
|
||||
|
||||
// 尝试解析输入的日期
|
||||
if (newValue) {
|
||||
const parsedDate = parse(newValue, inputFormat, new Date())
|
||||
if (isValid(parsedDate)) {
|
||||
onChange?.(parsedDate)
|
||||
}
|
||||
} else {
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}
|
||||
|
||||
const handleInputBlur = () => {
|
||||
// 输入框失焦时,如果日期无效,重置为当前value的格式
|
||||
if (inputValue && value) {
|
||||
const parsedDate = parse(inputValue, inputFormat, new Date())
|
||||
if (!isValid(parsedDate)) {
|
||||
setInputValue(format(value, inputFormat))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handleCalendarSelect = (date: Date | undefined) => {
|
||||
onChange?.(date)
|
||||
setIsOpen(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn("flex items-center gap-2", className)}>
|
||||
<Input
|
||||
type="text"
|
||||
value={inputValue}
|
||||
onChange={handleInputChange}
|
||||
onBlur={handleInputBlur}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
className={cn("flex-1", inputClassName)}
|
||||
/>
|
||||
<Popover open={isOpen} onOpenChange={setIsOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn("shrink-0", buttonClassName)}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
className={cn("w-auto p-0", popoverClassName)}
|
||||
align="end"
|
||||
>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={handleCalendarSelect}
|
||||
disabled={disabled}
|
||||
className={calendarClassName}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
118
src/components/common/date-range-picker.tsx
Normal file
118
src/components/common/date-range-picker.tsx
Normal file
@@ -0,0 +1,118 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { format } from 'date-fns'
|
||||
import { zhCN } from 'date-fns/locale'
|
||||
import { Calendar as CalendarIcon, X } from 'lucide-react'
|
||||
import { DateRange } from 'react-day-picker'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Calendar } from '@/components/ui/calendar'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
|
||||
// 日期范围类型
|
||||
export interface DateRangeValue {
|
||||
from?: Date
|
||||
to?: Date
|
||||
}
|
||||
|
||||
export interface DateRangePickerProps {
|
||||
value?: DateRangeValue
|
||||
onChange?: (value: DateRangeValue | undefined) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
numberOfMonths?: number
|
||||
clearable?: boolean
|
||||
}
|
||||
|
||||
export function DateRangePicker({
|
||||
value,
|
||||
onChange,
|
||||
placeholder = '选择日期范围',
|
||||
className,
|
||||
disabled = false,
|
||||
numberOfMonths = 2,
|
||||
clearable = false
|
||||
}: DateRangePickerProps) {
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [isHovered, setIsHovered] = React.useState(false)
|
||||
|
||||
const handleSelect = React.useCallback((range: DateRange | undefined) => {
|
||||
if (range) {
|
||||
onChange?.({
|
||||
from: range.from,
|
||||
to: range.to
|
||||
})
|
||||
} else {
|
||||
onChange?.(undefined)
|
||||
}
|
||||
}, [onChange])
|
||||
|
||||
const handleClear = React.useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
onChange?.(undefined)
|
||||
}, [onChange])
|
||||
|
||||
const formatDateRange = React.useCallback((dateRange?: DateRangeValue) => {
|
||||
if (!dateRange?.from) {
|
||||
return placeholder
|
||||
}
|
||||
|
||||
if (dateRange.to) {
|
||||
return `${format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })} 至 ${format(dateRange.to, 'yyyy-MM-dd', { locale: zhCN })}`
|
||||
}
|
||||
|
||||
return format(dateRange.from, 'yyyy-MM-dd', { locale: zhCN })
|
||||
}, [placeholder])
|
||||
|
||||
return (
|
||||
<div className={cn('grid gap-2', className)}>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<div
|
||||
className="relative"
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
<Button
|
||||
id="date"
|
||||
variant="outline"
|
||||
className={cn(
|
||||
'w-full justify-start text-left font-normal',
|
||||
!value?.from && 'text-muted-foreground',
|
||||
clearable && value?.from && 'pr-8'
|
||||
)}
|
||||
disabled={disabled}
|
||||
>
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{formatDateRange(value)}
|
||||
</Button>
|
||||
{clearable && value?.from && isHovered && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleClear}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 h-4 w-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none"
|
||||
disabled={disabled}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
<span className="sr-only">清空</span>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
autoFocus
|
||||
mode="range"
|
||||
defaultMonth={value?.from}
|
||||
selected={value ? { from: value.from, to: value.to } : undefined}
|
||||
onSelect={handleSelect}
|
||||
numberOfMonths={numberOfMonths}
|
||||
locale={zhCN}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
968
src/components/common/file-preview.tsx
Normal file
968
src/components/common/file-preview.tsx
Normal file
@@ -0,0 +1,968 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useState, useEffect, useRef } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import useEmblaCarousel from 'embla-carousel-react';
|
||||
import { useGesture } from '@use-gesture/react';
|
||||
import { motion, useMotionValue, useSpring, animate, useMotionValueEvent } from 'framer-motion';
|
||||
import {
|
||||
X,
|
||||
FileText,
|
||||
FileArchive,
|
||||
FileSpreadsheet,
|
||||
Image as ImageIcon,
|
||||
Video,
|
||||
Music,
|
||||
File,
|
||||
Download,
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
RotateCw,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
Maximize2,
|
||||
HelpCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
Plus,
|
||||
Minus,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Kbd } from '@/components/ui/kbd';
|
||||
import { cn, downloadFromFile } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 根据文件 MIME 类型返回对应的图标
|
||||
*/
|
||||
export const getFileIcon = (type: string | undefined) => {
|
||||
if (!!type) {
|
||||
if (type.startsWith('image/')) return <ImageIcon className="size-6" />;
|
||||
if (type.startsWith('video/')) return <Video className="size-6" />;
|
||||
if (type.startsWith('audio/')) return <Music className="size-6" />;
|
||||
if (type.includes('pdf')) return <FileText className="size-6" />;
|
||||
if (type.includes('word') || type.includes('doc')) return <FileText className="size-6" />;
|
||||
if (type.includes('excel') || type.includes('sheet')) return <FileSpreadsheet className="size-6" />;
|
||||
if (type.includes('zip') || type.includes('rar') || type.includes('7z')) return <FileArchive className="size-6" />;
|
||||
}
|
||||
return <File className="size-6" />;
|
||||
};
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
/**
|
||||
* 文件预览项的基础接口
|
||||
*/
|
||||
export interface FilePreviewItem {
|
||||
/** 文件唯一标识 */
|
||||
id: string;
|
||||
/** 文件名称 */
|
||||
name: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** 文件 MIME 类型 */
|
||||
type?: string;
|
||||
/** 预览 URL(用于图片预览) */
|
||||
preview?: string;
|
||||
/** 上传进度(0-100),undefined 表示未上传或已完成 */
|
||||
progress?: number;
|
||||
/** 浏览器 File 对象(可选,用于下载功能) */
|
||||
file?: File;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件卡片预览组件的属性
|
||||
*/
|
||||
export interface FileCardPreviewProps {
|
||||
/** 文件列表 */
|
||||
files: FilePreviewItem[];
|
||||
/** 外层容器的自定义类名 */
|
||||
className?: string;
|
||||
/** 网格容器的自定义类名 */
|
||||
gridClassName?: string;
|
||||
/** 是否禁用操作按钮 */
|
||||
disabled?: boolean;
|
||||
/** 是否显示下载按钮 */
|
||||
showDownload?: boolean;
|
||||
/** 是否显示删除按钮 */
|
||||
showRemove?: boolean;
|
||||
/** 是否显示文件信息(文件名和大小) */
|
||||
showFileInfo?: boolean;
|
||||
/** 删除文件的回调函数 */
|
||||
onRemove?: (id: string, file: FilePreviewItem) => void;
|
||||
/** 下载文件的回调函数(如果不提供,将使用默认的 downloadFromFile) */
|
||||
onDownload?: (id: string, file: FilePreviewItem) => void;
|
||||
/** 点击文件卡片的回调函数 */
|
||||
onClick?: (id: string, file: FilePreviewItem) => void;
|
||||
}
|
||||
|
||||
// ==================== 文件卡片预览组件 ====================
|
||||
|
||||
/**
|
||||
* 文件卡片预览组件
|
||||
*
|
||||
* 用于以卡片网格形式展示文件列表,支持图片预览、文件信息显示、下载和删除操作。
|
||||
*
|
||||
* 特性:
|
||||
* - 响应式网格布局(移动端 1 列,平板 2 列,桌面 3 列)
|
||||
* - 图片文件显示预览图,其他文件显示对应图标
|
||||
* - 支持上传进度显示(圆形进度条)
|
||||
* - PC 端悬停显示操作按钮和文件信息
|
||||
* - 移动端点击激活显示操作按钮和文件信息
|
||||
* - 可自定义是否显示下载、删除按钮和文件信息
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <FileCardPreview
|
||||
* files={files}
|
||||
* showDownload
|
||||
* showRemove
|
||||
* onRemove={(id) => console.log('Remove', id)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FileCardPreview({
|
||||
files,
|
||||
className,
|
||||
gridClassName,
|
||||
disabled = false,
|
||||
showDownload = true,
|
||||
showRemove = true,
|
||||
showFileInfo = true,
|
||||
onRemove,
|
||||
onDownload,
|
||||
onClick,
|
||||
}: FileCardPreviewProps) {
|
||||
const [activeFileId, setActiveFileId] = useState<string | null>(null);
|
||||
const [carouselOpen, setCarouselOpen] = useState(false);
|
||||
const [carouselIndex, setCarouselIndex] = useState(0);
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onRemove) {
|
||||
onRemove(id, fileItem);
|
||||
}
|
||||
setActiveFileId(null);
|
||||
},
|
||||
[files, onRemove]
|
||||
);
|
||||
|
||||
const handleDownload = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem) {
|
||||
if (onDownload) {
|
||||
onDownload(id, fileItem);
|
||||
} else if (fileItem.file) {
|
||||
downloadFromFile(fileItem.file);
|
||||
}
|
||||
}
|
||||
},
|
||||
[files, onDownload]
|
||||
);
|
||||
|
||||
const handlePreview = useCallback(
|
||||
(id: string, e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
const index = files.findIndex((f) => f.id === id);
|
||||
if (index !== -1) {
|
||||
setCarouselIndex(index);
|
||||
setCarouselOpen(true);
|
||||
}
|
||||
},
|
||||
[files]
|
||||
);
|
||||
|
||||
const handleFileClick = useCallback(
|
||||
(id: string) => {
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onClick) {
|
||||
onClick(id, fileItem);
|
||||
}
|
||||
setActiveFileId((prev) => (prev === id ? null : id));
|
||||
},
|
||||
[files, onClick]
|
||||
);
|
||||
|
||||
if (files.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'grid grid-cols-1 gap-1 sm:grid-cols-2 lg:grid-cols-3',
|
||||
gridClassName
|
||||
)}
|
||||
>
|
||||
{files.map((fileItem) => (
|
||||
<div
|
||||
key={fileItem.id}
|
||||
className="group relative aspect-square cursor-pointer p-1"
|
||||
onClick={() => handleFileClick(fileItem.id)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full overflow-hidden rounded-lg border bg-card transition-all',
|
||||
activeFileId === fileItem.id
|
||||
? 'border-primary ring-2 ring-primary ring-offset-2 shadow-lg'
|
||||
: 'md:group-hover:border-primary/50 md:group-hover:shadow-md'
|
||||
)}
|
||||
>
|
||||
{/* 文件预览区域 */}
|
||||
{fileItem.type?.startsWith('image/') && fileItem.preview ? (
|
||||
<img
|
||||
src={fileItem.preview}
|
||||
alt={fileItem.name}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center bg-muted text-muted-foreground">
|
||||
{getFileIcon(fileItem.type)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 上传进度指示器 */}
|
||||
{fileItem.progress !== undefined && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
|
||||
<div className="relative size-16">
|
||||
<svg className="size-full -rotate-90" viewBox="0 0 100 100">
|
||||
{/* 背景圆环 */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
className="text-white/20"
|
||||
/>
|
||||
{/* 进度圆环 */}
|
||||
<circle
|
||||
cx="50"
|
||||
cy="50"
|
||||
r="45"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="8"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${2 * Math.PI * 45}`}
|
||||
strokeDashoffset={`${2 * Math.PI * 45 * (1 - fileItem.progress / 100)}`}
|
||||
className="text-primary transition-all duration-300"
|
||||
/>
|
||||
</svg>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<span className="text-sm font-semibold text-white">
|
||||
{Math.round(fileItem.progress)}%
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PC端悬停或移动端点击后显示的操作按钮 */}
|
||||
{(showDownload || showRemove || fileItem.type?.startsWith('image/')) && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-0 flex items-center justify-center gap-2 rounded-lg bg-black/50 transition-opacity',
|
||||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100 pointer-events-none md:group-hover:pointer-events-auto'
|
||||
)}
|
||||
>
|
||||
{fileItem.type?.startsWith('image/') && (
|
||||
<Button
|
||||
onClick={(e) => handlePreview(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="预览"
|
||||
>
|
||||
<ZoomIn className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showDownload && (fileItem.file || onDownload) && (
|
||||
<Button
|
||||
onClick={(e) => handleDownload(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="下载"
|
||||
>
|
||||
<Download className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
{showRemove && onRemove && (
|
||||
<Button
|
||||
onClick={(e) => handleRemove(fileItem.id, e)}
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
disabled={disabled}
|
||||
className={cn(
|
||||
'size-7',
|
||||
activeFileId === fileItem.id && 'pointer-events-auto'
|
||||
)}
|
||||
title="删除"
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* PC端悬停或移动端点击后显示的文件信息 */}
|
||||
{showFileInfo && (
|
||||
<div
|
||||
className={cn(
|
||||
'absolute bottom-0 left-0 right-0 rounded-b-lg bg-gradient-to-t from-black/80 via-black/60 to-transparent p-2 pt-8 text-white transition-opacity',
|
||||
activeFileId === fileItem.id ? 'opacity-100' : 'opacity-0 md:group-hover:opacity-100'
|
||||
)}
|
||||
>
|
||||
<p className="truncate text-xs font-medium" title={fileItem.name}>
|
||||
{fileItem.name}
|
||||
</p>
|
||||
<p className="text-xs text-gray-300">
|
||||
{formatBytes(fileItem.size)}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 轮播预览 */}
|
||||
<FileCarouselPreview
|
||||
files={files}
|
||||
initialIndex={carouselIndex}
|
||||
open={carouselOpen}
|
||||
onClose={() => setCarouselOpen(false)}
|
||||
onDownload={onDownload}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ==================== 轮播预览组件 ====================
|
||||
|
||||
/**
|
||||
* 文件轮播预览组件的属性
|
||||
*/
|
||||
export interface FileCarouselPreviewProps {
|
||||
/** 文件列表 */
|
||||
files: FilePreviewItem[];
|
||||
/** 初始显示的文件索引 */
|
||||
initialIndex?: number;
|
||||
/** 是否打开预览 */
|
||||
open: boolean;
|
||||
/** 关闭预览的回调 */
|
||||
onClose: () => void;
|
||||
/** 下载文件的回调函数 */
|
||||
onDownload?: (id: string, file: FilePreviewItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件轮播预览组件
|
||||
*
|
||||
* 全屏图片查看器,支持缩放、旋转、拖拽、键盘导航等功能。
|
||||
*
|
||||
* 特性:
|
||||
* - Portal 渲染(避免 z-index 问题)
|
||||
* - ESC 键关闭,缩放、旋转、拖拽均有快捷键支持
|
||||
* - 缩放(按钮 + 滚轮)、旋转、拖拽移动(放大后)
|
||||
* - 下载图片
|
||||
* - 平滑动画
|
||||
* - 响应式设计
|
||||
* - 触摸友好
|
||||
* - ARIA 属性
|
||||
* - 键盘导航
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* const [open, setOpen] = useState(false);
|
||||
* const [index, setIndex] = useState(0);
|
||||
*
|
||||
* <FileCarouselPreview
|
||||
* files={files}
|
||||
* initialIndex={index}
|
||||
* open={open}
|
||||
* onClose={() => setOpen(false)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function FileCarouselPreview({
|
||||
files,
|
||||
initialIndex = 0,
|
||||
open,
|
||||
onClose,
|
||||
onDownload,
|
||||
}: FileCarouselPreviewProps) {
|
||||
const [mounted, setMounted] = useState(false);
|
||||
const [emblaRef, emblaApi] = useEmblaCarousel({
|
||||
startIndex: initialIndex,
|
||||
loop: false,
|
||||
duration: 20, // 设置切换动画时长(毫秒)
|
||||
});
|
||||
|
||||
const [currentIndex, setCurrentIndex] = useState(initialIndex);
|
||||
const [rotation, setRotation] = useState(0);
|
||||
const [showHelp, setShowHelp] = useState(false);
|
||||
const [currentScale, setCurrentScale] = useState(1);
|
||||
|
||||
// 使用 framer-motion 的 motion values 和 spring 动画
|
||||
const scaleMotion = useMotionValue(1);
|
||||
const xMotion = useMotionValue(0);
|
||||
const yMotion = useMotionValue(0);
|
||||
|
||||
// 使用 useSpring 包装 motion values,添加弹性物理效果
|
||||
const scale = useSpring(scaleMotion, { stiffness: 300, damping: 30 });
|
||||
const x = useSpring(xMotion, { stiffness: 300, damping: 30 });
|
||||
const y = useSpring(yMotion, { stiffness: 300, damping: 30 });
|
||||
|
||||
// 监听 scale 变化,更新 state 以触发按钮状态更新
|
||||
useMotionValueEvent(scale, "change", (latest) => {
|
||||
setCurrentScale(latest);
|
||||
});
|
||||
|
||||
const imageRef = useRef<HTMLDivElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const thumbnailRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 确保组件在客户端挂载
|
||||
useEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
// 监听 embla 选中事件
|
||||
useEffect(() => {
|
||||
if (!emblaApi) return;
|
||||
|
||||
const onSelect = () => {
|
||||
setCurrentIndex(emblaApi.selectedScrollSnap());
|
||||
// 切换图片时重置变换
|
||||
animate(scaleMotion, 1, { duration: 0.2 });
|
||||
animate(xMotion, 0, { duration: 0.2 });
|
||||
animate(yMotion, 0, { duration: 0.2 });
|
||||
setRotation(0);
|
||||
};
|
||||
|
||||
emblaApi.on('select', onSelect);
|
||||
onSelect();
|
||||
|
||||
return () => {
|
||||
emblaApi.off('select', onSelect);
|
||||
};
|
||||
}, [emblaApi, scaleMotion, xMotion, yMotion]);
|
||||
|
||||
// 禁用背景滚动
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
document.body.style.overflow = 'hidden';
|
||||
return () => {
|
||||
document.body.style.overflow = '';
|
||||
};
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 缩放功能
|
||||
const handleZoomIn = useCallback(() => {
|
||||
const current = scaleMotion.get();
|
||||
animate(scaleMotion, Math.min(current + 0.25, 5), { duration: 0.2 });
|
||||
}, [scaleMotion]);
|
||||
|
||||
const handleZoomOut = useCallback(() => {
|
||||
const current = scaleMotion.get();
|
||||
animate(scaleMotion, Math.max(current - 0.25, 0.5), { duration: 0.2 });
|
||||
}, [scaleMotion]);
|
||||
|
||||
const handleRotate = useCallback(() => {
|
||||
setRotation((prev) => (prev + 90) % 360);
|
||||
}, []);
|
||||
|
||||
const handleReset = useCallback(() => {
|
||||
animate(scaleMotion, 1, { duration: 0.2 });
|
||||
animate(xMotion, 0, { duration: 0.2 });
|
||||
animate(yMotion, 0, { duration: 0.2 });
|
||||
setRotation(0);
|
||||
}, [scaleMotion, xMotion, yMotion]);
|
||||
|
||||
|
||||
// 键盘事件处理
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
// 阻止事件冒泡,避免关闭父对话框
|
||||
switch (e.key) {
|
||||
case 'Escape':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
break;
|
||||
case 'ArrowLeft':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emblaApi?.scrollPrev();
|
||||
break;
|
||||
case 'ArrowRight':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
emblaApi?.scrollNext();
|
||||
break;
|
||||
case '+':
|
||||
case '=':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleZoomIn();
|
||||
break;
|
||||
case '-':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleZoomOut();
|
||||
break;
|
||||
case 'r':
|
||||
case 'R':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleRotate();
|
||||
break;
|
||||
case '0':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
handleReset();
|
||||
break;
|
||||
case '?':
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowHelp((prev) => !prev);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
// 使用 capture 阶段捕获事件,优先级更高
|
||||
window.addEventListener('keydown', handleKeyDown, true);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown, true);
|
||||
}, [open, emblaApi, onClose, handleZoomIn, handleZoomOut, handleRotate, handleReset]);
|
||||
|
||||
// 使用 use-gesture 处理所有手势
|
||||
// 注意:必须使用 target 选项才能正确使用 preventDefault
|
||||
useGesture(
|
||||
{
|
||||
// 拖拽手势 - 用于移动图片或切换图片
|
||||
onDrag: ({ offset: [ox, oy], active }) => {
|
||||
const currentScale = scale.get();
|
||||
|
||||
// 如果图片放大了,拖拽用于移动图片
|
||||
if (currentScale > 1) {
|
||||
x.set(ox);
|
||||
y.set(oy);
|
||||
}
|
||||
else if (!active) {
|
||||
// 重置位置,切换的逻辑embla-carousel-react处理,这里不用管
|
||||
animate(x, 0, { duration: 0.3 });
|
||||
animate(y, 0, { duration: 0.3 });
|
||||
}
|
||||
},
|
||||
|
||||
// 滚轮手势 - 用于缩放
|
||||
onWheel: ({ event, delta: [, dy], last }) => {
|
||||
// 避免在最后一个事件中访问 event(debounced)
|
||||
if (!last && event) {
|
||||
event.preventDefault();
|
||||
}
|
||||
const currentScale = scaleMotion.get();
|
||||
const scaleDelta = dy > 0 ? -0.1 : 0.1;
|
||||
const newScale = Math.max(0.5, Math.min(5, currentScale + scaleDelta));
|
||||
if (!last) {
|
||||
scaleMotion.set(newScale);
|
||||
}
|
||||
},
|
||||
|
||||
// 双指缩放手势 - 移动端
|
||||
onPinch: ({ offset: [s], origin: [ox, oy], first, memo, last }) => {
|
||||
if (first) {
|
||||
const currentScale = scaleMotion.get();
|
||||
const currentX = xMotion.get();
|
||||
const currentY = yMotion.get();
|
||||
return [currentScale, currentX, currentY, ox, oy];
|
||||
}
|
||||
|
||||
const [initialScale, initialX, initialY, initialOx, initialOy] = memo;
|
||||
const newScale = Math.max(0.5, Math.min(5, s));
|
||||
|
||||
// 计算缩放中心偏移
|
||||
if (imageRef.current) {
|
||||
const rect = imageRef.current.getBoundingClientRect();
|
||||
const centerX = rect.left + rect.width / 2;
|
||||
const centerY = rect.top + rect.height / 2;
|
||||
|
||||
// 相对于图片中心的偏移
|
||||
const offsetX = ox - centerX;
|
||||
const offsetY = oy - centerY;
|
||||
|
||||
// 根据缩放调整位置,使缩放中心保持在手指位置
|
||||
const scaleRatio = newScale / initialScale;
|
||||
xMotion.set(initialX + offsetX * (1 - scaleRatio));
|
||||
yMotion.set(initialY + offsetY * (1 - scaleRatio));
|
||||
}
|
||||
|
||||
if (!last) {
|
||||
scaleMotion.set(newScale);
|
||||
}
|
||||
return memo;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: imageRef,
|
||||
drag: {
|
||||
from: () => [x.get(), y.get()],
|
||||
bounds: (state) => {
|
||||
const currentScale = scale.get();
|
||||
if (currentScale <= 1) {
|
||||
// 未放大时,允许左右拖拽切换
|
||||
return { left: -200, right: 200, top: 0, bottom: 0 };
|
||||
}
|
||||
// 放大时不限制边界
|
||||
return { left: -Infinity, right: Infinity, top: -Infinity, bottom: Infinity };
|
||||
},
|
||||
},
|
||||
pinch: {
|
||||
scaleBounds: { min: 0.5, max: 5 },
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
wheel: {
|
||||
eventOptions: { passive: false },
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 缩略图容器的拖动手势
|
||||
useGesture(
|
||||
{
|
||||
onDrag: ({ movement: [mx], memo = thumbnailRef.current?.scrollLeft ?? 0 }) => {
|
||||
if (thumbnailRef.current) {
|
||||
thumbnailRef.current.scrollLeft = memo - mx;
|
||||
}
|
||||
return memo;
|
||||
},
|
||||
},
|
||||
{
|
||||
target: thumbnailRef,
|
||||
drag: {
|
||||
axis: 'x',
|
||||
filterTaps: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 下载当前文件
|
||||
const handleDownloadCurrent = useCallback(() => {
|
||||
const currentFile = files[currentIndex];
|
||||
if (currentFile) {
|
||||
if (onDownload) {
|
||||
onDownload(currentFile.id, currentFile);
|
||||
} else if (currentFile.file) {
|
||||
downloadFromFile(currentFile.file);
|
||||
}
|
||||
}
|
||||
}, [files, currentIndex, onDownload]);
|
||||
|
||||
|
||||
if (!mounted || !open) return null;
|
||||
|
||||
const currentFile = files[currentIndex];
|
||||
const canScrollPrev = emblaApi?.canScrollPrev() ?? false;
|
||||
const canScrollNext = emblaApi?.canScrollNext() ?? false;
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="fixed inset-0 z-70 flex items-center justify-center bg-black/95 pointer-events-auto"
|
||||
onKeyDown={(e) => {
|
||||
// 阻止键盘事件冒泡到父组件
|
||||
e.stopPropagation();
|
||||
}}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="文件预览"
|
||||
>
|
||||
{/* 顶部工具栏 */}
|
||||
<div
|
||||
className="absolute top-0 left-0 right-0 z-10 flex items-start gap-2 p-4 bg-gradient-to-b from-black/50 to-transparent pointer-events-none"
|
||||
>
|
||||
<span className="text-sm font-medium text-white shrink-0">
|
||||
{currentIndex + 1} / {files.length}
|
||||
</span>
|
||||
|
||||
{currentFile && (
|
||||
<span className="text-sm text-gray-300 break-words flex-1 min-w-0">
|
||||
{currentFile.name}
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2 shrink-0 pointer-events-auto">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
setShowHelp((prev) => !prev);
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="快捷键帮助 (?)"
|
||||
>
|
||||
<HelpCircle className='size-5'/>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
onClose();
|
||||
}}
|
||||
className="text-white hover:bg-white/20"
|
||||
title="关闭 (ESC)"
|
||||
>
|
||||
<X className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 快捷键帮助面板 */}
|
||||
{showHelp && (
|
||||
<div
|
||||
className="absolute top-20 right-4 z-20 bg-black/90 text-white p-4 rounded-lg text-sm space-y-3 max-w-xs pointer-events-none"
|
||||
>
|
||||
<h3 className="font-semibold mb-2">键盘快捷键</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">ESC</Kbd>
|
||||
<span className="text-gray-300">关闭</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex gap-1">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<ArrowLeft className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<ArrowRight className="size-3" />
|
||||
</Kbd>
|
||||
</div>
|
||||
<span className="text-gray-300">切换图片</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<div className="flex gap-1">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<Plus className="size-3" />
|
||||
</Kbd>
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">
|
||||
<Minus className="size-3" />
|
||||
</Kbd>
|
||||
</div>
|
||||
<span className="text-gray-300">缩放</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">R</Kbd>
|
||||
<span className="text-gray-300">旋转</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">0</Kbd>
|
||||
<span className="text-gray-300">重置</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center gap-4">
|
||||
<Kbd className="bg-white/10 text-white border border-white/20">?</Kbd>
|
||||
<span className="text-gray-300">显示/隐藏帮助</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 左侧工具栏 */}
|
||||
<div className="absolute left-4 top-1/2 -translate-y-1/2 z-10 flex flex-col gap-2">
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleZoomIn();
|
||||
}}
|
||||
disabled={currentScale >= 5}
|
||||
title="放大 (+)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<ZoomIn className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleZoomOut();
|
||||
}}
|
||||
disabled={currentScale <= 0.5}
|
||||
title="缩小 (-)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<ZoomOut className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleRotate();
|
||||
}}
|
||||
title="旋转 (R)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<RotateCw className="size-5" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleReset();
|
||||
}}
|
||||
title="重置 (0)"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<Maximize2 className="size-5" />
|
||||
</Button>
|
||||
{(currentFile?.file || onDownload) && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={(e) => {
|
||||
handleDownloadCurrent();
|
||||
}}
|
||||
title="下载"
|
||||
className="bg-black/50 hover:bg-black/70 text-white"
|
||||
>
|
||||
<Download className="size-5" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 轮播容器 */}
|
||||
<div className="relative w-full h-full flex items-center justify-center">
|
||||
<div ref={emblaRef} className="overflow-hidden w-full h-full">
|
||||
<div className="flex h-full gap-8">
|
||||
{files.map((file, index) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex-[0_0_100%] min-w-0 flex items-center justify-center"
|
||||
>
|
||||
<div
|
||||
ref={index === currentIndex ? imageRef : null}
|
||||
className="relative flex items-center justify-center w-full h-full touch-none"
|
||||
style={{
|
||||
cursor: index === currentIndex && scale.get() > 1 ? 'grab' : 'default',
|
||||
}}
|
||||
>
|
||||
{file.type?.startsWith('image/') && file.preview ? (
|
||||
<motion.img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="max-w-full max-h-full object-contain select-none"
|
||||
style={
|
||||
index === currentIndex
|
||||
? {
|
||||
scale,
|
||||
x,
|
||||
y,
|
||||
rotate: rotation,
|
||||
}
|
||||
: {}
|
||||
}
|
||||
draggable={false}
|
||||
/>
|
||||
) : (
|
||||
<div className="flex flex-col items-center justify-center gap-4 text-white">
|
||||
{getFileIcon(file.type)}
|
||||
<div className="text-center">
|
||||
<p className="text-lg font-medium">{file.name}</p>
|
||||
<p className="text-sm text-gray-400">{formatBytes(file.size)}</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* 底部导航区域:左右切换按钮 + 缩略图 */}
|
||||
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 z-10 flex items-center gap-2">
|
||||
{/* 左切换按钮 */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => emblaApi?.scrollPrev()}
|
||||
disabled={!canScrollPrev}
|
||||
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="上一张 (←)"
|
||||
>
|
||||
<ChevronLeft className="size-5" />
|
||||
</Button>
|
||||
|
||||
{/* 缩略图导航 */}
|
||||
<div
|
||||
ref={thumbnailRef}
|
||||
className="flex gap-2 max-w-[70vw] overflow-x-auto scrollbar-muted p-2 bg-black/50 rounded-lg cursor-grab active:cursor-grabbing touch-pan-x"
|
||||
>
|
||||
{files.map((file, index) => (
|
||||
<button
|
||||
key={file.id}
|
||||
onClick={() => emblaApi?.scrollTo(index)}
|
||||
className={cn(
|
||||
'relative size-16 flex-shrink-0 rounded overflow-hidden border-2 transition-all cursor-pointer select-none',
|
||||
index === currentIndex
|
||||
? 'border-primary ring-2 ring-primary'
|
||||
: 'border-transparent hover:border-white/50'
|
||||
)}
|
||||
title={file.name}
|
||||
>
|
||||
{file.type?.startsWith('image/') && file.preview ? (
|
||||
<img
|
||||
src={file.preview}
|
||||
alt={file.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center bg-muted text-muted-foreground">
|
||||
{getFileIcon(file.type)}
|
||||
</div>
|
||||
)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 右切换按钮 */}
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="icon"
|
||||
onClick={() => emblaApi?.scrollNext()}
|
||||
disabled={!canScrollNext}
|
||||
className="size-10 rounded-full bg-black/50 hover:bg-black/70 text-white flex-shrink-0 disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
title="下一张 (→)"
|
||||
>
|
||||
<ChevronRight className="size-5" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>,
|
||||
document.body
|
||||
);
|
||||
}
|
||||
508
src/components/common/file-upload-provider.tsx
Normal file
508
src/components/common/file-upload-provider.tsx
Normal file
@@ -0,0 +1,508 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useCallback, type ReactNode, useEffect, useImperativeHandle, forwardRef, type Ref, useRef } from 'react';
|
||||
import type { FileRejection } from 'react-dropzone';
|
||||
import imageCompression from 'browser-image-compression';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { useCallbackRef } from '@/hooks/use-callback-ref';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
const translateErrorMessage = (message: string, code?: string): string => {
|
||||
switch (code) {
|
||||
case 'file-invalid-type':
|
||||
return '文件类型不支持';
|
||||
case 'file-too-large':
|
||||
return '文件过大';
|
||||
case 'file-too-small':
|
||||
return '文件过小';
|
||||
case 'too-many-files':
|
||||
return '文件数量超过限制';
|
||||
default:
|
||||
return message;
|
||||
}
|
||||
};
|
||||
|
||||
// ==================== 类型定义 ====================
|
||||
|
||||
export interface UploadFileItem {
|
||||
id: string; // 客户端本地维护的文件唯一标识
|
||||
file?: File; // 浏览器文件对象
|
||||
preview?: string; // URL.createObjectURL 创建的预览URL
|
||||
progress?: number; // 文件上传服务器进度
|
||||
objectName?: string; // 文件上传之后服务器上的文件标识,用于表单提交
|
||||
}
|
||||
|
||||
export interface ImageCompressionOptions {
|
||||
compress?: boolean;
|
||||
maxWidthOrHeight?: number;
|
||||
fileType?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 暴露给父组件的 ref 方法接口
|
||||
*/
|
||||
export interface FileUploadRef {
|
||||
/** 获取当前已上传的文件列表 */
|
||||
getFiles: () => UploadFileItem[];
|
||||
/** 获取上传过程中产生的错误信息列表 */
|
||||
getErrors: () => string[];
|
||||
/** 添加文件到上传列表(会处理图片压缩和预览生成) */
|
||||
addFiles: (files: File[]) => void;
|
||||
/** 从上传列表中移除指定 ID 列表的文件 */
|
||||
removeFiles: (ids: string[]) => void;
|
||||
/** 清空所有错误信息 */
|
||||
clearErrors: () => void;
|
||||
/** 清空所有上传的文件,同时清空错误信息 */
|
||||
clear: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件上传组件的 Context 值类型定义
|
||||
*/
|
||||
export interface FileUploadContextValue {
|
||||
/** 当前已上传的文件列表 */
|
||||
files: UploadFileItem[];
|
||||
/** 上传过程中产生的错误信息列表 */
|
||||
errors: string[];
|
||||
/** 允许上传的最大文件数量 */
|
||||
maxFiles: number;
|
||||
/** 单个文件的最大大小(字节) */
|
||||
maxSize: number;
|
||||
/** 允许上传的文件类型,MIME 类型列表,例如 ['image/png', 'image/jpeg'] */
|
||||
accept?: string[];
|
||||
/** 是否允许多文件上传 */
|
||||
multiple: boolean;
|
||||
/** 是否禁用整个上传组件 */
|
||||
disabled: boolean;
|
||||
/** 上传按钮是否应该被禁用(基于文件数量限制和 disabled 状态计算得出) */
|
||||
isUploadDisabled: boolean;
|
||||
/** 图片压缩选项配置 */
|
||||
imageOptions?: ImageCompressionOptions;
|
||||
/** 添加文件到上传列表(会处理图片压缩和预览生成) */
|
||||
addFiles: (files: File[]) => void;
|
||||
/** 从上传列表中移除指定 ID 列表的文件 */
|
||||
removeFiles: (ids: string[]) => void;
|
||||
/** 添加一条错误信息到错误列表 */
|
||||
addError: (error: string) => void;
|
||||
/** 清空所有错误信息 */
|
||||
clearErrors: () => void;
|
||||
/** 清空所有上传的文件 */
|
||||
clear: () => void;
|
||||
/** 处理文件拖放事件,包含文件验证和错误处理逻辑 */
|
||||
handleFileDrop: (acceptedFiles: File[], fileRejections: FileRejection[]) => Promise<void>;
|
||||
}
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
const FileUploadContext = createContext<FileUploadContextValue | undefined>(undefined);
|
||||
|
||||
export const useFileUploadContext = () => {
|
||||
const context = useContext(FileUploadContext);
|
||||
if (!context) {
|
||||
throw new Error('FileUpload 子组件必须在 FileUpload 组件内使用');
|
||||
}
|
||||
return context;
|
||||
};
|
||||
|
||||
// ==================== 组件 ====================
|
||||
|
||||
export interface FileUploadProps {
|
||||
maxFiles?: number;
|
||||
maxSize?: number;
|
||||
accept?: string[];
|
||||
multiple?: boolean;
|
||||
disabled?: boolean;
|
||||
imageOptions?: ImageCompressionOptions;
|
||||
onFilesChange?: (files: UploadFileItem[]) => void;
|
||||
/**
|
||||
* 可选的文件上传处理器函数,用于将文件上传到服务器
|
||||
*
|
||||
* @param files - 待上传的文件映射对象,key 为文件的唯一标识 ID,value 为 File 对象
|
||||
* @param onProgress - 进度回调函数,用于更新每个文件的上传进度
|
||||
* - key: 文件的唯一标识 ID(与 files 参数中的 key 对应)
|
||||
* - progress: 上传进度百分比(0-100)
|
||||
* @returns 当文件上传完毕时返回上传结果映射对象
|
||||
* - key: 文件的唯一标识 ID(与 files 参数中的 key 对应)
|
||||
* - value: 上传成功后服务器返回的文件标识(objectName),用于后续访问文件
|
||||
* ```
|
||||
*/
|
||||
uploadFilesHandler?: (
|
||||
files: Record<string, File>,
|
||||
onProgress: (key: string, progress: number) => void
|
||||
) => Promise<Record<string, string>>;
|
||||
/**
|
||||
* 可选的文件下载处理器函数,用于从服务器下载文件
|
||||
*
|
||||
* @param objectNames - 待下载的文件标识列表
|
||||
* @param onProgress - 进度回调函数,用于更新每个文件的下载进度
|
||||
* - objectName: 文件的服务器标识
|
||||
* - progress: 下载进度百分比(0-100)
|
||||
* @returns 当文件下载完毕时返回 File 对象映射,key 为 objectName,value 为 File 对象
|
||||
*/
|
||||
downloadFilesHandler?: (
|
||||
objectNames: string[],
|
||||
onProgress: (objectName: string, progress: number) => void
|
||||
) => Promise<Record<string, File>>;
|
||||
value?: string[]; // 已上传的objectName列表,用于和表单集成,需要配合downloadFilesHandler使用
|
||||
onChange?: (value: string[]) => void; // 变更回调,参数为已上传文件的objectname列表,用于和表单集成
|
||||
children?: ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export const FileUpload = forwardRef(function FileUpload({
|
||||
maxFiles = 10,
|
||||
maxSize = 50 * 1024 * 1024,
|
||||
accept,
|
||||
multiple = true,
|
||||
disabled = false,
|
||||
imageOptions,
|
||||
onFilesChange,
|
||||
uploadFilesHandler,
|
||||
downloadFilesHandler,
|
||||
value,
|
||||
onChange,
|
||||
children,
|
||||
className,
|
||||
}: FileUploadProps, ref: Ref<FileUploadRef>) {
|
||||
const [files, setFiles] = useState<UploadFileItem[]>([]);
|
||||
const [errors, setErrors] = useState<string[]>([]);
|
||||
// 用于跟踪处理中的 value,避免重复添加
|
||||
const processingValueRef = useRef<Set<string>>(new Set());
|
||||
|
||||
const addError = useCallback((error: string) => {
|
||||
setErrors((prev) => [...prev, error]);
|
||||
}, []);
|
||||
|
||||
const clearErrors = useCallback(() => {
|
||||
setErrors([]);
|
||||
}, []);
|
||||
|
||||
const clear = useCallback(() => {
|
||||
// 清理所有预览 URL
|
||||
files.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
setFiles([]);
|
||||
setErrors([]);
|
||||
onFilesChange?.([]);
|
||||
onChange?.([]);
|
||||
}, [files, onFilesChange, onChange]);
|
||||
|
||||
const removeFiles = useCallback(
|
||||
(ids: string[]) => {
|
||||
setFiles((prev) => {
|
||||
const filesToRemove = prev.filter((f) => ids.includes(f.id));
|
||||
// 清理预览 URL
|
||||
filesToRemove.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
const updated = prev.filter((f) => !ids.includes(f.id));
|
||||
onFilesChange?.(updated);
|
||||
|
||||
// 调用 onChange 回调,返回 objectName 列表
|
||||
if (onChange) {
|
||||
const objectNames = updated
|
||||
.map((f) => f.objectName)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
queueMicrotask(() => onChange(objectNames));
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
},
|
||||
[onFilesChange, onChange]
|
||||
);
|
||||
|
||||
const addFiles = useCallback(
|
||||
async (newFiles: File[]) => {
|
||||
// 确定要添加的文件
|
||||
let filesToAdd: File[];
|
||||
let shouldReplaceAll = false;
|
||||
|
||||
if (!multiple) {
|
||||
// 当 multiple 为 false 时,清空现有文件并只保留第一个新文件
|
||||
filesToAdd = newFiles.slice(0, 1);
|
||||
shouldReplaceAll = true;
|
||||
|
||||
// 清理现有文件的预览 URL
|
||||
files.forEach((f) => {
|
||||
if (f.preview) {
|
||||
URL.revokeObjectURL(f.preview);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// multiple 为 true 时,检查文件数量限制
|
||||
const remainingSlots = maxFiles - files.length;
|
||||
if (remainingSlots <= 0) {
|
||||
return; // 已达到最大文件数量
|
||||
}
|
||||
// 限制新文件数量不超过剩余槽位
|
||||
filesToAdd = newFiles.slice(0, remainingSlots);
|
||||
}
|
||||
|
||||
const processedFiles = await Promise.all(
|
||||
filesToAdd.map(async (file) => {
|
||||
// 对图片文件进行压缩处理
|
||||
if (file.type.startsWith('image/') && imageOptions?.compress) {
|
||||
try {
|
||||
const options = {
|
||||
maxSizeMB: maxSize / (1024 * 1024), // 转换为 MB
|
||||
maxWidthOrHeight: imageOptions.maxWidthOrHeight ?? 1920,
|
||||
fileType: imageOptions.fileType ?? 'image/webp',
|
||||
useWebWorker: true,
|
||||
libURL: '/js/browser-image-compression.js',
|
||||
};
|
||||
|
||||
const compressedFile = await imageCompression(file, options);
|
||||
return compressedFile;
|
||||
} catch (error) {
|
||||
console.error('图片压缩失败:', error);
|
||||
return file; // 压缩失败时返回原文件
|
||||
}
|
||||
}
|
||||
return file;
|
||||
})
|
||||
);
|
||||
|
||||
// 创建上传文件项(统一逻辑)
|
||||
const uploadFiles: UploadFileItem[] = processedFiles.map((file) => ({
|
||||
id: `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`,
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
|
||||
}));
|
||||
|
||||
// 更新文件列表
|
||||
const updatedFiles = shouldReplaceAll ? uploadFiles : [...files, ...uploadFiles];
|
||||
setFiles(updatedFiles);
|
||||
onFilesChange?.(updatedFiles);
|
||||
|
||||
|
||||
// 如果提供了 uploadFilesHandler,则调用它上传文件
|
||||
if (uploadFilesHandler) {
|
||||
// 定义进度更新回调
|
||||
const onProgress = (key: string, progress: number) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item) =>
|
||||
item.id === key ? { ...item, progress } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 构建文件映射对象,并更新progress为0
|
||||
const filesMap: Record<string, File> = {};
|
||||
uploadFiles.forEach((item) => {
|
||||
filesMap[item.id] = item.file!;
|
||||
onProgress(item.id, 0)
|
||||
});
|
||||
|
||||
try {
|
||||
// 调用上传处理器
|
||||
const objectNames = await uploadFilesHandler(filesMap, onProgress);
|
||||
|
||||
// 更新文件的 objectName,上传成功后清除进度
|
||||
setFiles((prev) => {
|
||||
const updated = prev.map((item) =>
|
||||
objectNames[item.id]
|
||||
? { ...item, objectName: objectNames[item.id], progress: undefined }
|
||||
: item
|
||||
);
|
||||
onFilesChange?.(updated);
|
||||
|
||||
// 调用 onChange 回调,返回 objectName 列表
|
||||
if (onChange) {
|
||||
const uploadedObjectNames = updated
|
||||
.map((f) => f.objectName)
|
||||
.filter((name): name is string => name !== undefined);
|
||||
queueMicrotask(() => onChange(uploadedObjectNames));
|
||||
}
|
||||
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
// 上传失败时,添加错误信息并移除失败的文件
|
||||
const errorMessage = error instanceof Error ? error.message : '文件上传失败';
|
||||
addError(errorMessage);
|
||||
|
||||
// 移除上传失败的文件
|
||||
removeFiles(uploadFiles.map((uf) => uf.id));
|
||||
}
|
||||
}
|
||||
},
|
||||
[files, onFilesChange, maxSize, imageOptions, maxFiles, multiple, uploadFilesHandler, addError, removeFiles, onChange]
|
||||
);
|
||||
|
||||
// 使用 useCallbackRef 包装需要访问最新 files 的逻辑
|
||||
const processValue = useCallbackRef(() => {
|
||||
if (!downloadFilesHandler) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 获取当前 files 中的 objectName 集合
|
||||
const currentObjectNames = new Set(
|
||||
files.map((f) => f.objectName).filter((name): name is string => name !== undefined)
|
||||
);
|
||||
|
||||
// 如果 value 是数组,删除 files 中不在 value 中的文件
|
||||
if (Array.isArray(value)) {
|
||||
const valueSet = new Set(value);
|
||||
const filesToRemove = files.filter(
|
||||
(f) => f.objectName && !valueSet.has(f.objectName)
|
||||
);
|
||||
|
||||
if (filesToRemove.length > 0) {
|
||||
removeFiles(filesToRemove.map((f) => f.id));
|
||||
}
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
// 找出需要下载的文件(在 value 中但不在 files 和已处理记录中)
|
||||
const missingValues = value.filter(
|
||||
(name) => !currentObjectNames.has(name) && !processingValueRef.current.has(name)
|
||||
);
|
||||
|
||||
if (missingValues.length === 0) {
|
||||
return;
|
||||
}
|
||||
missingValues.forEach((name) => processingValueRef.current.add(name));
|
||||
|
||||
// 立即为缺失的文件创建占位项
|
||||
const placeholderItems: UploadFileItem[] = missingValues.map((objectName) => ({
|
||||
id: objectName,
|
||||
objectName,
|
||||
progress: 0,
|
||||
}));
|
||||
|
||||
setFiles((prev) => [...prev, ...placeholderItems]);
|
||||
|
||||
// 定义下载进度回调
|
||||
const onProgress = (objectName: string, progress: number) => {
|
||||
setFiles((prev) =>
|
||||
prev.map((item) =>
|
||||
item.objectName === objectName ? { ...item, progress } : item
|
||||
)
|
||||
);
|
||||
};
|
||||
|
||||
// 异步下载文件
|
||||
const downloadFiles = async () => {
|
||||
try {
|
||||
// 调用下载处理器
|
||||
const downloadedFilesMap = await downloadFilesHandler(missingValues, onProgress);
|
||||
|
||||
// 更新文件对象和预览,使用 objectName 匹配
|
||||
setFiles((prev) => {
|
||||
const updated = prev.map((item) => {
|
||||
if (!item.objectName || !downloadedFilesMap[item.objectName]) {
|
||||
return item;
|
||||
}
|
||||
|
||||
const file = downloadedFilesMap[item.objectName];
|
||||
return {
|
||||
...item,
|
||||
file,
|
||||
preview: file.type.startsWith('image/') ? URL.createObjectURL(file) : undefined,
|
||||
progress: undefined,
|
||||
};
|
||||
});
|
||||
|
||||
onFilesChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} catch (error) {
|
||||
// 下载失败时,移除占位项
|
||||
const errorMessage = error instanceof Error ? error.message : '文件下载失败';
|
||||
addError(errorMessage);
|
||||
|
||||
setFiles((prev) => {
|
||||
const updated = prev.filter((item) => !missingValues.includes(item.objectName!));
|
||||
onFilesChange?.(updated);
|
||||
return updated;
|
||||
});
|
||||
} finally {
|
||||
missingValues.forEach((name) => processingValueRef.current.delete(name));
|
||||
}
|
||||
};
|
||||
|
||||
void downloadFiles();
|
||||
});
|
||||
|
||||
// 处理 value,通过副作用实现受控模式
|
||||
useEffect(() => {
|
||||
processValue();
|
||||
}, [value, processValue]);
|
||||
|
||||
// 共享的文件处理逻辑
|
||||
const handleFileDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[]) => {
|
||||
clearErrors();
|
||||
|
||||
if (fileRejections.length > 0) {
|
||||
const error = fileRejections.at(0)?.errors.at(0);
|
||||
const translatedMessage = translateErrorMessage(error?.message || '', error?.code);
|
||||
addError(translatedMessage);
|
||||
return;
|
||||
}
|
||||
|
||||
// 自定义文件大小校验(针对非压缩图片或非图片文件)
|
||||
const oversizedFiles = acceptedFiles.filter((file) => {
|
||||
// 如果是图片且启用了压缩,跳过大小检查(压缩后会符合要求)
|
||||
if (file.type.startsWith('image/') && imageOptions?.compress) {
|
||||
return false;
|
||||
}
|
||||
return file.size > maxSize;
|
||||
});
|
||||
|
||||
if (oversizedFiles.length > 0) {
|
||||
addError(`文件过大: ${oversizedFiles[0].name} (${formatBytes(oversizedFiles[0].size)})`);
|
||||
return;
|
||||
}
|
||||
|
||||
await addFiles(acceptedFiles);
|
||||
},
|
||||
[addFiles, addError, clearErrors, maxSize, imageOptions]
|
||||
);
|
||||
|
||||
// 计算上传按钮是否应该被禁用
|
||||
const isUploadDisabled = disabled || (!multiple && files.length >= 1) || (multiple && files.length >= maxFiles);
|
||||
|
||||
const contextValue: FileUploadContextValue = {
|
||||
files,
|
||||
errors,
|
||||
maxFiles,
|
||||
maxSize,
|
||||
accept,
|
||||
multiple,
|
||||
disabled,
|
||||
isUploadDisabled,
|
||||
imageOptions,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
addError,
|
||||
clearErrors,
|
||||
clear,
|
||||
handleFileDrop,
|
||||
};
|
||||
|
||||
// 暴露指定方法给父组件
|
||||
useImperativeHandle(ref, () => ({
|
||||
getFiles: () => files,
|
||||
getErrors: () => errors,
|
||||
addFiles,
|
||||
removeFiles,
|
||||
clearErrors,
|
||||
clear,
|
||||
}), [files, errors, addFiles, removeFiles, clearErrors, clear]);
|
||||
|
||||
return (
|
||||
<FileUploadContext.Provider value={contextValue}>
|
||||
<div className={cn('w-full space-y-4', className)}>{children}</div>
|
||||
</FileUploadContext.Provider>
|
||||
);
|
||||
});
|
||||
335
src/components/common/file-upload.tsx
Normal file
335
src/components/common/file-upload.tsx
Normal file
@@ -0,0 +1,335 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, forwardRef, type ReactNode, type ComponentPropsWithoutRef, type Ref } from 'react';
|
||||
import type { DropEvent, FileRejection } from 'react-dropzone';
|
||||
import { useDropzone } from 'react-dropzone';
|
||||
import {
|
||||
Upload,
|
||||
AlertCircle,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { FileUpload, FileUploadRef, type UploadFileItem, useFileUploadContext } from './file-upload-provider';
|
||||
import { formatBytes } from '@/lib/format';
|
||||
import { FileCardPreview, type FilePreviewItem } from './file-preview';
|
||||
|
||||
// ==================== 工具函数 ====================
|
||||
|
||||
/**
|
||||
* 将 MIME 类型数组转换为 react-dropzone 需要的 accept 格式
|
||||
* @param mimeTypes MIME 类型数组,例如 ['image/png', 'image/jpeg']
|
||||
* @returns react-dropzone 的 accept 对象,例如 { 'image/png': [], 'image/jpeg': [] }
|
||||
*/
|
||||
const convertAcceptFormat = (mimeTypes?: string[]): Record<string, string[]> | undefined => {
|
||||
if (!mimeTypes || mimeTypes.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
return mimeTypes.reduce((acc, mimeType) => {
|
||||
acc[mimeType] = [];
|
||||
return acc;
|
||||
}, {} as Record<string, string[]>);
|
||||
};
|
||||
|
||||
// ==================== 约束信息组件 ====================
|
||||
|
||||
export interface UploadConstraintsProps {
|
||||
className?: string;
|
||||
formatter?: (params: {
|
||||
accept?: string[];
|
||||
maxSize: number;
|
||||
maxFiles: number;
|
||||
multiple: boolean;
|
||||
}) => string;
|
||||
}
|
||||
|
||||
export function UploadConstraints({ className, formatter }: UploadConstraintsProps) {
|
||||
const { maxFiles, maxSize, accept, multiple } = useFileUploadContext();
|
||||
|
||||
// 默认格式化函数
|
||||
const defaultFormatter = useCallback(
|
||||
(params: {
|
||||
accept?: string[];
|
||||
maxSize: number;
|
||||
maxFiles: number;
|
||||
multiple: boolean;
|
||||
}) => {
|
||||
let text = '';
|
||||
if (params.accept && params.accept.length > 0) {
|
||||
text += '支持 ';
|
||||
text += new Intl.ListFormat('zh-CN').format(params.accept);
|
||||
text += ' • ';
|
||||
}
|
||||
text += `最大 ${formatBytes(params.maxSize)}`;
|
||||
if (params.multiple) {
|
||||
text += ` • 最多 ${params.maxFiles} 个文件`;
|
||||
}
|
||||
return text;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const constraintsText = (formatter || defaultFormatter)({
|
||||
accept,
|
||||
maxSize,
|
||||
maxFiles,
|
||||
multiple,
|
||||
});
|
||||
|
||||
return <p className={cn('text-xs text-muted-foreground whitespace-normal break-words', className)}>{constraintsText}</p>;
|
||||
}
|
||||
|
||||
// ==================== 上传按钮组件 ====================
|
||||
|
||||
export interface UploadDropzoneButtonProps {
|
||||
className?: string;
|
||||
children?: ReactNode;
|
||||
constraints?: ReactNode;
|
||||
}
|
||||
|
||||
export function UploadDropzoneButton({
|
||||
className,
|
||||
children,
|
||||
constraints,
|
||||
}: UploadDropzoneButtonProps) {
|
||||
const { maxFiles, accept, multiple, isUploadDisabled, handleFileDrop } = useFileUploadContext();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[], _event: DropEvent) => {
|
||||
await handleFileDrop(acceptedFiles, fileRejections);
|
||||
},
|
||||
[handleFileDrop]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps, isDragActive } = useDropzone({
|
||||
accept: convertAcceptFormat(accept),
|
||||
maxFiles,
|
||||
multiple,
|
||||
disabled: isUploadDisabled,
|
||||
onDrop: handleDrop,
|
||||
// 不传递 maxSize 给 useDropzone,使用自定义校验
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'relative h-auto w-full flex-col overflow-hidden p-4 lg:p-6 xl:p-8',
|
||||
isDragActive && 'outline-none ring-1 ring-ring',
|
||||
className
|
||||
)}
|
||||
disabled={isUploadDisabled}
|
||||
type="button"
|
||||
variant="outline"
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} disabled={isUploadDisabled} />
|
||||
{children ? (
|
||||
children
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-2">
|
||||
<div className="flex size-12 items-center justify-center rounded-full bg-muted">
|
||||
<Upload className="size-5 text-muted-foreground" />
|
||||
</div>
|
||||
<div className="w-full space-y-1 text-center">
|
||||
<p className="text-sm font-medium">拖拽文件到此处或点击上传</p>
|
||||
{constraints}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 简单上传按钮组件 ====================
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
|
||||
export interface UploadButtonProps extends Omit<ComponentPropsWithoutRef<typeof Button>, 'type'> {}
|
||||
|
||||
export function UploadButton({
|
||||
className,
|
||||
children,
|
||||
variant = 'default',
|
||||
size = 'default',
|
||||
...props
|
||||
}: UploadButtonProps) {
|
||||
const { maxFiles, accept, multiple, isUploadDisabled, handleFileDrop } = useFileUploadContext();
|
||||
|
||||
const handleDrop = useCallback(
|
||||
async (acceptedFiles: File[], fileRejections: FileRejection[], _event: DropEvent) => {
|
||||
await handleFileDrop(acceptedFiles, fileRejections);
|
||||
},
|
||||
[handleFileDrop]
|
||||
);
|
||||
|
||||
const { getRootProps, getInputProps } = useDropzone({
|
||||
accept: convertAcceptFormat(accept),
|
||||
maxFiles,
|
||||
multiple,
|
||||
disabled: isUploadDisabled,
|
||||
onDrop: handleDrop,
|
||||
noClick: false,
|
||||
noDrag: true, // 移动端禁用拖拽功能
|
||||
// 不传递 maxSize 给 useDropzone,使用自定义校验
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={className}
|
||||
disabled={isUploadDisabled}
|
||||
type="button"
|
||||
variant={variant}
|
||||
size={size}
|
||||
{...props}
|
||||
{...getRootProps()}
|
||||
>
|
||||
<input {...getInputProps()} disabled={isUploadDisabled} />
|
||||
{children || (
|
||||
<>
|
||||
<Upload className="mr-2 size-4" />
|
||||
上传文件
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 预览区域组件 ====================
|
||||
|
||||
export interface UploadCardPreviewProps {
|
||||
className?: string;
|
||||
gridClassName?: string;
|
||||
onRemove?: (id: string, file: UploadFileItem) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* 上传文件卡片预览组件
|
||||
*
|
||||
* 基于 FileCardPreview 组件,集成了 FileUpload Context 的状态管理。
|
||||
* 用于在文件上传组件中展示已上传的文件列表。
|
||||
*/
|
||||
export function UploadCardPreview({
|
||||
className,
|
||||
gridClassName,
|
||||
onRemove,
|
||||
}: UploadCardPreviewProps) {
|
||||
const { files, disabled, removeFiles } = useFileUploadContext();
|
||||
|
||||
// 将 UploadFileItem 转换为 FilePreviewItem
|
||||
const previewFiles: FilePreviewItem[] = files.map((item) => ({
|
||||
id: item.id,
|
||||
name: item.file?.name || '',
|
||||
size: item.file?.size || 0,
|
||||
type: item.file?.type,
|
||||
preview: item.preview,
|
||||
progress: item.progress,
|
||||
file: item.file,
|
||||
}));
|
||||
|
||||
const handleRemove = useCallback(
|
||||
(id: string, file: FilePreviewItem) => {
|
||||
const fileItem = files.find((f) => f.id === id);
|
||||
if (fileItem && onRemove) {
|
||||
onRemove(id, fileItem);
|
||||
}
|
||||
removeFiles([id]);
|
||||
},
|
||||
[files, removeFiles, onRemove]
|
||||
);
|
||||
|
||||
return (
|
||||
<FileCardPreview
|
||||
files={previewFiles}
|
||||
className={className}
|
||||
gridClassName={gridClassName}
|
||||
disabled={disabled}
|
||||
showDownload
|
||||
showRemove
|
||||
showFileInfo
|
||||
onRemove={handleRemove}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 错误消息组件 ====================
|
||||
|
||||
export interface UploadAlertProps {
|
||||
className?: string;
|
||||
title?: string;
|
||||
}
|
||||
|
||||
export function UploadAlert({ className, title = '上传错误' }: UploadAlertProps) {
|
||||
const { errors } = useFileUploadContext();
|
||||
|
||||
if (errors.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant="destructive" className={className}>
|
||||
<AlertCircle className="size-4" />
|
||||
<AlertTitle>{title}</AlertTitle>
|
||||
<AlertDescription>
|
||||
{errors.map((error, index) => (
|
||||
<p key={index}>{error}</p>
|
||||
))}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
|
||||
// ==================== 开箱即用的组合好的组件 ====================
|
||||
|
||||
export interface CardUploadProps extends Omit<ComponentPropsWithoutRef<typeof FileUpload>, 'children'> {
|
||||
/** 约束信息的自定义格式化函数 */
|
||||
constraintsFormatter?: UploadConstraintsProps['formatter'];
|
||||
/** 预览区域的自定义类名 */
|
||||
previewClassName?: string;
|
||||
/** 预览区域网格的自定义类名 */
|
||||
previewGridClassName?: string;
|
||||
/** 错误提示的自定义标题 */
|
||||
alertTitle?: string;
|
||||
/** 移除文件时的回调 */
|
||||
onRemove?: (id: string, file: UploadFileItem) => void;
|
||||
/** 上传按钮的自定义内容 */
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export const CardUpload = forwardRef<FileUploadRef, CardUploadProps>(
|
||||
function CardUpload(
|
||||
{
|
||||
constraintsFormatter,
|
||||
previewClassName,
|
||||
previewGridClassName,
|
||||
alertTitle,
|
||||
onRemove,
|
||||
className,
|
||||
children,
|
||||
...fileUploadProps
|
||||
},
|
||||
ref: Ref<FileUploadRef>
|
||||
) {
|
||||
return (
|
||||
<FileUpload ref={ref} className={className} {...fileUploadProps}>
|
||||
{/* 移动端显示 UploadButton,桌面端显示 UploadDropzoneButton */}
|
||||
<div className="md:hidden space-y-2">
|
||||
<UploadButton>{children}</UploadButton>
|
||||
<UploadConstraints formatter={constraintsFormatter} />
|
||||
</div>
|
||||
<div className="hidden md:block">
|
||||
<UploadDropzoneButton
|
||||
constraints={<UploadConstraints formatter={constraintsFormatter} />}
|
||||
>
|
||||
{children}
|
||||
</UploadDropzoneButton>
|
||||
</div>
|
||||
<UploadAlert title={alertTitle} />
|
||||
<UploadCardPreview
|
||||
className={previewClassName}
|
||||
gridClassName={previewGridClassName}
|
||||
onRemove={onRemove}
|
||||
/>
|
||||
</FileUpload>
|
||||
);
|
||||
}
|
||||
);
|
||||
|
||||
316
src/components/common/form-dialog.tsx
Normal file
316
src/components/common/form-dialog.tsx
Normal file
@@ -0,0 +1,316 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useEffect, useRef, useState } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
|
||||
// FormDialog Context
|
||||
export interface FormDialogContextValue {
|
||||
form: UseFormReturn<any>
|
||||
close: () => void
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
const FormDialogContext = createContext<FormDialogContextValue | null>(null)
|
||||
|
||||
export function useFormDialogContext() {
|
||||
const context = useContext(FormDialogContext)
|
||||
if (!context) {
|
||||
throw new Error('useFormDialogContext must be used within FormDialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
}
|
||||
|
||||
// 取消按钮组件
|
||||
export interface FormCancelActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onCancel?: () => void
|
||||
}
|
||||
|
||||
export function FormCancelAction({ children = '取消', variant = 'outline', onCancel, ...props }: FormCancelActionProps) {
|
||||
const { close } = useFormDialogContext()
|
||||
|
||||
const handleClick = () => {
|
||||
if (onCancel) {
|
||||
onCancel()
|
||||
} else {
|
||||
close()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Button type="button" variant={variant} onClick={handleClick} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 重置按钮组件
|
||||
export interface FormResetActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
children?: React.ReactNode
|
||||
onReset?: () => void
|
||||
confirmTitle?: string
|
||||
confirmDescription?: string
|
||||
}
|
||||
|
||||
export function FormResetAction({
|
||||
children = '重置',
|
||||
variant = 'outline',
|
||||
onReset,
|
||||
confirmTitle = '确认重置',
|
||||
confirmDescription = '确定要重置表单吗?表单将回到打开时的状态。',
|
||||
...props
|
||||
}: FormResetActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
const [showConfirm, setShowConfirm] = useState(false)
|
||||
|
||||
const handleConfirm = () => {
|
||||
if (onReset) {
|
||||
onReset()
|
||||
} else {
|
||||
form.reset()
|
||||
}
|
||||
setShowConfirm(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Button type="button" variant={variant} onClick={() => setShowConfirm(true)} {...props}>
|
||||
{children}
|
||||
</Button>
|
||||
|
||||
<AlertDialog open={showConfirm} onOpenChange={setShowConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>{confirmTitle}</AlertDialogTitle>
|
||||
<AlertDialogDescription>{confirmDescription}</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirm}>确认</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 提交按钮组件
|
||||
export interface FormSubmitActionProps extends Omit<React.ComponentProps<typeof Button>, 'onClick' | 'type'> {
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
children?: React.ReactNode
|
||||
disabled?: boolean
|
||||
isSubmitting?: boolean
|
||||
showSpinningLoader?: boolean
|
||||
}
|
||||
|
||||
export function FormSubmitAction({
|
||||
onSubmit,
|
||||
children = '提交',
|
||||
disabled = false,
|
||||
isSubmitting = false,
|
||||
showSpinningLoader = true,
|
||||
variant = 'default',
|
||||
...props
|
||||
}: FormSubmitActionProps) {
|
||||
const { form } = useFormDialogContext()
|
||||
|
||||
return (
|
||||
<Button
|
||||
type="button"
|
||||
variant={variant}
|
||||
onClick={form.handleSubmit(onSubmit)}
|
||||
disabled={isSubmitting || disabled}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{isSubmitting && showSpinningLoader && <Loader2 className="ml-2 h-4 w-4 animate-spin" />}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
|
||||
// 操作按钮栏组件
|
||||
export interface FormActionBarProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function FormActionBar({ children }: FormActionBarProps) {
|
||||
return (
|
||||
<div className="flex justify-end space-x-2">
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// FormGridContent 组件
|
||||
export interface FormGridContentProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
export function FormGridContent({ className = 'grid grid-cols-1 gap-4' }: FormGridContentProps) {
|
||||
const { form, fields } = useFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className={cn("p-1", className)}>
|
||||
{fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface FormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
fields: FormFieldConfig[]
|
||||
onClose: () => void
|
||||
className?: string // 允许自定义对话框内容样式,可控制宽度
|
||||
formClassName?: string // 允许自定义表格样式
|
||||
children: React.ReactNode // 操作按钮区域内容
|
||||
}
|
||||
|
||||
export function FormDialog({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
fields,
|
||||
onClose,
|
||||
className = 'max-w-md',
|
||||
formClassName,
|
||||
children,
|
||||
}: FormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// 当对话框打开时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
const close = () => {
|
||||
onClose()
|
||||
}
|
||||
|
||||
// Context 值
|
||||
const contextValue: FormDialogContextValue = {
|
||||
form,
|
||||
close,
|
||||
fields
|
||||
}
|
||||
|
||||
// 表单内容组件,在 Dialog 和 Drawer 中复用
|
||||
const formContent = (
|
||||
<FormDialogContext.Provider value={contextValue}>
|
||||
<Form {...form}>
|
||||
<form ref={formRef} className={cn("space-y-6", formClassName)}>
|
||||
{children}
|
||||
</form>
|
||||
</Form>
|
||||
</FormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DrawerContent className={className}>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && close()}>
|
||||
<DialogContent className={className} showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
326
src/components/common/multi-step-form-dialog.tsx
Normal file
326
src/components/common/multi-step-form-dialog.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client'
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect, useRef } from 'react'
|
||||
import { UseFormReturn, ControllerRenderProps } from 'react-hook-form'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerHeader,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
} from '@/components/ui/drawer'
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@/components/ui/form'
|
||||
import { ChevronLeft, ChevronRight } from 'lucide-react'
|
||||
import { useIsMobile } from '@/hooks/use-mobile'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// MultiStepFormDialog Context
|
||||
export interface MultiStepFormDialogContextValue {
|
||||
onCancel: () => void
|
||||
onPrevious: () => void
|
||||
onNext: () => void
|
||||
isSubmitting: boolean
|
||||
isValidating: boolean
|
||||
submitButtonText: string
|
||||
isFirstStep: boolean
|
||||
isLastStep: boolean
|
||||
}
|
||||
|
||||
const MultiStepFormDialogContext = createContext<MultiStepFormDialogContextValue | null>(null)
|
||||
|
||||
export function useMultiStepFormDialogContext() {
|
||||
const context = useContext(MultiStepFormDialogContext)
|
||||
if (!context) {
|
||||
throw new Error('useMultiStepFormDialogContext must be used within MultiStepFormDialog')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
// 字段配置类型定义
|
||||
export interface FormFieldConfig {
|
||||
name: string
|
||||
label: string
|
||||
required?: boolean
|
||||
render: (props: { field: ControllerRenderProps<any, any> }) => React.ReactNode // 将...field传递给UI控件交给react-hook-form管理
|
||||
className?: string // 允许为单个字段指定自定义样式
|
||||
}
|
||||
|
||||
// 步骤配置类型定义
|
||||
export interface StepConfig {
|
||||
title: string
|
||||
description?: string
|
||||
fields: FormFieldConfig[]
|
||||
}
|
||||
|
||||
// 多步骤表单操作按钮栏组件
|
||||
export function MultiStepFormActionBar() {
|
||||
const {
|
||||
onCancel,
|
||||
onPrevious,
|
||||
onNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
} = useMultiStepFormDialogContext()
|
||||
|
||||
return (
|
||||
<div className="flex justify-between pt-6 border-t">
|
||||
<div className="flex space-x-2">
|
||||
<Button type="button" variant="outline" onClick={onCancel}>
|
||||
取消
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex space-x-2">
|
||||
{!isFirstStep && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={onPrevious}
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<ChevronLeft className="w-4 h-4 mr-1" />
|
||||
上一步
|
||||
</Button>
|
||||
)}
|
||||
{!isLastStep && (
|
||||
<Button
|
||||
type="button"
|
||||
onClick={onNext}
|
||||
disabled={isSubmitting || isValidating}
|
||||
>
|
||||
{isValidating ? '验证中...' : '下一步'}
|
||||
<ChevronRight className="w-4 h-4 ml-1" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="submit"
|
||||
disabled={isSubmitting}
|
||||
className={!isLastStep ? 'hidden' : ''}
|
||||
>
|
||||
{isSubmitting ? `${submitButtonText}中...` : submitButtonText}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export interface MultiStepFormDialogProps {
|
||||
isOpen: boolean
|
||||
title: string
|
||||
description: string
|
||||
form: UseFormReturn<any>
|
||||
onSubmit: (data: any) => Promise<void> | void
|
||||
steps: StepConfig[]
|
||||
contentClassName?: string
|
||||
gridClassName?: string
|
||||
|
||||
/* action */
|
||||
onClose: () => void
|
||||
isSubmitting: boolean
|
||||
submitButtonText: string
|
||||
}
|
||||
|
||||
export function MultiStepFormDialog({
|
||||
isOpen,
|
||||
title,
|
||||
description,
|
||||
form,
|
||||
onSubmit,
|
||||
steps,
|
||||
contentClassName = 'max-w-4xl',
|
||||
gridClassName = 'grid grid-cols-1 md:grid-cols-2 gap-4',
|
||||
onClose,
|
||||
isSubmitting,
|
||||
submitButtonText,
|
||||
}: MultiStepFormDialogProps) {
|
||||
const isMobile = useIsMobile()
|
||||
const [currentStep, setCurrentStep] = useState(0)
|
||||
const [isValidating, setIsValidating] = useState(false)
|
||||
const formRef = useRef<HTMLFormElement>(null)
|
||||
|
||||
// 当对话框打开或步骤改变时,自动聚焦到第一个表单输入控件
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur()
|
||||
// 使用 setTimeout 确保 DOM 已完全渲染
|
||||
const timer = setTimeout(() => {
|
||||
if (formRef.current) {
|
||||
// 查找第一个可聚焦的输入元素
|
||||
const firstInput = formRef.current.querySelector<HTMLElement>(
|
||||
'input:not([type="hidden"]):not([disabled]), textarea:not([disabled]), select:not([disabled])'
|
||||
)
|
||||
if (firstInput) {
|
||||
firstInput.focus()
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isOpen, currentStep])
|
||||
|
||||
const handleSubmit = async (data: any) => {
|
||||
await onSubmit(data)
|
||||
}
|
||||
|
||||
const handleClose = () => {
|
||||
setCurrentStep(0)
|
||||
onClose()
|
||||
}
|
||||
|
||||
const handleNext = async () => {
|
||||
if (currentStep < steps.length - 1) {
|
||||
setIsValidating(true)
|
||||
try {
|
||||
// 验证当前步骤的字段,只有验证通过才能跳转到下一步
|
||||
const currentStepFields = currentStepConfig.fields.map(field => field.name)
|
||||
const isValid = await form.trigger(currentStepFields)
|
||||
if (isValid) {
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
} finally {
|
||||
setIsValidating(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePrevious = () => {
|
||||
if (currentStep > 0) {
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const isFirstStep = currentStep === 0
|
||||
const isLastStep = currentStep === steps.length - 1
|
||||
const currentStepConfig = steps[currentStep]
|
||||
|
||||
// 步骤指示器组件
|
||||
const stepIndicator = (
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
{steps.map((step, index) => (
|
||||
<div key={index} className="flex items-center">
|
||||
<div
|
||||
className={`w-8 h-8 rounded-full flex items-center justify-center text-sm font-medium ${
|
||||
index <= currentStep
|
||||
? 'bg-primary text-primary-foreground'
|
||||
: 'bg-muted text-muted-foreground'
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</div>
|
||||
<div className="ml-2 text-sm">
|
||||
<div className={index <= currentStep ? 'text-primary font-medium' : 'text-muted-foreground'}>
|
||||
{step.title}
|
||||
</div>
|
||||
</div>
|
||||
{index < steps.length - 1 && (
|
||||
<div className={`w-12 h-0.5 mx-4 ${index < currentStep ? 'bg-primary' : 'bg-muted'}`} />
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
|
||||
// Context 值
|
||||
const contextValue: MultiStepFormDialogContextValue = {
|
||||
onCancel: handleClose,
|
||||
onPrevious: handlePrevious,
|
||||
onNext: handleNext,
|
||||
isSubmitting,
|
||||
isValidating,
|
||||
submitButtonText,
|
||||
isFirstStep,
|
||||
isLastStep
|
||||
}
|
||||
|
||||
// 表单内容组件
|
||||
const formContent = (
|
||||
<MultiStepFormDialogContext.Provider value={contextValue}>
|
||||
<div className="space-y-4">
|
||||
<Form {...form}>
|
||||
<form ref={formRef} onSubmit={form.handleSubmit(handleSubmit)} className="space-y-6">
|
||||
{/* 当前步骤标题和描述 */}
|
||||
<div className="border-b pb-4">
|
||||
<h3 className="text-lg font-medium">{currentStepConfig.title}</h3>
|
||||
{currentStepConfig.description && (
|
||||
<p className="text-sm text-muted-foreground mt-1">{currentStepConfig.description}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 当前步骤的字段 */}
|
||||
<div className={cn("p-1", gridClassName)}>
|
||||
{currentStepConfig.fields.map((fieldConfig) => (
|
||||
<FormField
|
||||
key={fieldConfig.name}
|
||||
control={form.control}
|
||||
name={fieldConfig.name}
|
||||
render={({ field }) => (
|
||||
<FormItem className={fieldConfig.className || ''}>
|
||||
<FormLabel className="flex items-center gap-1">
|
||||
{fieldConfig.label}
|
||||
{fieldConfig.required && <span className="text-red-500">*</span>}
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
{fieldConfig.render({ field })}
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<MultiStepFormActionBar />
|
||||
</form>
|
||||
</Form>
|
||||
</div>
|
||||
</MultiStepFormDialogContext.Provider>
|
||||
)
|
||||
|
||||
// 根据设备类型渲染不同的组件
|
||||
if (isMobile) {
|
||||
return (
|
||||
<Drawer open={isOpen} onOpenChange={handleClose}>
|
||||
<DrawerContent>
|
||||
<DrawerHeader>
|
||||
<DrawerTitle>{title}</DrawerTitle>
|
||||
<DrawerDescription>{description}</DrawerDescription>
|
||||
</DrawerHeader>
|
||||
<div className="px-4 pb-4 overflow-y-auto max-h-[70vh]">
|
||||
{stepIndicator}
|
||||
{formContent}
|
||||
</div>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={handleClose}>
|
||||
<DialogContent className={contentClassName}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
{stepIndicator}
|
||||
<div className='overflow-y-auto max-h-[70vh]'>
|
||||
{formContent}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
88
src/components/common/number-range-input.tsx
Normal file
88
src/components/common/number-range-input.tsx
Normal file
@@ -0,0 +1,88 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
// 数值范围类型
|
||||
export interface NumberRange {
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export interface NumberRangeInputProps {
|
||||
value?: NumberRange
|
||||
onChange?: (value: NumberRange) => void
|
||||
placeholder?: {
|
||||
min?: string
|
||||
max?: string
|
||||
}
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
step?: number
|
||||
min?: number
|
||||
max?: number
|
||||
}
|
||||
|
||||
export function NumberRangeInput({
|
||||
value = {},
|
||||
onChange,
|
||||
placeholder = { min: '最小值', max: '最大值' },
|
||||
className,
|
||||
disabled = false,
|
||||
step = 0.01,
|
||||
min,
|
||||
max
|
||||
}: NumberRangeInputProps) {
|
||||
const handleMinChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value
|
||||
const newMin = inputValue === '' ? undefined : parseFloat(inputValue)
|
||||
|
||||
if (inputValue === '' || !isNaN(newMin!)) {
|
||||
onChange?.({
|
||||
...value,
|
||||
min: newMin
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleMaxChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const inputValue = e.target.value
|
||||
const newMax = inputValue === '' ? undefined : parseFloat(inputValue)
|
||||
|
||||
if (inputValue === '' || !isNaN(newMax!)) {
|
||||
onChange?.({
|
||||
...value,
|
||||
max: newMax
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
<Input
|
||||
type="number"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value.min ?? ''}
|
||||
onChange={handleMinChange}
|
||||
placeholder={placeholder.min}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="text-muted-foreground text-sm">至</span>
|
||||
<Input
|
||||
type="number"
|
||||
step={step}
|
||||
min={min}
|
||||
max={max}
|
||||
value={value.max ?? ''}
|
||||
onChange={handleMaxChange}
|
||||
placeholder={placeholder.max}
|
||||
disabled={disabled}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
68
src/components/common/preview-card.tsx
Normal file
68
src/components/common/preview-card.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import * as HoverCardPrimitive from "@radix-ui/react-hover-card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface PreviewCardProps {
|
||||
children: React.ReactNode;
|
||||
title: React.ReactNode;
|
||||
description: React.ReactNode;
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
delayDuration?: number;
|
||||
side?: "top" | "right" | "bottom" | "left";
|
||||
align?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
/**
|
||||
* 鼠标悬停显示预览信息的卡片组件
|
||||
* 桌面端:鼠标悬停展示(进入后delayDuration延迟显示,离开后200ms关闭)
|
||||
* 移动端:@radix-ui/react-hover-card 原生支持触摸交互
|
||||
*/
|
||||
export function PreviewCard({
|
||||
children,
|
||||
title,
|
||||
description,
|
||||
disabled = false,
|
||||
className,
|
||||
delayDuration = 300,
|
||||
side = "right",
|
||||
align = "start",
|
||||
}: PreviewCardProps) {
|
||||
// 如果禁用,只渲染children
|
||||
if (disabled) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
return (
|
||||
<HoverCardPrimitive.Root openDelay={delayDuration} closeDelay={200}>
|
||||
<HoverCardPrimitive.Trigger asChild>
|
||||
<div className="inline-block">{children}</div>
|
||||
</HoverCardPrimitive.Trigger>
|
||||
<HoverCardPrimitive.Portal>
|
||||
<HoverCardPrimitive.Content
|
||||
side={side}
|
||||
align={align}
|
||||
sideOffset={5}
|
||||
className={cn(
|
||||
"z-50 w-80 rounded-md border bg-popover p-4 text-popover-foreground shadow-md outline-none max-w-[90vw]",
|
||||
"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-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2",
|
||||
"data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||
className
|
||||
)}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<div className="font-semibold text-sm leading-tight">{title}</div>
|
||||
<div className="text-xs text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardPrimitive.Content>
|
||||
</HoverCardPrimitive.Portal>
|
||||
</HoverCardPrimitive.Root>
|
||||
);
|
||||
}
|
||||
254
src/components/common/responsive-tabs.tsx
Normal file
254
src/components/common/responsive-tabs.tsx
Normal file
@@ -0,0 +1,254 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode } from "react";
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { PreviewCard } from "@/components/common/preview-card";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
|
||||
// 标签项类型定义
|
||||
export type ResponsiveTabItem<T extends string = string> = {
|
||||
id: T;
|
||||
name: string;
|
||||
description?: string;
|
||||
count?: number;
|
||||
badge?: ReactNode;
|
||||
};
|
||||
|
||||
// 根组件属性类型
|
||||
type ResponsiveTabsProps<T extends string = string> = {
|
||||
/** 标签项列表 */
|
||||
items: ResponsiveTabItem<T>[];
|
||||
/** 当前激活的标签值 */
|
||||
value: T;
|
||||
/** 标签值变化回调 */
|
||||
onValueChange: (value: T) => void;
|
||||
/** 容器类名 */
|
||||
className?: string;
|
||||
/** 是否在桌面端显示标签ID徽章 */
|
||||
showIdBadge?: boolean;
|
||||
/** 是否在标签上显示计数徽章 */
|
||||
showCountBadge?: boolean;
|
||||
/** 子组件 */
|
||||
children: ReactNode;
|
||||
};
|
||||
|
||||
// 骨架屏组件属性类型
|
||||
type ResponsiveTabsSkeletonProps = {
|
||||
/** 容器类名 */
|
||||
className?: string;
|
||||
/** 骨架屏内容 */
|
||||
children?: ReactNode;
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式标签页组件
|
||||
*
|
||||
* 在桌面端(lg及以上)显示垂直布局的标签页,在移动端和平板显示水平布局的标签页。
|
||||
* 桌面端标签支持预览卡片功能,移动端标签支持横向滚动。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs items={items} value={value} onValueChange={setValue}>
|
||||
* <ResponsiveTabs.Content value="tab1">
|
||||
* <div>Tab 1 content</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* <ResponsiveTabs.Content value="tab2">
|
||||
* <div>Tab 2 content</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* </ResponsiveTabs>
|
||||
* ```
|
||||
*/
|
||||
export function ResponsiveTabs<T extends string = string>({
|
||||
items,
|
||||
value,
|
||||
onValueChange,
|
||||
className = "",
|
||||
showIdBadge = false,
|
||||
showCountBadge = true,
|
||||
children,
|
||||
}: ResponsiveTabsProps<T>) {
|
||||
// 移动端:水平标签页布局
|
||||
const mobileLayout = (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<Tabs value={value} onValueChange={onValueChange as (value: string) => void}>
|
||||
<div className="overflow-x-auto pb-2 scrollbar-hide">
|
||||
<TabsList variant="default" size="sm" className="inline-flex w-auto min-w-full mt-0">
|
||||
{items.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{item.name}</span>
|
||||
{showIdBadge && (
|
||||
<Badge variant="secondary" size="xs" appearance="light" className="font-mono text-xs">
|
||||
{item.id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={item.description}
|
||||
disabled={!item.description}
|
||||
side="bottom"
|
||||
align="center"
|
||||
>
|
||||
<TabsTrigger
|
||||
value={item.id}
|
||||
className="flex-shrink-0 gap-1.5"
|
||||
>
|
||||
<span className="whitespace-nowrap text-xs">{item.name}</span>
|
||||
{showCountBadge && item.count !== undefined && (
|
||||
<Badge variant="secondary" size="xs" appearance="ghost" className="text-xs">
|
||||
{item.count}
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge}
|
||||
</TabsTrigger>
|
||||
</PreviewCard>
|
||||
))}
|
||||
</TabsList>
|
||||
</div>
|
||||
|
||||
{children}
|
||||
</Tabs>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 桌面端:垂直标签页布局
|
||||
const desktopLayout = (
|
||||
<div className={className}>
|
||||
<Tabs value={value} onValueChange={onValueChange as (value: string) => void} orientation="vertical" className="flex gap-5">
|
||||
<TabsList variant="default" className="flex-col items-stretch w-45 2xl:w-52 h-auto shrink-0 self-start bg-muted/30 rounded-lg p-1.5">
|
||||
{items.map((item) => (
|
||||
<PreviewCard
|
||||
key={item.id}
|
||||
title={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<span>{item.name}</span>
|
||||
{showIdBadge && (
|
||||
<Badge variant="secondary" size="xs" appearance="light" className="font-mono text-xs">
|
||||
{item.id}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
description={item.description}
|
||||
>
|
||||
<TabsTrigger
|
||||
value={item.id}
|
||||
className="justify-between w-full px-3 py-2 data-[state=active]:bg-background/90 data-[state=active]:shadow-sm rounded-md"
|
||||
>
|
||||
<span className="font-medium text-sm">{item.name}</span>
|
||||
{showCountBadge && item.count !== undefined && (
|
||||
<Badge variant="secondary" size="sm" appearance="light" className="text-xs">
|
||||
{item.count}
|
||||
</Badge>
|
||||
)}
|
||||
{item.badge}
|
||||
</TabsTrigger>
|
||||
</PreviewCard>
|
||||
))}
|
||||
</TabsList>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
{children}
|
||||
</div>
|
||||
</Tabs>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 使用 CSS 媒体查询来避免客户端渲染闪烁
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">{mobileLayout}</div>
|
||||
<div className="hidden lg:block">{desktopLayout}</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 标签内容组件
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs.Content value="tab1">
|
||||
* <div>Content for tab 1</div>
|
||||
* </ResponsiveTabs.Content>
|
||||
* ```
|
||||
*/
|
||||
ResponsiveTabs.Content = function ResponsiveTabsContent({
|
||||
value,
|
||||
children,
|
||||
className = "mt-0",
|
||||
}: {
|
||||
value: string;
|
||||
children: ReactNode;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<TabsContent value={value} className={className}>
|
||||
{children}
|
||||
</TabsContent>
|
||||
);
|
||||
};
|
||||
|
||||
/**
|
||||
* 响应式标签页骨架屏组件
|
||||
*
|
||||
* 显示加载状态的骨架屏,在移动端和桌面端有不同的布局。
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <ResponsiveTabs.Skeleton>
|
||||
* <Skeleton className="h-10 w-full" />
|
||||
* <Skeleton className="h-48 w-full" />
|
||||
* </ResponsiveTabs.Skeleton>
|
||||
* ```
|
||||
*/
|
||||
ResponsiveTabs.Skeleton = function ResponsiveTabsSkeleton({
|
||||
className = "",
|
||||
children,
|
||||
}: ResponsiveTabsSkeletonProps) {
|
||||
// 桌面端和移动端共用的内容骨架屏
|
||||
const contentSkeleton = <div className="space-y-4">{children}</div>;
|
||||
|
||||
// 移动端骨架屏
|
||||
const mobileSkeleton = (
|
||||
<div className={className}>
|
||||
<div className="space-y-4">
|
||||
<div className="flex gap-2 overflow-x-auto pb-2 scrollbar-hide">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-9 w-24 shrink-0 rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
{contentSkeleton}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 桌面端骨架屏
|
||||
const desktopSkeleton = (
|
||||
<div className={className}>
|
||||
<div className="flex gap-5">
|
||||
<div className="flex-col space-y-2 w-45 2xl:w-52 shrink-0">
|
||||
{[1, 2, 3, 4].map((i) => (
|
||||
<Skeleton key={i} className="h-12 w-full rounded-md" />
|
||||
))}
|
||||
</div>
|
||||
<div className="flex-1 space-y-4">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 使用 CSS 媒体查询来避免客户端渲染闪烁
|
||||
return (
|
||||
<>
|
||||
<div className="lg:hidden">{mobileSkeleton}</div>
|
||||
<div className="hidden lg:block">{desktopSkeleton}</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
61
src/components/common/search-input.tsx
Normal file
61
src/components/common/search-input.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import React from 'react'
|
||||
import { Search, X } from 'lucide-react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
import { InputGroup, InputGroupAddon, InputGroupButton, InputGroupInput } from '@/components/ui/input-group'
|
||||
|
||||
export interface SearchInputProps {
|
||||
value?: string
|
||||
onChange?: (value: string) => void
|
||||
placeholder?: string
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
onClear?: () => void
|
||||
}
|
||||
|
||||
export function SearchInput({
|
||||
value = '',
|
||||
onChange,
|
||||
placeholder = '搜索...',
|
||||
className,
|
||||
disabled = false,
|
||||
onClear
|
||||
}: SearchInputProps) {
|
||||
const handleChange = React.useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
onChange?.(e.target.value)
|
||||
}, [onChange])
|
||||
|
||||
const handleClear = React.useCallback(() => {
|
||||
onChange?.('')
|
||||
onClear?.()
|
||||
}, [onChange, onClear])
|
||||
|
||||
return (
|
||||
<InputGroup className={cn('w-full', className)}>
|
||||
<InputGroupAddon align="inline-start">
|
||||
<Search className="size-4 text-muted-foreground" />
|
||||
</InputGroupAddon>
|
||||
<InputGroupInput
|
||||
type="text"
|
||||
value={value}
|
||||
onChange={handleChange}
|
||||
placeholder={placeholder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{value && (
|
||||
<InputGroupAddon align="inline-end">
|
||||
<InputGroupButton
|
||||
size="icon-xs"
|
||||
onClick={handleClear}
|
||||
disabled={disabled}
|
||||
aria-label="清空搜索"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</InputGroupButton>
|
||||
</InputGroupAddon>
|
||||
)}
|
||||
</InputGroup>
|
||||
)
|
||||
}
|
||||
166
src/components/common/stats-card-group.tsx
Normal file
166
src/components/common/stats-card-group.tsx
Normal file
@@ -0,0 +1,166 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Carousel,
|
||||
CarouselContent,
|
||||
CarouselItem,
|
||||
type CarouselApi,
|
||||
} from '@/components/ui/carousel'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
|
||||
export interface StatsCardItem {
|
||||
id: string
|
||||
/** 卡片标题 */
|
||||
title: string
|
||||
/** 卡片描述(可选) */
|
||||
description?: string
|
||||
/** 图标组件(可选) */
|
||||
icon?: LucideIcon
|
||||
/** 自定义内容,如果提供则会替代默认的标题/描述/图标布局 */
|
||||
content?: React.ReactNode
|
||||
}
|
||||
|
||||
export interface StatsCardGroupProps {
|
||||
items: StatsCardItem[]
|
||||
/** 桌面端的网格类名,例如 "md:grid-cols-2 lg:grid-cols-4" */
|
||||
gridClassName?: string
|
||||
/** 卡片容器的额外类名 */
|
||||
className?: string
|
||||
/** 自定义卡片渲染函数 */
|
||||
renderCard?: (item: StatsCardItem) => React.ReactNode
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认的统计卡片渲染组件
|
||||
*/
|
||||
function DefaultStatsCard({ item }: { item: StatsCardItem }) {
|
||||
const Icon = item.icon
|
||||
|
||||
return (
|
||||
<Card className="h-full">
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
|
||||
<CardTitle className="text-sm font-medium">{item.title}</CardTitle>
|
||||
{Icon && <Icon className="h-4 w-4 text-muted-foreground" />}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{item.description && (
|
||||
<p className="text-xs text-muted-foreground">{item.description}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 响应式统计卡片组组件
|
||||
* - 移动端:使用轮播,一次显示一张卡片,支持拖动切换
|
||||
* - 桌面端:使用网格布局,由 gridClassName 控制列数
|
||||
* - 所有卡片高度一致
|
||||
*/
|
||||
export function StatsCardGroup({
|
||||
items,
|
||||
gridClassName = 'md:grid-cols-2 lg:grid-cols-4',
|
||||
className,
|
||||
renderCard,
|
||||
}: StatsCardGroupProps) {
|
||||
const [api, setApi] = React.useState<CarouselApi>()
|
||||
const [current, setCurrent] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!api) return
|
||||
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
|
||||
api.on('select', () => {
|
||||
setCurrent(api.selectedScrollSnap())
|
||||
})
|
||||
}, [api])
|
||||
|
||||
if (items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// 渲染单个卡片
|
||||
const renderItem = (item: StatsCardItem) => {
|
||||
if (renderCard) {
|
||||
return renderCard(item)
|
||||
}
|
||||
if (item.content) {
|
||||
return item.content
|
||||
}
|
||||
return <DefaultStatsCard item={item} />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('w-full', className)}>
|
||||
{/* 移动端:轮播模式 */}
|
||||
<div className="md:hidden relative">
|
||||
<Carousel
|
||||
setApi={setApi}
|
||||
opts={{
|
||||
align: 'start',
|
||||
loop: false,
|
||||
}}
|
||||
className="w-full"
|
||||
>
|
||||
<CarouselContent>
|
||||
{items.map((item) => (
|
||||
<CarouselItem key={item.id}>
|
||||
<div className="h-full">{renderItem(item)}</div>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
|
||||
{/* 轮播指示器 - 悬浮在底部 */}
|
||||
{items.length > 1 && (
|
||||
<div className="absolute bottom-4 left-0 right-0 flex justify-center gap-2 pointer-events-none">
|
||||
<div className="flex gap-2 pointer-events-auto">
|
||||
{items.map((_, index) => (
|
||||
<button
|
||||
key={index}
|
||||
className={cn(
|
||||
'h-2 rounded-full transition-all',
|
||||
current === index
|
||||
? 'w-8 bg-primary'
|
||||
: 'w-2 bg-muted-foreground/30'
|
||||
)}
|
||||
onClick={() => api?.scrollTo(index)}
|
||||
aria-label={`跳转到第 ${index + 1} 张卡片`}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 桌面端:网格布局 */}
|
||||
<div className={cn('hidden md:grid gap-4', gridClassName)}>
|
||||
{items.map((item) => (
|
||||
<div key={item.id} className="h-full">
|
||||
{renderItem(item)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* 统计卡片包装器 - 确保所有卡片高度一致
|
||||
* 当需要自定义卡片内容时使用
|
||||
*/
|
||||
export function StatsCardWrapper({
|
||||
children,
|
||||
className,
|
||||
}: {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}) {
|
||||
return (
|
||||
<Card className={cn('h-full flex flex-col', className)}>{children}</Card>
|
||||
)
|
||||
}
|
||||
322
src/components/common/task-dialog.tsx
Normal file
322
src/components/common/task-dialog.tsx
Normal file
@@ -0,0 +1,322 @@
|
||||
'use client'
|
||||
|
||||
import React, { useState, useEffect, ReactNode } from 'react'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from '@/components/ui/dialog'
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle
|
||||
} from '@/components/ui/alert-dialog'
|
||||
import { CheckCircle2, XCircle, Loader2, StopCircle, Minimize2 } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import type { BaseTaskProgress, TaskState } from '@/server/routers/jobs'
|
||||
|
||||
/**
|
||||
* TaskDialog 组件属性
|
||||
*/
|
||||
export interface TaskDialogProps<T extends BaseTaskProgress = BaseTaskProgress> {
|
||||
/** 对话框是否打开 */
|
||||
open: boolean
|
||||
/** 对话框关闭回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
/** 任务 ID */
|
||||
jobId: string | null
|
||||
/** tRPC subscription hook */
|
||||
useSubscription: (
|
||||
input: { jobId: string },
|
||||
opts: {
|
||||
enabled: boolean
|
||||
onData: (data: { data: T }) => void
|
||||
onError: (error: unknown) => void
|
||||
}
|
||||
) => void
|
||||
/** 对话框标题 */
|
||||
title?: string
|
||||
/** 对话框描述 */
|
||||
description?: string
|
||||
/** 取消任务的回调函数 */
|
||||
onCancelTask?: (jobId: string) => void | Promise<void>
|
||||
/** 任务完成的回调函数 */
|
||||
onTaskCompleted?: () => void
|
||||
/** 是否正在取消任务 */
|
||||
isCancelling?: boolean
|
||||
/** 自定义状态消息渲染函数 */
|
||||
renderStatusMessage?: (progress: T) => string
|
||||
/** 自定义详细信息渲染函数 */
|
||||
renderDetails?: (progress: T) => ReactNode
|
||||
/** 任务完成后自动关闭对话框的延迟时间(毫秒),设为 0 则不自动关闭 */
|
||||
autoCloseDelay?: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用任务监控对话框组件
|
||||
*
|
||||
* 用于显示后台任务的执行进度,支持:
|
||||
* - 通过 tRPC subscription 实时获取任务进度
|
||||
* - 显示进度条和状态信息
|
||||
* - 取消正在执行的任务
|
||||
* - 自定义进度信息展示
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* <TaskDialog
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* jobId={jobId}
|
||||
* useSubscription={trpc.jobs.subscribeSyncAssetsProgress.useSubscription}
|
||||
* title="数据同步"
|
||||
* description="正在同步数据..."
|
||||
* onCancelTask={handleCancel}
|
||||
* onTaskCompleted={handleCompleted}
|
||||
* renderStatusMessage={(progress) => `处理中: ${progress.current}/${progress.total}`}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export function TaskDialog<T extends BaseTaskProgress = BaseTaskProgress>({
|
||||
open,
|
||||
onOpenChange,
|
||||
jobId,
|
||||
useSubscription,
|
||||
title = '任务进度',
|
||||
description = '正在执行任务,请稍候...',
|
||||
onCancelTask,
|
||||
onTaskCompleted,
|
||||
isCancelling = false,
|
||||
renderStatusMessage,
|
||||
renderDetails,
|
||||
autoCloseDelay = 2000,
|
||||
}: TaskDialogProps<T>) {
|
||||
const [progress, setProgress] = useState<T | null>(null)
|
||||
const [showCloseConfirm, setShowCloseConfirm] = useState(false)
|
||||
const [showCancelConfirm, setShowCancelConfirm] = useState(false)
|
||||
|
||||
// 使用传入的subscription hook
|
||||
useSubscription(
|
||||
{ jobId: jobId || '' },
|
||||
{
|
||||
enabled: open && !!jobId,
|
||||
onData: ({ data }) => {
|
||||
setProgress(data)
|
||||
|
||||
// 如果任务完成,显示提示并延迟关闭
|
||||
if (data.state === 'completed') {
|
||||
toast.success('任务完成!')
|
||||
if (autoCloseDelay > 0) {
|
||||
setTimeout(() => {
|
||||
onOpenChange(false)
|
||||
onTaskCompleted?.()
|
||||
}, autoCloseDelay)
|
||||
} else {
|
||||
onTaskCompleted?.()
|
||||
}
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('订阅错误:', error)
|
||||
toast.error('连接进度服务失败')
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
// 当对话框打开时重置进度
|
||||
useEffect(() => {
|
||||
if (open && jobId) {
|
||||
setProgress(null)
|
||||
}
|
||||
}, [open, jobId])
|
||||
|
||||
// 处理对话框关闭
|
||||
const handleDialogClose = () => {
|
||||
// 如果任务还在运行,提示用户
|
||||
if (progress?.state === 'active' || progress?.state === 'waiting') {
|
||||
setShowCloseConfirm(true)
|
||||
return
|
||||
}
|
||||
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 确认关闭对话框
|
||||
const confirmClose = () => {
|
||||
setShowCloseConfirm(false)
|
||||
onOpenChange(false)
|
||||
}
|
||||
|
||||
// 处理取消任务
|
||||
const handleCancelTask = () => {
|
||||
if (!progress?.jobId || !onCancelTask) return
|
||||
setShowCancelConfirm(true)
|
||||
}
|
||||
|
||||
// 确认取消任务
|
||||
const confirmCancelTask = async () => {
|
||||
if (!progress?.jobId || !onCancelTask) return
|
||||
setShowCancelConfirm(false)
|
||||
await onCancelTask(progress.jobId)
|
||||
}
|
||||
|
||||
// 渲染进度状态图标
|
||||
const renderStatusIcon = () => {
|
||||
if (!progress) return null
|
||||
|
||||
switch (progress.state) {
|
||||
case 'waiting':
|
||||
case 'active':
|
||||
return <Loader2 className="h-6 w-6 animate-spin text-blue-500" />
|
||||
case 'completed':
|
||||
return <CheckCircle2 className="h-6 w-6 text-green-500" />
|
||||
case 'failed':
|
||||
return <XCircle className="h-6 w-6 text-red-500" />
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
// 获取默认状态消息
|
||||
const getDefaultStatusMessage = () => {
|
||||
if (!progress) return ''
|
||||
|
||||
switch (progress.state) {
|
||||
case 'waiting':
|
||||
return '任务等待中...'
|
||||
case 'active':
|
||||
return '正在处理任务...'
|
||||
case 'completed':
|
||||
return '任务完成!'
|
||||
case 'failed':
|
||||
return progress.error || '任务失败'
|
||||
default:
|
||||
return ''
|
||||
}
|
||||
}
|
||||
|
||||
// 获取状态消息
|
||||
const getStatusMessage = () => {
|
||||
if (!progress) return ''
|
||||
return renderStatusMessage ? renderStatusMessage(progress) : getDefaultStatusMessage()
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={open} onOpenChange={handleDialogClose}>
|
||||
<DialogContent className="sm:max-w-md" showCloseButton={false}>
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
{renderStatusIcon()}
|
||||
{title}
|
||||
</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 进度条 */}
|
||||
{progress && (
|
||||
<>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between text-sm">
|
||||
<span className="text-muted-foreground">进度</span>
|
||||
<span className="font-medium">{Math.round(progress.progressPercent)}%</span>
|
||||
</div>
|
||||
<Progress value={progress.progressPercent} className="h-2" />
|
||||
</div>
|
||||
|
||||
{/* 状态消息 */}
|
||||
<Alert>
|
||||
<AlertDescription className="break-all">
|
||||
{getStatusMessage()}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
|
||||
{/* 自定义详细信息 */}
|
||||
{renderDetails && (
|
||||
<div className="overflow-x-auto">
|
||||
{renderDetails(progress)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 错误信息 */}
|
||||
{progress.error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription className="overflow-x-auto">
|
||||
{progress.error}
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 操作按钮 */}
|
||||
<div className="flex gap-2">
|
||||
{progress?.state === 'completed' || progress?.state === 'failed' ? (
|
||||
<Button onClick={() => handleDialogClose()} className="w-full">
|
||||
关闭
|
||||
</Button>
|
||||
) : (
|
||||
<>
|
||||
{onCancelTask && (
|
||||
<Button
|
||||
onClick={handleCancelTask}
|
||||
variant="destructive"
|
||||
className="flex-1"
|
||||
disabled={isCancelling}
|
||||
>
|
||||
<StopCircle className="mr-2 h-4 w-4" />
|
||||
{isCancelling ? '停止中...' : '停止任务'}
|
||||
</Button>
|
||||
)}
|
||||
<Button onClick={() => handleDialogClose()} variant="outline" className="flex-1">
|
||||
<Minimize2 className="mr-2 h-4 w-4" />
|
||||
后台运行
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 关闭确认对话框 */}
|
||||
<AlertDialog open={showCloseConfirm} onOpenChange={setShowCloseConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认关闭</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
任务正在进行中,确定要关闭进度窗口吗?任务将继续在后台运行。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmClose}>确定</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 取消任务确认对话框 */}
|
||||
<AlertDialog open={showCancelConfirm} onOpenChange={setShowCancelConfirm}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认停止任务</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确定要停止当前任务吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={confirmCancelTask}>确定</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
// 导出类型以便使用
|
||||
export type { BaseTaskProgress, TaskState }
|
||||
221
src/components/common/theme-toggle-button.tsx
Normal file
221
src/components/common/theme-toggle-button.tsx
Normal file
@@ -0,0 +1,221 @@
|
||||
'use client';
|
||||
|
||||
import { Moon, Sun } from 'lucide-react';
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
type AnimationVariant =
|
||||
| 'circle'
|
||||
| 'circle-blur'
|
||||
| 'gif'
|
||||
| 'polygon';
|
||||
|
||||
type StartPosition =
|
||||
| 'center'
|
||||
| 'top-left'
|
||||
| 'top-right'
|
||||
| 'bottom-left'
|
||||
| 'bottom-right';
|
||||
|
||||
export interface ThemeToggleButtonProps {
|
||||
theme?: 'light' | 'dark';
|
||||
showLabel?: boolean;
|
||||
variant?: AnimationVariant;
|
||||
start?: StartPosition;
|
||||
url?: string; // For gif variant
|
||||
className?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export const ThemeToggleButton = ({
|
||||
theme = 'light',
|
||||
showLabel = false,
|
||||
variant = 'circle',
|
||||
start = 'center',
|
||||
url,
|
||||
className,
|
||||
onClick,
|
||||
}: ThemeToggleButtonProps) => {
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
// Inject animation styles for this specific transition
|
||||
const styleId = `theme-transition-${Date.now()}`;
|
||||
const style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
|
||||
// Generate animation CSS based on variant
|
||||
let css = '';
|
||||
const positions = {
|
||||
center: 'center',
|
||||
'top-left': 'top left',
|
||||
'top-right': 'top right',
|
||||
'bottom-left': 'bottom left',
|
||||
'bottom-right': 'bottom right',
|
||||
};
|
||||
|
||||
if (variant === 'circle') {
|
||||
const cx = start === 'center' ? '50' : start.includes('left') ? '0' : '100';
|
||||
const cy = start === 'center' ? '50' : start.includes('top') ? '0' : '100';
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: circle-expand 0.4s ease-out;
|
||||
transform-origin: ${positions[start]};
|
||||
}
|
||||
@keyframes circle-expand {
|
||||
from {
|
||||
clip-path: circle(0% at ${cx}% ${cy}%);
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at ${cx}% ${cy}%);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'circle-blur') {
|
||||
const cx = start === 'center' ? '50' : start.includes('left') ? '0' : '100';
|
||||
const cy = start === 'center' ? '50' : start.includes('top') ? '0' : '100';
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: circle-blur-expand 0.5s ease-out;
|
||||
transform-origin: ${positions[start]};
|
||||
filter: blur(0);
|
||||
}
|
||||
@keyframes circle-blur-expand {
|
||||
from {
|
||||
clip-path: circle(0% at ${cx}% ${cy}%);
|
||||
filter: blur(4px);
|
||||
}
|
||||
to {
|
||||
clip-path: circle(150% at ${cx}% ${cy}%);
|
||||
filter: blur(0);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'gif' && url) {
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: fade-out 0.4s ease-out;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: gif-reveal 2.5s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
mask-image: url('${url}');
|
||||
mask-size: 0%;
|
||||
mask-repeat: no-repeat;
|
||||
mask-position: center;
|
||||
}
|
||||
@keyframes fade-out {
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
@keyframes gif-reveal {
|
||||
0% {
|
||||
mask-size: 0%;
|
||||
}
|
||||
20% {
|
||||
mask-size: 35%;
|
||||
}
|
||||
60% {
|
||||
mask-size: 35%;
|
||||
}
|
||||
100% {
|
||||
mask-size: 300%;
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
} else if (variant === 'polygon') {
|
||||
css = `
|
||||
@supports (view-transition-name: root) {
|
||||
::view-transition-old(root) {
|
||||
animation: none;
|
||||
}
|
||||
::view-transition-new(root) {
|
||||
animation: ${theme === 'light' ? 'wipe-in-dark' : 'wipe-in-light'} 0.4s ease-out;
|
||||
}
|
||||
@keyframes wipe-in-dark {
|
||||
from {
|
||||
clip-path: polygon(0 0, 0 0, 0 100%, 0 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
@keyframes wipe-in-light {
|
||||
from {
|
||||
clip-path: polygon(100% 0, 100% 0, 100% 100%, 100% 100%);
|
||||
}
|
||||
to {
|
||||
clip-path: polygon(0 0, 100% 0, 100% 100%, 0 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
`;
|
||||
}
|
||||
|
||||
if (css) {
|
||||
style.textContent = css;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Clean up animation styles after transition
|
||||
setTimeout(() => {
|
||||
const styleEl = document.getElementById(styleId);
|
||||
if (styleEl) {
|
||||
styleEl.remove();
|
||||
}
|
||||
}, 3000);
|
||||
}
|
||||
|
||||
// Call the onClick handler if provided
|
||||
onClick?.();
|
||||
}, [onClick, variant, start, url, theme]);
|
||||
|
||||
return (
|
||||
<Button
|
||||
variant="outline"
|
||||
size={showLabel ? 'default' : 'icon'}
|
||||
onClick={handleClick}
|
||||
className={cn(
|
||||
'relative overflow-hidden transition-all',
|
||||
showLabel && 'gap-2',
|
||||
className
|
||||
)}
|
||||
aria-label={`Switch to ${theme === 'light' ? 'dark' : 'light'} theme`}
|
||||
>
|
||||
{theme === 'light' ? (
|
||||
<Sun className="h-[1.2rem] w-[1.2rem]" />
|
||||
) : (
|
||||
<Moon className="h-[1.2rem] w-[1.2rem]" />
|
||||
)}
|
||||
{showLabel && (
|
||||
<span className="text-sm">
|
||||
{theme === 'light' ? 'Light' : 'Dark'}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
);
|
||||
};
|
||||
|
||||
// Export a helper hook for using with View Transitions API
|
||||
export const useThemeTransition = () => {
|
||||
const startTransition = useCallback((updateFn: () => void) => {
|
||||
if ('startViewTransition' in document) {
|
||||
(document as any).startViewTransition(updateFn);
|
||||
} else {
|
||||
updateFn();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { startTransition };
|
||||
};
|
||||
93
src/components/common/triple-column-adaptive-drawer.tsx
Normal file
93
src/components/common/triple-column-adaptive-drawer.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import { ReactNode } from 'react'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import {
|
||||
Drawer,
|
||||
DrawerContent,
|
||||
DrawerTitle,
|
||||
DrawerDescription,
|
||||
DrawerTrigger,
|
||||
} from '@/components/ui/drawer'
|
||||
import { CarouselLayout, CarouselColumn } from '@/components/layout/carousel-layout'
|
||||
|
||||
export interface TripleColumnConfig {
|
||||
/** 列的唯一标识 */
|
||||
id: string
|
||||
/** 列的标题 */
|
||||
title: string
|
||||
/** 列的内容 */
|
||||
content: ReactNode
|
||||
}
|
||||
|
||||
export interface TripleColumnAdaptiveDrawerProps {
|
||||
/** 触发器元素 */
|
||||
trigger: ReactNode
|
||||
/** 抽屉标题(用于无障碍访问) */
|
||||
drawerTitle: string
|
||||
/** 抽屉描述(用于无障碍访问) */
|
||||
drawerDescription: string
|
||||
/** 三列配置 */
|
||||
columns: [TripleColumnConfig, TripleColumnConfig, TripleColumnConfig]
|
||||
/** 默认激活的列(移动端) */
|
||||
defaultActiveColumn?: 0 | 1 | 2
|
||||
/** 抽屉高度类名 */
|
||||
heightClassName?: string
|
||||
/** 是否打开(受控) */
|
||||
open: boolean
|
||||
/** 打开状态变化回调 */
|
||||
onOpenChange: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 三栏自适应抽屉组件
|
||||
*
|
||||
* 在桌面端显示三栏并排布局,在移动端通过拖拽左右切换栏目
|
||||
*/
|
||||
export function TripleColumnAdaptiveDrawer({
|
||||
trigger,
|
||||
drawerTitle,
|
||||
drawerDescription,
|
||||
columns,
|
||||
defaultActiveColumn = 1,
|
||||
heightClassName = 'h-[85vh] md:h-[70vh] 2xl:h-[50vh]',
|
||||
open,
|
||||
onOpenChange,
|
||||
}: TripleColumnAdaptiveDrawerProps) {
|
||||
const handleOpenChange = (newOpen: boolean) => {
|
||||
if (newOpen) {
|
||||
// 使当前拥有焦点的元素(通常是用来触发打开这个drawer的控件)失去焦点,不然控制台会警告焦点在一个要被隐藏于屏幕阅读器的控件上
|
||||
(document.activeElement as HTMLElement)?.blur();
|
||||
}
|
||||
onOpenChange(newOpen)
|
||||
}
|
||||
|
||||
// 转换为 CarouselColumn 格式
|
||||
const carouselColumns: CarouselColumn[] = columns.map((column) => ({
|
||||
id: column.id,
|
||||
title: column.title,
|
||||
content: column.content,
|
||||
desktopClassName: 'flex-1 p-4',
|
||||
mobileClassName: 'p-4',
|
||||
}))
|
||||
|
||||
return (
|
||||
<Drawer open={open} onOpenChange={handleOpenChange}>
|
||||
<DrawerTrigger asChild>
|
||||
{trigger}
|
||||
</DrawerTrigger>
|
||||
<DrawerContent className={heightClassName}>
|
||||
<VisuallyHidden>
|
||||
<DrawerTitle>{drawerTitle}</DrawerTitle>
|
||||
<DrawerDescription>{drawerDescription}</DrawerDescription>
|
||||
</VisuallyHidden>
|
||||
<CarouselLayout
|
||||
columns={carouselColumns}
|
||||
defaultActiveIndex={defaultActiveColumn}
|
||||
showDesktopDivider={true}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
91
src/components/data-details/detail-badge-list.tsx
Normal file
91
src/components/data-details/detail-badge-list.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Badge, badgeVariants } from '@/components/ui/badge'
|
||||
import { type VariantProps } from 'class-variance-authority'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailBadgeListProps {
|
||||
items: Array<{ label: string; variant?: VariantProps<typeof badgeVariants>['variant'] }>
|
||||
maxVisible?: number
|
||||
onBadgeClick?: (label: string) => void
|
||||
grouped?: boolean
|
||||
groups?: Record<string, Array<{ label: string; variant?: VariantProps<typeof badgeVariants>['variant'] }>>
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Badge列表组件
|
||||
* 展示Badge列表,支持分组和折叠
|
||||
*/
|
||||
export function DetailBadgeList({
|
||||
items,
|
||||
maxVisible,
|
||||
onBadgeClick,
|
||||
grouped = false,
|
||||
groups,
|
||||
className,
|
||||
}: DetailBadgeListProps) {
|
||||
if (grouped && groups) {
|
||||
return (
|
||||
<div className={cn('space-y-4', className)}>
|
||||
{Object.entries(groups).map(([groupName, groupItems]) => (
|
||||
<div key={groupName} className="space-y-2.5">
|
||||
<div className="text-xs font-semibold text-muted-foreground uppercase tracking-wide">
|
||||
{groupName}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{groupItems.map((item, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant={item.variant || 'secondary'}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1',
|
||||
onBadgeClick && 'cursor-pointer hover:opacity-80 transition-opacity'
|
||||
)}
|
||||
onClick={() => onBadgeClick?.(item.label)}
|
||||
>
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const visibleItems = maxVisible ? items.slice(0, maxVisible) : items
|
||||
const remainingCount = maxVisible ? items.length - maxVisible : 0
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground', className)}>
|
||||
无
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-wrap gap-2', className)}>
|
||||
{visibleItems.map((item, index) => (
|
||||
<Badge
|
||||
key={index}
|
||||
variant={item.variant || 'secondary'}
|
||||
className={cn(
|
||||
'text-xs px-2.5 py-1',
|
||||
onBadgeClick && 'cursor-pointer hover:opacity-80 transition-opacity'
|
||||
)}
|
||||
onClick={() => onBadgeClick?.(item.label)}
|
||||
>
|
||||
{item.label}
|
||||
</Badge>
|
||||
))}
|
||||
{remainingCount > 0 && (
|
||||
<Badge variant="outline" className="text-xs px-2.5 py-1">
|
||||
+{remainingCount}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
184
src/components/data-details/detail-code-block.tsx
Normal file
184
src/components/data-details/detail-code-block.tsx
Normal file
@@ -0,0 +1,184 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy, Check, Maximize2 } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Dialog,
|
||||
DialogBody,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
import { Highlight, themes } from 'prism-react-renderer'
|
||||
import { useTheme } from 'next-themes'
|
||||
|
||||
export interface DetailCodeBlockProps {
|
||||
code: string
|
||||
language?: string
|
||||
title?: string
|
||||
copyable?: boolean
|
||||
showLineNumbers?: boolean
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 代码块组件
|
||||
* 展示代码或JSON数据,支持复制和行号显示功能
|
||||
*/
|
||||
export function DetailCodeBlock({
|
||||
code,
|
||||
language = 'text',
|
||||
title,
|
||||
copyable = true,
|
||||
showLineNumbers = true,
|
||||
maxHeight = '400px',
|
||||
className,
|
||||
}: DetailCodeBlockProps) {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
const [fullscreenOpen, setFullscreenOpen] = React.useState(false)
|
||||
const { theme } = useTheme()
|
||||
|
||||
const handleCopy = () => {
|
||||
const success = copy(code)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
// 根据主题选择合适的代码高亮主题
|
||||
const prismTheme = theme === 'dark' ? themes.vsDark : themes.vsLight
|
||||
|
||||
// 渲染代码高亮内容
|
||||
const renderCodeContent = (isFullscreen = false) => (
|
||||
<Highlight
|
||||
theme={prismTheme}
|
||||
code={code}
|
||||
language={language as any}
|
||||
>
|
||||
{({ className: highlightClassName, style, tokens, getLineProps, getTokenProps }) => {
|
||||
// 提取背景色用于外层容器
|
||||
const backgroundColor = style?.backgroundColor
|
||||
// 计算行号的最大宽度
|
||||
const lineNumberWidth = String(tokens.length).length
|
||||
|
||||
return (
|
||||
<div
|
||||
className="overflow-auto p-4"
|
||||
style={{
|
||||
maxHeight: isFullscreen ? undefined : maxHeight,
|
||||
backgroundColor
|
||||
}}
|
||||
>
|
||||
<pre
|
||||
className={cn(
|
||||
'text-sm leading-relaxed',
|
||||
isFullscreen && 'whitespace-pre',
|
||||
highlightClassName
|
||||
)}
|
||||
style={{ ...style, backgroundColor: 'transparent' }}
|
||||
>
|
||||
{tokens.map((line, i) => (
|
||||
<div key={i} {...getLineProps({ line })} className="table-row">
|
||||
{showLineNumbers && (
|
||||
<span
|
||||
className="table-cell select-none pr-4 text-right opacity-50"
|
||||
style={{
|
||||
width: `${lineNumberWidth + 1}ch`,
|
||||
minWidth: `${lineNumberWidth + 1}ch`,
|
||||
}}
|
||||
>
|
||||
{i + 1}
|
||||
</span>
|
||||
)}
|
||||
<span className="table-cell">
|
||||
{line.map((token, key) => (
|
||||
<span key={key} {...getTokenProps({ token })} />
|
||||
))}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</pre>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</Highlight>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className={cn('relative rounded-lg border bg-muted/50', className)}>
|
||||
{(title || copyable) && (
|
||||
<div className="flex items-center justify-between gap-3 border-b px-4 py-3 bg-muted/30">
|
||||
{title && (
|
||||
<div className="text-sm font-semibold flex-1 break-words min-w-0 self-center">{title}</div>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={() => setFullscreenOpen(true)}
|
||||
>
|
||||
<Maximize2 className="h-3.5 w-3.5 mr-1.5" />
|
||||
全屏
|
||||
</Button>
|
||||
{copyable && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-3"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<>
|
||||
<Check className="h-3.5 w-3.5 mr-1.5" />
|
||||
已复制
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Copy className="h-3.5 w-3.5 mr-1.5" />
|
||||
复制
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{renderCodeContent()}
|
||||
</div>
|
||||
|
||||
{/* 全屏对话框 */}
|
||||
<Dialog open={fullscreenOpen} onOpenChange={setFullscreenOpen}>
|
||||
<DialogContent className="p-0" variant="fullscreen">
|
||||
<DialogHeader className="pt-5 pb-3 m-0 border-b border-border">
|
||||
<DialogTitle className="px-6 text-base">{title || '代码查看'}</DialogTitle>
|
||||
<DialogDescription className="sr-only">
|
||||
全屏查看代码
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogBody className="overflow-auto">
|
||||
{renderCodeContent(true)}
|
||||
</DialogBody>
|
||||
<DialogFooter className="px-6 py-4 border-t border-border">
|
||||
<DialogClose asChild>
|
||||
<Button type="button">关闭</Button>
|
||||
</DialogClose>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</>
|
||||
)
|
||||
}
|
||||
93
src/components/data-details/detail-copyable.tsx
Normal file
93
src/components/data-details/detail-copyable.tsx
Normal file
@@ -0,0 +1,93 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy, Check } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface DetailCopyableProps {
|
||||
value: string
|
||||
label?: string
|
||||
truncate?: boolean
|
||||
maxLength?: number
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 可复制文本组件
|
||||
* 带复制按钮的文本展示,支持截断和悬停显示完整内容
|
||||
*/
|
||||
export function DetailCopyable({
|
||||
value,
|
||||
label,
|
||||
truncate = false,
|
||||
maxLength = 50,
|
||||
className,
|
||||
}: DetailCopyableProps) {
|
||||
const [copied, setCopied] = React.useState(false)
|
||||
|
||||
const handleCopy = () => {
|
||||
const success = copy(value)
|
||||
if (success) {
|
||||
setCopied(true)
|
||||
toast.success('已复制到剪贴板')
|
||||
setTimeout(() => setCopied(false), 2000)
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
|
||||
const displayValue = truncate && value.length > maxLength
|
||||
? `${value.slice(0, maxLength)}...`
|
||||
: value
|
||||
|
||||
const content = (
|
||||
<div className={cn('flex items-center gap-2 group', className)}>
|
||||
{label && (
|
||||
<span className="text-sm font-medium text-muted-foreground">
|
||||
{label}:
|
||||
</span>
|
||||
)}
|
||||
<code className="flex-1 text-sm bg-muted px-2 py-1 rounded font-mono break-all">
|
||||
{displayValue}
|
||||
</code>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
{copied ? (
|
||||
<Check className="h-3 w-3" />
|
||||
) : (
|
||||
<Copy className="h-3 w-3" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (truncate && value.length > maxLength) {
|
||||
return (
|
||||
<TooltipProvider>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
{content}
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom" className="max-w-md break-all">
|
||||
<code className="text-xs">{value}</code>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
)
|
||||
}
|
||||
|
||||
return content
|
||||
}
|
||||
32
src/components/data-details/detail-field-group.tsx
Normal file
32
src/components/data-details/detail-field-group.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailFieldGroupProps {
|
||||
columns?: 1 | 2 | 3
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段组组件
|
||||
* 将多个字段组织在一起,提供网格布局
|
||||
*/
|
||||
export function DetailFieldGroup({
|
||||
columns = 2,
|
||||
children,
|
||||
className,
|
||||
}: DetailFieldGroupProps) {
|
||||
const gridCols = {
|
||||
1: 'grid-cols-1',
|
||||
2: 'grid-cols-1 md:grid-cols-2',
|
||||
3: 'grid-cols-1 md:grid-cols-2 lg:grid-cols-3',
|
||||
}
|
||||
|
||||
return (
|
||||
<dl className={cn('grid gap-6', gridCols[columns], className)}>
|
||||
{children}
|
||||
</dl>
|
||||
)
|
||||
}
|
||||
91
src/components/data-details/detail-field.tsx
Normal file
91
src/components/data-details/detail-field.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Copy } from 'lucide-react'
|
||||
import copy from 'copy-to-clipboard'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { toast } from 'sonner'
|
||||
|
||||
export interface DetailFieldProps {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
direction?: 'horizontal' | 'vertical'
|
||||
copyable?: boolean
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 字段展示组件
|
||||
* 展示标签-值对,支持水平和垂直布局
|
||||
*/
|
||||
export function DetailField({
|
||||
label,
|
||||
value,
|
||||
direction = 'vertical',
|
||||
copyable = false,
|
||||
className,
|
||||
}: DetailFieldProps) {
|
||||
const handleCopy = () => {
|
||||
if (typeof value === 'string') {
|
||||
const success = copy(value)
|
||||
if (success) {
|
||||
toast.success('已复制到剪贴板')
|
||||
} else {
|
||||
toast.error('复制失败')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const isEmpty = value === null || value === undefined || value === ''
|
||||
|
||||
if (direction === 'horizontal') {
|
||||
return (
|
||||
<div className={cn('flex items-start gap-6 py-2', className)}>
|
||||
<dt className="text-sm font-medium text-muted-foreground min-w-[120px] flex-shrink-0">
|
||||
{label}
|
||||
</dt>
|
||||
<dd className="text-sm flex-1 min-w-0 flex items-start gap-2">
|
||||
{isEmpty ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="break-words leading-relaxed">{value}</span>
|
||||
)}
|
||||
{copyable && !isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<dt className="text-sm font-medium text-muted-foreground">{label}</dt>
|
||||
<dd className="text-sm flex items-start gap-2">
|
||||
{isEmpty ? (
|
||||
<span className="text-muted-foreground">-</span>
|
||||
) : (
|
||||
<span className="break-words flex-1 leading-relaxed">{value}</span>
|
||||
)}
|
||||
{copyable && !isEmpty && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-7 w-7 flex-shrink-0"
|
||||
onClick={handleCopy}
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
)}
|
||||
</dd>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
47
src/components/data-details/detail-header.tsx
Normal file
47
src/components/data-details/detail-header.tsx
Normal file
@@ -0,0 +1,47 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailHeaderProps {
|
||||
title: React.ReactNode
|
||||
subtitle?: React.ReactNode
|
||||
icon?: React.ReactNode
|
||||
actions?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情头部组件
|
||||
* 展示标题、副标题、图标和操作按钮
|
||||
*/
|
||||
export function DetailHeader({
|
||||
title,
|
||||
subtitle,
|
||||
icon,
|
||||
actions,
|
||||
className,
|
||||
}: DetailHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-start justify-between gap-6 pb-6 border-b', className)}>
|
||||
<div className="flex items-start gap-4 flex-1 min-w-0">
|
||||
{icon && (
|
||||
<div className="flex-shrink-0 mt-1.5 text-muted-foreground">
|
||||
{icon}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1 min-w-0 space-y-3">
|
||||
<h3 className="text-2xl font-bold leading-tight tracking-tight">
|
||||
{title}
|
||||
</h3>
|
||||
{subtitle && (
|
||||
<div className="text-sm text-muted-foreground flex items-center gap-2 flex-wrap">
|
||||
{subtitle}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{actions && <div className="flex-shrink-0">{actions}</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
105
src/components/data-details/detail-list.tsx
Normal file
105
src/components/data-details/detail-list.tsx
Normal file
@@ -0,0 +1,105 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { Search, type LucideIcon } from 'lucide-react'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailListProps {
|
||||
items: Array<{
|
||||
id: string
|
||||
label: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
onClick?: () => void
|
||||
}>
|
||||
searchable?: boolean
|
||||
emptyText?: string
|
||||
maxHeight?: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 通用列表组件
|
||||
* 展示项目列表,支持图标、描述、操作和搜索
|
||||
*/
|
||||
export function DetailList({
|
||||
items,
|
||||
searchable = false,
|
||||
emptyText = '暂无数据',
|
||||
maxHeight = '300px',
|
||||
className,
|
||||
}: DetailListProps) {
|
||||
const [searchQuery, setSearchQuery] = React.useState('')
|
||||
|
||||
const filteredItems = React.useMemo(() => {
|
||||
if (!searchQuery) return items
|
||||
const query = searchQuery.toLowerCase()
|
||||
return items.filter(
|
||||
(item) =>
|
||||
item.label.toLowerCase().includes(query) ||
|
||||
item.description?.toLowerCase().includes(query)
|
||||
)
|
||||
}, [items, searchQuery])
|
||||
|
||||
if (items.length === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
|
||||
{emptyText}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-3', className)}>
|
||||
{searchable && (
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="搜索..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 h-9"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<div
|
||||
className="space-y-1.5 overflow-y-auto rounded-md border bg-muted/30 p-2"
|
||||
style={{ maxHeight }}
|
||||
>
|
||||
{filteredItems.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground text-center py-8">
|
||||
未找到匹配项
|
||||
</div>
|
||||
) : (
|
||||
filteredItems.map((item) => {
|
||||
const Icon = item.icon
|
||||
return (
|
||||
<div
|
||||
key={item.id}
|
||||
className={cn(
|
||||
'flex items-start gap-3 rounded-md p-3 text-sm transition-colors',
|
||||
item.onClick &&
|
||||
'cursor-pointer hover:bg-accent hover:text-accent-foreground'
|
||||
)}
|
||||
onClick={item.onClick}
|
||||
>
|
||||
{Icon && (
|
||||
<Icon className="h-4 w-4 mt-0.5 text-muted-foreground flex-shrink-0" />
|
||||
)}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-medium break-words leading-relaxed">{item.label}</div>
|
||||
{item.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 break-words leading-relaxed">
|
||||
{item.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
90
src/components/data-details/detail-section.tsx
Normal file
90
src/components/data-details/detail-section.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import { ChevronDown, type LucideIcon } from 'lucide-react'
|
||||
import {
|
||||
Collapsible,
|
||||
CollapsibleContent,
|
||||
CollapsibleTrigger,
|
||||
} from '@/components/ui/collapsible'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailSectionProps {
|
||||
title?: string
|
||||
description?: string
|
||||
icon?: LucideIcon
|
||||
collapsible?: boolean
|
||||
defaultOpen?: boolean
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
onOpenChange?: (open: boolean) => void
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情分区组件
|
||||
* 支持标题、描述、图标和折叠功能
|
||||
*/
|
||||
export function DetailSection({
|
||||
title,
|
||||
description,
|
||||
icon: Icon,
|
||||
collapsible = false,
|
||||
defaultOpen = true,
|
||||
children,
|
||||
className,
|
||||
onOpenChange,
|
||||
}: DetailSectionProps) {
|
||||
const [isOpen, setIsOpen] = React.useState(defaultOpen)
|
||||
|
||||
const handleOpenChange = React.useCallback((open: boolean) => {
|
||||
setIsOpen(open)
|
||||
onOpenChange?.(open)
|
||||
}, [onOpenChange])
|
||||
|
||||
const header = title && (
|
||||
<div className="flex items-center gap-2.5 mb-4">
|
||||
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
<h4 className="text-base font-semibold">{title}</h4>
|
||||
</div>
|
||||
)
|
||||
|
||||
const desc = description && (
|
||||
<p className="text-sm text-muted-foreground mb-4">{description}</p>
|
||||
)
|
||||
|
||||
if (collapsible && title) {
|
||||
return (
|
||||
<div className={cn('space-y-4 py-1', className)}>
|
||||
<Collapsible open={isOpen} onOpenChange={handleOpenChange}>
|
||||
<CollapsibleTrigger className="flex items-center justify-between w-full group hover:opacity-80 transition-opacity">
|
||||
<div className="flex items-center gap-2.5">
|
||||
{Icon && <Icon className="h-5 w-5 text-muted-foreground" />}
|
||||
<h4 className="text-base font-semibold">{title}</h4>
|
||||
</div>
|
||||
<ChevronDown
|
||||
className={cn(
|
||||
'h-5 w-5 text-muted-foreground transition-transform duration-200',
|
||||
isOpen && 'transform rotate-180'
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
{desc}
|
||||
<CollapsibleContent className="space-y-4 pt-2">
|
||||
{children}
|
||||
</CollapsibleContent>
|
||||
</Collapsible>
|
||||
<Separator />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-4 py-1', className)}>
|
||||
{header}
|
||||
{desc}
|
||||
{children}
|
||||
<Separator />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
65
src/components/data-details/detail-sheet.tsx
Normal file
65
src/components/data-details/detail-sheet.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
'use client'
|
||||
|
||||
import * as React from 'react'
|
||||
import {
|
||||
Sheet,
|
||||
SheetContent,
|
||||
SheetDescription,
|
||||
SheetHeader,
|
||||
SheetTitle,
|
||||
} from '@/components/ui/sheet'
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
export interface DetailSheetProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
children: React.ReactNode
|
||||
width?: 'sm' | 'md' | 'lg' | 'xl' | 'full'
|
||||
header: string | React.ReactNode
|
||||
description: string | React.ReactNode
|
||||
}
|
||||
|
||||
const widthClasses = {
|
||||
sm: 'sm:max-w-sm',
|
||||
md: 'sm:max-w-md',
|
||||
lg: 'sm:max-w-lg',
|
||||
xl: 'sm:max-w-xl',
|
||||
full: 'sm:max-w-full',
|
||||
}
|
||||
|
||||
/**
|
||||
* 详情展示Sheet容器
|
||||
* 提供基础的Sheet开关和布局,不包含任何业务逻辑
|
||||
*/
|
||||
export function DetailSheet({
|
||||
open,
|
||||
onOpenChange,
|
||||
children,
|
||||
width = 'lg',
|
||||
header = '详情',
|
||||
description = "",
|
||||
}: DetailSheetProps) {
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent
|
||||
className={cn('overflow-y-auto p-6', widthClasses[width])}
|
||||
side="right"
|
||||
>
|
||||
<SheetHeader className="space-y-3">
|
||||
{typeof header === 'string' ? (
|
||||
<SheetTitle className="text-xl">{header}</SheetTitle>
|
||||
) : (
|
||||
header
|
||||
)}
|
||||
{typeof description === 'string' ? (
|
||||
<SheetDescription className="text-base">{description}</SheetDescription>
|
||||
) : (
|
||||
description
|
||||
)}
|
||||
</SheetHeader>
|
||||
{children}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
)
|
||||
}
|
||||
394
src/components/data-details/detail-timeline.tsx
Normal file
394
src/components/data-details/detail-timeline.tsx
Normal file
@@ -0,0 +1,394 @@
|
||||
|
||||
"use client"
|
||||
|
||||
import * as React from 'react'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { LucideIcon } from 'lucide-react'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { formatDate } from '@/lib/format'
|
||||
|
||||
// ==================== Context ====================
|
||||
|
||||
/** Timeline Context 值类型 */
|
||||
interface TimelineContextValue {
|
||||
/** 时间轴项总数 */
|
||||
totalItems: number
|
||||
}
|
||||
|
||||
const TimelineContext = React.createContext<TimelineContextValue | undefined>(undefined)
|
||||
|
||||
const useTimelineContext = () => {
|
||||
const context = React.useContext(TimelineContext)
|
||||
if (!context) {
|
||||
throw new Error('Timeline 子组件必须在 Timeline 组件内使用')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
/** TimelineItem Context */
|
||||
interface TimelineItemContextValue {
|
||||
/** 是否是最后一项 */
|
||||
isLast: boolean
|
||||
/** 当前项索引 */
|
||||
index: number
|
||||
}
|
||||
|
||||
const TimelineItemContext = React.createContext<TimelineItemContextValue | undefined>(undefined)
|
||||
|
||||
const useTimelineItemContext = () => {
|
||||
return React.useContext(TimelineItemContext)
|
||||
}
|
||||
|
||||
// ==================== Timeline 根容器 ====================
|
||||
|
||||
export interface TimelineProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴根容器组件
|
||||
* 提供时间轴的整体布局和 Context
|
||||
*/
|
||||
export function Timeline({ children, className }: TimelineProps) {
|
||||
// 统计 TimelineItem 子组件数量
|
||||
const items = React.Children.toArray(children).filter(
|
||||
(child) => React.isValidElement(child) && child.type === TimelineItem
|
||||
)
|
||||
|
||||
const contextValue: TimelineContextValue = {
|
||||
totalItems: items.length,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineContext.Provider value={contextValue}>
|
||||
<div className={cn('relative space-y-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TimelineContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineEmpty 空状态 ====================
|
||||
|
||||
export interface TimelineEmptyProps {
|
||||
children?: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴空状态组件
|
||||
*/
|
||||
export function TimelineEmpty({ children = '暂无记录', className }: TimelineEmptyProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-center py-8 text-sm text-muted-foreground', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineItem 时间轴项容器 ====================
|
||||
|
||||
export interface TimelineItemProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴单项容器
|
||||
*/
|
||||
export function TimelineItem({ children, className }: TimelineItemProps) {
|
||||
const context = useTimelineContext()
|
||||
|
||||
// 动态计算索引
|
||||
const itemRef = React.useRef<HTMLDivElement>(null)
|
||||
const [itemIndex, setItemIndex] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (itemRef.current) {
|
||||
const parent = itemRef.current.parentElement
|
||||
if (parent) {
|
||||
const allItems = Array.from(parent.children)
|
||||
const currentIndex = allItems.indexOf(itemRef.current)
|
||||
setItemIndex(currentIndex)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
const isLast = itemIndex === context.totalItems - 1
|
||||
|
||||
const itemContextValue: TimelineItemContextValue = {
|
||||
isLast,
|
||||
index: itemIndex,
|
||||
}
|
||||
|
||||
return (
|
||||
<TimelineItemContext.Provider value={itemContextValue}>
|
||||
<div ref={itemRef} className={cn('relative flex gap-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
</TimelineItemContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineConnector 连接线 ====================
|
||||
|
||||
export interface TimelineConnectorProps {
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴连接线组件
|
||||
* 自动判断是否为最后一项来决定是否显示
|
||||
*/
|
||||
export function TimelineConnector({ className }: TimelineConnectorProps) {
|
||||
const itemContext = useTimelineItemContext()
|
||||
|
||||
if (!itemContext || itemContext.isLast) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('absolute left-[15px] top-8 h-full w-[2px] bg-border', className)} />
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineNode 节点(图标) ====================
|
||||
|
||||
export interface TimelineNodeProps {
|
||||
/** 图标组件 */
|
||||
icon?: LucideIcon
|
||||
/** 节点样式 */
|
||||
className?: string
|
||||
/** 图标样式 */
|
||||
iconClassName?: string
|
||||
/** 是否显示默认圆点(当无图标时) */
|
||||
showDot?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴节点组件
|
||||
* 可以显示图标或圆点
|
||||
*/
|
||||
export function TimelineNode({
|
||||
icon: Icon,
|
||||
className,
|
||||
iconClassName,
|
||||
showDot = true,
|
||||
}: TimelineNodeProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex h-8 w-8 shrink-0 items-center justify-center rounded-full border-2 border-border bg-background',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{Icon ? (
|
||||
<Icon className={cn('h-4 w-4 text-muted-foreground', iconClassName)} />
|
||||
) : showDot ? (
|
||||
<div className="h-2 w-2 rounded-full bg-muted-foreground" />
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineContent 内容区域 ====================
|
||||
|
||||
export interface TimelineContentProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴内容区域容器
|
||||
*/
|
||||
export function TimelineContent({ children, className }: TimelineContentProps) {
|
||||
return (
|
||||
<div className={cn('flex-1 space-y-2 pb-4', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineHeader 头部容器 ====================
|
||||
|
||||
export interface TimelineHeaderProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴头部容器(包含标题、徽章、操作等)
|
||||
*/
|
||||
export function TimelineHeader({ children, className }: TimelineHeaderProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center justify-between gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTitleArea 标题区域 ====================
|
||||
|
||||
export interface TimelineTitleAreaProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴标题区域容器(包含标题和徽章)
|
||||
*/
|
||||
export function TimelineTitleArea({ children, className }: TimelineTitleAreaProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2 flex-1 min-w-0', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTitle 标题 ====================
|
||||
|
||||
export interface TimelineTitleProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴标题组件
|
||||
*/
|
||||
export function TimelineTitle({ children, className }: TimelineTitleProps) {
|
||||
return (
|
||||
<h4 className={cn('text-sm font-medium leading-none', className)}>
|
||||
{children}
|
||||
</h4>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineBadge 徽章 ====================
|
||||
|
||||
export interface TimelineBadgeProps {
|
||||
children: React.ReactNode
|
||||
variant?: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴徽章组件
|
||||
*/
|
||||
export function TimelineBadge({
|
||||
children,
|
||||
variant = 'default',
|
||||
className
|
||||
}: TimelineBadgeProps) {
|
||||
return (
|
||||
<Badge variant={variant} className={cn('text-xs shrink-0', className)}>
|
||||
{children}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineActions 操作区域 ====================
|
||||
|
||||
export interface TimelineActionsProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴操作区域容器
|
||||
*/
|
||||
export function TimelineActions({ children, className }: TimelineActionsProps) {
|
||||
return (
|
||||
<div className={cn('flex items-center gap-2', className)}>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineTimestamp 时间戳 ====================
|
||||
|
||||
export interface TimelineTimestampProps {
|
||||
/** 时间戳(Date 对象或字符串) */
|
||||
timestamp: Date | string
|
||||
/** 自定义格式化函数 */
|
||||
format?: (timestamp: Date | string) => string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴时间戳组件
|
||||
*/
|
||||
export function TimelineTimestamp({
|
||||
timestamp,
|
||||
format,
|
||||
className
|
||||
}: TimelineTimestampProps) {
|
||||
const displayText = format
|
||||
? format(timestamp)
|
||||
: typeof timestamp === 'string'
|
||||
? timestamp
|
||||
: formatDate(timestamp, 'PPP HH:mm:ss')
|
||||
|
||||
return (
|
||||
<p className={cn('text-xs text-muted-foreground', className)}>
|
||||
{displayText}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineDescription 描述 ====================
|
||||
|
||||
export interface TimelineDescriptionProps {
|
||||
children: React.ReactNode
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴描述组件
|
||||
*/
|
||||
export function TimelineDescription({ children, className }: TimelineDescriptionProps) {
|
||||
return (
|
||||
<p className={cn('text-sm text-muted-foreground', className)}>
|
||||
{children}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
// ==================== TimelineMetadata 元数据 ====================
|
||||
|
||||
export interface TimelineMetadataItem {
|
||||
label: string
|
||||
value: React.ReactNode
|
||||
className?: string
|
||||
labelClassName?: string
|
||||
valueClassName?: string
|
||||
}
|
||||
|
||||
export interface TimelineMetadataProps {
|
||||
/** 元数据列表 */
|
||||
items: TimelineMetadataItem[]
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间轴元数据组件
|
||||
*/
|
||||
export function TimelineMetadata({ items, className }: TimelineMetadataProps) {
|
||||
if (!items || items.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('mt-2 space-y-1', className)}>
|
||||
{items.map((item, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={cn("flex items-center gap-2 text-xs text-muted-foreground", item.className)}
|
||||
>
|
||||
<span className={cn('font-medium', item.labelClassName)}>{item.label}:</span>
|
||||
<span className={cn('font-mono', item.valueClassName)}>{item.value}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
67
src/components/data-details/index.ts
Normal file
67
src/components/data-details/index.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
/**
|
||||
* 通用对象详情展示框架
|
||||
*
|
||||
* 基于"小而美"的原子组件设计理念,提供一套可自由组装的详情展示组件库。
|
||||
* 每个组件职责单一,无复杂配置,通过组合而非配置实现灵活性。
|
||||
*/
|
||||
|
||||
export { DetailSheet } from './detail-sheet'
|
||||
export type { DetailSheetProps } from './detail-sheet'
|
||||
|
||||
export { DetailHeader } from './detail-header'
|
||||
export type { DetailHeaderProps } from './detail-header'
|
||||
|
||||
export { DetailSection } from './detail-section'
|
||||
export type { DetailSectionProps } from './detail-section'
|
||||
|
||||
export { DetailField } from './detail-field'
|
||||
export type { DetailFieldProps } from './detail-field'
|
||||
|
||||
export { DetailFieldGroup } from './detail-field-group'
|
||||
export type { DetailFieldGroupProps } from './detail-field-group'
|
||||
|
||||
export { DetailBadgeList } from './detail-badge-list'
|
||||
export type { DetailBadgeListProps } from './detail-badge-list'
|
||||
|
||||
export { DetailList } from './detail-list'
|
||||
export type { DetailListProps } from './detail-list'
|
||||
|
||||
export { DetailCodeBlock } from './detail-code-block'
|
||||
export type { DetailCodeBlockProps } from './detail-code-block'
|
||||
|
||||
export { DetailCopyable } from './detail-copyable'
|
||||
export type { DetailCopyableProps } from './detail-copyable'
|
||||
|
||||
export {
|
||||
Timeline,
|
||||
TimelineEmpty,
|
||||
TimelineItem,
|
||||
TimelineConnector,
|
||||
TimelineNode,
|
||||
TimelineContent,
|
||||
TimelineHeader,
|
||||
TimelineTitleArea,
|
||||
TimelineTitle,
|
||||
TimelineBadge,
|
||||
TimelineActions,
|
||||
TimelineTimestamp,
|
||||
TimelineDescription,
|
||||
TimelineMetadata,
|
||||
} from './detail-timeline'
|
||||
export type {
|
||||
TimelineProps,
|
||||
TimelineEmptyProps,
|
||||
TimelineItemProps,
|
||||
TimelineConnectorProps,
|
||||
TimelineNodeProps,
|
||||
TimelineContentProps,
|
||||
TimelineHeaderProps,
|
||||
TimelineTitleAreaProps,
|
||||
TimelineTitleProps,
|
||||
TimelineBadgeProps,
|
||||
TimelineActionsProps,
|
||||
TimelineTimestampProps,
|
||||
TimelineDescriptionProps,
|
||||
TimelineMetadataProps,
|
||||
TimelineMetadataItem,
|
||||
} from './detail-timeline'
|
||||
178
src/components/data-table/action-bar.tsx
Normal file
178
src/components/data-table/action-bar.tsx
Normal file
@@ -0,0 +1,178 @@
|
||||
"use client";
|
||||
|
||||
import type { Table } from "@tanstack/react-table";
|
||||
import { Loader, X } from "lucide-react";
|
||||
import { AnimatePresence, motion } from "motion/react";
|
||||
import * as React from "react";
|
||||
import * as ReactDOM from "react-dom";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipTrigger,
|
||||
} from "@/components/ui/tooltip";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableActionBarProps<TData>
|
||||
extends React.ComponentProps<typeof motion.div> {
|
||||
table: Table<TData>;
|
||||
visible?: boolean;
|
||||
container?: Element | DocumentFragment | null;
|
||||
}
|
||||
|
||||
function DataTableActionBar<TData>({
|
||||
table,
|
||||
visible: visibleProp,
|
||||
container: containerProp,
|
||||
children,
|
||||
className,
|
||||
...props
|
||||
}: DataTableActionBarProps<TData>) {
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
function onKeyDown(event: KeyboardEvent) {
|
||||
if (event.key === "Escape") {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => window.removeEventListener("keydown", onKeyDown);
|
||||
}, [table]);
|
||||
|
||||
const container =
|
||||
containerProp ?? (mounted ? globalThis.document?.body : null);
|
||||
|
||||
if (!container) return null;
|
||||
|
||||
const visible =
|
||||
visibleProp ?? table.getFilteredSelectedRowModel().rows.length > 0;
|
||||
|
||||
return ReactDOM.createPortal(
|
||||
<AnimatePresence>
|
||||
{visible && (
|
||||
<motion.div
|
||||
role="toolbar"
|
||||
aria-orientation="horizontal"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: 20 }}
|
||||
transition={{ duration: 0.2, ease: "easeInOut" }}
|
||||
className={cn(
|
||||
"fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>,
|
||||
container,
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarActionProps
|
||||
extends React.ComponentProps<typeof Button> {
|
||||
tooltip?: string;
|
||||
isPending?: boolean;
|
||||
}
|
||||
|
||||
function DataTableActionBarAction({
|
||||
size = "sm",
|
||||
tooltip,
|
||||
isPending,
|
||||
disabled,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: DataTableActionBarActionProps) {
|
||||
const trigger = (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size={size}
|
||||
className={cn(
|
||||
"gap-1.5 border border-secondary bg-secondary/50 hover:bg-secondary/70 [&>svg]:size-3.5",
|
||||
size === "icon" ? "size-7" : "h-7",
|
||||
className,
|
||||
)}
|
||||
disabled={disabled || isPending}
|
||||
{...props}
|
||||
>
|
||||
{isPending ? <Loader className="animate-spin" /> : children}
|
||||
</Button>
|
||||
);
|
||||
|
||||
if (!tooltip) return trigger;
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>{trigger}</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={6}
|
||||
className="border bg-accent font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>{tooltip}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
|
||||
interface DataTableActionBarSelectionProps<TData> {
|
||||
table: Table<TData>;
|
||||
}
|
||||
|
||||
function DataTableActionBarSelection<TData>({
|
||||
table,
|
||||
}: DataTableActionBarSelectionProps<TData>) {
|
||||
const onClearSelection = React.useCallback(() => {
|
||||
table.toggleAllRowsSelected(false);
|
||||
}, [table]);
|
||||
|
||||
return (
|
||||
<div className="flex h-7 items-center rounded-md border pr-1 pl-2.5">
|
||||
<span className="whitespace-nowrap text-xs">
|
||||
{table.getFilteredSelectedRowModel().rows.length} selected
|
||||
</span>
|
||||
<Separator
|
||||
orientation="vertical"
|
||||
className="mr-1 ml-2 data-[orientation=vertical]:h-4"
|
||||
/>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-5"
|
||||
onClick={onClearSelection}
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent
|
||||
sideOffset={10}
|
||||
className="flex items-center gap-2 border bg-accent px-2 py-1 font-semibold text-foreground dark:bg-zinc-900 [&>span]:hidden"
|
||||
>
|
||||
<p>Clear selection</p>
|
||||
<kbd className="select-none rounded border bg-background px-1.5 py-px font-mono font-normal text-[0.7rem] text-foreground shadow-xs">
|
||||
<abbr title="Escape" className="no-underline">
|
||||
Esc
|
||||
</abbr>
|
||||
</kbd>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export {
|
||||
DataTableActionBar,
|
||||
DataTableActionBarAction,
|
||||
DataTableActionBarSelection,
|
||||
};
|
||||
99
src/components/data-table/column-header.tsx
Normal file
99
src/components/data-table/column-header.tsx
Normal file
@@ -0,0 +1,99 @@
|
||||
"use client";
|
||||
|
||||
import type { Column } from "@tanstack/react-table";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronsUpDown,
|
||||
ChevronUp,
|
||||
EyeOff,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuCheckboxItem,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableColumnHeaderProps<TData, TValue>
|
||||
extends React.ComponentProps<typeof DropdownMenuTrigger> {
|
||||
column: Column<TData, TValue>;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export function DataTableColumnHeader<TData, TValue>({
|
||||
column,
|
||||
title,
|
||||
className,
|
||||
...props
|
||||
}: DataTableColumnHeaderProps<TData, TValue>) {
|
||||
if (!column.getCanSort() && !column.getCanHide()) {
|
||||
return <div className={cn(className)}>{title}</div>;
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
className={cn(
|
||||
"-ml-1.5 flex h-8 items-center gap-1.5 rounded-md px-2 py-1.5 hover:bg-accent focus:outline-none focus:ring-1 focus:ring-ring data-[state=open]:bg-accent [&_svg]:size-4 [&_svg]:shrink-0 [&_svg]:text-muted-foreground",
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
{title}
|
||||
{column.getCanSort() &&
|
||||
(column.getIsSorted() === "desc" ? (
|
||||
<ChevronDown />
|
||||
) : column.getIsSorted() === "asc" ? (
|
||||
<ChevronUp />
|
||||
) : (
|
||||
<ChevronsUpDown />
|
||||
))}
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-28">
|
||||
{column.getCanSort() && (
|
||||
<>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "asc"}
|
||||
onClick={() => column.toggleSorting(false)}
|
||||
>
|
||||
<ChevronUp />
|
||||
升序
|
||||
</DropdownMenuCheckboxItem>
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={column.getIsSorted() === "desc"}
|
||||
onClick={() => column.toggleSorting(true)}
|
||||
>
|
||||
<ChevronDown />
|
||||
降序
|
||||
</DropdownMenuCheckboxItem>
|
||||
{column.getIsSorted() && (
|
||||
<DropdownMenuItem
|
||||
className="pl-2 [&_svg]:text-muted-foreground"
|
||||
onClick={() => column.clearSorting()}
|
||||
>
|
||||
<X />
|
||||
重置
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
{column.getCanHide() && (
|
||||
<DropdownMenuCheckboxItem
|
||||
className="relative pr-8 pl-2 [&>span:first-child]:right-2 [&>span:first-child]:left-auto [&_svg]:text-muted-foreground"
|
||||
checked={!column.getIsVisible()}
|
||||
onClick={() => column.toggleVisibility(false)}
|
||||
>
|
||||
<EyeOff />
|
||||
隐藏
|
||||
</DropdownMenuCheckboxItem>
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
143
src/components/data-table/data-table.tsx
Normal file
143
src/components/data-table/data-table.tsx
Normal file
@@ -0,0 +1,143 @@
|
||||
import { flexRender, type Table as TanstackTable, Column } from "@tanstack/react-table";
|
||||
import type * as React from "react";
|
||||
|
||||
import { DataTablePagination } from "@/components/data-table/pagination";
|
||||
import { DataTableSkeleton } from "@/components/data-table/table-skeleton";
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface DataTableProps<TData> extends React.ComponentProps<"div"> {
|
||||
table: TanstackTable<TData>;
|
||||
actionBar?: React.ReactNode;
|
||||
className?: string,
|
||||
tableClassName?: string,
|
||||
isLoading?: boolean;
|
||||
}
|
||||
|
||||
export function getGridStyles<TData>({
|
||||
column,
|
||||
header
|
||||
}: {
|
||||
column: Column<TData>;
|
||||
header: boolean
|
||||
}): { className: string; style: React.CSSProperties } {
|
||||
const isPinned = column.getIsPinned();
|
||||
|
||||
return {
|
||||
className: cn(
|
||||
isPinned ? "sticky" : "relative",
|
||||
isPinned && "z-[10]",
|
||||
// 被固定的列头添加背景色和更深的颜色
|
||||
...(header ? [
|
||||
isPinned ? "bg-muted" : "bg-muted",
|
||||
] : [
|
||||
isPinned && "bg-background"
|
||||
]),
|
||||
),
|
||||
style: {
|
||||
left: isPinned === "left" ? `${column.getStart("left")}px` : undefined,
|
||||
right: isPinned === "right" ? `${column.getAfter("right")}px` : undefined,
|
||||
width: column.getSize(),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export function DataTable<TData>({
|
||||
table,
|
||||
actionBar,
|
||||
children,
|
||||
className,
|
||||
tableClassName,
|
||||
isLoading = false,
|
||||
...props
|
||||
}: DataTableProps<TData>) {
|
||||
return (
|
||||
<div
|
||||
className={cn("flex w-full flex-col gap-2.5 overflow-auto", className)}
|
||||
{...props}
|
||||
>
|
||||
{children}
|
||||
{
|
||||
!isLoading ?
|
||||
<>
|
||||
<div className="overflow-hidden rounded-md border">
|
||||
<Table className={tableClassName}>
|
||||
<TableHeader className="sticky top-0 z-10">
|
||||
{table.getHeaderGroups().map((headerGroup) => (
|
||||
<TableRow key={headerGroup.id}>
|
||||
{headerGroup.headers.map((header) => (
|
||||
<TableHead
|
||||
key={header.id}
|
||||
colSpan={header.colSpan}
|
||||
{...getGridStyles({ column: header.column, header: true })}
|
||||
>
|
||||
{header.isPlaceholder
|
||||
? null
|
||||
: flexRender(
|
||||
header.column.columnDef.header,
|
||||
header.getContext(),
|
||||
)}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
))}
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{table.getRowModel().rows?.length ? (
|
||||
table.getRowModel().rows.map((row) => (
|
||||
<TableRow
|
||||
key={row.id}
|
||||
data-state={row.getIsSelected() && "selected"}
|
||||
>
|
||||
{row.getVisibleCells().map((cell) => (
|
||||
<TableCell
|
||||
key={cell.id}
|
||||
{...getGridStyles({ column: cell.column, header: false })}
|
||||
>
|
||||
{flexRender(
|
||||
cell.column.columnDef.cell,
|
||||
cell.getContext(),
|
||||
)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
))
|
||||
) : (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={table.getAllColumns().length}
|
||||
className="h-24 text-center"
|
||||
>
|
||||
暂无数据
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2.5">
|
||||
<DataTablePagination table={table} />
|
||||
{actionBar &&
|
||||
table.getFilteredSelectedRowModel().rows.length > 0 &&
|
||||
actionBar}
|
||||
</div>
|
||||
</> :
|
||||
<DataTableSkeleton
|
||||
columnCount={table.getVisibleFlatColumns().length}
|
||||
rowCount={table.getState().pagination.pageSize}
|
||||
withViewOptions={false}
|
||||
className={className}
|
||||
{...props}
|
||||
/>
|
||||
}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user