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}