mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
feat(frontend): 控制台 next-intl 中英双语
接入无 URL 前缀的 zh-CN/en,顶栏与外观设置可切换语言;选择写入 cookie 后刷新生效。
This commit is contained in:
@@ -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'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 {
|
||||
|
||||
Reference in New Issue
Block a user