mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-10-12 02:06:37 +08:00
feat(web): add zone-based website management
以稳定 Zone ID 替换旧托管域名详情页:列表展示根域与域名计数,详情提供 概览/域名/路由/证书/设置 Tabs,并补齐 ZoneService 与 vitest 行为测试。 Zone 列表 API 返回 domain_count;同步 Swagger 与重构计划进度。
This commit is contained in:
+59
-55
@@ -10849,7 +10849,7 @@ const docTemplate = `{
|
|||||||
"data": {
|
"data": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/model.Zone"
|
"$ref": "#/definitions/zone.ListItem"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16857,36 +16857,12 @@ const docTemplate = `{
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cert_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domain": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"domain_cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"domains": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enable_https": {
|
"enable_https": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -16955,6 +16931,12 @@ const docTemplate = `{
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_domain_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -16985,15 +16967,6 @@ const docTemplate = `{
|
|||||||
"cache_rules": {
|
"cache_rules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"cert_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -17006,24 +16979,6 @@ const docTemplate = `{
|
|||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "string"
|
"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": {
|
"enable_https": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -17054,9 +17009,6 @@ const docTemplate = `{
|
|||||||
"pages_project_id": {
|
"pages_project_id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"primary_domain": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"redirect_http": {
|
"redirect_http": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -17092,6 +17044,35 @@ const docTemplate = `{
|
|||||||
},
|
},
|
||||||
"upstreams": {
|
"upstreams": {
|
||||||
"type": "string"
|
"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": {
|
"zone.Overview": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -52,7 +52,7 @@
|
|||||||
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
||||||
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败的模型与迁移测试**
|
- [x] **Step 1: 写失败的模型与迁移测试**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
|
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 文件尚不存在。
|
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
|
||||||
|
|
||||||
- [ ] **Step 2: 新建双方言 DDL**
|
- [x] **Step 2: 新建双方言 DDL**
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
CREATE TABLE IF NOT EXISTS of_zones (
|
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`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
||||||
|
|
||||||
- [ ] **Step 3: 实现模型和受事务保护的绑定替换**
|
- [x] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
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` 内。
|
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
|
||||||
|
|
||||||
- [ ] **Step 4: 运行模型与迁移测试**
|
- [x] **Step 4: 运行模型与迁移测试**
|
||||||
|
|
||||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||||
|
|
||||||
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add internal/db/migrator/goose internal/model
|
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.
|
- 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`.
|
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败的逻辑与 API 测试**
|
- [x] **Step 1: 写失败的逻辑与 API 测试**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
|
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 信封。
|
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
|
||||||
|
|
||||||
- [ ] **Step 2: 实现精确域名和 Zone 归属验证**
|
- [x] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
|
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` 验证且输入等于结果。
|
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
|
||||||
|
|
||||||
- [ ] **Step 3: 实现显式导入命令**
|
- [x] **Step 3: 实现显式导入命令**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
|
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。
|
导入以事务执行:用 `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
|
```go
|
||||||
zoneGroup := apiGroup.Group("/zones")
|
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`。
|
把 `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`
|
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.
|
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
|
||||||
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败的路由逻辑测试**
|
- [x] **Step 1: 写失败的路由逻辑测试**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
|
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 不覆盖。
|
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
|
||||||
|
|
||||||
- [ ] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type ZoneDomainBindingInput struct {
|
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` 所需后端逻辑。
|
移除 `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` 查询域名/证书。
|
在 `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`
|
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
|
||||||
|
|
||||||
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
|
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:**
|
**Interfaces:**
|
||||||
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
|
- 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
|
```go
|
||||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||||
@@ -260,7 +260,7 @@ func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
|||||||
|
|
||||||
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
||||||
|
|
||||||
- [ ] **Step 2: 在快照边界联查并生成临时渲染字段**
|
- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||||
|
|
||||||
```go
|
```go
|
||||||
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||||
@@ -270,17 +270,17 @@ snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
|
|||||||
|
|
||||||
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
||||||
|
|
||||||
- [ ] **Step 3: 移除旧字段回退路径**
|
- [x] **Step 3: 移除旧字段回退路径**
|
||||||
|
|
||||||
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
|
删除 `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`
|
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 绑定与迁移前一致。
|
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
|
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:**
|
**Interfaces:**
|
||||||
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
|
- 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 链接。
|
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
|
||||||
|
|
||||||
- [ ] **Step 2: 实现类型化服务与查询键**
|
- [x] **Step 2: 实现类型化服务与查询键**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
|
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` 丢失。
|
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
|
||||||
|
|
||||||
- [ ] **Step 3: 用 Next 动态段实现 Zone 详情**
|
- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
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 放入同目录组件。
|
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
|
||||||
|
|
||||||
- [ ] **Step 4: 实现列表和详情交互**
|
- [x] **Step 4: 实现列表和详情交互**
|
||||||
|
|
||||||
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
||||||
|
|
||||||
- [ ] **Step 5: 验证并 Commit**
|
- [x] **Step 5: 验证并 Commit**
|
||||||
|
|
||||||
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
|
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[]`.
|
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
|
||||||
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
||||||
|
|
||||||
- [ ] **Step 1: 写失败的选择器测试**
|
- [x] **Step 1: 写失败的选择器测试**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
||||||
@@ -380,11 +380,11 @@ expect(onChange).toHaveBeenCalledWith([7])
|
|||||||
|
|
||||||
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
||||||
|
|
||||||
- [ ] **Step 2: 移除旧前端负载与自动匹配**
|
- [x] **Step 2: 移除旧前端负载与自动匹配**
|
||||||
|
|
||||||
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
||||||
|
|
||||||
- [ ] **Step 3: 实现 Zone 域名选择和保存负载**
|
- [x] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||||
@@ -395,13 +395,13 @@ mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
|||||||
|
|
||||||
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
||||||
|
|
||||||
- [ ] **Step 4: 运行前端类型和交互测试**
|
- [x] **Step 4: 运行前端类型和交互测试**
|
||||||
|
|
||||||
Run: `cd frontend && pnpm exec tsc --noEmit`
|
Run: `cd frontend && pnpm exec tsc --noEmit`
|
||||||
|
|
||||||
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
- [x] **Step 5: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
||||||
|
|||||||
+59
-55
@@ -10842,7 +10842,7 @@
|
|||||||
"data": {
|
"data": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/model.Zone"
|
"$ref": "#/definitions/zone.ListItem"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -16850,36 +16850,12 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"cert_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "array",
|
"type": "array",
|
||||||
"items": {
|
"items": {
|
||||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"domain": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"domain_cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"domains": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "string"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"enable_https": {
|
"enable_https": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -16948,6 +16924,12 @@
|
|||||||
"items": {
|
"items": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"zone_domain_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -16978,15 +16960,6 @@
|
|||||||
"cache_rules": {
|
"cache_rules": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"cert_id": {
|
|
||||||
"type": "integer"
|
|
||||||
},
|
|
||||||
"cert_ids": {
|
|
||||||
"type": "array",
|
|
||||||
"items": {
|
|
||||||
"type": "integer"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"created_at": {
|
"created_at": {
|
||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
@@ -16999,24 +16972,6 @@
|
|||||||
"custom_headers": {
|
"custom_headers": {
|
||||||
"type": "string"
|
"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": {
|
"enable_https": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -17047,9 +17002,6 @@
|
|||||||
"pages_project_id": {
|
"pages_project_id": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
},
|
},
|
||||||
"primary_domain": {
|
|
||||||
"type": "string"
|
|
||||||
},
|
|
||||||
"redirect_http": {
|
"redirect_http": {
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
},
|
},
|
||||||
@@ -17085,6 +17037,35 @@
|
|||||||
},
|
},
|
||||||
"upstreams": {
|
"upstreams": {
|
||||||
"type": "string"
|
"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": {
|
"zone.Overview": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+39
-37
@@ -2727,26 +2727,10 @@ definitions:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
cert_id:
|
|
||||||
type: integer
|
|
||||||
cert_ids:
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
custom_headers:
|
custom_headers:
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/proxy_route.CustomHeaderInput'
|
$ref: '#/definitions/proxy_route.CustomHeaderInput'
|
||||||
type: array
|
type: array
|
||||||
domain:
|
|
||||||
type: string
|
|
||||||
domain_cert_ids:
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
domains:
|
|
||||||
items:
|
|
||||||
type: string
|
|
||||||
type: array
|
|
||||||
enable_https:
|
enable_https:
|
||||||
type: boolean
|
type: boolean
|
||||||
enabled:
|
enabled:
|
||||||
@@ -2793,6 +2777,10 @@ definitions:
|
|||||||
items:
|
items:
|
||||||
type: string
|
type: string
|
||||||
type: array
|
type: array
|
||||||
|
zone_domain_ids:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
type: object
|
type: object
|
||||||
proxy_route.View:
|
proxy_route.View:
|
||||||
properties:
|
properties:
|
||||||
@@ -2812,12 +2800,6 @@ definitions:
|
|||||||
type: array
|
type: array
|
||||||
cache_rules:
|
cache_rules:
|
||||||
type: string
|
type: string
|
||||||
cert_id:
|
|
||||||
type: integer
|
|
||||||
cert_ids:
|
|
||||||
items:
|
|
||||||
type: integer
|
|
||||||
type: array
|
|
||||||
created_at:
|
created_at:
|
||||||
type: string
|
type: string
|
||||||
custom_header_list:
|
custom_header_list:
|
||||||
@@ -2826,18 +2808,6 @@ definitions:
|
|||||||
type: array
|
type: array
|
||||||
custom_headers:
|
custom_headers:
|
||||||
type: string
|
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:
|
enable_https:
|
||||||
type: boolean
|
type: boolean
|
||||||
enabled:
|
enabled:
|
||||||
@@ -2858,8 +2828,6 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
pages_project_id:
|
pages_project_id:
|
||||||
type: integer
|
type: integer
|
||||||
primary_domain:
|
|
||||||
type: string
|
|
||||||
redirect_http:
|
redirect_http:
|
||||||
type: boolean
|
type: boolean
|
||||||
remark:
|
remark:
|
||||||
@@ -2884,6 +2852,25 @@ definitions:
|
|||||||
type: string
|
type: string
|
||||||
upstreams:
|
upstreams:
|
||||||
type: string
|
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
|
type: object
|
||||||
push.Config:
|
push.Config:
|
||||||
properties:
|
properties:
|
||||||
@@ -4026,6 +4013,21 @@ definitions:
|
|||||||
remark:
|
remark:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
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:
|
zone.Overview:
|
||||||
properties:
|
properties:
|
||||||
domains:
|
domains:
|
||||||
@@ -10555,7 +10557,7 @@ paths:
|
|||||||
- properties:
|
- properties:
|
||||||
data:
|
data:
|
||||||
items:
|
items:
|
||||||
$ref: '#/definitions/model.Zone'
|
$ref: '#/definitions/zone.ListItem'
|
||||||
type: array
|
type: array
|
||||||
type: object
|
type: object
|
||||||
security:
|
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';
|
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({
|
export const manualImportSchema = z.object({
|
||||||
name: z
|
name: z
|
||||||
.string()
|
.string()
|
||||||
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
|
|||||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
|
|
||||||
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
|
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
|
||||||
|
|
||||||
export type FileImportFormValues = {
|
export type FileImportFormValues = {
|
||||||
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
|
|||||||
remark: string;
|
remark: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const defaultManagedDomainValues: ManagedDomainFormValues = {
|
|
||||||
domain: '',
|
|
||||||
cert_id: '',
|
|
||||||
enabled: true,
|
|
||||||
remark: '',
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultManualImportValues: ManualImportFormValues = {
|
export const defaultManualImportValues: ManualImportFormValues = {
|
||||||
name: '',
|
name: '',
|
||||||
cert_pem: '',
|
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 {
|
import type {
|
||||||
ManagedDomainItem,
|
|
||||||
ManagedDomainMutationPayload,
|
|
||||||
ProxyRouteItem,
|
|
||||||
TlsCertificateFileImportPayload,
|
TlsCertificateFileImportPayload,
|
||||||
TlsCertificateItem,
|
TlsCertificateItem,
|
||||||
TlsCertificateMutationPayload,
|
TlsCertificateMutationPayload,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
import {formatDateTime} from '@/lib/utils';
|
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';
|
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||||
|
|
||||||
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
|
|||||||
: certificate.name;
|
: 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(
|
export function toManualPayload(
|
||||||
values: ManualImportFormValues,
|
values: ManualImportFormValues,
|
||||||
): TlsCertificateMutationPayload {
|
): TlsCertificateMutationPayload {
|
||||||
@@ -100,20 +75,3 @@ export function toFilePayload(
|
|||||||
keyFile,
|
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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,211 +1,119 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
|
||||||
import {useMemo, useState} from 'react';
|
import {useMemo, useState} from 'react';
|
||||||
import {Globe, Plus, Trash2} from 'lucide-react';
|
import {useQuery} from '@tanstack/react-query';
|
||||||
import {toast} from 'sonner';
|
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 {Button} from '@/components/ui/button';
|
||||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||||
import {ErrorInline} from '@/components/layout/error';
|
import {ErrorInline} from '@/components/layout/error';
|
||||||
|
import {Input} from '@/components/ui/input';
|
||||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||||
import type {ManagedDomainItem} from '@/lib/services/openflare';
|
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||||
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
|
|
||||||
|
|
||||||
import {CertificateImportDialog} from './components/certificate-import-dialog';
|
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
|
||||||
import {WebsiteStatusBadge} from './components/status-badge';
|
import {getErrorMessage} from './components/website-utils';
|
||||||
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'];
|
|
||||||
|
|
||||||
export default function WebsitesPage() {
|
export default function WebsitesPage() {
|
||||||
const queryClient = useQueryClient();
|
const [search, setSearch] = useState('');
|
||||||
const [editorOpen, setEditorOpen] = useState(false);
|
const [editorOpen, setEditorOpen] = useState(false);
|
||||||
const [importOpen, setImportOpen] = useState(false);
|
const zonesQuery = useQuery({
|
||||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
queryKey: zoneQueryKey,
|
||||||
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
|
queryFn: () => ZoneService.list(),
|
||||||
|
|
||||||
const domainsQuery = useQuery({
|
|
||||||
queryKey: domainsQueryKey,
|
|
||||||
queryFn: () => WebsiteService.list(),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const certificatesQuery = useQuery({
|
const zones = useMemo(() => {
|
||||||
queryKey: certificatesQueryKey,
|
const keyword = search.trim().toLowerCase();
|
||||||
queryFn: () => TlsCertificateService.list(),
|
const list = zonesQuery.data ?? [];
|
||||||
});
|
if (!keyword) {
|
||||||
|
return list;
|
||||||
const deleteMutation = useMutation({
|
}
|
||||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
|
||||||
onSuccess: async () => {
|
}, [search, zonesQuery.data]);
|
||||||
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],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
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 justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Globe className="size-5 text-primary" />
|
<Globe className="size-5 text-primary" />
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-2">
|
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
||||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
<Plus className="mr-1 size-3.5" />
|
||||||
<Plus className="size-3.5 mr-1" />
|
新增 Zone
|
||||||
新增网站
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card className="border-dashed shadow-none">
|
<Card className="border-dashed shadow-none">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="gap-3 pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||||
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
|
<div>
|
||||||
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
|
<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>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{domainsQuery.isLoading ? (
|
{zonesQuery.isLoading ? (
|
||||||
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
|
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
|
||||||
) : domainsQuery.isError ? (
|
) : zonesQuery.isError ? (
|
||||||
<div className="p-8 border border-dashed rounded-lg">
|
<div className="rounded-lg border border-dashed p-8">
|
||||||
<ErrorInline
|
<ErrorInline
|
||||||
message={getErrorMessage(domainsQuery.error)}
|
|
||||||
onRetry={() => void domainsQuery.refetch()}
|
|
||||||
className="justify-center"
|
className="justify-center"
|
||||||
|
message={getErrorMessage(zonesQuery.error)}
|
||||||
|
onRetry={() => void zonesQuery.refetch()}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : domains.length === 0 ? (
|
) : zones.length === 0 ? (
|
||||||
<EmptyStateWithBorder
|
<EmptyStateWithBorder
|
||||||
icon={Globe}
|
icon={Globe}
|
||||||
description="暂无网站,点击右上角「新增网站」开始录入。"
|
description={
|
||||||
|
search
|
||||||
|
? '未找到匹配的 Zone。'
|
||||||
|
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
|
||||||
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="grid gap-3 lg:grid-cols-2">
|
<div className="grid gap-3 lg:grid-cols-2">
|
||||||
{domains.map((domain) => {
|
{zones.map((zone) => (
|
||||||
const certificate = domain.cert_id
|
<div key={zone.id} className="rounded-lg border bg-card p-4">
|
||||||
? (certificateMap.get(domain.cert_id) ?? null)
|
<div className="flex items-start justify-between gap-3">
|
||||||
: null;
|
<div className="min-w-0">
|
||||||
const matchType = getMatchTypeMeta(domain.domain);
|
<h2 className="truncate text-sm font-semibold">{zone.domain}</h2>
|
||||||
|
<p className="mt-2 text-xs text-muted-foreground">
|
||||||
return (
|
{zone.domain_count ?? 0} 个域名
|
||||||
<div
|
{zone.remark ? ` · ${zone.remark}` : ''}
|
||||||
key={domain.id}
|
</p>
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="h-7 shrink-0 text-xs"
|
||||||
|
asChild
|
||||||
|
>
|
||||||
|
<Link href={`/websites/${zone.id}`}>管理</Link>
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<WebsiteEditorDialog
|
<ZoneEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
|
||||||
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>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,8 @@ import {
|
|||||||
UptimeKumaService,
|
UptimeKumaService,
|
||||||
WafService,
|
WafService,
|
||||||
WebsiteService,
|
WebsiteService,
|
||||||
|
ZoneDomainService,
|
||||||
|
ZoneService,
|
||||||
} from './openflare';
|
} from './openflare';
|
||||||
|
|
||||||
const services = {
|
const services = {
|
||||||
@@ -67,6 +69,8 @@ const services = {
|
|||||||
openflareDashboard: DashboardService,
|
openflareDashboard: DashboardService,
|
||||||
openflareWaf: WafService,
|
openflareWaf: WafService,
|
||||||
openflareWebsite: WebsiteService,
|
openflareWebsite: WebsiteService,
|
||||||
|
openflareZone: ZoneService,
|
||||||
|
openflareZoneDomain: ZoneDomainService,
|
||||||
openflareTls: TlsCertificateService,
|
openflareTls: TlsCertificateService,
|
||||||
openflareDns: DnsAccountService,
|
openflareDns: DnsAccountService,
|
||||||
openflarePages: PagesService,
|
openflarePages: PagesService,
|
||||||
@@ -201,6 +205,7 @@ export {
|
|||||||
export type {
|
export type {
|
||||||
NodeItem,
|
NodeItem,
|
||||||
ProxyRouteItem,
|
ProxyRouteItem,
|
||||||
|
ProxyRouteZoneDomain,
|
||||||
ProxyRouteConfigSection,
|
ProxyRouteConfigSection,
|
||||||
ConfigVersionSummary,
|
ConfigVersionSummary,
|
||||||
ConfigVersionDetail,
|
ConfigVersionDetail,
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ export { ApplyLogService } from './apply-log.service';
|
|||||||
export { DashboardService } from './dashboard.service';
|
export { DashboardService } from './dashboard.service';
|
||||||
export { WafService } from './waf.service';
|
export { WafService } from './waf.service';
|
||||||
export { WebsiteService } from './website.service';
|
export { WebsiteService } from './website.service';
|
||||||
|
export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
|
||||||
export { TlsCertificateService } from './tls-certificate.service';
|
export { TlsCertificateService } from './tls-certificate.service';
|
||||||
export { DnsAccountService } from './dns-account.service';
|
export { DnsAccountService } from './dns-account.service';
|
||||||
export { PagesService } from './pages.service';
|
export { PagesService } from './pages.service';
|
||||||
@@ -49,6 +50,7 @@ export type {
|
|||||||
ProxyRouteItem,
|
ProxyRouteItem,
|
||||||
ProxyRouteMutationPayload,
|
ProxyRouteMutationPayload,
|
||||||
ProxyRoutePoWConfig,
|
ProxyRoutePoWConfig,
|
||||||
|
ProxyRouteZoneDomain,
|
||||||
ReleaseChannel,
|
ReleaseChannel,
|
||||||
SupportFile,
|
SupportFile,
|
||||||
DashboardOverview,
|
DashboardOverview,
|
||||||
@@ -105,6 +107,11 @@ export type {
|
|||||||
ManagedDomainItem,
|
ManagedDomainItem,
|
||||||
ManagedDomainMatchResult,
|
ManagedDomainMatchResult,
|
||||||
ManagedDomainMutationPayload,
|
ManagedDomainMutationPayload,
|
||||||
|
ZoneDomainItem,
|
||||||
|
ZoneDomainMutationPayload,
|
||||||
|
ZoneItem,
|
||||||
|
ZoneMutationPayload,
|
||||||
|
ZoneOverview,
|
||||||
TlsCertificateApplyPayload,
|
TlsCertificateApplyPayload,
|
||||||
TlsCertificateContentItem,
|
TlsCertificateContentItem,
|
||||||
TlsCertificateDetailItem,
|
TlsCertificateDetailItem,
|
||||||
|
|||||||
@@ -228,13 +228,19 @@ export interface ProxyRoutePoWConfig {
|
|||||||
blacklist: ProxyRoutePoWListConfig;
|
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 {
|
export interface ProxyRouteItem {
|
||||||
id: number;
|
id: number;
|
||||||
site_name: string;
|
site_name: string;
|
||||||
domain: string;
|
zone_domain_ids: number[];
|
||||||
domains: string[];
|
zone_domains: ProxyRouteZoneDomain[];
|
||||||
primary_domain: string;
|
|
||||||
domain_count: number;
|
|
||||||
origin_id: number | null;
|
origin_id: number | null;
|
||||||
origin_url: string;
|
origin_url: string;
|
||||||
origin_host: string;
|
origin_host: string;
|
||||||
@@ -242,9 +248,6 @@ export interface ProxyRouteItem {
|
|||||||
upstream_list: string[];
|
upstream_list: string[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enable_https: boolean;
|
enable_https: boolean;
|
||||||
cert_id: number | null;
|
|
||||||
cert_ids: number[];
|
|
||||||
domain_cert_ids: number[];
|
|
||||||
redirect_http: boolean;
|
redirect_http: boolean;
|
||||||
limit_conn_per_server: number;
|
limit_conn_per_server: number;
|
||||||
limit_conn_per_ip: number;
|
limit_conn_per_ip: number;
|
||||||
@@ -271,8 +274,7 @@ export interface ProxyRouteItem {
|
|||||||
|
|
||||||
export interface ProxyRouteMutationPayload {
|
export interface ProxyRouteMutationPayload {
|
||||||
site_name?: string;
|
site_name?: string;
|
||||||
domain: string;
|
zone_domain_ids: number[];
|
||||||
domains?: string[];
|
|
||||||
origin_id: number | null;
|
origin_id: number | null;
|
||||||
origin_url: string;
|
origin_url: string;
|
||||||
origin_scheme: 'http' | 'https';
|
origin_scheme: 'http' | 'https';
|
||||||
@@ -283,9 +285,6 @@ export interface ProxyRouteMutationPayload {
|
|||||||
upstreams: string[];
|
upstreams: string[];
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
enable_https: boolean;
|
enable_https: boolean;
|
||||||
cert_id: number | null;
|
|
||||||
cert_ids?: number[];
|
|
||||||
domain_cert_ids?: number[];
|
|
||||||
redirect_http: boolean;
|
redirect_http: boolean;
|
||||||
limit_conn_per_server?: number;
|
limit_conn_per_server?: number;
|
||||||
limit_conn_per_ip?: number;
|
limit_conn_per_ip?: number;
|
||||||
@@ -962,6 +961,43 @@ export interface DashboardOverviewCompact {
|
|||||||
|
|
||||||
// ==================== Websites / TLS / DNS ====================
|
// ==================== 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 {
|
export interface ManagedDomainItem {
|
||||||
id: number;
|
id: number;
|
||||||
domain: string;
|
domain: string;
|
||||||
@@ -1078,4 +1114,4 @@ export interface DnsAccountMutationPayload {
|
|||||||
name: string;
|
name: string;
|
||||||
type: string;
|
type: string;
|
||||||
authorization: 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`) }
|
||||||
|
}
|
||||||
@@ -82,13 +82,18 @@
|
|||||||
"@eslint/eslintrc": "^3",
|
"@eslint/eslintrc": "^3",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@tailwindcss/typography": "^0.5.19",
|
"@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/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.5.6",
|
"eslint-config-next": "15.5.6",
|
||||||
|
"jsdom": "^29.1.1",
|
||||||
"tailwindcss": "^4",
|
"tailwindcss": "^4",
|
||||||
"tw-animate-css": "^1.4.0",
|
"tw-animate-css": "^1.4.0",
|
||||||
"typescript": "^5.9.3"
|
"typescript": "^5.9.3",
|
||||||
|
"vitest": "^4.1.10"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+1198
File diff suppressed because it is too large
Load Diff
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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'],
|
||||||
|
},
|
||||||
|
})
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/Rain-kl/Wavelet/internal/db"
|
"github.com/Rain-kl/Wavelet/internal/db"
|
||||||
"github.com/Rain-kl/Wavelet/internal/model"
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
@@ -34,6 +35,16 @@ type Overview struct {
|
|||||||
Domains []model.ZoneDomain `json:"domains"`
|
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) {
|
func zoneRoot(domain string) (string, error) {
|
||||||
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
|
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
|
return &zone, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// List returns all Zones in stable domain order.
|
// List returns all Zones in stable domain order, with domain counts for list cards.
|
||||||
func List(ctx context.Context) ([]model.Zone, error) {
|
func List(ctx context.Context) ([]ListItem, error) {
|
||||||
var zones []model.Zone
|
var zones []model.Zone
|
||||||
err := db.DB(ctx).Order("domain asc").Find(&zones).Error
|
if err := db.DB(ctx).Order("domain asc").Find(&zones).Error; err != nil {
|
||||||
return zones, err
|
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.
|
// GetOverview returns a Zone and its domains.
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ func abort(c *gin.Context, err error, missing string) bool {
|
|||||||
// @Tags openflare-zone
|
// @Tags openflare-zone
|
||||||
// @Produce json
|
// @Produce json
|
||||||
// @Security SessionCookie
|
// @Security SessionCookie
|
||||||
// @Success 200 {object} response.Any{data=[]model.Zone}
|
// @Success 200 {object} response.Any{data=[]zone.ListItem}
|
||||||
// @Router /api/v1/d/zones [get]
|
// @Router /api/v1/d/zones [get]
|
||||||
func ListHandler(c *gin.Context) {
|
func ListHandler(c *gin.Context) {
|
||||||
items, err := List(c.Request.Context())
|
items, err := List(c.Request.Context())
|
||||||
|
|||||||
Reference in New Issue
Block a user