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:
ryan
2026-07-19 19:36:48 +08:00
127 changed files with 24144 additions and 1933 deletions
@@ -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>
);
}
+302 -6
View File
@@ -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>
);
}
+66
View File
@@ -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 };
+3
View File
@@ -251,6 +251,9 @@ export type {
DnsAccountItem,
PagesProject,
PagesDeployment,
PagesSource,
PagesSourceActionReceipt,
PagesSourceStatus,
OriginItem,
OriginDetail,
AccessLogList,
+16
View File
@@ -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,
);
}
+122 -4
View File
@@ -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 {
+166 -7
View File
@@ -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);
}
});
});