Files
2025-11-13 05:05:53 +08:00

947 lines
33 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
export const runtime = 'edge'
import { useEffect, useState } from "react"
import { zodResolver } from "@hookform/resolvers/zod"
import { useForm } from "react-hook-form"
import * as z from "zod"
import { Card, CardContent } from "@/registry/new-york/ui/card"
import { Input } from "@/registry/new-york/ui/input"
import { Button } from "@/registry/new-york/ui/button"
import { useToast } from "@/registry/new-york/hooks/use-toast"
import {
Loader2,
Plus,
Upload,
X,
AlertCircle,
Copy,
Inbox,
Search,
Trash2,
CheckSquare,
Square
} from "lucide-react"
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/registry/new-york/ui/dialog"
import {
Form,
FormControl,
FormDescription,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/registry/new-york/ui/form"
import { Toaster } from "@/registry/new-york/ui/toaster"
const Icons = {
loader2: Loader2,
plus: Plus,
upload: Upload,
x: X,
alertCircle: AlertCircle,
copy: Copy,
inbox: Inbox,
search: Search,
trash2: Trash2,
checkSquare: CheckSquare,
square: Square
}
const formSchema = z.object({
resource: z.object({
image: z.string().optional(),
}),
})
// 假设 ResourceMetadataItem 是 metadata 中每个项的类型
interface ResourceMetadataItem {
hash: string;
path: string; // 根据实际结构添加其他属性
}
// Rename the local declaration
interface LocalResourceMetadata {
id: string;
title: string;
items: Array<{
title: string;
description: string;
icon: string;
url: string;
}>;
}
// 修改骨架屏组件,进一步调整大小
const ResourceGridSkeleton = () => (
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{[1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12].map((index) => (
<div key={index} className="bg-white rounded-lg border shadow-sm animate-pulse">
<div className="aspect-square bg-gray-200 rounded-t-lg" />
<div className="p-2 space-y-1">
<div className="h-2 w-3/4 bg-gray-200 rounded" />
<div className="h-2 w-1/2 bg-gray-200 rounded" />
</div>
</div>
))}
</div>
);
// Add style tag to the document
if (typeof document !== 'undefined') {
const styleSheet = document.createElement('style');
styleSheet.textContent = `
@keyframes shimmer {
0% {
transform: translateX(-100%) skewX(-12deg);
}
100% {
transform: translateX(200%) skewX(-12deg);
}
}
`;
document.head.appendChild(styleSheet);
}
export default function ResourceManagement() {
const { toast } = useToast()
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
defaultValues: {
resource: {
image: undefined,
},
},
})
const [resources, setResources] = useState<LocalResourceMetadata[]>([]);
const [error, setError] = useState<string | null>(null);
const [isDialogOpen, setIsDialogOpen] = useState(false); // State to control dialog visibility
const [uploadProgress, setUploadProgress] = useState(0); // State to track upload progress
const [isUploading, setIsUploading] = useState(false); // State to track if an upload is in progress
const [selectedImage, setSelectedImage] = useState<string | null>(null); // State to hold the selected image for preview
const [uploadSpeed, setUploadSpeed] = useState<number>(0);
const [abortController, setAbortController] = useState<AbortController | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [selectedFile, setSelectedFile] = useState<File | null>(null); // 新增文件状态
const [searchQuery, setSearchQuery] = useState("");
const [selectedResources, setSelectedResources] = useState<Set<string>>(new Set());
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [resourceReferences, setResourceReferences] = useState<Record<string, Array<{ type: string; location: string; title?: string }>>>({});
const fetchResources = async () => {
try {
setIsLoading(true);
const response = await fetch('/api/resource');
if (!response.ok) {
throw new Error('Network response was not ok');
}
const data = await response.json();
if (data.metadata && Array.isArray(data.metadata)) {
const transformedResources: LocalResourceMetadata[] = data.metadata.map((item: ResourceMetadataItem, index: number) => ({
id: item.hash,
title: `Resource ${index + 1}`,
items: [{
title: item.path,
description: '',
icon: '',
url: item.path.startsWith('/') ? item.path : `/${item.path}`
}]
}));
setResources(transformedResources);
} else {
throw new Error('Metadata is not available or is not an array');
}
} catch (error) {
if (error instanceof Error) {
setError(error.message);
} else {
setError('An unknown error occurred');
}
} finally {
setIsLoading(false);
}
};
// Update useEffect to use the new fetchResources function
useEffect(() => {
fetchResources();
}, []);
async function onSubmit() {
try {
if (!selectedFile) {
toast({
title: "错误",
description: "请先选择图片",
variant: "destructive",
});
return;
}
if (selectedImage) {
await uploadImageWithProgress(selectedImage);
// Refresh the resources list after successful upload
await fetchResources();
}
toast({
title: "成功",
description: "资源已上传",
});
setIsDialogOpen(false);
form.reset();
setSelectedImage(null);
setSelectedFile(null);
} catch (error) {
if (error instanceof Error) {
toast({
title: "错误",
description: error.message || "上传资源失败",
variant: "destructive",
});
} else {
toast({
title: "错误",
description: "上传资源失败",
variant: "destructive",
});
}
}
}
const handleImageChange = async (file: File) => {
// Check file size (5MB limit)
if (file.size > 5 * 1024 * 1024) {
toast({
title: "错误",
description: "文件大小不能超过 5MB",
variant: "destructive",
});
return;
}
// Check file type
if (!file.type.startsWith('image/')) {
toast({
title: "错误",
description: "请选择有效的图片文件",
variant: "destructive",
});
return;
}
setSelectedFile(file); // 保存文件对象
const base64Image = await toBase64(file);
setSelectedImage(base64Image);
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files[0];
if (file) {
handleImageChange(file);
}
};
const handleDragOver = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
e.stopPropagation();
};
const uploadImageWithProgress = (base64Image: string) => {
return new Promise((resolve, reject) => {
const controller = new AbortController();
setAbortController(controller);
const xhr = new XMLHttpRequest();
xhr.open("POST", "/api/resource", true);
xhr.setRequestHeader("Content-Type", "application/json");
let startTime = Date.now();
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
const percentComplete = (Number(event.loaded) / Number(event.total)) * 100;
setUploadProgress(Math.round(percentComplete));
setIsUploading(true);
// Calculate upload speed (bytes per second)
const currentTime = Date.now();
const elapsedTime = (currentTime - startTime) / 1000; // Convert to seconds
const speed = event.loaded / elapsedTime; // bytes per second
setUploadSpeed(speed);
}
};
xhr.onload = () => {
setIsUploading(false);
setUploadProgress(0);
setUploadSpeed(0);
setAbortController(null);
if (xhr.status >= 200 && xhr.status < 300) {
try {
const response = JSON.parse(xhr.response);
resolve(response);
} catch (e) {
resolve(xhr.response);
}
} else {
reject(new Error(`上传失败: ${xhr.statusText}`));
}
};
xhr.onerror = () => {
setIsUploading(false);
setUploadProgress(0);
setUploadSpeed(0);
setAbortController(null);
reject(new Error("网络错误,上传失败"));
};
xhr.onabort = () => {
setIsUploading(false);
setUploadProgress(0);
setUploadSpeed(0);
setAbortController(null);
toast({
title: "已取消",
description: "上传已取消",
});
};
xhr.send(JSON.stringify({ image: base64Image }));
});
};
// 添加取消上传函数
const cancelUpload = () => {
if (abortController) {
abortController.abort();
}
};
// Helper function to convert file to Base64
const toBase64 = (file: File): Promise<string> => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onload = () => {
resolve(reader.result as string); // Return Base64 string
};
reader.onerror = (error) => reject(error);
});
};
const copyToClipboard = (url: string) => {
navigator.clipboard.writeText(url).then(() => {
toast({
title: "成功",
description: "链接已复制到剪贴板",
});
}).catch((error) => {
toast({
title: "错误",
description: "复制链接失败",
variant: "destructive",
});
});
};
// 添加搜索过滤函数
const filteredResources = resources.filter((resource) =>
resource.items[0].url.toLowerCase().includes(searchQuery.toLowerCase())
);
// 批量选择相关函数
const toggleResourceSelection = (resourceId: string) => {
const newSelected = new Set(selectedResources);
if (newSelected.has(resourceId)) {
newSelected.delete(resourceId);
} else {
newSelected.add(resourceId);
}
setSelectedResources(newSelected);
};
const selectAllResources = () => {
if (selectedResources.size === filteredResources.length) {
setSelectedResources(new Set());
} else {
setSelectedResources(new Set(filteredResources.map(r => r.id)));
}
};
// 检查资源引用
const checkResourceReferences = async (resourcePaths: string[]) => {
try {
const response = await fetch('/api/resource/check-references', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ resourcePaths }),
});
if (!response.ok) {
throw new Error('Failed to check references');
}
const data = await response.json();
return data.references;
} catch (error) {
console.error('Error checking references:', error);
return {};
}
};
// 批量删除资源
const handleBatchDelete = async () => {
if (selectedResources.size === 0) return;
// 获取选中资源的路径和hash
const selectedResourcesData = filteredResources.filter(r => selectedResources.has(r.id));
const resourcePaths = selectedResourcesData.map(r => r.items[0].url);
const resourceHashes = selectedResourcesData.map(r => r.id);
// 检查引用
const references = await checkResourceReferences(resourcePaths);
setResourceReferences(references);
setIsDeleteDialogOpen(true);
};
// 确认删除
const confirmDelete = async () => {
if (selectedResources.size === 0) return;
try {
setIsDeleting(true);
const resourceHashes = Array.from(selectedResources);
const response = await fetch('/api/resource', {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ resourceHashes }),
});
if (!response.ok) {
throw new Error('Failed to delete resources');
}
const result = await response.json();
toast({
title: "成功",
description: result.message || `成功删除 ${selectedResources.size} 个资源`,
});
// 刷新资源列表
await fetchResources();
setSelectedResources(new Set());
setIsDeleteDialogOpen(false);
} catch (error) {
toast({
title: "错误",
description: error instanceof Error ? error.message : "删除资源失败",
variant: "destructive",
});
} finally {
setIsDeleting(false);
}
};
return (
<>
{/* 顶部进度条 - 优化样式 */}
{isUploading && (
<div className="fixed top-0 left-0 right-0 z-[100]">
<div className="bg-white/80 backdrop-blur-sm shadow-lg border-b px-4 py-3">
<div className="max-w-7xl mx-auto">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Icons.upload className="h-4 w-4 animate-pulse text-blue-600" />
<span className="text-sm font-medium text-gray-700">...</span>
</div>
<div className="flex items-center gap-4">
<div className="flex items-center gap-2 text-sm text-gray-600">
<span className="font-medium">{uploadProgress}%</span>
<span className="text-gray-400"></span>
<span>{(uploadSpeed / (1024 * 1024)).toFixed(2)} MB/s</span>
</div>
<Button
variant="ghost"
onClick={cancelUpload}
size="sm"
className="text-red-600 hover:text-red-700 hover:bg-red-50"
>
<Icons.x className="h-4 w-4 mr-1" />
</Button>
</div>
</div>
<div className="relative w-full h-1.5 bg-gray-100 rounded-full overflow-hidden">
{/* 背景动画效果 */}
<div
className="absolute inset-0 bg-gradient-to-r from-blue-500 to-blue-600 animate-pulse opacity-20"
/>
{/* 主进度条 */}
<div
className="relative h-full bg-gradient-to-r from-blue-500 to-blue-600 rounded-full transition-all duration-300 ease-out"
style={{
width: `${uploadProgress}%`,
boxShadow: '0 0 10px rgba(37, 99, 235, 0.5)'
}}
>
{/* 光晕效果 */}
<div
className="absolute right-0 top-0 h-full w-4 bg-white opacity-30 transform -skew-x-12 animate-[shimmer_1s_infinite]"
/>
</div>
</div>
</div>
</div>
</div>
)}
<div className="space-y-4 p-6">
<div className="flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="relative">
<Icons.search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<Input
placeholder="搜索资源..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10 max-w-md"
/>
</div>
{filteredResources.length > 0 && (
<div className="flex items-center gap-2">
<Button
variant="outline"
size="sm"
onClick={selectAllResources}
className="flex items-center gap-2"
>
{selectedResources.size === filteredResources.length ? (
<Icons.checkSquare className="h-4 w-4" />
) : (
<Icons.square className="h-4 w-4" />
)}
({selectedResources.size}/{filteredResources.length})
</Button>
{selectedResources.size > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleBatchDelete}
className="flex items-center gap-2"
>
<Icons.trash2 className="h-4 w-4" />
({selectedResources.size})
</Button>
)}
</div>
)}
</div>
<Button onClick={() => setIsDialogOpen(true)}>
<Icons.plus className="mr-2 h-4 w-4" />
</Button>
</div>
{/* 添加搜索框 */}
</div>
{error && (
<div className="bg-red-50 border-l-4 border-red-400 p-4">
<div className="flex items-center">
<Icons.alertCircle className="h-5 w-5 text-red-400 mr-2" />
<p className="text-red-700">{error}</p>
</div>
</div>
)}
{isLoading ? (
<ResourceGridSkeleton />
) : filteredResources.length > 0 ? (
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-8 xl:grid-cols-10 gap-2">
{filteredResources.map((resource, index) => (
<div
key={index}
className={`group bg-white rounded-lg border shadow-sm hover:shadow-md transition-all duration-200 ${selectedResources.has(resource.id) ? 'ring-2 ring-blue-500 border-blue-500' : ''
}`}
>
<div className="relative aspect-square">
{/* 选择框 */}
<div className="absolute top-2 left-2 z-10">
<Button
variant="ghost"
size="sm"
onClick={() => toggleResourceSelection(resource.id)}
className="h-6 w-6 p-0 bg-white/80 backdrop-blur-sm hover:bg-white/90"
>
{selectedResources.has(resource.id) ? (
<Icons.checkSquare className="h-3 w-3 text-blue-600" />
) : (
<Icons.square className="h-3 w-3 text-gray-600" />
)}
</Button>
</div>
<img
src={resource.items[0].url}
alt={`Resource ${index + 1}`}
className="w-full h-full object-cover rounded-t-lg"
loading="lazy"
/>
{/* 图片遮罩层和预览按钮 - 进一步缩小 */}
<div className="absolute inset-0 bg-black/60 opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-center justify-center">
<a
href={resource.items[0].url}
target="_blank"
rel="noopener noreferrer"
className="bg-white/10 backdrop-blur-sm text-white px-1.5 py-0.5 rounded-full
hover:bg-white/20 transition-colors duration-200 flex items-center gap-0.5 text-[10px]"
>
<Icons.upload className="h-2 w-2" />
</a>
</div>
</div>
<div className="p-1.5 space-y-1">
{/* 文件名和上传时间 */}
<div className="space-y-0.5">
<p className="text-[10px] text-gray-500 truncate" title={resource.items[0].url}>
{resource.items[0].url.split('/').pop()}
</p>
</div>
{/* 复制链接按钮 - 进一步缩小 */}
<Button
variant="outline"
size="sm"
onClick={() => copyToClipboard(resource.items[0].url)}
className="w-full h-5 text-[8px] flex items-center justify-center gap-0.5 hover:bg-gray-50"
>
<Icons.copy className="h-2 w-2" />
</Button>
</div>
</div>
))}
</div>
) : (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-lg bg-muted/50">
<Icons.inbox className="h-10 w-10 text-gray-400 mb-2" />
<p className="text-gray-500">
{searchQuery ? '未找到匹配的资源' : '暂无资源'}
</p>
{!searchQuery && (
<Button
variant="outline"
onClick={() => setIsDialogOpen(true)}
className="mt-4"
>
</Button>
)}
</div>
)}
</div>
{/* Dialog for adding resources */}
<Dialog open={isDialogOpen} onOpenChange={(open) => {
setIsDialogOpen(open);
if (!open) {
// 关闭对话框时重置状态
setSelectedImage(null);
setSelectedFile(null);
form.reset();
}
}}>
<DialogContent className="sm:max-w-[425px]">
<DialogHeader>
<DialogTitle></DialogTitle>
<DialogDescription>
</DialogDescription>
</DialogHeader>
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)} className="space-y-4">
<FormField
control={form.control}
name="resource.image"
render={() => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Card
className={`border-2 border-dashed ${selectedImage ? 'border-blue-400' : 'border-gray-200'
} hover:border-blue-400 transition-colors duration-200`}
>
<CardContent
className="flex flex-col items-center justify-center p-6 cursor-pointer"
onDrop={handleDrop}
onDragOver={handleDragOver}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const files = (e.target as HTMLInputElement).files;
if (files && files[0]) {
handleImageChange(files[0]);
}
};
input.click();
}}
>
{selectedImage ? (
<div className="relative w-full">
<img
src={selectedImage}
alt="Preview"
className="w-full h-48 object-cover rounded-lg"
/>
<Button
type="button"
variant="destructive"
size="icon"
className="absolute top-2 right-2"
onClick={(e) => {
e.stopPropagation();
setSelectedImage(null);
}}
>
<Icons.x className="h-4 w-4" />
</Button>
</div>
) : (
<>
<Icons.upload className="h-12 w-12 text-gray-400 mb-4" />
<div className="space-y-2 text-center">
<p className="text-sm text-gray-600">
</p>
<p className="text-xs text-gray-400">
JPGPNG 5MB
</p>
</div>
</>
)}
</CardContent>
</Card>
</FormControl>
<FormDescription>
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
<DialogFooter>
<Button
type="submit"
disabled={!selectedFile || isUploading}
className="w-full"
>
{isUploading ? (
<>
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
'开始上传'
)}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
{/* 批量删除确认对话框 */}
<Dialog open={isDeleteDialogOpen} onOpenChange={setIsDeleteDialogOpen}>
<DialogContent className="sm:max-w-[600px] max-h-[80vh] flex flex-col">
<DialogHeader className="flex-shrink-0">
<DialogTitle className="flex items-center gap-2">
<Icons.alertCircle className="h-5 w-5 text-red-500" />
</DialogTitle>
<DialogDescription>
{selectedResources.size}
</DialogDescription>
</DialogHeader>
<div className="flex-1 overflow-hidden flex flex-col space-y-4">
{/* 显示有引用的资源警告 */}
{Object.entries(resourceReferences).some(([_, refs]) => refs.length > 0) && (
<div className="bg-yellow-50 border border-yellow-200 rounded-lg p-4 flex-shrink-0">
<div className="flex items-start gap-2">
<Icons.alertCircle className="h-5 w-5 text-yellow-600 mt-0.5 flex-shrink-0" />
<div className="space-y-2 min-w-0">
<p className="text-sm font-medium text-yellow-800">
使
</p>
<div className="max-h-24 overflow-y-auto space-y-2">
{Object.entries(resourceReferences).map(([resourcePath, refs]) =>
refs.length > 0 && (
<div key={resourcePath} className="text-sm text-yellow-700">
<p className="font-medium truncate" title={resourcePath}>
{resourcePath.split('/').pop()}
</p>
<ul className="ml-4 space-y-1">
{refs.slice(0, 3).map((ref, index) => (
<li key={index} className="text-xs truncate">
{ref.location}
</li>
))}
{refs.length > 3 && (
<li className="text-xs text-yellow-600">
{refs.length - 3} ...
</li>
)}
</ul>
</div>
)
)}
</div>
<p className="text-xs text-yellow-600">
</p>
</div>
</div>
</div>
)}
{/* 显示将要删除的资源列表 */}
<div className="flex-1 overflow-hidden">
<div className="flex items-center justify-between mb-3">
<p className="text-sm font-medium text-gray-700">
({selectedResources.size} )
</p>
{selectedResources.size > 12 && (
<p className="text-xs text-gray-500">
12 {selectedResources.size}
</p>
)}
</div>
<div className="overflow-y-auto max-h-48">
{selectedResources.size <= 12 ? (
// 少量资源时显示网格
<div className="grid grid-cols-4 gap-2">
{filteredResources
.filter(r => selectedResources.has(r.id))
.map((resource, index) => (
<div key={index} className="relative group">
<img
src={resource.items[0].url}
alt="Resource"
className="w-full aspect-square object-cover rounded border"
/>
<div className="absolute inset-0 bg-red-500/20 rounded"></div>
<div className="absolute bottom-1 left-1 right-1">
<div className="bg-black/60 text-white text-xs px-1 py-0.5 rounded truncate">
{resource.items[0].url.split('/').pop()}
</div>
</div>
</div>
))}
</div>
) : (
// 大量资源时显示列表
<div className="space-y-2">
{filteredResources
.filter(r => selectedResources.has(r.id))
.slice(0, 12)
.map((resource, index) => (
<div key={index} className="flex items-center gap-3 p-2 bg-gray-50 rounded-lg">
<img
src={resource.items[0].url}
alt="Resource"
className="w-12 h-12 object-cover rounded border flex-shrink-0"
/>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 truncate">
{resource.items[0].url.split('/').pop()}
</p>
<p className="text-xs text-gray-500 truncate">
{resource.items[0].url}
</p>
</div>
<div className="w-4 h-4 bg-red-500/20 rounded border border-red-300 flex-shrink-0"></div>
</div>
))}
{selectedResources.size > 12 && (
<div className="text-center py-3 border-t border-gray-200">
<p className="text-sm text-gray-500">
{selectedResources.size - 12}
</p>
</div>
)}
</div>
)}
</div>
</div>
</div>
<DialogFooter className="gap-2 flex-shrink-0 border-t pt-4">
<Button
variant="outline"
onClick={() => setIsDeleteDialogOpen(false)}
disabled={isDeleting}
>
</Button>
<Button
variant="destructive"
onClick={confirmDelete}
disabled={isDeleting}
>
{isDeleting ? (
<>
<Icons.loader2 className="mr-2 h-4 w-4 animate-spin" />
...
</>
) : (
<>
<Icons.trash2 className="mr-2 h-4 w-4" />
({selectedResources.size})
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<Toaster />
</>
)
}