[优化] 上传部署包功能及相关界面优化

This commit is contained in:
ryan
2026-06-04 09:13:29 +08:00
parent 3eecd31868
commit fbb0909638
2 changed files with 263 additions and 155 deletions
@@ -1,9 +1,12 @@
import type { ReactNode } from 'react';
interface EmptyStateProps { interface EmptyStateProps {
title: string; title: string;
description?: string; description?: string;
children?: ReactNode;
} }
export function EmptyState({ title, description }: EmptyStateProps) { export function EmptyState({ title, description, children }: EmptyStateProps) {
return ( return (
<div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm"> <div className="rounded-2xl border border-dashed border-[var(--border-default)] bg-[var(--surface-muted)] px-5 py-6 text-sm">
<p className="text-base font-semibold text-[var(--foreground-primary)]"> <p className="text-base font-semibold text-[var(--foreground-primary)]">
@@ -14,6 +17,8 @@ export function EmptyState({ title, description }: EmptyStateProps) {
{description} {description}
</p> </p>
) : null} ) : null}
{children ? <div className="mt-4">{children}</div> : null}
</div> </div>
); );
} }
@@ -134,10 +134,8 @@ export function PagesPage() {
export function PagesProjectDetailPage({ projectId }: { projectId: string }) { export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
const router = useRouter(); const router = useRouter();
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [isEditModalOpen, setEditModalOpen] = useState(false); const [isEditModalOpen, setEditModalOpen] = useState(false);
const [isUploadModalOpen, setUploadModalOpen] = useState(false);
const parsedProjectId = Number(projectId); const parsedProjectId = Number(projectId);
const projectQuery = useQuery({ const projectQuery = useQuery({
@@ -151,29 +149,6 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
enabled: projectId !== '' && Number.isFinite(parsedProjectId), enabled: projectId !== '' && Number.isFinite(parsedProjectId),
}); });
const uploadMutation = useMutation({
mutationFn: () => {
if (!file) {
throw new Error('请选择 zip 文件');
}
setUploadProgress(0);
return uploadPagesDeployment(parsedProjectId, file, entryFile, (percent) => {
setUploadProgress(percent);
});
},
onSuccess: () => {
setFile(null);
setUploadProgress(null);
queryClient.invalidateQueries({
queryKey: deploymentsQueryKey(parsedProjectId),
});
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) });
queryClient.invalidateQueries({ queryKey: projectsQueryKey });
},
onError: () => {
setUploadProgress(null);
},
});
const activateMutation = useMutation({ const activateMutation = useMutation({
mutationFn: (deploymentId: number) => mutationFn: (deploymentId: number) =>
activatePagesDeployment(parsedProjectId, deploymentId), activatePagesDeployment(parsedProjectId, deploymentId),
@@ -262,6 +237,12 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
> >
编辑项目 编辑项目
</SecondaryButton> </SecondaryButton>
<PrimaryButton
type="button"
onClick={() => setUploadModalOpen(true)}
>
上传部署包
</PrimaryButton>
<DangerButton <DangerButton
type="button" type="button"
disabled={deleteProjectMutation.isPending} disabled={deleteProjectMutation.isPending}
@@ -273,149 +254,271 @@ export function PagesProjectDetailPage({ projectId }: { projectId: string }) {
} }
/> />
<AppCard
<div className="grid gap-6 lg:grid-cols-[minmax(0,0.85fr)_minmax(0,1.15fr)]"> title="部署历史"
<AppCard description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。"
title="上传部署包" action={
description="上传已构建的 zip 静态资源包,默认入口 index.html。" (deploymentsQuery.data ?? []).length > 0 ? (
>
<div className="space-y-4">
<ResourceField
label="部署包"
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
>
<ResourceInput
type="file"
accept=".zip,application/zip"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</ResourceField>
<ResourceField label="入口文件">
<ResourceInput
value={entryFile}
onChange={(event) => setEntryFile(event.target.value)}
/>
</ResourceField>
{uploadProgress !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
<PrimaryButton <PrimaryButton
type="button" type="button"
disabled={!file || uploadMutation.isPending} onClick={() => setUploadModalOpen(true)}
onClick={() => uploadMutation.mutate()}
> >
{uploadMutation.isPending 上传部署包
? `上传中 (${uploadProgress ?? 0}%)...`
: '上传部署'}
</PrimaryButton> </PrimaryButton>
{uploadMutation.error ? ( ) : null
<p className="text-sm text-[var(--status-danger-foreground)]"> }
{uploadMutation.error.message} >
</p> {deploymentsQuery.isLoading ? (
) : null} <p className="text-sm text-[var(--foreground-secondary)]">
</div> 加载中...
</AppCard> </p>
) : deploymentsQuery.isError ? (
<AppCard <p className="text-sm text-[var(--status-danger-foreground)]">
title="部署历史" {deploymentsQuery.error.message}
description="部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。" </p>
> ) : (deploymentsQuery.data ?? []).length === 0 ? (
{deploymentsQuery.isLoading ? ( <EmptyState
<p className="text-sm text-[var(--foreground-secondary)]"> title="暂无部署"
加载中... description="上传 zip 部署包后,可以在这里激活某个部署版本。"
</p> >
) : deploymentsQuery.isError ? ( <PrimaryButton
<p className="text-sm text-[var(--status-danger-foreground)]"> type="button"
{deploymentsQuery.error.message} onClick={() => setUploadModalOpen(true)}
</p> >
) : (deploymentsQuery.data ?? []).length === 0 ? ( 上传部署包
<EmptyState </PrimaryButton>
title="暂无部署" </EmptyState>
description="上传 zip 部署包后,可以在这里激活某个部署版本。" ) : (
/> <div className="overflow-hidden rounded-2xl border border-[var(--border-default)]">
) : ( {(deploymentsQuery.data ?? []).map((deployment) => (
<div className="overflow-hidden rounded-2xl border border-[var(--border-default)]"> <div
{(deploymentsQuery.data ?? []).map((deployment) => ( key={deployment.id}
<div className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between"
key={deployment.id} >
className="flex flex-col gap-3 border-b border-[var(--border-default)] p-4 last:border-b-0 md:flex-row md:items-center md:justify-between" <div>
> <p className="text-sm font-medium text-[var(--foreground-primary)]">
<div> #{deployment.deployment_number}{' '}
<p className="text-sm font-medium text-[var(--foreground-primary)]"> {deployment.status === 'active' ? '· 已激活' : ''}
#{deployment.deployment_number}{' '} </p>
{deployment.status === 'active' ? '· 已激活' : ''} <p className="mt-1 text-xs text-[var(--foreground-secondary)]">
</p> {deployment.checksum.slice(0, 16)} ·{' '}
<p className="mt-1 text-xs text-[var(--foreground-secondary)]"> {deployment.file_count} files ·{' '}
{deployment.checksum.slice(0, 16)} ·{' '} {formatBytes(deployment.total_size)}
{deployment.file_count} files ·{' '} </p>
{formatBytes(deployment.total_size)}
</p>
</div>
<div className="flex gap-2">
<SecondaryButton
type="button"
disabled={
deployment.status === 'active' ||
activateMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认激活部署 #${deployment.deployment_number} 吗?`,
)
) {
activateMutation.mutate(deployment.id);
}
}}
>
激活
</SecondaryButton>
<DangerButton
type="button"
disabled={
deployment.status === 'active' ||
deleteDeploymentMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认删除部署 #${deployment.deployment_number} 吗?`,
)
) {
deleteDeploymentMutation.mutate(deployment.id);
}
}}
>
删除
</DangerButton>
</div>
</div> </div>
))} <div className="flex gap-2">
</div> <SecondaryButton
)} type="button"
</AppCard> disabled={
</div> deployment.status === 'active' ||
activateMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认激活部署 #${deployment.deployment_number} 吗?`,
)
) {
activateMutation.mutate(deployment.id);
}
}}
>
激活
</SecondaryButton>
<DangerButton
type="button"
disabled={
deployment.status === 'active' ||
deleteDeploymentMutation.isPending
}
onClick={() => {
if (
window.confirm(
`确认删除部署 #${deployment.deployment_number} 吗?`,
)
) {
deleteDeploymentMutation.mutate(deployment.id);
}
}}
>
删除
</DangerButton>
</div>
</div>
))}
</div>
)}
</AppCard>
<PagesProjectEditModal <PagesProjectEditModal
isOpen={isEditModalOpen} isOpen={isEditModalOpen}
onClose={() => setEditModalOpen(false)} onClose={() => setEditModalOpen(false)}
project={project} project={project}
/> />
<PagesDeploymentUploadModal
isOpen={isUploadModalOpen}
onClose={() => setUploadModalOpen(false)}
projectId={parsedProjectId}
/>
</div> </div>
); );
} }
function PagesDeploymentUploadModal({
isOpen,
onClose,
projectId,
}: {
isOpen: boolean;
onClose: () => void;
projectId: number;
}) {
const queryClient = useQueryClient();
const [file, setFile] = useState<File | null>(null);
const [entryFile, setEntryFile] = useState('index.html');
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
const [errorMessage, setErrorMessage] = useState<string | null>(null);
const resetForm = () => {
setFile(null);
setEntryFile('index.html');
setUploadProgress(null);
setErrorMessage(null);
};
const handleClose = () => {
resetForm();
onClose();
};
const uploadMutation = useMutation({
mutationFn: async ({ shouldActivate }: { shouldActivate: boolean }) => {
if (!file) {
throw new Error('请选择 zip 文件');
}
setUploadProgress(0);
setErrorMessage(null);
const deployment = await uploadPagesDeployment(
projectId,
file,
entryFile,
(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 (
<AppModal
isOpen={isOpen}
onClose={handleClose}
title="上传部署包"
description="上传已构建的 zip 静态资源包,默认入口为 index.html。"
footer={
<div className="flex flex-wrap justify-end gap-3">
<SecondaryButton
type="button"
onClick={handleClose}
disabled={uploadMutation.isPending}
>
取消
</SecondaryButton>
<SecondaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={handleUploadOnly}
>
{uploadMutation.isPending && !uploadMutation.variables?.shouldActivate
? `上传中 (${uploadProgress ?? 0}%)...`
: '上传'}
</SecondaryButton>
<PrimaryButton
type="button"
disabled={!file || uploadMutation.isPending}
onClick={handleUploadAndDeploy}
>
{uploadMutation.isPending && uploadMutation.variables?.shouldActivate
? `上传并部署中 (${uploadProgress ?? 0}%)...`
: '上传并部署'}
</PrimaryButton>
</div>
}
>
<div className="space-y-4">
<ResourceField
label="部署包"
hint="仅支持 zip,Server 会校验文件数量、体积、路径逃逸和入口文件。"
>
<ResourceInput
type="file"
accept=".zip,application/zip"
onChange={(event) => setFile(event.target.files?.[0] ?? null)}
/>
</ResourceField>
<ResourceField label="入口文件">
<ResourceInput
value={entryFile}
onChange={(event) => setEntryFile(event.target.value)}
/>
</ResourceField>
{uploadProgress !== null && (
<div className="space-y-2">
<div className="flex items-center justify-between text-xs font-medium text-[var(--foreground-secondary)]">
<span>上传进度</span>
<span>{uploadProgress}%</span>
</div>
<div className="h-2 w-full overflow-hidden rounded-full bg-[var(--surface-muted)]">
<div
className="h-full rounded-full bg-[var(--brand-primary)] transition-all duration-300 ease-out"
style={{ width: `${uploadProgress}%` }}
/>
</div>
</div>
)}
{errorMessage ? (
<p className="text-sm text-[var(--status-danger-foreground)]">
{errorMessage}
</p>
) : null}
</div>
</AppModal>
);
}
function PagesProjectEditModal({ function PagesProjectEditModal({
isOpen, isOpen,
onClose, onClose,