mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-13 18:36:37 +08:00
feat(web): add zone-based website management
以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供 概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。 Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
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 {getCertificateStatus} from '../../components/website-utils';
|
||||
|
||||
export function ZoneCertificatesPanel({
|
||||
domains,
|
||||
certificates,
|
||||
}: {
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
}) {
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const boundCertificates = useMemo(() => {
|
||||
const rows = new Map<
|
||||
number,
|
||||
{certificate: TlsCertificateItem; domains: ZoneDomainItem[]}
|
||||
>();
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain.cert_id == null) {
|
||||
continue;
|
||||
}
|
||||
const certificate = certificateMap.get(domain.cert_id);
|
||||
if (!certificate) {
|
||||
continue;
|
||||
}
|
||||
const existing = rows.get(certificate.id);
|
||||
if (existing) {
|
||||
existing.domains.push(domain);
|
||||
} else {
|
||||
rows.set(certificate.id, {certificate, domains: [domain]});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(rows.values()).sort((left, right) =>
|
||||
left.certificate.name.localeCompare(right.certificate.name),
|
||||
);
|
||||
}, [certificateMap, domains]);
|
||||
|
||||
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>
|
||||
<CardTitle className="text-base">本 Zone 使用的证书</CardTitle>
|
||||
<CardDescription>
|
||||
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/certificates">
|
||||
证书库
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{unboundCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{boundCertificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description="暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>覆盖域名</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
</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>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect} 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 {Dialog, DialogContent, DialogDescription, DialogFooter, 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 {Textarea} from '@/components/ui/textarea'
|
||||
import {TlsCertificateService, ZoneDomainService, type ZoneDomainItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({domain: z.string().trim().min(1, '请输入完整域名').refine((value) => !value.includes('*.'), 'Zone 域名不支持通配符'), cert_id: z.string(), remark: z.string().max(255)})
|
||||
type Values = z.infer<typeof schema>
|
||||
export function ZoneDomainDialog({open, onOpenChange, zoneId, domain, onSaved}: {open: boolean; onOpenChange(open: boolean): void; zoneId: number; domain?: ZoneDomainItem | null; onSaved(): Promise<unknown> | void}) {
|
||||
const form = useForm<Values>({resolver: zodResolver(schema), defaultValues: {domain: '', cert_id: '', remark: ''}}); const queryClient = useQueryClient()
|
||||
const certificatesQuery = useQuery({queryKey: ['openflare', 'tls-certificates'], queryFn: () => TlsCertificateService.list(), enabled: open})
|
||||
useEffect(() => { if (open) form.reset({domain: domain?.domain ?? '', cert_id: domain?.cert_id?.toString() ?? '', remark: domain?.remark ?? ''}) }, [domain, form, open])
|
||||
const mutation = useMutation({mutationFn: (values: Values) => {const payload = {domain: values.domain.toLowerCase(), cert_id: values.cert_id ? Number(values.cert_id) : null, remark: values.remark.trim()}; return domain ? ZoneDomainService.update(zoneId, domain.id, payload) : ZoneDomainService.create(zoneId, payload)}, onSuccess: async () => {toast.success(domain ? '域名已更新' : '域名已添加'); await Promise.all([onSaved(), queryClient.invalidateQueries({queryKey: ['openflare', 'zones']})]); onOpenChange(false)}, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>{domain ? '编辑 Zone 域名' : '添加 Zone 域名'}</DialogTitle><DialogDescription>填写明确 FQDN;通配符仅可存在于所选证书的 SAN 中。</DialogDescription></DialogHeader><form id="zone-domain-editor" className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}><div className="space-y-1.5"><Label htmlFor="fqdn">完整域名</Label><Input id="fqdn" placeholder="api.arctel.de" {...form.register('domain')} />{form.formState.errors.domain && <p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>}</div><div className="space-y-1.5"><Label>证书</Label><Select value={form.watch('cert_id') || '__none'} onValueChange={(value) => form.setValue('cert_id', value === '__none' ? '' : value)}><SelectTrigger><SelectValue placeholder="不绑定证书" /></SelectTrigger><SelectContent><SelectItem value="__none">不绑定证书</SelectItem>{(certificatesQuery.data ?? []).map((certificate) => <SelectItem key={certificate.id} value={String(certificate.id)}>{certificate.name} · {certificate.primary_domain}</SelectItem>)}</SelectContent></Select></div><div className="space-y-1.5"><Label htmlFor="domain-remark">备注</Label><Textarea id="domain-remark" rows={2} {...form.register('remark')} /></div></form><DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button type="submit" form="zone-domain-editor" disabled={mutation.isPending}>{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}{domain ? '保存修改' : '添加域名'}</Button></DialogFooter></DialogContent></Dialog>
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {MoreHorizontal, Pencil, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {ZoneDomainService, type TlsCertificateItem, type ZoneDomainItem} from '@/lib/services/openflare';
|
||||
|
||||
import {ZoneDomainDialog} from './zone-domain-dialog';
|
||||
|
||||
export function ZoneDomainsTable({
|
||||
zoneId,
|
||||
domains,
|
||||
certificates,
|
||||
onChanged,
|
||||
}: {
|
||||
zoneId: number;
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
onChanged(): Promise<unknown> | void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<ZoneDomainItem | null | undefined>(undefined);
|
||||
const [deleting, setDeleting] = useState<ZoneDomainItem | null>(null);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
|
||||
onSuccess: async () => {
|
||||
toast.success('域名已删除');
|
||||
setDeleting(null);
|
||||
await onChanged();
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
});
|
||||
|
||||
const certificateMap = new Map(certificates.map((certificate) => [certificate.id, certificate]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditing(null)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无已添加域名" />
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>FQDN</TableHead>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>关联路由</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">{domain.domain}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`)
|
||||
: '未绑定'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{domain.proxy_route_id ? `#${domain.proxy_route_id}` : '未关联'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-muted-foreground">
|
||||
{domain.remark || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`操作 ${domain.domain}`}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditing(domain)}>
|
||||
<Pencil />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleting(domain)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ZoneDomainDialog
|
||||
open={editing !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(undefined);
|
||||
}
|
||||
}}
|
||||
zoneId={zoneId}
|
||||
domain={editing ?? null}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除域名</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {deleting?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={remove.isPending || !deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (deleting) {
|
||||
remove.mutate(deleting.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'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 {Textarea} from '@/components/ui/textarea'
|
||||
import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({domain: z.string().trim().min(1, '请输入 Zone 根域').refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'), remark: z.string().max(255)})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function ZoneEditorDialog({open, onOpenChange, zone}: {open: boolean; onOpenChange(open: boolean): void; zone?: ZoneItem | null}) {
|
||||
const queryClient = useQueryClient(); const form = useForm<Values>({resolver: zodResolver(schema), defaultValues: {domain: '', remark: ''}})
|
||||
useEffect(() => { if (open) form.reset({domain: zone?.domain ?? '', remark: zone?.remark ?? ''}) }, [form, open, zone])
|
||||
const mutation = useMutation({mutationFn: (values: Values) => zone ? ZoneService.update(zone.id, values) : ZoneService.create(values), onSuccess: async () => { toast.success(zone ? 'Zone 已更新' : 'Zone 已创建'); await queryClient.invalidateQueries({queryKey: zoneQueryKey}); onOpenChange(false) }, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle><DialogDescription>Zone 仅接受可注册根域,例如 arctel.de。</DialogDescription></DialogHeader><form id="zone-editor" className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate({domain: values.domain.toLowerCase(), remark: values.remark.trim()}))}><div className="space-y-1.5"><Label htmlFor="zone-domain">根域</Label><Input id="zone-domain" placeholder="arctel.de" {...form.register('domain')} />{form.formState.errors.domain && <p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>}</div><div className="space-y-1.5"><Label htmlFor="zone-remark">备注</Label><Textarea id="zone-remark" rows={2} {...form.register('remark')} /></div></form><DialogFooter><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" />}{zone ? '保存修改' : '新增 Zone'}</Button></DialogFooter></DialogContent></Dialog>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import type {ZoneOverview} from '@/lib/services/openflare'
|
||||
import {formatDateTime} from '@/lib/utils'
|
||||
|
||||
export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
|
||||
const boundRoutes = new Set(overview.domains.flatMap((domain) => domain.proxy_route_id ? [domain.proxy_route_id] : [])).size
|
||||
const certificates = overview.domains.filter((domain) => domain.cert_id !== null).length
|
||||
return <div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">域名</CardTitle><CardDescription>显式 FQDN</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{overview.domains.length}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">关联路由</CardTitle><CardDescription>已绑定的不同规则</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{boundRoutes}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">已配证书</CardTitle><CardDescription>域名级 TLS 绑定</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{certificates}</CardContent></Card>
|
||||
<Card className="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"><div><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></div><div className="sm:col-span-2"><p className="text-xs text-muted-foreground">备注</p><p className="mt-1">{overview.zone.remark || '暂无备注'}</p></div></CardContent></Card>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty'
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import type {ZoneDomainItem} from '@/lib/services/openflare'
|
||||
import {Route} from 'lucide-react'
|
||||
|
||||
export function ZoneRouteSummary({domains}: {domains: ZoneDomainItem[]}) {
|
||||
const groups = Map.groupBy(domains.filter((domain) => domain.proxy_route_id !== null), (domain) => domain.proxy_route_id!)
|
||||
return <Card className="shadow-none"><CardHeader><CardTitle className="text-base">路由关联</CardTitle><CardDescription>域名只能关联一个反代路由;路由可关联多个 Zone 域名。</CardDescription></CardHeader><CardContent>{groups.size === 0 ? <EmptyStateWithBorder icon={Route} description="暂无关联反代路由。" /> : <div className="space-y-3">{[...groups].map(([routeID, items]) => <div key={routeID} className="rounded-lg border p-3"><p className="text-sm font-semibold">路由 #{routeID}</p><p className="mt-1 text-xs text-muted-foreground">{items.map((item) => item.domain).join(' · ')}</p></div>)}</div>}</CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {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,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
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 {TlsCertificateService, ZoneService, 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 {ZoneRouteSummary} from './components/zone-route-summary';
|
||||
|
||||
export function ZonePageClient({zoneId}: {zoneId: number}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [editZone, setEditZone] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId],
|
||||
queryFn: () => ZoneService.getOverview(zoneId),
|
||||
enabled: Number.isInteger(zoneId) && zoneId > 0,
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: overviewQuery.isSuccess,
|
||||
});
|
||||
|
||||
const deleteZone = useMutation({
|
||||
mutationFn: () => ZoneService.deleteById(zoneId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Zone 已删除');
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey});
|
||||
window.location.assign('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!Number.isInteger(zoneId) || zoneId <= 0) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="无效的网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return (
|
||||
<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">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
返回网站列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const certificates = certificatesQuery.data ?? [];
|
||||
const boundCertCount = new Set(
|
||||
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="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="mr-1 size-3.5" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
编辑 Zone
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
删除 Zone
|
||||
</Button>
|
||||
</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">
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="px-0 pb-2 text-xs font-semibold">
|
||||
域名 ({overview.domains.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes" className="px-0 pb-2 text-xs font-semibold">
|
||||
路由
|
||||
</TabsTrigger>
|
||||
<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>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<ZoneOverviewPanel overview={overview} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<ZoneDomainsTable
|
||||
zoneId={zoneId}
|
||||
domains={overview.domains}
|
||||
certificates={certificates}
|
||||
onChanged={() => overviewQuery.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="routes">
|
||||
<ZoneRouteSummary domains={overview.domains} />
|
||||
</TabsContent>
|
||||
<TabsContent value="certificates">
|
||||
<ZoneCertificatesPanel domains={overview.domains} certificates={certificates} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-semibold">Zone 设置</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
根域和备注可在此 Zone 的编辑操作中维护。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
编辑根域与备注
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ZoneEditorDialog
|
||||
open={editZone}
|
||||
onOpenChange={setEditZone}
|
||||
zone={overview.zone}
|
||||
/>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {overview.zone.domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteZone.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={deleteZone.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteZone.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteZone.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ZonePageClient} from './page-client'
|
||||
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
const {zoneId} = await params
|
||||
return <ZonePageClient zoneId={Number(zoneId)} />
|
||||
}
|
||||
Reference in New Issue
Block a user