mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-06 23:56:37 +08:00
merge: 合并 PR #22 Pages 部署源 V2 到 feat/pages-source-sync-v2
基于最新 main 合并 deqiying/feat/pages-source-sync-v2, 解决 docs/changelog/index.md 与限流相关条目的冲突。
This commit is contained in:
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Link2, Loader2, UploadCloud } from 'lucide-react';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,10 +14,14 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
} from '@/components/ui/field';
|
||||
import { Progress } from '@/components/ui/progress';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { PagesService } from '@/lib/services/openflare';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
@@ -46,52 +50,51 @@ const PAGES_PACKAGE_EXTENSIONS = [
|
||||
|
||||
function isSupportedPagesPackage(fileName: string) {
|
||||
const lower = fileName.toLowerCase();
|
||||
return PAGES_PACKAGE_EXTENSIONS.some((ext) => lower.endsWith(ext));
|
||||
return PAGES_PACKAGE_EXTENSIONS.some((extension) =>
|
||||
lower.endsWith(extension),
|
||||
);
|
||||
}
|
||||
|
||||
export function pagesEntryPath(rootDir: string, entryFile: string) {
|
||||
const root = rootDir.trim().replace(/^\/+|\/+$/g, '');
|
||||
const entry = entryFile.trim().replace(/^\/+/, '');
|
||||
return root ? `${root}/${entry}` : entry;
|
||||
}
|
||||
|
||||
interface DeploymentUploadDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: number;
|
||||
rootDir: string;
|
||||
entryFile: string;
|
||||
}
|
||||
|
||||
export function DeploymentUploadDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
rootDir,
|
||||
entryFile,
|
||||
}: DeploymentUploadDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [mode, setMode] = useState<'file' | 'url'>('file');
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [packageURL, setPackageURL] = useState('');
|
||||
const [isDragActive, setIsDragActive] = useState(false);
|
||||
const [uploadProgress, setUploadProgress] = useState<number | null>(null);
|
||||
|
||||
const resetForm = () => {
|
||||
setFile(null);
|
||||
setPackageURL('');
|
||||
setIsDragActive(false);
|
||||
setUploadProgress(null);
|
||||
if (fileInputRef.current) fileInputRef.current.value = '';
|
||||
};
|
||||
|
||||
const handleClose = (nextOpen: boolean) => {
|
||||
const handleOpenChange = (nextOpen: boolean) => {
|
||||
if (!nextOpen) resetForm();
|
||||
onOpenChange(nextOpen);
|
||||
};
|
||||
|
||||
const invalidate = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
};
|
||||
|
||||
const uploadFileMutation = useMutation({
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!file) throw new Error('请选择部署包');
|
||||
return PagesService.uploadDeployment(projectId, {
|
||||
@@ -101,8 +104,14 @@ export function DeploymentUploadDialog({
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('部署包上传成功');
|
||||
await invalidate();
|
||||
handleClose(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
handleOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '上传失败');
|
||||
@@ -110,27 +119,6 @@ export function DeploymentUploadDialog({
|
||||
},
|
||||
});
|
||||
|
||||
const uploadURLMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
const url = packageURL.trim();
|
||||
if (!url) throw new Error('请填写部署包下载链接');
|
||||
if (!/^https?:\/\//i.test(url)) {
|
||||
throw new Error('链接必须以 http:// 或 https:// 开头');
|
||||
}
|
||||
return PagesService.uploadDeploymentFromURL(projectId, { url });
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('已从链接下载并创建部署');
|
||||
await invalidate();
|
||||
handleClose(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '从链接导入失败');
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = uploadFileMutation.isPending || uploadURLMutation.isPending;
|
||||
|
||||
const handleFileSelect = (selected: File | null) => {
|
||||
if (!selected) return;
|
||||
if (!isSupportedPagesPackage(selected.name)) {
|
||||
@@ -140,150 +128,112 @@ export function DeploymentUploadDialog({
|
||||
setFile(selected);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (mode === 'file') {
|
||||
uploadFileMutation.mutate();
|
||||
return;
|
||||
}
|
||||
uploadURLMutation.mutate();
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
mode === 'file' ? Boolean(file) : packageURL.trim().length > 0;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={handleClose}>
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传部署包</DialogTitle>
|
||||
<DialogDescription>
|
||||
支持本地上传或从 URL 下载静态资源压缩包(zip / tar.gz / tar.xz /
|
||||
tar.bz2 / tar / 7z),创建部署后可在列表中激活。
|
||||
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Tabs
|
||||
value={mode}
|
||||
onValueChange={(value) => setMode(value as 'file' | 'url')}
|
||||
className='w-full'
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='file'>本地上传</TabsTrigger>
|
||||
<TabsTrigger value='url'>从 URL 下载</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value='file' className='space-y-4 mt-4'>
|
||||
<div
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
'rounded-lg border border-dashed p-8 text-center transition',
|
||||
'flex min-h-52 w-full flex-col items-center justify-center gap-3 rounded-lg border border-dashed p-8 text-center transition-colors',
|
||||
isDragActive ? 'border-primary bg-primary/5' : 'bg-muted/20',
|
||||
)}
|
||||
onDragEnter={(e) => {
|
||||
e.preventDefault();
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragActive(true);
|
||||
}}
|
||||
onDragOver={(e) => e.preventDefault()}
|
||||
onDragLeave={(e) => {
|
||||
e.preventDefault();
|
||||
onDragOver={(event) => event.preventDefault()}
|
||||
onDragLeave={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragActive(false);
|
||||
}}
|
||||
onDrop={(e) => {
|
||||
e.preventDefault();
|
||||
onDrop={(event) => {
|
||||
event.preventDefault();
|
||||
setIsDragActive(false);
|
||||
handleFileSelect(e.dataTransfer.files[0] ?? null);
|
||||
handleFileSelect(event.dataTransfer.files[0] ?? null);
|
||||
}}
|
||||
>
|
||||
<UploadCloud className='size-8 mx-auto text-muted-foreground' />
|
||||
<p className='mt-3 text-sm'>拖拽部署包到此处,或点击选择文件</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z
|
||||
</p>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='mt-3'
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
选择文件
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type='file'
|
||||
accept={PAGES_PACKAGE_ACCEPT}
|
||||
className='hidden'
|
||||
onChange={(e) => handleFileSelect(e.target.files?.[0] ?? null)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<UploadCloud className='size-8 text-muted-foreground' />
|
||||
<span className='text-sm font-medium'>
|
||||
拖拽部署包到此处,或点击选择文件
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
zip、tar.gz、tar.xz、tar.bz2、tar、7z
|
||||
</span>
|
||||
</button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
id='pages-package'
|
||||
type='file'
|
||||
accept={PAGES_PACKAGE_ACCEPT}
|
||||
className='hidden'
|
||||
onChange={(event) =>
|
||||
handleFileSelect(event.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
{file ? (
|
||||
<div className='rounded-lg border border-dashed px-4 py-3 text-sm'>
|
||||
<p className='font-medium'>{file.name}</p>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
{formatBytes(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
<FieldDescription>
|
||||
已选择 {file.name}({formatBytes(file.size)})
|
||||
</FieldDescription>
|
||||
) : (
|
||||
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
{uploadProgress !== null ? (
|
||||
<div className='space-y-1.5'>
|
||||
<div className='flex justify-between text-xs text-muted-foreground'>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
|
||||
</span>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={uploadProgress >= 100 ? 100 : uploadProgress}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value='url' className='space-y-4 mt-4'>
|
||||
<div className='space-y-2'>
|
||||
<Label htmlFor='package-url'>部署包下载链接</Label>
|
||||
<div className='relative'>
|
||||
<Link2 className='absolute left-3 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||
<Input
|
||||
id='package-url'
|
||||
className='pl-9'
|
||||
placeholder='https://example.com/dist/site.zip'
|
||||
value={packageURL}
|
||||
onChange={(e) => setPackageURL(e.target.value)}
|
||||
disabled={isPending}
|
||||
/>
|
||||
</div>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
服务端将使用浏览器环境请求头从该链接下载压缩包,支持内网地址与自签证书
|
||||
HTTPS。
|
||||
</p>
|
||||
<Field>
|
||||
<FieldLabel>部署入口</FieldLabel>
|
||||
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
|
||||
{pagesEntryPath(rootDir, entryFile)}
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
<FieldDescription>
|
||||
入口来自项目设置;部署包上传不会覆盖该配置。
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='entryFile'>入口文件</Label>
|
||||
<Input id='entryFile' defaultValue='index.html' disabled />
|
||||
</div>
|
||||
{uploadProgress !== null ? (
|
||||
<Field>
|
||||
<div className='flex items-center justify-between text-xs text-muted-foreground'>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
|
||||
</span>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(uploadProgress, 100)} />
|
||||
</Field>
|
||||
) : null}
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => handleClose(false)}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit || isPending}>
|
||||
{isPending ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
{mode === 'url' ? '下载中...' : '上传中...'}
|
||||
</>
|
||||
) : mode === 'url' ? (
|
||||
'下载并创建部署'
|
||||
<Button
|
||||
type='button'
|
||||
disabled={!file || uploadMutation.isPending}
|
||||
onClick={() => uploadMutation.mutate()}
|
||||
>
|
||||
{uploadMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
'上传并创建部署'
|
||||
<UploadCloud data-icon='inline-start' />
|
||||
)}
|
||||
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -17,6 +17,10 @@ export function deploymentsQueryKey(projectId: number) {
|
||||
return ['openflare', 'pages', 'deployments', projectId] as const;
|
||||
}
|
||||
|
||||
export function sourceQueryKey(projectId: number) {
|
||||
return ['openflare', 'pages', 'source', projectId] as const;
|
||||
}
|
||||
|
||||
export function deploymentFilesQueryKey(
|
||||
projectId: number,
|
||||
deploymentId: number,
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
|
||||
import { EmptyInline } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PagesService } from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
deploymentFilesQueryKey,
|
||||
formatBytes,
|
||||
} from '../../components/pages-utils';
|
||||
|
||||
interface DeploymentFilesPanelProps {
|
||||
projectId: number;
|
||||
deploymentId: number;
|
||||
}
|
||||
|
||||
export function DeploymentFilesPanel({
|
||||
projectId,
|
||||
deploymentId,
|
||||
}: DeploymentFilesPanelProps) {
|
||||
const filesQuery = useQuery({
|
||||
queryKey: deploymentFilesQueryKey(projectId, deploymentId),
|
||||
queryFn: () => PagesService.listDeploymentFiles(deploymentId),
|
||||
});
|
||||
|
||||
if (filesQuery.isLoading) {
|
||||
return (
|
||||
<div className='flex flex-col gap-2 p-4'>
|
||||
<Skeleton className='h-8 w-full' />
|
||||
<Skeleton className='h-8 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (filesQuery.isError) {
|
||||
return (
|
||||
<div className='p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
filesQuery.error instanceof Error
|
||||
? filesQuery.error.message
|
||||
: '文件清单加载失败'
|
||||
}
|
||||
onRetry={() => void filesQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const files = filesQuery.data ?? [];
|
||||
if (files.length === 0) {
|
||||
return <EmptyInline message='暂无文件记录' />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>路径</TableHead>
|
||||
<TableHead className='text-right'>大小</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
<TableRow key={file.id}>
|
||||
<TableCell className='font-mono text-xs'>{file.path}</TableCell>
|
||||
<TableCell className='text-right text-xs text-muted-foreground'>
|
||||
{formatBytes(file.size)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,300 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight } 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,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {
|
||||
deploymentFilesQueryKey,
|
||||
deploymentsQueryKey,
|
||||
formatBytes,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import { DeploymentFilesPanel } from './deployment-files-panel';
|
||||
|
||||
const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
|
||||
manual_upload: '本地上传',
|
||||
manual_url: 'URL 导入',
|
||||
remote_url: 'Remote URL',
|
||||
github_release: 'GitHub',
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
||||
manual_upload: '手动上传',
|
||||
manual_url: '手动导入',
|
||||
manual_sync: '手动同步',
|
||||
scheduled_auto_update: '定时更新',
|
||||
};
|
||||
|
||||
interface DeploymentHistoryProps {
|
||||
projectId: number;
|
||||
activeDeploymentId?: number | null;
|
||||
}
|
||||
|
||||
type PendingAction = {
|
||||
type: 'activate' | 'delete';
|
||||
deployment: PagesDeployment;
|
||||
};
|
||||
|
||||
function deploymentSnapshot(deployment: PagesDeployment) {
|
||||
return [
|
||||
SOURCE_LABELS[deployment.source_type],
|
||||
deployment.source_label,
|
||||
TRIGGER_LABELS[deployment.trigger_type],
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
export function DeploymentHistory({
|
||||
projectId,
|
||||
activeDeploymentId,
|
||||
}: DeploymentHistoryProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [pendingAction, setPendingAction] = useState<PendingAction | null>(
|
||||
null,
|
||||
);
|
||||
|
||||
const deploymentsQuery = useQuery({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
queryFn: () => PagesService.listDeployments(projectId),
|
||||
});
|
||||
|
||||
const deployments = useMemo(() => {
|
||||
const records = [...(deploymentsQuery.data ?? [])];
|
||||
return records.sort((left, right) => {
|
||||
const leftActive =
|
||||
left.id === activeDeploymentId || left.status === 'active';
|
||||
const rightActive =
|
||||
right.id === activeDeploymentId || right.status === 'active';
|
||||
if (leftActive !== rightActive) return leftActive ? -1 : 1;
|
||||
return right.deployment_number - left.deployment_number;
|
||||
});
|
||||
}, [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;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署历史</CardTitle>
|
||||
<CardDescription>
|
||||
部署记录不可变,来源信息是创建部署时的安全快照。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder description='加载部署历史...' />
|
||||
) : deploymentsQuery.isError ? (
|
||||
<div className='rounded-lg border p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
deploymentsQuery.error instanceof Error
|
||||
? deploymentsQuery.error.message
|
||||
: '部署历史加载失败'
|
||||
}
|
||||
onRetry={() => void deploymentsQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : deployments.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无部署'
|
||||
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
|
||||
/>
|
||||
) : (
|
||||
deployments.map((deployment) => {
|
||||
const active =
|
||||
deployment.id === activeDeploymentId ||
|
||||
deployment.status === 'active';
|
||||
const expanded = expandedDeploymentId === deployment.id;
|
||||
|
||||
return (
|
||||
<div key={deployment.id} className='rounded-lg border'>
|
||||
<div className='flex flex-col gap-4 p-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex min-w-0 items-start gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={expanded ? '收起文件清单' : '展开文件清单'}
|
||||
onClick={() =>
|
||||
setExpandedDeploymentId(
|
||||
expanded ? null : deployment.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
{expanded ? <ChevronDown /> : <ChevronRight />}
|
||||
</Button>
|
||||
<div className='flex min-w-0 flex-col gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-sm font-medium'>
|
||||
部署 #{deployment.deployment_number}
|
||||
</span>
|
||||
<Badge variant={active ? 'default' : 'outline'}>
|
||||
{active ? '当前生产部署' : '历史部署'}
|
||||
</Badge>
|
||||
<Badge variant='secondary'>
|
||||
{deploymentSnapshot(deployment)}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='truncate text-xs text-muted-foreground'>
|
||||
{deployment.checksum.slice(0, 16)} ·{' '}
|
||||
{deployment.file_count} 个文件 ·{' '}
|
||||
{formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
创建于 {formatDateTime(deployment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 md:ml-10'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={active || actionPending}
|
||||
onClick={() =>
|
||||
setPendingAction({ type: 'activate', deployment })
|
||||
}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={active || actionPending}
|
||||
onClick={() =>
|
||||
setPendingAction({ type: 'delete', deployment })
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<DeploymentFilesPanel
|
||||
projectId={projectId}
|
||||
deploymentId={deployment.id}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && !actionPending) setPendingAction(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingAction?.type === 'activate' ? '激活历史部署' : '删除部署'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingAction?.type === 'activate'
|
||||
? '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。'
|
||||
: `确认删除部署 #${pendingAction?.deployment.deployment_number} 吗?此操作不可恢复。`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={actionPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={actionPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (!pendingAction) return;
|
||||
if (pendingAction.type === 'activate') {
|
||||
activateMutation.mutate(pendingAction.deployment.id);
|
||||
} else {
|
||||
deleteMutation.mutate(pendingAction.deployment.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{actionPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,613 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import {
|
||||
Download,
|
||||
Github,
|
||||
Pencil,
|
||||
RefreshCw,
|
||||
RotateCcw,
|
||||
Search,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
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,
|
||||
CardFooter,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { AdminTaskService } from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesSource,
|
||||
type PagesSourceActionPayload,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceStatus,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import { type PagesSourceMode, PagesSourceDialog } from './pages-source-dialog';
|
||||
import {
|
||||
GitHubSourceDetails,
|
||||
RemoteSourceDetails,
|
||||
} from './pages-source-details';
|
||||
|
||||
const ACTION_POLL_INTERVAL = 2_000;
|
||||
const ACTION_MAX_WAIT = 16 * 60 * 1_000;
|
||||
const LATEST_POLL_INTERVAL = 5 * 60 * 1_000;
|
||||
const LATEST_NEAR_DUE_POLL_INTERVAL = 30_000;
|
||||
const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000;
|
||||
|
||||
const SOURCE_STATUS: Record<
|
||||
PagesSourceStatus,
|
||||
{
|
||||
label: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
}
|
||||
> = {
|
||||
idle: { label: '空闲', variant: 'outline' },
|
||||
checking: { label: '检查中', variant: 'secondary' },
|
||||
update_available: { label: '有可用更新', variant: 'default' },
|
||||
syncing: { label: '同步中', variant: 'secondary' },
|
||||
failed: { label: '最近同步失败', variant: 'destructive' },
|
||||
attention: { label: '需要确认', variant: 'destructive' },
|
||||
};
|
||||
|
||||
interface ActiveAction {
|
||||
receipt: PagesSourceActionReceipt;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface LatestSourceOverdueWindow {
|
||||
nextCheckAt: string;
|
||||
startedAt: number;
|
||||
}
|
||||
|
||||
export interface LatestSourcePollingDecision {
|
||||
interval: number | false;
|
||||
overdueWindow: LatestSourceOverdueWindow | null;
|
||||
}
|
||||
|
||||
export function getLatestSourceIdlePollingDecision(
|
||||
source: PagesSource | undefined,
|
||||
now: number,
|
||||
overdueWindow: LatestSourceOverdueWindow | null,
|
||||
): LatestSourcePollingDecision {
|
||||
if (
|
||||
source?.source_type !== 'github_release' ||
|
||||
source.release_selector !== 'latest'
|
||||
) {
|
||||
return { interval: false, overdueWindow: null };
|
||||
}
|
||||
|
||||
const nextCheckAt = source.next_check_at;
|
||||
const nextCheckTime = nextCheckAt ? Date.parse(nextCheckAt) : Number.NaN;
|
||||
if (!nextCheckAt || !Number.isFinite(nextCheckTime)) {
|
||||
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
|
||||
const timeUntilCheck = nextCheckTime - now;
|
||||
if (timeUntilCheck > LATEST_POLL_INTERVAL) {
|
||||
return { interval: LATEST_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
if (timeUntilCheck > 0) {
|
||||
return { interval: LATEST_NEAR_DUE_POLL_INTERVAL, overdueWindow: null };
|
||||
}
|
||||
|
||||
const currentWindow =
|
||||
overdueWindow?.nextCheckAt === nextCheckAt
|
||||
? overdueWindow
|
||||
: { nextCheckAt, startedAt: now };
|
||||
if (now - currentWindow.startedAt >= LATEST_OVERDUE_MAX_WAIT) {
|
||||
return { interval: false, overdueWindow: currentWindow };
|
||||
}
|
||||
return {
|
||||
interval: LATEST_NEAR_DUE_POLL_INTERVAL,
|
||||
overdueWindow: currentWindow,
|
||||
};
|
||||
}
|
||||
|
||||
function sourceDeploymentFingerprint(source: PagesSource) {
|
||||
if (source.source_type === 'manual') return '|';
|
||||
return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`;
|
||||
}
|
||||
|
||||
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||
return action === 'check' ? '检查' : '同步并发布';
|
||||
}
|
||||
|
||||
export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
const queryClient = useQueryClient();
|
||||
const handledExecutionID = useRef<string | null>(null);
|
||||
const sourcePollingStartedAt = useRef<number | null>(null);
|
||||
const latestOverdueWindow = useRef<LatestSourceOverdueWindow | null>(null);
|
||||
const sourceDeploymentState = useRef<string | undefined>(undefined);
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<PagesSourceMode>('manual');
|
||||
const [activeAction, setActiveAction] = useState<ActiveAction | null>(null);
|
||||
const [actionTimedOut, setActionTimedOut] = useState(false);
|
||||
const [attentionDialogOpen, setAttentionDialogOpen] = useState(false);
|
||||
|
||||
const sourceQuery = useQuery({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
queryFn: () => PagesService.getSource(projectId),
|
||||
refetchInterval: (query) => {
|
||||
const source = query.state.data;
|
||||
if (
|
||||
source &&
|
||||
source.source_type !== 'manual' &&
|
||||
(source.sync_status === 'checking' || source.sync_status === 'syncing')
|
||||
) {
|
||||
sourcePollingStartedAt.current ??= Date.now();
|
||||
latestOverdueWindow.current = null;
|
||||
return Date.now() - sourcePollingStartedAt.current < ACTION_MAX_WAIT
|
||||
? ACTION_POLL_INTERVAL
|
||||
: false;
|
||||
}
|
||||
sourcePollingStartedAt.current = null;
|
||||
const decision = getLatestSourceIdlePollingDecision(
|
||||
source,
|
||||
Date.now(),
|
||||
latestOverdueWindow.current,
|
||||
);
|
||||
latestOverdueWindow.current = decision.overdueWindow;
|
||||
return decision.interval;
|
||||
},
|
||||
});
|
||||
|
||||
const invalidateSourceState = useCallback(
|
||||
() =>
|
||||
Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['openflare', 'pages', 'deployment-files', projectId],
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]),
|
||||
[projectId, queryClient],
|
||||
);
|
||||
|
||||
const executionQuery = useQuery({
|
||||
queryKey: [
|
||||
'admin',
|
||||
'task-execution',
|
||||
activeAction?.receipt.execution_id ?? '',
|
||||
],
|
||||
queryFn: () => {
|
||||
const executionID = activeAction?.receipt.execution_id;
|
||||
if (!executionID) throw new Error('缺少任务执行 ID');
|
||||
return AdminTaskService.getTaskExecution(executionID);
|
||||
},
|
||||
enabled: Boolean(activeAction) && !actionTimedOut,
|
||||
refetchInterval: (query) => {
|
||||
if (actionTimedOut) return false;
|
||||
const status = query.state.data?.status;
|
||||
return status === 'pending' || status === 'running'
|
||||
? ACTION_POLL_INTERVAL
|
||||
: false;
|
||||
},
|
||||
});
|
||||
|
||||
const beginActionPolling = useCallback(
|
||||
(receipt: PagesSourceActionReceipt) => {
|
||||
handledExecutionID.current = null;
|
||||
setActiveAction({ receipt, startedAt: Date.now() });
|
||||
setActionTimedOut(false);
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!activeAction || actionTimedOut) return;
|
||||
const elapsed = Date.now() - activeAction.startedAt;
|
||||
const remaining = Math.max(0, ACTION_MAX_WAIT - elapsed);
|
||||
const timeout = window.setTimeout(() => setActionTimedOut(true), remaining);
|
||||
return () => window.clearTimeout(timeout);
|
||||
}, [actionTimedOut, activeAction]);
|
||||
|
||||
useEffect(() => {
|
||||
const source = sourceQuery.data;
|
||||
if (!source) return;
|
||||
const fingerprint = sourceDeploymentFingerprint(source);
|
||||
const previousFingerprint = sourceDeploymentState.current;
|
||||
sourceDeploymentState.current = fingerprint;
|
||||
if (
|
||||
previousFingerprint === undefined ||
|
||||
previousFingerprint === fingerprint
|
||||
) {
|
||||
return;
|
||||
}
|
||||
void invalidateSourceState();
|
||||
}, [invalidateSourceState, sourceQuery.data]);
|
||||
|
||||
useEffect(() => {
|
||||
const execution = executionQuery.data;
|
||||
if (
|
||||
!activeAction ||
|
||||
!execution ||
|
||||
!['succeeded', 'failed'].includes(execution.status)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (handledExecutionID.current === execution.id) return;
|
||||
handledExecutionID.current = execution.id;
|
||||
|
||||
void invalidateSourceState();
|
||||
|
||||
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||
if (execution.status === 'succeeded') {
|
||||
toast.success(`部署源${actionLabel}完成`);
|
||||
} else {
|
||||
toast.error(execution.error_message || `部署源${actionLabel}失败`);
|
||||
}
|
||||
setActiveAction(null);
|
||||
setActionTimedOut(false);
|
||||
}, [activeAction, executionQuery.data, invalidateSourceState]);
|
||||
|
||||
const checkMutation = useMutation({
|
||||
mutationFn: () => PagesService.checkSource(projectId),
|
||||
onSuccess: async (receipt) => {
|
||||
beginActionPolling(receipt);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
toast.success('检查任务已提交');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
|
||||
},
|
||||
});
|
||||
|
||||
const syncMutation = useMutation({
|
||||
mutationFn: (payload: PagesSourceActionPayload) =>
|
||||
PagesService.syncSource(projectId, payload),
|
||||
onSuccess: async (receipt) => {
|
||||
setAttentionDialogOpen(false);
|
||||
beginActionPolling(receipt);
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
toast.success('同步任务已提交');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '同步任务提交失败');
|
||||
},
|
||||
});
|
||||
|
||||
const source = sourceQuery.data;
|
||||
const executionBusy =
|
||||
activeAction !== null &&
|
||||
(executionQuery.data?.status === undefined ||
|
||||
executionQuery.data.status === 'pending' ||
|
||||
executionQuery.data.status === 'running');
|
||||
const sourceBusy =
|
||||
source?.source_type !== 'manual' &&
|
||||
(source?.sync_status === 'checking' || source?.sync_status === 'syncing');
|
||||
const actionsDisabled =
|
||||
checkMutation.isPending ||
|
||||
syncMutation.isPending ||
|
||||
executionBusy ||
|
||||
sourceBusy;
|
||||
const checkBusy =
|
||||
checkMutation.isPending ||
|
||||
(executionBusy && activeAction?.receipt.action === 'check') ||
|
||||
(source?.source_type !== 'manual' && source?.sync_status === 'checking');
|
||||
const syncBusy =
|
||||
syncMutation.isPending ||
|
||||
(executionBusy && activeAction?.receipt.action === 'sync') ||
|
||||
(source?.source_type !== 'manual' && source?.sync_status === 'syncing');
|
||||
const dispatchError = checkMutation.error ?? syncMutation.error;
|
||||
|
||||
const openSourceDialog = (mode: PagesSourceMode) => {
|
||||
setDialogMode(mode);
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
const dispatchSync = () => {
|
||||
checkMutation.reset();
|
||||
if (
|
||||
source?.source_type === 'github_release' &&
|
||||
source.sync_status === 'attention'
|
||||
) {
|
||||
setAttentionDialogOpen(true);
|
||||
return;
|
||||
}
|
||||
syncMutation.mutate({});
|
||||
};
|
||||
|
||||
if (sourceQuery.isLoading) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<CardDescription>加载来源配置...</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-3'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
<Skeleton className='h-20 w-full' />
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
if (sourceQuery.isError || !source) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<CardDescription>来源配置与部署历史相互独立。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorInline
|
||||
message={
|
||||
sourceQuery.error instanceof Error
|
||||
? sourceQuery.error.message
|
||||
: '部署源加载失败'
|
||||
}
|
||||
onRetry={() => void sourceQuery.refetch()}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
const effectiveSourceStatus = executionBusy
|
||||
? activeAction?.receipt.action === 'check'
|
||||
? 'checking'
|
||||
: 'syncing'
|
||||
: source.source_type === 'manual'
|
||||
? undefined
|
||||
: (source.sync_status ?? 'idle');
|
||||
const status =
|
||||
source.source_type === 'manual'
|
||||
? null
|
||||
: SOURCE_STATUS[effectiveSourceStatus ?? 'idle'];
|
||||
const attentionRevision =
|
||||
source.source_type === 'github_release' &&
|
||||
source.sync_status === 'attention'
|
||||
? source.last_seen
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>部署源</CardTitle>
|
||||
<CardDescription>
|
||||
来源配置负责发现内容,发布结果记录在独立的部署历史中。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
{status ? (
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
) : (
|
||||
<Badge variant='outline'>手动部署</Badge>
|
||||
)}
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className='flex flex-col gap-4'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<div className='rounded-lg border bg-muted/20 p-4'>
|
||||
<p className='text-sm font-medium'>本地部署包</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。
|
||||
</p>
|
||||
</div>
|
||||
) : source.source_type === 'remote_url' ? (
|
||||
<RemoteSourceDetails source={source} />
|
||||
) : (
|
||||
<GitHubSourceDetails source={source} />
|
||||
)}
|
||||
|
||||
{dispatchError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
dispatchError instanceof Error
|
||||
? dispatchError.message
|
||||
: '来源任务提交失败'
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
{executionQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={
|
||||
executionQuery.error instanceof Error
|
||||
? executionQuery.error.message
|
||||
: '任务状态读取失败'
|
||||
}
|
||||
onRetry={() => void executionQuery.refetch()}
|
||||
/>
|
||||
) : null}
|
||||
{actionTimedOut ? (
|
||||
<div className='flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
自动等待已停止,任务可能仍在后台运行。
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
if (!activeAction) return;
|
||||
setActiveAction({ ...activeAction, startedAt: Date.now() });
|
||||
setActionTimedOut(false);
|
||||
void executionQuery.refetch();
|
||||
void sourceQuery.refetch();
|
||||
}}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
刷新任务状态
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
</CardContent>
|
||||
|
||||
<CardFooter className='flex flex-wrap gap-2 border-t'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => openSourceDialog('remote_url')}
|
||||
>
|
||||
<Download data-icon='inline-start' />
|
||||
配置 Remote URL
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
onClick={() => openSourceDialog('github_release')}
|
||||
>
|
||||
<Github data-icon='inline-start' />
|
||||
配置 GitHub Release
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => openSourceDialog(source.source_type)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
编辑来源
|
||||
</Button>
|
||||
{source.source_type === 'github_release' ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => {
|
||||
syncMutation.reset();
|
||||
checkMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{checkBusy ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
<Search data-icon='inline-start' />
|
||||
)}
|
||||
检查更新
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type='button'
|
||||
disabled={
|
||||
actionsDisabled ||
|
||||
(source.sync_status === 'attention' && !attentionRevision)
|
||||
}
|
||||
onClick={dispatchSync}
|
||||
>
|
||||
{syncBusy ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : (
|
||||
<Download data-icon='inline-start' />
|
||||
)}
|
||||
同步并发布
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
disabled={actionsDisabled}
|
||||
onClick={() => openSourceDialog('manual')}
|
||||
>
|
||||
<RotateCcw data-icon='inline-start' />
|
||||
切换回手动
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='ml-auto'
|
||||
onClick={() => void sourceQuery.refetch()}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
刷新
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
|
||||
<PagesSourceDialog
|
||||
open={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
projectId={projectId}
|
||||
source={source}
|
||||
initialMode={dialogMode}
|
||||
onActionDispatched={beginActionPolling}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={attentionDialogOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!syncMutation.isPending) setAttentionDialogOpen(open);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
|
||||
<span className='break-all font-mono'>
|
||||
{attentionRevision?.revision ??
|
||||
'当前 revision 已不可用,请刷新后重试'}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={syncMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={syncMutation.isPending || !attentionRevision}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (!attentionRevision) return;
|
||||
syncMutation.mutate({
|
||||
confirmed_revision: attentionRevision.revision,
|
||||
});
|
||||
}}
|
||||
>
|
||||
{syncMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认并发布
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
|
||||
import {
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
type PagesSourceRevision,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
function revisionSummary(revision?: PagesSourceRevision) {
|
||||
if (!revision) return '尚无记录';
|
||||
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
|
||||
}
|
||||
|
||||
export function RemoteSourceDetails({
|
||||
source,
|
||||
}: {
|
||||
source: PagesRemoteURLSource;
|
||||
}) {
|
||||
return (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>脱敏地址</span>
|
||||
<code className='truncate text-sm'>{source.display_url}</code>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>网络策略</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.remote_network_policy === 'trusted_internal'
|
||||
? '受信内网模式'
|
||||
: '公网安全模式'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_synced_at
|
||||
? formatDateTime(source.last_synced_at)
|
||||
: '尚未同步'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>已应用 revision</span>
|
||||
<span className='font-mono text-sm'>
|
||||
{revisionSummary(source.last_applied)}
|
||||
</span>
|
||||
</div>
|
||||
{source.last_error ? (
|
||||
<div className='md:col-span-2'>
|
||||
<ErrorInline message={source.last_error} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function GitHubSourceDetails({
|
||||
source,
|
||||
}: {
|
||||
source: PagesGitHubReleaseSource;
|
||||
}) {
|
||||
const attentionRevision =
|
||||
source.sync_status === 'attention' ? source.last_seen : undefined;
|
||||
|
||||
return (
|
||||
<div className='flex flex-col gap-4'>
|
||||
{attentionRevision ? (
|
||||
<Alert variant='destructive'>
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
当前远端 revision
|
||||
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
|
||||
revision。
|
||||
</p>
|
||||
<code className='break-all'>{attentionRevision.revision}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
) : null}
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>GitHub 仓库</span>
|
||||
<code className='truncate text-sm'>{source.github_repository}</code>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>Release 选择</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.release_selector === 'latest'
|
||||
? '最新 Release'
|
||||
: `固定 Tag · ${source.release_tag ?? '未提供'}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex min-w-0 flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>Release Asset</span>
|
||||
<code className='truncate text-sm'>{source.asset_name}</code>
|
||||
</div>
|
||||
{source.release_selector === 'latest' ? (
|
||||
<>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>自动更新</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.auto_update_enabled ? '已开启' : '已关闭'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>检查间隔</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.check_interval_minutes} 分钟
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4 md:col-span-2'>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
下次检查时间
|
||||
</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.next_check_at
|
||||
? formatDateTime(source.next_check_at)
|
||||
: '等待调度'}
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>远端已发现</span>
|
||||
<span className='font-mono text-sm'>
|
||||
{revisionSummary(source.last_seen)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>当前已应用</span>
|
||||
<span className='font-mono text-sm'>
|
||||
{revisionSummary(source.last_applied)}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近检查</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_checked_at
|
||||
? formatDateTime(source.last_checked_at)
|
||||
: '尚未检查'}
|
||||
</span>
|
||||
</div>
|
||||
<div className='flex flex-col gap-1 rounded-lg border p-4'>
|
||||
<span className='text-xs text-muted-foreground'>最近同步</span>
|
||||
<span className='text-sm font-medium'>
|
||||
{source.last_synced_at
|
||||
? formatDateTime(source.last_synced_at)
|
||||
: '尚未同步'}
|
||||
</span>
|
||||
</div>
|
||||
{source.last_error ? (
|
||||
<div className='md:col-span-2'>
|
||||
<ErrorInline message={source.last_error} />
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,591 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Eye, EyeOff } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Field,
|
||||
FieldDescription,
|
||||
FieldGroup,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import {
|
||||
type PagesRemoteNetworkPolicy,
|
||||
type PagesSource,
|
||||
type PagesSourceActionReceipt,
|
||||
type PagesSourceUpdatePayload,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../../components/pages-utils';
|
||||
import {
|
||||
type PagesGitHubSourceFormErrors,
|
||||
type PagesGitHubSourceFormValue,
|
||||
PagesSourceGitHubFields,
|
||||
} from './pages-source-github-fields';
|
||||
import {
|
||||
validGitHubAssetName,
|
||||
validGitHubReleaseTag,
|
||||
validGitHubRepositoryURL,
|
||||
} from './pages-source-validation';
|
||||
|
||||
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
|
||||
|
||||
type Confirmation = 'trusted_internal' | 'manual' | null;
|
||||
|
||||
interface PagesSourceDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
projectId: number;
|
||||
source: PagesSource;
|
||||
initialMode?: PagesSourceMode;
|
||||
onActionDispatched?: (receipt: PagesSourceActionReceipt) => void;
|
||||
}
|
||||
|
||||
const DEFAULT_GITHUB_ASSET = 'dist.zip';
|
||||
const DEFAULT_GITHUB_CHECK_INTERVAL = 60;
|
||||
const EMPTY_GITHUB_ERRORS: PagesGitHubSourceFormErrors = {
|
||||
repository: '',
|
||||
releaseTag: '',
|
||||
assetName: '',
|
||||
checkInterval: '',
|
||||
};
|
||||
|
||||
function githubRepositoryURL(repository: string) {
|
||||
const value = repository.trim();
|
||||
return value ? `https://github.com/${value}` : '';
|
||||
}
|
||||
|
||||
export function PagesSourceDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
projectId,
|
||||
source,
|
||||
initialMode,
|
||||
onActionDispatched,
|
||||
}: PagesSourceDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const [mode, setMode] = useState<PagesSourceMode>('manual');
|
||||
const [networkPolicy, setNetworkPolicy] =
|
||||
useState<PagesRemoteNetworkPolicy>('public');
|
||||
const [replaceURL, setReplaceURL] = useState(false);
|
||||
const [remoteURL, setRemoteURL] = useState('');
|
||||
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
|
||||
const [urlError, setURLError] = useState('');
|
||||
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
|
||||
repositoryURL: '',
|
||||
releaseSelector: 'latest',
|
||||
releaseTag: '',
|
||||
assetName: DEFAULT_GITHUB_ASSET,
|
||||
autoUpdateEnabled: false,
|
||||
checkIntervalMinutes: String(DEFAULT_GITHUB_CHECK_INTERVAL),
|
||||
});
|
||||
const [githubErrors, setGitHubErrors] =
|
||||
useState<PagesGitHubSourceFormErrors>(EMPTY_GITHUB_ERRORS);
|
||||
const [confirmation, setConfirmation] = useState<Confirmation>(null);
|
||||
const initializedForOpen = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
initializedForOpen.current = false;
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
return;
|
||||
}
|
||||
// Runtime polling may replace the source view while the dialog is open.
|
||||
// Initialize only on the open edge so it cannot overwrite an unsaved draft.
|
||||
if (initializedForOpen.current) return;
|
||||
initializedForOpen.current = true;
|
||||
const nextMode = initialMode ?? source.source_type;
|
||||
setMode(nextMode);
|
||||
setNetworkPolicy(
|
||||
source.source_type === 'remote_url'
|
||||
? source.remote_network_policy
|
||||
: 'public',
|
||||
);
|
||||
setReplaceURL(source.source_type !== 'remote_url');
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
setGitHubForm({
|
||||
repositoryURL:
|
||||
source.source_type === 'github_release'
|
||||
? githubRepositoryURL(source.github_repository)
|
||||
: '',
|
||||
releaseSelector:
|
||||
source.source_type === 'github_release'
|
||||
? source.release_selector
|
||||
: 'latest',
|
||||
releaseTag:
|
||||
source.source_type === 'github_release'
|
||||
? (source.release_tag ?? '')
|
||||
: '',
|
||||
assetName:
|
||||
source.source_type === 'github_release'
|
||||
? source.asset_name
|
||||
: DEFAULT_GITHUB_ASSET,
|
||||
autoUpdateEnabled:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest'
|
||||
? source.auto_update_enabled
|
||||
: false,
|
||||
checkIntervalMinutes:
|
||||
source.source_type === 'github_release' &&
|
||||
source.release_selector === 'latest'
|
||||
? String(
|
||||
source.check_interval_minutes || DEFAULT_GITHUB_CHECK_INTERVAL,
|
||||
)
|
||||
: String(DEFAULT_GITHUB_CHECK_INTERVAL),
|
||||
});
|
||||
setGitHubErrors(EMPTY_GITHUB_ERRORS);
|
||||
setConfirmation(null);
|
||||
}, [initialMode, open, source]);
|
||||
|
||||
const invalidateSourceState = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: sourceQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
};
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (payload: PagesSourceUpdatePayload) =>
|
||||
PagesService.updateSource(projectId, payload),
|
||||
onSuccess: async (result) => {
|
||||
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
|
||||
if (result.check_task) onActionDispatched?.(result.check_task);
|
||||
await invalidateSourceState();
|
||||
toast.success('部署源已更新');
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '部署源更新失败');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteSource(projectId),
|
||||
onSuccess: async (manualSource) => {
|
||||
queryClient.setQueryData(sourceQueryKey(projectId), manualSource);
|
||||
await invalidateSourceState();
|
||||
toast.success('已切换回手动部署');
|
||||
setConfirmation(null);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '切换失败');
|
||||
},
|
||||
});
|
||||
|
||||
const isPending = updateMutation.isPending || deleteMutation.isPending;
|
||||
|
||||
const remotePayload = (): PagesSourceUpdatePayload => ({
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: replaceURL,
|
||||
remote_url: replaceURL ? remoteURL.trim() : '',
|
||||
remote_network_policy: networkPolicy,
|
||||
});
|
||||
|
||||
const submitRemote = () => {
|
||||
if (replaceURL) {
|
||||
const value = remoteURL.trim();
|
||||
if (!value) {
|
||||
setURLError('请输入 Remote URL');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
||||
} catch {
|
||||
setURLError('请输入有效的 HTTP(S) URL');
|
||||
return;
|
||||
}
|
||||
}
|
||||
setURLError('');
|
||||
if (networkPolicy === 'trusted_internal') {
|
||||
setConfirmation('trusted_internal');
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate(remotePayload());
|
||||
};
|
||||
|
||||
const submitGitHub = () => {
|
||||
const normalizedRepositoryURL = githubForm.repositoryURL.trim();
|
||||
const nextRepositoryError = validGitHubRepositoryURL(
|
||||
normalizedRepositoryURL,
|
||||
)
|
||||
? ''
|
||||
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
|
||||
const nextReleaseTagError =
|
||||
githubForm.releaseSelector === 'tag' &&
|
||||
!validGitHubReleaseTag(githubForm.releaseTag)
|
||||
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
|
||||
: '';
|
||||
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||
? ''
|
||||
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||
const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes);
|
||||
const nextCheckIntervalError =
|
||||
githubForm.releaseSelector === 'latest' &&
|
||||
(!Number.isInteger(checkIntervalMinutes) ||
|
||||
checkIntervalMinutes < 5 ||
|
||||
checkIntervalMinutes > 1440)
|
||||
? '检查间隔须为 5–1440 分钟的整数'
|
||||
: '';
|
||||
|
||||
setGitHubErrors({
|
||||
repository: nextRepositoryError,
|
||||
releaseTag: nextReleaseTagError,
|
||||
assetName: nextAssetNameError,
|
||||
checkInterval: nextCheckIntervalError,
|
||||
});
|
||||
if (
|
||||
nextRepositoryError ||
|
||||
nextReleaseTagError ||
|
||||
nextAssetNameError ||
|
||||
nextCheckIntervalError
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: PagesSourceUpdatePayload =
|
||||
githubForm.releaseSelector === 'latest'
|
||||
? {
|
||||
source_type: 'github_release',
|
||||
repository_url: normalizedRepositoryURL,
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: githubForm.assetName,
|
||||
auto_update_enabled: githubForm.autoUpdateEnabled,
|
||||
check_interval_minutes: checkIntervalMinutes,
|
||||
}
|
||||
: {
|
||||
source_type: 'github_release',
|
||||
repository_url: normalizedRepositoryURL,
|
||||
release_selector: 'tag',
|
||||
release_tag: githubForm.releaseTag,
|
||||
asset_name: githubForm.assetName,
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
};
|
||||
updateMutation.mutate(payload);
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
switch (mode) {
|
||||
case 'manual':
|
||||
if (source.source_type === 'manual') {
|
||||
onOpenChange(false);
|
||||
} else {
|
||||
setConfirmation('manual');
|
||||
}
|
||||
return;
|
||||
case 'remote_url':
|
||||
submitRemote();
|
||||
return;
|
||||
case 'github_release':
|
||||
submitGitHub();
|
||||
}
|
||||
};
|
||||
|
||||
const submitLabel =
|
||||
mode === 'manual'
|
||||
? '使用手动部署'
|
||||
: mode === 'remote_url'
|
||||
? '保存 Remote 来源'
|
||||
: '保存 GitHub 来源';
|
||||
|
||||
return (
|
||||
<>
|
||||
<Dialog
|
||||
open={open}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!isPending) onOpenChange(nextOpen);
|
||||
}}
|
||||
>
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>部署源设置</DialogTitle>
|
||||
<DialogDescription>
|
||||
手动部署、Remote URL 与 GitHub Release 使用独立配置。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldTitle id='pages-source-mode'>来源类型</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={mode}
|
||||
aria-labelledby='pages-source-mode'
|
||||
className='grid w-full grid-cols-1 sm:grid-cols-3'
|
||||
onValueChange={(value) => {
|
||||
if (
|
||||
value === 'manual' ||
|
||||
value === 'remote_url' ||
|
||||
value === 'github_release'
|
||||
) {
|
||||
setMode(value);
|
||||
if (value !== 'remote_url') {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
}
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='manual' className='w-full'>
|
||||
手动部署
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='remote_url' className='w-full'>
|
||||
Remote URL
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='github_release' className='w-full'>
|
||||
GitHub Release
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
<span>
|
||||
远端来源只负责发现内容,发布结果始终保留为不可变部署。
|
||||
</span>
|
||||
<span className='block'>
|
||||
仓库源码构建将在后续作为独立来源类型提供。
|
||||
</span>
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{mode === 'manual' ? (
|
||||
<Field>
|
||||
<FieldLabel>手动部署</FieldLabel>
|
||||
<div className='rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground'>
|
||||
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
|
||||
</div>
|
||||
</Field>
|
||||
) : mode === 'remote_url' ? (
|
||||
<>
|
||||
<Field data-invalid={Boolean(urlError)}>
|
||||
<FieldLabel htmlFor='pages-remote-url'>Remote URL</FieldLabel>
|
||||
{source.source_type === 'remote_url' && !replaceURL ? (
|
||||
<div className='flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<code className='min-w-0 truncate text-xs'>
|
||||
{source.display_url}
|
||||
</code>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => {
|
||||
setReplaceURL(true);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
}}
|
||||
>
|
||||
更换地址
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className='flex gap-2'>
|
||||
<Input
|
||||
id='pages-remote-url'
|
||||
type={revealRemoteURL ? 'url' : 'password'}
|
||||
placeholder='https://artifacts.example.com/site.zip?token=...'
|
||||
value={remoteURL}
|
||||
aria-invalid={Boolean(urlError)}
|
||||
autoComplete='off'
|
||||
className='min-w-0 flex-1'
|
||||
onChange={(event) => {
|
||||
setRemoteURL(event.target.value);
|
||||
setURLError('');
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='icon'
|
||||
aria-label={
|
||||
revealRemoteURL
|
||||
? '隐藏 Remote URL'
|
||||
: '显示 Remote URL'
|
||||
}
|
||||
title={
|
||||
revealRemoteURL
|
||||
? '隐藏 Remote URL'
|
||||
: '显示 Remote URL'
|
||||
}
|
||||
onClick={() =>
|
||||
setRevealRemoteURL((visible) => !visible)
|
||||
}
|
||||
>
|
||||
{revealRemoteURL ? <EyeOff /> : <Eye />}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<FieldDescription>
|
||||
{urlError ||
|
||||
(replaceURL
|
||||
? '保存后不会回显原始地址或 query token。'
|
||||
: '界面只显示脱敏地址;留空表示保留当前地址。')}
|
||||
</FieldDescription>
|
||||
{source.source_type === 'remote_url' && replaceURL ? (
|
||||
<Button
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='self-start'
|
||||
onClick={() => {
|
||||
setReplaceURL(false);
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
setURLError('');
|
||||
}}
|
||||
>
|
||||
保留当前地址
|
||||
</Button>
|
||||
) : null}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle id='pages-network-policy'>网络策略</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={networkPolicy}
|
||||
aria-labelledby='pages-network-policy'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(value) => {
|
||||
if (value === 'public' || value === 'trusted_internal') {
|
||||
setNetworkPolicy(value);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='public' className='w-full'>
|
||||
公网安全模式
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem
|
||||
value='trusted_internal'
|
||||
className='w-full'
|
||||
>
|
||||
受信内网模式
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
{networkPolicy === 'public'
|
||||
? '阻止内网地址、代理与不安全 TLS。'
|
||||
: '允许访问内网地址与自签名证书,仅用于可信来源。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
</>
|
||||
) : (
|
||||
<PagesSourceGitHubFields
|
||||
value={githubForm}
|
||||
errors={githubErrors}
|
||||
defaultAssetName={DEFAULT_GITHUB_ASSET}
|
||||
onChange={setGitHubForm}
|
||||
onErrorsChange={setGitHubErrors}
|
||||
/>
|
||||
)}
|
||||
</FieldGroup>
|
||||
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={isPending}
|
||||
onClick={() => {
|
||||
setRemoteURL('');
|
||||
setRevealRemoteURL(false);
|
||||
onOpenChange(false);
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</Button>
|
||||
<Button type='button' disabled={isPending} onClick={handleSubmit}>
|
||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
{submitLabel}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
<AlertDialog
|
||||
open={confirmation !== null}
|
||||
onOpenChange={(nextOpen) => {
|
||||
if (!nextOpen && !isPending) setConfirmation(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{confirmation === 'manual'
|
||||
? '切换回手动部署'
|
||||
: '启用受信内网模式'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{confirmation === 'manual'
|
||||
? '当前来源配置将被删除,但已有部署与当前生产版本会保留。'
|
||||
: '该模式允许访问私有网络并接受自签名证书。请确认此地址属于可信内部来源,且不会被非可信用户控制。'}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (confirmation === 'manual') {
|
||||
deleteMutation.mutate();
|
||||
} else if (confirmation === 'trusted_internal') {
|
||||
updateMutation.mutate(remotePayload());
|
||||
}
|
||||
}}
|
||||
>
|
||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,211 @@
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
FieldDescription,
|
||||
FieldError,
|
||||
FieldLabel,
|
||||
FieldTitle,
|
||||
} from '@/components/ui/field';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Switch } from '@/components/ui/switch';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { PagesGitHubReleaseSelector } from '@/lib/services/openflare';
|
||||
|
||||
export interface PagesGitHubSourceFormValue {
|
||||
repositoryURL: string;
|
||||
releaseSelector: PagesGitHubReleaseSelector;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
autoUpdateEnabled: boolean;
|
||||
checkIntervalMinutes: string;
|
||||
}
|
||||
|
||||
export interface PagesGitHubSourceFormErrors {
|
||||
repository: string;
|
||||
releaseTag: string;
|
||||
assetName: string;
|
||||
checkInterval: string;
|
||||
}
|
||||
|
||||
interface PagesSourceGitHubFieldsProps {
|
||||
value: PagesGitHubSourceFormValue;
|
||||
errors: PagesGitHubSourceFormErrors;
|
||||
defaultAssetName: string;
|
||||
onChange: (value: PagesGitHubSourceFormValue) => void;
|
||||
onErrorsChange: (errors: PagesGitHubSourceFormErrors) => void;
|
||||
}
|
||||
|
||||
export function PagesSourceGitHubFields({
|
||||
value,
|
||||
errors,
|
||||
defaultAssetName,
|
||||
onChange,
|
||||
onErrorsChange,
|
||||
}: PagesSourceGitHubFieldsProps) {
|
||||
return (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.repository)}>
|
||||
<FieldLabel htmlFor='pages-github-repository'>
|
||||
GitHub 仓库 URL
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-repository'
|
||||
type='url'
|
||||
placeholder='https://github.com/owner/repo'
|
||||
value={value.repositoryURL}
|
||||
aria-invalid={Boolean(errors.repository)}
|
||||
aria-describedby='pages-github-repository-description pages-github-repository-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, repositoryURL: event.target.value });
|
||||
onErrorsChange({ ...errors, repository: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-repository-description'>
|
||||
仅支持公开 github.com 仓库。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-repository-error'>
|
||||
{errors.repository}
|
||||
</FieldError>
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
value={value.releaseSelector}
|
||||
aria-labelledby='pages-github-selector'
|
||||
className='grid w-full grid-cols-2'
|
||||
onValueChange={(selector) => {
|
||||
if (selector === 'latest' || selector === 'tag') {
|
||||
onChange({
|
||||
...value,
|
||||
releaseSelector: selector,
|
||||
autoUpdateEnabled:
|
||||
selector === 'latest' ? value.autoUpdateEnabled : false,
|
||||
});
|
||||
onErrorsChange({
|
||||
...errors,
|
||||
releaseTag: '',
|
||||
checkInterval: '',
|
||||
});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='latest' className='w-full'>
|
||||
最新 Release
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='tag' className='w-full'>
|
||||
固定 Tag
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<FieldDescription>
|
||||
{value.releaseSelector === 'latest'
|
||||
? '定时检查最新 Release;是否自动发布由下方开关控制。'
|
||||
: '固定 Tag 仅支持管理员手动检查与同步。'}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
{value.releaseSelector === 'tag' ? (
|
||||
<Field data-invalid={Boolean(errors.releaseTag)}>
|
||||
<FieldLabel htmlFor='pages-github-tag'>Release tag</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-tag'
|
||||
placeholder='v1.2.3'
|
||||
value={value.releaseTag}
|
||||
aria-invalid={Boolean(errors.releaseTag)}
|
||||
aria-describedby='pages-github-tag-description pages-github-tag-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, releaseTag: event.target.value });
|
||||
onErrorsChange({ ...errors, releaseTag: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-tag-description'>
|
||||
精确检查并同步指定 tag。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-tag-error'>
|
||||
{errors.releaseTag}
|
||||
</FieldError>
|
||||
</Field>
|
||||
) : null}
|
||||
|
||||
{value.releaseSelector === 'latest' ? (
|
||||
<>
|
||||
<Field orientation='horizontal'>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor='pages-github-auto-update'>
|
||||
自动更新
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
检查到新的 Release 后自动同步并发布。
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
id='pages-github-auto-update'
|
||||
checked={value.autoUpdateEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
onChange({ ...value, autoUpdateEnabled: checked })
|
||||
}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field data-invalid={Boolean(errors.checkInterval)}>
|
||||
<FieldLabel htmlFor='pages-github-check-interval'>
|
||||
检查间隔(分钟)
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-check-interval'
|
||||
type='number'
|
||||
min={5}
|
||||
max={1440}
|
||||
step={1}
|
||||
inputMode='numeric'
|
||||
value={value.checkIntervalMinutes}
|
||||
aria-invalid={Boolean(errors.checkInterval)}
|
||||
aria-describedby='pages-github-check-interval-description pages-github-check-interval-error'
|
||||
onChange={(event) => {
|
||||
onChange({
|
||||
...value,
|
||||
checkIntervalMinutes: event.target.value,
|
||||
});
|
||||
onErrorsChange({ ...errors, checkInterval: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-check-interval-description'>
|
||||
可设置为 5–1440 分钟。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-check-interval-error'>
|
||||
{errors.checkInterval}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<Field data-invalid={Boolean(errors.assetName)}>
|
||||
<FieldLabel htmlFor='pages-github-asset'>
|
||||
Release Asset 文件名
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-asset'
|
||||
placeholder={defaultAssetName}
|
||||
value={value.assetName}
|
||||
aria-invalid={Boolean(errors.assetName)}
|
||||
aria-describedby='pages-github-asset-description pages-github-asset-error'
|
||||
autoComplete='off'
|
||||
onChange={(event) => {
|
||||
onChange({ ...value, assetName: event.target.value });
|
||||
onErrorsChange({ ...errors, assetName: '' });
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-asset-description'>
|
||||
按文件名精确匹配已上传的 Release Asset。
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-asset-error'>
|
||||
{errors.assetName}
|
||||
</FieldError>
|
||||
</Field>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
function hasWellFormedUnicode(value: string) {
|
||||
for (let index = 0; index < value.length; index += 1) {
|
||||
const code = value.charCodeAt(index);
|
||||
if (code >= 0xd800 && code <= 0xdbff) {
|
||||
const next = value.charCodeAt(index + 1);
|
||||
if (!(next >= 0xdc00 && next <= 0xdfff)) return false;
|
||||
index += 1;
|
||||
} else if (code >= 0xdc00 && code <= 0xdfff) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function validGitHubSafeText(value: string) {
|
||||
return (
|
||||
value !== '' &&
|
||||
new TextEncoder().encode(value).byteLength <= 255 &&
|
||||
!/[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u2028-\u202e\u2066-\u2069]/u.test(
|
||||
value,
|
||||
) &&
|
||||
hasWellFormedUnicode(value)
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubRepositoryURL(raw: string) {
|
||||
const match = /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)$/u.exec(raw);
|
||||
if (!match) return false;
|
||||
|
||||
const [, host, owner, rawRepository] = match;
|
||||
const repository = rawRepository.replace(/\.git$/u, '');
|
||||
return (
|
||||
host.toLowerCase() === 'github.com' &&
|
||||
/^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/iu.test(owner) &&
|
||||
/^[a-z0-9._-]+$/iu.test(repository) &&
|
||||
repository.length <= 100 &&
|
||||
!['.', '..'].includes(repository)
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubAssetName(value: string) {
|
||||
return (
|
||||
validGitHubSafeText(value) &&
|
||||
value !== '.' &&
|
||||
value !== '..' &&
|
||||
!value.includes('/') &&
|
||||
!value.includes('\\')
|
||||
);
|
||||
}
|
||||
|
||||
export function validGitHubReleaseTag(value: string) {
|
||||
const components = value.split('/');
|
||||
return (
|
||||
validGitHubSafeText(value) &&
|
||||
!value.endsWith('.') &&
|
||||
!value.includes('..') &&
|
||||
!value.includes('@{') &&
|
||||
![' ', '~', '^', ':', '?', '*', '[', '\\'].some((character) =>
|
||||
value.includes(character),
|
||||
) &&
|
||||
components.every(
|
||||
(component) =>
|
||||
component !== '' &&
|
||||
!component.startsWith('.') &&
|
||||
!component.endsWith('.lock'),
|
||||
)
|
||||
);
|
||||
}
|
||||
@@ -1,505 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useSearchParams } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
FileText,
|
||||
Loader2,
|
||||
Trash2,
|
||||
Upload,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { DeploymentUploadDialog } from '../components/deployment-upload-dialog';
|
||||
import { ProjectEditorDialog } from '../components/project-editor-dialog';
|
||||
import {
|
||||
deploymentFilesQueryKey,
|
||||
deploymentsQueryKey,
|
||||
formatBytes,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
} from '../components/pages-utils';
|
||||
|
||||
function DeploymentFilesPanel({
|
||||
projectId,
|
||||
deployment,
|
||||
}: {
|
||||
projectId: number;
|
||||
deployment: PagesDeployment;
|
||||
}) {
|
||||
const filesQuery = useQuery({
|
||||
queryKey: deploymentFilesQueryKey(projectId, deployment.id),
|
||||
queryFn: () => PagesService.listDeploymentFiles(deployment.id),
|
||||
});
|
||||
|
||||
if (filesQuery.isLoading) {
|
||||
return (
|
||||
<p className='px-4 py-3 text-xs text-muted-foreground'>加载文件清单...</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (filesQuery.isError) {
|
||||
return (
|
||||
<p className='px-4 py-3 text-xs text-destructive'>
|
||||
{filesQuery.error instanceof Error
|
||||
? filesQuery.error.message
|
||||
: '加载失败'}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
const files = filesQuery.data ?? [];
|
||||
if (files.length === 0) {
|
||||
return (
|
||||
<p className='px-4 py-3 text-xs text-muted-foreground'>暂无文件记录</p>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='border-t border-dashed bg-muted/10'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className='border-dashed hover:bg-transparent'>
|
||||
<TableHead className='text-xs'>路径</TableHead>
|
||||
<TableHead className='text-xs text-right'>大小</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{files.map((file) => (
|
||||
<TableRow key={file.id} className='border-dashed'>
|
||||
<TableCell className='text-xs font-mono'>{file.path}</TableCell>
|
||||
<TableCell className='text-xs text-right text-muted-foreground'>
|
||||
{formatBytes(file.size)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function PagesDetailPageClient() {
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const projectId = searchParams.get('id')?.trim() ?? '';
|
||||
const parsedProjectId = Number(projectId);
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
|
||||
number | null
|
||||
>(null);
|
||||
const [deleteProjectOpen, setDeleteProjectOpen] = useState(false);
|
||||
const [pendingDeploymentAction, setPendingDeploymentAction] = useState<{
|
||||
type: 'activate' | 'delete';
|
||||
deployment: PagesDeployment;
|
||||
} | null>(null);
|
||||
|
||||
const enabled = projectId !== '' && Number.isFinite(parsedProjectId);
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
queryFn: () => PagesService.getProject(parsedProjectId),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const deploymentsQuery = useQuery({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
queryFn: () => PagesService.listDeployments(parsedProjectId),
|
||||
enabled,
|
||||
});
|
||||
|
||||
const activateMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
PagesService.activateDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: async () => {
|
||||
toast.success('部署已激活');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
setPendingDeploymentAction(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '激活失败');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteDeploymentMutation = useMutation({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
PagesService.deleteDeployment(parsedProjectId, deploymentId),
|
||||
onSuccess: async () => {
|
||||
toast.success('部署已删除');
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(parsedProjectId),
|
||||
}),
|
||||
queryClient.invalidateQueries({ queryKey: projectQueryKey(projectId) }),
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
]);
|
||||
setPendingDeploymentAction(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteProject(parsedProjectId),
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已删除');
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
window.location.href = '/pages';
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
const project = projectQuery.data;
|
||||
const deployments = useMemo(
|
||||
() => deploymentsQuery.data ?? [],
|
||||
[deploymentsQuery.data],
|
||||
);
|
||||
|
||||
if (!enabled) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder description='缺少有效的 Pages 项目 ID。' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder icon={FileText} description='加载项目详情...' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<div className='py-6 px-1'>
|
||||
<ErrorInline
|
||||
message={
|
||||
projectQuery.error instanceof Error
|
||||
? projectQuery.error.message
|
||||
: '加载失败'
|
||||
}
|
||||
onRetry={() => void projectQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!project) {
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-4'>
|
||||
<Button variant='ghost' size='sm' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder description='Pages 项目不存在或已被删除。' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='space-y-2'>
|
||||
<Button variant='ghost' size='sm' className='h-8 px-2 -ml-2' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft className='size-4 mr-1' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{project.name}
|
||||
</h1>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{project.slug} · {project.deployment_count} 个部署
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑项目
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => setUploadOpen(true)}>
|
||||
<Upload className='size-3.5 mr-1' />
|
||||
上传部署包
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setDeleteProjectOpen(true)}
|
||||
>
|
||||
<Trash2 className='size-3.5 mr-1' />
|
||||
删除项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-3 sm:grid-cols-3'>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
状态
|
||||
</p>
|
||||
<Badge variant='outline' className='mt-2 text-[10px]'>
|
||||
{project.enabled ? '已启用' : '已停用'}
|
||||
</Badge>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
当前激活
|
||||
</p>
|
||||
<p className='mt-2 text-sm font-semibold'>
|
||||
{project.active_deployment
|
||||
? `#${project.active_deployment.deployment_number}`
|
||||
: '暂无'}
|
||||
</p>
|
||||
</div>
|
||||
<div className='rounded-lg border border-dashed px-4 py-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
更新时间
|
||||
</p>
|
||||
<p className='mt-2 text-sm'>{formatDateTime(project.updated_at)}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='border border-dashed rounded-lg overflow-hidden bg-background'>
|
||||
<div className='px-4 py-3 border-b border-dashed'>
|
||||
<h2 className='text-sm font-semibold'>部署历史</h2>
|
||||
<p className='text-xs text-muted-foreground mt-1'>
|
||||
部署不可变;激活后发布配置,Agent 才会拉取并切换静态资源。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{deploymentsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder />
|
||||
) : deployments.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无部署'
|
||||
description='上传 zip 部署包后,可以在这里激活某个部署版本。'
|
||||
/>
|
||||
) : (
|
||||
<div className='divide-y divide-dashed'>
|
||||
{deployments.map((deployment) => {
|
||||
const expanded = expandedDeploymentId === deployment.id;
|
||||
return (
|
||||
<div key={deployment.id}>
|
||||
<div className='flex flex-col gap-3 p-4 md:flex-row md:items-center md:justify-between'>
|
||||
<div className='flex items-start gap-2'>
|
||||
<Button
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='size-7 shrink-0'
|
||||
onClick={() =>
|
||||
setExpandedDeploymentId(
|
||||
expanded ? null : deployment.id,
|
||||
)
|
||||
}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className='size-4' />
|
||||
) : (
|
||||
<ChevronRight className='size-4' />
|
||||
)}
|
||||
</Button>
|
||||
<div>
|
||||
<div className='flex items-center gap-2'>
|
||||
<p className='text-sm font-medium'>
|
||||
#{deployment.deployment_number}
|
||||
</p>
|
||||
{deployment.status === 'active' ? (
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
已激活
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
{deployment.checksum.slice(0, 16)} ·{' '}
|
||||
{deployment.file_count} files ·{' '}
|
||||
{formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
<p className='mt-1 text-xs text-muted-foreground'>
|
||||
创建于 {formatDateTime(deployment.created_at)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2 md:ml-9'>
|
||||
<Button
|
||||
variant='outline'
|
||||
size='sm'
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
activateMutation.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
setPendingDeploymentAction({
|
||||
type: 'activate',
|
||||
deployment,
|
||||
})
|
||||
}
|
||||
>
|
||||
激活
|
||||
</Button>
|
||||
<Button
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
disabled={
|
||||
deployment.status === 'active' ||
|
||||
deleteDeploymentMutation.isPending
|
||||
}
|
||||
onClick={() =>
|
||||
setPendingDeploymentAction({
|
||||
type: 'delete',
|
||||
deployment,
|
||||
})
|
||||
}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
{expanded ? (
|
||||
<DeploymentFilesPanel
|
||||
projectId={parsedProjectId}
|
||||
deployment={deployment}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<ProjectEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
project={project}
|
||||
/>
|
||||
<DeploymentUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
projectId={parsedProjectId}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteProjectOpen} onOpenChange={setDeleteProjectOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除项目 {project.name} 吗?此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className='bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
onClick={() => deleteProjectMutation.mutate()}
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<Loader2 className='size-4 animate-spin' />
|
||||
) : (
|
||||
'确认删除'
|
||||
)}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog
|
||||
open={pendingDeploymentAction !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) setPendingDeploymentAction(null);
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingDeploymentAction?.type === 'activate'
|
||||
? '激活部署'
|
||||
: '删除部署'}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingDeploymentAction?.type === 'activate'
|
||||
? `确认激活部署 #${pendingDeploymentAction.deployment.deployment_number} 吗?`
|
||||
: `确认删除部署 #${pendingDeploymentAction?.deployment.deployment_number} 吗?`}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className={
|
||||
pendingDeploymentAction?.type === 'delete'
|
||||
? 'bg-destructive text-destructive-foreground hover:bg-destructive/90'
|
||||
: undefined
|
||||
}
|
||||
onClick={() => {
|
||||
if (!pendingDeploymentAction) return;
|
||||
if (pendingDeploymentAction.type === 'activate') {
|
||||
activateMutation.mutate(
|
||||
pendingDeploymentAction.deployment.id,
|
||||
);
|
||||
} else {
|
||||
deleteDeploymentMutation.mutate(
|
||||
pendingDeploymentAction.deployment.id,
|
||||
);
|
||||
}
|
||||
}}
|
||||
>
|
||||
确认
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +1,319 @@
|
||||
import { Suspense } from 'react';
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Suspense, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, FileText, Pencil, Trash2, 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 { Skeleton } from '@/components/ui/skeleton';
|
||||
import { Spinner } from '@/components/ui/spinner';
|
||||
import { PagesService } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import { PagesDetailPageClient } from './page-client';
|
||||
import {
|
||||
DeploymentUploadDialog,
|
||||
pagesEntryPath,
|
||||
} from '../components/deployment-upload-dialog';
|
||||
import { ProjectEditorDialog } from '../components/project-editor-dialog';
|
||||
import {
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '../components/pages-utils';
|
||||
import { DeploymentHistory } from './components/deployment-history';
|
||||
import { PagesSourceCard } from './components/pages-source-card';
|
||||
|
||||
function PagesDetailPageFallback() {
|
||||
return (
|
||||
<div className='py-6 px-1 space-y-6'>
|
||||
<Skeleton className='h-8 w-48' />
|
||||
<Skeleton className='h-10 w-full max-w-xl' />
|
||||
<div className='flex w-full flex-col gap-6 px-1 py-6'>
|
||||
<Skeleton className='h-8 w-32' />
|
||||
<Skeleton className='h-12 w-full max-w-xl' />
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<Skeleton className='h-40 w-full' />
|
||||
<Skeleton className='h-40 w-full' />
|
||||
</div>
|
||||
<Skeleton className='h-64 w-full' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PagesDetailRoute() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [deleteProjectOpen, setDeleteProjectOpen] = useState(false);
|
||||
|
||||
const rawProjectId = searchParams.get('id')?.trim() ?? '';
|
||||
const projectId = Number(rawProjectId);
|
||||
const validProjectId =
|
||||
rawProjectId !== '' && Number.isInteger(projectId) && projectId > 0;
|
||||
|
||||
const projectQuery = useQuery({
|
||||
queryKey: projectQueryKey(projectId),
|
||||
queryFn: () => PagesService.getProject(projectId),
|
||||
enabled: validProjectId,
|
||||
});
|
||||
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteProject(projectId),
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已删除');
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
},
|
||||
});
|
||||
|
||||
if (!validProjectId) {
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<EmptyStateWithBorder description='缺少有效的 Pages 项目 ID。' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isLoading) {
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<LoadingStateWithBorder icon={FileText} description='加载项目详情...' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (projectQuery.isError) {
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<ErrorInline
|
||||
message={
|
||||
projectQuery.error instanceof Error
|
||||
? projectQuery.error.message
|
||||
: '项目详情加载失败'
|
||||
}
|
||||
onRetry={() => void projectQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const project = projectQuery.data;
|
||||
if (!project) {
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-4 px-1 py-6'>
|
||||
<Button variant='ghost' size='sm' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder description='Pages 项目不存在或已被删除。' />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const activeDeployment = project.active_deployment;
|
||||
const entryPath = pagesEntryPath(project.root_dir ?? '', project.entry_file);
|
||||
|
||||
return (
|
||||
<div className='flex w-full flex-col gap-6 px-1 py-6'>
|
||||
<div className='flex flex-col gap-4'>
|
||||
<Button variant='ghost' size='sm' className='self-start' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
|
||||
<div className='flex flex-col gap-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{project.name}
|
||||
</h1>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{project.slug} · {project.deployment_count} 个部署
|
||||
</p>
|
||||
</div>
|
||||
<div className='flex flex-wrap gap-2'>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
编辑项目
|
||||
</Button>
|
||||
<Button type='button' size='sm' onClick={() => setUploadOpen(true)}>
|
||||
<Upload data-icon='inline-start' />
|
||||
上传部署包
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
onClick={() => setDeleteProjectOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
删除项目
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='grid gap-4 lg:grid-cols-2'>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>当前生产部署</CardTitle>
|
||||
<CardDescription>
|
||||
Agent 当前应拉取并提供服务的不可变部署。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={activeDeployment ? 'default' : 'outline'}>
|
||||
{activeDeployment ? '生产中' : '未发布'}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-2'>
|
||||
{activeDeployment ? (
|
||||
<>
|
||||
<p className='text-lg font-semibold'>
|
||||
部署 #{activeDeployment.deployment_number}
|
||||
</p>
|
||||
<p className='font-mono text-xs text-muted-foreground'>
|
||||
{activeDeployment.checksum.slice(0, 20)}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
激活于{' '}
|
||||
{activeDeployment.activated_at
|
||||
? formatDateTime(activeDeployment.activated_at)
|
||||
: '未知时间'}
|
||||
</p>
|
||||
</>
|
||||
) : (
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
尚无生产部署。上传或同步来源后,从部署历史激活一个版本。
|
||||
</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>站点入口</CardTitle>
|
||||
<CardDescription>
|
||||
解包校验、发布快照与 Agent 切换共同使用此路径。
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
|
||||
{project.enabled ? '项目已启用' : '项目已停用'}
|
||||
</Badge>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
<CardContent className='flex flex-col gap-2'>
|
||||
<code className='rounded-md border bg-muted/20 px-3 py-2 text-sm'>
|
||||
{entryPath}
|
||||
</code>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
项目更新于 {formatDateTime(project.updated_at)}
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<PagesSourceCard key={`source-${projectId}`} projectId={projectId} />
|
||||
<DeploymentHistory
|
||||
key={`deployments-${projectId}`}
|
||||
projectId={projectId}
|
||||
activeDeploymentId={project.active_deployment_id}
|
||||
/>
|
||||
|
||||
<ProjectEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={(nextOpen) => {
|
||||
setEditorOpen(nextOpen);
|
||||
if (!nextOpen) {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
}
|
||||
}}
|
||||
project={project}
|
||||
/>
|
||||
<DeploymentUploadDialog
|
||||
open={uploadOpen}
|
||||
onOpenChange={setUploadOpen}
|
||||
projectId={projectId}
|
||||
rootDir={project.root_dir ?? ''}
|
||||
entryFile={project.entry_file}
|
||||
/>
|
||||
|
||||
<AlertDialog open={deleteProjectOpen} onOpenChange={setDeleteProjectOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除项目 {project.name} 吗?此操作不可恢复。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteProjectMutation.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function PagesDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<PagesDetailPageFallback />}>
|
||||
<PagesDetailPageClient />
|
||||
<PagesDetailRoute />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import * as React from 'react';
|
||||
import { cva, type VariantProps } from 'class-variance-authority';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const alertVariants = cva(
|
||||
'relative grid w-full grid-cols-[0_1fr] items-start gap-y-0.5 rounded-lg border px-4 py-3 text-sm has-[>svg]:grid-cols-[calc(var(--spacing)*4)_1fr] has-[>svg]:gap-x-3 [&>svg]:size-4 [&>svg]:translate-y-0.5 [&>svg]:text-current',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-card text-card-foreground',
|
||||
destructive:
|
||||
'bg-card text-destructive *:data-[slot=alert-description]:text-destructive/90 [&>svg]:text-current',
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
function Alert({
|
||||
className,
|
||||
variant,
|
||||
...props
|
||||
}: React.ComponentProps<'div'> & VariantProps<typeof alertVariants>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert'
|
||||
role='alert'
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertTitle({ className, ...props }: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-title'
|
||||
className={cn(
|
||||
'col-start-2 line-clamp-1 min-h-4 font-medium tracking-tight',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function AlertDescription({
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'div'>) {
|
||||
return (
|
||||
<div
|
||||
data-slot='alert-description'
|
||||
className={cn(
|
||||
'col-start-2 grid justify-items-start gap-1 text-sm text-muted-foreground [&_p]:leading-relaxed',
|
||||
className,
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export { Alert, AlertDescription, AlertTitle };
|
||||
@@ -251,6 +251,9 @@ export type {
|
||||
DnsAccountItem,
|
||||
PagesProject,
|
||||
PagesDeployment,
|
||||
PagesSource,
|
||||
PagesSourceActionReceipt,
|
||||
PagesSourceStatus,
|
||||
OriginItem,
|
||||
OriginDetail,
|
||||
AccessLogList,
|
||||
|
||||
@@ -109,8 +109,24 @@ export type {
|
||||
OriginMutationPayload,
|
||||
PagesDeployment,
|
||||
PagesDeploymentFile,
|
||||
PagesGitHubLatestSourceUpdatePayload,
|
||||
PagesGitHubReleaseSelector,
|
||||
PagesGitHubReleaseSource,
|
||||
PagesGitHubSourceUpdatePayload,
|
||||
PagesGitHubTagSourceUpdatePayload,
|
||||
PagesManualSource,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
PagesRemoteNetworkPolicy,
|
||||
PagesRemoteSourceUpdatePayload,
|
||||
PagesRemoteURLSource,
|
||||
PagesSource,
|
||||
PagesSourceActionPayload,
|
||||
PagesSourceActionReceipt,
|
||||
PagesSourceRevision,
|
||||
PagesSourceStatus,
|
||||
PagesSourceUpdatePayload,
|
||||
PagesSourceUpdateResult,
|
||||
AcmeAccountItem,
|
||||
DnsAccountItem,
|
||||
DnsAccountMutationPayload,
|
||||
|
||||
@@ -12,6 +12,11 @@ import type {
|
||||
PagesDeploymentUploadPayload,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
PagesSource,
|
||||
PagesSourceActionPayload,
|
||||
PagesSourceActionReceipt,
|
||||
PagesSourceUpdatePayload,
|
||||
PagesSourceUpdateResult,
|
||||
} from './types';
|
||||
|
||||
export class PagesService extends OpenFlareBaseService {
|
||||
@@ -40,6 +45,41 @@ export class PagesService extends OpenFlareBaseService {
|
||||
return this.post<void>(`/${id}/delete`);
|
||||
}
|
||||
|
||||
static getSource(projectId: number): Promise<PagesSource> {
|
||||
return this.get<PagesSource>(`/${projectId}/source`);
|
||||
}
|
||||
|
||||
static updateSource(
|
||||
projectId: number,
|
||||
payload: PagesSourceUpdatePayload,
|
||||
): Promise<PagesSourceUpdateResult> {
|
||||
return this.post<PagesSourceUpdateResult>(
|
||||
`/${projectId}/source/update`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
static deleteSource(projectId: number): Promise<PagesSource> {
|
||||
return this.post<PagesSource>(`/${projectId}/source/delete`);
|
||||
}
|
||||
|
||||
static checkSource(projectId: number): Promise<PagesSourceActionReceipt> {
|
||||
return this.post<PagesSourceActionReceipt>(
|
||||
`/${projectId}/source/check`,
|
||||
{},
|
||||
);
|
||||
}
|
||||
|
||||
static syncSource(
|
||||
projectId: number,
|
||||
payload: PagesSourceActionPayload = {},
|
||||
): Promise<PagesSourceActionReceipt> {
|
||||
return this.post<PagesSourceActionReceipt>(
|
||||
`/${projectId}/source/sync`,
|
||||
payload,
|
||||
);
|
||||
}
|
||||
|
||||
static listDeployments(projectId: number): Promise<PagesDeployment[]> {
|
||||
return this.get<PagesDeployment[]>(`/${projectId}/deployments`);
|
||||
}
|
||||
@@ -58,8 +98,6 @@ export class PagesService extends OpenFlareBaseService {
|
||||
): Promise<PagesDeployment> {
|
||||
const formData = new FormData();
|
||||
formData.append('package', payload.file);
|
||||
formData.append('root_dir', payload.rootDir ?? '');
|
||||
formData.append('entry_file', payload.entryFile ?? 'index.html');
|
||||
|
||||
return this.postFormData<PagesDeployment>(
|
||||
`/${projectId}/deployments/upload`,
|
||||
@@ -75,6 +113,7 @@ export class PagesService extends OpenFlareBaseService {
|
||||
return this.post<PagesDeployment>(
|
||||
`/${projectId}/deployments/upload-from-url`,
|
||||
payload,
|
||||
{ timeout: apiConfig.uploadTimeout } as InternalAxiosRequestConfig,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -394,9 +394,11 @@ export interface PagesDeployment {
|
||||
status: 'uploaded' | 'active';
|
||||
file_count: number;
|
||||
total_size: number;
|
||||
root_dir?: string;
|
||||
entry_file: string;
|
||||
created_by: string;
|
||||
source_type: 'manual_upload' | 'manual_url' | 'remote_url' | 'github_release';
|
||||
source_label: string;
|
||||
trigger_type:
|
||||
'manual_upload' | 'manual_url' | 'manual_sync' | 'scheduled_auto_update';
|
||||
created_at: string;
|
||||
activated_at?: string | null;
|
||||
}
|
||||
@@ -448,8 +450,6 @@ export interface PagesProjectPayload {
|
||||
|
||||
export interface PagesDeploymentUploadPayload {
|
||||
file: File;
|
||||
rootDir?: string;
|
||||
entryFile?: string;
|
||||
onProgress?: (percent: number) => void;
|
||||
}
|
||||
|
||||
@@ -457,6 +457,124 @@ export interface PagesDeploymentUploadFromURLPayload {
|
||||
url: string;
|
||||
}
|
||||
|
||||
export type PagesSourceStatus =
|
||||
'idle' | 'checking' | 'update_available' | 'syncing' | 'failed' | 'attention';
|
||||
|
||||
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
|
||||
|
||||
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
|
||||
|
||||
export interface PagesSourceRevision {
|
||||
revision: string;
|
||||
label: string;
|
||||
asset_name?: string;
|
||||
}
|
||||
|
||||
interface PagesSourceRuntimeView {
|
||||
sync_status?: PagesSourceStatus;
|
||||
update_available?: boolean;
|
||||
last_seen?: PagesSourceRevision;
|
||||
last_applied?: PagesSourceRevision;
|
||||
last_checked_at?: string | null;
|
||||
last_synced_at?: string | null;
|
||||
next_check_at?: string | null;
|
||||
last_error?: string;
|
||||
}
|
||||
|
||||
export interface PagesManualSource {
|
||||
source_type: 'manual';
|
||||
}
|
||||
|
||||
export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
|
||||
source_type: 'remote_url';
|
||||
has_remote_url: boolean;
|
||||
display_url: string;
|
||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||
}
|
||||
|
||||
interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView {
|
||||
source_type: 'github_release';
|
||||
github_repository: string;
|
||||
asset_name: string;
|
||||
}
|
||||
|
||||
interface PagesGitHubLatestReleaseSource extends PagesGitHubReleaseSourceBase {
|
||||
release_selector: 'latest';
|
||||
release_tag?: '';
|
||||
auto_update_enabled: boolean;
|
||||
check_interval_minutes: number;
|
||||
}
|
||||
|
||||
interface PagesGitHubTagReleaseSource extends PagesGitHubReleaseSourceBase {
|
||||
release_selector: 'tag';
|
||||
release_tag: string;
|
||||
auto_update_enabled: false;
|
||||
check_interval_minutes?: 0;
|
||||
}
|
||||
|
||||
export type PagesGitHubReleaseSource =
|
||||
PagesGitHubLatestReleaseSource | PagesGitHubTagReleaseSource;
|
||||
|
||||
/**
|
||||
* 部署源使用判别联合,后续仓库构建来源只需增加独立 git_repository variant,
|
||||
* 不需要向 Remote 或 GitHub Release 填入构建字段。
|
||||
*/
|
||||
export type PagesSource =
|
||||
PagesManualSource | PagesRemoteURLSource | PagesGitHubReleaseSource;
|
||||
|
||||
export interface PagesRemoteSourceUpdatePayload {
|
||||
source_type: 'remote_url';
|
||||
remote_url_set: boolean;
|
||||
remote_url: string;
|
||||
remote_network_policy: PagesRemoteNetworkPolicy;
|
||||
}
|
||||
|
||||
interface PagesGitHubSourceUpdateBase {
|
||||
source_type: 'github_release';
|
||||
repository_url: string;
|
||||
asset_name: string;
|
||||
}
|
||||
|
||||
export interface PagesGitHubLatestSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||
release_selector: 'latest';
|
||||
release_tag: '';
|
||||
auto_update_enabled: boolean;
|
||||
check_interval_minutes: number;
|
||||
}
|
||||
|
||||
export interface PagesGitHubTagSourceUpdatePayload extends PagesGitHubSourceUpdateBase {
|
||||
release_selector: 'tag';
|
||||
release_tag: string;
|
||||
auto_update_enabled: false;
|
||||
check_interval_minutes: 0;
|
||||
}
|
||||
|
||||
export type PagesGitHubSourceUpdatePayload =
|
||||
PagesGitHubLatestSourceUpdatePayload | PagesGitHubTagSourceUpdatePayload;
|
||||
|
||||
/**
|
||||
* Source 更新请求保持 Provider 判别联合;未来仓库拉取构建使用独立 git_repository variant,
|
||||
* 不向 Remote URL 或 GitHub Release payload 混入构建字段。
|
||||
*/
|
||||
export type PagesSourceUpdatePayload =
|
||||
PagesRemoteSourceUpdatePayload | PagesGitHubSourceUpdatePayload;
|
||||
|
||||
export interface PagesSourceActionPayload {
|
||||
confirmed_revision?: string;
|
||||
}
|
||||
|
||||
export interface PagesSourceActionReceipt {
|
||||
task_id: string;
|
||||
execution_id: string;
|
||||
action: 'check' | 'sync';
|
||||
}
|
||||
|
||||
export interface PagesSourceUpdateResult {
|
||||
source: PagesSource;
|
||||
check_task: PagesSourceActionReceipt | null;
|
||||
warning: string;
|
||||
}
|
||||
|
||||
// ==================== Origins ====================
|
||||
|
||||
export interface OriginItem {
|
||||
|
||||
@@ -2,27 +2,34 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import type { AxiosResponse } from 'axios';
|
||||
|
||||
import apiClient from '@/lib/services/core/api-client';
|
||||
import { apiConfig } from '@/lib/services/core/config';
|
||||
import { PagesService } from '@/lib/services/openflare/pages.service';
|
||||
|
||||
vi.mock('@/lib/services/core/api-client', () => ({
|
||||
default: {
|
||||
get: vi.fn(),
|
||||
post: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
function response<T>(data: T) {
|
||||
return {
|
||||
data: { error_msg: '', data },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
} as AxiosResponse;
|
||||
}
|
||||
|
||||
describe('PagesService', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(apiClient.get).mockReset();
|
||||
vi.mocked(apiClient.post).mockReset();
|
||||
});
|
||||
|
||||
it('requests deployment files using the backend deployment route', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue({
|
||||
data: { error_msg: '', data: [] },
|
||||
status: 200,
|
||||
statusText: 'OK',
|
||||
headers: {},
|
||||
config: { headers: {} },
|
||||
} as AxiosResponse);
|
||||
vi.mocked(apiClient.get).mockResolvedValue(response([]));
|
||||
|
||||
await PagesService.listDeploymentFiles(7);
|
||||
|
||||
@@ -31,4 +38,156 @@ describe('PagesService', () => {
|
||||
expect.objectContaining({ params: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('connects all source endpoints with explicit action payloads', async () => {
|
||||
vi.mocked(apiClient.get).mockResolvedValue(
|
||||
response({ source_type: 'manual' }),
|
||||
);
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({ source_type: 'manual' }),
|
||||
);
|
||||
|
||||
await PagesService.getSource(12);
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
remote_url: 'https://example.com/site.zip?token=secret',
|
||||
remote_network_policy: 'public',
|
||||
});
|
||||
await PagesService.deleteSource(12);
|
||||
await PagesService.checkSource(12);
|
||||
await PagesService.syncSource(12);
|
||||
|
||||
expect(apiClient.get).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source',
|
||||
expect.objectContaining({ params: undefined }),
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
expect.objectContaining({
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
}),
|
||||
undefined,
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'/api/v1/d/pages/12/source/delete',
|
||||
undefined,
|
||||
undefined,
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'/api/v1/d/pages/12/source/check',
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
expect(apiClient.post).toHaveBeenNthCalledWith(
|
||||
4,
|
||||
'/api/v1/d/pages/12/source/sync',
|
||||
{},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends the complete GitHub latest discriminator payload', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('sends safe disabled defaults with the GitHub tag discriminator', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(
|
||||
response({
|
||||
source: { source_type: 'manual' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await PagesService.updateSource(12, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/12/source/update',
|
||||
{
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
asset_name: 'site.tar.gz',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
},
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('uploads only the package multipart field', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||
const file = new File(['site'], 'site.zip', {
|
||||
type: 'application/zip',
|
||||
});
|
||||
|
||||
await PagesService.uploadDeployment(8, { file });
|
||||
|
||||
const formData = vi.mocked(apiClient.post).mock.calls[0]?.[1];
|
||||
expect(formData).toBeInstanceOf(FormData);
|
||||
expect(Array.from((formData as FormData).keys())).toEqual(['package']);
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/8/deployments/upload',
|
||||
formData,
|
||||
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps the compatibility URL import on the long upload timeout', async () => {
|
||||
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||
|
||||
await PagesService.uploadDeploymentFromURL(8, {
|
||||
url: 'https://example.com/site.zip',
|
||||
});
|
||||
|
||||
expect(apiClient.post).toHaveBeenCalledWith(
|
||||
'/api/v1/d/pages/8/deployments/upload-from-url',
|
||||
{ url: 'https://example.com/site.zip' },
|
||||
expect.objectContaining({ timeout: apiConfig.uploadTimeout }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,355 @@
|
||||
import { act } from 'react';
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||
import {
|
||||
getLatestSourceIdlePollingDecision,
|
||||
PagesSourceCard,
|
||||
} from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||
import {
|
||||
deploymentsQueryKey,
|
||||
projectQueryKey,
|
||||
projectsQueryKey,
|
||||
sourceQueryKey,
|
||||
} from '@/app/(main)/pages/components/pages-utils';
|
||||
import {
|
||||
type PagesDeployment,
|
||||
type PagesGitHubReleaseSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||
return {
|
||||
...actual,
|
||||
PagesService: {
|
||||
getSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
checkSource: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
listDeployments: vi.fn(),
|
||||
listDeploymentFiles: vi.fn(),
|
||||
activateDeployment: vi.fn(),
|
||||
deleteDeployment: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderWithQuery(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
const rendered = render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
return {
|
||||
...rendered,
|
||||
queryClient,
|
||||
rerenderWithQuery: (nextUI: React.ReactNode) =>
|
||||
rendered.rerender(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
{nextUI}
|
||||
</QueryClientProvider>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const latestSource: PagesGitHubReleaseSource = {
|
||||
source_type: 'github_release',
|
||||
github_repository: 'openflare/site',
|
||||
release_selector: 'latest',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: true,
|
||||
check_interval_minutes: 120,
|
||||
sync_status: 'idle',
|
||||
update_available: false,
|
||||
last_seen: {
|
||||
revision: 'b'.repeat(64),
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
label: 'v1.2.2',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_checked_at: '2026-07-19T10:00:00Z',
|
||||
last_synced_at: '2026-07-19T09:00:00Z',
|
||||
next_check_at: '2026-07-19T12:00:00Z',
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
describe('Pages latest source automatic updates', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(PagesService.getSource).mockReset();
|
||||
vi.mocked(PagesService.updateSource).mockReset();
|
||||
vi.mocked(PagesService.deleteSource).mockReset();
|
||||
vi.mocked(PagesService.checkSource).mockReset();
|
||||
vi.mocked(PagesService.syncSource).mockReset();
|
||||
vi.mocked(PagesService.listDeployments).mockReset();
|
||||
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||
vi.mocked(PagesService.activateDeployment).mockReset();
|
||||
vi.mocked(PagesService.deleteDeployment).mockReset();
|
||||
});
|
||||
|
||||
it('backfills latest settings and hides them after selecting a fixed tag', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
|
||||
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
|
||||
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '自动更新' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('does not show automatic schedule details for a fixed tag source', async () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
...latestSource,
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
next_check_at: null,
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects a latest interval outside 5–1440 minutes', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||
await user.clear(intervalInput);
|
||||
await user.type(intervalInput, '4');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
expect(screen.getByRole('alert')).toHaveTextContent(
|
||||
'检查间隔须为 5–1440 分钟的整数',
|
||||
);
|
||||
expect(intervalInput).toHaveAttribute(
|
||||
'aria-describedby',
|
||||
'pages-github-check-interval-description pages-github-check-interval-error',
|
||||
);
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('keeps an unsaved source draft when idle polling updates runtime fields', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
const assetInput = screen.getByLabelText('Release Asset 文件名');
|
||||
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||
await user.clear(assetInput);
|
||||
await user.type(assetInput, 'draft.zip');
|
||||
await user.clear(intervalInput);
|
||||
await user.type(intervalInput, '30');
|
||||
await user.click(screen.getByRole('switch', { name: '自动更新' }));
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(sourceQueryKey(9), {
|
||||
...latestSource,
|
||||
last_checked_at: '2026-07-19T10:30:00Z',
|
||||
next_check_at: '2026-07-19T12:30:00Z',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(assetInput).toHaveValue('draft.zip');
|
||||
expect(intervalInput).toHaveValue(30);
|
||||
expect(
|
||||
screen.getByRole('switch', { name: '自动更新' }),
|
||||
).not.toBeChecked();
|
||||
});
|
||||
});
|
||||
|
||||
it('remounts project-scoped source state when the route project changes', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockImplementation(async (projectId) => ({
|
||||
...latestSource,
|
||||
github_repository:
|
||||
projectId === 9 ? 'openflare/project-nine' : 'openflare/project-ten',
|
||||
}));
|
||||
const { rerenderWithQuery } = renderWithQuery(
|
||||
<PagesSourceCard key='source-9' projectId={9} />,
|
||||
);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
expect(screen.getByRole('dialog')).toBeVisible();
|
||||
|
||||
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
|
||||
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
expect(await screen.findByText('openflare/project-ten')).toBeVisible();
|
||||
});
|
||||
|
||||
it('uses low-frequency, near-due and bounded overdue polling', () => {
|
||||
const now = Date.parse('2026-07-19T10:00:00Z');
|
||||
const farSource = {
|
||||
...latestSource,
|
||||
next_check_at: new Date(now + 60 * 60 * 1_000).toISOString(),
|
||||
};
|
||||
expect(getLatestSourceIdlePollingDecision(farSource, now, null)).toEqual({
|
||||
interval: 5 * 60 * 1_000,
|
||||
overdueWindow: null,
|
||||
});
|
||||
|
||||
const nearSource = {
|
||||
...latestSource,
|
||||
next_check_at: new Date(now + 60_000).toISOString(),
|
||||
};
|
||||
expect(getLatestSourceIdlePollingDecision(nearSource, now, null)).toEqual({
|
||||
interval: 30_000,
|
||||
overdueWindow: null,
|
||||
});
|
||||
|
||||
const overdueSource = {
|
||||
...latestSource,
|
||||
next_check_at: new Date(now - 1).toISOString(),
|
||||
};
|
||||
const overdue = getLatestSourceIdlePollingDecision(
|
||||
overdueSource,
|
||||
now,
|
||||
null,
|
||||
);
|
||||
expect(overdue.interval).toBe(30_000);
|
||||
expect(
|
||||
getLatestSourceIdlePollingDecision(
|
||||
overdueSource,
|
||||
now + 10 * 60 * 1_000,
|
||||
overdue.overdueWindow,
|
||||
).interval,
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
getLatestSourceIdlePollingDecision(
|
||||
{
|
||||
...latestSource,
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
},
|
||||
now,
|
||||
null,
|
||||
).interval,
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it('refreshes source consumers after a background deployment is applied', async () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
|
||||
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
const updatedSource: PagesGitHubReleaseSource = {
|
||||
...latestSource,
|
||||
last_applied: {
|
||||
revision: 'b'.repeat(64),
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_synced_at: '2026-07-19T10:30:00Z',
|
||||
};
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(updatedSource);
|
||||
|
||||
act(() => {
|
||||
queryClient.setQueryData(sourceQueryKey(9), updatedSource);
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: sourceQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: projectQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: deploymentsQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: ['openflare', 'pages', 'deployment-files', 9],
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: projectsQueryKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('warns that rollback closes auto update and refreshes four query groups', async () => {
|
||||
const user = userEvent.setup();
|
||||
const deployment: PagesDeployment = {
|
||||
id: 51,
|
||||
project_id: 9,
|
||||
deployment_number: 7,
|
||||
checksum: 'd'.repeat(64),
|
||||
status: 'uploaded',
|
||||
file_count: 12,
|
||||
total_size: 4_096,
|
||||
created_by: 'user:1',
|
||||
source_type: 'github_release',
|
||||
source_label: 'v1.2.2',
|
||||
trigger_type: 'scheduled_auto_update',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
activated_at: null,
|
||||
};
|
||||
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||
vi.mocked(PagesService.activateDeployment).mockResolvedValue({} as never);
|
||||
|
||||
const { queryClient } = renderWithQuery(
|
||||
<DeploymentHistory projectId={9} activeDeploymentId={52} />,
|
||||
);
|
||||
await screen.findByText('GitHub · v1.2.2 · 定时更新');
|
||||
const invalidateQueries = vi.spyOn(queryClient, 'invalidateQueries');
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '激活' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(
|
||||
within(dialog).getByText(
|
||||
'激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。',
|
||||
),
|
||||
).toBeVisible();
|
||||
await user.click(within(dialog).getByRole('button', { name: '确认' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.activateDeployment).toHaveBeenCalledWith(9, 51);
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: deploymentsQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: projectQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: sourceQueryKey(9),
|
||||
});
|
||||
expect(invalidateQueries).toHaveBeenCalledWith({
|
||||
queryKey: projectsQueryKey,
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,663 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { render, screen, waitFor, within } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { DeploymentUploadDialog } from '@/app/(main)/pages/components/deployment-upload-dialog';
|
||||
import { DeploymentHistory } from '@/app/(main)/pages/detail/components/deployment-history';
|
||||
import { PagesSourceCard } from '@/app/(main)/pages/detail/components/pages-source-card';
|
||||
import {
|
||||
AdminTaskService,
|
||||
type TaskExecution,
|
||||
type TaskExecutionStatus,
|
||||
} from '@/lib/services/admin';
|
||||
import {
|
||||
type PagesDeployment,
|
||||
type PagesGitHubReleaseSource,
|
||||
type PagesRemoteURLSource,
|
||||
PagesService,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import('@/lib/services/openflare')>();
|
||||
return {
|
||||
...actual,
|
||||
PagesService: {
|
||||
getSource: vi.fn(),
|
||||
updateSource: vi.fn(),
|
||||
deleteSource: vi.fn(),
|
||||
checkSource: vi.fn(),
|
||||
syncSource: vi.fn(),
|
||||
listDeployments: vi.fn(),
|
||||
listDeploymentFiles: vi.fn(),
|
||||
activateDeployment: vi.fn(),
|
||||
deleteDeployment: vi.fn(),
|
||||
uploadDeployment: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('@/lib/services/admin', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/lib/services/admin')>();
|
||||
return {
|
||||
...actual,
|
||||
AdminTaskService: {
|
||||
getTaskExecution: vi.fn(),
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
function renderWithQuery(ui: React.ReactNode) {
|
||||
const queryClient = new QueryClient({
|
||||
defaultOptions: {
|
||||
queries: { retry: false, gcTime: 0 },
|
||||
mutations: { retry: false },
|
||||
},
|
||||
});
|
||||
return render(
|
||||
<QueryClientProvider client={queryClient}>{ui}</QueryClientProvider>,
|
||||
);
|
||||
}
|
||||
|
||||
const remoteSource: PagesRemoteURLSource = {
|
||||
source_type: 'remote_url',
|
||||
has_remote_url: true,
|
||||
display_url: 'https://artifacts.example.com/site.zip?***',
|
||||
remote_network_policy: 'public',
|
||||
sync_status: 'idle',
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
label: 'site.zip',
|
||||
},
|
||||
last_synced_at: '2026-07-19T10:00:00Z',
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubLatestSource: PagesGitHubReleaseSource = {
|
||||
source_type: 'github_release',
|
||||
github_repository: 'openflare/site',
|
||||
release_selector: 'latest',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 60,
|
||||
sync_status: 'update_available',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: 'b'.repeat(64),
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_applied: {
|
||||
revision: 'a'.repeat(64),
|
||||
label: 'v1.2.2',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
last_checked_at: '2026-07-19T10:00:00Z',
|
||||
last_synced_at: '2026-07-18T10:00:00Z',
|
||||
next_check_at: '2026-07-19T11:00:00Z',
|
||||
last_error: '',
|
||||
};
|
||||
|
||||
const githubAttentionRevision = 'c'.repeat(64);
|
||||
const githubAttentionSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'attention',
|
||||
update_available: true,
|
||||
last_seen: {
|
||||
revision: githubAttentionRevision,
|
||||
label: 'v1.2.3',
|
||||
asset_name: 'dist.zip',
|
||||
},
|
||||
};
|
||||
|
||||
function taskExecution(
|
||||
status: TaskExecutionStatus,
|
||||
errorMessage = '',
|
||||
): TaskExecution {
|
||||
return {
|
||||
id: '42',
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
task_type: 'of_pages_source_action',
|
||||
task_name: 'Pages 来源动作',
|
||||
status,
|
||||
retryable: false,
|
||||
max_retry: 0,
|
||||
retry_count: 0,
|
||||
log: '',
|
||||
error_message: errorMessage,
|
||||
result: '',
|
||||
duration: 1,
|
||||
payload: '',
|
||||
triggered_by: 'admin:1',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
updated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
}
|
||||
|
||||
describe('Pages source UI', () => {
|
||||
beforeEach(() => {
|
||||
vi.mocked(PagesService.getSource).mockReset();
|
||||
vi.mocked(PagesService.updateSource).mockReset();
|
||||
vi.mocked(PagesService.deleteSource).mockReset();
|
||||
vi.mocked(PagesService.checkSource).mockReset();
|
||||
vi.mocked(PagesService.syncSource).mockReset();
|
||||
vi.mocked(PagesService.listDeployments).mockReset();
|
||||
vi.mocked(PagesService.listDeploymentFiles).mockReset();
|
||||
vi.mocked(PagesService.activateDeployment).mockReset();
|
||||
vi.mocked(PagesService.deleteDeployment).mockReset();
|
||||
vi.mocked(PagesService.uploadDeployment).mockReset();
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockReset();
|
||||
});
|
||||
|
||||
it('offers the three source types without future repository build controls', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('本地部署包')).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 Remote URL' }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
expect(screen.getByRole('radio', { name: '手动部署' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'Remote URL' })).toBeVisible();
|
||||
expect(screen.getByRole('radio', { name: 'GitHub Release' })).toBeVisible();
|
||||
expect(
|
||||
screen.getByText('仓库源码构建将在后续作为独立来源类型提供。'),
|
||||
).toBeVisible();
|
||||
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
|
||||
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60);
|
||||
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('submits the GitHub latest automatic update settings', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: githubLatestSource,
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
expect(screen.getByLabelText('Release Asset 文件名')).toHaveValue(
|
||||
'dist.zip',
|
||||
);
|
||||
await user.click(screen.getByRole('switch', { name: '自动更新' }));
|
||||
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
|
||||
await user.clear(intervalInput);
|
||||
await user.type(intervalInput, '15');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'latest',
|
||||
release_tag: '',
|
||||
asset_name: 'dist.zip',
|
||||
auto_update_enabled: true,
|
||||
check_interval_minutes: 15,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects non-canonical GitHub repository URL paths', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
const repositoryInput = screen.getByLabelText('GitHub 仓库 URL');
|
||||
await user.type(repositoryInput, 'https://github.com//openflare/site');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
expect(
|
||||
screen.getByText(
|
||||
'请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址',
|
||||
),
|
||||
).toBeVisible();
|
||||
|
||||
await user.clear(repositoryInput);
|
||||
await user.type(repositoryInput, 'https://github.com/openflare/site/');
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('submits the GitHub tag discriminator with safe disabled defaults', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: {
|
||||
...githubLatestSource,
|
||||
release_selector: 'tag',
|
||||
release_tag: 'v1.2.3',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
next_check_at: null,
|
||||
},
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
expect(
|
||||
screen.queryByRole('switch', { name: '自动更新' }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(screen.queryByLabelText('检查间隔(分钟)')).not.toBeInTheDocument();
|
||||
await user.type(
|
||||
screen.getByLabelText('Release tag'),
|
||||
'release/candidate#1&channel=stable',
|
||||
);
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
' site?arch=amd64#stable.zip ',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'github_release',
|
||||
repository_url: 'https://github.com/openflare/site',
|
||||
release_selector: 'tag',
|
||||
release_tag: 'release/candidate#1&channel=stable',
|
||||
asset_name: ' site?arch=amd64#stable.zip ',
|
||||
auto_update_enabled: false,
|
||||
check_interval_minutes: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects unsafe GitHub tag and asset values before saving', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue({
|
||||
source_type: 'manual',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('radio', { name: '固定 Tag' }));
|
||||
await user.type(screen.getByLabelText('Release tag'), 'release//candidate');
|
||||
await user.clear(screen.getByLabelText('Release Asset 文件名'));
|
||||
await user.type(
|
||||
screen.getByLabelText('Release Asset 文件名'),
|
||||
'../dist.zip',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText(
|
||||
'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符',
|
||||
),
|
||||
).toBeVisible();
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('polls the initial check receipt and represents the queued window locally', async () => {
|
||||
const user = userEvent.setup();
|
||||
const idleGitHubSource: PagesGitHubReleaseSource = {
|
||||
...githubLatestSource,
|
||||
sync_status: 'idle',
|
||||
update_available: false,
|
||||
};
|
||||
vi.mocked(PagesService.getSource)
|
||||
.mockResolvedValueOnce({ source_type: 'manual' })
|
||||
.mockResolvedValue(idleGitHubSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: idleGitHubSource,
|
||||
check_task: {
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
},
|
||||
warning: '',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('pending'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(
|
||||
await screen.findByRole('button', { name: '配置 GitHub Release' }),
|
||||
);
|
||||
await user.type(
|
||||
screen.getByLabelText('GitHub 仓库 URL'),
|
||||
'https://github.com/openflare/site',
|
||||
);
|
||||
await user.click(screen.getByRole('button', { name: '保存 GitHub 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(screen.getByText('检查中')).toBeVisible();
|
||||
});
|
||||
const checkButton = screen.getByRole('button', { name: /检查更新/ });
|
||||
expect(checkButton).toBeDisabled();
|
||||
expect(within(checkButton).getByRole('status')).toBeVisible();
|
||||
expect(PagesService.checkSource).not.toHaveBeenCalled();
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('shows GitHub latest automatic update state and schedule', async () => {
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('openflare/site')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.3 · bbbbbbbbbbbb')).toBeVisible();
|
||||
expect(screen.getByText('v1.2.2 · aaaaaaaaaaaa')).toBeVisible();
|
||||
expect(screen.getByText('有可用更新')).toBeVisible();
|
||||
expect(screen.getByText('下次检查时间')).toBeVisible();
|
||||
expect(screen.getByText('自动更新')).toBeVisible();
|
||||
expect(screen.getByText('已关闭')).toBeVisible();
|
||||
expect(screen.getByText('检查间隔')).toBeVisible();
|
||||
expect(screen.getByText('60 分钟')).toBeVisible();
|
||||
});
|
||||
|
||||
it('dispatches a GitHub check and starts TaskExecution polling', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.checkSource).toHaveBeenCalledWith(9);
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
expect(PagesService.syncSource).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders a GitHub check dispatch error', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockRejectedValue(
|
||||
new Error('GitHub API 暂不可用'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('GitHub API 暂不可用')).toBeVisible();
|
||||
});
|
||||
|
||||
it('renders a TaskExecution polling error with an explicit retry', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubLatestSource);
|
||||
vi.mocked(PagesService.checkSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'check',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockRejectedValue(
|
||||
new Error('任务状态暂时不可读取'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '检查更新' }));
|
||||
|
||||
expect(await screen.findByText('任务状态暂时不可读取')).toBeVisible();
|
||||
expect(screen.getByRole('button', { name: '重试' })).toBeVisible();
|
||||
});
|
||||
|
||||
it('requires the exact currently displayed revision for attention sync', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(githubAttentionSource);
|
||||
vi.mocked(PagesService.syncSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution).mockResolvedValue(
|
||||
taskExecution('succeeded'),
|
||||
);
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
const alert = await screen.findByRole('alert');
|
||||
expect(
|
||||
within(alert).getByText('Release Asset 发生变化,需要显式确认'),
|
||||
).toBeVisible();
|
||||
expect(within(alert).getByText(githubAttentionRevision)).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '同步并发布' }));
|
||||
const dialog = screen.getByRole('alertdialog');
|
||||
expect(within(dialog).getByText(githubAttentionRevision)).toBeVisible();
|
||||
await user.click(
|
||||
within(dialog).getByRole('button', { name: '确认并发布' }),
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {
|
||||
confirmed_revision: githubAttentionRevision,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('never reuses the masked URL as an editable value', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: remoteSource,
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
expect(
|
||||
await screen.findByText('https://artifacts.example.com/site.zip?***'),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByText(/token=secret/)).not.toBeInTheDocument();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||
|
||||
const input = screen.getByPlaceholderText(
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(input).toHaveValue('');
|
||||
expect(input).toHaveAttribute('type', 'password');
|
||||
await user.type(input, 'https://new.example.com/site.zip?token=new');
|
||||
expect(
|
||||
screen.queryByText('https://new.example.com/site.zip?token=new'),
|
||||
).not.toBeInTheDocument();
|
||||
await user.click(screen.getByRole('button', { name: '显示 Remote URL' }));
|
||||
expect(input).toHaveAttribute('type', 'url');
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: true,
|
||||
remote_url: 'https://new.example.com/site.zip?token=new',
|
||||
remote_network_policy: 'public',
|
||||
});
|
||||
});
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.queryByRole('dialog')).not.toBeInTheDocument();
|
||||
});
|
||||
await user.click(screen.getByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('button', { name: '更换地址' }));
|
||||
const reopenedInput = screen.getByPlaceholderText(
|
||||
'https://artifacts.example.com/site.zip?token=...',
|
||||
);
|
||||
expect(reopenedInput).toHaveValue('');
|
||||
expect(reopenedInput).toHaveAttribute('type', 'password');
|
||||
});
|
||||
|
||||
it('requires a second confirmation for trusted internal networking', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
|
||||
vi.mocked(PagesService.updateSource).mockResolvedValue({
|
||||
source: { ...remoteSource, remote_network_policy: 'trusted_internal' },
|
||||
check_task: null,
|
||||
warning: '',
|
||||
});
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
|
||||
await user.click(screen.getByRole('radio', { name: '受信内网模式' }));
|
||||
await user.click(screen.getByRole('button', { name: '保存 Remote 来源' }));
|
||||
|
||||
expect(await screen.findByText('启用受信内网模式')).toBeVisible();
|
||||
expect(PagesService.updateSource).not.toHaveBeenCalled();
|
||||
|
||||
await user.click(screen.getByRole('button', { name: '确认' }));
|
||||
await waitFor(() => {
|
||||
expect(PagesService.updateSource).toHaveBeenCalledWith(9, {
|
||||
source_type: 'remote_url',
|
||||
remote_url_set: false,
|
||||
remote_url: '',
|
||||
remote_network_policy: 'trusted_internal',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('polls the existing task execution detail after sync dispatch', async () => {
|
||||
const user = userEvent.setup();
|
||||
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
|
||||
vi.mocked(PagesService.syncSource).mockResolvedValue({
|
||||
task_id: 'manual_of_pages_source_action_1',
|
||||
execution_id: '42',
|
||||
action: 'sync',
|
||||
});
|
||||
vi.mocked(AdminTaskService.getTaskExecution)
|
||||
.mockResolvedValueOnce(taskExecution('pending'))
|
||||
.mockResolvedValue(taskExecution('succeeded'));
|
||||
|
||||
renderWithQuery(<PagesSourceCard projectId={9} />);
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: '同步并发布' }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(PagesService.syncSource).toHaveBeenCalledWith(9, {});
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
|
||||
});
|
||||
expect(screen.getByRole('button', { name: /同步并发布/ })).toBeDisabled();
|
||||
|
||||
await waitFor(
|
||||
() => {
|
||||
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledTimes(2);
|
||||
},
|
||||
{ timeout: 3_500 },
|
||||
);
|
||||
});
|
||||
|
||||
it('shows the actual project entry and no one-off URL upload tab', () => {
|
||||
renderWithQuery(
|
||||
<DeploymentUploadDialog
|
||||
open
|
||||
onOpenChange={vi.fn()}
|
||||
projectId={9}
|
||||
rootDir='dist/site'
|
||||
entryFile='home.html'
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('dist/site/home.html')).toBeVisible();
|
||||
expect(screen.queryByText('从 URL 下载')).not.toBeInTheDocument();
|
||||
expect(screen.queryByText('部署包下载链接')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders a deployment query failure instead of an empty history', async () => {
|
||||
vi.mocked(PagesService.listDeployments).mockRejectedValue(
|
||||
new Error('部署历史暂时不可用'),
|
||||
);
|
||||
|
||||
renderWithQuery(<DeploymentHistory projectId={9} />);
|
||||
|
||||
expect(await screen.findByText('部署历史暂时不可用')).toBeVisible();
|
||||
expect(screen.queryByText('暂无部署')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the immutable GitHub deployment provenance in Chinese', async () => {
|
||||
const deployment: PagesDeployment = {
|
||||
id: 51,
|
||||
project_id: 9,
|
||||
deployment_number: 7,
|
||||
checksum: 'd'.repeat(64),
|
||||
status: 'active',
|
||||
file_count: 12,
|
||||
total_size: 4_096,
|
||||
created_by: 'user:1',
|
||||
source_type: 'github_release',
|
||||
source_label: 'v1.2.3',
|
||||
trigger_type: 'manual_sync',
|
||||
created_at: '2026-07-19T10:00:00Z',
|
||||
activated_at: '2026-07-19T10:00:01Z',
|
||||
};
|
||||
vi.mocked(PagesService.listDeployments).mockResolvedValue([deployment]);
|
||||
|
||||
renderWithQuery(
|
||||
<DeploymentHistory projectId={9} activeDeploymentId={51} />,
|
||||
);
|
||||
|
||||
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
|
||||
expect(screen.getByText('当前生产部署')).toBeVisible();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,100 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
validGitHubAssetName,
|
||||
validGitHubReleaseTag,
|
||||
validGitHubRepositoryURL,
|
||||
} from '@/app/(main)/pages/detail/components/pages-source-validation';
|
||||
|
||||
describe('Pages GitHub source validation', () => {
|
||||
it('accepts only canonical public GitHub repository URLs', () => {
|
||||
expect(validGitHubRepositoryURL('https://github.com/acme/site')).toBe(true);
|
||||
expect(validGitHubRepositoryURL('https://GitHub.com/acme/site.git')).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
const invalid = [
|
||||
'http://github.com/acme/site',
|
||||
'https://github.com//acme/site',
|
||||
'https://github.com/acme/site/',
|
||||
'https://github.com/acme/site/extra',
|
||||
'https://github.com/acme/./site',
|
||||
'https://github.com/acme/../site',
|
||||
'https://github.com/acme/%73ite',
|
||||
'https://github.com/acme/site?token=secret',
|
||||
'https://github.com:443/acme/site',
|
||||
String.raw`https://github.com/acme\site`,
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubRepositoryURL(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('mirrors Git ref rules while preserving legal release tag characters', () => {
|
||||
const valid = [
|
||||
'@',
|
||||
'release/v1#stable&channel=prod',
|
||||
'foo.LOCK',
|
||||
'中文/发布=稳定',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'release v1',
|
||||
'release~v1',
|
||||
'release^v1',
|
||||
'release:v1',
|
||||
'release?v1',
|
||||
'release*v1',
|
||||
'release[v1',
|
||||
String.raw`release\v1`,
|
||||
'release..v1',
|
||||
'release@{v1',
|
||||
'release//v1',
|
||||
'/release',
|
||||
'release/',
|
||||
'release.',
|
||||
'.release',
|
||||
'release/.candidate',
|
||||
'release/v1.lock',
|
||||
'release\nsecret',
|
||||
'release\u2028secret',
|
||||
'release\u202esecret',
|
||||
'a'.repeat(256),
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubReleaseTag(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
|
||||
it('preserves exact legal asset names and rejects path or display controls', () => {
|
||||
const valid = [
|
||||
'dist.zip',
|
||||
'dist?channel=stable&part#1.zip',
|
||||
' dist.zip ',
|
||||
'build=production.zip',
|
||||
];
|
||||
for (const value of valid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(true);
|
||||
}
|
||||
|
||||
const invalid = [
|
||||
'',
|
||||
'.',
|
||||
'..',
|
||||
'../dist.zip',
|
||||
String.raw`dir\dist.zip`,
|
||||
'dist\n.zip',
|
||||
'dist\u2028.zip',
|
||||
'dist\u202e.zip',
|
||||
'a'.repeat(256),
|
||||
'\ud800',
|
||||
];
|
||||
for (const value of invalid) {
|
||||
expect(validGitHubAssetName(value), value).toBe(false);
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user