'use client'; import { useMemo, useState } from 'react'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { ChevronDown, ChevronRight, Rocket, Upload } from 'lucide-react'; import { toast } from 'sonner'; import { EmptyStateWithBorder } from '@/components/layout/empty'; import { ErrorInline } from '@/components/layout/error'; import { LoadingStateWithBorder } from '@/components/layout/loading'; import { AlertDialog, AlertDialogAction, AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, AlertDialogHeader, AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; import { Card, CardAction, CardContent, CardDescription, CardHeader, CardTitle, } from '@/components/ui/card'; import { Spinner } from '@/components/ui/spinner'; import { type PagesDeployment, PagesService } from '@/lib/services/openflare'; import { cn, formatDateTime } from '@/lib/utils'; import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog'; import { deploymentFilesQueryKey, deploymentsQueryKey, formatBytes, projectQueryKey, projectsQueryKey, sourceQueryKey, } from '../../components/pages-utils'; import { DeploymentFilesPanel } from './deployment-files-panel'; const SOURCE_LABELS: Record = { manual_upload: '本地上传', manual_url: 'URL 导入', remote_url: 'Remote URL', github_release: 'GitHub', }; const TRIGGER_LABELS: Record = { manual_upload: '手动上传', manual_url: '手动导入', manual_sync: '手动同步', scheduled_auto_update: '定时更新', }; interface DeploymentHistoryProps { projectId: number; activeDeploymentId?: number | null; rootDir?: string; entryFile?: string; } type PendingAction = { type: 'activate' | 'delete'; deployment: PagesDeployment; }; function isActiveDeployment( deployment: PagesDeployment, activeDeploymentId?: number | null, ) { return deployment.id === activeDeploymentId || deployment.status === 'active'; } function deploymentSnapshot(deployment: PagesDeployment) { return [ SOURCE_LABELS[deployment.source_type], deployment.source_label, TRIGGER_LABELS[deployment.trigger_type], ] .filter(Boolean) .join(' · '); } function DeploymentMeta({ deployment }: { deployment: PagesDeployment }) { return ( <>

{deployment.checksum.slice(0, 16)} · {deployment.file_count} 个文件 ·{' '} {formatBytes(deployment.total_size)}

创建于 {formatDateTime(deployment.created_at)} {deployment.activated_at ? ` · 激活于 ${formatDateTime(deployment.activated_at)}` : ''}

); } interface DeploymentRowProps { deployment: PagesDeployment; active: boolean; expanded: boolean; actionPending: boolean; showActions: boolean; onToggleExpand: () => void; onActivate: () => void; onDelete: () => void; projectId: number; } function DeploymentRow({ deployment, active, expanded, actionPending, showActions, onToggleExpand, onActivate, onDelete, projectId, }: DeploymentRowProps) { return (
部署 #{deployment.deployment_number} {deploymentSnapshot(deployment)}
{showActions ? (
) : null}
{expanded ? ( ) : null}
); } export function DeploymentHistory({ projectId, activeDeploymentId, rootDir = '', entryFile = 'index.html', }: DeploymentHistoryProps) { const queryClient = useQueryClient(); const [uploadOpen, setUploadOpen] = useState(false); const [expandedDeploymentId, setExpandedDeploymentId] = useState< number | null >(null); const [pendingAction, setPendingAction] = useState( null, ); const deploymentsQuery = useQuery({ queryKey: deploymentsQueryKey(projectId), queryFn: () => PagesService.listDeployments(projectId), }); const { productionDeployment, allDeployments } = useMemo(() => { const records = [...(deploymentsQuery.data ?? [])]; records.sort( (left, right) => right.deployment_number - left.deployment_number, ); const production = records.find((item) => isActiveDeployment(item, activeDeploymentId)) ?? null; return { productionDeployment: production, allDeployments: records, }; }, [activeDeploymentId, deploymentsQuery.data]); const invalidateDeploymentState = async () => { await Promise.all([ queryClient.invalidateQueries({ queryKey: deploymentsQueryKey(projectId), }), queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }), queryClient.invalidateQueries({ queryKey: projectsQueryKey }), ]); }; const activateMutation = useMutation({ mutationFn: (deploymentId: number) => PagesService.activateDeployment(projectId, deploymentId), onSuccess: async () => { toast.success('历史部署已激活;自动更新(如已开启)已关闭'); await invalidateDeploymentState(); setPendingAction(null); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '激活失败'); }, }); const deleteMutation = useMutation({ mutationFn: (deploymentId: number) => PagesService.deleteDeployment(projectId, deploymentId), onSuccess: async (_, deploymentId) => { toast.success('部署已删除'); queryClient.removeQueries({ queryKey: deploymentFilesQueryKey(projectId, deploymentId), }); await invalidateDeploymentState(); setPendingAction(null); }, onError: (error) => { toast.error(error instanceof Error ? error.message : '删除失败'); }, }); const actionPending = activateMutation.isPending || deleteMutation.isPending; const toggleExpand = (deploymentId: number) => { setExpandedDeploymentId((current) => current === deploymentId ? null : deploymentId, ); }; const dialogs = ( <> { if (!open && !actionPending) setPendingAction(null); }} > {pendingAction?.type === 'activate' ? '激活历史部署' : '删除部署'} {pendingAction?.type === 'activate' ? '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。' : `确认删除部署 #${pendingAction?.deployment.deployment_number} 吗?此操作不可恢复。`} 取消 { event.preventDefault(); if (!pendingAction) return; if (pendingAction.type === 'activate') { activateMutation.mutate(pendingAction.deployment.id); } else { deleteMutation.mutate(pendingAction.deployment.id); } }} > {actionPending ? : null} 确认 ); if (deploymentsQuery.isLoading) { return ( <> {dialogs} ); } if (deploymentsQuery.isError) { return ( <>
void deploymentsQuery.refetch()} />
{dialogs} ); } return (
Production 当前对外生效的生产部署;切换激活状态后会立即生效。 {productionDeployment ? ( toggleExpand(productionDeployment.id)} onActivate={() => undefined} onDelete={() => undefined} projectId={projectId} /> ) : ( )} All deployments 部署记录不可变,来源信息是创建部署时的安全快照。可从历史部署激活或删除。 {allDeployments.length === 0 ? ( ) : ( allDeployments.map((deployment) => { const active = isActiveDeployment(deployment, activeDeploymentId); return ( toggleExpand(deployment.id)} onActivate={() => setPendingAction({ type: 'activate', deployment }) } onDelete={() => setPendingAction({ type: 'delete', deployment }) } projectId={projectId} /> ); }) )} {dialogs}
); }