feat(frontend): 控制台 next-intl 中英双语

接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
ryan
2026-08-16 16:49:16 +08:00
parent 64fbaa7ef1
commit 5a8722ff07
219 changed files with 21437 additions and 5212 deletions
@@ -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>
+16 -10
View File
@@ -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>
+12 -10
View File
@@ -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) => (