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
@@ -2,10 +2,7 @@
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm } from 'react-hook-form';
import { Loader2 } from 'lucide-react';
import { z } from 'zod';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -17,86 +14,15 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Textarea } from '@/components/ui/textarea';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import { projectQueryKey, projectsQueryKey } from './pages-utils';
const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, '请输入项目名称').max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
description: z.string().trim().max(1000).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头',
});
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头',
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头',
});
}
}
});
type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
function toFormValues(project?: PagesProject | null): PagesProjectFormValues {
if (!project) {
return {
name: '',
slug: '',
description: '',
spa_fallback_enabled: false,
spa_fallback_path: '/index.html',
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
};
}
return {
name: project.name,
slug: project.slug,
description: project.description || '',
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '',
root_dir: project.root_dir || '',
entry_file: project.entry_file || 'index.html',
};
}
import {
buildProjectPayload,
ProjectFormFields,
toFormValues,
usePagesProjectForm,
} from './project-form';
interface ProjectEditorDialogProps {
open: boolean;
@@ -110,39 +36,15 @@ export function ProjectEditorDialog({
project,
}: ProjectEditorDialogProps) {
const queryClient = useQueryClient();
const form = useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
});
const form = usePagesProjectForm(project);
useEffect(() => {
if (open) form.reset(toFormValues(project));
}, [form, project, open]);
const mutation = useMutation({
mutationFn: async (values: PagesProjectFormValues) => {
const payload = {
name: values.name.trim(),
slug: values.slug?.trim() || '',
description: values.description?.trim() || '',
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project?.spa_fallback_path || '/index.html',
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled
? values.api_proxy_path.trim()
: '',
api_proxy_pass: values.api_proxy_enabled
? values.api_proxy_pass.trim()
: '',
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file.trim(),
};
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
const payload = buildProjectPayload(values, project);
return project
? PagesService.updateProject(project.id, payload)
: PagesService.createProject(payload);
@@ -162,9 +64,6 @@ export function ProjectEditorDialog({
},
});
const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
@@ -182,113 +81,7 @@ export function ProjectEditorDialog({
className='space-y-4'
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='name'>项目名称</Label>
<Input id='name' {...form.register('name')} />
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor='slug'>项目标识</Label>
<Input
id='slug'
placeholder='留空自动生成'
{...form.register('slug')}
/>
</div>
</div>
<div className='space-y-1.5'>
<Label htmlFor='description'>描述</Label>
<Textarea
id='description'
rows={2}
{...form.register('description')}
/>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='entry_file'>入口文件</Label>
<Input id='entry_file' {...form.register('entry_file')} />
</div>
<div className='space-y-1.5'>
<Label htmlFor='root_dir'>根目录</Label>
<Input
id='root_dir'
placeholder='可选'
{...form.register('root_dir')}
/>
</div>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>SPA fallback</p>
<p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径
</p>
</div>
<Switch
checked={spaEnabled}
onCheckedChange={(checked) =>
form.setValue('spa_fallback_enabled', checked)
}
/>
</div>
{spaEnabled ? (
<div className='space-y-1.5'>
<Label htmlFor='spa_fallback_path'>回退路径</Label>
<Input
id='spa_fallback_path'
{...form.register('spa_fallback_path')}
/>
</div>
) : null}
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>API 反向代理</p>
<p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则
</p>
</div>
<Switch
checked={apiEnabled}
onCheckedChange={(checked) =>
form.setValue('api_proxy_enabled', checked)
}
/>
</div>
{apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor='api_proxy_path'>匹配路径</Label>
<Input
id='api_proxy_path'
{...form.register('api_proxy_path')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor='api_proxy_pass'>后端地址</Label>
<Input
id='api_proxy_pass'
{...form.register('api_proxy_pass')}
/>
</div>
<div className='space-y-1.5 md:col-span-2'>
<Label htmlFor='api_proxy_rewrite'>重写规则</Label>
<Input
id='api_proxy_rewrite'
{...form.register('api_proxy_rewrite')}
/>
</div>
</div>
) : null}
<ProjectFormFields form={form} idPrefix='dialog' />
</form>
<DialogFooter>
@@ -0,0 +1,236 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useForm, type UseFormReturn } from 'react-hook-form';
import { z } from 'zod';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { type PagesProject } from '@/lib/services/openflare';
export const pagesProjectSchema = z
.object({
name: z.string().trim().min(1, '请输入项目名称').max(255),
slug: z.string().trim().max(255).optional().or(z.literal('')),
spa_fallback_enabled: z.boolean(),
spa_fallback_path: z.string().trim(),
api_proxy_enabled: z.boolean(),
api_proxy_path: z.string().trim(),
api_proxy_pass: z.string().trim(),
api_proxy_rewrite: z.string().trim(),
root_dir: z.string().trim().max(512).optional().or(z.literal('')),
entry_file: z.string().trim().min(1, '请输入入口文件').max(512),
})
.superRefine((data, ctx) => {
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['spa_fallback_path'],
message: '回退路径必须以 / 开头',
});
}
if (data.api_proxy_enabled) {
if (!data.api_proxy_path.startsWith('/')) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_path'],
message: '匹配路径必须以 / 开头',
});
}
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
path: ['api_proxy_pass'],
message: '后端地址必须以 http:// 或 https:// 开头',
});
}
}
});
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
export function toFormValues(
project?: PagesProject | null,
): PagesProjectFormValues {
if (!project) {
return {
name: '',
slug: '',
spa_fallback_enabled: false,
spa_fallback_path: '/index.html',
api_proxy_enabled: false,
api_proxy_path: '',
api_proxy_pass: '',
api_proxy_rewrite: '',
root_dir: '',
entry_file: 'index.html',
};
}
return {
name: project.name,
slug: project.slug,
spa_fallback_enabled: project.spa_fallback_enabled,
spa_fallback_path: project.spa_fallback_path,
api_proxy_enabled: project.api_proxy_enabled || false,
api_proxy_path: project.api_proxy_path || '',
api_proxy_pass: project.api_proxy_pass || '',
api_proxy_rewrite: project.api_proxy_rewrite || '',
root_dir: project.root_dir || '',
entry_file: project.entry_file || 'index.html',
};
}
export function buildProjectPayload(
values: PagesProjectFormValues,
project?: PagesProject | null,
) {
return {
name: values.name.trim(),
slug: values.slug?.trim() || '',
description: project?.description || '',
enabled: project ? project.enabled : true,
spa_fallback_enabled: values.spa_fallback_enabled,
spa_fallback_path: values.spa_fallback_enabled
? values.spa_fallback_path.trim()
: project?.spa_fallback_path || '/index.html',
api_proxy_enabled: values.api_proxy_enabled,
api_proxy_path: values.api_proxy_enabled
? values.api_proxy_path.trim()
: '',
api_proxy_pass: values.api_proxy_enabled
? values.api_proxy_pass.trim()
: '',
api_proxy_rewrite: values.api_proxy_enabled
? values.api_proxy_rewrite.trim()
: '',
root_dir: values.root_dir?.trim() || '',
entry_file: values.entry_file.trim(),
};
}
export function usePagesProjectForm(project?: PagesProject | null) {
return useForm<PagesProjectFormValues>({
resolver: zodResolver(pagesProjectSchema),
defaultValues: toFormValues(project),
});
}
interface ProjectFormFieldsProps {
form: UseFormReturn<PagesProjectFormValues>;
idPrefix?: string;
}
export function ProjectFormFields({
form,
idPrefix = '',
}: ProjectFormFieldsProps) {
const spaEnabled = form.watch('spa_fallback_enabled');
const apiEnabled = form.watch('api_proxy_enabled');
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
return (
<div className='space-y-4'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('name')}>项目名称</Label>
<Input id={fieldId('name')} {...form.register('name')} />
{form.formState.errors.name ? (
<p className='text-xs text-destructive'>
{form.formState.errors.name.message}
</p>
) : null}
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('slug')}>项目标识</Label>
<Input
id={fieldId('slug')}
placeholder='留空自动生成'
{...form.register('slug')}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
<Input
id={fieldId('root_dir')}
placeholder='可选'
{...form.register('root_dir')}
/>
</div>
</div>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>SPA fallback</p>
<p className='text-xs text-muted-foreground'>
未命中静态文件时回退到指定路径
</p>
</div>
<Switch
checked={spaEnabled}
onCheckedChange={(checked) =>
form.setValue('spa_fallback_enabled', checked, {
shouldDirty: true,
})
}
/>
</div>
{spaEnabled ? (
<div className='space-y-1.5'>
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
<Input
id={fieldId('spa_fallback_path')}
{...form.register('spa_fallback_path')}
/>
</div>
) : null}
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div>
<p className='text-sm font-medium'>API 反向代理</p>
<p className='text-xs text-muted-foreground'>
为静态站点附加 API 反代规则
</p>
</div>
<Switch
checked={apiEnabled}
onCheckedChange={(checked) =>
form.setValue('api_proxy_enabled', checked, { shouldDirty: true })
}
/>
</div>
{apiEnabled ? (
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
<Input
id={fieldId('api_proxy_path')}
{...form.register('api_proxy_path')}
/>
</div>
<div className='space-y-1.5'>
<Label htmlFor={fieldId('api_proxy_pass')}>后端地址</Label>
<Input
id={fieldId('api_proxy_pass')}
{...form.register('api_proxy_pass')}
/>
</div>
<div className='space-y-1.5 md:col-span-2'>
<Label htmlFor={fieldId('api_proxy_rewrite')}>重写规则</Label>
<Input
id={fieldId('api_proxy_rewrite')}
{...form.register('api_proxy_rewrite')}
/>
</div>
</div>
) : null}
</div>
);
}
@@ -0,0 +1,107 @@
'use client';
import { useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useRouter } from 'next/navigation';
import { Trash2 } 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 {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { Spinner } from '@/components/ui/spinner';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import { projectsQueryKey } from '../../components/pages-utils';
interface DangerZoneCardProps {
project: PagesProject;
}
export function DangerZoneCard({ project }: DangerZoneCardProps) {
const router = useRouter();
const queryClient = useQueryClient();
const [deleteOpen, setDeleteOpen] = useState(false);
const deleteProjectMutation = useMutation({
mutationFn: () => PagesService.deleteProject(project.id),
onSuccess: async () => {
toast.success('项目已删除');
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
router.push('/pages');
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '删除失败');
},
});
return (
<>
<Card className='border-dashed border-destructive/30 bg-destructive/5 shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base text-destructive'>危险区域</CardTitle>
</CardHeader>
<CardContent className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='space-y-1'>
<p className='text-sm font-medium'>删除项目</p>
<p className='text-xs text-muted-foreground'>
将永久删除 {project.name}({project.slug})及其全部部署。
</p>
</div>
<Button
type='button'
size='sm'
variant='destructive'
onClick={() => setDeleteOpen(true)}
>
<Trash2 data-icon='inline-start' />
删除项目
</Button>
</CardContent>
</Card>
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
<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>
</>
);
}
@@ -2,7 +2,7 @@
import { useMemo, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { ChevronDown, ChevronRight } from 'lucide-react';
import { ChevronDown, ChevronRight, Upload } from 'lucide-react';
import { toast } from 'sonner';
import { EmptyStateWithBorder } from '@/components/layout/empty';
@@ -22,6 +22,7 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardHeader,
@@ -31,6 +32,7 @@ import { Spinner } from '@/components/ui/spinner';
import { type PagesDeployment, PagesService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { DeploymentUploadDialog } from '../../components/deployment-upload-dialog';
import {
deploymentFilesQueryKey,
deploymentsQueryKey,
@@ -58,6 +60,8 @@ const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
interface DeploymentHistoryProps {
projectId: number;
activeDeploymentId?: number | null;
rootDir?: string;
entryFile?: string;
}
type PendingAction = {
@@ -78,8 +82,11 @@ function deploymentSnapshot(deployment: PagesDeployment) {
export function DeploymentHistory({
projectId,
activeDeploymentId,
rootDir = '',
entryFile = 'index.html',
}: DeploymentHistoryProps) {
const queryClient = useQueryClient();
const [uploadOpen, setUploadOpen] = useState(false);
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
number | null
>(null);
@@ -148,14 +155,25 @@ export function DeploymentHistory({
return (
<>
<Card>
<CardHeader>
<CardTitle>部署历史</CardTitle>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>部署历史</CardTitle>
<CardDescription>
部署记录不可变,来源信息是创建部署时的安全快照。
</CardDescription>
<CardAction>
<Button
type='button'
size='sm'
className='whitespace-nowrap'
onClick={() => setUploadOpen(true)}
>
<Upload data-icon='inline-start' />
手动上传
</Button>
</CardAction>
</CardHeader>
<CardContent className='flex flex-col gap-3'>
<CardContent className='space-y-3'>
{deploymentsQuery.isLoading ? (
<LoadingStateWithBorder description='加载部署历史...' />
) : deploymentsQuery.isError ? (
@@ -182,7 +200,10 @@ export function DeploymentHistory({
const expanded = expandedDeploymentId === deployment.id;
return (
<div key={deployment.id} className='rounded-lg border'>
<div
key={deployment.id}
className='rounded-lg border border-dashed'
>
<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
@@ -204,7 +225,7 @@ export function DeploymentHistory({
部署 #{deployment.deployment_number}
</span>
<Badge variant={active ? 'default' : 'outline'}>
{active ? '当前生产部署' : '历史部署'}
{active ? 'Production' : '历史部署'}
</Badge>
<Badge variant='secondary'>
{deploymentSnapshot(deployment)}
@@ -295,6 +316,14 @@ export function DeploymentHistory({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<DeploymentUploadDialog
open={uploadOpen}
onOpenChange={setUploadOpen}
projectId={projectId}
rootDir={rootDir}
entryFile={entryFile}
/>
</>
);
}
@@ -2,14 +2,7 @@
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 { Download, Pencil, RefreshCw, Search } from 'lucide-react';
import { toast } from 'sonner';
import { ErrorInline } from '@/components/layout/error';
@@ -27,7 +20,6 @@ import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Card,
CardAction,
CardContent,
CardDescription,
CardFooter,
@@ -348,12 +340,12 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
if (sourceQuery.isLoading) {
return (
<Card>
<CardHeader>
<CardTitle>部署源</CardTitle>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>加载来源配置...</CardDescription>
</CardHeader>
<CardContent className='flex flex-col gap-3'>
<CardContent className='space-y-3'>
<Skeleton className='h-10 w-full' />
<Skeleton className='h-20 w-full' />
</CardContent>
@@ -363,9 +355,9 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
if (sourceQuery.isError || !source) {
return (
<Card>
<CardHeader>
<CardTitle>部署源</CardTitle>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>来源配置与部署历史相互独立。</CardDescription>
</CardHeader>
<CardContent>
@@ -401,24 +393,26 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
return (
<>
<Card>
<CardHeader>
<CardTitle>部署源</CardTitle>
<CardDescription>
来源配置负责发现内容,发布结果记录在独立的部署历史中。
</CardDescription>
<CardAction>
<Card className='border-dashed shadow-none'>
<CardHeader className='pb-3'>
<div className='flex items-start justify-between gap-3'>
<div>
<CardTitle className='text-base'>部署源</CardTitle>
<CardDescription>
配置远端来源并同步发布;发布结果见部署历史。
</CardDescription>
</div>
{status ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<Badge variant='outline'>手动部署</Badge>
)}
</CardAction>
</div>
</CardHeader>
<CardContent className='flex flex-col gap-4'>
<CardContent className='space-y-4'>
{source.source_type === 'manual' ? (
<div className='rounded-lg border bg-muted/20 p-4'>
<div className='rounded-lg border border-dashed bg-muted/20 p-4'>
<p className='text-sm font-medium'>本地部署包</p>
<p className='mt-1 text-sm text-muted-foreground'>
当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。
@@ -450,7 +444,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
/>
) : null}
{actionTimedOut ? (
<div className='flex flex-col gap-2 rounded-lg border p-3 sm:flex-row sm:items-center sm:justify-between'>
<div className='flex flex-col gap-2 rounded-lg border border-dashed p-3 sm:flex-row sm:items-center sm:justify-between'>
<span className='text-xs text-muted-foreground'>
自动等待已停止,任务可能仍在后台运行。
</span>
@@ -473,39 +467,23 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
) : null}
</CardContent>
<CardFooter className='flex flex-wrap gap-2 border-t'>
{source.source_type === 'manual' ? (
<CardFooter className='flex flex-wrap gap-2 border-t border-dashed'>
<Button
type='button'
size='sm'
variant='outline'
disabled={source.source_type !== 'manual' && actionsDisabled}
onClick={() => openSourceDialog(source.source_type)}
>
<Pencil data-icon='inline-start' />
配置
</Button>
{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'
size='sm'
variant='outline'
disabled={actionsDisabled}
onClick={() => {
@@ -523,6 +501,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
) : null}
<Button
type='button'
size='sm'
disabled={
actionsDisabled ||
(source.sync_status === 'attention' && !attentionRevision)
@@ -536,17 +515,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
)}
同步并发布
</Button>
<Button
type='button'
variant='ghost'
disabled={actionsDisabled}
onClick={() => openSourceDialog('manual')}
>
<RotateCcw data-icon='inline-start' />
切换回手动
</Button>
</>
)}
) : null}
<Button
type='button'
variant='ghost'
@@ -1,17 +1,49 @@
import { TriangleAlert } from 'lucide-react';
import type { ReactNode } from 'react';
import { ErrorInline } from '@/components/layout/error';
import { Alert, AlertDescription, AlertTitle } from '@/components/ui/alert';
import { Badge } from '@/components/ui/badge';
import {
type PagesGitHubReleaseSource,
type PagesRemoteURLSource,
type PagesSourceRevision,
} from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { cn, formatDateTime } from '@/lib/utils';
function revisionSummary(revision?: PagesSourceRevision) {
if (!revision) return '尚无记录';
return `${revision.label} · ${revision.revision.slice(0, 12)}`;
const label = revision.label?.trim();
const short = revision.revision.slice(0, 12);
return label ? `${label} · ${short}` : short;
}
function formatOptionalTime(value?: string | null, empty = '—') {
return value ? formatDateTime(value) : empty;
}
function SourceMetaRow({
label,
children,
mono,
}: {
label: string;
children: ReactNode;
mono?: boolean;
}) {
return (
<div className='grid grid-cols-[4.75rem_minmax(0,1fr)] items-baseline gap-x-3 py-0.5 sm:grid-cols-[5.5rem_minmax(0,1fr)]'>
<dt className='text-xs text-muted-foreground'>{label}</dt>
<dd
className={cn(
'min-w-0 break-all text-sm leading-relaxed',
mono && 'font-mono text-[13px]',
)}
>
{children}
</dd>
</div>
);
}
export function RemoteSourceDetails({
@@ -20,38 +52,27 @@ export function RemoteSourceDetails({
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 className='space-y-4'>
<div className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
<dl className='space-y-3.5'>
<SourceMetaRow label='地址' mono>
{source.remote_url || '—'}
</SourceMetaRow>
<div className='grid gap-3.5 sm:grid-cols-2'>
<SourceMetaRow label='TLS'>
{source.allow_insecure ? '允许不安全连接' : '校验证书'}
</SourceMetaRow>
<SourceMetaRow label='最近同步'>
{formatOptionalTime(source.last_synced_at, '尚未同步')}
</SourceMetaRow>
</div>
<SourceMetaRow label='已应用' mono>
{revisionSummary(source.last_applied)}
</SourceMetaRow>
</dl>
</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}
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
</div>
);
}
@@ -63,9 +84,13 @@ export function GitHubSourceDetails({
}) {
const attentionRevision =
source.sync_status === 'attention' ? source.last_seen : undefined;
const releaseLabel =
source.release_selector === 'latest'
? '最新 Release'
: `固定 Tag · ${source.release_tag || '未提供'}`;
return (
<div className='flex flex-col gap-4'>
<div className='space-y-4'>
{attentionRevision ? (
<Alert variant='destructive'>
<TriangleAlert />
@@ -81,83 +106,51 @@ export function GitHubSourceDetails({
</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 className='rounded-lg border border-dashed bg-muted/15 px-5 py-5'>
<div className='mb-4 flex flex-wrap items-center gap-2 border-b border-dashed pb-4'>
<code className='text-sm font-medium'>{source.github_repository}</code>
<Badge variant='secondary' className='font-normal'>
{source.asset_name}
</Badge>
<Badge variant='outline' className='font-normal'>
{releaseLabel}
</Badge>
</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'>
<dl className='grid gap-x-8 gap-y-3.5 sm:grid-cols-2'>
{source.release_selector === 'latest' ? (
<>
<SourceMetaRow label='自动更新'>
{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'>
</SourceMetaRow>
<SourceMetaRow label='检查间隔'>
{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'>
</SourceMetaRow>
<SourceMetaRow label='下次检查'>
{formatOptionalTime(source.next_check_at, '等待调度')}
</SourceMetaRow>
<SourceMetaRow label='最近检查'>
{formatOptionalTime(source.last_checked_at, '尚未检查')}
</SourceMetaRow>
</>
) : (
<SourceMetaRow label='最近检查'>
{formatOptionalTime(source.last_checked_at, '尚未检查')}
</SourceMetaRow>
)}
<SourceMetaRow label='远端' mono>
{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'>
</SourceMetaRow>
<SourceMetaRow label='已应用' mono>
{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}
</SourceMetaRow>
<SourceMetaRow label='最近同步'>
{formatOptionalTime(source.last_synced_at, '尚未同步')}
</SourceMetaRow>
</dl>
</div>
{source.last_error ? <ErrorInline message={source.last_error} /> : null}
</div>
);
}
@@ -2,7 +2,6 @@
import { useEffect, useRef, useState } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Eye, EyeOff } from 'lucide-react';
import { toast } from 'sonner';
import {
@@ -33,9 +32,9 @@ import {
} from '@/components/ui/field';
import { Input } from '@/components/ui/input';
import { Spinner } from '@/components/ui/spinner';
import { Switch } from '@/components/ui/switch';
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
import {
type PagesRemoteNetworkPolicy,
type PagesSource,
type PagesSourceActionReceipt,
type PagesSourceUpdatePayload,
@@ -61,7 +60,7 @@ import {
export type PagesSourceMode = 'manual' | 'remote_url' | 'github_release';
type Confirmation = 'trusted_internal' | 'manual' | null;
type Confirmation = 'manual' | null;
interface PagesSourceDialogProps {
open: boolean;
@@ -96,11 +95,8 @@ export function PagesSourceDialog({
}: PagesSourceDialogProps) {
const queryClient = useQueryClient();
const [mode, setMode] = useState<PagesSourceMode>('manual');
const [networkPolicy, setNetworkPolicy] =
useState<PagesRemoteNetworkPolicy>('public');
const [replaceURL, setReplaceURL] = useState(false);
const [allowInsecure, setAllowInsecure] = useState(false);
const [remoteURL, setRemoteURL] = useState('');
const [revealRemoteURL, setRevealRemoteURL] = useState(false);
const [urlError, setURLError] = useState('');
const [githubForm, setGitHubForm] = useState<PagesGitHubSourceFormValue>({
repositoryURL: '',
@@ -118,8 +114,6 @@ export function PagesSourceDialog({
useEffect(() => {
if (!open) {
initializedForOpen.current = false;
setRemoteURL('');
setRevealRemoteURL(false);
return;
}
// Runtime polling may replace the source view while the dialog is open.
@@ -128,14 +122,12 @@ export function PagesSourceDialog({
initializedForOpen.current = true;
const nextMode = initialMode ?? source.source_type;
setMode(nextMode);
setNetworkPolicy(
source.source_type === 'remote_url'
? source.remote_network_policy
: 'public',
setAllowInsecure(
source.source_type === 'remote_url' && Boolean(source.allow_insecure),
);
setRemoteURL(
source.source_type === 'remote_url' ? (source.remote_url ?? '') : '',
);
setReplaceURL(source.source_type !== 'remote_url');
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
setGitHubForm({
repositoryURL:
@@ -192,8 +184,6 @@ export function PagesSourceDialog({
toast.success('部署源已更新');
if (result.warning) toast.warning(result.warning);
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -208,8 +198,6 @@ export function PagesSourceDialog({
await invalidateSourceState();
toast.success('已切换回手动部署');
setConfirmation(null);
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
},
onError: (error) => {
@@ -221,31 +209,24 @@ export function PagesSourceDialog({
const remotePayload = (): PagesSourceUpdatePayload => ({
source_type: 'remote_url',
remote_url_set: replaceURL,
remote_url: replaceURL ? remoteURL.trim() : '',
remote_network_policy: networkPolicy,
remote_url: remoteURL.trim(),
allow_insecure: allowInsecure,
});
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');
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('');
updateMutation.mutate(remotePayload());
};
@@ -347,7 +328,7 @@ export function PagesSourceDialog({
<DialogHeader>
<DialogTitle>部署源设置</DialogTitle>
<DialogDescription>
手动部署、Remote URL 与 GitHub Release 使用独立配置。
选择部署来源。
</DialogDescription>
</DialogHeader>
@@ -368,9 +349,9 @@ export function PagesSourceDialog({
) {
setMode(value);
if (value !== 'remote_url') {
setRemoteURL('');
setRevealRemoteURL(false);
setURLError('');
} else if (source.source_type === 'remote_url') {
setRemoteURL(source.remote_url ?? '');
}
}
}}
@@ -385,14 +366,6 @@ export function PagesSourceDialog({
GitHub Release
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
<span>
远端来源只负责发现内容,发布结果始终保留为不可变部署。
</span>
<span className='block'>
仓库源码构建将在后续作为独立来源类型提供。
</span>
</FieldDescription>
</Field>
{mode === 'manual' ? (
@@ -406,115 +379,37 @@ export function PagesSourceDialog({
<>
<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>
)}
<Input
id='pages-remote-url'
type='url'
placeholder='https://artifacts.example.com/site.zip'
value={remoteURL}
aria-invalid={Boolean(urlError)}
autoComplete='off'
onChange={(event) => {
setRemoteURL(event.target.value);
setURLError('');
}}
/>
<FieldDescription>
{urlError ||
(replaceURL
? '保存后不会回显原始地址或 query token。'
: '界面只显示脱敏地址;留空表示保留当前地址。')}
{urlError || '填写可直接下载的部署包 HTTP(S) 地址。'}
</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>
<div className='flex items-center justify-between rounded-lg border border-dashed px-4 py-3'>
<div className='space-y-1 pr-4'>
<p className='text-sm font-medium'>允许不安全的连接</p>
<p className='text-xs text-muted-foreground'>
默认允许公网与内网地址;开启后跳过 TLS
证书校验,适用于自签名或私有 CA。
</p>
</div>
<Switch
checked={allowInsecure}
onCheckedChange={setAllowInsecure}
aria-label='允许不安全的连接'
/>
</div>
</>
) : (
<PagesSourceGitHubFields
@@ -532,11 +427,7 @@ export function PagesSourceDialog({
type='button'
variant='outline'
disabled={isPending}
onClick={() => {
setRemoteURL('');
setRevealRemoteURL(false);
onOpenChange(false);
}}
onClick={() => onOpenChange(false)}
>
取消
</Button>
@@ -556,15 +447,9 @@ export function PagesSourceDialog({
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>
{confirmation === 'manual'
? '切换回手动部署'
: '启用受信内网模式'}
</AlertDialogTitle>
<AlertDialogTitle>切换回手动部署</AlertDialogTitle>
<AlertDialogDescription>
{confirmation === 'manual'
? '当前来源配置将被删除,但已有部署与当前生产版本会保留。'
: '该模式允许访问私有网络并接受自签名证书。请确认此地址属于可信内部来源,且不会被非可信用户控制。'}
当前来源配置将被删除,但已有部署与当前生产版本会保留。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
@@ -575,8 +460,6 @@ export function PagesSourceDialog({
event.preventDefault();
if (confirmation === 'manual') {
deleteMutation.mutate();
} else if (confirmation === 'trusted_internal') {
updateMutation.mutate(remotePayload());
}
}}
>
@@ -100,11 +100,6 @@ export function PagesSourceGitHubFields({
固定 Tag
</ToggleGroupItem>
</ToggleGroup>
<FieldDescription>
{value.releaseSelector === 'latest'
? '定时检查最新 Release;是否自动发布由下方开关控制。'
: '固定 Tag 仅支持管理员手动检查与同步。'}
</FieldDescription>
</Field>
{value.releaseSelector === 'tag' ? (
@@ -199,9 +194,6 @@ export function PagesSourceGitHubFields({
onErrorsChange({ ...errors, assetName: '' });
}}
/>
<FieldDescription id='pages-github-asset-description'>
按文件名精确匹配已上传的 Release Asset。
</FieldDescription>
<FieldError id='pages-github-asset-error'>
{errors.assetName}
</FieldError>
@@ -0,0 +1,110 @@
'use client';
import { useEffect } from 'react';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from '@/components/ui/card';
import { type PagesProject, PagesService } from '@/lib/services/openflare';
import {
projectQueryKey,
projectsQueryKey,
sourceQueryKey,
} from '../../components/pages-utils';
import {
buildProjectPayload,
ProjectFormFields,
toFormValues,
usePagesProjectForm,
} from '../../components/project-form';
interface ProjectSettingsCardProps {
project: PagesProject;
}
export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
const queryClient = useQueryClient();
const form = usePagesProjectForm(project);
useEffect(() => {
form.reset(toFormValues(project));
}, [form, project]);
const mutation = useMutation({
mutationFn: async (values: Parameters<typeof buildProjectPayload>[0]) => {
const payload = buildProjectPayload(values, project);
return PagesService.updateProject(project.id, payload);
},
onSuccess: async () => {
toast.success('项目已更新');
await Promise.all([
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
queryClient.invalidateQueries({
queryKey: projectQueryKey(project.id),
}),
queryClient.invalidateQueries({
queryKey: sourceQueryKey(project.id),
}),
]);
},
onError: (error) => {
toast.error(error instanceof Error ? error.message : '保存失败');
},
});
return (
<Card className='border-dashed shadow-none'>
<CardHeader className='flex flex-row items-center justify-between gap-4'>
<div>
<CardTitle className='text-base'>编辑 Pages 项目</CardTitle>
<CardDescription>
配置静态站点托管参数,保存后会同步到项目详情与代理引用。
</CardDescription>
</div>
<div className='flex shrink-0 flex-wrap gap-2'>
<Button
type='button'
size='sm'
variant='outline'
disabled={!form.formState.isDirty || mutation.isPending}
onClick={() => form.reset(toFormValues(project))}
>
重置
</Button>
<Button
type='submit'
size='sm'
form='pages-project-settings-form'
disabled={!form.formState.isDirty || mutation.isPending}
>
{mutation.isPending ? (
<>
<Loader2 className='mr-1 size-4 animate-spin' />
保存中...
</>
) : (
'保存修改'
)}
</Button>
</div>
</CardHeader>
<CardContent className='space-y-4'>
<form
id='pages-project-settings-form'
onSubmit={form.handleSubmit((values) => mutation.mutate(values))}
>
<ProjectFormFields form={form} idPrefix='settings' />
</form>
</CardContent>
</Card>
);
}
+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>
);
}
@@ -0,0 +1,21 @@
'use client';
import { type PagesProject } from '@/lib/services/openflare';
import { DeploymentHistory } from '../components/deployment-history';
interface DeploymentsTabProps {
project: PagesProject;
}
export function DeploymentsTab({ project }: DeploymentsTabProps) {
return (
<DeploymentHistory
key={`deployments-${project.id}`}
projectId={project.id}
activeDeploymentId={project.active_deployment_id}
rootDir={project.root_dir ?? ''}
entryFile={project.entry_file}
/>
);
}
@@ -0,0 +1,21 @@
'use client';
import { type PagesProject } from '@/lib/services/openflare';
import { DangerZoneCard } from '../components/danger-zone-card';
import { PagesSourceCard } from '../components/pages-source-card';
import { ProjectSettingsCard } from '../components/project-settings-card';
interface SettingsTabProps {
project: PagesProject;
}
export function SettingsTab({ project }: SettingsTabProps) {
return (
<div className='flex flex-col gap-6'>
<ProjectSettingsCard project={project} />
<PagesSourceCard key={`source-${project.id}`} projectId={project.id} />
<DangerZoneCard project={project} />
</div>
);
}
-1
View File
@@ -117,7 +117,6 @@ export type {
PagesManualSource,
PagesProject,
PagesProjectPayload,
PagesRemoteNetworkPolicy,
PagesRemoteSourceUpdatePayload,
PagesRemoteURLSource,
PagesSource,
+3 -7
View File
@@ -460,8 +460,6 @@ export interface PagesDeploymentUploadFromURLPayload {
export type PagesSourceStatus =
'idle' | 'checking' | 'update_available' | 'syncing' | 'failed' | 'attention';
export type PagesRemoteNetworkPolicy = 'public' | 'trusted_internal';
export type PagesGitHubReleaseSelector = 'latest' | 'tag';
export interface PagesSourceRevision {
@@ -487,9 +485,8 @@ export interface PagesManualSource {
export interface PagesRemoteURLSource extends PagesSourceRuntimeView {
source_type: 'remote_url';
has_remote_url: boolean;
display_url: string;
remote_network_policy: PagesRemoteNetworkPolicy;
remote_url: string;
allow_insecure?: boolean;
}
interface PagesGitHubReleaseSourceBase extends PagesSourceRuntimeView {
@@ -524,9 +521,8 @@ export type PagesSource =
export interface PagesRemoteSourceUpdatePayload {
source_type: 'remote_url';
remote_url_set: boolean;
remote_url: string;
remote_network_policy: PagesRemoteNetworkPolicy;
allow_insecure?: boolean;
}
interface PagesGitHubSourceUpdateBase {
@@ -50,9 +50,8 @@ describe('PagesService', () => {
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',
remote_url: 'https://example.com/site.zip?token=secret',
allow_insecure: false,
});
await PagesService.deleteSource(12);
await PagesService.checkSource(12);
@@ -67,8 +66,7 @@ describe('PagesService', () => {
'/api/v1/d/pages/12/source/update',
expect.objectContaining({
source_type: 'remote_url',
remote_url_set: true,
}),
}),
undefined,
);
expect(apiClient.post).toHaveBeenNthCalledWith(
@@ -106,7 +106,7 @@ describe('Pages latest source automatic updates', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('switch', { name: '自动更新' })).toBeChecked();
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(120);
@@ -131,7 +131,7 @@ describe('Pages latest source automatic updates', () => {
expect(await screen.findByText('固定 Tag · v1.2.3')).toBeVisible();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
expect(screen.queryByText('检查间隔')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查')).not.toBeInTheDocument();
expect(screen.queryByText('下次检查时间')).not.toBeInTheDocument();
});
@@ -141,7 +141,7 @@ describe('Pages latest source automatic updates', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
await user.click(await screen.findByRole('button', { name: '配置' }));
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(intervalInput);
await user.type(intervalInput, '4');
@@ -162,7 +162,7 @@ describe('Pages latest source automatic updates', () => {
vi.mocked(PagesService.getSource).mockResolvedValue(latestSource);
const { queryClient } = renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
await user.click(await screen.findByRole('button', { name: '配置' }));
const assetInput = screen.getByLabelText('Release Asset 文件名');
const intervalInput = screen.getByLabelText('检查间隔(分钟)');
await user.clear(assetInput);
@@ -199,7 +199,7 @@ describe('Pages latest source automatic updates', () => {
<PagesSourceCard key='source-9' projectId={9} />,
);
await user.click(await screen.findByRole('button', { name: '编辑来源' }));
await user.click(await screen.findByRole('button', { name: '配置' }));
expect(screen.getByRole('dialog')).toBeVisible();
rerenderWithQuery(<PagesSourceCard key='source-10' projectId={10} />);
@@ -62,9 +62,8 @@ function renderWithQuery(ui: React.ReactNode) {
const remoteSource: PagesRemoteURLSource = {
source_type: 'remote_url',
has_remote_url: true,
display_url: 'https://artifacts.example.com/site.zip?***',
remote_network_policy: 'public',
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
allow_insecure: false,
sync_status: 'idle',
last_applied: {
revision: 'a'.repeat(64),
@@ -159,28 +158,21 @@ describe('Pages source UI', () => {
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.getByRole('button', { name: '配置' })).toBeVisible();
expect(screen.queryByText('检查更新')).not.toBeInTheDocument();
expect(screen.queryByText('自动更新')).not.toBeInTheDocument();
await user.click(
screen.getByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(screen.getByRole('button', { name: '配置' }));
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.getByRole('radio', { name: '手动部署' })).toBeChecked();
expect(screen.queryByText('构建命令')).not.toBeInTheDocument();
expect(screen.queryByText('输出目录')).not.toBeInTheDocument();
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
expect(screen.getByRole('switch', { name: '自动更新' })).not.toBeChecked();
expect(screen.getByLabelText('检查间隔(分钟)')).toHaveValue(60);
});
it('submits the GitHub latest automatic update settings', async () => {
@@ -196,9 +188,8 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
@@ -233,9 +224,8 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { 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 来源' }));
@@ -271,9 +261,8 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
@@ -315,9 +304,8 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
@@ -369,9 +357,8 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
await user.click(
await screen.findByRole('button', { name: '配置 GitHub Release' }),
);
await user.click(await screen.findByRole('button', { name: '配置' }));
await user.click(screen.getByRole('radio', { name: 'GitHub Release' }));
await user.type(
screen.getByLabelText('GitHub 仓库 URL'),
'https://github.com/openflare/site',
@@ -382,7 +369,9 @@ describe('Pages source UI', () => {
expect(AdminTaskService.getTaskExecution).toHaveBeenCalledWith('42');
expect(screen.getByText('检查中')).toBeVisible();
});
const checkButton = screen.getByRole('button', { name: /检查更新/ });
const checkButton = await screen.findByRole('button', {
name: /检查更新/,
});
expect(checkButton).toBeDisabled();
expect(within(checkButton).getByRole('status')).toBeVisible();
expect(PagesService.checkSource).not.toHaveBeenCalled();
@@ -398,7 +387,7 @@ describe('Pages source UI', () => {
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('检查间隔')).toBeVisible();
@@ -496,11 +485,14 @@ describe('Pages source UI', () => {
});
});
it('never reuses the masked URL as an editable value', async () => {
it('edits the remote URL as plain text', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: remoteSource,
source: {
...remoteSource,
remote_url: 'https://new.example.com/site.zip?token=new',
},
check_task: null,
warning: '',
});
@@ -508,72 +500,55 @@ describe('Pages source UI', () => {
renderWithQuery(<PagesSourceCard projectId={9} />);
expect(
await screen.findByText('https://artifacts.example.com/site.zip?***'),
await screen.findByText(
'https://artifacts.example.com/site.zip?token=secret',
),
).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' }));
await user.click(screen.getByRole('button', { name: '配置' }));
const input = screen.getByLabelText('Remote URL');
expect(input).toHaveAttribute('type', 'url');
expect(input).toHaveValue(
'https://artifacts.example.com/site.zip?token=secret',
);
await user.clear(input);
await user.type(input, 'https://new.example.com/site.zip?token=new');
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',
allow_insecure: false,
});
});
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 () => {
it('saves the allow-insecure connection switch for remote sources', async () => {
const user = userEvent.setup();
vi.mocked(PagesService.getSource).mockResolvedValue(remoteSource);
vi.mocked(PagesService.updateSource).mockResolvedValue({
source: { ...remoteSource, remote_network_policy: 'trusted_internal' },
source: { ...remoteSource, allow_insecure: true },
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(await screen.findByRole('button', { name: '配置' }));
expect(
screen.getByRole('switch', { name: '允许不安全的连接' }),
).not.toBeChecked();
await user.click(
screen.getByRole('switch', { 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',
remote_url: 'https://artifacts.example.com/site.zip?token=secret',
allow_insecure: true,
});
});
});
@@ -658,6 +633,6 @@ describe('Pages source UI', () => {
);
expect(await screen.findByText('GitHub · v1.2.3 · 手动同步')).toBeVisible();
expect(screen.getByText('当前生产部署')).toBeVisible();
expect(screen.getByText('Production')).toBeVisible();
});
});