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
@@ -31,9 +31,11 @@ import {
TlsCertificateService,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
type AcmeApplyFormValues,
acmeApplySchema,
createAcmeApplySchema,
defaultAcmeApplyValues,
} from './schemas';
import { getErrorMessage } from './website-utils';
@@ -57,6 +59,7 @@ export function CertificateApplyDialog({
mode = 'create',
certificate,
}: CertificateApplyDialogProps) {
const t = useTranslations('certificates');
const queryClient = useQueryClient();
const [error, setError] = useState('');
const [showAdvanced, setShowAdvanced] = useState(false);
@@ -74,7 +77,7 @@ export function CertificateApplyDialog({
});
const form = useForm<AcmeApplyFormValues>({
resolver: zodResolver(acmeApplySchema),
resolver: zodResolver(createAcmeApplySchema(t)),
defaultValues: defaultAcmeApplyValues,
});
@@ -141,22 +144,22 @@ export function CertificateApplyDialog({
onApplied?.(result);
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const title =
mode === 'edit-acme'
? '编辑并重新申请证书'
? t('applyEditTitle')
: mode === 'convert-upload'
? '转换为申请证书'
: '申请证书';
? t('applyConvertTitle')
: t('applyTitle');
const description =
mode === 'edit-acme'
? '修改 ACME 证书配置。保存后将使用新配置重新申请证书。'
? t('applyEditDesc')
: mode === 'convert-upload'
? '填写 ACME 申请资料。申请成功后,当前手动证书会原地转换为可自动续签的申请证书。'
: "使用 ACME (Let's Encrypt 等) 自动申请和续期证书,支持通配符域名。";
? t('applyConvertDesc')
: t('applyDesc');
return (
<Dialog open={open} onOpenChange={onOpenChange}>
@@ -177,33 +180,33 @@ export function CertificateApplyDialog({
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Input placeholder='例如:主站证书' {...form.register('name')} />
<Label>{t('name')}</Label>
<Input placeholder={t('namePlaceholder')} {...form.register('name')} />
</div>
<div className='space-y-2'>
<Label>主域名</Label>
<Label>{t('primaryDomain')}</Label>
<Input
placeholder='example.com 或 *.example.com'
placeholder={t('primaryDomainPlaceholder')}
{...form.register('primary_domain')}
/>
</div>
</div>
<div className='space-y-2'>
<Label>其他域名</Label>
<Label>{t('otherDomains')}</Label>
<Textarea
rows={3}
placeholder='example.net'
{...form.register('other_domains')}
/>
<p className='text-xs text-muted-foreground'>
每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。
{t('otherDomainsHint')}
</p>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>DNS 服务商账号</Label>
<Label>{t('dnsAccount')}</Label>
<Select
value={String(form.watch('dns_account_id') || 0)}
onValueChange={(value) =>
@@ -213,10 +216,10 @@ export function CertificateApplyDialog({
}
>
<SelectTrigger>
<SelectValue placeholder='请选择 DNS 账号' />
<SelectValue placeholder={t('selectDnsAccount')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='0'>请选择 DNS 账号</SelectItem>
<SelectItem value='0'>{t('selectDnsAccount')}</SelectItem>
{dnsAccountsQuery.data?.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.name} ({account.type})
@@ -227,7 +230,7 @@ export function CertificateApplyDialog({
</div>
<div className='space-y-2'>
<Label>密钥算法</Label>
<Label>{t('keyAlgorithm')}</Label>
<Select
value={form.watch('key_algorithm')}
onValueChange={(value) => form.setValue('key_algorithm', value)}
@@ -246,18 +249,18 @@ export function CertificateApplyDialog({
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
placeholder='可选,用于记录证书用途。'
placeholder={t('remarkPlaceholder')}
{...form.register('remark')}
/>
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>开启自动续签</p>
<p className='text-sm font-medium'>{t('autoRenew')}</p>
<p className='text-xs text-muted-foreground'>
开启后,将在证书过期前 7 天自动续期。
{t('autoRenewDesc')}
</p>
</div>
<Switch
@@ -275,7 +278,7 @@ export function CertificateApplyDialog({
className='w-full justify-between rounded-none'
onClick={() => setShowAdvanced((current) => !current)}
>
高级选项
{t('advanced')}
<ChevronDown
className={`size-4 transition-transform ${showAdvanced ? 'rotate-180' : ''}`}
/>
@@ -284,16 +287,16 @@ export function CertificateApplyDialog({
<div className='space-y-4 border-t p-3'>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>DNS 验证服务器 1</Label>
<Label>{t('dnsServer1')}</Label>
<Input
placeholder='为空则使用默认权威 DNS'
placeholder={t('dnsServerPlaceholder')}
{...form.register('dns1')}
/>
</div>
<div className='space-y-2'>
<Label>DNS 验证服务器 2</Label>
<Label>{t('dnsServer2')}</Label>
<Input
placeholder='为空则使用默认权威 DNS'
placeholder={t('dnsServerPlaceholder')}
{...form.register('dns2')}
/>
</div>
@@ -301,9 +304,9 @@ export function CertificateApplyDialog({
<div className='grid gap-4 md:grid-cols-2'>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>跳过 CNAME 检查</p>
<p className='text-sm font-medium'>{t('skipCname')}</p>
<p className='text-xs text-muted-foreground'>
在执行 DNS-01 验证时不追踪 CNAME 记录。
{t('skipCnameDesc')}
</p>
</div>
<Switch
@@ -315,9 +318,9 @@ export function CertificateApplyDialog({
</div>
<div className='flex items-center justify-between rounded-lg border px-3 py-2'>
<div>
<p className='text-sm font-medium'>跳过 DNS 前置检查</p>
<p className='text-sm font-medium'>{t('skipDns')}</p>
<p className='text-xs text-muted-foreground'>
直接请求 Let&apos;s Encrypt 验证而不做本地校验。
{t('skipDnsDesc')}
</p>
</div>
<Switch
@@ -336,14 +339,14 @@ export function CertificateApplyDialog({
{applyMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
提交中...
{t('submitting')}
</>
) : mode === 'edit-acme' ? (
'保存并申请'
t('saveAndApply')
) : mode === 'convert-upload' ? (
'开始转换'
t('startConvert')
) : (
'开始申请'
t('startApply')
)}
</Button>
</form>
@@ -19,6 +19,8 @@ import { LoadingStateWithBorder } from '@/components/layout/loading';
import { TlsCertificateService } from '@/lib/services/openflare';
import { formatDateTime } from '@/lib/utils';
import { useTranslations } from 'next-intl';
import { WebsiteStatusBadge } from './status-badge';
import { getCertificateStatus, getErrorMessage } from './website-utils';
@@ -39,6 +41,7 @@ export function CertificateDetailDialog({
onDelete,
deleting = false,
}: CertificateDetailDialogProps) {
const t = useTranslations('certificates');
const certificateQuery = useQuery({
queryKey: ['openflare', 'tls-certificates', 'detail', certificateId],
queryFn: () => TlsCertificateService.getById(certificateId as number),
@@ -53,7 +56,7 @@ export function CertificateDetailDialog({
const certificate = certificateQuery.data;
const content = contentQuery.data;
const status = certificate ? getCertificateStatus(certificate) : null;
const status = certificate ? getCertificateStatus(certificate, t) : null;
const loading = certificateQuery.isLoading || contentQuery.isLoading;
const hasError = certificateQuery.isError || contentQuery.isError;
@@ -62,7 +65,7 @@ export function CertificateDetailDialog({
await navigator.clipboard.writeText(value);
toast.success(message);
} catch (error) {
toast.error(getErrorMessage(error));
toast.error(getErrorMessage(error, t('requestFailed')));
}
};
@@ -70,35 +73,34 @@ export function CertificateDetailDialog({
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>证书详情</DialogTitle>
<DialogDescription>
查看证书元信息、备注以及当前保存的 PEM 内容。
</DialogDescription>
<DialogTitle>{t('detailTitle')}</DialogTitle>
<DialogDescription>{t('detailDesc')}</DialogDescription>
</DialogHeader>
{loading ? (
<LoadingStateWithBorder description='加载证书详情中...' />
<LoadingStateWithBorder description={t('loadingDetail')} />
) : hasError ? (
<ErrorInline
message={getErrorMessage(
certificateQuery.error ?? contentQuery.error,
t('requestFailed'),
)}
className='justify-center'
/>
) : !certificate || !content ? (
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
<EmptyStateWithBorder description={t('notFound')} />
) : (
<div className='space-y-4'>
<div className='grid gap-3 md:grid-cols-2'>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
证书名称
{t('name')}
</p>
<p className='mt-1 text-sm'>{certificate.name}</p>
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
状态
{t('status')}
</p>
<div className='mt-1'>
{status ? (
@@ -111,7 +113,7 @@ export function CertificateDetailDialog({
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
生效时间
{t('notBefore')}
</p>
<p className='mt-1 text-sm'>
{formatDateTime(certificate.not_before)}
@@ -119,7 +121,7 @@ export function CertificateDetailDialog({
</div>
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
到期时间
{t('notAfter')}
</p>
<p className='mt-1 text-sm'>
{formatDateTime(certificate.not_after)}
@@ -129,26 +131,28 @@ export function CertificateDetailDialog({
<div className='rounded-lg border p-3'>
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
备注
{t('remark')}
</p>
<p className='mt-1 text-sm'>
{certificate.remark || t('noRemark')}
</p>
<p className='mt-1 text-sm'>{certificate.remark || '暂无备注'}</p>
</div>
<div className='space-y-3'>
<div>
<div className='mb-2 flex items-center justify-between'>
<p className='text-sm font-medium'>证书 PEM</p>
<p className='text-sm font-medium'>{t('certPem')}</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(content.cert_pem, '证书 PEM 已复制')
void handleCopy(content.cert_pem, t('certPemCopied'))
}
>
<Copy className='mr-1 size-3' />
复制
{t('copy')}
</Button>
</div>
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
@@ -157,18 +161,18 @@ export function CertificateDetailDialog({
</div>
<div>
<div className='mb-2 flex items-center justify-between'>
<p className='text-sm font-medium'>私钥 PEM</p>
<p className='text-sm font-medium'>{t('keyPem')}</p>
<Button
type='button'
variant='outline'
size='sm'
className='h-7 text-xs'
onClick={() =>
void handleCopy(content.key_pem, '私钥 PEM 已复制')
void handleCopy(content.key_pem, t('keyPemCopied'))
}
>
<Copy className='mr-1 size-3' />
复制
{t('copy')}
</Button>
</div>
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
@@ -185,10 +189,10 @@ export function CertificateDetailDialog({
variant='outline'
onClick={() => onOpenChange(false)}
>
关闭
{t('close')}
</Button>
<Button type='button' onClick={onEdit} disabled={!certificate}>
编辑证书
{t('editCert')}
</Button>
<Button
type='button'
@@ -199,10 +203,10 @@ export function CertificateDetailDialog({
{deleting ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
删除中...
{t('deleting')}
</>
) : (
'删除证书'
t('deleteCert')
)}
</Button>
</DialogFooter>
@@ -24,10 +24,12 @@ import { Textarea } from '@/components/ui/textarea';
import type { TlsCertificateItem } from '@/lib/services/openflare';
import { TlsCertificateService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
createManualImportSchema,
defaultManualImportValues,
type ManualImportFormValues,
manualImportSchema,
} from './schemas';
import { getErrorMessage, toManualPayload } from './website-utils';
@@ -48,9 +50,11 @@ export function CertificateEditorDialog({
onSaved,
onConvert,
}: CertificateEditorDialogProps) {
const t = useTranslations('certificates');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const form = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
resolver: zodResolver(createManualImportSchema(t)),
defaultValues: defaultManualImportValues,
});
@@ -103,21 +107,19 @@ export function CertificateEditorDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
<DialogHeader>
<DialogTitle>编辑证书</DialogTitle>
<DialogDescription>
可以修改证书名称、备注,以及重新上传 PEM 证书和私钥内容。
</DialogDescription>
<DialogTitle>{t('editTitle')}</DialogTitle>
<DialogDescription>{t('editDesc')}</DialogDescription>
</DialogHeader>
{certificateQuery.isLoading ? (
<LoadingStateWithBorder description='加载证书内容中...' />
<LoadingStateWithBorder description={t('loadingContent')} />
) : certificateQuery.isError ? (
<ErrorInline
message={getErrorMessage(certificateQuery.error)}
message={getErrorMessage(certificateQuery.error, t('requestFailed'))}
className='justify-center'
/>
) : !certificateQuery.data ? (
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
<EmptyStateWithBorder description={t('notFound')} />
) : (
<form
id='certificate-editor-form'
@@ -126,23 +128,23 @@ export function CertificateEditorDialog({
>
{updateMutation.isError ? (
<p className='text-sm text-destructive'>
{getErrorMessage(updateMutation.error)}
{getErrorMessage(updateMutation.error, t('requestFailed'))}
</p>
) : null}
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input {...form.register('name')} />
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input {...form.register('remark')} />
</div>
</div>
<div className='space-y-2'>
<Label>证书 PEM</Label>
<Label>{t('certPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
{...form.register('cert_pem')}
@@ -150,7 +152,7 @@ export function CertificateEditorDialog({
</div>
<div className='space-y-2'>
<Label>私钥 PEM</Label>
<Label>{t('keyPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
{...form.register('key_pem')}
@@ -170,22 +172,22 @@ export function CertificateEditorDialog({
}}
disabled={updateMutation.isPending}
>
转换来源
{t('convertSource')}
</Button>
) : null}
</div>
<div className='flex gap-2'>
<Button type='button' variant='outline' onClick={handleClose}>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={updateMutation.isPending}>
{updateMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
保存中...
{t('saving')}
</>
) : (
'保存证书'
t('saveCert')
)}
</Button>
</div>
@@ -21,12 +21,14 @@ import { Textarea } from '@/components/ui/textarea';
import type { TlsCertificateItem } from '@/lib/services/openflare';
import { TlsCertificateService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
createManualImportSchema,
defaultFileImportValues,
defaultManualImportValues,
type FileImportFormValues,
type ManualImportFormValues,
manualImportSchema,
} from './schemas';
import {
getErrorMessage,
@@ -47,6 +49,7 @@ export function CertificateImportDialog({
onOpenChange,
onImported,
}: CertificateImportDialogProps) {
const t = useTranslations('certificates');
const queryClient = useQueryClient();
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
const [error, setError] = useState('');
@@ -58,7 +61,7 @@ export function CertificateImportDialog({
const [fileInputNonce, setFileInputNonce] = useState(0);
const manualForm = useForm<ManualImportFormValues>({
resolver: zodResolver(manualImportSchema),
resolver: zodResolver(createManualImportSchema(t)),
defaultValues: defaultManualImportValues,
});
@@ -100,20 +103,20 @@ export function CertificateImportDialog({
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const fileImportMutation = useMutation({
mutationFn: (values: FileImportFormValues) =>
TlsCertificateService.importFile(
toFilePayload(values, certFile, keyFile),
toFilePayload(values, certFile, keyFile, t('selectFiles')),
),
onSuccess: async (certificate) => {
await invalidateQueries();
onImported?.(certificate);
handleClose();
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const handleManualSubmit = manualForm.handleSubmit((values) => {
@@ -134,10 +137,8 @@ export function CertificateImportDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className='max-w-2xl'>
<DialogHeader>
<DialogTitle>添加证书</DialogTitle>
<DialogDescription>
支持手动粘贴 PEM 或上传证书文件。导入成功后可立即在网站表单里选择。
</DialogDescription>
<DialogTitle>{t('importTitle')}</DialogTitle>
<DialogDescription>{t('importDesc')}</DialogDescription>
</DialogHeader>
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
@@ -147,15 +148,15 @@ export function CertificateImportDialog({
onValueChange={(value) => setImportMode(value as 'manual' | 'file')}
>
<TabsList className='grid w-full grid-cols-2'>
<TabsTrigger value='manual'>手动导入</TabsTrigger>
<TabsTrigger value='file'>文件导入</TabsTrigger>
<TabsTrigger value='manual'>{t('manualImport')}</TabsTrigger>
<TabsTrigger value='file'>{t('fileImport')}</TabsTrigger>
</TabsList>
<TabsContent value='manual' className='space-y-4'>
<form className='space-y-4' onSubmit={handleManualSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input
placeholder='example-com'
{...manualForm.register('name')}
@@ -167,16 +168,16 @@ export function CertificateImportDialog({
) : null}
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
placeholder='例如:主站生产证书'
placeholder={t('remarkProdPlaceholder')}
{...manualForm.register('remark')}
/>
</div>
</div>
<div className='space-y-2'>
<Label>证书 PEM</Label>
<Label>{t('certPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
placeholder='-----BEGIN CERTIFICATE-----'
@@ -185,7 +186,7 @@ export function CertificateImportDialog({
</div>
<div className='space-y-2'>
<Label>私钥 PEM</Label>
<Label>{t('keyPem')}</Label>
<Textarea
className='min-h-32 font-mono text-xs'
placeholder='-----BEGIN PRIVATE KEY-----'
@@ -197,10 +198,10 @@ export function CertificateImportDialog({
{pending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
导入中...
{t('importing')}
</>
) : (
'导入证书'
t('importCert')
)}
</Button>
</form>
@@ -210,7 +211,7 @@ export function CertificateImportDialog({
<form className='space-y-4' onSubmit={handleFileSubmit}>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书名称</Label>
<Label>{t('name')}</Label>
<Input
value={fileForm.name}
onChange={(event) =>
@@ -223,7 +224,7 @@ export function CertificateImportDialog({
/>
</div>
<div className='space-y-2'>
<Label>备注</Label>
<Label>{t('remark')}</Label>
<Input
value={fileForm.remark}
onChange={(event) =>
@@ -232,14 +233,14 @@ export function CertificateImportDialog({
remark: event.target.value,
}))
}
placeholder='例如:泛域名生产证书'
placeholder={t('remarkWildcardPlaceholder')}
/>
</div>
</div>
<div className='grid gap-4 md:grid-cols-2'>
<div className='space-y-2'>
<Label>证书文件</Label>
<Label>{t('certFile')}</Label>
<Input
key={`cert-${fileInputNonce}`}
type='file'
@@ -250,12 +251,12 @@ export function CertificateImportDialog({
/>
<p className='text-xs text-muted-foreground'>
{certFile
? `已选择:${certFile.name}`
: '请选择 PEM/CRT 文件'}
? t('fileSelected', { name: certFile.name })
: t('selectCertFile')}
</p>
</div>
<div className='space-y-2'>
<Label>私钥文件</Label>
<Label>{t('keyFile')}</Label>
<Input
key={`key-${fileInputNonce}`}
type='file'
@@ -266,8 +267,8 @@ export function CertificateImportDialog({
/>
<p className='text-xs text-muted-foreground'>
{keyFile
? `已选择:${keyFile.name}`
: '请选择 KEY/PEM 文件'}
? t('fileSelected', { name: keyFile.name })
: t('selectKeyFile')}
</p>
</div>
</div>
@@ -277,10 +278,10 @@ export function CertificateImportDialog({
{pending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
上传中...
{t('uploading')}
</>
) : (
'上传文件'
t('uploadFiles')
)}
</Button>
<Button
@@ -289,7 +290,7 @@ export function CertificateImportDialog({
disabled={pending}
onClick={resetFileForm}
>
清空文件
{t('clearFiles')}
</Button>
</div>
</form>
@@ -28,17 +28,17 @@ import {
import type { DnsAccountMutationPayload } from '@/lib/services/openflare';
import { DnsAccountService } from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import { getErrorMessage } from './website-utils';
const dnsAccountsQueryKey = ['openflare', 'dns-accounts'];
const dnsAccountSchema = z.object({
name: z.string().trim().min(1, '请输入名称').max(255),
type: z.string().min(1),
authorization: z.string().trim().min(1, '请输入 Token'),
});
type DnsAccountFormValues = z.infer<typeof dnsAccountSchema>;
type DnsAccountFormValues = {
name: string;
type: string;
authorization: string;
};
interface DnsAccountCreateDialogProps {
open: boolean;
@@ -51,8 +51,15 @@ export function DnsAccountCreateDialog({
onOpenChange,
onCreated,
}: DnsAccountCreateDialogProps) {
const t = useTranslations('dnsAccounts');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const [error, setError] = useState('');
const dnsAccountSchema = z.object({
name: z.string().trim().min(1, t('nameRequired')).max(255),
type: z.string().min(1),
authorization: z.string().trim().min(1, t('tokenRequired')),
});
const form = useForm<DnsAccountFormValues>({
resolver: zodResolver(dnsAccountSchema),
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
@@ -68,7 +75,7 @@ export function DnsAccountCreateDialog({
onCreated?.();
onOpenChange(false);
},
onError: (err) => setError(getErrorMessage(err)),
onError: (err) => setError(getErrorMessage(err, t('requestFailed'))),
});
const onSubmit = form.handleSubmit((values) => {
@@ -90,19 +97,17 @@ export function DnsAccountCreateDialog({
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent>
<DialogHeader>
<DialogTitle>添加 DNS 账号</DialogTitle>
<DialogDescription>
统一管理 DNS 服务商账号,用于 ACME 证书的 DNS 验证申请。
</DialogDescription>
<DialogTitle>{t('createTitle')}</DialogTitle>
<DialogDescription>{t('createDesc')}</DialogDescription>
</DialogHeader>
<form className='space-y-4' onSubmit={onSubmit}>
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
<div className='space-y-2'>
<Label>账号名称</Label>
<Label>{t('accountName')}</Label>
<Input
placeholder='Cloudflare 邮箱账号'
placeholder={t('accountNamePlaceholder')}
{...form.register('name')}
/>
{form.formState.errors.name ? (
@@ -113,7 +118,7 @@ export function DnsAccountCreateDialog({
</div>
<div className='space-y-2'>
<Label>DNS 服务商</Label>
<Label>{t('provider')}</Label>
<Select
value={form.watch('type')}
onValueChange={(value) => form.setValue('type', value)}
@@ -131,7 +136,7 @@ export function DnsAccountCreateDialog({
<Label>API Token</Label>
<Input
{...form.register('authorization')}
placeholder='请勿使用 Global API Key'
placeholder={t('tokenPlaceholder')}
/>
{form.formState.errors.authorization ? (
<p className='text-xs text-destructive'>
@@ -142,16 +147,16 @@ export function DnsAccountCreateDialog({
<DialogFooter>
<Button type='button' variant='outline' onClick={handleClose}>
取消
{tc('cancel')}
</Button>
<Button type='submit' disabled={createMutation.isPending}>
{createMutation.isPending ? (
<>
<Loader2 className='mr-1 size-3.5 animate-spin' />
提交中...
{t('submitting')}
</>
) : (
'提交'
t('submit')
)}
</Button>
</DialogFooter>
@@ -35,18 +35,19 @@ import {
type ZoneItem,
} from '@/lib/services/openflare';
import { useTranslations } from 'next-intl';
import {
previewZoneDomainInput,
resolveZoneDomainInput,
type ZoneDomainInputError,
} from './resolve-zone-domain-input';
const schema = z.object({
zone_id: z.string().min(1, '请选择 Zone'),
domain_input: z.string().trim().min(1, '请输入域名'),
cert_id: z.string(),
});
type Values = z.infer<typeof schema>;
type Values = {
zone_id: string;
domain_input: string;
cert_id: string;
};
export function QuickCreateZoneDomainDialog({
open,
@@ -64,7 +65,19 @@ export function QuickCreateZoneDomainDialog({
zones?: ZoneItem[];
onCreated(domain: ZoneDomainItem): void | Promise<void>;
}) {
const t = useTranslations('websites');
const tc = useTranslations('common');
const queryClient = useQueryClient();
const domainErrorMessage = (
error: ZoneDomainInputError | undefined,
root?: string,
) => {
if (!error) return t('invalidFormat');
if (error === 'mustBelongToZone') {
return t('mustBelongToZone', { root: root ?? '' });
}
return t(error);
};
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
queryFn: () => ZoneService.list(),
@@ -97,6 +110,11 @@ export function QuickCreateZoneDomainDialog({
);
}, [fixedZoneId, fixedZoneRoot, zones]);
const schema = z.object({
zone_id: z.string().min(1, t('selectZone')),
domain_input: z.string().trim().min(1, t('enterDomain')),
cert_id: z.string(),
});
const form = useForm<Values>({
resolver: zodResolver(schema),
defaultValues: {
@@ -136,11 +154,11 @@ export function QuickCreateZoneDomainDialog({
const zoneId = Number(values.zone_id);
const zone = fixedZone ?? zones.find((item) => item.id === zoneId);
if (!zone) {
throw new Error('请选择 Zone');
throw new Error(t('selectZone'));
}
const resolved = resolveZoneDomainInput(values.domain_input, zone.domain);
if (resolved.error || !resolved.domain) {
throw new Error(resolved.error || '域名格式不合法');
throw new Error(domainErrorMessage(resolved.error, zone.domain));
}
return ZoneDomainService.create(zone.id, {
domain: resolved.domain,
@@ -148,7 +166,7 @@ export function QuickCreateZoneDomainDialog({
});
},
onSuccess: async (domain) => {
toast.success('域名已添加', { description: domain.domain });
toast.success(t('domainAdded'), { description: domain.domain });
await Promise.all([
onCreated(domain),
queryClient.invalidateQueries({ queryKey: zoneQueryKey }),
@@ -159,17 +177,15 @@ export function QuickCreateZoneDomainDialog({
onOpenChange(false);
},
onError: (error) =>
toast.error(error instanceof Error ? error.message : '添加失败'),
toast.error(error instanceof Error ? error.message : t('addFailed')),
});
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>快捷新增域名</DialogTitle>
<DialogDescription>
选择 Zone 后输入:子域名称(如 api)、@(根域)或完整 FQDN。
</DialogDescription>
<DialogTitle>{t('quickCreateTitle')}</DialogTitle>
<DialogDescription>{t('quickCreateDesc')}</DialogDescription>
</DialogHeader>
<form
@@ -177,7 +193,7 @@ export function QuickCreateZoneDomainDialog({
className='space-y-4'
onSubmit={form.handleSubmit((values) => {
if (!selectedZone) {
form.setError('zone_id', { message: '请选择 Zone' });
form.setError('zone_id', { message: t('selectZone') });
return;
}
const resolved = resolveZoneDomainInput(
@@ -185,7 +201,12 @@ export function QuickCreateZoneDomainDialog({
selectedZone.domain,
);
if (resolved.error) {
form.setError('domain_input', { message: resolved.error });
form.setError('domain_input', {
message: domainErrorMessage(
resolved.error,
selectedZone.domain,
),
});
return;
}
mutation.mutate(values);
@@ -199,7 +220,7 @@ export function QuickCreateZoneDomainDialog({
onValueChange={(value) => form.setValue('zone_id', value)}
>
<SelectTrigger>
<SelectValue placeholder='选择注册根域' />
<SelectValue placeholder={t('selectRegisteredRoot')} />
</SelectTrigger>
<SelectContent>
{zones.map((zone) => (
@@ -217,7 +238,7 @@ export function QuickCreateZoneDomainDialog({
</div>
) : (
<div className='rounded-md border bg-muted/30 px-3 py-2 text-sm'>
Zone:
{t('zoneLabel')}
<span className='ml-1 font-medium'>
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
</span>
@@ -225,28 +246,38 @@ export function QuickCreateZoneDomainDialog({
)}
<div className='space-y-1.5'>
<Label htmlFor='domain-input'>域名</Label>
<Label htmlFor='domain-input'>{t('domain')}</Label>
<Input
id='domain-input'
placeholder={
selectedZone
? `api 或 @ 或 api.${selectedZone.domain}`
: 'api / @ / 完整域名'
? t('domainPlaceholderWithZone', {
domain: selectedZone.domain,
})
: t('domainPlaceholder')
}
{...form.register('domain_input')}
/>
{preview ? (
<p className='text-xs text-muted-foreground'>
将创建:
{t('willCreate')}
<code className='ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]'>
{preview}
</code>
</p>
) : (
<p className='text-xs text-muted-foreground'>
示例:输入 <code className='font-mono'>api</code> →{' '}
<code className='font-mono'>api.zone.com</code>;
<code className='font-mono'>@</code> → 根域本身
{t.rich('domainExample', {
api: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
fqdn: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
apex: (chunks) => (
<code className='font-mono'>{chunks}</code>
),
})}
</p>
)}
{form.formState.errors.domain_input ? (
@@ -257,7 +288,7 @@ export function QuickCreateZoneDomainDialog({
</div>
<div className='space-y-1.5'>
<Label>证书(可选)</Label>
<Label>{t('certOptional')}</Label>
<Select
value={form.watch('cert_id') || '__none'}
onValueChange={(value) =>
@@ -265,10 +296,10 @@ export function QuickCreateZoneDomainDialog({
}
>
<SelectTrigger>
<SelectValue placeholder='不绑定证书' />
<SelectValue placeholder={t('noCert')} />
</SelectTrigger>
<SelectContent>
<SelectItem value='__none'>不绑定证书</SelectItem>
<SelectItem value='__none'>{t('noCert')}</SelectItem>
{(certificatesQuery.data ?? []).map((certificate) => (
<SelectItem
key={certificate.id}
@@ -284,7 +315,7 @@ export function QuickCreateZoneDomainDialog({
<DialogFooter>
<Button variant='outline' onClick={() => onOpenChange(false)}>
取消
{tc('cancel')}
</Button>
<Button
type='submit'
@@ -294,7 +325,7 @@ export function QuickCreateZoneDomainDialog({
{mutation.isPending ? (
<Loader2 className='mr-1 size-4 animate-spin' />
) : null}
添加域名
{t('addDomain')}
</Button>
</DialogFooter>
</DialogContent>
@@ -5,21 +5,29 @@
* - single label `name` → `name.example.com`
* - full FQDN must equal the root or end with `.root`
*/
export type ZoneDomainInputError =
| 'selectZone'
| 'enterDomain'
| 'noWildcard'
| 'invalidFormat'
| 'invalidSubdomain'
| 'mustBelongToZone';
export function resolveZoneDomainInput(
rawInput: string,
zoneRoot: string,
): { domain: string; error?: string } {
): { domain: string; error?: ZoneDomainInputError } {
const input = rawInput.trim().toLowerCase();
const root = zoneRoot.trim().toLowerCase();
if (!root) {
return { domain: '', error: '请先选择 Zone' };
return { domain: '', error: 'selectZone' };
}
if (!input) {
return { domain: '', error: '请输入域名' };
return { domain: '', error: 'enterDomain' };
}
if (input.includes('*')) {
return { domain: '', error: 'Zone 域名不支持通配符' };
return { domain: '', error: 'noWildcard' };
}
if (
input.includes('://') ||
@@ -27,7 +35,7 @@ export function resolveZoneDomainInput(
input.includes('?') ||
input.includes('#')
) {
return { domain: '', error: '域名格式不合法' };
return { domain: '', error: 'invalidFormat' };
}
if (input === '@') {
@@ -37,7 +45,7 @@ export function resolveZoneDomainInput(
// Short label: no dots → prefix under zone root
if (!input.includes('.')) {
if (!/^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(input)) {
return { domain: '', error: '子域名称格式不合法' };
return { domain: '', error: 'invalidSubdomain' };
}
return { domain: `${input}.${root}` };
}
@@ -47,7 +55,7 @@ export function resolveZoneDomainInput(
return { domain: input };
}
return { domain: '', error: `域名必须属于 Zone ${root}` };
return { domain: '', error: 'mustBelongToZone' };
}
/** Live preview string for the input helper text. */
@@ -1,17 +1,23 @@
import { z } from 'zod';
export const manualImportSchema = z.object({
name: z
.string()
.trim()
.min(1, '请输入证书名称')
.max(255, '证书名称不能超过 255 个字符'),
cert_pem: z.string().trim().min(1, '请输入证书 PEM 内容'),
key_pem: z.string().trim().min(1, '请输入私钥 PEM 内容'),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
import type { TranslateFn } from './website-utils';
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
export function createManualImportSchema(t: TranslateFn) {
return z.object({
name: z
.string()
.trim()
.min(1, t('validation.nameRequired'))
.max(255, t('validation.nameTooLong')),
cert_pem: z.string().trim().min(1, t('validation.certPemRequired')),
key_pem: z.string().trim().min(1, t('validation.keyPemRequired')),
remark: z.string().max(255, t('validation.remarkTooLong')),
});
}
export type ManualImportFormValues = z.infer<
ReturnType<typeof createManualImportSchema>
>;
export type FileImportFormValues = {
name: string;
@@ -30,22 +36,26 @@ export const defaultFileImportValues: FileImportFormValues = {
remark: '',
};
export const acmeApplySchema = z.object({
name: z.string().trim().min(1, '请输入证书名称').max(255),
primary_domain: z.string().trim().min(1, '请输入主域名'),
other_domains: z.string(),
dns_account_id: z.number().min(1, '请选择 DNS 账号'),
acme_account_id: z.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean(),
skip_dns: z.boolean(),
dns1: z.string(),
dns2: z.string(),
remark: z.string().max(255),
});
export function createAcmeApplySchema(t: TranslateFn) {
return z.object({
name: z.string().trim().min(1, t('validation.nameRequired')).max(255),
primary_domain: z.string().trim().min(1, t('validation.primaryDomainRequired')),
other_domains: z.string(),
dns_account_id: z.number().min(1, t('validation.dnsAccountRequired')),
acme_account_id: z.number(),
key_algorithm: z.string(),
auto_renew: z.boolean(),
disable_cname: z.boolean(),
skip_dns: z.boolean(),
dns1: z.string(),
dns2: z.string(),
remark: z.string().max(255),
});
}
export type AcmeApplyFormValues = z.infer<typeof acmeApplySchema>;
export type AcmeApplyFormValues = z.infer<
ReturnType<typeof createAcmeApplySchema>
>;
export const defaultAcmeApplyValues: AcmeApplyFormValues = {
name: '',
@@ -9,8 +9,15 @@ import type { FileImportFormValues, ManualImportFormValues } from './schemas';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
export function getErrorMessage(error: unknown) {
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
export type TranslateFn = (
key: string,
values?: Record<string, string | number>,
) => string;
export function getErrorMessage(error: unknown, fallback?: string) {
return error instanceof Error
? error.message
: (fallback ?? '请求失败,请稍后重试。');
}
export function getMatchTypeMeta(domain: string): {
@@ -22,7 +29,10 @@ export function getMatchTypeMeta(domain: string): {
: { label: '精确匹配', tone: 'info' };
}
export function getCertificateStatus(certificate: TlsCertificateItem): {
export function getCertificateStatus(
certificate: TlsCertificateItem,
t: TranslateFn,
): {
label: string;
tone: StatusTone;
} {
@@ -31,18 +41,18 @@ export function getCertificateStatus(certificate: TlsCertificateItem): {
const days = Math.ceil(diffMs / (1000 * 60 * 60 * 24));
if (Number.isNaN(expiresAt)) {
return { label: '有效期未知', tone: 'warning' };
return { label: t('certStatus.unknownExpiry'), tone: 'warning' };
}
if (days < 0) {
return { label: '已过期', tone: 'danger' };
return { label: t('certStatus.expired'), tone: 'danger' };
}
if (days <= 30) {
return { label: `${days} 天内到期`, tone: 'warning' };
return { label: t('certStatus.expiresInDays', { days }), tone: 'warning' };
}
return { label: '有效', tone: 'success' };
return { label: t('certStatus.valid'), tone: 'success' };
}
export function buildCertificateLabel(certificate: TlsCertificateItem) {
@@ -66,9 +76,10 @@ export function toFilePayload(
values: FileImportFormValues,
certFile: File | null,
keyFile: File | null,
missingFilesMessage: string,
): TlsCertificateFileImportPayload {
if (!certFile || !keyFile) {
throw new Error('请选择证书文件和私钥文件。');
throw new Error(missingFilesMessage);
}
return {