mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -3,6 +3,7 @@
|
||||
import { useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { UploadCloud } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -76,6 +77,8 @@ export function DeploymentUploadDialog({
|
||||
rootDir,
|
||||
entryFile,
|
||||
}: DeploymentUploadDialogProps) {
|
||||
const t = useTranslations('pages.upload');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
@@ -96,14 +99,14 @@ export function DeploymentUploadDialog({
|
||||
|
||||
const uploadMutation = useMutation({
|
||||
mutationFn: () => {
|
||||
if (!file) throw new Error('请选择部署包');
|
||||
if (!file) throw new Error(t('selectFile'));
|
||||
return PagesService.uploadDeployment(projectId, {
|
||||
file,
|
||||
onProgress: setUploadProgress,
|
||||
});
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('部署包上传成功');
|
||||
toast.success(t('success'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: deploymentsQueryKey(projectId),
|
||||
@@ -114,7 +117,7 @@ export function DeploymentUploadDialog({
|
||||
handleOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '上传失败');
|
||||
toast.error(error instanceof Error ? error.message : t('failed'));
|
||||
setUploadProgress(null);
|
||||
},
|
||||
});
|
||||
@@ -122,7 +125,7 @@ export function DeploymentUploadDialog({
|
||||
const handleFileSelect = (selected: File | null) => {
|
||||
if (!selected) return;
|
||||
if (!isSupportedPagesPackage(selected.name)) {
|
||||
toast.error('仅支持 zip、tar.gz、tar.xz、tar.bz2、tar、7z 格式的部署包');
|
||||
toast.error(t('unsupported'));
|
||||
return;
|
||||
}
|
||||
setFile(selected);
|
||||
@@ -132,15 +135,13 @@ export function DeploymentUploadDialog({
|
||||
<Dialog open={open} onOpenChange={handleOpenChange}>
|
||||
<DialogContent className='sm:max-w-lg'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>上传部署包</DialogTitle>
|
||||
<DialogDescription>
|
||||
上传不可变的静态资源压缩包,完成后可在部署历史中激活。
|
||||
</DialogDescription>
|
||||
<DialogTitle>{t('title')}</DialogTitle>
|
||||
<DialogDescription>{t('description')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor='pages-package'>本地部署包</FieldLabel>
|
||||
<FieldLabel htmlFor='pages-package'>{t('localPackage')}</FieldLabel>
|
||||
<button
|
||||
type='button'
|
||||
className={cn(
|
||||
@@ -165,7 +166,7 @@ export function DeploymentUploadDialog({
|
||||
>
|
||||
<UploadCloud className='size-8 text-muted-foreground' />
|
||||
<span className='text-sm font-medium'>
|
||||
拖拽部署包到此处,或点击选择文件
|
||||
{t('dropHint')}
|
||||
</span>
|
||||
<span className='text-xs text-muted-foreground'>
|
||||
zip、tar.gz、tar.xz、tar.bz2、tar、7z
|
||||
@@ -183,20 +184,23 @@ export function DeploymentUploadDialog({
|
||||
/>
|
||||
{file ? (
|
||||
<FieldDescription>
|
||||
已选择 {file.name}({formatBytes(file.size)})
|
||||
{t('selected', {
|
||||
name: file.name,
|
||||
size: formatBytes(file.size),
|
||||
})}
|
||||
</FieldDescription>
|
||||
) : (
|
||||
<FieldDescription>请选择一个受支持的压缩包。</FieldDescription>
|
||||
<FieldDescription>{t('chooseArchive')}</FieldDescription>
|
||||
)}
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldLabel>部署入口</FieldLabel>
|
||||
<FieldLabel>{t('entry')}</FieldLabel>
|
||||
<div className='rounded-md border bg-muted/20 px-3 py-2 font-mono text-sm'>
|
||||
{pagesEntryPath(rootDir, entryFile)}
|
||||
</div>
|
||||
<FieldDescription>
|
||||
入口来自项目设置;部署包上传不会覆盖该配置。
|
||||
{t('entryHint')}
|
||||
</FieldDescription>
|
||||
</Field>
|
||||
|
||||
@@ -204,10 +208,10 @@ export function DeploymentUploadDialog({
|
||||
<Field>
|
||||
<div className='flex items-center justify-between text-xs text-muted-foreground'>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '服务端处理中' : '上传进度'}
|
||||
{uploadProgress >= 100 ? t('processing') : t('progress')}
|
||||
</span>
|
||||
<span>
|
||||
{uploadProgress >= 100 ? '请稍候' : `${uploadProgress}%`}
|
||||
{uploadProgress >= 100 ? t('pleaseWait') : `${uploadProgress}%`}
|
||||
</span>
|
||||
</div>
|
||||
<Progress value={Math.min(uploadProgress, 100)} />
|
||||
@@ -221,7 +225,7 @@ export function DeploymentUploadDialog({
|
||||
variant='outline'
|
||||
onClick={() => handleOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -233,7 +237,7 @@ export function DeploymentUploadDialog({
|
||||
) : (
|
||||
<UploadCloud data-icon='inline-start' />
|
||||
)}
|
||||
{uploadMutation.isPending ? '上传中...' : '上传并创建部署'}
|
||||
{uploadMutation.isPending ? t('uploading') : t('submit')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -35,6 +36,8 @@ export function ProjectEditorDialog({
|
||||
onOpenChange,
|
||||
project,
|
||||
}: ProjectEditorDialogProps) {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const form = usePagesProjectForm(project);
|
||||
|
||||
@@ -50,7 +53,7 @@ export function ProjectEditorDialog({
|
||||
: PagesService.createProject(payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success(project ? '项目已更新' : '项目已创建');
|
||||
toast.success(project ? t('updated') : t('created'));
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
if (project) {
|
||||
await queryClient.invalidateQueries({
|
||||
@@ -60,7 +63,7 @@ export function ProjectEditorDialog({
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -69,11 +72,9 @@ export function ProjectEditorDialog({
|
||||
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>
|
||||
{project ? '编辑 Pages 项目' : '新建 Pages 项目'}
|
||||
{project ? t('editTitle') : t('createTitle')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
配置静态站点托管参数,上传部署包后在代理规则中选择 Pages 上游。
|
||||
</DialogDescription>
|
||||
<DialogDescription>{t('editorDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -86,7 +87,7 @@ export function ProjectEditorDialog({
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -96,12 +97,12 @@ export function ProjectEditorDialog({
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='size-4 animate-spin mr-1' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : project ? (
|
||||
'保存修改'
|
||||
t('saveChanges')
|
||||
) : (
|
||||
'创建项目'
|
||||
t('createProject')
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useMemo } from 'react';
|
||||
import { useForm, type UseFormReturn } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
@@ -9,46 +11,50 @@ 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('/')) {
|
||||
function createPagesProjectSchema(t: (key: string) => string) {
|
||||
return z
|
||||
.object({
|
||||
name: z.string().trim().min(1, t('form.nameRequired')).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, t('form.entryRequired')).max(512),
|
||||
})
|
||||
.superRefine((data, ctx) => {
|
||||
if (data.spa_fallback_enabled && !data.spa_fallback_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: '匹配路径必须以 / 开头',
|
||||
path: ['spa_fallback_path'],
|
||||
message: t('form.fallbackPathPrefix'),
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: '后端地址必须以 http:// 或 https:// 开头',
|
||||
});
|
||||
if (data.api_proxy_enabled) {
|
||||
if (!data.api_proxy_path.startsWith('/')) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_path'],
|
||||
message: t('form.matchPathPrefix'),
|
||||
});
|
||||
}
|
||||
if (!/^https?:\/\//i.test(data.api_proxy_pass)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ['api_proxy_pass'],
|
||||
message: t('form.backendPrefix'),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export type PagesProjectFormValues = z.infer<typeof pagesProjectSchema>;
|
||||
export type PagesProjectFormValues = z.infer<
|
||||
ReturnType<typeof createPagesProjectSchema>
|
||||
>;
|
||||
|
||||
export function toFormValues(
|
||||
project?: PagesProject | null,
|
||||
@@ -110,8 +116,10 @@ export function buildProjectPayload(
|
||||
}
|
||||
|
||||
export function usePagesProjectForm(project?: PagesProject | null) {
|
||||
const t = useTranslations('pages');
|
||||
const schema = useMemo(() => createPagesProjectSchema((key) => t(key)), [t]);
|
||||
return useForm<PagesProjectFormValues>({
|
||||
resolver: zodResolver(pagesProjectSchema),
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: toFormValues(project),
|
||||
});
|
||||
}
|
||||
@@ -125,6 +133,7 @@ export function ProjectFormFields({
|
||||
form,
|
||||
idPrefix = '',
|
||||
}: ProjectFormFieldsProps) {
|
||||
const t = useTranslations('pages');
|
||||
const spaEnabled = form.watch('spa_fallback_enabled');
|
||||
const apiEnabled = form.watch('api_proxy_enabled');
|
||||
const fieldId = (name: string) => (idPrefix ? `${idPrefix}-${name}` : name);
|
||||
@@ -133,7 +142,7 @@ export function ProjectFormFields({
|
||||
<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>
|
||||
<Label htmlFor={fieldId('name')}>{t('form.name')}</Label>
|
||||
<Input id={fieldId('name')} {...form.register('name')} />
|
||||
{form.formState.errors.name ? (
|
||||
<p className='text-xs text-destructive'>
|
||||
@@ -142,10 +151,10 @@ export function ProjectFormFields({
|
||||
) : null}
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('slug')}>项目标识</Label>
|
||||
<Label htmlFor={fieldId('slug')}>{t('form.slug')}</Label>
|
||||
<Input
|
||||
id={fieldId('slug')}
|
||||
placeholder='留空自动生成'
|
||||
placeholder={t('form.slugPlaceholder')}
|
||||
{...form.register('slug')}
|
||||
/>
|
||||
</div>
|
||||
@@ -153,14 +162,14 @@ export function ProjectFormFields({
|
||||
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('entry_file')}>入口文件</Label>
|
||||
<Label htmlFor={fieldId('entry_file')}>{t('form.entryFile')}</Label>
|
||||
<Input id={fieldId('entry_file')} {...form.register('entry_file')} />
|
||||
</div>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('root_dir')}>根目录</Label>
|
||||
<Label htmlFor={fieldId('root_dir')}>{t('form.rootDir')}</Label>
|
||||
<Input
|
||||
id={fieldId('root_dir')}
|
||||
placeholder='可选'
|
||||
placeholder={t('form.optional')}
|
||||
{...form.register('root_dir')}
|
||||
/>
|
||||
</div>
|
||||
@@ -168,9 +177,9 @@ export function ProjectFormFields({
|
||||
|
||||
<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-sm font-medium'>{t('form.spaFallback')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
未命中静态文件时回退到指定路径
|
||||
{t('form.spaFallbackDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -184,7 +193,9 @@ export function ProjectFormFields({
|
||||
</div>
|
||||
{spaEnabled ? (
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('spa_fallback_path')}>回退路径</Label>
|
||||
<Label htmlFor={fieldId('spa_fallback_path')}>
|
||||
{t('form.fallbackPath')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('spa_fallback_path')}
|
||||
{...form.register('spa_fallback_path')}
|
||||
@@ -194,9 +205,9 @@ export function ProjectFormFields({
|
||||
|
||||
<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-sm font-medium'>{t('form.apiProxy')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
为静态站点附加 API 反代规则
|
||||
{t('form.apiProxyDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
@@ -209,21 +220,27 @@ export function ProjectFormFields({
|
||||
{apiEnabled ? (
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor={fieldId('api_proxy_path')}>匹配路径</Label>
|
||||
<Label htmlFor={fieldId('api_proxy_path')}>
|
||||
{t('form.matchPath')}
|
||||
</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>
|
||||
<Label htmlFor={fieldId('api_proxy_pass')}>
|
||||
{t('form.backend')}
|
||||
</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>
|
||||
<Label htmlFor={fieldId('api_proxy_rewrite')}>
|
||||
{t('form.rewrite')}
|
||||
</Label>
|
||||
<Input
|
||||
id={fieldId('api_proxy_rewrite')}
|
||||
{...form.register('api_proxy_rewrite')}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import Link from 'next/link';
|
||||
import { ChevronRight } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { PagesProject } from '@/lib/services/openflare';
|
||||
@@ -12,6 +13,7 @@ interface ProjectListItemProps {
|
||||
}
|
||||
|
||||
export function ProjectListItem({ project }: ProjectListItemProps) {
|
||||
const t = useTranslations('pages');
|
||||
return (
|
||||
<Link
|
||||
href={`/pages/detail?id=${project.id}`}
|
||||
@@ -22,10 +24,10 @@ export function ProjectListItem({ project }: ProjectListItemProps) {
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<h2 className='text-base font-semibold'>{project.name}</h2>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{project.enabled ? '已启用' : '已停用'}
|
||||
{project.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{project.spa_fallback_enabled ? 'SPA fallback' : '严格 404'}
|
||||
{project.spa_fallback_enabled ? t('spaFallback') : t('strict404')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground'>{project.slug}</p>
|
||||
@@ -37,24 +39,24 @@ export function ProjectListItem({ project }: ProjectListItemProps) {
|
||||
</div>
|
||||
|
||||
<div className='rounded-lg border border-dashed px-4 py-3 text-sm md:min-w-36'>
|
||||
<p className='text-xs text-muted-foreground'>当前激活</p>
|
||||
<p className='text-xs text-muted-foreground'>{t('currentActive')}</p>
|
||||
<p className='mt-1 font-semibold'>
|
||||
{project.active_deployment
|
||||
? `#${project.active_deployment.deployment_number}`
|
||||
: '暂无'}
|
||||
: t('none')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className='mt-4 flex items-center justify-between border-t border-dashed pt-3 text-xs text-muted-foreground'>
|
||||
<span>
|
||||
激活时间:
|
||||
{t('activatedAt')}
|
||||
{project.active_deployment?.activated_at
|
||||
? formatDateTime(project.active_deployment.activated_at)
|
||||
: '未激活'}
|
||||
: t('notActivated')}
|
||||
</span>
|
||||
<span className='inline-flex items-center text-primary group-hover:translate-x-0.5 transition-transform'>
|
||||
查看详情
|
||||
{t('viewDetail')}
|
||||
<ChevronRight className='size-3.5 ml-0.5' />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Trash2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -28,6 +29,8 @@ interface DangerZoneCardProps {
|
||||
}
|
||||
|
||||
export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
const [deleteOpen, setDeleteOpen] = useState(false);
|
||||
@@ -35,12 +38,12 @@ export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
const deleteProjectMutation = useMutation({
|
||||
mutationFn: () => PagesService.deleteProject(project.id),
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已删除');
|
||||
toast.success(t('deleted'));
|
||||
await queryClient.invalidateQueries({ queryKey: projectsQueryKey });
|
||||
router.push('/pages');
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('deleteFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -48,13 +51,18 @@ export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
<>
|
||||
<Card className='border-dashed border-destructive/30 bg-destructive/5 shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base text-destructive'>危险区域</CardTitle>
|
||||
<CardTitle className='text-base text-destructive'>
|
||||
{t('danger.title')}
|
||||
</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-sm font-medium'>{t('danger.deleteProject')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
将永久删除 {project.name}({project.slug})及其全部部署。
|
||||
{t('danger.deleteHint', {
|
||||
name: project.name,
|
||||
slug: project.slug,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
@@ -64,7 +72,7 @@ export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
onClick={() => setDeleteOpen(true)}
|
||||
>
|
||||
<Trash2 data-icon='inline-start' />
|
||||
删除项目
|
||||
{t('danger.deleteProject')}
|
||||
</Button>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -72,14 +80,14 @@ export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
<AlertDialog open={deleteOpen} onOpenChange={setDeleteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>删除 Pages 项目</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('danger.confirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除项目 {project.name} 吗?此操作不可恢复。
|
||||
{t('danger.confirmDesc', { name: project.name })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteProjectMutation.isPending}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={deleteProjectMutation.isPending}
|
||||
@@ -91,7 +99,7 @@ export function DangerZoneCard({ project }: DangerZoneCardProps) {
|
||||
{deleteProjectMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认删除
|
||||
{t('danger.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
'use client';
|
||||
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyInline } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -29,6 +30,7 @@ export function DeploymentFilesPanel({
|
||||
projectId,
|
||||
deploymentId,
|
||||
}: DeploymentFilesPanelProps) {
|
||||
const t = useTranslations('pages.files');
|
||||
const filesQuery = useQuery({
|
||||
queryKey: deploymentFilesQueryKey(projectId, deploymentId),
|
||||
queryFn: () => PagesService.listDeploymentFiles(deploymentId),
|
||||
@@ -50,7 +52,7 @@ export function DeploymentFilesPanel({
|
||||
message={
|
||||
filesQuery.error instanceof Error
|
||||
? filesQuery.error.message
|
||||
: '文件清单加载失败'
|
||||
: t('loadFailed')
|
||||
}
|
||||
onRetry={() => void filesQuery.refetch()}
|
||||
/>
|
||||
@@ -60,15 +62,15 @@ export function DeploymentFilesPanel({
|
||||
|
||||
const files = filesQuery.data ?? [];
|
||||
if (files.length === 0) {
|
||||
return <EmptyInline message='暂无文件记录' />;
|
||||
return <EmptyInline message={t('empty')} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>路径</TableHead>
|
||||
<TableHead className='text-right'>大小</TableHead>
|
||||
<TableHead>{t('path')}</TableHead>
|
||||
<TableHead className='text-right'>{t('size')}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ChevronDown, ChevronRight, Rocket, Upload } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
@@ -43,18 +44,27 @@ import {
|
||||
} from '../../components/pages-utils';
|
||||
import { DeploymentFilesPanel } from './deployment-files-panel';
|
||||
|
||||
const SOURCE_LABELS: Record<PagesDeployment['source_type'], string> = {
|
||||
manual_upload: '本地上传',
|
||||
manual_url: 'URL 导入',
|
||||
remote_url: 'Remote URL',
|
||||
github_release: 'GitHub',
|
||||
const SOURCE_LABEL_KEYS: Record<
|
||||
PagesDeployment['source_type'],
|
||||
'history.source.manual_upload' | 'history.source.manual_url' | null
|
||||
> = {
|
||||
manual_upload: 'history.source.manual_upload',
|
||||
manual_url: 'history.source.manual_url',
|
||||
remote_url: null,
|
||||
github_release: null,
|
||||
};
|
||||
|
||||
const TRIGGER_LABELS: Record<PagesDeployment['trigger_type'], string> = {
|
||||
manual_upload: '手动上传',
|
||||
manual_url: '手动导入',
|
||||
manual_sync: '手动同步',
|
||||
scheduled_auto_update: '定时更新',
|
||||
const TRIGGER_LABEL_KEYS: Record<
|
||||
PagesDeployment['trigger_type'],
|
||||
| 'history.trigger.manual_upload'
|
||||
| 'history.trigger.manual_url'
|
||||
| 'history.trigger.manual_sync'
|
||||
| 'history.trigger.scheduled_auto_update'
|
||||
> = {
|
||||
manual_upload: 'history.trigger.manual_upload',
|
||||
manual_url: 'history.trigger.manual_url',
|
||||
manual_sync: 'history.trigger.manual_sync',
|
||||
scheduled_auto_update: 'history.trigger.scheduled_auto_update',
|
||||
};
|
||||
|
||||
interface DeploymentHistoryProps {
|
||||
@@ -76,27 +86,47 @@ function isActiveDeployment(
|
||||
return deployment.id === activeDeploymentId || deployment.status === 'active';
|
||||
}
|
||||
|
||||
function deploymentSnapshot(deployment: PagesDeployment) {
|
||||
function sourceLabel(
|
||||
type: PagesDeployment['source_type'],
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
const key = SOURCE_LABEL_KEYS[type];
|
||||
if (key) return t(key);
|
||||
return type === 'remote_url' ? 'Remote URL' : 'GitHub';
|
||||
}
|
||||
|
||||
function deploymentSnapshot(
|
||||
deployment: PagesDeployment,
|
||||
t: (key: string) => string,
|
||||
) {
|
||||
return [
|
||||
SOURCE_LABELS[deployment.source_type],
|
||||
sourceLabel(deployment.source_type, t),
|
||||
deployment.source_label,
|
||||
TRIGGER_LABELS[deployment.trigger_type],
|
||||
t(TRIGGER_LABEL_KEYS[deployment.trigger_type]),
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
}
|
||||
|
||||
function DeploymentMeta({ deployment }: { deployment: PagesDeployment }) {
|
||||
const t = useTranslations('pages');
|
||||
return (
|
||||
<>
|
||||
<p className='truncate text-xs text-muted-foreground'>
|
||||
{deployment.checksum.slice(0, 16)} · {deployment.file_count} 个文件 ·{' '}
|
||||
{t('history.fileMeta', {
|
||||
checksum: deployment.checksum.slice(0, 16),
|
||||
count: deployment.file_count,
|
||||
})}
|
||||
{formatBytes(deployment.total_size)}
|
||||
</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
创建于 {formatDateTime(deployment.created_at)}
|
||||
{t('history.createdAt', {
|
||||
time: formatDateTime(deployment.created_at),
|
||||
})}
|
||||
{deployment.activated_at
|
||||
? ` · 激活于 ${formatDateTime(deployment.activated_at)}`
|
||||
? t('history.activatedAt', {
|
||||
time: formatDateTime(deployment.activated_at),
|
||||
})
|
||||
: ''}
|
||||
</p>
|
||||
</>
|
||||
@@ -126,6 +156,7 @@ function DeploymentRow({
|
||||
onDelete,
|
||||
projectId,
|
||||
}: DeploymentRowProps) {
|
||||
const t = useTranslations('pages');
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -139,7 +170,9 @@ function DeploymentRow({
|
||||
type='button'
|
||||
variant='ghost'
|
||||
size='icon-sm'
|
||||
aria-label={expanded ? '收起文件清单' : '展开文件清单'}
|
||||
aria-label={
|
||||
expanded ? t('history.collapseFiles') : t('history.expandFiles')
|
||||
}
|
||||
onClick={onToggleExpand}
|
||||
>
|
||||
{expanded ? <ChevronDown /> : <ChevronRight />}
|
||||
@@ -147,10 +180,12 @@ function DeploymentRow({
|
||||
<div className='flex min-w-0 flex-col gap-2'>
|
||||
<div className='flex flex-wrap items-center gap-2'>
|
||||
<span className='text-sm font-medium'>
|
||||
部署 #{deployment.deployment_number}
|
||||
{t('history.deploymentNumber', {
|
||||
number: deployment.deployment_number,
|
||||
})}
|
||||
</span>
|
||||
<Badge variant='secondary'>
|
||||
{deploymentSnapshot(deployment)}
|
||||
{deploymentSnapshot(deployment, (key) => t(key))}
|
||||
</Badge>
|
||||
</div>
|
||||
<DeploymentMeta deployment={deployment} />
|
||||
@@ -165,7 +200,7 @@ function DeploymentRow({
|
||||
disabled={active || actionPending}
|
||||
onClick={onActivate}
|
||||
>
|
||||
激活
|
||||
{t('history.activate')}
|
||||
</Button>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -174,7 +209,7 @@ function DeploymentRow({
|
||||
disabled={active || actionPending}
|
||||
onClick={onDelete}
|
||||
>
|
||||
删除
|
||||
{t('history.delete')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -195,6 +230,8 @@ export function DeploymentHistory({
|
||||
rootDir = '',
|
||||
entryFile = 'index.html',
|
||||
}: DeploymentHistoryProps) {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [uploadOpen, setUploadOpen] = useState(false);
|
||||
const [expandedDeploymentId, setExpandedDeploymentId] = useState<
|
||||
@@ -240,12 +277,12 @@ export function DeploymentHistory({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
PagesService.activateDeployment(projectId, deploymentId),
|
||||
onSuccess: async () => {
|
||||
toast.success('历史部署已激活;自动更新(如已开启)已关闭');
|
||||
toast.success(t('history.activated'));
|
||||
await invalidateDeploymentState();
|
||||
setPendingAction(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '激活失败');
|
||||
toast.error(error instanceof Error ? error.message : t('history.activateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -253,7 +290,7 @@ export function DeploymentHistory({
|
||||
mutationFn: (deploymentId: number) =>
|
||||
PagesService.deleteDeployment(projectId, deploymentId),
|
||||
onSuccess: async (_, deploymentId) => {
|
||||
toast.success('部署已删除');
|
||||
toast.success(t('history.deleted'));
|
||||
queryClient.removeQueries({
|
||||
queryKey: deploymentFilesQueryKey(projectId, deploymentId),
|
||||
});
|
||||
@@ -261,7 +298,7 @@ export function DeploymentHistory({
|
||||
setPendingAction(null);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '删除失败');
|
||||
toast.error(error instanceof Error ? error.message : t('history.deleteFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -284,16 +321,22 @@ export function DeploymentHistory({
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{pendingAction?.type === 'activate' ? '激活历史部署' : '删除部署'}
|
||||
{pendingAction?.type === 'activate'
|
||||
? t('history.activateTitle')
|
||||
: t('history.deleteTitle')}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{pendingAction?.type === 'activate'
|
||||
? '激活其它历史部署会终止当前来源任务;若已开启自动更新,将同时关闭自动更新。'
|
||||
: `确认删除部署 #${pendingAction?.deployment.deployment_number} 吗?此操作不可恢复。`}
|
||||
? t('history.activateDesc')
|
||||
: t('history.deleteDesc', {
|
||||
number: pendingAction?.deployment.deployment_number ?? 0,
|
||||
})}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={actionPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={actionPending}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={actionPending}
|
||||
onClick={(event) => {
|
||||
@@ -307,7 +350,7 @@ export function DeploymentHistory({
|
||||
}}
|
||||
>
|
||||
{actionPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
确认
|
||||
{t('history.confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
@@ -326,7 +369,7 @@ export function DeploymentHistory({
|
||||
if (deploymentsQuery.isLoading) {
|
||||
return (
|
||||
<>
|
||||
<LoadingStateWithBorder description='加载部署历史...' />
|
||||
<LoadingStateWithBorder description={t('history.loading')} />
|
||||
{dialogs}
|
||||
</>
|
||||
);
|
||||
@@ -340,7 +383,7 @@ export function DeploymentHistory({
|
||||
message={
|
||||
deploymentsQuery.error instanceof Error
|
||||
? deploymentsQuery.error.message
|
||||
: '部署历史加载失败'
|
||||
: t('history.loadFailed')
|
||||
}
|
||||
onRetry={() => void deploymentsQuery.refetch()}
|
||||
/>
|
||||
@@ -356,7 +399,7 @@ export function DeploymentHistory({
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>Production</CardTitle>
|
||||
<CardDescription>
|
||||
当前对外生效的生产部署;切换激活状态后会立即生效。
|
||||
{t('history.productionHint')}
|
||||
</CardDescription>
|
||||
<CardAction>
|
||||
<Button
|
||||
@@ -366,7 +409,7 @@ export function DeploymentHistory({
|
||||
onClick={() => setUploadOpen(true)}
|
||||
>
|
||||
<Upload data-icon='inline-start' />
|
||||
手动上传
|
||||
{t('history.manualUpload')}
|
||||
</Button>
|
||||
</CardAction>
|
||||
</CardHeader>
|
||||
@@ -386,8 +429,8 @@ export function DeploymentHistory({
|
||||
) : (
|
||||
<EmptyStateWithBorder
|
||||
icon={Rocket}
|
||||
title='暂无 Production 部署'
|
||||
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
|
||||
title={t('history.emptyProductionTitle')}
|
||||
description={t('history.emptyProductionDesc')}
|
||||
/>
|
||||
)}
|
||||
</CardContent>
|
||||
@@ -397,14 +440,14 @@ export function DeploymentHistory({
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>All deployments</CardTitle>
|
||||
<CardDescription>
|
||||
部署记录不可变,来源信息是创建部署时的安全快照。可从历史部署激活或删除。
|
||||
{t('history.historyHint')}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
{allDeployments.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='暂无部署'
|
||||
description='上传本地部署包,或配置 Remote URL / GitHub Release 来源后同步发布。'
|
||||
title={t('history.emptyTitle')}
|
||||
description={t('history.emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
allDeployments.map((deployment) => {
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { Download, Pencil, RefreshCw, Search } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -58,16 +59,19 @@ const LATEST_OVERDUE_MAX_WAIT = 10 * 60 * 1_000;
|
||||
const SOURCE_STATUS: Record<
|
||||
PagesSourceStatus,
|
||||
{
|
||||
label: string;
|
||||
labelKey: string;
|
||||
variant: 'default' | 'secondary' | 'destructive' | 'outline';
|
||||
}
|
||||
> = {
|
||||
idle: { label: '空闲', variant: 'outline' },
|
||||
checking: { label: '检查中', variant: 'secondary' },
|
||||
update_available: { label: '有可用更新', variant: 'default' },
|
||||
syncing: { label: '同步中', variant: 'secondary' },
|
||||
failed: { label: '最近同步失败', variant: 'destructive' },
|
||||
attention: { label: '需要确认', variant: 'destructive' },
|
||||
idle: { labelKey: 'source.status.idle', variant: 'outline' },
|
||||
checking: { labelKey: 'source.status.checking', variant: 'secondary' },
|
||||
update_available: {
|
||||
labelKey: 'source.status.update_available',
|
||||
variant: 'default',
|
||||
},
|
||||
syncing: { labelKey: 'source.status.syncing', variant: 'secondary' },
|
||||
failed: { labelKey: 'source.status.failed', variant: 'destructive' },
|
||||
attention: { labelKey: 'source.status.attention', variant: 'destructive' },
|
||||
};
|
||||
|
||||
interface ActiveAction {
|
||||
@@ -129,11 +133,9 @@ function sourceDeploymentFingerprint(source: PagesSource) {
|
||||
return `${source.last_synced_at ?? ''}|${source.last_applied?.revision ?? ''}`;
|
||||
}
|
||||
|
||||
function sourceActionLabel(action: PagesSourceActionReceipt['action']) {
|
||||
return action === 'check' ? '检查' : '同步并发布';
|
||||
}
|
||||
|
||||
export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const handledExecutionID = useRef<string | null>(null);
|
||||
const sourcePollingStartedAt = useRef<number | null>(null);
|
||||
@@ -198,7 +200,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
],
|
||||
queryFn: () => {
|
||||
const executionID = activeAction?.receipt.execution_id;
|
||||
if (!executionID) throw new Error('缺少任务执行 ID');
|
||||
if (!executionID) throw new Error(t('source.missingExecutionId'));
|
||||
return AdminTaskService.getTaskExecution(executionID);
|
||||
},
|
||||
enabled: Boolean(activeAction) && !actionTimedOut,
|
||||
@@ -257,11 +259,16 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
|
||||
void invalidateSourceState();
|
||||
|
||||
const actionLabel = sourceActionLabel(activeAction.receipt.action);
|
||||
const actionLabel =
|
||||
activeAction.receipt.action === 'check'
|
||||
? t('source.check')
|
||||
: t('source.syncPublish');
|
||||
if (execution.status === 'succeeded') {
|
||||
toast.success(`部署源${actionLabel}完成`);
|
||||
toast.success(t('source.actionDone', { action: actionLabel }));
|
||||
} else {
|
||||
toast.error(execution.error_message || `部署源${actionLabel}失败`);
|
||||
toast.error(
|
||||
execution.error_message || t('source.actionFailed', { action: actionLabel }),
|
||||
);
|
||||
}
|
||||
setActiveAction(null);
|
||||
setActionTimedOut(false);
|
||||
@@ -274,10 +281,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
toast.success('检查任务已提交');
|
||||
toast.success(t('source.checkQueued'));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '检查任务提交失败');
|
||||
toast.error(error instanceof Error ? error.message : t('source.checkQueueFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -290,10 +297,10 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
await queryClient.invalidateQueries({
|
||||
queryKey: sourceQueryKey(projectId),
|
||||
});
|
||||
toast.success('同步任务已提交');
|
||||
toast.success(t('source.syncQueued'));
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '同步任务提交失败');
|
||||
toast.error(error instanceof Error ? error.message : t('source.syncQueueFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -342,8 +349,8 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardDescription>加载来源配置...</CardDescription>
|
||||
<CardTitle className='text-base'>{t('source.title')}</CardTitle>
|
||||
<CardDescription>{t('source.loadingDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className='space-y-3'>
|
||||
<Skeleton className='h-10 w-full' />
|
||||
@@ -357,15 +364,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
return (
|
||||
<Card className='border-dashed shadow-none'>
|
||||
<CardHeader className='pb-3'>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardDescription>来源配置与部署历史相互独立。</CardDescription>
|
||||
<CardTitle className='text-base'>{t('source.title')}</CardTitle>
|
||||
<CardDescription>{t('source.independentDesc')}</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<ErrorInline
|
||||
message={
|
||||
sourceQuery.error instanceof Error
|
||||
? sourceQuery.error.message
|
||||
: '部署源加载失败'
|
||||
: t('source.loadFailed')
|
||||
}
|
||||
onRetry={() => void sourceQuery.refetch()}
|
||||
/>
|
||||
@@ -397,15 +404,15 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
<CardHeader className='pb-3'>
|
||||
<div className='flex items-start justify-between gap-3'>
|
||||
<div>
|
||||
<CardTitle className='text-base'>部署源</CardTitle>
|
||||
<CardTitle className='text-base'>{t('source.title')}</CardTitle>
|
||||
<CardDescription>
|
||||
配置远端来源并同步发布;发布结果见部署历史。
|
||||
{t('source.configDesc')}
|
||||
</CardDescription>
|
||||
</div>
|
||||
{status ? (
|
||||
<Badge variant={status.variant}>{status.label}</Badge>
|
||||
<Badge variant={status.variant}>{t(status.labelKey)}</Badge>
|
||||
) : (
|
||||
<Badge variant='outline'>手动部署</Badge>
|
||||
<Badge variant='outline'>{t('source.manualBadge')}</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardHeader>
|
||||
@@ -413,9 +420,9 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
<CardContent className='space-y-4'>
|
||||
{source.source_type === 'manual' ? (
|
||||
<div className='rounded-lg border border-dashed bg-muted/20 p-4'>
|
||||
<p className='text-sm font-medium'>本地部署包</p>
|
||||
<p className='text-sm font-medium'>{t('source.localPackage')}</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
当前没有持久化远端来源。上传部署包后,再从部署历史显式激活。
|
||||
{t('source.noRemote')}
|
||||
</p>
|
||||
</div>
|
||||
) : source.source_type === 'remote_url' ? (
|
||||
@@ -429,7 +436,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
message={
|
||||
dispatchError instanceof Error
|
||||
? dispatchError.message
|
||||
: '来源任务提交失败'
|
||||
: t('source.submitFailed')
|
||||
}
|
||||
/>
|
||||
) : null}
|
||||
@@ -438,7 +445,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
message={
|
||||
executionQuery.error instanceof Error
|
||||
? executionQuery.error.message
|
||||
: '任务状态读取失败'
|
||||
: t('source.statusFailed')
|
||||
}
|
||||
onRetry={() => void executionQuery.refetch()}
|
||||
/>
|
||||
@@ -446,7 +453,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
{actionTimedOut ? (
|
||||
<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'>
|
||||
自动等待已停止,任务可能仍在后台运行。
|
||||
{t('source.waitStopped')}
|
||||
</span>
|
||||
<Button
|
||||
type='button'
|
||||
@@ -461,7 +468,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
}}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
刷新任务状态
|
||||
{t('source.refreshStatus')}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -476,7 +483,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
onClick={() => openSourceDialog(source.source_type)}
|
||||
>
|
||||
<Pencil data-icon='inline-start' />
|
||||
配置
|
||||
{t('source.configure')}
|
||||
</Button>
|
||||
{source.source_type !== 'manual' ? (
|
||||
<>
|
||||
@@ -496,7 +503,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
) : (
|
||||
<Search data-icon='inline-start' />
|
||||
)}
|
||||
检查更新
|
||||
{t('source.checkUpdate')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
@@ -513,7 +520,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
) : (
|
||||
<Download data-icon='inline-start' />
|
||||
)}
|
||||
同步并发布
|
||||
{t('source.syncPublish')}
|
||||
</Button>
|
||||
</>
|
||||
) : null}
|
||||
@@ -525,7 +532,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
onClick={() => void sourceQuery.refetch()}
|
||||
>
|
||||
<RefreshCw data-icon='inline-start' />
|
||||
刷新
|
||||
{t('source.refresh')}
|
||||
</Button>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
@@ -547,18 +554,17 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认发布当前 GitHub revision</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('source.confirmTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
<span>这将发布卡片当前显示的精确 revision:</span>{' '}
|
||||
<span>{t('source.confirmLead')}</span>{' '}
|
||||
<span className='break-all font-mono'>
|
||||
{attentionRevision?.revision ??
|
||||
'当前 revision 已不可用,请刷新后重试'}
|
||||
{attentionRevision?.revision ?? t('source.revisionUnavailable')}
|
||||
</span>
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={syncMutation.isPending}>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={syncMutation.isPending || !attentionRevision}
|
||||
@@ -573,7 +579,7 @@ export function PagesSourceCard({ projectId }: { projectId: number }) {
|
||||
{syncMutation.isPending ? (
|
||||
<Spinner data-icon='inline-start' />
|
||||
) : null}
|
||||
确认并发布
|
||||
{t('source.confirmPublish')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { TriangleAlert } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import type { ReactNode } from 'react';
|
||||
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -11,8 +12,11 @@ import {
|
||||
} from '@/lib/services/openflare';
|
||||
import { cn, formatDateTime } from '@/lib/utils';
|
||||
|
||||
function revisionSummary(revision?: PagesSourceRevision) {
|
||||
if (!revision) return '尚无记录';
|
||||
function revisionSummary(
|
||||
revision: PagesSourceRevision | undefined,
|
||||
empty: string,
|
||||
) {
|
||||
if (!revision) return empty;
|
||||
const label = revision.label?.trim();
|
||||
const short = revision.revision.slice(0, 12);
|
||||
return label ? `${label} · ${short}` : short;
|
||||
@@ -51,23 +55,24 @@ export function RemoteSourceDetails({
|
||||
}: {
|
||||
source: PagesRemoteURLSource;
|
||||
}) {
|
||||
const t = useTranslations('pages.source');
|
||||
return (
|
||||
<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>
|
||||
<SourceMetaRow label={t('address')} mono>
|
||||
{source.remote_url || '—'}
|
||||
</SourceMetaRow>
|
||||
<div className='grid gap-3.5 sm:grid-cols-2'>
|
||||
<SourceMetaRow label='TLS'>
|
||||
{source.allow_insecure ? '允许不安全连接' : '校验证书'}
|
||||
{source.allow_insecure ? t('allowInsecure') : t('verifyCert')}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近同步'>
|
||||
{formatOptionalTime(source.last_synced_at, '尚未同步')}
|
||||
<SourceMetaRow label={t('lastSync')}>
|
||||
{formatOptionalTime(source.last_synced_at, t('neverSynced'))}
|
||||
</SourceMetaRow>
|
||||
</div>
|
||||
<SourceMetaRow label='已应用' mono>
|
||||
{revisionSummary(source.last_applied)}
|
||||
<SourceMetaRow label={t('applied')} mono>
|
||||
{revisionSummary(source.last_applied, t('noRecord'))}
|
||||
</SourceMetaRow>
|
||||
</dl>
|
||||
</div>
|
||||
@@ -82,25 +87,22 @@ export function GitHubSourceDetails({
|
||||
}: {
|
||||
source: PagesGitHubReleaseSource;
|
||||
}) {
|
||||
const t = useTranslations('pages.source');
|
||||
const attentionRevision =
|
||||
source.sync_status === 'attention' ? source.last_seen : undefined;
|
||||
const releaseLabel =
|
||||
source.release_selector === 'latest'
|
||||
? '最新 Release'
|
||||
: `固定 Tag · ${source.release_tag || '未提供'}`;
|
||||
? t('latestReleaseLabel')
|
||||
: t('pinnedTagLabel', { tag: source.release_tag || t('tagMissing') });
|
||||
|
||||
return (
|
||||
<div className='space-y-4'>
|
||||
{attentionRevision ? (
|
||||
<Alert variant='destructive'>
|
||||
<TriangleAlert />
|
||||
<AlertTitle>Release Asset 发生变化,需要显式确认</AlertTitle>
|
||||
<AlertTitle>{t('assetChangedTitle')}</AlertTitle>
|
||||
<AlertDescription>
|
||||
<p>
|
||||
当前远端 revision
|
||||
与已发布内容不一致。请核对版本和资源后,再确认发布这一精确
|
||||
revision。
|
||||
</p>
|
||||
<p>{t('assetChangedDesc')}</p>
|
||||
<code className='break-all'>{attentionRevision.revision}</code>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
@@ -122,32 +124,36 @@ export function GitHubSourceDetails({
|
||||
<dl className='grid gap-x-8 gap-y-3.5 sm:grid-cols-2'>
|
||||
{source.release_selector === 'latest' ? (
|
||||
<>
|
||||
<SourceMetaRow label='自动更新'>
|
||||
{source.auto_update_enabled ? '已开启' : '已关闭'}
|
||||
<SourceMetaRow label={t('autoUpdate')}>
|
||||
{source.auto_update_enabled
|
||||
? t('autoUpdateOn')
|
||||
: t('autoUpdateOff')}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='检查间隔'>
|
||||
{source.check_interval_minutes} 分钟
|
||||
<SourceMetaRow label={t('checkInterval')}>
|
||||
{t('intervalMinutes', {
|
||||
count: source.check_interval_minutes,
|
||||
})}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='下次检查'>
|
||||
{formatOptionalTime(source.next_check_at, '等待调度')}
|
||||
<SourceMetaRow label={t('nextCheck')}>
|
||||
{formatOptionalTime(source.next_check_at, t('waitingSchedule'))}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近检查'>
|
||||
{formatOptionalTime(source.last_checked_at, '尚未检查')}
|
||||
<SourceMetaRow label={t('lastCheck')}>
|
||||
{formatOptionalTime(source.last_checked_at, t('neverChecked'))}
|
||||
</SourceMetaRow>
|
||||
</>
|
||||
) : (
|
||||
<SourceMetaRow label='最近检查'>
|
||||
{formatOptionalTime(source.last_checked_at, '尚未检查')}
|
||||
<SourceMetaRow label={t('lastCheck')}>
|
||||
{formatOptionalTime(source.last_checked_at, t('neverChecked'))}
|
||||
</SourceMetaRow>
|
||||
)}
|
||||
<SourceMetaRow label='远端' mono>
|
||||
{revisionSummary(source.last_seen)}
|
||||
<SourceMetaRow label={t('remote')} mono>
|
||||
{revisionSummary(source.last_seen, t('noRecord'))}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='已应用' mono>
|
||||
{revisionSummary(source.last_applied)}
|
||||
<SourceMetaRow label={t('applied')} mono>
|
||||
{revisionSummary(source.last_applied, t('noRecord'))}
|
||||
</SourceMetaRow>
|
||||
<SourceMetaRow label='最近同步'>
|
||||
{formatOptionalTime(source.last_synced_at, '尚未同步')}
|
||||
<SourceMetaRow label={t('lastSync')}>
|
||||
{formatOptionalTime(source.last_synced_at, t('neverSynced'))}
|
||||
</SourceMetaRow>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
@@ -93,6 +94,8 @@ export function PagesSourceDialog({
|
||||
initialMode,
|
||||
onActionDispatched,
|
||||
}: PagesSourceDialogProps) {
|
||||
const t = useTranslations('pages.source');
|
||||
const tCommon = useTranslations('common');
|
||||
const queryClient = useQueryClient();
|
||||
const [mode, setMode] = useState<PagesSourceMode>('manual');
|
||||
const [allowInsecure, setAllowInsecure] = useState(false);
|
||||
@@ -181,13 +184,13 @@ export function PagesSourceDialog({
|
||||
queryClient.setQueryData(sourceQueryKey(projectId), result.source);
|
||||
if (result.check_task) onActionDispatched?.(result.check_task);
|
||||
await invalidateSourceState();
|
||||
toast.success('部署源已更新');
|
||||
toast.success(t('updated'));
|
||||
if (result.warning) toast.warning(result.warning);
|
||||
setConfirmation(null);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '部署源更新失败');
|
||||
toast.error(error instanceof Error ? error.message : t('updateFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -196,12 +199,12 @@ export function PagesSourceDialog({
|
||||
onSuccess: async (manualSource) => {
|
||||
queryClient.setQueryData(sourceQueryKey(projectId), manualSource);
|
||||
await invalidateSourceState();
|
||||
toast.success('已切换回手动部署');
|
||||
toast.success(t('switchedManual'));
|
||||
setConfirmation(null);
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '切换失败');
|
||||
toast.error(error instanceof Error ? error.message : t('switchFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -216,14 +219,14 @@ export function PagesSourceDialog({
|
||||
const submitRemote = () => {
|
||||
const value = remoteURL.trim();
|
||||
if (!value) {
|
||||
setURLError('请输入 Remote URL');
|
||||
setURLError(t('urlRequired'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error();
|
||||
} catch {
|
||||
setURLError('请输入有效的 HTTP(S) URL');
|
||||
setURLError(t('urlInvalid'));
|
||||
return;
|
||||
}
|
||||
setURLError('');
|
||||
@@ -236,22 +239,22 @@ export function PagesSourceDialog({
|
||||
normalizedRepositoryURL,
|
||||
)
|
||||
? ''
|
||||
: '请输入 https://github.com/{owner}/{repo} 格式的公开仓库地址';
|
||||
: t('githubUrlInvalid');
|
||||
const nextReleaseTagError =
|
||||
githubForm.releaseSelector === 'tag' &&
|
||||
!validGitHubReleaseTag(githubForm.releaseTag)
|
||||
? 'Release tag 须为有效 Git ref(1–255 字节,可使用 /、#、&、=)'
|
||||
? t('tagInvalid')
|
||||
: '';
|
||||
const nextAssetNameError = validGitHubAssetName(githubForm.assetName)
|
||||
? ''
|
||||
: 'Asset 文件名须为 1–255 字节,且不能是路径或包含控制、换行、双向文本字符';
|
||||
: t('assetInvalid');
|
||||
const checkIntervalMinutes = Number(githubForm.checkIntervalMinutes);
|
||||
const nextCheckIntervalError =
|
||||
githubForm.releaseSelector === 'latest' &&
|
||||
(!Number.isInteger(checkIntervalMinutes) ||
|
||||
checkIntervalMinutes < 5 ||
|
||||
checkIntervalMinutes > 1440)
|
||||
? '检查间隔须为 5–1440 分钟的整数'
|
||||
? t('intervalInvalid')
|
||||
: '';
|
||||
|
||||
setGitHubErrors({
|
||||
@@ -311,10 +314,10 @@ export function PagesSourceDialog({
|
||||
|
||||
const submitLabel =
|
||||
mode === 'manual'
|
||||
? '使用手动部署'
|
||||
? t('useManual')
|
||||
: mode === 'remote_url'
|
||||
? '保存 Remote 来源'
|
||||
: '保存 GitHub 来源';
|
||||
? t('saveRemote')
|
||||
: t('saveGithub');
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -326,13 +329,13 @@ export function PagesSourceDialog({
|
||||
>
|
||||
<DialogContent className='sm:max-w-xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>部署源设置</DialogTitle>
|
||||
<DialogDescription>选择部署来源。</DialogDescription>
|
||||
<DialogTitle>{t('settingsTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('settingsDesc')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldTitle id='pages-source-mode'>来源类型</FieldTitle>
|
||||
<FieldTitle id='pages-source-mode'>{t('mode')}</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
@@ -355,7 +358,7 @@ export function PagesSourceDialog({
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='manual' className='w-full'>
|
||||
手动部署
|
||||
{t('manual')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='remote_url' className='w-full'>
|
||||
Remote URL
|
||||
@@ -368,9 +371,9 @@ export function PagesSourceDialog({
|
||||
|
||||
{mode === 'manual' ? (
|
||||
<Field>
|
||||
<FieldLabel>手动部署</FieldLabel>
|
||||
<FieldLabel>{t('manual')}</FieldLabel>
|
||||
<div className='rounded-lg border bg-muted/20 p-4 text-sm text-muted-foreground'>
|
||||
保留现有部署与当前生产版本,后续通过“上传部署包”创建新部署。
|
||||
{t('manualHint')}
|
||||
</div>
|
||||
</Field>
|
||||
) : mode === 'remote_url' ? (
|
||||
@@ -390,22 +393,21 @@ export function PagesSourceDialog({
|
||||
}}
|
||||
/>
|
||||
<FieldDescription>
|
||||
{urlError || '填写可直接下载的部署包 HTTP(S) 地址。'}
|
||||
{urlError || t('urlHint')}
|
||||
</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-sm font-medium'>{t('allowInsecure')}</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
默认允许公网与内网地址;开启后跳过 TLS
|
||||
证书校验,适用于自签名或私有 CA。
|
||||
{t('allowInsecureDesc')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={allowInsecure}
|
||||
onCheckedChange={setAllowInsecure}
|
||||
aria-label='允许不安全的连接'
|
||||
aria-label={t('allowInsecure')}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
@@ -427,7 +429,7 @@ export function PagesSourceDialog({
|
||||
disabled={isPending}
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
取消
|
||||
{tCommon('cancel')}
|
||||
</Button>
|
||||
<Button type='button' disabled={isPending} onClick={handleSubmit}>
|
||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
@@ -445,13 +447,15 @@ export function PagesSourceDialog({
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>切换回手动部署</AlertDialogTitle>
|
||||
<AlertDialogTitle>{t('switchManualTitle')}</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
当前来源配置将被删除,但已有部署与当前生产版本会保留。
|
||||
{t('switchManualDesc')}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={isPending}>
|
||||
{tCommon('cancel')}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
disabled={isPending}
|
||||
onClick={(event) => {
|
||||
@@ -462,7 +466,7 @@ export function PagesSourceDialog({
|
||||
}}
|
||||
>
|
||||
{isPending ? <Spinner data-icon='inline-start' /> : null}
|
||||
确认
|
||||
{tCommon('confirm')}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import {
|
||||
Field,
|
||||
FieldContent,
|
||||
@@ -42,11 +44,12 @@ export function PagesSourceGitHubFields({
|
||||
onChange,
|
||||
onErrorsChange,
|
||||
}: PagesSourceGitHubFieldsProps) {
|
||||
const t = useTranslations('pages.source');
|
||||
return (
|
||||
<>
|
||||
<Field data-invalid={Boolean(errors.repository)}>
|
||||
<FieldLabel htmlFor='pages-github-repository'>
|
||||
GitHub 仓库 URL
|
||||
{t('githubUrl')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-repository'
|
||||
@@ -62,7 +65,7 @@ export function PagesSourceGitHubFields({
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-repository-description'>
|
||||
仅支持公开 github.com 仓库。
|
||||
{t('githubUrlHint')}
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-repository-error'>
|
||||
{errors.repository}
|
||||
@@ -70,7 +73,7 @@ export function PagesSourceGitHubFields({
|
||||
</Field>
|
||||
|
||||
<Field>
|
||||
<FieldTitle id='pages-github-selector'>Release 选择</FieldTitle>
|
||||
<FieldTitle id='pages-github-selector'>{t('releaseSelect')}</FieldTitle>
|
||||
<ToggleGroup
|
||||
type='single'
|
||||
variant='outline'
|
||||
@@ -94,10 +97,10 @@ export function PagesSourceGitHubFields({
|
||||
}}
|
||||
>
|
||||
<ToggleGroupItem value='latest' className='w-full'>
|
||||
最新 Release
|
||||
{t('latestRelease')}
|
||||
</ToggleGroupItem>
|
||||
<ToggleGroupItem value='tag' className='w-full'>
|
||||
固定 Tag
|
||||
{t('pinnedTag')}
|
||||
</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</Field>
|
||||
@@ -118,7 +121,7 @@ export function PagesSourceGitHubFields({
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-tag-description'>
|
||||
精确检查并同步指定 tag。
|
||||
{t('pinnedTagHint')}
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-tag-error'>
|
||||
{errors.releaseTag}
|
||||
@@ -131,10 +134,10 @@ export function PagesSourceGitHubFields({
|
||||
<Field orientation='horizontal'>
|
||||
<FieldContent>
|
||||
<FieldLabel htmlFor='pages-github-auto-update'>
|
||||
自动更新
|
||||
{t('autoUpdate')}
|
||||
</FieldLabel>
|
||||
<FieldDescription>
|
||||
检查到新的 Release 后自动同步并发布。
|
||||
{t('autoUpdateHint')}
|
||||
</FieldDescription>
|
||||
</FieldContent>
|
||||
<Switch
|
||||
@@ -148,7 +151,7 @@ export function PagesSourceGitHubFields({
|
||||
|
||||
<Field data-invalid={Boolean(errors.checkInterval)}>
|
||||
<FieldLabel htmlFor='pages-github-check-interval'>
|
||||
检查间隔(分钟)
|
||||
{t('checkInterval')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-check-interval'
|
||||
@@ -169,7 +172,7 @@ export function PagesSourceGitHubFields({
|
||||
}}
|
||||
/>
|
||||
<FieldDescription id='pages-github-check-interval-description'>
|
||||
可设置为 5–1440 分钟。
|
||||
{t('checkIntervalHint')}
|
||||
</FieldDescription>
|
||||
<FieldError id='pages-github-check-interval-error'>
|
||||
{errors.checkInterval}
|
||||
@@ -180,7 +183,7 @@ export function PagesSourceGitHubFields({
|
||||
|
||||
<Field data-invalid={Boolean(errors.assetName)}>
|
||||
<FieldLabel htmlFor='pages-github-asset'>
|
||||
Release Asset 文件名
|
||||
{t('assetName')}
|
||||
</FieldLabel>
|
||||
<Input
|
||||
id='pages-github-asset'
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -32,6 +33,7 @@ interface ProjectSettingsCardProps {
|
||||
}
|
||||
|
||||
export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
const t = useTranslations('pages');
|
||||
const queryClient = useQueryClient();
|
||||
const form = usePagesProjectForm(project);
|
||||
|
||||
@@ -45,7 +47,7 @@ export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
return PagesService.updateProject(project.id, payload);
|
||||
},
|
||||
onSuccess: async () => {
|
||||
toast.success('项目已更新');
|
||||
toast.success(t('updated'));
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({ queryKey: projectsQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
@@ -57,7 +59,7 @@ export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
]);
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(error instanceof Error ? error.message : '保存失败');
|
||||
toast.error(error instanceof Error ? error.message : t('saveFailed'));
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,10 +67,8 @@ export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
<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>
|
||||
<CardTitle className='text-base'>{t('editTitle')}</CardTitle>
|
||||
<CardDescription>{t('settingsDesc')}</CardDescription>
|
||||
</div>
|
||||
<div className='flex shrink-0 flex-wrap gap-2'>
|
||||
<Button
|
||||
@@ -78,7 +78,7 @@ export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
disabled={!form.formState.isDirty || mutation.isPending}
|
||||
onClick={() => form.reset(toFormValues(project))}
|
||||
>
|
||||
重置
|
||||
{t('reset')}
|
||||
</Button>
|
||||
<Button
|
||||
type='submit'
|
||||
@@ -89,10 +89,10 @@ export function ProjectSettingsCard({ project }: ProjectSettingsCardProps) {
|
||||
{mutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className='mr-1 size-4 animate-spin' />
|
||||
保存中...
|
||||
{t('saving')}
|
||||
</>
|
||||
) : (
|
||||
'保存修改'
|
||||
t('saveChanges')
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,6 +5,7 @@ import { Suspense } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { ArrowLeft, FileText } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -38,6 +39,7 @@ function PagesDetailPageFallback() {
|
||||
}
|
||||
|
||||
function PagesDetailRoute() {
|
||||
const t = useTranslations('pages');
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
@@ -66,7 +68,7 @@ function PagesDetailRoute() {
|
||||
if (!validProjectId) {
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<EmptyStateWithBorder description='缺少有效的 Pages 项目 ID。' />
|
||||
<EmptyStateWithBorder description={t('detail.invalidId')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -74,7 +76,10 @@ function PagesDetailRoute() {
|
||||
if (projectQuery.isLoading) {
|
||||
return (
|
||||
<div className='w-full px-1 py-6'>
|
||||
<LoadingStateWithBorder icon={FileText} description='加载项目详情...' />
|
||||
<LoadingStateWithBorder
|
||||
icon={FileText}
|
||||
description={t('detail.loading')}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -87,7 +92,7 @@ function PagesDetailRoute() {
|
||||
message={
|
||||
projectQuery.error instanceof Error
|
||||
? projectQuery.error.message
|
||||
: '项目详情加载失败'
|
||||
: t('detail.loadFailed')
|
||||
}
|
||||
onRetry={() => void projectQuery.refetch()}
|
||||
/>
|
||||
@@ -103,10 +108,10 @@ function PagesDetailRoute() {
|
||||
<Button variant='ghost' size='sm' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
{t('detail.back')}
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder description='Pages 项目不存在或已被删除。' />
|
||||
<EmptyStateWithBorder description={t('detail.notFound')} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -117,7 +122,7 @@ function PagesDetailRoute() {
|
||||
<Button variant='ghost' size='sm' className='self-start' asChild>
|
||||
<Link href='/pages'>
|
||||
<ArrowLeft data-icon='inline-start' />
|
||||
返回列表
|
||||
{t('detail.back')}
|
||||
</Link>
|
||||
</Button>
|
||||
<div className='flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between'>
|
||||
@@ -128,11 +133,12 @@ function PagesDetailRoute() {
|
||||
{project.name}
|
||||
</h1>
|
||||
<Badge variant={project.enabled ? 'secondary' : 'outline'}>
|
||||
{project.enabled ? '已启用' : '已停用'}
|
||||
{project.enabled ? t('enabled') : t('disabled')}
|
||||
</Badge>
|
||||
</div>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
{project.slug} · {project.deployment_count} 个部署
|
||||
{project.slug} ·{' '}
|
||||
{t('detail.deploymentCount', { count: project.deployment_count })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -144,13 +150,13 @@ function PagesDetailRoute() {
|
||||
value='deployments'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
部署
|
||||
{t('detail.tabDeployments')}
|
||||
</TabsTrigger>
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
设置
|
||||
{t('detail.tabSettings')}
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import { useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { FileText, Plus, RefreshCw } from 'lucide-react';
|
||||
import { useTranslations } from 'next-intl';
|
||||
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
@@ -15,6 +16,8 @@ import { ProjectListItem } from './components/project-list-item';
|
||||
import { projectsQueryKey } from './components/pages-utils';
|
||||
|
||||
export default function PagesPage() {
|
||||
const t = useTranslations('pages');
|
||||
const tCommon = useTranslations('common');
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
|
||||
const projectsQuery = useQuery({
|
||||
@@ -28,11 +31,10 @@ export default function PagesPage() {
|
||||
<div className='flex items-center gap-2'>
|
||||
<FileText className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>Pages</h1>
|
||||
<p className='text-sm text-muted-foreground'>
|
||||
边缘静态站点托管,上传静态资源部署包并在代理规则中选择 Pages
|
||||
上游。
|
||||
</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{t('title')}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>{t('subtitle')}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className='flex gap-2'>
|
||||
@@ -45,11 +47,11 @@ export default function PagesPage() {
|
||||
<RefreshCw
|
||||
className={`size-3.5 mr-1 ${projectsQuery.isFetching ? 'animate-spin' : ''}`}
|
||||
/>
|
||||
刷新
|
||||
{t('refresh')}
|
||||
</Button>
|
||||
<Button size='sm' onClick={() => setEditorOpen(true)}>
|
||||
<Plus className='size-3.5 mr-1' />
|
||||
新建项目
|
||||
{t('create')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -59,7 +61,7 @@ export default function PagesPage() {
|
||||
message={
|
||||
projectsQuery.error instanceof Error
|
||||
? projectsQuery.error.message
|
||||
: '加载失败'
|
||||
: tCommon('loadFailed')
|
||||
}
|
||||
onRetry={() => void projectsQuery.refetch()}
|
||||
/>
|
||||
@@ -70,8 +72,8 @@ export default function PagesPage() {
|
||||
<LoadingStateWithBorder />
|
||||
) : (projectsQuery.data ?? []).length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
title='还没有 Pages 项目'
|
||||
description='先创建一个项目,再上传静态资源部署包。'
|
||||
title={t('emptyTitle')}
|
||||
description={t('emptyDesc')}
|
||||
/>
|
||||
) : (
|
||||
(projectsQuery.data ?? []).map((project) => (
|
||||
|
||||
Reference in New Issue
Block a user