mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 02:26:36 +08:00
prettier
This commit is contained in:
@@ -14,7 +14,7 @@ import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import {Textarea} from '@/components/ui/textarea';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {DnsAccountService, TlsCertificateService} from '@/lib/services/openflare';
|
||||
import {DnsAccountService, TlsCertificateService,} from '@/lib/services/openflare';
|
||||
|
||||
import {type AcmeApplyFormValues, acmeApplySchema, defaultAcmeApplyValues,} from './schemas';
|
||||
import {getErrorMessage} from './website-utils';
|
||||
@@ -99,7 +99,10 @@ export function CertificateApplyDialog({
|
||||
}, [certificate, form, mode, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (defaultAcmeAccountQuery.data && form.getValues('acme_account_id') === 0) {
|
||||
if (
|
||||
defaultAcmeAccountQuery.data &&
|
||||
form.getValues('acme_account_id') === 0
|
||||
) {
|
||||
form.setValue('acme_account_id', defaultAcmeAccountQuery.data.id);
|
||||
}
|
||||
}, [defaultAcmeAccountQuery.data, form, open]);
|
||||
@@ -115,7 +118,7 @@ export function CertificateApplyDialog({
|
||||
return TlsCertificateService.apply(values);
|
||||
},
|
||||
onSuccess: async (result) => {
|
||||
await queryClient.invalidateQueries({queryKey: certificatesQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: certificatesQueryKey });
|
||||
onApplied?.(result);
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -138,61 +141,63 @@ export function CertificateApplyDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
<DialogDescription>{description}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
className="space-y-4"
|
||||
className='space-y-4'
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
setError('');
|
||||
applyMutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>证书名称</Label>
|
||||
<Input placeholder="例如:主站证书" {...form.register('name')} />
|
||||
<Input placeholder='例如:主站证书' {...form.register('name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>主域名</Label>
|
||||
<Input
|
||||
placeholder="example.com 或 *.example.com"
|
||||
placeholder='example.com 或 *.example.com'
|
||||
{...form.register('primary_domain')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>其他域名</Label>
|
||||
<Textarea
|
||||
rows={3}
|
||||
placeholder="example.net"
|
||||
placeholder='example.net'
|
||||
{...form.register('other_domains')}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
每行一个域名。如申请通配符证书,请填写对应的根域名以便一并签发。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>DNS 服务商账号</Label>
|
||||
<Select
|
||||
value={String(form.watch('dns_account_id') || 0)}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('dns_account_id', Number(value), {shouldValidate: true})
|
||||
form.setValue('dns_account_id', Number(value), {
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="请选择 DNS 账号" />
|
||||
<SelectValue placeholder='请选择 DNS 账号' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">请选择 DNS 账号</SelectItem>
|
||||
<SelectItem value='0'>请选择 DNS 账号</SelectItem>
|
||||
{dnsAccountsQuery.data?.map((account) => (
|
||||
<SelectItem key={account.id} value={String(account.id)}>
|
||||
{account.name} ({account.type})
|
||||
@@ -202,7 +207,7 @@ export function CertificateApplyDialog({
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>密钥算法</Label>
|
||||
<Select
|
||||
value={form.watch('key_algorithm')}
|
||||
@@ -212,38 +217,43 @@ export function CertificateApplyDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="RSA2048">RSA 2048</SelectItem>
|
||||
<SelectItem value="RSA4096">RSA 4096</SelectItem>
|
||||
<SelectItem value="EC256">ECC 256</SelectItem>
|
||||
<SelectItem value="EC384">ECC 384</SelectItem>
|
||||
<SelectItem value='RSA2048'>RSA 2048</SelectItem>
|
||||
<SelectItem value='RSA4096'>RSA 4096</SelectItem>
|
||||
<SelectItem value='EC256'>ECC 256</SelectItem>
|
||||
<SelectItem value='EC384'>ECC 384</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>备注</Label>
|
||||
<Input placeholder="可选,用于记录证书用途。" {...form.register('remark')} />
|
||||
<Input
|
||||
placeholder='可选,用于记录证书用途。'
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<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-xs text-muted-foreground">
|
||||
<p className='text-sm font-medium'>开启自动续签</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
开启后,将在证书过期前 7 天自动续期。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('auto_renew')}
|
||||
onCheckedChange={(checked) => form.setValue('auto_renew', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('auto_renew', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="overflow-hidden rounded-lg border">
|
||||
<div className='overflow-hidden rounded-lg border'>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
className="w-full justify-between rounded-none"
|
||||
type='button'
|
||||
variant='ghost'
|
||||
className='w-full justify-between rounded-none'
|
||||
onClick={() => setShowAdvanced((current) => !current)}
|
||||
>
|
||||
高级选项
|
||||
@@ -252,40 +262,50 @@ export function CertificateApplyDialog({
|
||||
/>
|
||||
</Button>
|
||||
{showAdvanced ? (
|
||||
<div className="space-y-4 border-t p-3">
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<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>
|
||||
<Input placeholder="为空则使用默认权威 DNS" {...form.register('dns1')} />
|
||||
<Input
|
||||
placeholder='为空则使用默认权威 DNS'
|
||||
{...form.register('dns1')}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>DNS 验证服务器 2</Label>
|
||||
<Input placeholder="为空则使用默认权威 DNS" {...form.register('dns2')} />
|
||||
<Input
|
||||
placeholder='为空则使用默认权威 DNS'
|
||||
{...form.register('dns2')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<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-xs text-muted-foreground">
|
||||
<p className='text-sm font-medium'>跳过 CNAME 检查</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
在执行 DNS-01 验证时不追踪 CNAME 记录。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('disable_cname')}
|
||||
onCheckedChange={(checked) => form.setValue('disable_cname', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('disable_cname', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<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-xs text-muted-foreground">
|
||||
<p className='text-sm font-medium'>跳过 DNS 前置检查</p>
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
直接请求 Let's Encrypt 验证而不做本地校验。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={form.watch('skip_dns')}
|
||||
onCheckedChange={(checked) => form.setValue('skip_dns', checked)}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('skip_dns', checked)
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -293,10 +313,10 @@ export function CertificateApplyDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={applyMutation.isPending}>
|
||||
<Button type='submit' disabled={applyMutation.isPending}>
|
||||
{applyMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
提交中...
|
||||
</>
|
||||
) : mode === 'edit-acme' ? (
|
||||
|
||||
@@ -68,7 +68,7 @@ export function CertificateDetailDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-3xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className='max-w-3xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>证书详情</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -77,82 +77,101 @@ export function CertificateDetailDialog({
|
||||
</DialogHeader>
|
||||
|
||||
{loading ? (
|
||||
<LoadingStateWithBorder description="加载证书详情中..." />
|
||||
<LoadingStateWithBorder description='加载证书详情中...' />
|
||||
) : hasError ? (
|
||||
<ErrorInline
|
||||
message={getErrorMessage(certificateQuery.error ?? contentQuery.error)}
|
||||
className="justify-center"
|
||||
message={getErrorMessage(
|
||||
certificateQuery.error ?? contentQuery.error,
|
||||
)}
|
||||
className='justify-center'
|
||||
/>
|
||||
) : !certificate || !content ? (
|
||||
<EmptyStateWithBorder description="证书不存在,可能已被删除。" />
|
||||
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
|
||||
) : (
|
||||
<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">
|
||||
<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'>
|
||||
证书名称
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{certificate.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">状态</p>
|
||||
<div className="mt-1">
|
||||
{status ? <WebsiteStatusBadge label={status.label} tone={status.tone} /> : null}
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
状态
|
||||
</p>
|
||||
<div className='mt-1'>
|
||||
{status ? (
|
||||
<WebsiteStatusBadge
|
||||
label={status.label}
|
||||
tone={status.tone}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
生效时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{formatDateTime(certificate.not_before)}</p>
|
||||
<p className='mt-1 text-sm'>
|
||||
{formatDateTime(certificate.not_before)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
到期时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{formatDateTime(certificate.not_after)}</p>
|
||||
<p className='mt-1 text-sm'>
|
||||
{formatDateTime(certificate.not_after)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
|
||||
<p className="mt-1 text-sm">{certificate.remark || '暂无备注'}</p>
|
||||
<div className='rounded-lg border p-3'>
|
||||
<p className='text-[10px] uppercase tracking-wider text-muted-foreground'>
|
||||
备注
|
||||
</p>
|
||||
<p className='mt-1 text-sm'>{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">证书 PEM</p>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<p className='text-sm font-medium'>证书 PEM</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleCopy(content.cert_pem, '证书 PEM 已复制')}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(content.cert_pem, '证书 PEM 已复制')
|
||||
}
|
||||
>
|
||||
<Copy className="mr-1 size-3" />
|
||||
<Copy className='mr-1 size-3' />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap">
|
||||
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
|
||||
{content.cert_pem}
|
||||
</pre>
|
||||
</div>
|
||||
<div>
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<p className="text-sm font-medium">私钥 PEM</p>
|
||||
<div className='mb-2 flex items-center justify-between'>
|
||||
<p className='text-sm font-medium'>私钥 PEM</p>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => void handleCopy(content.key_pem, '私钥 PEM 已复制')}
|
||||
type='button'
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() =>
|
||||
void handleCopy(content.key_pem, '私钥 PEM 已复制')
|
||||
}
|
||||
>
|
||||
<Copy className="mr-1 size-3" />
|
||||
<Copy className='mr-1 size-3' />
|
||||
复制
|
||||
</Button>
|
||||
</div>
|
||||
<pre className="max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap">
|
||||
<pre className='max-h-48 overflow-auto rounded-lg border bg-muted/40 p-3 text-xs break-all whitespace-pre-wrap'>
|
||||
{content.key_pem}
|
||||
</pre>
|
||||
</div>
|
||||
@@ -161,21 +180,25 @@ export function CertificateDetailDialog({
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
关闭
|
||||
</Button>
|
||||
<Button type="button" onClick={onEdit} disabled={!certificate}>
|
||||
<Button type='button' onClick={onEdit} disabled={!certificate}>
|
||||
编辑证书
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
type='button'
|
||||
variant='destructive'
|
||||
onClick={onDelete}
|
||||
disabled={!certificate || deleting}
|
||||
>
|
||||
{deleting ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
删除中...
|
||||
</>
|
||||
) : (
|
||||
@@ -186,4 +209,4 @@ export function CertificateDetailDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -58,11 +58,14 @@ export function CertificateEditorDialog({
|
||||
|
||||
const updateMutation = useMutation({
|
||||
mutationFn: (values: ManualImportFormValues) =>
|
||||
TlsCertificateService.update(certificateId as number, toManualPayload(values)),
|
||||
TlsCertificateService.update(
|
||||
certificateId as number,
|
||||
toManualPayload(values),
|
||||
),
|
||||
onSuccess: async (certificate) => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: certificatesQueryKey }),
|
||||
]);
|
||||
onSaved?.(certificate);
|
||||
handleClose();
|
||||
},
|
||||
@@ -94,7 +97,7 @@ export function CertificateEditorDialog({
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
|
||||
<DialogContent className="max-w-2xl max-h-[90vh] overflow-y-auto">
|
||||
<DialogContent className='max-w-2xl max-h-[90vh] overflow-y-auto'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>编辑证书</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -103,49 +106,59 @@ export function CertificateEditorDialog({
|
||||
</DialogHeader>
|
||||
|
||||
{certificateQuery.isLoading ? (
|
||||
<LoadingStateWithBorder description="加载证书内容中..." />
|
||||
<LoadingStateWithBorder description='加载证书内容中...' />
|
||||
) : certificateQuery.isError ? (
|
||||
<ErrorInline
|
||||
message={getErrorMessage(certificateQuery.error)}
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
/>
|
||||
) : !certificateQuery.data ? (
|
||||
<EmptyStateWithBorder description="证书不存在,可能已被删除。" />
|
||||
<EmptyStateWithBorder description='证书不存在,可能已被删除。' />
|
||||
) : (
|
||||
<form id="certificate-editor-form" className="space-y-4" onSubmit={handleSubmit}>
|
||||
<form
|
||||
id='certificate-editor-form'
|
||||
className='space-y-4'
|
||||
onSubmit={handleSubmit}
|
||||
>
|
||||
{updateMutation.isError ? (
|
||||
<p className="text-sm text-destructive">
|
||||
<p className='text-sm text-destructive'>
|
||||
{getErrorMessage(updateMutation.error)}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>证书名称</Label>
|
||||
<Input {...form.register('name')} />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>备注</Label>
|
||||
<Input {...form.register('remark')} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>证书 PEM</Label>
|
||||
<Textarea className="min-h-32 font-mono text-xs" {...form.register('cert_pem')} />
|
||||
<Textarea
|
||||
className='min-h-32 font-mono text-xs'
|
||||
{...form.register('cert_pem')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>私钥 PEM</Label>
|
||||
<Textarea className="min-h-32 font-mono text-xs" {...form.register('key_pem')} />
|
||||
<Textarea
|
||||
className='min-h-32 font-mono text-xs'
|
||||
{...form.register('key_pem')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<DialogFooter className='sm:justify-between'>
|
||||
<div>
|
||||
{canConvert ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
type='button'
|
||||
variant='outline'
|
||||
onClick={() => {
|
||||
if (certificateQuery.data) {
|
||||
onConvert?.(certificateQuery.data);
|
||||
@@ -157,14 +170,14 @@ export function CertificateEditorDialog({
|
||||
</Button>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
<div className='flex gap-2'>
|
||||
<Button type='button' variant='outline' onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={updateMutation.isPending}>
|
||||
<Button type='submit' disabled={updateMutation.isPending}>
|
||||
{updateMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
保存中...
|
||||
</>
|
||||
) : (
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
type ManualImportFormValues,
|
||||
manualImportSchema,
|
||||
} from './schemas';
|
||||
import {getErrorMessage, toFilePayload, toManualPayload} from './website-utils';
|
||||
import {getErrorMessage, toFilePayload, toManualPayload,} from './website-utils';
|
||||
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
|
||||
@@ -40,7 +40,9 @@ export function CertificateImportDialog({
|
||||
const queryClient = useQueryClient();
|
||||
const [importMode, setImportMode] = useState<'manual' | 'file'>('manual');
|
||||
const [error, setError] = useState('');
|
||||
const [fileForm, setFileForm] = useState<FileImportFormValues>(defaultFileImportValues);
|
||||
const [fileForm, setFileForm] = useState<FileImportFormValues>(
|
||||
defaultFileImportValues,
|
||||
);
|
||||
const [certFile, setCertFile] = useState<File | null>(null);
|
||||
const [keyFile, setKeyFile] = useState<File | null>(null);
|
||||
const [fileInputNonce, setFileInputNonce] = useState(0);
|
||||
@@ -57,8 +59,8 @@ export function CertificateImportDialog({
|
||||
|
||||
const invalidateQueries = async () => {
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
]);
|
||||
queryClient.invalidateQueries({ queryKey: certificatesQueryKey }),
|
||||
]);
|
||||
};
|
||||
|
||||
const resetFileForm = () => {
|
||||
@@ -93,7 +95,9 @@ export function CertificateImportDialog({
|
||||
|
||||
const fileImportMutation = useMutation({
|
||||
mutationFn: (values: FileImportFormValues) =>
|
||||
TlsCertificateService.importFile(toFilePayload(values, certFile, keyFile)),
|
||||
TlsCertificateService.importFile(
|
||||
toFilePayload(values, certFile, keyFile),
|
||||
),
|
||||
onSuccess: async (certificate) => {
|
||||
await invalidateQueries();
|
||||
onImported?.(certificate);
|
||||
@@ -113,11 +117,12 @@ export function CertificateImportDialog({
|
||||
fileImportMutation.mutate(fileForm);
|
||||
};
|
||||
|
||||
const pending = manualImportMutation.isPending || fileImportMutation.isPending;
|
||||
const pending =
|
||||
manualImportMutation.isPending || fileImportMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogContent className='max-w-2xl'>
|
||||
<DialogHeader>
|
||||
<DialogTitle>添加证书</DialogTitle>
|
||||
<DialogDescription>
|
||||
@@ -125,54 +130,63 @@ export function CertificateImportDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
|
||||
|
||||
<Tabs value={importMode} onValueChange={(value) => setImportMode(value as 'manual' | 'file')}>
|
||||
<TabsList className="grid w-full grid-cols-2">
|
||||
<TabsTrigger value="manual">手动导入</TabsTrigger>
|
||||
<TabsTrigger value="file">文件导入</TabsTrigger>
|
||||
<Tabs
|
||||
value={importMode}
|
||||
onValueChange={(value) => setImportMode(value as 'manual' | 'file')}
|
||||
>
|
||||
<TabsList className='grid w-full grid-cols-2'>
|
||||
<TabsTrigger value='manual'>手动导入</TabsTrigger>
|
||||
<TabsTrigger value='file'>文件导入</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">
|
||||
<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>
|
||||
<Input placeholder="example-com" {...manualForm.register('name')} />
|
||||
<Input
|
||||
placeholder='example-com'
|
||||
{...manualForm.register('name')}
|
||||
/>
|
||||
{manualForm.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{manualForm.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>备注</Label>
|
||||
<Input placeholder="例如:主站生产证书" {...manualForm.register('remark')} />
|
||||
<Input
|
||||
placeholder='例如:主站生产证书'
|
||||
{...manualForm.register('remark')}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>证书 PEM</Label>
|
||||
<Textarea
|
||||
className="min-h-32 font-mono text-xs"
|
||||
placeholder="-----BEGIN CERTIFICATE-----"
|
||||
className='min-h-32 font-mono text-xs'
|
||||
placeholder='-----BEGIN CERTIFICATE-----'
|
||||
{...manualForm.register('cert_pem')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>私钥 PEM</Label>
|
||||
<Textarea
|
||||
className="min-h-32 font-mono text-xs"
|
||||
placeholder="-----BEGIN PRIVATE KEY-----"
|
||||
className='min-h-32 font-mono text-xs'
|
||||
placeholder='-----BEGIN PRIVATE KEY-----'
|
||||
{...manualForm.register('key_pem')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Button type="submit" disabled={pending}>
|
||||
<Button type='submit' disabled={pending}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
导入中...
|
||||
</>
|
||||
) : (
|
||||
@@ -182,63 +196,77 @@ export function CertificateImportDialog({
|
||||
</form>
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="file" className="space-y-4">
|
||||
<form className="space-y-4" onSubmit={handleFileSubmit}>
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<TabsContent value='file' className='space-y-4'>
|
||||
<form className='space-y-4' onSubmit={handleFileSubmit}>
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>证书名称</Label>
|
||||
<Input
|
||||
value={fileForm.name}
|
||||
onChange={(event) =>
|
||||
setFileForm((current) => ({...current, name: event.target.value}))
|
||||
setFileForm((current) => ({
|
||||
...current,
|
||||
name: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="wildcard-example"
|
||||
placeholder='wildcard-example'
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>备注</Label>
|
||||
<Input
|
||||
value={fileForm.remark}
|
||||
onChange={(event) =>
|
||||
setFileForm((current) => ({...current, remark: event.target.value}))
|
||||
setFileForm((current) => ({
|
||||
...current,
|
||||
remark: event.target.value,
|
||||
}))
|
||||
}
|
||||
placeholder="例如:泛域名生产证书"
|
||||
placeholder='例如:泛域名生产证书'
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<div className='grid gap-4 md:grid-cols-2'>
|
||||
<div className='space-y-2'>
|
||||
<Label>证书文件</Label>
|
||||
<Input
|
||||
key={`cert-${fileInputNonce}`}
|
||||
type="file"
|
||||
accept=".pem,.crt,.cer"
|
||||
onChange={(event) => setCertFile(event.target.files?.[0] ?? null)}
|
||||
type='file'
|
||||
accept='.pem,.crt,.cer'
|
||||
onChange={(event) =>
|
||||
setCertFile(event.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{certFile ? `已选择:${certFile.name}` : '请选择 PEM/CRT 文件'}
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{certFile
|
||||
? `已选择:${certFile.name}`
|
||||
: '请选择 PEM/CRT 文件'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>私钥文件</Label>
|
||||
<Input
|
||||
key={`key-${fileInputNonce}`}
|
||||
type="file"
|
||||
accept=".key,.pem"
|
||||
onChange={(event) => setKeyFile(event.target.files?.[0] ?? null)}
|
||||
type='file'
|
||||
accept='.key,.pem'
|
||||
onChange={(event) =>
|
||||
setKeyFile(event.target.files?.[0] ?? null)
|
||||
}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{keyFile ? `已选择:${keyFile.name}` : '请选择 KEY/PEM 文件'}
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
{keyFile
|
||||
? `已选择:${keyFile.name}`
|
||||
: '请选择 KEY/PEM 文件'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<Button type="submit" disabled={pending}>
|
||||
<div className='flex gap-2'>
|
||||
<Button type='submit' disabled={pending}>
|
||||
{pending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
上传中...
|
||||
</>
|
||||
) : (
|
||||
@@ -246,8 +274,8 @@ export function CertificateImportDialog({
|
||||
)}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
type='button'
|
||||
variant='outline'
|
||||
disabled={pending}
|
||||
onClick={resetFileForm}
|
||||
>
|
||||
|
||||
@@ -19,8 +19,8 @@ import {
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {DnsAccountService} from '@/lib/services/openflare';
|
||||
import type {DnsAccountMutationPayload} from '@/lib/services/openflare';
|
||||
import {DnsAccountService} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from './website-utils';
|
||||
|
||||
@@ -49,13 +49,14 @@ export function DnsAccountCreateDialog({
|
||||
const [error, setError] = useState('');
|
||||
const form = useForm<DnsAccountFormValues>({
|
||||
resolver: zodResolver(dnsAccountSchema),
|
||||
defaultValues: {name: '', type: 'cloudflare', authorization: ''},
|
||||
defaultValues: { name: '', type: 'cloudflare', authorization: '' },
|
||||
});
|
||||
|
||||
const createMutation = useMutation({
|
||||
mutationFn: (payload: DnsAccountMutationPayload) => DnsAccountService.create(payload),
|
||||
mutationFn: (payload: DnsAccountMutationPayload) =>
|
||||
DnsAccountService.create(payload),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: dnsAccountsQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: dnsAccountsQueryKey });
|
||||
form.reset();
|
||||
setError('');
|
||||
onCreated?.();
|
||||
@@ -68,9 +69,9 @@ export function DnsAccountCreateDialog({
|
||||
setError('');
|
||||
let auth = values.authorization.trim();
|
||||
if (!auth.startsWith('{')) {
|
||||
auth = JSON.stringify({api_token: values.authorization});
|
||||
auth = JSON.stringify({ api_token: values.authorization });
|
||||
}
|
||||
createMutation.mutate({...values, authorization: auth});
|
||||
createMutation.mutate({ ...values, authorization: auth });
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
@@ -89,21 +90,23 @@ export function DnsAccountCreateDialog({
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={onSubmit}>
|
||||
{error ? <p className="text-sm text-destructive">{error}</p> : null}
|
||||
<form className='space-y-4' onSubmit={onSubmit}>
|
||||
{error ? <p className='text-sm text-destructive'>{error}</p> : null}
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>账号名称</Label>
|
||||
<Input
|
||||
placeholder="Cloudflare 邮箱账号"
|
||||
placeholder='Cloudflare 邮箱账号'
|
||||
{...form.register('name')}
|
||||
/>
|
||||
{form.formState.errors.name ? (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.name.message}</p>
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.name.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>DNS 服务商</Label>
|
||||
<Select
|
||||
value={form.watch('type')}
|
||||
@@ -113,32 +116,32 @@ export function DnsAccountCreateDialog({
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="cloudflare">Cloudflare</SelectItem>
|
||||
<SelectItem value='cloudflare'>Cloudflare</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className='space-y-2'>
|
||||
<Label>API Token</Label>
|
||||
<Input
|
||||
{...form.register('authorization')}
|
||||
placeholder="请勿使用 Global API Key"
|
||||
placeholder='请勿使用 Global API Key'
|
||||
/>
|
||||
{form.formState.errors.authorization ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.authorization.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
<Button type='button' variant='outline' onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={createMutation.isPending}>
|
||||
<Button type='submit' disabled={createMutation.isPending}>
|
||||
{createMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
<Loader2 className='mr-1 size-3.5 animate-spin' />
|
||||
提交中...
|
||||
</>
|
||||
) : (
|
||||
@@ -150,4 +153,4 @@ export function DnsAccountCreateDialog({
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
'use client';
|
||||
|
||||
import {useEffect, useMemo} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useForm} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {z} from 'zod';
|
||||
import { useEffect, useMemo } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -17,9 +17,15 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
TlsCertificateService,
|
||||
ZoneDomainService,
|
||||
@@ -29,7 +35,10 @@ import {
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {previewZoneDomainInput, resolveZoneDomainInput} from './resolve-zone-domain-input';
|
||||
import {
|
||||
previewZoneDomainInput,
|
||||
resolveZoneDomainInput,
|
||||
} from './resolve-zone-domain-input';
|
||||
|
||||
const schema = z.object({
|
||||
zone_id: z.string().min(1, '请选择 Zone'),
|
||||
@@ -78,7 +87,12 @@ export function QuickCreateZoneDomainDialog({
|
||||
return (
|
||||
zones.find((zone) => zone.id === fixedZoneId) ??
|
||||
(fixedZoneRoot
|
||||
? ({id: fixedZoneId, domain: fixedZoneRoot, created_at: '', updated_at: ''} as ZoneItem)
|
||||
? ({
|
||||
id: fixedZoneId,
|
||||
domain: fixedZoneRoot,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
} as ZoneItem)
|
||||
: undefined)
|
||||
);
|
||||
}, [fixedZoneId, fixedZoneRoot, zones]);
|
||||
@@ -120,8 +134,7 @@ export function QuickCreateZoneDomainDialog({
|
||||
const mutation = useMutation({
|
||||
mutationFn: async (values: Values) => {
|
||||
const zoneId = Number(values.zone_id);
|
||||
const zone =
|
||||
fixedZone ?? zones.find((item) => item.id === zoneId);
|
||||
const zone = fixedZone ?? zones.find((item) => item.id === zoneId);
|
||||
if (!zone) {
|
||||
throw new Error('请选择 Zone');
|
||||
}
|
||||
@@ -135,11 +148,13 @@ export function QuickCreateZoneDomainDialog({
|
||||
});
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
toast.success('域名已添加', {description: domain.domain});
|
||||
toast.success('域名已添加', { description: domain.domain });
|
||||
await Promise.all([
|
||||
onCreated(domain),
|
||||
queryClient.invalidateQueries({queryKey: zoneQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: [...zoneQueryKey, 'all-domains']}),
|
||||
queryClient.invalidateQueries({ queryKey: zoneQueryKey }),
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: [...zoneQueryKey, 'all-domains'],
|
||||
}),
|
||||
]);
|
||||
onOpenChange(false);
|
||||
},
|
||||
@@ -158,11 +173,11 @@ export function QuickCreateZoneDomainDialog({
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
id="quick-create-zone-domain"
|
||||
className="space-y-4"
|
||||
id='quick-create-zone-domain'
|
||||
className='space-y-4'
|
||||
onSubmit={form.handleSubmit((values) => {
|
||||
if (!selectedZone) {
|
||||
form.setError('zone_id', {message: '请选择 Zone'});
|
||||
form.setError('zone_id', { message: '请选择 Zone' });
|
||||
return;
|
||||
}
|
||||
const resolved = resolveZoneDomainInput(
|
||||
@@ -170,21 +185,21 @@ export function QuickCreateZoneDomainDialog({
|
||||
selectedZone.domain,
|
||||
);
|
||||
if (resolved.error) {
|
||||
form.setError('domain_input', {message: resolved.error});
|
||||
form.setError('domain_input', { message: resolved.error });
|
||||
return;
|
||||
}
|
||||
mutation.mutate(values);
|
||||
})}
|
||||
>
|
||||
{!fixedZoneId ? (
|
||||
<div className="space-y-1.5">
|
||||
<div className='space-y-1.5'>
|
||||
<Label>Zone</Label>
|
||||
<Select
|
||||
value={form.watch('zone_id') || undefined}
|
||||
onValueChange={(value) => form.setValue('zone_id', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择注册根域" />
|
||||
<SelectValue placeholder='选择注册根域' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{zones.map((zone) => (
|
||||
@@ -195,24 +210,24 @@ export function QuickCreateZoneDomainDialog({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{form.formState.errors.zone_id ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.zone_id.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-md border bg-muted/30 px-3 py-2 text-sm">
|
||||
<div className='rounded-md border bg-muted/30 px-3 py-2 text-sm'>
|
||||
Zone:
|
||||
<span className="ml-1 font-medium">
|
||||
<span className='ml-1 font-medium'>
|
||||
{fixedZoneRoot || fixedZone?.domain || `#${fixedZoneId}`}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="domain-input">域名</Label>
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='domain-input'>域名</Label>
|
||||
<Input
|
||||
id="domain-input"
|
||||
id='domain-input'
|
||||
placeholder={
|
||||
selectedZone
|
||||
? `api 或 @ 或 api.${selectedZone.domain}`
|
||||
@@ -221,27 +236,27 @@ export function QuickCreateZoneDomainDialog({
|
||||
{...form.register('domain_input')}
|
||||
/>
|
||||
{preview ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
将创建:
|
||||
<code className="ml-1 rounded bg-muted px-1 py-0.5 font-mono text-[11px]">
|
||||
<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> → 根域本身
|
||||
<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> → 根域本身
|
||||
</p>
|
||||
)}
|
||||
{form.formState.errors.domain_input ? (
|
||||
<p className="text-xs text-destructive">
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.domain_input.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<div className='space-y-1.5'>
|
||||
<Label>证书(可选)</Label>
|
||||
<Select
|
||||
value={form.watch('cert_id') || '__none'}
|
||||
@@ -250,12 +265,15 @@ export function QuickCreateZoneDomainDialog({
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
<SelectValue placeholder='不绑定证书' />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none">不绑定证书</SelectItem>
|
||||
<SelectItem value='__none'>不绑定证书</SelectItem>
|
||||
{(certificatesQuery.data ?? []).map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
<SelectItem
|
||||
key={certificate.id}
|
||||
value={String(certificate.id)}
|
||||
>
|
||||
{certificate.name} · {certificate.primary_domain}
|
||||
</SelectItem>
|
||||
))}
|
||||
@@ -265,16 +283,16 @@ export function QuickCreateZoneDomainDialog({
|
||||
</form>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button
|
||||
type="submit"
|
||||
form="quick-create-zone-domain"
|
||||
type='submit'
|
||||
form='quick-create-zone-domain'
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending ? (
|
||||
<Loader2 className="mr-1 size-4 animate-spin" />
|
||||
<Loader2 className='mr-1 size-4 animate-spin' />
|
||||
) : null}
|
||||
添加域名
|
||||
</Button>
|
||||
|
||||
@@ -8,45 +8,53 @@
|
||||
export function resolveZoneDomainInput(
|
||||
rawInput: string,
|
||||
zoneRoot: string,
|
||||
): {domain: string; error?: string} {
|
||||
): { domain: string; error?: string } {
|
||||
const input = rawInput.trim().toLowerCase();
|
||||
const root = zoneRoot.trim().toLowerCase();
|
||||
|
||||
if (!root) {
|
||||
return {domain: '', error: '请先选择 Zone'};
|
||||
return { domain: '', error: '请先选择 Zone' };
|
||||
}
|
||||
if (!input) {
|
||||
return {domain: '', error: '请输入域名'};
|
||||
return { domain: '', error: '请输入域名' };
|
||||
}
|
||||
if (input.includes('*')) {
|
||||
return {domain: '', error: 'Zone 域名不支持通配符'};
|
||||
return { domain: '', error: 'Zone 域名不支持通配符' };
|
||||
}
|
||||
if (input.includes('://') || input.includes('/') || input.includes('?') || input.includes('#')) {
|
||||
return {domain: '', error: '域名格式不合法'};
|
||||
if (
|
||||
input.includes('://') ||
|
||||
input.includes('/') ||
|
||||
input.includes('?') ||
|
||||
input.includes('#')
|
||||
) {
|
||||
return { domain: '', error: '域名格式不合法' };
|
||||
}
|
||||
|
||||
if (input === '@') {
|
||||
return {domain: root};
|
||||
return { domain: root };
|
||||
}
|
||||
|
||||
// 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: '子域名称格式不合法' };
|
||||
}
|
||||
return {domain: `${input}.${root}`};
|
||||
return { domain: `${input}.${root}` };
|
||||
}
|
||||
|
||||
// Full FQDN
|
||||
if (input === root || input.endsWith(`.${root}`)) {
|
||||
return {domain: input};
|
||||
return { domain: input };
|
||||
}
|
||||
|
||||
return {domain: '', error: `域名必须属于 Zone ${root}`};
|
||||
return { domain: '', error: `域名必须属于 Zone ${root}` };
|
||||
}
|
||||
|
||||
/** Live preview string for the input helper text. */
|
||||
export function previewZoneDomainInput(rawInput: string, zoneRoot: string): string {
|
||||
export function previewZoneDomainInput(
|
||||
rawInput: string,
|
||||
zoneRoot: string,
|
||||
): string {
|
||||
const resolved = resolveZoneDomainInput(rawInput, zoneRoot);
|
||||
if (resolved.error || !resolved.domain) {
|
||||
return '';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import {z} from 'zod';
|
||||
import { z } from 'zod';
|
||||
|
||||
export const manualImportSchema = z.object({
|
||||
name: z
|
||||
@@ -60,4 +60,4 @@ export const defaultAcmeApplyValues: AcmeApplyFormValues = {
|
||||
dns1: '',
|
||||
dns2: '',
|
||||
remark: '',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {cn} from '@/lib/utils';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type {StatusTone} from './website-utils';
|
||||
import type { StatusTone } from './website-utils';
|
||||
|
||||
const toneClassName: Record<StatusTone, string> = {
|
||||
success: 'bg-emerald-500/10 border-emerald-500/20 text-emerald-600',
|
||||
@@ -21,10 +21,14 @@ export function WebsiteStatusBadge({
|
||||
}) {
|
||||
return (
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={cn('text-[10px] rounded-full py-0 px-2 font-medium', toneClassName[tone], className)}
|
||||
variant='outline'
|
||||
className={cn(
|
||||
'text-[10px] rounded-full py-0 px-2 font-medium',
|
||||
toneClassName[tone],
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,9 +3,9 @@ import type {
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import type {FileImportFormValues, ManualImportFormValues} from './schemas';
|
||||
import type { FileImportFormValues, ManualImportFormValues } from './schemas';
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
@@ -13,10 +13,13 @@ export function getErrorMessage(error: unknown) {
|
||||
return error instanceof Error ? error.message : '请求失败,请稍后重试。';
|
||||
}
|
||||
|
||||
export function getMatchTypeMeta(domain: string): {label: string; tone: StatusTone} {
|
||||
export function getMatchTypeMeta(domain: string): {
|
||||
label: string;
|
||||
tone: StatusTone;
|
||||
} {
|
||||
return domain.startsWith('*.')
|
||||
? {label: '通配符', tone: 'warning'}
|
||||
: {label: '精确匹配', tone: 'info'};
|
||||
? { label: '通配符', tone: 'warning' }
|
||||
: { label: '精确匹配', tone: 'info' };
|
||||
}
|
||||
|
||||
export function getCertificateStatus(certificate: TlsCertificateItem): {
|
||||
@@ -28,18 +31,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: '有效期未知', tone: 'warning' };
|
||||
}
|
||||
|
||||
if (days < 0) {
|
||||
return {label: '已过期', tone: 'danger'};
|
||||
return { label: '已过期', tone: 'danger' };
|
||||
}
|
||||
|
||||
if (days <= 30) {
|
||||
return {label: `${days} 天内到期`, tone: 'warning'};
|
||||
return { label: `${days} 天内到期`, tone: 'warning' };
|
||||
}
|
||||
|
||||
return {label: '有效', tone: 'success'};
|
||||
return { label: '有效', tone: 'success' };
|
||||
}
|
||||
|
||||
export function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
|
||||
Reference in New Issue
Block a user