diff --git a/docs/docs.go b/docs/docs.go index 9c4cde39..ceb1a18e 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -10849,7 +10849,7 @@ const docTemplate = `{ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Zone" + "$ref": "#/definitions/zone.ListItem" } } } @@ -16857,36 +16857,12 @@ const docTemplate = `{ "type": "string" } }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "custom_headers": { "type": "array", "items": { "$ref": "#/definitions/proxy_route.CustomHeaderInput" } }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, "enable_https": { "type": "boolean" }, @@ -16955,6 +16931,12 @@ const docTemplate = `{ "items": { "type": "string" } + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } } } }, @@ -16985,15 +16967,6 @@ const docTemplate = `{ "cache_rules": { "type": "string" }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "created_at": { "type": "string" }, @@ -17006,24 +16979,6 @@ const docTemplate = `{ "custom_headers": { "type": "string" }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domain_count": { - "type": "integer" - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, "enable_https": { "type": "boolean" }, @@ -17054,9 +17009,6 @@ const docTemplate = `{ "pages_project_id": { "type": "integer" }, - "primary_domain": { - "type": "string" - }, "redirect_http": { "type": "boolean" }, @@ -17092,6 +17044,35 @@ const docTemplate = `{ }, "upstreams": { "type": "string" + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "zone_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.ZoneDomainView" + } + } + } + }, + "proxy_route.ZoneDomainView": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" } } }, @@ -18799,6 +18780,29 @@ const docTemplate = `{ } } }, + "zone.ListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "zone.Overview": { "type": "object", "properties": { diff --git a/docs/plan/20260712-zone-domain-refactor.md b/docs/plan/20260712-zone-domain-refactor.md index 4ed96af4..0912d32a 100644 --- a/docs/plan/20260712-zone-domain-refactor.md +++ b/docs/plan/20260712-zone-domain-refactor.md @@ -52,7 +52,7 @@ - Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`. - Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK. -- [ ] **Step 1: 写失败的模型与迁移测试** +- [x] **Step 1: 写失败的模型与迁移测试** ```go func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) { @@ -64,7 +64,7 @@ Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count= Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。 -- [ ] **Step 2: 新建双方言 DDL** +- [x] **Step 2: 新建双方言 DDL** ```sql CREATE TABLE IF NOT EXISTS of_zones ( @@ -94,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_ SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。 -- [ ] **Step 3: 实现模型和受事务保护的绑定替换** +- [x] **Step 3: 实现模型和受事务保护的绑定替换** ```go type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time } @@ -105,13 +105,13 @@ func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs 实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。 -- [ ] **Step 4: 运行模型与迁移测试** +- [x] **Step 4: 运行模型与迁移测试** Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1` Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add internal/db/migrator/goose internal/model @@ -132,7 +132,7 @@ git commit -m "feat(zone): add normalized zone domain schema" - Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers. - Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`. -- [ ] **Step 1: 写失败的逻辑与 API 测试** +- [x] **Step 1: 写失败的逻辑与 API 测试** ```go func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) } @@ -145,7 +145,7 @@ func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) { 集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。 -- [ ] **Step 2: 实现精确域名和 Zone 归属验证** +- [x] **Step 2: 实现精确域名和 Zone 归属验证** ```go func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) } @@ -154,7 +154,7 @@ func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.Z 拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。 -- [ ] **Step 3: 实现显式导入命令** +- [x] **Step 3: 实现显式导入命令** ```go var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error { @@ -165,7 +165,7 @@ var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.C 导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。 -- [ ] **Step 4: 注册 API 并删除旧 managed-domain 路由** +- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由** ```go zoneGroup := apiGroup.Group("/zones") @@ -177,7 +177,7 @@ zoneGroup.GET("/:id/overview", zone.GetOverviewHandler) 把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。 -- [ ] **Step 5: 验证并 Commit** +- [x] **Step 5: 验证并 Commit** Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger` @@ -200,7 +200,7 @@ git commit -m "feat(zone): add zone management api and legacy importer" - Consumes: `zone_domain_ids []uint` and Task 1 binding API. - Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`. -- [ ] **Step 1: 写失败的路由逻辑测试** +- [x] **Step 1: 写失败的路由逻辑测试** ```go input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true} @@ -211,7 +211,7 @@ require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs) 同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。 -- [ ] **Step 2: 删除路由输入/视图中的旧域名与证书字段** +- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段** ```go type ZoneDomainBindingInput struct { @@ -222,17 +222,17 @@ type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`; 移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。 -- [ ] **Step 3: 用关联记录验证并构建路由** +- [x] **Step 3: 用关联记录验证并构建路由** 在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。 -- [ ] **Step 4: 运行路由和 TLS 回归测试** +- [x] **Step 4: 运行路由和 TLS 回归测试** Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1` Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model @@ -250,7 +250,7 @@ git commit -m "refactor(proxy): bind routes through zone domains" **Interfaces:** - Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields. -- [ ] **Step 1: 写快照等价性失败测试** +- [x] **Step 1: 写快照等价性失败测试** ```go func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) { @@ -260,7 +260,7 @@ func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) { 加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。 -- [ ] **Step 2: 在快照边界联查并生成临时渲染字段** +- [x] **Step 2: 在快照边界联查并生成临时渲染字段** ```go domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID) @@ -270,17 +270,17 @@ snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains) `pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。 -- [ ] **Step 3: 移除旧字段回退路径** +- [x] **Step 3: 移除旧字段回退路径** 删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。 -- [ ] **Step 4: 运行数据面测试** +- [x] **Step 4: 运行数据面测试** Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1` Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty @@ -305,7 +305,7 @@ git commit -m "refactor(config): render routes from zone domains" **Interfaces:** - Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types. -- [ ] **Step 1: 写服务与页面行为测试** +- [x] **Step 1: 写服务与页面行为测试** 先安装仅用于本次页面测试的开发依赖: @@ -320,7 +320,7 @@ expect(screen.getByRole('heading', {name: 'arctel.de'})).toBeVisible() 覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。 -- [ ] **Step 2: 实现类型化服务与查询键** +- [x] **Step 2: 实现类型化服务与查询键** ```ts export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string } @@ -330,7 +330,7 @@ export const zoneQueryKey = ['openflare', 'zones'] as const 所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。 -- [ ] **Step 3: 用 Next 动态段实现 Zone 详情** +- [x] **Step 3: 用 Next 动态段实现 Zone 详情** ```tsx export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) { @@ -341,11 +341,11 @@ export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'> 遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。 -- [ ] **Step 4: 实现列表和详情交互** +- [x] **Step 4: 实现列表和详情交互** 列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。 -- [ ] **Step 5: 验证并 Commit** +- [x] **Step 5: 验证并 Commit** Run: `cd frontend && pnpm exec vitest run && pnpm lint` @@ -370,7 +370,7 @@ git commit -m "feat(web): add zone-based website management" - Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`. - Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。 -- [ ] **Step 1: 写失败的选择器测试** +- [x] **Step 1: 写失败的选择器测试** ```tsx render() @@ -380,11 +380,11 @@ expect(onChange).toHaveBeenCalledWith([7]) 覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。 -- [ ] **Step 2: 移除旧前端负载与自动匹配** +- [x] **Step 2: 移除旧前端负载与自动匹配** 从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。 -- [ ] **Step 3: 实现 Zone 域名选择和保存负载** +- [x] **Step 3: 实现 Zone 域名选择和保存负载** ```ts mutationFn: (payload) => ProxyRouteService.update(route.id, { @@ -395,13 +395,13 @@ mutationFn: (payload) => ProxyRouteService.update(route.id, { 展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。 -- [ ] **Step 4: 运行前端类型和交互测试** +- [x] **Step 4: 运行前端类型和交互测试** Run: `cd frontend && pnpm exec tsc --noEmit` Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。 -- [ ] **Step 5: Commit** +- [x] **Step 5: Commit** ```bash git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts diff --git a/docs/swagger.json b/docs/swagger.json index 2f43e071..a98e7191 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -10842,7 +10842,7 @@ "data": { "type": "array", "items": { - "$ref": "#/definitions/model.Zone" + "$ref": "#/definitions/zone.ListItem" } } } @@ -16850,36 +16850,12 @@ "type": "string" } }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "custom_headers": { "type": "array", "items": { "$ref": "#/definitions/proxy_route.CustomHeaderInput" } }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, "enable_https": { "type": "boolean" }, @@ -16948,6 +16924,12 @@ "items": { "type": "string" } + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } } } }, @@ -16978,15 +16960,6 @@ "cache_rules": { "type": "string" }, - "cert_id": { - "type": "integer" - }, - "cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, "created_at": { "type": "string" }, @@ -16999,24 +16972,6 @@ "custom_headers": { "type": "string" }, - "domain": { - "type": "string" - }, - "domain_cert_ids": { - "type": "array", - "items": { - "type": "integer" - } - }, - "domain_count": { - "type": "integer" - }, - "domains": { - "type": "array", - "items": { - "type": "string" - } - }, "enable_https": { "type": "boolean" }, @@ -17047,9 +17002,6 @@ "pages_project_id": { "type": "integer" }, - "primary_domain": { - "type": "string" - }, "redirect_http": { "type": "boolean" }, @@ -17085,6 +17037,35 @@ }, "upstreams": { "type": "string" + }, + "zone_domain_ids": { + "type": "array", + "items": { + "type": "integer" + } + }, + "zone_domains": { + "type": "array", + "items": { + "$ref": "#/definitions/proxy_route.ZoneDomainView" + } + } + } + }, + "proxy_route.ZoneDomainView": { + "type": "object", + "properties": { + "cert_id": { + "type": "integer" + }, + "domain": { + "type": "string" + }, + "id": { + "type": "integer" + }, + "zone_id": { + "type": "integer" } } }, @@ -18792,6 +18773,29 @@ } } }, + "zone.ListItem": { + "type": "object", + "properties": { + "created_at": { + "type": "string" + }, + "domain": { + "type": "string" + }, + "domain_count": { + "type": "integer" + }, + "id": { + "type": "integer" + }, + "remark": { + "type": "string" + }, + "updated_at": { + "type": "string" + } + } + }, "zone.Overview": { "type": "object", "properties": { diff --git a/docs/swagger.yaml b/docs/swagger.yaml index e2eed193..f2795d8d 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -2727,26 +2727,10 @@ definitions: items: type: string type: array - cert_id: - type: integer - cert_ids: - items: - type: integer - type: array custom_headers: items: $ref: '#/definitions/proxy_route.CustomHeaderInput' type: array - domain: - type: string - domain_cert_ids: - items: - type: integer - type: array - domains: - items: - type: string - type: array enable_https: type: boolean enabled: @@ -2793,6 +2777,10 @@ definitions: items: type: string type: array + zone_domain_ids: + items: + type: integer + type: array type: object proxy_route.View: properties: @@ -2812,12 +2800,6 @@ definitions: type: array cache_rules: type: string - cert_id: - type: integer - cert_ids: - items: - type: integer - type: array created_at: type: string custom_header_list: @@ -2826,18 +2808,6 @@ definitions: type: array custom_headers: type: string - domain: - type: string - domain_cert_ids: - items: - type: integer - type: array - domain_count: - type: integer - domains: - items: - type: string - type: array enable_https: type: boolean enabled: @@ -2858,8 +2828,6 @@ definitions: type: string pages_project_id: type: integer - primary_domain: - type: string redirect_http: type: boolean remark: @@ -2884,6 +2852,25 @@ definitions: type: string upstreams: type: string + zone_domain_ids: + items: + type: integer + type: array + zone_domains: + items: + $ref: '#/definitions/proxy_route.ZoneDomainView' + type: array + type: object + proxy_route.ZoneDomainView: + properties: + cert_id: + type: integer + domain: + type: string + id: + type: integer + zone_id: + type: integer type: object push.Config: properties: @@ -4026,6 +4013,21 @@ definitions: remark: type: string type: object + zone.ListItem: + properties: + created_at: + type: string + domain: + type: string + domain_count: + type: integer + id: + type: integer + remark: + type: string + updated_at: + type: string + type: object zone.Overview: properties: domains: @@ -10555,7 +10557,7 @@ paths: - properties: data: items: - $ref: '#/definitions/model.Zone' + $ref: '#/definitions/zone.ListItem' type: array type: object security: diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx new file mode 100644 index 00000000..3c8fee49 --- /dev/null +++ b/frontend/app/(main)/websites/[zoneId]/components/zone-certificates.tsx @@ -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 ( +
+ + +
+ 本 Zone 使用的证书 + + 证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。 + +
+ +
+ + {unboundCount > 0 ? ( +

+ 还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。 +

+ ) : null} + + {boundCertificates.length === 0 ? ( + + ) : ( +
+ + + + 证书 + 状态 + 覆盖域名 + 到期时间 + + + + {boundCertificates.map(({certificate, domains: boundDomains}) => { + const status = getCertificateStatus(certificate); + return ( + + + {certificate.name} +

+ {certificate.primary_domain || `证书 #${certificate.id}`} +

+
+ + + {status.label} + + + + {boundDomains.map((domain) => domain.domain).join(' · ')} + + + {formatDateTime(certificate.not_after)} + +
+ ); + })} +
+
+
+ )} +
+
+
+ ); +} diff --git a/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx b/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx new file mode 100644 index 00000000..b9ceda97 --- /dev/null +++ b/frontend/app/(main)/websites/[zoneId]/components/zone-domain-dialog.tsx @@ -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 +export function ZoneDomainDialog({open, onOpenChange, zoneId, domain, onSaved}: {open: boolean; onOpenChange(open: boolean): void; zoneId: number; domain?: ZoneDomainItem | null; onSaved(): Promise | void}) { + const form = useForm({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 {domain ? '编辑 Zone 域名' : '添加 Zone 域名'}填写明确 FQDN;通配符仅可存在于所选证书的 SAN 中。
mutation.mutate(values))}>
{form.formState.errors.domain &&

{form.formState.errors.domain.message}

}