'use client'; import {useMutation, useQueryClient} from '@tanstack/react-query'; import {useRef, useState} from 'react'; import {File as FileIcon, UploadCloud, X} from 'lucide-react'; import {AppModal} from '@/components/ui/app-modal'; import {activatePagesDeployment, uploadPagesDeployment,} from '@/features/pages/api/pages'; import {PrimaryButton, SecondaryButton,} from '@/features/shared/components/resource-primitives'; import {cn} from '@/lib/utils/cn'; import {deploymentsQueryKey, projectQueryKey, projectsQueryKey,} from '../utils'; interface PagesDeploymentUploadModalProps { isOpen: boolean; onClose: () => void; projectId: number; } function formatBytes(bytes: number, decimals = 2) { if (bytes === 0) return '0 Bytes'; const k = 1024; const dm = decimals < 0 ? 0 : decimals; const sizes = ['Bytes', 'KB', 'MB', 'GB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i]; } export function PagesDeploymentUploadModal({ isOpen, onClose, projectId, }: PagesDeploymentUploadModalProps) { const queryClient = useQueryClient(); const [file, setFile] = useState(null); const [isDragActive, setIsDragActive] = useState(false); const [uploadProgress, setUploadProgress] = useState(null); const [errorMessage, setErrorMessage] = useState(null); const fileInputRef = useRef(null); const resetForm = () => { setFile(null); setIsDragActive(false); setUploadProgress(null); setErrorMessage(null); if (fileInputRef.current) { fileInputRef.current.value = ''; } }; const handleClose = () => { resetForm(); onClose(); }; const handleDrag = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); if (e.type === 'dragenter' || e.type === 'dragover') { setIsDragActive(true); } else if (e.type === 'dragleave') { setIsDragActive(false); } }; const handleDrop = (e: React.DragEvent) => { e.preventDefault(); e.stopPropagation(); setIsDragActive(false); if (e.dataTransfer.files && e.dataTransfer.files[0]) { const droppedFile = e.dataTransfer.files[0]; if (droppedFile.name.toLowerCase().endsWith('.zip')) { setFile(droppedFile); setErrorMessage(null); } else { setErrorMessage('仅支持 zip 格式的文件'); } } }; const uploadMutation = useMutation({ mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => { if (!file) { throw new Error('请选择 zip 文件'); } setUploadProgress(0); setErrorMessage(null); const deployment = await uploadPagesDeployment( projectId, file, '', 'index.html', (percent) => { setUploadProgress(percent); }, ); if (shouldActivate) { await activatePagesDeployment(projectId, deployment.id); } return deployment; }, onSuccess: () => { resetForm(); queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId), }); queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId), }); queryClient.invalidateQueries({ queryKey: projectsQueryKey, }); onClose(); }, onError: (error) => { setUploadProgress(null); setErrorMessage(error instanceof Error ? error.message : '上传失败'); }, }); const handleUploadOnly = () => { uploadMutation.mutate({ shouldActivate: false }); }; const handleUploadAndDeploy = () => { uploadMutation.mutate({ shouldActivate: true }); }; return ( 取消 {uploadMutation.isPending && !uploadMutation.variables?.shouldActivate ? `上传中 (${uploadProgress ?? 0}%)...` : '上传'} {uploadMutation.isPending && uploadMutation.variables?.shouldActivate ? `上传并部署中 (${uploadProgress ?? 0}%)...` : '上传并部署'} } >
{/* Hidden File Input */} { const selectedFile = event.target.files?.[0] ?? null; if (selectedFile) { if (selectedFile.name.toLowerCase().endsWith('.zip')) { setFile(selectedFile); setErrorMessage(null); } else { setErrorMessage('仅支持 zip 格式的文件'); } } }} className="hidden" />
部署包
fileInputRef.current?.click()} className={cn( 'group relative flex min-h-48 cursor-pointer flex-col items-center justify-center rounded-2xl border-2 border-dashed border-[var(--border-default)] bg-[var(--surface-elevated)] p-6 text-center transition-all duration-200 hover:border-[var(--brand-primary)] hover:bg-[var(--surface-hover)]', isDragActive && 'scale-[0.99] border-[var(--brand-primary)] bg-[var(--accent-soft)]/20 shadow-inner', file && 'border-solid border-[var(--brand-primary)]', )} > {file ? (
e.stopPropagation()} >

{file.name}

大小: {formatBytes(file.size)}

) : (

点击或拖拽 zip 部署包到此处

仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件

)}
{errorMessage && ( {errorMessage} )}
{uploadProgress !== null && (
上传进度 {uploadProgress}%
)}
); }