feat(web): add zone-based website management

以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供
概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。
Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
This commit is contained in:
ryan
2026-07-12 15:23:43 +08:00
parent d4d9bad74d
commit 350b433cc1
30 changed files with 2369 additions and 1178 deletions
@@ -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>
}