mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-05 07:26:36 +08:00
refactor(pages): 精简部署源模型并重构详情页交互
将 Remote 网络策略收敛为 allow_insecure,去掉脱敏与无用字段; Pages 详情拆为部署/设置 Tab,统一卡片样式与来源信息展示。
This commit is contained in:
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +117,6 @@ export type {
|
||||
PagesManualSource,
|
||||
PagesProject,
|
||||
PagesProjectPayload,
|
||||
PagesRemoteNetworkPolicy,
|
||||
PagesRemoteSourceUpdatePayload,
|
||||
PagesRemoteURLSource,
|
||||
PagesSource,
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user