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