This commit is contained in:
ryan
2026-07-13 15:08:40 +08:00
parent 08e8eea932
commit 1eff7878a1
441 changed files with 26491 additions and 18405 deletions
@@ -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&apos;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) {