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
+59 -55
View File
@@ -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": {
+30 -30
View File
@@ -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(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
@@ -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
+59 -55
View File
@@ -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": {
+39 -37
View File
@@ -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:
@@ -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)} />
}
@@ -1,33 +1,5 @@
import {z} from 'zod';
export const managedDomainSchema = z.object({
domain: z
.string()
.trim()
.min(1, '请输入域名')
.max(255, '域名不能超过 255 个字符')
.refine(
(value) => !value.includes('://') && !value.includes('/'),
'域名格式不合法',
)
.refine(
(value) =>
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
value,
),
'域名格式不合法',
)
.refine(
(value) =>
!value.includes('*') ||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
'通配符域名仅支持 *.example.com 格式',
),
cert_id: z.string(),
enabled: z.boolean(),
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export const manualImportSchema = z.object({
name: z
.string()
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
remark: z.string().max(255, '备注不能超过 255 个字符'),
});
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
export type FileImportFormValues = {
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
remark: string;
};
export const defaultManagedDomainValues: ManagedDomainFormValues = {
domain: '',
cert_id: '',
enabled: true,
remark: '',
};
export const defaultManualImportValues: ManualImportFormValues = {
name: '',
cert_pem: '',
@@ -1,250 +0,0 @@
'use client';
import {zodResolver} from '@hookform/resolvers/zod';
import {useMutation, useQueryClient} from '@tanstack/react-query';
import {useEffect} from 'react';
import {useForm, useWatch} from 'react-hook-form';
import {Loader2} from 'lucide-react';
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 {Switch} from '@/components/ui/switch';
import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
import {WebsiteService} from '@/lib/services/openflare';
import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
import {WebsiteStatusBadge} from './status-badge';
import {
buildCertificateLabel,
getErrorMessage,
getMatchTypeMeta,
toManagedDomainFormValues,
toManagedDomainPayload,
} from './website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
interface WebsiteEditorDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
onRequestImportCertificate: () => void;
certificates: TlsCertificateItem[];
certificatesLoading?: boolean;
initialDomain?: ManagedDomainItem | null;
preferredCertificateId?: number | null;
}
export function WebsiteEditorDialog({
open,
onOpenChange,
onSaved,
onRequestImportCertificate,
certificates,
certificatesLoading = false,
initialDomain = null,
preferredCertificateId = null,
}: WebsiteEditorDialogProps) {
const queryClient = useQueryClient();
const form = useForm<ManagedDomainFormValues>({
resolver: zodResolver(managedDomainSchema),
defaultValues: defaultManagedDomainValues,
});
const watchedDomain = useWatch({control: form.control, name: 'domain'});
const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
const saveMutation = useMutation({
mutationFn: async (values: ManagedDomainFormValues) => {
const payload = toManagedDomainPayload(values);
return initialDomain
? WebsiteService.update(initialDomain.id, payload)
: WebsiteService.create(payload);
},
onSuccess: async (domain) => {
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
onSaved?.(domain, initialDomain ? 'update' : 'create');
handleClose();
},
});
const currentCertificate = watchedCertId
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
: null;
const handleSubmit = form.handleSubmit((values) => {
saveMutation.mutate(values);
});
const handleClose = () => {
saveMutation.reset();
form.reset(defaultManagedDomainValues);
onOpenChange(false);
};
useEffect(() => {
if (!open) return;
form.reset(
initialDomain
? toManagedDomainFormValues(initialDomain)
: defaultManagedDomainValues,
);
}, [form, initialDomain, open]);
useEffect(() => {
if (!open || !preferredCertificateId) return;
form.setValue('cert_id', String(preferredCertificateId), {
shouldDirty: true,
shouldValidate: true,
});
}, [form, open, preferredCertificateId]);
return (
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
<DialogContent className="max-w-2xl">
<DialogHeader>
<DialogTitle>{initialDomain ? '编辑网站' : '新增网站'}</DialogTitle>
<DialogDescription>
录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。
</DialogDescription>
</DialogHeader>
<form className="space-y-4" onSubmit={handleSubmit}>
{saveMutation.isError ? (
<p className="text-sm text-destructive">{getErrorMessage(saveMutation.error)}</p>
) : null}
<div className="grid gap-4 md:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="domain">域名</Label>
<Input
id="domain"
placeholder="example.com 或 *.example.com"
{...form.register('domain')}
/>
{form.formState.errors.domain ? (
<p className="text-xs text-destructive">
{form.formState.errors.domain.message}
</p>
) : null}
</div>
<div className="space-y-2">
<Label>绑定证书</Label>
<Select
value={watchedCertId || 'none'}
disabled={certificatesLoading}
onValueChange={(value) =>
form.setValue('cert_id', value === 'none' ? '' : value, {
shouldDirty: true,
shouldValidate: true,
})
}
>
<SelectTrigger>
<SelectValue placeholder="不绑定证书" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none">不绑定证书</SelectItem>
{certificates.map((certificate) => (
<SelectItem key={certificate.id} value={String(certificate.id)}>
{buildCertificateLabel(certificate)}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={onRequestImportCertificate}
>
添加证书
</Button>
</div>
</div>
<div className="grid gap-3 rounded-lg border border-dashed p-3 md:grid-cols-3">
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前域名</p>
<p className="mt-1 text-sm">{watchedDomain?.trim() || '未填写域名'}</p>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">匹配类型</p>
<div className="mt-1">
{watchedDomain?.trim() ? (
<WebsiteStatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
) : (
<WebsiteStatusBadge label="等待输入" tone="warning" />
)}
</div>
</div>
<div>
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前证书</p>
<div className="mt-1">
<WebsiteStatusBadge
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
tone={currentCertificate ? 'success' : 'warning'}
/>
</div>
</div>
</div>
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div>
<p className="text-sm font-medium">启用网站</p>
<p className="text-xs text-muted-foreground">
停用后该网站不会参与自动匹配,但记录会保留。
</p>
</div>
<Switch
checked={watchedEnabled}
onCheckedChange={(checked) =>
form.setValue('enabled', checked, {shouldDirty: true, shouldValidate: true})
}
/>
</div>
<div className="space-y-2">
<Label htmlFor="remark">备注</Label>
<Input
id="remark"
placeholder="例如:主站 / 泛域名 / 生产"
{...form.register('remark')}
/>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
取消
</Button>
<Button type="submit" disabled={saveMutation.isPending}>
{saveMutation.isPending ? (
<>
<Loader2 className="mr-1 size-3.5 animate-spin" />
保存中...
</>
) : initialDomain ? (
'保存网站'
) : (
'创建网站'
)}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
@@ -1,14 +1,11 @@
import type {
ManagedDomainItem,
ManagedDomainMutationPayload,
ProxyRouteItem,
TlsCertificateFileImportPayload,
TlsCertificateItem,
TlsCertificateMutationPayload,
} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
import type {FileImportFormValues, ManualImportFormValues} from './schemas';
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
: certificate.name;
}
export function toManagedDomainPayload(
values: ManagedDomainFormValues,
): ManagedDomainMutationPayload {
return {
domain: values.domain.trim().toLowerCase(),
cert_id: values.cert_id ? Number(values.cert_id) : null,
enabled: values.enabled,
remark: values.remark.trim(),
};
}
export function toManagedDomainFormValues(
domain: ManagedDomainItem,
): ManagedDomainFormValues {
return {
domain: domain.domain,
cert_id: domain.cert_id ? String(domain.cert_id) : '',
enabled: domain.enabled,
remark: domain.remark || '',
};
}
export function toManualPayload(
values: ManualImportFormValues,
): TlsCertificateMutationPayload {
@@ -100,20 +75,3 @@ export function toFilePayload(
keyFile,
};
}
export function isRouteRelatedToManagedDomain(
managedDomain: string,
route: ProxyRouteItem,
) {
const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
return domains.some((routeDomain) => {
if (managedDomain === routeDomain) {
return true;
}
if (!managedDomain.startsWith('*.')) {
return false;
}
const suffix = managedDomain.slice(2);
return routeDomain.endsWith(`.${suffix}`);
});
}
@@ -1,466 +0,0 @@
'use client';
import Link from 'next/link';
import {useRouter, useSearchParams} from 'next/navigation';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {ArrowLeft, Globe, 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 {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
import type {TlsCertificateItem} from '@/lib/services/openflare';
import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
import {formatDateTime} from '@/lib/utils';
import {getUpstreamSummary} from '@/app/(main)/proxy-routes/components/helpers';
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
import {CertificateImportDialog} from '../components/certificate-import-dialog';
import {WebsiteStatusBadge} from '../components/status-badge';
import {WebsiteEditorDialog} from '../components/website-editor-dialog';
import {
buildCertificateLabel,
getCertificateStatus,
getErrorMessage,
isRouteRelatedToManagedDomain,
} from '../components/website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
const certificatesQueryKey = ['openflare', 'tls-certificates'];
export function WebsiteDetailPageClient() {
const router = useRouter();
const searchParams = useSearchParams();
const queryClient = useQueryClient();
const websiteId = searchParams.get('id')?.trim() ?? '';
const [editorOpen, setEditorOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [detailOpen, setDetailOpen] = useState(false);
const [certEditorOpen, setCertEditorOpen] = useState(false);
const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
const [deleteCertOpen, setDeleteCertOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
const [convertCertificate, setConvertCertificate] = useState<TlsCertificateItem | null>(null);
const domainsQuery = useQuery({
queryKey: domainsQueryKey,
queryFn: () => WebsiteService.list(),
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const routesQuery = useQuery({
queryKey: ['openflare', 'proxy-routes'],
queryFn: () => ProxyRouteService.list(),
});
const website = useMemo(
() =>
(domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
[domainsQuery.data, websiteId],
);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const certificate = website?.cert_id
? (certificateMap.get(website.cert_id) ?? null)
: null;
const relatedRoutes = useMemo(() => {
if (!website) return [];
return (routesQuery.data ?? []).filter((route) =>
isRouteRelatedToManagedDomain(website.domain, route),
);
}, [routesQuery.data, website]);
const deleteDomainMutation = useMutation({
mutationFn: (id: number) => WebsiteService.deleteById(id),
onSuccess: async () => {
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
router.push('/websites');
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const deleteCertificateMutation = useMutation({
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
onSuccess: async () => {
toast.success('证书已删除');
setDeleteCertOpen(false);
await Promise.all([
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
queryClient.invalidateQueries({queryKey: domainsQueryKey}),
]);
},
onError: (error) => toast.error(getErrorMessage(error)),
});
if (!websiteId) {
return (
<div className="py-6 px-1">
<EmptyStateWithBorder
icon={Globe}
description="缺少网站 ID,请从网站列表进入详情页。"
/>
</div>
);
}
if (
domainsQuery.isLoading ||
certificatesQuery.isLoading ||
routesQuery.isLoading
) {
return (
<div className="py-6 px-1">
<LoadingStateWithBorder icon={Globe} description="加载网站详情中..." />
</div>
);
}
if (domainsQuery.isError) {
return (
<div className="py-6 px-1">
<ErrorInline
message={getErrorMessage(domainsQuery.error)}
onRetry={() => void domainsQuery.refetch()}
className="justify-center"
/>
</div>
);
}
if (!website) {
return (
<div className="py-6 px-1 space-y-4">
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
<Link href="/websites">
<ArrowLeft className="size-4 mr-1" />
返回网站列表
</Link>
</Button>
<EmptyStateWithBorder
icon={Globe}
description="网站不存在,可能已被删除或 ID 无效。"
/>
</div>
);
}
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
return (
<div className="py-6 px-1 space-y-6">
<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">{website.domain}</h1>
<p className="text-sm text-muted-foreground">网站详情</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="size-3.5 mr-1" />
返回
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setEditorOpen(true)}
>
编辑网站
</Button>
<Button size="sm" className="h-7 text-xs" onClick={() => setImportOpen(true)}>
<Plus className="size-3.5 mr-1" />
添加证书
</Button>
<Button
variant="destructive"
size="sm"
className="h-7 text-xs"
onClick={() => setDeleteWebsiteOpen(true)}
>
<Trash2 className="size-3.5 mr-1" />
删除网站
</Button>
</div>
</div>
<div className="grid gap-4 xl:grid-cols-2">
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">网站信息</CardTitle>
<CardDescription>当前网站的基础配置与托管信息。</CardDescription>
</CardHeader>
<CardContent className="grid gap-3 md:grid-cols-2">
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">域名</p>
<p className="mt-1 text-sm">{website.domain}</p>
</div>
<div className="rounded-lg border p-3">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
创建时间
</p>
<p className="mt-1 text-sm">{formatDateTime(website.created_at)}</p>
</div>
<div className="rounded-lg border p-3 md:col-span-2">
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
<p className="mt-1 text-sm">{website.remark || '暂无备注'}</p>
</div>
</CardContent>
</Card>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">证书信息</CardTitle>
<CardDescription>
当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
</CardDescription>
</CardHeader>
<CardContent>
{certificate ? (
<div className="space-y-3">
{certificateStatus ? (
<WebsiteStatusBadge
label={certificateStatus.label}
tone={certificateStatus.tone}
/>
) : null}
<div className="space-y-1 text-xs text-muted-foreground">
<p>证书名称:{buildCertificateLabel(certificate)}</p>
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
<p>备注:{certificate.remark || '暂无备注'}</p>
</div>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
className="h-7 text-xs"
onClick={() => setDetailOpen(true)}
>
查看
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteCertOpen(true)}
>
删除
</Button>
</div>
</div>
) : (
<EmptyStateWithBorder description="未绑定证书,点击右上角「添加证书」后可在编辑网站时直接选择。" />
)}
</CardContent>
</Card>
</div>
<Card className="shadow-none">
<CardHeader className="pb-2">
<CardTitle className="text-sm">关联规则</CardTitle>
<CardDescription>命中当前网站域名的代理规则。</CardDescription>
</CardHeader>
<CardContent>
{relatedRoutes.length === 0 ? (
<EmptyStateWithBorder description="暂无关联规则。" />
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead>规则域名</TableHead>
<TableHead>源站</TableHead>
<TableHead>HTTPS</TableHead>
<TableHead>证书</TableHead>
<TableHead>状态</TableHead>
<TableHead>备注</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{relatedRoutes.map((route) => {
const routeCertificate = route.cert_id
? (certificateMap.get(route.cert_id) ?? null)
: null;
const matchedDomain =
route.domains.find((d) => d === website.domain) ?? route.primary_domain;
return (
<TableRow key={route.id}>
<TableCell>
<div className="space-y-1">
<p className="text-sm">{matchedDomain}</p>
<WebsiteStatusBadge
label={
matchedDomain === website.domain
? '直接关联'
: '被网站覆盖'
}
tone={matchedDomain === website.domain ? 'info' : 'warning'}
/>
</div>
</TableCell>
<TableCell className="text-xs text-muted-foreground max-w-56 break-all">
{getUpstreamSummary(route)}
</TableCell>
<TableCell>
<WebsiteStatusBadge
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
tone={route.enable_https ? 'success' : 'warning'}
/>
</TableCell>
<TableCell className="text-xs">
{routeCertificate ? routeCertificate.name : '未绑定证书'}
</TableCell>
<TableCell>
<WebsiteStatusBadge
label={route.enabled ? '启用' : '停用'}
tone={route.enabled ? 'success' : 'warning'}
/>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{route.remark || '暂无备注'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
)}
</CardContent>
</Card>
<WebsiteEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
initialDomain={website}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setImportOpen(true)}
onSaved={() => toast.success('网站已更新')}
/>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(imported) => {
setPreferredCertificateId(imported.id);
toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
setEditorOpen(true);
}}
/>
<CertificateDetailDialog
certificateId={certificate?.id ?? null}
open={detailOpen}
onOpenChange={setDetailOpen}
onEdit={() => {
setDetailOpen(false);
setCertEditorOpen(true);
}}
onDelete={() => {
setDetailOpen(false);
setDeleteCertOpen(true);
}}
deleting={deleteCertificateMutation.isPending}
/>
<CertificateEditorDialog
certificateId={certificate?.id ?? null}
open={certEditorOpen}
onOpenChange={setCertEditorOpen}
onSaved={(updated) => toast.success(`证书 ${updated.name} 已更新`)}
onConvert={(manualCertificate) => {
setCertEditorOpen(false);
setConvertCertificate(manualCertificate);
}}
/>
{convertCertificate ? (
<CertificateApplyDialog
open
onOpenChange={(open) => !open && setConvertCertificate(null)}
mode="convert-upload"
certificate={convertCertificate}
onApplied={(converted) => {
setConvertCertificate(null);
toast.success(`证书 ${converted.name} 转换申请已提交`);
}}
/>
) : null}
<AlertDialog open={deleteWebsiteOpen} onOpenChange={setDeleteWebsiteOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
<AlertDialogDescription>
确认删除网站 {website.domain} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteDomainMutation.mutate(website.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleteCertOpen} onOpenChange={setDeleteCertOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
<AlertDialogDescription>
确认删除证书 {certificate?.name} 吗?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => certificate && deleteCertificateMutation.mutate(certificate.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</div>
);
}
@@ -1,23 +0,0 @@
import {Suspense} from 'react';
import {Skeleton} from '@/components/ui/skeleton';
import {WebsiteDetailPageClient} from './page-client';
function WebsiteDetailPageFallback() {
return (
<div className="py-6 px-1 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-xl" />
<Skeleton className="h-64 w-full" />
</div>
);
}
export default function WebsiteDetailPage() {
return (
<Suspense fallback={<WebsiteDetailPageFallback />}>
<WebsiteDetailPageClient />
</Suspense>
);
}
+72 -164
View File
@@ -1,211 +1,119 @@
'use client';
import Link from 'next/link';
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
import {useMemo, useState} from 'react';
import {Globe, Plus, Trash2} from 'lucide-react';
import {toast} from 'sonner';
import {useQuery} from '@tanstack/react-query';
import {Globe, Plus, Search} from 'lucide-react';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {Button} from '@/components/ui/button';
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
import {EmptyStateWithBorder} from '@/components/layout/empty';
import {ErrorInline} from '@/components/layout/error';
import {Input} from '@/components/ui/input';
import {LoadingStateWithBorder} from '@/components/layout/loading';
import type {ManagedDomainItem} from '@/lib/services/openflare';
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
import {CertificateImportDialog} from './components/certificate-import-dialog';
import {WebsiteStatusBadge} from './components/status-badge';
import {WebsiteEditorDialog} from './components/website-editor-dialog';
import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
const domainsQueryKey = ['openflare', 'managed-domains'];
const certificatesQueryKey = ['openflare', 'tls-certificates'];
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
import {getErrorMessage} from './components/website-utils';
export default function WebsitesPage() {
const queryClient = useQueryClient();
const [search, setSearch] = useState('');
const [editorOpen, setEditorOpen] = useState(false);
const [importOpen, setImportOpen] = useState(false);
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
const domainsQuery = useQuery({
queryKey: domainsQueryKey,
queryFn: () => WebsiteService.list(),
const zonesQuery = useQuery({
queryKey: zoneQueryKey,
queryFn: () => ZoneService.list(),
});
const certificatesQuery = useQuery({
queryKey: certificatesQueryKey,
queryFn: () => TlsCertificateService.list(),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => WebsiteService.deleteById(id),
onSuccess: async () => {
toast.success('网站已删除');
setDeleteTarget(null);
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
},
onError: (error) => toast.error(getErrorMessage(error)),
});
const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
const certificates = useMemo(
() => certificatesQuery.data ?? [],
[certificatesQuery.data],
);
const certificateMap = useMemo(
() => new Map(certificates.map((item) => [item.id, item])),
[certificates],
);
const zones = useMemo(() => {
const keyword = search.trim().toLowerCase();
const list = zonesQuery.data ?? [];
if (!keyword) {
return list;
}
return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
}, [search, zonesQuery.data]);
return (
<div className="py-6 px-1 space-y-6">
<div className="space-y-6 py-6 px-1">
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<Globe className="size-5 text-primary" />
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
</div>
<div className="flex items-center gap-2">
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
<Plus className="size-3.5 mr-1" />
新增网站
</Button>
</div>
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
<Plus className="mr-1 size-3.5" />
新增 Zone
</Button>
</div>
<Card className="border-dashed shadow-none">
<CardHeader className="pb-3">
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
<CardHeader className="gap-3 pb-3 sm:flex-row sm:items-end sm:justify-between">
<div>
<CardTitle className="text-base font-semibold">Zone 列表</CardTitle>
<CardDescription>
以可注册根域组织网站,并在详情中维护明确 FQDN、证书与路由关联。
</CardDescription>
</div>
<div className="relative w-full sm:w-64">
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
<Input
aria-label="搜索 Zone 根域"
placeholder="搜索 Zone 根域"
value={search}
onChange={(event) => setSearch(event.target.value)}
className="h-8 pl-8 text-xs"
/>
</div>
</CardHeader>
<CardContent>
{domainsQuery.isLoading ? (
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
) : domainsQuery.isError ? (
<div className="p-8 border border-dashed rounded-lg">
{zonesQuery.isLoading ? (
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
) : zonesQuery.isError ? (
<div className="rounded-lg border border-dashed p-8">
<ErrorInline
message={getErrorMessage(domainsQuery.error)}
onRetry={() => void domainsQuery.refetch()}
className="justify-center"
message={getErrorMessage(zonesQuery.error)}
onRetry={() => void zonesQuery.refetch()}
/>
</div>
) : domains.length === 0 ? (
) : zones.length === 0 ? (
<EmptyStateWithBorder
icon={Globe}
description="暂无网站,点击右上角「新增网站」开始录入。"
description={
search
? '未找到匹配的 Zone。'
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
}
/>
) : (
<div className="grid gap-3 lg:grid-cols-2">
{domains.map((domain) => {
const certificate = domain.cert_id
? (certificateMap.get(domain.cert_id) ?? null)
: null;
const matchType = getMatchTypeMeta(domain.domain);
return (
<div
key={domain.id}
className="rounded-lg border bg-card p-4 space-y-3"
>
<div className="flex items-start justify-between gap-3">
<div className="space-y-2 min-w-0">
<div className="flex flex-wrap items-center gap-2">
<h2 className="text-sm font-semibold truncate">{domain.domain}</h2>
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
<WebsiteStatusBadge
label={domain.enabled ? '启用' : '停用'}
tone={domain.enabled ? 'success' : 'warning'}
/>
</div>
<p className="text-xs text-muted-foreground">
{domain.remark || '暂无备注'}
</p>
<p className="text-xs text-muted-foreground">
绑定证书:
{certificate
? buildCertificateLabel(certificate)
: '未绑定证书'}
</p>
</div>
<div className="flex shrink-0 gap-1">
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
<Link href={`/websites/detail?id=${domain.id}`}>
详情
</Link>
</Button>
<Button
variant="outline"
size="sm"
className="h-7 text-xs text-destructive"
onClick={() => setDeleteTarget(domain)}
>
<Trash2 className="size-3" />
</Button>
</div>
{zones.map((zone) => (
<div key={zone.id} className="rounded-lg border bg-card p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<h2 className="truncate text-sm font-semibold">{zone.domain}</h2>
<p className="mt-2 text-xs text-muted-foreground">
{zone.domain_count ?? 0} 个域名
{zone.remark ? ` · ${zone.remark}` : ''}
</p>
</div>
<Button
variant="outline"
size="sm"
className="h-7 shrink-0 text-xs"
asChild
>
<Link href={`/websites/${zone.id}`}>管理</Link>
</Button>
</div>
);
})}
</div>
))}
</div>
)}
</CardContent>
</Card>
<WebsiteEditorDialog
open={editorOpen}
onOpenChange={setEditorOpen}
certificates={certificates}
certificatesLoading={certificatesQuery.isLoading}
preferredCertificateId={preferredCertificateId}
onRequestImportCertificate={() => setImportOpen(true)}
onSaved={(_, mode) => {
setPreferredCertificateId(null);
toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
}}
/>
<CertificateImportDialog
open={importOpen}
onOpenChange={setImportOpen}
onImported={(certificate) => {
setPreferredCertificateId(certificate.id);
toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
}}
/>
<AlertDialog
open={deleteTarget !== null}
onOpenChange={(open) => !open && setDeleteTarget(null)}
>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
<AlertDialogDescription>
确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>取消</AlertDialogCancel>
<AlertDialogAction
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
>
删除
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<ZoneEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
</div>
);
}
+5
View File
@@ -42,6 +42,8 @@ import {
UptimeKumaService,
WafService,
WebsiteService,
ZoneDomainService,
ZoneService,
} from './openflare';
const services = {
@@ -67,6 +69,8 @@ const services = {
openflareDashboard: DashboardService,
openflareWaf: WafService,
openflareWebsite: WebsiteService,
openflareZone: ZoneService,
openflareZoneDomain: ZoneDomainService,
openflareTls: TlsCertificateService,
openflareDns: DnsAccountService,
openflarePages: PagesService,
@@ -201,6 +205,7 @@ export {
export type {
NodeItem,
ProxyRouteItem,
ProxyRouteZoneDomain,
ProxyRouteConfigSection,
ConfigVersionSummary,
ConfigVersionDetail,
+7
View File
@@ -7,6 +7,7 @@ export { ApplyLogService } from './apply-log.service';
export { DashboardService } from './dashboard.service';
export { WafService } from './waf.service';
export { WebsiteService } from './website.service';
export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
export { TlsCertificateService } from './tls-certificate.service';
export { DnsAccountService } from './dns-account.service';
export { PagesService } from './pages.service';
@@ -49,6 +50,7 @@ export type {
ProxyRouteItem,
ProxyRouteMutationPayload,
ProxyRoutePoWConfig,
ProxyRouteZoneDomain,
ReleaseChannel,
SupportFile,
DashboardOverview,
@@ -105,6 +107,11 @@ export type {
ManagedDomainItem,
ManagedDomainMatchResult,
ManagedDomainMutationPayload,
ZoneDomainItem,
ZoneDomainMutationPayload,
ZoneItem,
ZoneMutationPayload,
ZoneOverview,
TlsCertificateApplyPayload,
TlsCertificateContentItem,
TlsCertificateDetailItem,
+49 -13
View File
@@ -228,13 +228,19 @@ export interface ProxyRoutePoWConfig {
blacklist: ProxyRoutePoWListConfig;
}
/** Route-bound Zone domain as returned by proxy-route APIs. */
export interface ProxyRouteZoneDomain {
id: number;
zone_id: number;
domain: string;
cert_id: number | null;
}
export interface ProxyRouteItem {
id: number;
site_name: string;
domain: string;
domains: string[];
primary_domain: string;
domain_count: number;
zone_domain_ids: number[];
zone_domains: ProxyRouteZoneDomain[];
origin_id: number | null;
origin_url: string;
origin_host: string;
@@ -242,9 +248,6 @@ export interface ProxyRouteItem {
upstream_list: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids: number[];
domain_cert_ids: number[];
redirect_http: boolean;
limit_conn_per_server: number;
limit_conn_per_ip: number;
@@ -271,8 +274,7 @@ export interface ProxyRouteItem {
export interface ProxyRouteMutationPayload {
site_name?: string;
domain: string;
domains?: string[];
zone_domain_ids: number[];
origin_id: number | null;
origin_url: string;
origin_scheme: 'http' | 'https';
@@ -283,9 +285,6 @@ export interface ProxyRouteMutationPayload {
upstreams: string[];
enabled: boolean;
enable_https: boolean;
cert_id: number | null;
cert_ids?: number[];
domain_cert_ids?: number[];
redirect_http: boolean;
limit_conn_per_server?: number;
limit_conn_per_ip?: number;
@@ -962,6 +961,43 @@ export interface DashboardOverviewCompact {
// ==================== Websites / TLS / DNS ====================
export interface ZoneItem {
id: number;
domain: string;
remark: string;
/** Present on list API; may be omitted on nested zone objects. */
domain_count?: number;
created_at: string;
updated_at: string;
}
export interface ZoneMutationPayload {
domain: string;
remark: string;
}
export interface ZoneDomainItem {
id: number;
zone_id: number;
proxy_route_id: number | null;
domain: string;
cert_id: number | null;
remark: string;
created_at: string;
updated_at: string;
}
export interface ZoneDomainMutationPayload {
domain: string;
cert_id: number | null;
remark: string;
}
export interface ZoneOverview {
zone: ZoneItem;
domains: ZoneDomainItem[];
}
export interface ManagedDomainItem {
id: number;
domain: string;
@@ -1078,4 +1114,4 @@ export interface DnsAccountMutationPayload {
name: string;
type: string;
authorization: string;
}
}
@@ -0,0 +1,22 @@
import {OpenFlareBaseService} from './base.service'
import type {ZoneDomainItem, ZoneDomainMutationPayload, ZoneItem, ZoneMutationPayload, ZoneOverview} from './types'
export const zoneQueryKey = ['openflare', 'zones'] as const
export class ZoneService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/zones'
static list(): Promise<ZoneItem[]> { return this.get<ZoneItem[]>('/') }
static getOverview(id: number): Promise<ZoneOverview> { return this.get<ZoneOverview>(`/${id}/overview`) }
static create(payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>('/', payload) }
static update(id: number, payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>(`/${id}/update`, payload) }
static deleteById(id: number): Promise<void> { return this.post<void>(`/${id}/delete`) }
}
export class ZoneDomainService extends OpenFlareBaseService {
protected static override readonly basePath = '/api/v1/d/zones'
static create(zoneId: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains`, payload) }
static update(zoneId: number, id: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains/${id}/update`, payload) }
static deleteById(zoneId: number, id: number): Promise<void> { return this.post<void>(`/${zoneId}/domains/${id}/delete`) }
}
+6 -1
View File
@@ -82,13 +82,18 @@
"@eslint/eslintrc": "^3",
"@tailwindcss/postcss": "^4",
"@tailwindcss/typography": "^0.5.19",
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@testing-library/user-event": "^14.6.1",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "15.5.6",
"jsdom": "^29.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5.9.3"
"typescript": "^5.9.3",
"vitest": "^4.1.10"
}
}
+1198
View File
File diff suppressed because it is too large Load Diff
+1
View File
@@ -0,0 +1 @@
import '@testing-library/jest-dom/vitest'
@@ -0,0 +1,64 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
import {fireEvent, render, screen} from '@testing-library/react';
import {describe, expect, it, vi} from 'vitest';
import WebsitesPage from '@/app/(main)/websites/page';
import {ZoneService} from '@/lib/services/openflare';
vi.mock('next/link', () => ({
default: ({children, href}: {children: React.ReactNode; href: string}) => (
<a href={href}>{children}</a>
),
}));
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/openflare')>();
return {...actual, ZoneService: {list: vi.fn()}};
});
function renderPage() {
const client = new QueryClient({defaultOptions: {queries: {retry: false}}});
return render(
<QueryClientProvider client={client}>
<WebsitesPage />
</QueryClientProvider>,
);
}
describe('WebsitesPage', () => {
it('filters zones, shows domain counts, and links to stable ID routes', async () => {
vi.mocked(ZoneService.list).mockResolvedValue([
{
id: 42,
domain: 'arctel.de',
remark: '生产站点',
domain_count: 3,
created_at: '',
updated_at: '',
},
{
id: 43,
domain: 'example.com',
remark: '',
domain_count: 0,
created_at: '',
updated_at: '',
},
]);
renderPage();
expect(await screen.findByRole('heading', {name: 'arctel.de'})).toBeVisible();
expect(screen.getByText(/3 个域名/)).toBeVisible();
expect(screen.getByText(/0 个域名/)).toBeVisible();
fireEvent.change(screen.getByPlaceholderText('搜索 Zone 根域'), {
target: {value: 'arctel'},
});
expect(screen.getByRole('link', {name: '管理'})).toHaveAttribute(
'href',
'/websites/42',
);
expect(screen.queryByText('example.com')).not.toBeInTheDocument();
});
});
+85
View File
@@ -0,0 +1,85 @@
import {QueryClient, QueryClientProvider} from '@tanstack/react-query';
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';
vi.mock('next/link', () => ({
default: ({children, href}: {children: React.ReactNode; href: string}) => (
<a href={href}>{children}</a>
),
}));
vi.mock('@/lib/services/openflare', async (importOriginal) => {
const actual = await importOriginal<typeof import('@/lib/services/openflare')>();
return {
...actual,
ZoneService: {
getOverview: vi.fn(),
deleteById: vi.fn(),
list: vi.fn(),
},
TlsCertificateService: {
list: vi.fn(),
},
};
});
function renderPage(zoneId: number) {
const client = new QueryClient({
defaultOptions: {
queries: {retry: false, gcTime: 0},
},
});
return render(
<QueryClientProvider client={client}>
<ZonePageClient zoneId={zoneId} />
</QueryClientProvider>,
);
}
describe('ZonePageClient', () => {
beforeEach(() => {
vi.mocked(ZoneService.getOverview).mockReset();
vi.mocked(ZoneService.deleteById).mockReset();
vi.mocked(TlsCertificateService.list).mockReset();
vi.mocked(TlsCertificateService.list).mockResolvedValue([]);
});
it('loads the overview by stable ID and exposes all tabs including empty domains', async () => {
vi.mocked(ZoneService.getOverview).mockImplementation(async () => ({
zone: {id: 42, domain: 'arctel.de', remark: '', created_at: '', updated_at: ''},
domains: [],
}));
renderPage(42);
await waitFor(() => {
expect(ZoneService.getOverview).toHaveBeenCalledWith(42);
});
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.getByRole('tab', {name: '设置'})).toBeVisible();
});
it('renders a not-found state for a missing Zone', async () => {
vi.mocked(ZoneService.getOverview).mockRejectedValue(new Error('Zone 不存在'));
renderPage(42);
expect(
await screen.findByText('网站不存在,可能已被删除或 ID 无效。'),
).toBeVisible();
});
it('renders invalid ID empty state without calling the API', async () => {
renderPage(0);
expect(
await screen.findByText('无效的网站 ID,请从网站列表进入详情页。'),
).toBeVisible();
expect(ZoneService.getOverview).not.toHaveBeenCalled();
});
});
+15
View File
@@ -0,0 +1,15 @@
import path from 'node:path'
import {defineConfig} from 'vitest/config'
export default defineConfig({
resolve: {
alias: {
'@': path.resolve(__dirname),
},
},
test: {
environment: 'jsdom',
globals: true,
setupFiles: ['./tests/setup.ts'],
},
})
+45 -4
View File
@@ -8,6 +8,7 @@ import (
"context"
"errors"
"strings"
"time"
"github.com/Rain-kl/Wavelet/internal/db"
"github.com/Rain-kl/Wavelet/internal/model"
@@ -34,6 +35,16 @@ type Overview struct {
Domains []model.ZoneDomain `json:"domains"`
}
// ListItem is a Zone list row with denormalized domain count for the UI.
type ListItem struct {
ID uint `json:"id"`
Domain string `json:"domain"`
Remark string `json:"remark"`
DomainCount int64 `json:"domain_count"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func zoneRoot(domain string) (string, error) {
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
}
@@ -99,11 +110,41 @@ func Update(ctx context.Context, id uint, input Input) (*model.Zone, error) {
return &zone, nil
}
// List returns all Zones in stable domain order.
func List(ctx context.Context) ([]model.Zone, error) {
// List returns all Zones in stable domain order, with domain counts for list cards.
func List(ctx context.Context) ([]ListItem, error) {
var zones []model.Zone
err := db.DB(ctx).Order("domain asc").Find(&zones).Error
return zones, err
if err := db.DB(ctx).Order("domain asc").Find(&zones).Error; err != nil {
return nil, err
}
type countRow struct {
ZoneID uint `gorm:"column:zone_id"`
Count int64 `gorm:"column:count"`
}
var rows []countRow
if err := db.DB(ctx).Model(&model.ZoneDomain{}).
Select("zone_id, count(*) as count").
Group("zone_id").
Scan(&rows).Error; err != nil {
return nil, err
}
counts := make(map[uint]int64, len(rows))
for _, row := range rows {
counts[row.ZoneID] = row.Count
}
items := make([]ListItem, 0, len(zones))
for _, zone := range zones {
items = append(items, ListItem{
ID: zone.ID,
Domain: zone.Domain,
Remark: zone.Remark,
DomainCount: counts[zone.ID],
CreatedAt: zone.CreatedAt,
UpdatedAt: zone.UpdatedAt,
})
}
return items, nil
}
// GetOverview returns a Zone and its domains.
+1 -1
View File
@@ -33,7 +33,7 @@ func abort(c *gin.Context, err error, missing string) bool {
// @Tags openflare-zone
// @Produce json
// @Security SessionCookie
// @Success 200 {object} response.Any{data=[]model.Zone}
// @Success 200 {object} response.Any{data=[]zone.ListItem}
// @Router /api/v1/d/zones [get]
func ListHandler(c *gin.Context) {
items, err := List(c.Request.Context())