mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-14 02:46: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>
|
||||
|
||||
Reference in New Issue
Block a user