From 7ef84cce520768244e9f8bc6a829626e6196460a Mon Sep 17 00:00:00 2001 From: ryan Date: Sun, 12 Jul 2026 15:50:43 +0800 Subject: [PATCH] feat(web): improve zone domain list and detail navigation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 合并 Zone 域名与路由展示,上游地址多行显示并支持路由详情跳转; 域名列表对齐用户管理页样式;Zone 页支持 ?tab= 定位;反代详情返回 使用浏览器历史上一级。 --- .../(main)/proxy-routes/components/helpers.ts | 31 ++- .../detail/components/route-header.tsx | 16 +- .../components/zone-domains-table.tsx | 241 +++++++++++++----- .../components/zone-route-summary.tsx | 9 - .../(main)/websites/[zoneId]/page-client.tsx | 56 +++- frontend/tests/zone/zone-page.test.tsx | 17 +- 6 files changed, 268 insertions(+), 102 deletions(-) delete mode 100644 frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx diff --git a/frontend/app/(main)/proxy-routes/components/helpers.ts b/frontend/app/(main)/proxy-routes/components/helpers.ts index 425ea523..95666714 100644 --- a/frontend/app/(main)/proxy-routes/components/helpers.ts +++ b/frontend/app/(main)/proxy-routes/components/helpers.ts @@ -93,21 +93,36 @@ export function getRouteDomainsLabel(route: ProxyRouteItem): string { return names.length > 0 ? names.join(', ') : '未绑定域名'; } -export function getUpstreamSummary(route: ProxyRouteItem): string { +/** Upstream address labels for display (supports multi-upstream). */ +export function getUpstreamLabels(route: ProxyRouteItem): string[] { if (route.upstream_type === 'pages') { - return route.pages_project_id - ? `Pages 项目 #${route.pages_project_id}` - : 'Pages 项目未绑定'; + return [ + route.pages_project_id + ? `Pages 项目 #${route.pages_project_id}` + : 'Pages 项目未绑定', + ]; } if (route.upstream_type === 'tunnel') { const protocol = route.tunnel_target_protocol || 'http'; const target = route.tunnel_target_addr || '未配置目标'; - return `Tunnel → ${protocol}://${target}`; + return [`Tunnel → ${protocol}://${target}`]; } - if ((route.upstream_list ?? []).length <= 1) { - return route.origin_url; + const list = (route.upstream_list ?? []).filter(Boolean); + if (list.length > 0) { + return list; } - return `${route.upstream_list.length} 个上游,主上游 ${route.origin_url}`; + return route.origin_url ? [route.origin_url] : []; +} + +export function getUpstreamSummary(route: ProxyRouteItem): string { + const labels = getUpstreamLabels(route); + if (labels.length === 0) { + return '未配置上游'; + } + if (labels.length === 1) { + return labels[0]; + } + return labels.join(' · '); } const originHostPattern = diff --git a/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx b/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx index bfd3ae8d..d4e99ba4 100644 --- a/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx +++ b/frontend/app/(main)/proxy-routes/detail/components/route-header.tsx @@ -1,6 +1,6 @@ 'use client'; -import Link from 'next/link'; +import {useRouter} from 'next/navigation'; import {useCallback, useState} from 'react'; import {ArrowLeft, Loader2, Route, Upload} from 'lucide-react'; import {toast} from 'sonner'; @@ -41,6 +41,7 @@ interface RouteHeaderProps { } export function RouteHeader({ route }: RouteHeaderProps) { + const router = useRouter(); const [publishConfirmOpen, setPublishConfirmOpen] = useState(false); const [diff, setDiff] = useState(null); const [diffLoading, setDiffLoading] = useState(false); @@ -90,11 +91,14 @@ export function RouteHeader({ route }: RouteHeaderProps) { return ( <>
-
diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx index de8d468a..913f3b10 100644 --- a/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx +++ b/frontend/app/(main)/websites/[zoneId]/components/zone-domains-table.tsx @@ -1,8 +1,9 @@ 'use client'; -import {useState} from 'react'; +import Link from 'next/link'; +import {useMemo, useState} from 'react'; import {useMutation} from '@tanstack/react-query'; -import {MoreHorizontal, Pencil, Plus, Trash2} from 'lucide-react'; +import {Eye, Pencil, Plus, Trash2} from 'lucide-react'; import {toast} from 'sonner'; import { @@ -16,27 +17,32 @@ import { 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 {Tooltip, TooltipContent, TooltipProvider, TooltipTrigger} from '@/components/ui/tooltip'; +import { + ZoneDomainService, + type ProxyRouteItem, + type TlsCertificateItem, + type ZoneDomainItem, +} from '@/lib/services/openflare'; +import {getUpstreamLabels} from '../../../proxy-routes/components/helpers'; import {ZoneDomainDialog} from './zone-domain-dialog'; export function ZoneDomainsTable({ zoneId, domains, certificates, + routes, + routesLoading = false, onChanged, }: { zoneId: number; domains: ZoneDomainItem[]; certificates: TlsCertificateItem[]; + routes: ProxyRouteItem[]; + routesLoading?: boolean; onChanged(): Promise | void; }) { const [editing, setEditing] = useState(undefined); @@ -52,12 +58,24 @@ export function ZoneDomainsTable({ onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'), }); - const certificateMap = new Map(certificates.map((certificate) => [certificate.id, certificate])); + const certificateMap = useMemo( + () => new Map(certificates.map((certificate) => [certificate.id, certificate])), + [certificates], + ); + const routeMap = useMemo( + () => new Map(routes.map((route) => [route.id, route])), + [routes], + ); return ( <> -
- @@ -66,62 +84,151 @@ export function ZoneDomainsTable({ {domains.length === 0 ? ( ) : ( -
- - - - FQDN - 证书 - 关联路由 - 备注 - - - - - {domains.map((domain) => ( - - {domain.domain} - - {domain.cert_id - ? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`) - : '未绑定'} - - - {domain.proxy_route_id ? `#${domain.proxy_route_id}` : '未关联'} - - - {domain.remark || '—'} - - - - - - - - setEditing(domain)}> - - 编辑 - - setDeleting(domain)} - > - - 删除 - - - - +
+ +
+ + + + FQDN + + + 证书 + + + 上游 + + + 备注 + + + 操作 + - ))} - -
+ + + {domains.map((domain) => { + const route = + domain.proxy_route_id != null + ? routeMap.get(domain.proxy_route_id) + : undefined; + const upstreams = route ? getUpstreamLabels(route) : []; + const certLabel = domain.cert_id + ? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`) + : '未绑定'; + + return ( + + + + {domain.domain} + + + + {certLabel} + + + {!domain.proxy_route_id ? ( + + 未关联路由 + + ) : routesLoading && !route ? ( + + 加载中… + + ) : upstreams.length === 0 ? ( + + 未配置上游 + + ) : ( +
+ {upstreams.map((upstream) => ( + + {upstream} + + ))} +
+ )} +
+ + {domain.remark || '—'} + + event.stopPropagation()} + > +
+ {domain.proxy_route_id ? ( + + + + + + 查看路由详情 + + + ) : null} + + + + + + + 编辑域名 + + + + + + + + + 删除域名 + + +
+
+
+ ); + })} +
+ +
)} diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx deleted file mode 100644 index 44a5e444..00000000 --- a/frontend/app/(main)/websites/[zoneId]/components/zone-route-summary.tsx +++ /dev/null @@ -1,9 +0,0 @@ -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 路由关联域名只能关联一个反代路由;路由可关联多个 Zone 域名。{groups.size === 0 ? :
{[...groups].map(([routeID, items]) =>

路由 #{routeID}

{items.map((item) => item.domain).join(' · ')}

)}
}
-} diff --git a/frontend/app/(main)/websites/[zoneId]/page-client.tsx b/frontend/app/(main)/websites/[zoneId]/page-client.tsx index 92dd676a..cea85008 100644 --- a/frontend/app/(main)/websites/[zoneId]/page-client.tsx +++ b/frontend/app/(main)/websites/[zoneId]/page-client.tsx @@ -1,7 +1,8 @@ 'use client'; import Link from 'next/link'; -import {useState} from 'react'; +import {usePathname, useRouter, useSearchParams} from 'next/navigation'; +import {useCallback, useMemo, useState} from 'react'; import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'; import {ArrowLeft, Globe, Pencil, Trash2} from 'lucide-react'; import {toast} from 'sonner'; @@ -20,21 +21,53 @@ 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 { + ProxyRouteService, + 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'; + +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'; +} export function ZonePageClient({zoneId}: {zoneId: number}) { + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); const queryClient = useQueryClient(); - const [activeTab, setActiveTab] = useState('overview'); + const activeTab = useMemo( + () => getZonePageTab(searchParams.get('tab')), + [searchParams], + ); const [editZone, setEditZone] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); + const setActiveTab = useCallback( + (tab: string) => { + const next = getZonePageTab(tab); + const params = new URLSearchParams(searchParams.toString()); + if (next === 'overview') { + params.delete('tab'); + } else { + params.set('tab', next); + } + const query = params.toString(); + router.replace(query ? `${pathname}?${query}` : pathname, {scroll: false}); + }, + [pathname, router, searchParams], + ); + const overviewQuery = useQuery({ queryKey: [...zoneQueryKey, zoneId], queryFn: () => ZoneService.getOverview(zoneId), @@ -47,6 +80,12 @@ export function ZonePageClient({zoneId}: {zoneId: number}) { enabled: overviewQuery.isSuccess, }); + const routesQuery = useQuery({ + queryKey: ['openflare', 'proxy-routes'], + queryFn: () => ProxyRouteService.list(), + enabled: overviewQuery.isSuccess, + }); + const deleteZone = useMutation({ mutationFn: () => ZoneService.deleteById(zoneId), onSuccess: async () => { @@ -95,6 +134,7 @@ export function ZonePageClient({zoneId}: {zoneId: number}) { const overview = overviewQuery.data; 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), ).size; @@ -145,9 +185,6 @@ export function ZonePageClient({zoneId}: {zoneId: number}) { 域名 ({overview.domains.length}) - - 路由 - 证书 ({boundCertCount}) @@ -164,12 +201,11 @@ export function ZonePageClient({zoneId}: {zoneId: number}) { zoneId={zoneId} domains={overview.domains} certificates={certificates} + routes={routes} + routesLoading={routesQuery.isLoading} onChanged={() => overviewQuery.refetch()} /> - - - diff --git a/frontend/tests/zone/zone-page.test.tsx b/frontend/tests/zone/zone-page.test.tsx index 8b3e51ab..4f4cbf5a 100644 --- a/frontend/tests/zone/zone-page.test.tsx +++ b/frontend/tests/zone/zone-page.test.tsx @@ -3,7 +3,9 @@ import {render, screen, waitFor} from '@testing-library/react'; import {beforeEach, describe, expect, it, vi} from 'vitest'; import {ZonePageClient} from '@/app/(main)/websites/[zoneId]/page-client'; -import {TlsCertificateService, ZoneService} from '@/lib/services/openflare'; +import {ProxyRouteService, TlsCertificateService, ZoneService} from '@/lib/services/openflare'; + +const replaceMock = vi.fn(); vi.mock('next/link', () => ({ default: ({children, href}: {children: React.ReactNode; href: string}) => ( @@ -11,6 +13,12 @@ vi.mock('next/link', () => ({ ), })); +vi.mock('next/navigation', () => ({ + useRouter: () => ({replace: replaceMock}), + usePathname: () => '/websites/42', + useSearchParams: () => new URLSearchParams(), +})); + vi.mock('@/lib/services/openflare', async (importOriginal) => { const actual = await importOriginal(); return { @@ -23,6 +31,9 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => { TlsCertificateService: { list: vi.fn(), }, + ProxyRouteService: { + list: vi.fn(), + }, }; }); @@ -45,6 +56,8 @@ describe('ZonePageClient', () => { vi.mocked(ZoneService.deleteById).mockReset(); vi.mocked(TlsCertificateService.list).mockReset(); vi.mocked(TlsCertificateService.list).mockResolvedValue([]); + vi.mocked(ProxyRouteService.list).mockReset(); + vi.mocked(ProxyRouteService.list).mockResolvedValue([]); }); it('loads the overview by stable ID and exposes all tabs including empty domains', async () => { @@ -61,7 +74,7 @@ describe('ZonePageClient', () => { expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible(); expect(screen.getByRole('tab', {name: '域名 (0)'})).toBeVisible(); expect(screen.getByRole('tab', {name: '证书 (0)'})).toBeVisible(); - expect(screen.getByRole('tab', {name: '路由'})).toBeVisible(); + expect(screen.queryByRole('tab', {name: '路由'})).not.toBeInTheDocument(); expect(screen.getByRole('tab', {name: '设置'})).toBeVisible(); });