mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 10:26:38 +08:00
prettier
This commit is contained in:
@@ -1,18 +1,34 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {ExternalLink, FileKey} from 'lucide-react';
|
||||
import { useMemo } from 'react';
|
||||
import { ExternalLink, FileKey } from 'lucide-react';
|
||||
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem, ZoneDomainItem} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type {
|
||||
TlsCertificateItem,
|
||||
ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {getCertificateStatus} from '../../components/website-utils';
|
||||
import { getCertificateStatus } from '../../components/website-utils';
|
||||
|
||||
export function ZoneCertificatesPanel({
|
||||
domains,
|
||||
@@ -22,14 +38,15 @@ export function ZoneCertificatesPanel({
|
||||
certificates: TlsCertificateItem[];
|
||||
}) {
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
() =>
|
||||
new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const boundCertificates = useMemo(() => {
|
||||
const rows = new Map<
|
||||
number,
|
||||
{certificate: TlsCertificateItem; domains: ZoneDomainItem[]}
|
||||
{ certificate: TlsCertificateItem; domains: ZoneDomainItem[] }
|
||||
>();
|
||||
|
||||
for (const domain of domains) {
|
||||
@@ -44,7 +61,7 @@ export function ZoneCertificatesPanel({
|
||||
if (existing) {
|
||||
existing.domains.push(domain);
|
||||
} else {
|
||||
rows.set(certificate.id, {certificate, domains: [domain]});
|
||||
rows.set(certificate.id, { certificate, domains: [domain] });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -53,28 +70,30 @@ export function ZoneCertificatesPanel({
|
||||
);
|
||||
}, [certificateMap, domains]);
|
||||
|
||||
const unboundCount = domains.filter((domain) => domain.cert_id == null).length;
|
||||
const unboundCount = domains.filter(
|
||||
(domain) => domain.cert_id == null,
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div className='space-y-4'>
|
||||
<Card className='shadow-none'>
|
||||
<CardHeader className='flex flex-row items-start justify-between gap-3 space-y-0'>
|
||||
<div>
|
||||
<CardTitle className="text-base">本 Zone 使用的证书</CardTitle>
|
||||
<CardTitle className='text-base'>本 Zone 使用的证书</CardTitle>
|
||||
<CardDescription>
|
||||
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/certificates">
|
||||
<Button variant='outline' size='sm' className='h-7 text-xs' asChild>
|
||||
<Link href='/certificates'>
|
||||
证书库
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
<ExternalLink className='ml-1 size-3.5' />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
<CardContent className='space-y-3'>
|
||||
{unboundCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
|
||||
</p>
|
||||
) : null}
|
||||
@@ -82,10 +101,10 @@ export function ZoneCertificatesPanel({
|
||||
{boundCertificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description="暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。"
|
||||
description='暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。'
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<div className='rounded-lg border'>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
@@ -96,30 +115,35 @@ export function ZoneCertificatesPanel({
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boundCertificates.map(({certificate, domains: boundDomains}) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
return (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell className="font-medium">
|
||||
{certificate.name}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{certificate.primary_domain || `证书 #${certificate.id}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-muted-foreground">
|
||||
{boundDomains.map((domain) => domain.domain).join(' · ')}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDateTime(certificate.not_after)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{boundCertificates.map(
|
||||
({ certificate, domains: boundDomains }) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
return (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell className='font-medium'>
|
||||
{certificate.name}
|
||||
<p className='mt-0.5 text-xs text-muted-foreground'>
|
||||
{certificate.primary_domain ||
|
||||
`证书 #${certificate.id}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant='outline' className='text-[10px]'>
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className='max-w-xs truncate text-muted-foreground'>
|
||||
{boundDomains
|
||||
.map((domain) => domain.domain)
|
||||
.join(' · ')}
|
||||
</TableCell>
|
||||
<TableCell className='text-muted-foreground'>
|
||||
{formatDateTime(certificate.not_after)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import {QuickCreateZoneDomainDialog} from '../../components/quick-create-zone-domain-dialog';
|
||||
import { QuickCreateZoneDomainDialog } from '../../components/quick-create-zone-domain-dialog';
|
||||
|
||||
/** Zone 详情页添加域名(固定 Zone,支持简写 / @ / 完整 FQDN)。 */
|
||||
export function ZoneDomainDialog({
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {Eye, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useMutation } from '@tanstack/react-query';
|
||||
import { Eye, Plus, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,10 +16,22 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import {
|
||||
ZoneDomainService,
|
||||
type ProxyRouteItem,
|
||||
@@ -27,8 +39,8 @@ import {
|
||||
type ZoneDomainItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {getUpstreamLabels} from '../../../proxy-routes/components/helpers';
|
||||
import {ZoneDomainDialog} from './zone-domain-dialog';
|
||||
import { getUpstreamLabels } from '../../../proxy-routes/components/helpers';
|
||||
import { ZoneDomainDialog } from './zone-domain-dialog';
|
||||
|
||||
export function ZoneDomainsTable({
|
||||
zoneId,
|
||||
@@ -57,11 +69,13 @@ export function ZoneDomainsTable({
|
||||
setDeleting(null);
|
||||
await onChanged();
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
});
|
||||
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
() =>
|
||||
new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
const routeMap = useMemo(
|
||||
@@ -71,36 +85,36 @@ export function ZoneDomainsTable({
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-3 flex justify-end">
|
||||
<div className='mb-3 flex justify-end'>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setCreateOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无已添加域名" />
|
||||
<EmptyStateWithBorder description='暂无已添加域名' />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-dashed shadow-none">
|
||||
<div className='overflow-hidden rounded-lg border border-dashed shadow-none'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full min-w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[160px]">
|
||||
<Table className='w-full min-w-full caption-bottom text-sm'>
|
||||
<TableHeader className='sticky top-0 z-20 bg-background'>
|
||||
<TableRow className='border-b border-dashed hover:bg-transparent'>
|
||||
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[160px]'>
|
||||
FQDN
|
||||
</TableHead>
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[120px]">
|
||||
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[120px]'>
|
||||
证书
|
||||
</TableHead>
|
||||
<TableHead className="h-8 whitespace-nowrap py-2 min-w-[200px]">
|
||||
<TableHead className='h-8 whitespace-nowrap py-2 min-w-[200px]'>
|
||||
上游
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
<TableHead className='sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center'>
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -113,44 +127,45 @@ export function ZoneDomainsTable({
|
||||
: undefined;
|
||||
const upstreams = route ? getUpstreamLabels(route) : [];
|
||||
const certLabel = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`)
|
||||
? (certificateMap.get(domain.cert_id)?.name ??
|
||||
`证书 #${domain.cert_id}`)
|
||||
: '未绑定';
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={domain.id}
|
||||
className="group border-dashed hover:bg-muted/30"
|
||||
className='group border-dashed hover:bg-muted/30'
|
||||
>
|
||||
<TableCell className="py-1">
|
||||
<TableCell className='py-1'>
|
||||
<span
|
||||
className="max-w-[220px] truncate text-[11px] font-medium leading-tight"
|
||||
className='max-w-[220px] truncate text-[11px] font-medium leading-tight'
|
||||
title={domain.domain}
|
||||
>
|
||||
{domain.domain}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
<TableCell className='py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground'>
|
||||
{certLabel}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-[280px] py-1">
|
||||
<TableCell className='max-w-[280px] py-1'>
|
||||
{!domain.proxy_route_id ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
未关联路由
|
||||
</span>
|
||||
) : routesLoading && !route ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
加载中…
|
||||
</span>
|
||||
) : upstreams.length === 0 ? (
|
||||
<span className="font-mono text-[10px] text-muted-foreground">
|
||||
<span className='font-mono text-[10px] text-muted-foreground'>
|
||||
未配置上游
|
||||
</span>
|
||||
) : (
|
||||
<div className="flex flex-col gap-0">
|
||||
<div className='flex flex-col gap-0'>
|
||||
{upstreams.map((upstream) => (
|
||||
<span
|
||||
key={upstream}
|
||||
className="truncate font-mono text-[10px] leading-tight text-muted-foreground"
|
||||
className='truncate font-mono text-[10px] leading-tight text-muted-foreground'
|
||||
title={upstream}
|
||||
>
|
||||
{upstream}
|
||||
@@ -160,27 +175,27 @@ export function ZoneDomainsTable({
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
className='sticky right-0 z-10 bg-background py-1 text-center'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<div className='flex items-center justify-center gap-0.5'>
|
||||
{domain.proxy_route_id ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-foreground'
|
||||
asChild
|
||||
>
|
||||
<Link
|
||||
href={`/proxy-routes/detail?id=${domain.proxy_route_id}`}
|
||||
>
|
||||
<Eye className="size-3" />
|
||||
<Eye className='size-3' />
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
查看路由详情
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -189,15 +204,15 @@ export function ZoneDomainsTable({
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-destructive'
|
||||
onClick={() => setDeleting(domain)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
<Trash2 className='size-3' />
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
删除域名
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
@@ -236,9 +251,11 @@ export function ZoneDomainsTable({
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={remove.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={remove.isPending || !deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,88 +1,117 @@
|
||||
'use client'
|
||||
'use client';
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {useMutation, 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 {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
|
||||
import { useEffect } from 'react';
|
||||
import { useMutation, 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 {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import {
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneItem,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
const schema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入 Zone 根域')
|
||||
.refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'),
|
||||
})
|
||||
type Values = z.infer<typeof schema>
|
||||
.refine(
|
||||
(value) => !/[*/?#@]|:\/\//.test(value),
|
||||
'请输入不含协议或通配符的根域',
|
||||
),
|
||||
});
|
||||
type Values = z.infer<typeof schema>;
|
||||
|
||||
export function ZoneEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
zone,
|
||||
}: {
|
||||
open: boolean
|
||||
onOpenChange(open: boolean): void
|
||||
zone?: ZoneItem | null
|
||||
open: boolean;
|
||||
onOpenChange(open: boolean): void;
|
||||
zone?: ZoneItem | null;
|
||||
}) {
|
||||
const queryClient = useQueryClient()
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<Values>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {domain: ''},
|
||||
})
|
||||
defaultValues: { domain: '' },
|
||||
});
|
||||
useEffect(() => {
|
||||
if (open) form.reset({domain: zone?.domain ?? ''})
|
||||
}, [form, open, zone])
|
||||
if (open) form.reset({ domain: zone?.domain ?? '' });
|
||||
}, [form, open, zone]);
|
||||
const mutation = useMutation({
|
||||
mutationFn: (values: Values) =>
|
||||
zone
|
||||
? ZoneService.update(zone.id, {domain: values.domain.toLowerCase()})
|
||||
: ZoneService.create({domain: values.domain.toLowerCase()}),
|
||||
? ZoneService.update(zone.id, { domain: values.domain.toLowerCase() })
|
||||
: ZoneService.create({ domain: values.domain.toLowerCase() }),
|
||||
onSuccess: async () => {
|
||||
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建')
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey})
|
||||
onOpenChange(false)
|
||||
toast.success(zone ? 'Zone 已更新' : 'Zone 已创建');
|
||||
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
})
|
||||
onError: (error) =>
|
||||
toast.error(error instanceof Error ? error.message : '保存失败'),
|
||||
});
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle>
|
||||
<DialogDescription>Zone 仅接受可注册根域,例如 example.com。</DialogDescription>
|
||||
<DialogDescription>
|
||||
Zone 仅接受可注册根域,例如 example.com。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form
|
||||
id="zone-editor"
|
||||
className="space-y-4"
|
||||
id='zone-editor'
|
||||
className='space-y-4'
|
||||
onSubmit={form.handleSubmit((values) =>
|
||||
mutation.mutate({domain: values.domain.toLowerCase()}),
|
||||
mutation.mutate({ domain: values.domain.toLowerCase() }),
|
||||
)}
|
||||
>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="zone-domain">根域</Label>
|
||||
<Input id="zone-domain" placeholder="example.com" {...form.register('domain')} />
|
||||
<div className='space-y-1.5'>
|
||||
<Label htmlFor='zone-domain'>根域</Label>
|
||||
<Input
|
||||
id='zone-domain'
|
||||
placeholder='example.com'
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
{form.formState.errors.domain && (
|
||||
<p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>
|
||||
<p className='text-xs text-destructive'>
|
||||
{form.formState.errors.domain.message}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
<Button variant='outline' onClick={() => onOpenChange(false)}>
|
||||
取消
|
||||
</Button>
|
||||
<Button form="zone-editor" type="submit" disabled={mutation.isPending}>
|
||||
{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}
|
||||
<Button
|
||||
form='zone-editor'
|
||||
type='submit'
|
||||
disabled={mutation.isPending}
|
||||
>
|
||||
{mutation.isPending && (
|
||||
<Loader2 className='mr-1 size-4 animate-spin' />
|
||||
)}
|
||||
{zone ? '保存修改' : '新增 Zone'}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,45 +1,45 @@
|
||||
'use client';
|
||||
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Activity, HardDrive, Users} from 'lucide-react';
|
||||
import {Area, AreaChart, CartesianGrid, XAxis, YAxis} from 'recharts';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Activity, HardDrive, Users } from 'lucide-react';
|
||||
import { Area, AreaChart, CartesianGrid, XAxis, YAxis } from 'recharts';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
ChartContainer,
|
||||
ChartTooltip,
|
||||
ChartTooltipContent,
|
||||
type ChartConfig,
|
||||
} from '@/components/ui/chart';
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import {
|
||||
ZoneService,
|
||||
zoneQueryKey,
|
||||
type ZoneOverview,
|
||||
type ZoneStatsRange,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {formatBytes, formatCompactNumber} from '@/lib/utils/metrics';
|
||||
import {cn} from '@/lib/utils';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
import { formatBytes, formatCompactNumber } from '@/lib/utils/metrics';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const rangeOptions: Array<{value: ZoneStatsRange; label: string}> = [
|
||||
{value: '24h', label: '24 小时'},
|
||||
{value: '7d', label: '7 天'},
|
||||
{value: '30d', label: '30 天'},
|
||||
const rangeOptions: Array<{ value: ZoneStatsRange; label: string }> = [
|
||||
{ value: '24h', label: '24 小时' },
|
||||
{ value: '7d', label: '7 天' },
|
||||
{ value: '30d', label: '30 天' },
|
||||
];
|
||||
|
||||
const visitorsChartConfig = {
|
||||
value: {label: '唯一访问者', color: 'hsl(217 91% 60%)'},
|
||||
value: { label: '唯一访问者', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const requestsChartConfig = {
|
||||
value: {label: '请求总数', color: 'hsl(217 91% 60%)'},
|
||||
value: { label: '请求总数', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
const bytesChartConfig = {
|
||||
value: {label: '已提供数据', color: 'hsl(217 91% 60%)'},
|
||||
value: { label: '已提供数据', color: 'hsl(217 91% 60%)' },
|
||||
} satisfies ChartConfig;
|
||||
|
||||
function formatAxisLabel(iso: string, range: ZoneStatsRange) {
|
||||
@@ -71,7 +71,7 @@ function formatWindowLabel(startedAt?: string, endedAt?: string) {
|
||||
}
|
||||
const fmt = (value: Date) =>
|
||||
value
|
||||
.toLocaleDateString('en-GB', {day: 'numeric', month: 'long'})
|
||||
.toLocaleDateString('en-GB', { day: 'numeric', month: 'long' })
|
||||
.toUpperCase();
|
||||
return `${fmt(start)} — ${fmt(end)}`;
|
||||
}
|
||||
@@ -105,16 +105,16 @@ export function ZoneOverviewPanel({
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="inline-flex rounded-lg border bg-muted/40 p-0.5">
|
||||
<div className='space-y-6'>
|
||||
<div className='space-y-4'>
|
||||
<div className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||
<div className='inline-flex rounded-lg border bg-muted/40 p-0.5'>
|
||||
{rangeOptions.map((option) => (
|
||||
<Button
|
||||
key={option.value}
|
||||
type="button"
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
type='button'
|
||||
size='sm'
|
||||
variant='ghost'
|
||||
className={cn(
|
||||
'h-7 rounded-md px-3 text-xs font-medium shadow-none',
|
||||
range === option.value
|
||||
@@ -127,25 +127,28 @@ export function ZoneOverviewPanel({
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="text-xs font-medium tracking-wide text-muted-foreground">
|
||||
{formatWindowLabel(stats?.window_started_at, stats?.window_ended_at)}
|
||||
<p className='text-xs font-medium tracking-wide text-muted-foreground'>
|
||||
{formatWindowLabel(
|
||||
stats?.window_started_at,
|
||||
stats?.window_ended_at,
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{statsQuery.isLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({length: 3}).map((_, index) => (
|
||||
<Skeleton key={index} className="h-36 w-full rounded-xl" />
|
||||
<div className='space-y-3'>
|
||||
{Array.from({ length: 3 }).map((_, index) => (
|
||||
<Skeleton key={index} className='h-36 w-full rounded-xl' />
|
||||
))}
|
||||
</div>
|
||||
) : statsQuery.isError ? (
|
||||
<div className="rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground">
|
||||
<div className='rounded-xl border border-dashed p-6 text-center text-sm text-muted-foreground'>
|
||||
加载流量统计失败
|
||||
<div className="mt-3">
|
||||
<div className='mt-3'>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="h-7 text-xs"
|
||||
size='sm'
|
||||
variant='outline'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => void statsQuery.refetch()}
|
||||
>
|
||||
重试
|
||||
@@ -153,58 +156,60 @@ export function ZoneOverviewPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className='space-y-3'>
|
||||
{!stats?.available ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
<p className='text-xs text-muted-foreground'>
|
||||
分析存储暂不可用,图表可能为空。请确认 ClickHouse 已启用。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<MetricTrendCard
|
||||
icon={Users}
|
||||
label="唯一访问者"
|
||||
label='唯一访问者'
|
||||
value={formatCompactNumber(stats?.unique_visitors ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="visitors"
|
||||
dataKey='visitors'
|
||||
config={visitorsChartConfig}
|
||||
gradientId="zone-visitors"
|
||||
gradientId='zone-visitors'
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={Activity}
|
||||
label="请求总数"
|
||||
label='请求总数'
|
||||
value={formatCompactNumber(stats?.request_count ?? 0)}
|
||||
data={chartData}
|
||||
dataKey="requests"
|
||||
dataKey='requests'
|
||||
config={requestsChartConfig}
|
||||
gradientId="zone-requests"
|
||||
gradientId='zone-requests'
|
||||
/>
|
||||
<MetricTrendCard
|
||||
icon={HardDrive}
|
||||
label="已提供的数据总计"
|
||||
value={formatBytes(stats?.bytes_sent ?? 0, {zeroText: '0 B'})}
|
||||
label='已提供的数据总计'
|
||||
value={formatBytes(stats?.bytes_sent ?? 0, { zeroText: '0 B' })}
|
||||
data={chartData}
|
||||
dataKey="bytes"
|
||||
dataKey='bytes'
|
||||
config={bytesChartConfig}
|
||||
gradientId="zone-bytes"
|
||||
valueFormatter={(value) => formatBytes(value, {zeroText: '0 B'})}
|
||||
gradientId='zone-bytes'
|
||||
valueFormatter={(value) =>
|
||||
formatBytes(value, { zeroText: '0 B' })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="border-dashed shadow-none md:col-span-3">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">Zone 信息</CardTitle>
|
||||
<div className='grid gap-4 md:grid-cols-3'>
|
||||
<Card className='border-dashed shadow-none md:col-span-3'>
|
||||
<CardHeader className='pb-2'>
|
||||
<CardTitle className='text-sm'>Zone 信息</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 text-sm sm:grid-cols-2">
|
||||
<CardContent className='grid gap-3 text-sm sm:grid-cols-2'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">根域</p>
|
||||
<p className="mt-1 font-medium">{overview.zone.domain}</p>
|
||||
<p className='text-xs text-muted-foreground'>根域</p>
|
||||
<p className='mt-1 font-medium'>{overview.zone.domain}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">创建时间</p>
|
||||
<p className="mt-1">{formatDateTime(overview.zone.created_at)}</p>
|
||||
<p className='text-xs text-muted-foreground'>创建时间</p>
|
||||
<p className='mt-1'>{formatDateTime(overview.zone.created_at)}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
@@ -233,44 +238,58 @@ function MetricTrendCard({
|
||||
valueFormatter?: (value: number) => string;
|
||||
}) {
|
||||
return (
|
||||
<Card className="overflow-hidden border shadow-none">
|
||||
<CardContent className="p-0">
|
||||
<div className="grid gap-0 md:grid-cols-[minmax(140px,200px)_1fr]">
|
||||
<div className="flex flex-col justify-between gap-3 border-b p-4 md:border-b-0 md:border-r">
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Icon className="size-3.5 text-primary" />
|
||||
<Card className='overflow-hidden border shadow-none'>
|
||||
<CardContent className='p-0'>
|
||||
<div className='grid gap-0 md:grid-cols-[minmax(140px,200px)_1fr]'>
|
||||
<div className='flex flex-col justify-between gap-3 border-b p-4 md:border-b-0 md:border-r'>
|
||||
<div className='flex items-center gap-1.5 text-xs text-muted-foreground'>
|
||||
<Icon className='size-3.5 text-primary' />
|
||||
<span>{label}</span>
|
||||
</div>
|
||||
<p className="text-3xl font-semibold tracking-tight">{value}</p>
|
||||
<p className='text-3xl font-semibold tracking-tight'>{value}</p>
|
||||
</div>
|
||||
<div className="h-36 min-h-[9rem] w-full px-2 py-3 md:h-40">
|
||||
<ChartContainer config={config} className="h-full w-full aspect-auto">
|
||||
<AreaChart data={data} margin={{top: 8, right: 12, left: 0, bottom: 0}}>
|
||||
<div className='h-36 min-h-[9rem] w-full px-2 py-3 md:h-40'>
|
||||
<ChartContainer
|
||||
config={config}
|
||||
className='h-full w-full aspect-auto'
|
||||
>
|
||||
<AreaChart
|
||||
data={data}
|
||||
margin={{ top: 8, right: 12, left: 0, bottom: 0 }}
|
||||
>
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
|
||||
<stop offset="5%" stopColor="var(--color-value)" stopOpacity={0.28} />
|
||||
<stop offset="95%" stopColor="var(--color-value)" stopOpacity={0.02} />
|
||||
<linearGradient id={gradientId} x1='0' y1='0' x2='0' y2='1'>
|
||||
<stop
|
||||
offset='5%'
|
||||
stopColor='var(--color-value)'
|
||||
stopOpacity={0.28}
|
||||
/>
|
||||
<stop
|
||||
offset='95%'
|
||||
stopColor='var(--color-value)'
|
||||
stopOpacity={0.02}
|
||||
/>
|
||||
</linearGradient>
|
||||
</defs>
|
||||
<CartesianGrid
|
||||
strokeDasharray="3 3"
|
||||
strokeDasharray='3 3'
|
||||
vertical={false}
|
||||
className="stroke-border/60"
|
||||
className='stroke-border/60'
|
||||
/>
|
||||
<XAxis
|
||||
dataKey="label"
|
||||
dataKey='label'
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
minTickGap={28}
|
||||
tickMargin={8}
|
||||
className="text-[10px]"
|
||||
className='text-[10px]'
|
||||
/>
|
||||
<YAxis
|
||||
tickLine={false}
|
||||
axisLine={false}
|
||||
width={36}
|
||||
tickMargin={4}
|
||||
className="text-[10px]"
|
||||
className='text-[10px]'
|
||||
allowDecimals={false}
|
||||
tickFormatter={(tick) =>
|
||||
valueFormatter
|
||||
@@ -291,10 +310,10 @@ function MetricTrendCard({
|
||||
}
|
||||
/>
|
||||
<Area
|
||||
type="monotone"
|
||||
type='monotone'
|
||||
dataKey={dataKey}
|
||||
name="value"
|
||||
stroke="var(--color-value)"
|
||||
name='value'
|
||||
stroke='var(--color-value)'
|
||||
strokeWidth={2}
|
||||
fill={`url(#${gradientId})`}
|
||||
isAnimationActive={false}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
'use client';
|
||||
|
||||
import {usePathname, useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {ArrowLeft, Globe, Pencil, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||
import { ArrowLeft, Globe, Pencil, Trash2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
@@ -16,10 +16,10 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import {
|
||||
ProxyRouteService,
|
||||
TlsCertificateService,
|
||||
@@ -27,17 +27,19 @@ import {
|
||||
zoneQueryKey,
|
||||
} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from '../components/website-utils';
|
||||
import {ZoneCertificatesPanel} from './components/zone-certificates';
|
||||
import {ZoneDomainsTable} from './components/zone-domains-table';
|
||||
import {ZoneEditorDialog} from './components/zone-editor-dialog';
|
||||
import {ZoneOverviewPanel} from './components/zone-overview';
|
||||
import { getErrorMessage } from '../components/website-utils';
|
||||
import { ZoneCertificatesPanel } from './components/zone-certificates';
|
||||
import { ZoneDomainsTable } from './components/zone-domains-table';
|
||||
import { ZoneEditorDialog } from './components/zone-editor-dialog';
|
||||
import { ZoneOverviewPanel } from './components/zone-overview';
|
||||
|
||||
const zoneTabs = ['overview', 'domains', 'certificates', 'settings'] as const;
|
||||
export type ZonePageTab = (typeof zoneTabs)[number];
|
||||
|
||||
function getZonePageTab(value: string | null | undefined): ZonePageTab {
|
||||
return zoneTabs.includes(value as ZonePageTab) ? (value as ZonePageTab) : 'overview';
|
||||
return zoneTabs.includes(value as ZonePageTab)
|
||||
? (value as ZonePageTab)
|
||||
: 'overview';
|
||||
}
|
||||
|
||||
function getZoneIdFromPathname(pathname: string | null): number {
|
||||
@@ -73,7 +75,9 @@ export function ZonePageClient() {
|
||||
params.set('tab', next);
|
||||
}
|
||||
const query = params.toString();
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {scroll: false});
|
||||
router.replace(query ? `${pathname}?${query}` : pathname, {
|
||||
scroll: false,
|
||||
});
|
||||
},
|
||||
[pathname, router, searchParams],
|
||||
);
|
||||
@@ -100,7 +104,7 @@ export function ZonePageClient() {
|
||||
mutationFn: () => ZoneService.deleteById(zoneId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Zone 已删除');
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey});
|
||||
await queryClient.invalidateQueries({ queryKey: zoneQueryKey });
|
||||
window.location.assign('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
@@ -108,18 +112,21 @@ export function ZonePageClient() {
|
||||
|
||||
if (!mounted) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={Globe}
|
||||
description='加载 Zone 详情中...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!Number.isInteger(zoneId) || zoneId <= 0) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<div className='py-6 px-1'>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="无效的网站 ID,请从网站列表进入详情页。"
|
||||
description='无效的网站 ID,请从网站列表进入详情页。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -127,27 +134,30 @@ export function ZonePageClient() {
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={Globe}
|
||||
description='加载 Zone 详情中...'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overviewQuery.data) {
|
||||
return (
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<div className='space-y-4 py-6 px-1'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-0 text-xs'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
<ArrowLeft className='size-3.5' />
|
||||
返回
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
description='网站不存在,可能已被删除或 ID 无效。'
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
@@ -157,51 +167,67 @@ export function ZonePageClient() {
|
||||
const certificates = certificatesQuery.data ?? [];
|
||||
const routes = routesQuery.data ?? [];
|
||||
const boundCertCount = new Set(
|
||||
overview.domains.map((domain) => domain.cert_id).filter((id): id is number => id != null),
|
||||
overview.domains
|
||||
.map((domain) => domain.cert_id)
|
||||
.filter((id): id is number => id != null),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-6 px-1">
|
||||
<div className="space-y-4">
|
||||
<div className='space-y-6 py-6 px-1'>
|
||||
<div className='space-y-4'>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1.5 px-0 text-xs"
|
||||
variant='ghost'
|
||||
size='sm'
|
||||
className='h-8 gap-1.5 px-0 text-xs'
|
||||
onClick={() => router.back()}
|
||||
>
|
||||
<ArrowLeft className="size-3.5" />
|
||||
<ArrowLeft className='size-3.5' />
|
||||
返回
|
||||
</Button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className='size-5 text-primary' />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{overview.zone.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">Zone 网站管理</p>
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>
|
||||
{overview.zone.domain}
|
||||
</h1>
|
||||
<p className='text-sm text-muted-foreground'>Zone 网站管理</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="mb-6 inline-flex w-fit gap-8">
|
||||
<TabsTrigger value="overview" className="px-0 pb-2 text-xs font-semibold">
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className='w-full'>
|
||||
<TabsList variant='line' className='mb-6 inline-flex w-fit gap-8'>
|
||||
<TabsTrigger
|
||||
value='overview'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="px-0 pb-2 text-xs font-semibold">
|
||||
<TabsTrigger
|
||||
value='domains'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
域名 ({overview.domains.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="certificates" className="px-0 pb-2 text-xs font-semibold">
|
||||
<TabsTrigger
|
||||
value='certificates'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
证书 ({boundCertCount})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
|
||||
<TabsTrigger
|
||||
value='settings'
|
||||
className='px-0 pb-2 text-xs font-semibold'
|
||||
>
|
||||
设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<TabsContent value='overview'>
|
||||
<ZoneOverviewPanel overview={overview} zoneId={zoneId} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<TabsContent value='domains'>
|
||||
<ZoneDomainsTable
|
||||
zoneId={zoneId}
|
||||
zoneRoot={overview.zone.domain}
|
||||
@@ -212,49 +238,56 @@ export function ZonePageClient() {
|
||||
onChanged={() => overviewQuery.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="certificates">
|
||||
<ZoneCertificatesPanel domains={overview.domains} certificates={certificates} />
|
||||
<TabsContent value='certificates'>
|
||||
<ZoneCertificatesPanel
|
||||
domains={overview.domains}
|
||||
certificates={certificates}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="space-y-4">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-semibold">基本信息</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<TabsContent value='settings'>
|
||||
<div className='space-y-4'>
|
||||
<div className='rounded-lg border p-4'>
|
||||
<p className='text-sm font-semibold'>基本信息</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
维护 Zone 的可注册根域。根域变更后,请确认其下域名仍归属该根域。
|
||||
</p>
|
||||
<div className="mt-3 grid gap-2 text-sm sm:grid-cols-2">
|
||||
<div className='mt-3 grid gap-2 text-sm sm:grid-cols-2'>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">当前根域</p>
|
||||
<p className="mt-1 font-mono text-[13px] font-medium">{overview.zone.domain}</p>
|
||||
<p className='text-xs text-muted-foreground'>当前根域</p>
|
||||
<p className='mt-1 font-mono text-[13px] font-medium'>
|
||||
{overview.zone.domain}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">域名数量</p>
|
||||
<p className="mt-1 font-mono text-[13px] font-medium">{overview.domains.length}</p>
|
||||
<p className='text-xs text-muted-foreground'>域名数量</p>
|
||||
<p className='mt-1 font-mono text-[13px] font-medium'>
|
||||
{overview.domains.length}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
variant='outline'
|
||||
size='sm'
|
||||
className='mt-4 h-7 text-xs'
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
<Pencil className='mr-1 size-3.5' />
|
||||
编辑根域
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-destructive/30 p-4">
|
||||
<p className="text-sm font-semibold text-destructive">危险操作</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
<div className='rounded-lg border border-destructive/30 p-4'>
|
||||
<p className='text-sm font-semibold text-destructive'>危险操作</p>
|
||||
<p className='mt-1 text-sm text-muted-foreground'>
|
||||
删除 Zone 前须清空其下全部域名;删除后不可恢复。
|
||||
</p>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
variant='destructive'
|
||||
size='sm'
|
||||
className='mt-4 h-7 text-xs'
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
<Trash2 className='mr-1 size-3.5' />
|
||||
删除 Zone
|
||||
</Button>
|
||||
</div>
|
||||
@@ -273,13 +306,16 @@ export function ZonePageClient() {
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {overview.zone.domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
|
||||
确认删除 {overview.zone.domain}{' '}
|
||||
吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteZone.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogCancel disabled={deleteZone.isPending}>
|
||||
取消
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
className='bg-destructive text-white hover:bg-destructive/90'
|
||||
disabled={deleteZone.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -1,20 +1,25 @@
|
||||
import {Suspense} from 'react'
|
||||
import {Globe} from 'lucide-react'
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading'
|
||||
import {ZonePageClient} from './page-client'
|
||||
import { Suspense } from 'react';
|
||||
import { Globe } from 'lucide-react';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import { ZonePageClient } from './page-client';
|
||||
|
||||
export async function generateStaticParams() {
|
||||
return [{zoneId: '1'}];
|
||||
return [{ zoneId: '1' }];
|
||||
}
|
||||
|
||||
export default async function ZonePage() {
|
||||
return (
|
||||
<Suspense fallback={
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
</div>
|
||||
}>
|
||||
<Suspense
|
||||
fallback={
|
||||
<div className='py-6 px-1'>
|
||||
<LoadingStateWithBorder
|
||||
icon={Globe}
|
||||
description='加载 Zone 详情中...'
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<ZonePageClient />
|
||||
</Suspense>
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,23 +1,35 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {useRouter} from 'next/navigation';
|
||||
import {Eye, Globe, Plus, Search} from 'lucide-react';
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Eye, Globe, Plus, Search } from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip';
|
||||
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||
import { ErrorInline } from '@/components/layout/error';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { LoadingStateWithBorder } from '@/components/layout/loading';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { ZoneService, zoneQueryKey } from '@/lib/services/openflare';
|
||||
import { formatDateTime } from '@/lib/utils';
|
||||
|
||||
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
|
||||
import {getErrorMessage} from './components/website-utils';
|
||||
import { ZoneEditorDialog } from './[zoneId]/components/zone-editor-dialog';
|
||||
import { getErrorMessage } from './components/website-utils';
|
||||
|
||||
export default function WebsitesPage() {
|
||||
const router = useRouter();
|
||||
@@ -38,40 +50,43 @@ export default function WebsitesPage() {
|
||||
}, [search, zonesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<div className="flex items-center justify-between gap-3 pb-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
||||
<div className='space-y-4 py-6 px-1'>
|
||||
<div className='flex items-center justify-between gap-3 pb-2'>
|
||||
<div className='flex items-center gap-2'>
|
||||
<Globe className='size-5 text-primary' />
|
||||
<h1 className='text-2xl font-semibold tracking-tight'>网站</h1>
|
||||
</div>
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
variant='secondary'
|
||||
size='sm'
|
||||
className='h-7 text-xs'
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
<Plus className='mr-1 size-3.5' />
|
||||
新增 Zone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<div className='relative w-full sm:w-64'>
|
||||
<Search className='pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground' />
|
||||
<Input
|
||||
aria-label="搜索 Zone 根域"
|
||||
placeholder="搜索 Zone 根域"
|
||||
aria-label='搜索 Zone 根域'
|
||||
placeholder='搜索 Zone 根域'
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
className='h-8 pl-8 text-xs'
|
||||
/>
|
||||
</div>
|
||||
|
||||
{zonesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
|
||||
<LoadingStateWithBorder
|
||||
icon={Globe}
|
||||
description='加载 Zone 列表中...'
|
||||
/>
|
||||
) : zonesQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-8">
|
||||
<div className='rounded-lg border border-dashed p-8'>
|
||||
<ErrorInline
|
||||
className="justify-center"
|
||||
className='justify-center'
|
||||
message={getErrorMessage(zonesQuery.error)}
|
||||
onRetry={() => void zonesQuery.refetch()}
|
||||
/>
|
||||
@@ -86,27 +101,27 @@ export default function WebsitesPage() {
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-dashed shadow-none">
|
||||
<div className='overflow-hidden rounded-lg border border-dashed shadow-none'>
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Table className="w-full min-w-full caption-bottom text-sm">
|
||||
<TableHeader className="sticky top-0 z-20 bg-background">
|
||||
<TableRow className="border-b border-dashed hover:bg-transparent">
|
||||
<TableHead className="h-8 w-[90px] whitespace-nowrap py-2">
|
||||
<Table className='w-full min-w-full caption-bottom text-sm'>
|
||||
<TableHeader className='sticky top-0 z-20 bg-background'>
|
||||
<TableRow className='border-b border-dashed hover:bg-transparent'>
|
||||
<TableHead className='h-8 w-[90px] whitespace-nowrap py-2'>
|
||||
ID
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[180px] whitespace-nowrap py-2">
|
||||
<TableHead className='h-8 min-w-[180px] whitespace-nowrap py-2'>
|
||||
根域
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[100px] whitespace-nowrap py-2">
|
||||
<TableHead className='h-8 min-w-[100px] whitespace-nowrap py-2'>
|
||||
域名数
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
<TableHead className='h-8 min-w-[140px] whitespace-nowrap py-2'>
|
||||
创建时间
|
||||
</TableHead>
|
||||
<TableHead className="h-8 min-w-[140px] whitespace-nowrap py-2">
|
||||
<TableHead className='h-8 min-w-[140px] whitespace-nowrap py-2'>
|
||||
更新时间
|
||||
</TableHead>
|
||||
<TableHead className="sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center">
|
||||
<TableHead className='sticky right-0 z-10 h-8 w-[90px] bg-background py-2 text-center'>
|
||||
操作
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
@@ -115,44 +130,44 @@ export default function WebsitesPage() {
|
||||
{zones.map((zone) => (
|
||||
<TableRow
|
||||
key={zone.id}
|
||||
className="group cursor-pointer border-dashed hover:bg-muted/30"
|
||||
className='group cursor-pointer border-dashed hover:bg-muted/30'
|
||||
onClick={() => router.push(`/websites/${zone.id}`)}
|
||||
>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
<TableCell className='py-1 font-mono text-[11px] text-muted-foreground'>
|
||||
{zone.id}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] font-medium">
|
||||
<TableCell className='py-1 font-mono text-[11px] font-medium'>
|
||||
{zone.domain}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[11px] text-muted-foreground">
|
||||
<TableCell className='py-1 font-mono text-[11px] text-muted-foreground'>
|
||||
{zone.domain_count ?? 0}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
<TableCell className='py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground'>
|
||||
{formatDateTime(zone.created_at)}
|
||||
</TableCell>
|
||||
<TableCell className="py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground">
|
||||
<TableCell className='py-1 font-mono text-[10px] whitespace-nowrap text-muted-foreground'>
|
||||
{formatDateTime(zone.updated_at)}
|
||||
</TableCell>
|
||||
<TableCell
|
||||
className="sticky right-0 z-10 bg-background py-1 text-center"
|
||||
className='sticky right-0 z-10 bg-background py-1 text-center'
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-0.5">
|
||||
<div className='flex items-center justify-center gap-0.5'>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-foreground"
|
||||
variant='ghost'
|
||||
size='icon'
|
||||
className='h-6 w-6 text-muted-foreground hover:text-foreground'
|
||||
asChild
|
||||
>
|
||||
<Link href={`/websites/${zone.id}`}>
|
||||
<Eye className="size-3" />
|
||||
<span className="sr-only">管理</span>
|
||||
<Eye className='size-3' />
|
||||
<span className='sr-only'>管理</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="text-xs">
|
||||
<TooltipContent side='top' className='text-xs'>
|
||||
管理 Zone
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
|
||||
Reference in New Issue
Block a user