refactor(pages): 精简部署源模型并重构详情页交互

将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段;
Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
This commit is contained in:
ryan
2026-07-19 20:23:31 +08:00
parent f0b51a99b3
commit abe8fb8268
40 changed files with 1050 additions and 1314 deletions
+58 -195
View File
@@ -1,62 +1,37 @@
'use client';
import Link from 'next/link';
import { Suspense, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Suspense } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useRouter, useSearchParams } from 'next/navigation';
import { ArrowLeft, FileText, Pencil, Trash2, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { ArrowLeft, FileText } from 'lucide-react';
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { PagesService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
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';
import { projectQueryKey } from '../components/pages-utils';
import { DeploymentsTab } from './tabs/deployments-tab';
import { SettingsTab } from './tabs/settings-tab';
type PagesDetailTab = 'deployments' | 'settings';
function resolveTab(value: string | null): PagesDetailTab {
if (value === 'settings') return 'settings';
return 'deployments';
}
function PagesDetailPageFallback() {
return (
<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-10 w-64' />
<Skeleton className='h-64 w-full' />
</div>
);
@@ -65,15 +40,12 @@ function PagesDetailPageFallback() {
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 tab = resolveTab(searchParams.get('tab'));
const projectQuery = useQuery({
queryKey: projectQueryKey(projectId),
@@ -81,17 +53,15 @@ function PagesDetailRoute() {
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 : '删除失败');
},
});
const handleTabChange = (value: string) => {
const next = resolveTab(value);
const params = new URLSearchParams();
params.set('id', String(projectId));
if (next === 'settings') {
params.set('tab', 'settings');
}
router.replace(`/pages/detail?${params.toString()}`);
};
if (!validProjectId) {
return (
@@ -141,9 +111,6 @@ function PagesDetailRoute() {
);
}
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'>
@@ -153,159 +120,55 @@ function PagesDetailRoute() {
返回列表
</Link>
</Button>
<div className='flex flex-col gap-4 lg:flex-row lg:items-start lg:justify-between'>
<div className='flex flex-col gap-3 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>
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
{project.enabled ? '已启用' : '已停用'}
</Badge>
</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>
<Tabs
value={tab}
onValueChange={handleTabChange}
className='w-full'
>
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
<TabsTrigger
value='deployments'
className='px-0 pb-2 text-xs font-semibold'
>
部署
</TabsTrigger>
<TabsTrigger
value='settings'
className='px-0 pb-2 text-xs font-semibold'
>
设置
</TabsTrigger>
</TabsList>
<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>
<TabsContent
value='deployments'
className='focus-visible:outline-none'
>
<DeploymentsTab project={project} />
</TabsContent>
<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>
<TabsContent value='settings' className='focus-visible:outline-none'>
<SettingsTab project={project} />
</TabsContent>
</Tabs>
</div>
);
}