mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-30 14:26:36 +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": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Zone"
|
||||
"$ref": "#/definitions/zone.ListItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16857,36 +16857,12 @@ const docTemplate = `{
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"custom_headers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -16955,6 +16931,12 @@ const docTemplate = `{
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"zone_domain_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16985,15 +16967,6 @@ const docTemplate = `{
|
||||
"cache_rules": {
|
||||
"type": "string"
|
||||
},
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -17006,24 +16979,6 @@ const docTemplate = `{
|
||||
"custom_headers": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"domain_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -17054,9 +17009,6 @@ const docTemplate = `{
|
||||
"pages_project_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"primary_domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"redirect_http": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -17092,6 +17044,35 @@ const docTemplate = `{
|
||||
},
|
||||
"upstreams": {
|
||||
"type": "string"
|
||||
},
|
||||
"zone_domain_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"zone_domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/proxy_route.ZoneDomainView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy_route.ZoneDomainView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"zone_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18799,6 +18780,29 @@ const docTemplate = `{
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.ListItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.Overview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
@@ -52,7 +52,7 @@
|
||||
- Produces: `model.Zone`, `model.ZoneDomain`, `ListZoneDomainsByRouteID(ctx, routeID)`, `ReplaceZoneDomainRouteBindings(ctx, routeID, domainIDs)`.
|
||||
- Consumes: existing `model.ProxyRoute` and `model.TLSCertificate` IDs; no physical FK.
|
||||
|
||||
- [ ] **Step 1: 写失败的模型与迁移测试**
|
||||
- [x] **Step 1: 写失败的模型与迁移测试**
|
||||
|
||||
```go
|
||||
func TestReplaceZoneDomainRouteBindingsRejectsForeignDomain(t *testing.T) {
|
||||
@@ -64,7 +64,7 @@ Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=
|
||||
|
||||
Expected: FAIL,因为 Zone 模型和 goose 文件尚不存在。
|
||||
|
||||
- [ ] **Step 2: 新建双方言 DDL**
|
||||
- [x] **Step 2: 新建双方言 DDL**
|
||||
|
||||
```sql
|
||||
CREATE TABLE IF NOT EXISTS of_zones (
|
||||
@@ -94,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_of_zone_domains_cert_id ON of_zone_domains (cert_
|
||||
|
||||
SQLite 使用 `INTEGER PRIMARY KEY AUTOINCREMENT`、`DATETIME`,字段/索引语义完全对齐。此任务不得删除旧列或旧表。
|
||||
|
||||
- [ ] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||
- [x] **Step 3: 实现模型和受事务保护的绑定替换**
|
||||
|
||||
```go
|
||||
type Zone struct { ID uint; Domain string; Remark string; CreatedAt time.Time; UpdatedAt time.Time }
|
||||
@@ -105,13 +105,13 @@ func ReplaceZoneDomainRouteBindings(ctx context.Context, routeID uint, domainIDs
|
||||
|
||||
实现先锁定/读取请求域名,拒绝已绑定到其他路由的记录,再把当前路由已绑定但不在 `domainIDs` 的记录置空,最后将请求记录写为 `routeID`;所有动作放在同一 `db.DB(ctx).Transaction` 内。
|
||||
|
||||
- [ ] **Step 4: 运行模型与迁移测试**
|
||||
- [x] **Step 4: 运行模型与迁移测试**
|
||||
|
||||
Run: `go test ./internal/model ./internal/db/migrator -run 'Zone|Migrat' -count=1`
|
||||
|
||||
Expected: PASS,空 SQLite 库可应用迁移,唯一域名和绑定排他性受保护。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/db/migrator/goose internal/model
|
||||
@@ -132,7 +132,7 @@ git commit -m "feat(zone): add normalized zone domain schema"
|
||||
- Produces: `zone.Create`, `zone.Update`, `zone.GetOverview`, `zone.ImportLegacy(ctx) (ImportReport, error)` and Zone REST handlers.
|
||||
- Consumes: Task 1 models; legacy `managed_domains` and proxy-route columns only inside `ImportLegacy`.
|
||||
|
||||
- [ ] **Step 1: 写失败的逻辑与 API 测试**
|
||||
- [x] **Step 1: 写失败的逻辑与 API 测试**
|
||||
|
||||
```go
|
||||
func TestCreateZoneDomainRejectsWildcard(t *testing.T) { _, err := CreateDomain(ctx, zoneID, DomainInput{Domain: "*.example.com"}); require.EqualError(t, err, errDomainWildcardUnsupported) }
|
||||
@@ -145,7 +145,7 @@ func TestLegacyImportUsesEffectiveTLDPlusOne(t *testing.T) {
|
||||
|
||||
集成测试请求 `POST /api/v1/d/zones/`、`POST /api/v1/d/zones/:id/domains`,并断言错误响应使用 400 信封。
|
||||
|
||||
- [ ] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||
- [x] **Step 2: 实现精确域名和 Zone 归属验证**
|
||||
|
||||
```go
|
||||
func zoneRoot(domain string) (string, error) { return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain))) }
|
||||
@@ -154,7 +154,7 @@ func CreateDomain(ctx context.Context, zoneID uint, input DomainInput) (*model.Z
|
||||
|
||||
拒绝空值、协议、路径和 `*`;要求 `zoneRoot(input.Domain) == zone.Domain`;若 `cert_id` 非空,验证 TLS 证书存在。Zone 根域创建也必须经 `EffectiveTLDPlusOne` 验证且输入等于结果。
|
||||
|
||||
- [ ] **Step 3: 实现显式导入命令**
|
||||
- [x] **Step 3: 实现显式导入命令**
|
||||
|
||||
```go
|
||||
var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.Command, _ []string) error {
|
||||
@@ -165,7 +165,7 @@ var migrateZonesCmd = &cobra.Command{Use: "migrate-zones", RunE: func(_ *cobra.C
|
||||
|
||||
导入以事务执行:用 `routeidentity.DecodeDomains(route.Domains, route.Domain)` 读取旧路由;按 `domain_cert_ids` 的同一索引写入 `zone_domains.cert_id`;只在无路由域名时导入旧 `managed_domains`。发现无效根域、通配符记录或全局域名冲突时回滚并输出全部冲突项。重复执行不得生成重复 Zone/ZoneDomain。
|
||||
|
||||
- [ ] **Step 4: 注册 API 并删除旧 managed-domain 路由**
|
||||
- [x] **Step 4: 注册 API 并删除旧 managed-domain 路由**
|
||||
|
||||
```go
|
||||
zoneGroup := apiGroup.Group("/zones")
|
||||
@@ -177,7 +177,7 @@ zoneGroup.GET("/:id/overview", zone.GetOverviewHandler)
|
||||
|
||||
把 `managed-domains` 路由块从 `register_tls.go` 移除;每个 Handler 使用 `apiutil.BindJSON` 和 `response.AbortBadRequest/AbortNotFound/AbortConflict`。
|
||||
|
||||
- [ ] **Step 5: 验证并 Commit**
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/zone ./internal/apps/openflare/integration -count=1 && make swagger`
|
||||
|
||||
@@ -200,7 +200,7 @@ git commit -m "feat(zone): add zone management api and legacy importer"
|
||||
- Consumes: `zone_domain_ids []uint` and Task 1 binding API.
|
||||
- Produces: `proxy_route.Input{ZoneDomainIDs []uint}`, `proxy_route.View{ZoneDomains []ZoneDomainView}`.
|
||||
|
||||
- [ ] **Step 1: 写失败的路由逻辑测试**
|
||||
- [x] **Step 1: 写失败的路由逻辑测试**
|
||||
|
||||
```go
|
||||
input := Input{SiteName: "api", ZoneDomainIDs: []uint{domainA.ID, domainB.ID}, EnableHTTPS: true}
|
||||
@@ -211,7 +211,7 @@ require.Equal(t, []uint{domainA.ID, domainB.ID}, view.ZoneDomainIDs)
|
||||
|
||||
同时覆盖:空 `zone_domain_ids`、重复 ID、其他路由已占用域名、HTTPS 域名无证书、证书 SAN 不覆盖。
|
||||
|
||||
- [ ] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||
- [x] **Step 2: 删除路由输入/视图中的旧域名与证书字段**
|
||||
|
||||
```go
|
||||
type ZoneDomainBindingInput struct {
|
||||
@@ -222,17 +222,17 @@ type ZoneDomainView struct { ID uint `json:"id"`; ZoneID uint `json:"zone_id"`;
|
||||
|
||||
移除 `Input.Domain`、`Input.Domains`、`Input.CertID`、`Input.CertIDs`、`Input.DomainCertIDs` 及对应 View 字段;删除旧证书派生辅助函数与 `WebsiteService.match` 所需后端逻辑。
|
||||
|
||||
- [ ] **Step 3: 用关联记录验证并构建路由**
|
||||
- [x] **Step 3: 用关联记录验证并构建路由**
|
||||
|
||||
在 `buildProxyRoute` 中读取所有 `ZoneDomainIDs`,对每个 HTTPS 域名调用现有 `validateCertificateCoverage`,再调用 `ReplaceZoneDomainRouteBindings`。更新/删除路由也必须在事务内同步解除关联。来源、证书删除检查和 Origin 路由摘要改从 `zone_domains` 查询域名/证书。
|
||||
|
||||
- [ ] **Step 4: 运行路由和 TLS 回归测试**
|
||||
- [x] **Step 4: 运行路由和 TLS 回归测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/proxy_route ./internal/apps/openflare/tls ./internal/apps/openflare/origin -count=1`
|
||||
|
||||
Expected: PASS;任一证书已被 Zone 域名引用时,删除证书被拒绝。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/proxy_route internal/apps/openflare/tls internal/apps/openflare/origin internal/model
|
||||
@@ -250,7 +250,7 @@ git commit -m "refactor(proxy): bind routes through zone domains"
|
||||
**Interfaces:**
|
||||
- Produces: snapshot/render `Route{SiteName, Domains, DomainCertIDs}` built transiently from ZoneDomain rows; neither DB model nor API stores those fields.
|
||||
|
||||
- [ ] **Step 1: 写快照等价性失败测试**
|
||||
- [x] **Step 1: 写快照等价性失败测试**
|
||||
|
||||
```go
|
||||
func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
@@ -260,7 +260,7 @@ func TestBuildSnapshotReadsZoneDomainCertificates(t *testing.T) {
|
||||
|
||||
加入 Pages、Tunnel、WAF 绑定测试,断言 Route ID 与 `site_name` 未改变。
|
||||
|
||||
- [ ] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||
- [x] **Step 2: 在快照边界联查并生成临时渲染字段**
|
||||
|
||||
```go
|
||||
domains, err := model.ListZoneDomainsByRouteID(ctx, route.ID)
|
||||
@@ -270,17 +270,17 @@ snapshotRoute.DomainCertIDs = certIDsInDomainOrder(domains)
|
||||
|
||||
`pkg/render/openresty.Route` 可继续保留 `Domains` 与 `DomainCertIDs`,因为它是不可变配置快照的渲染输入;移除其中持久化主域/证书回退逻辑,所有错误消息改用 `SiteName`。
|
||||
|
||||
- [ ] **Step 3: 移除旧字段回退路径**
|
||||
- [x] **Step 3: 移除旧字段回退路径**
|
||||
|
||||
删除 `routeidentity.DecodeDomains` 对持久化 `route.Domain` 的依赖;Flared、Uptime Kuma、配置 diff、WAF 文档和 Pages 错误信息都从 snapshot/ZoneDomain 查询的明确域名获取显示文本。
|
||||
|
||||
- [ ] **Step 4: 运行数据面测试**
|
||||
- [x] **Step 4: 运行数据面测试**
|
||||
|
||||
Run: `go test ./internal/apps/openflare/config_version ./pkg/render/openresty ./internal/apps/openflare/flared ./internal/apps/openflare/uptimekuma -count=1`
|
||||
|
||||
Expected: PASS;迁移后的路由产生的 `server_name`、证书支持文件和 WAF RouteID 绑定与迁移前一致。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add internal/apps/openflare/config_version internal/apps/openflare/flared internal/apps/openflare/uptimekuma internal/apps/openflare/routeidentity pkg/render/openresty
|
||||
@@ -305,7 +305,7 @@ git commit -m "refactor(config): render routes from zone domains"
|
||||
**Interfaces:**
|
||||
- Produces: `ZoneService.list/getOverview/create/update/delete`, `ZoneDomainService.create/update/delete` and `ZoneOverview` TypeScript types.
|
||||
|
||||
- [ ] **Step 1: 写服务与页面行为测试**
|
||||
- [x] **Step 1: 写服务与页面行为测试**
|
||||
|
||||
先安装仅用于本次页面测试的开发依赖:
|
||||
|
||||
@@ -320,7 +320,7 @@ expect(screen.getByRole('heading', {name: 'arctel.de'})).toBeVisible()
|
||||
|
||||
覆盖 `/websites/42` 的加载、404、空域名、搜索列表和从列表点击 ID 链接。
|
||||
|
||||
- [ ] **Step 2: 实现类型化服务与查询键**
|
||||
- [x] **Step 2: 实现类型化服务与查询键**
|
||||
|
||||
```ts
|
||||
export interface ZoneDomainItem { id: number; zone_id: number; proxy_route_id: number | null; domain: string; cert_id: number | null; remark: string }
|
||||
@@ -330,7 +330,7 @@ export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
|
||||
所有 React Query 回调使用箭头函数,避免静态 service `this` 丢失。
|
||||
|
||||
- [ ] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||
- [x] **Step 3: 用 Next 动态段实现 Zone 详情**
|
||||
|
||||
```tsx
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
@@ -341,11 +341,11 @@ export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>
|
||||
|
||||
遵循本地 Next 文档:动态 `params` 是 Promise;无效或非正整数 ID 显示既有 `EmptyStateWithBorder`,不把域名写入 URL。主页面只维护页面骨架和 Tabs,具体 Tab 放入同目录组件。
|
||||
|
||||
- [ ] **Step 4: 实现列表和详情交互**
|
||||
- [x] **Step 4: 实现列表和详情交互**
|
||||
|
||||
列表仅渲染 Zone 根域及计数;详情使用概览、域名、路由、证书、设置 Tabs。域名弹窗拒绝 `*.`,但证书选择器不限制其 SAN。删除 Zone/域名使用确认对话框和服务端错误文案。
|
||||
|
||||
- [ ] **Step 5: 验证并 Commit**
|
||||
- [x] **Step 5: 验证并 Commit**
|
||||
|
||||
Run: `cd frontend && pnpm exec vitest run && pnpm lint`
|
||||
|
||||
@@ -370,7 +370,7 @@ git commit -m "feat(web): add zone-based website management"
|
||||
- Consumes: `ZoneDomainItem[]` and route `zone_domain_ids: number[]`.
|
||||
- Produces: selector values with explicit domain/Zone/证书信息;不发送任何旧域名或证书字段。
|
||||
|
||||
- [ ] **Step 1: 写失败的选择器测试**
|
||||
- [x] **Step 1: 写失败的选择器测试**
|
||||
|
||||
```tsx
|
||||
render(<ZoneDomainSelector value={[7]} onChange={onChange} domains={[apiDomain]} />)
|
||||
@@ -380,11 +380,11 @@ expect(onChange).toHaveBeenCalledWith([7])
|
||||
|
||||
覆盖搜索、跨 Zone 多选、已被其他路由占用的禁用项和 HTTPS 缺少证书的表单错误。
|
||||
|
||||
- [ ] **Step 2: 移除旧前端负载与自动匹配**
|
||||
- [x] **Step 2: 移除旧前端负载与自动匹配**
|
||||
|
||||
从 `ProxyRouteItem`/`ProxyRouteMutationPayload` 删除 `domain`、`domains`、`primary_domain`、`cert_id`、`cert_ids`、`domain_cert_ids`;删除 `WebsiteService.match` 及 `DomainListInput` 自动填证书交互。
|
||||
|
||||
- [ ] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||
- [x] **Step 3: 实现 Zone 域名选择和保存负载**
|
||||
|
||||
```ts
|
||||
mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||
@@ -395,13 +395,13 @@ mutationFn: (payload) => ProxyRouteService.update(route.id, {
|
||||
|
||||
展示每个选择项的 FQDN、所属 Zone 与证书;路由详情的“域名”区只编辑关联关系,证书链接跳转 Zone 详情而非路由内编辑。
|
||||
|
||||
- [ ] **Step 4: 运行前端类型和交互测试**
|
||||
- [x] **Step 4: 运行前端类型和交互测试**
|
||||
|
||||
Run: `cd frontend && pnpm exec tsc --noEmit`
|
||||
|
||||
Expected: PASS;不存在旧持久化域名/证书字段的 TypeScript 引用。
|
||||
|
||||
- [ ] **Step 5: Commit**
|
||||
- [x] **Step 5: Commit**
|
||||
|
||||
```bash
|
||||
git add frontend/app/'(main)'/proxy-routes frontend/lib/services/openflare/types.ts
|
||||
|
||||
+59
-55
@@ -10842,7 +10842,7 @@
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/model.Zone"
|
||||
"$ref": "#/definitions/zone.ListItem"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -16850,36 +16850,12 @@
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"custom_headers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/proxy_route.CustomHeaderInput"
|
||||
}
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -16948,6 +16924,12 @@
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"zone_domain_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -16978,15 +16960,6 @@
|
||||
"cache_rules": {
|
||||
"type": "string"
|
||||
},
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -16999,24 +16972,6 @@
|
||||
"custom_headers": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_cert_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"domain_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"enable_https": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -17047,9 +17002,6 @@
|
||||
"pages_project_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"primary_domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"redirect_http": {
|
||||
"type": "boolean"
|
||||
},
|
||||
@@ -17085,6 +17037,35 @@
|
||||
},
|
||||
"upstreams": {
|
||||
"type": "string"
|
||||
},
|
||||
"zone_domain_ids": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"zone_domains": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/definitions/proxy_route.ZoneDomainView"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"proxy_route.ZoneDomainView": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"cert_id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"zone_id": {
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -18792,6 +18773,29 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.ListItem": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"created_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain": {
|
||||
"type": "string"
|
||||
},
|
||||
"domain_count": {
|
||||
"type": "integer"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"remark": {
|
||||
"type": "string"
|
||||
},
|
||||
"updated_at": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"zone.Overview": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
|
||||
+39
-37
@@ -2727,26 +2727,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
cert_id:
|
||||
type: integer
|
||||
cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
custom_headers:
|
||||
items:
|
||||
$ref: '#/definitions/proxy_route.CustomHeaderInput'
|
||||
type: array
|
||||
domain:
|
||||
type: string
|
||||
domain_cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
domains:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -2793,6 +2777,10 @@ definitions:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
zone_domain_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
type: object
|
||||
proxy_route.View:
|
||||
properties:
|
||||
@@ -2812,12 +2800,6 @@ definitions:
|
||||
type: array
|
||||
cache_rules:
|
||||
type: string
|
||||
cert_id:
|
||||
type: integer
|
||||
cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
created_at:
|
||||
type: string
|
||||
custom_header_list:
|
||||
@@ -2826,18 +2808,6 @@ definitions:
|
||||
type: array
|
||||
custom_headers:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
domain_cert_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
domain_count:
|
||||
type: integer
|
||||
domains:
|
||||
items:
|
||||
type: string
|
||||
type: array
|
||||
enable_https:
|
||||
type: boolean
|
||||
enabled:
|
||||
@@ -2858,8 +2828,6 @@ definitions:
|
||||
type: string
|
||||
pages_project_id:
|
||||
type: integer
|
||||
primary_domain:
|
||||
type: string
|
||||
redirect_http:
|
||||
type: boolean
|
||||
remark:
|
||||
@@ -2884,6 +2852,25 @@ definitions:
|
||||
type: string
|
||||
upstreams:
|
||||
type: string
|
||||
zone_domain_ids:
|
||||
items:
|
||||
type: integer
|
||||
type: array
|
||||
zone_domains:
|
||||
items:
|
||||
$ref: '#/definitions/proxy_route.ZoneDomainView'
|
||||
type: array
|
||||
type: object
|
||||
proxy_route.ZoneDomainView:
|
||||
properties:
|
||||
cert_id:
|
||||
type: integer
|
||||
domain:
|
||||
type: string
|
||||
id:
|
||||
type: integer
|
||||
zone_id:
|
||||
type: integer
|
||||
type: object
|
||||
push.Config:
|
||||
properties:
|
||||
@@ -4026,6 +4013,21 @@ definitions:
|
||||
remark:
|
||||
type: string
|
||||
type: object
|
||||
zone.ListItem:
|
||||
properties:
|
||||
created_at:
|
||||
type: string
|
||||
domain:
|
||||
type: string
|
||||
domain_count:
|
||||
type: integer
|
||||
id:
|
||||
type: integer
|
||||
remark:
|
||||
type: string
|
||||
updated_at:
|
||||
type: string
|
||||
type: object
|
||||
zone.Overview:
|
||||
properties:
|
||||
domains:
|
||||
@@ -10555,7 +10557,7 @@ paths:
|
||||
- properties:
|
||||
data:
|
||||
items:
|
||||
$ref: '#/definitions/model.Zone'
|
||||
$ref: '#/definitions/zone.ListItem'
|
||||
type: array
|
||||
type: object
|
||||
security:
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMemo} from 'react';
|
||||
import {ExternalLink, FileKey} from 'lucide-react';
|
||||
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Badge} from '@/components/ui/badge';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem, ZoneDomainItem} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import {getCertificateStatus} from '../../components/website-utils';
|
||||
|
||||
export function ZoneCertificatesPanel({
|
||||
domains,
|
||||
certificates,
|
||||
}: {
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
}) {
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((certificate) => [certificate.id, certificate])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const boundCertificates = useMemo(() => {
|
||||
const rows = new Map<
|
||||
number,
|
||||
{certificate: TlsCertificateItem; domains: ZoneDomainItem[]}
|
||||
>();
|
||||
|
||||
for (const domain of domains) {
|
||||
if (domain.cert_id == null) {
|
||||
continue;
|
||||
}
|
||||
const certificate = certificateMap.get(domain.cert_id);
|
||||
if (!certificate) {
|
||||
continue;
|
||||
}
|
||||
const existing = rows.get(certificate.id);
|
||||
if (existing) {
|
||||
existing.domains.push(domain);
|
||||
} else {
|
||||
rows.set(certificate.id, {certificate, domains: [domain]});
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(rows.values()).sort((left, right) =>
|
||||
left.certificate.name.localeCompare(right.certificate.name),
|
||||
);
|
||||
}, [certificateMap, domains]);
|
||||
|
||||
const unboundCount = domains.filter((domain) => domain.cert_id == null).length;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="flex flex-row items-start justify-between gap-3 space-y-0">
|
||||
<div>
|
||||
<CardTitle className="text-base">本 Zone 使用的证书</CardTitle>
|
||||
<CardDescription>
|
||||
证书在全局证书库管理;此处仅展示已绑定到本 Zone 域名的证书。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/certificates">
|
||||
证书库
|
||||
<ExternalLink className="ml-1 size-3.5" />
|
||||
</Link>
|
||||
</Button>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-3">
|
||||
{unboundCount > 0 ? (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
还有 {unboundCount} 个域名未绑定证书,可在「域名」Tab 中选择证书。
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{boundCertificates.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={FileKey}
|
||||
description="暂无绑定证书。请先在域名行选择证书,或前往证书库导入/申请。"
|
||||
/>
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>覆盖域名</TableHead>
|
||||
<TableHead>到期时间</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{boundCertificates.map(({certificate, domains: boundDomains}) => {
|
||||
const status = getCertificateStatus(certificate);
|
||||
return (
|
||||
<TableRow key={certificate.id}>
|
||||
<TableCell className="font-medium">
|
||||
{certificate.name}
|
||||
<p className="mt-0.5 text-xs text-muted-foreground">
|
||||
{certificate.primary_domain || `证书 #${certificate.id}`}
|
||||
</p>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline" className="text-[10px]">
|
||||
{status.label}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="max-w-xs truncate text-muted-foreground">
|
||||
{boundDomains.map((domain) => domain.domain).join(' · ')}
|
||||
</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{formatDateTime(certificate.not_after)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {Loader2} from 'lucide-react'
|
||||
import {toast} from 'sonner'
|
||||
import {z} from 'zod'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
|
||||
import {Textarea} from '@/components/ui/textarea'
|
||||
import {TlsCertificateService, ZoneDomainService, type ZoneDomainItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({domain: z.string().trim().min(1, '请输入完整域名').refine((value) => !value.includes('*.'), 'Zone 域名不支持通配符'), cert_id: z.string(), remark: z.string().max(255)})
|
||||
type Values = z.infer<typeof schema>
|
||||
export function ZoneDomainDialog({open, onOpenChange, zoneId, domain, onSaved}: {open: boolean; onOpenChange(open: boolean): void; zoneId: number; domain?: ZoneDomainItem | null; onSaved(): Promise<unknown> | void}) {
|
||||
const form = useForm<Values>({resolver: zodResolver(schema), defaultValues: {domain: '', cert_id: '', remark: ''}}); const queryClient = useQueryClient()
|
||||
const certificatesQuery = useQuery({queryKey: ['openflare', 'tls-certificates'], queryFn: () => TlsCertificateService.list(), enabled: open})
|
||||
useEffect(() => { if (open) form.reset({domain: domain?.domain ?? '', cert_id: domain?.cert_id?.toString() ?? '', remark: domain?.remark ?? ''}) }, [domain, form, open])
|
||||
const mutation = useMutation({mutationFn: (values: Values) => {const payload = {domain: values.domain.toLowerCase(), cert_id: values.cert_id ? Number(values.cert_id) : null, remark: values.remark.trim()}; return domain ? ZoneDomainService.update(zoneId, domain.id, payload) : ZoneDomainService.create(zoneId, payload)}, onSuccess: async () => {toast.success(domain ? '域名已更新' : '域名已添加'); await Promise.all([onSaved(), queryClient.invalidateQueries({queryKey: ['openflare', 'zones']})]); onOpenChange(false)}, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>{domain ? '编辑 Zone 域名' : '添加 Zone 域名'}</DialogTitle><DialogDescription>填写明确 FQDN;通配符仅可存在于所选证书的 SAN 中。</DialogDescription></DialogHeader><form id="zone-domain-editor" className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate(values))}><div className="space-y-1.5"><Label htmlFor="fqdn">完整域名</Label><Input id="fqdn" placeholder="api.arctel.de" {...form.register('domain')} />{form.formState.errors.domain && <p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>}</div><div className="space-y-1.5"><Label>证书</Label><Select value={form.watch('cert_id') || '__none'} onValueChange={(value) => form.setValue('cert_id', value === '__none' ? '' : value)}><SelectTrigger><SelectValue placeholder="不绑定证书" /></SelectTrigger><SelectContent><SelectItem value="__none">不绑定证书</SelectItem>{(certificatesQuery.data ?? []).map((certificate) => <SelectItem key={certificate.id} value={String(certificate.id)}>{certificate.name} · {certificate.primary_domain}</SelectItem>)}</SelectContent></Select></div><div className="space-y-1.5"><Label htmlFor="domain-remark">备注</Label><Textarea id="domain-remark" rows={2} {...form.register('remark')} /></div></form><DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button type="submit" form="zone-domain-editor" disabled={mutation.isPending}>{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}{domain ? '保存修改' : '添加域名'}</Button></DialogFooter></DialogContent></Dialog>
|
||||
}
|
||||
@@ -0,0 +1,174 @@
|
||||
'use client';
|
||||
|
||||
import {useState} from 'react';
|
||||
import {useMutation} from '@tanstack/react-query';
|
||||
import {MoreHorizontal, Pencil, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow} from '@/components/ui/table';
|
||||
import {ZoneDomainService, type TlsCertificateItem, type ZoneDomainItem} from '@/lib/services/openflare';
|
||||
|
||||
import {ZoneDomainDialog} from './zone-domain-dialog';
|
||||
|
||||
export function ZoneDomainsTable({
|
||||
zoneId,
|
||||
domains,
|
||||
certificates,
|
||||
onChanged,
|
||||
}: {
|
||||
zoneId: number;
|
||||
domains: ZoneDomainItem[];
|
||||
certificates: TlsCertificateItem[];
|
||||
onChanged(): Promise<unknown> | void;
|
||||
}) {
|
||||
const [editing, setEditing] = useState<ZoneDomainItem | null | undefined>(undefined);
|
||||
const [deleting, setDeleting] = useState<ZoneDomainItem | null>(null);
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: (id: number) => ZoneDomainService.deleteById(zoneId, id),
|
||||
onSuccess: async () => {
|
||||
toast.success('域名已删除');
|
||||
setDeleting(null);
|
||||
await onChanged();
|
||||
},
|
||||
onError: (error) => toast.error(error instanceof Error ? error.message : '删除失败'),
|
||||
});
|
||||
|
||||
const certificateMap = new Map(certificates.map((certificate) => [certificate.id, certificate]));
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="mb-4 flex justify-end">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditing(null)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
添加域名
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{domains.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无已添加域名" />
|
||||
) : (
|
||||
<div className="rounded-lg border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>FQDN</TableHead>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>关联路由</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
<TableHead className="w-12" />
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{domains.map((domain) => (
|
||||
<TableRow key={domain.id}>
|
||||
<TableCell className="font-medium">{domain.domain}</TableCell>
|
||||
<TableCell className="text-muted-foreground">
|
||||
{domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id)?.name ?? `证书 #${domain.cert_id}`)
|
||||
: '未绑定'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{domain.proxy_route_id ? `#${domain.proxy_route_id}` : '未关联'}
|
||||
</TableCell>
|
||||
<TableCell className="max-w-48 truncate text-muted-foreground">
|
||||
{domain.remark || '—'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
aria-label={`操作 ${domain.domain}`}
|
||||
>
|
||||
<MoreHorizontal />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => setEditing(domain)}>
|
||||
<Pencil />
|
||||
编辑
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onClick={() => setDeleting(domain)}
|
||||
>
|
||||
<Trash2 />
|
||||
删除
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ZoneDomainDialog
|
||||
open={editing !== undefined}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setEditing(undefined);
|
||||
}
|
||||
}}
|
||||
zoneId={zoneId}
|
||||
domain={editing ?? null}
|
||||
onSaved={onChanged}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleting !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
setDeleting(null);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除域名</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {deleting?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={remove.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={remove.isPending || !deleting}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
if (deleting) {
|
||||
remove.mutate(deleting.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{remove.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
'use client'
|
||||
|
||||
import {useEffect} from 'react'
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {Loader2} from 'lucide-react'
|
||||
import {toast} from 'sonner'
|
||||
import {z} from 'zod'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle} from '@/components/ui/dialog'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Label} from '@/components/ui/label'
|
||||
import {Textarea} from '@/components/ui/textarea'
|
||||
import {ZoneService, zoneQueryKey, type ZoneItem} from '@/lib/services/openflare'
|
||||
|
||||
const schema = z.object({domain: z.string().trim().min(1, '请输入 Zone 根域').refine((value) => !/[*/?#@]|:\/\//.test(value), '请输入不含协议或通配符的根域'), remark: z.string().max(255)})
|
||||
type Values = z.infer<typeof schema>
|
||||
|
||||
export function ZoneEditorDialog({open, onOpenChange, zone}: {open: boolean; onOpenChange(open: boolean): void; zone?: ZoneItem | null}) {
|
||||
const queryClient = useQueryClient(); const form = useForm<Values>({resolver: zodResolver(schema), defaultValues: {domain: '', remark: ''}})
|
||||
useEffect(() => { if (open) form.reset({domain: zone?.domain ?? '', remark: zone?.remark ?? ''}) }, [form, open, zone])
|
||||
const mutation = useMutation({mutationFn: (values: Values) => zone ? ZoneService.update(zone.id, values) : ZoneService.create(values), onSuccess: async () => { toast.success(zone ? 'Zone 已更新' : 'Zone 已创建'); await queryClient.invalidateQueries({queryKey: zoneQueryKey}); onOpenChange(false) }, onError: (error) => toast.error(error instanceof Error ? error.message : '保存失败')})
|
||||
return <Dialog open={open} onOpenChange={onOpenChange}><DialogContent><DialogHeader><DialogTitle>{zone ? '编辑 Zone' : '新增 Zone'}</DialogTitle><DialogDescription>Zone 仅接受可注册根域,例如 arctel.de。</DialogDescription></DialogHeader><form id="zone-editor" className="space-y-4" onSubmit={form.handleSubmit((values) => mutation.mutate({domain: values.domain.toLowerCase(), remark: values.remark.trim()}))}><div className="space-y-1.5"><Label htmlFor="zone-domain">根域</Label><Input id="zone-domain" placeholder="arctel.de" {...form.register('domain')} />{form.formState.errors.domain && <p className="text-xs text-destructive">{form.formState.errors.domain.message}</p>}</div><div className="space-y-1.5"><Label htmlFor="zone-remark">备注</Label><Textarea id="zone-remark" rows={2} {...form.register('remark')} /></div></form><DialogFooter><Button variant="outline" onClick={() => onOpenChange(false)}>取消</Button><Button form="zone-editor" type="submit" disabled={mutation.isPending}>{mutation.isPending && <Loader2 className="mr-1 size-4 animate-spin" />}{zone ? '保存修改' : '新增 Zone'}</Button></DialogFooter></DialogContent></Dialog>
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import type {ZoneOverview} from '@/lib/services/openflare'
|
||||
import {formatDateTime} from '@/lib/utils'
|
||||
|
||||
export function ZoneOverviewPanel({overview}: {overview: ZoneOverview}) {
|
||||
const boundRoutes = new Set(overview.domains.flatMap((domain) => domain.proxy_route_id ? [domain.proxy_route_id] : [])).size
|
||||
const certificates = overview.domains.filter((domain) => domain.cert_id !== null).length
|
||||
return <div className="grid gap-4 md:grid-cols-3">
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">域名</CardTitle><CardDescription>显式 FQDN</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{overview.domains.length}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">关联路由</CardTitle><CardDescription>已绑定的不同规则</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{boundRoutes}</CardContent></Card>
|
||||
<Card className="shadow-none"><CardHeader className="pb-2"><CardTitle className="text-sm">已配证书</CardTitle><CardDescription>域名级 TLS 绑定</CardDescription></CardHeader><CardContent className="text-2xl font-semibold">{certificates}</CardContent></Card>
|
||||
<Card className="shadow-none md:col-span-3"><CardHeader className="pb-2"><CardTitle className="text-sm">Zone 信息</CardTitle></CardHeader><CardContent className="grid gap-3 text-sm sm:grid-cols-2"><div><p className="text-xs text-muted-foreground">根域</p><p className="mt-1 font-medium">{overview.zone.domain}</p></div><div><p className="text-xs text-muted-foreground">创建时间</p><p className="mt-1">{formatDateTime(overview.zone.created_at)}</p></div><div className="sm:col-span-2"><p className="text-xs text-muted-foreground">备注</p><p className="mt-1">{overview.zone.remark || '暂无备注'}</p></div></CardContent></Card>
|
||||
</div>
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty'
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card'
|
||||
import type {ZoneDomainItem} from '@/lib/services/openflare'
|
||||
import {Route} from 'lucide-react'
|
||||
|
||||
export function ZoneRouteSummary({domains}: {domains: ZoneDomainItem[]}) {
|
||||
const groups = Map.groupBy(domains.filter((domain) => domain.proxy_route_id !== null), (domain) => domain.proxy_route_id!)
|
||||
return <Card className="shadow-none"><CardHeader><CardTitle className="text-base">路由关联</CardTitle><CardDescription>域名只能关联一个反代路由;路由可关联多个 Zone 域名。</CardDescription></CardHeader><CardContent>{groups.size === 0 ? <EmptyStateWithBorder icon={Route} description="暂无关联反代路由。" /> : <div className="space-y-3">{[...groups].map(([routeID, items]) => <div key={routeID} className="rounded-lg border p-3"><p className="text-sm font-semibold">路由 #{routeID}</p><p className="mt-1 text-xs text-muted-foreground">{items.map((item) => item.domain).join(' · ')}</p></div>)}</div>}</CardContent></Card>
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useState} from 'react';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {ArrowLeft, Globe, Pencil, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs';
|
||||
import {TlsCertificateService, ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
import {getErrorMessage} from '../components/website-utils';
|
||||
import {ZoneCertificatesPanel} from './components/zone-certificates';
|
||||
import {ZoneDomainsTable} from './components/zone-domains-table';
|
||||
import {ZoneEditorDialog} from './components/zone-editor-dialog';
|
||||
import {ZoneOverviewPanel} from './components/zone-overview';
|
||||
import {ZoneRouteSummary} from './components/zone-route-summary';
|
||||
|
||||
export function ZonePageClient({zoneId}: {zoneId: number}) {
|
||||
const queryClient = useQueryClient();
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [editZone, setEditZone] = useState(false);
|
||||
const [confirmDelete, setConfirmDelete] = useState(false);
|
||||
|
||||
const overviewQuery = useQuery({
|
||||
queryKey: [...zoneQueryKey, zoneId],
|
||||
queryFn: () => ZoneService.getOverview(zoneId),
|
||||
enabled: Number.isInteger(zoneId) && zoneId > 0,
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: ['openflare', 'tls-certificates'],
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
enabled: overviewQuery.isSuccess,
|
||||
});
|
||||
|
||||
const deleteZone = useMutation({
|
||||
mutationFn: () => ZoneService.deleteById(zoneId),
|
||||
onSuccess: async () => {
|
||||
toast.success('Zone 已删除');
|
||||
await queryClient.invalidateQueries({queryKey: zoneQueryKey});
|
||||
window.location.assign('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!Number.isInteger(zoneId) || zoneId <= 0) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="无效的网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isLoading) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (overviewQuery.isError || !overviewQuery.data) {
|
||||
return (
|
||||
<div className="space-y-4 py-6 px-1">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="mr-1 size-4" />
|
||||
返回网站列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const overview = overviewQuery.data;
|
||||
const certificates = certificatesQuery.data ?? [];
|
||||
const boundCertCount = new Set(
|
||||
overview.domains.map((domain) => domain.cert_id).filter((id): id is number => id != null),
|
||||
).size;
|
||||
|
||||
return (
|
||||
<div className="space-y-6 py-6 px-1">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{overview.zone.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">Zone 网站管理</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="mr-1 size-3.5" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
编辑 Zone
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setConfirmDelete(true)}
|
||||
>
|
||||
<Trash2 className="mr-1 size-3.5" />
|
||||
删除 Zone
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab} className="w-full">
|
||||
<TabsList variant="line" className="mb-6 inline-flex w-fit gap-8">
|
||||
<TabsTrigger value="overview" className="px-0 pb-2 text-xs font-semibold">
|
||||
概览
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="domains" className="px-0 pb-2 text-xs font-semibold">
|
||||
域名 ({overview.domains.length})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="routes" className="px-0 pb-2 text-xs font-semibold">
|
||||
路由
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="certificates" className="px-0 pb-2 text-xs font-semibold">
|
||||
证书 ({boundCertCount})
|
||||
</TabsTrigger>
|
||||
<TabsTrigger value="settings" className="px-0 pb-2 text-xs font-semibold">
|
||||
设置
|
||||
</TabsTrigger>
|
||||
</TabsList>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<ZoneOverviewPanel overview={overview} />
|
||||
</TabsContent>
|
||||
<TabsContent value="domains">
|
||||
<ZoneDomainsTable
|
||||
zoneId={zoneId}
|
||||
domains={overview.domains}
|
||||
certificates={certificates}
|
||||
onChanged={() => overviewQuery.refetch()}
|
||||
/>
|
||||
</TabsContent>
|
||||
<TabsContent value="routes">
|
||||
<ZoneRouteSummary domains={overview.domains} />
|
||||
</TabsContent>
|
||||
<TabsContent value="certificates">
|
||||
<ZoneCertificatesPanel domains={overview.domains} certificates={certificates} />
|
||||
</TabsContent>
|
||||
<TabsContent value="settings">
|
||||
<div className="rounded-lg border p-4">
|
||||
<p className="text-sm font-semibold">Zone 设置</p>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
根域和备注可在此 Zone 的编辑操作中维护。
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="mt-4 h-7 text-xs"
|
||||
onClick={() => setEditZone(true)}
|
||||
>
|
||||
<Pencil className="mr-1 size-3.5" />
|
||||
编辑根域与备注
|
||||
</Button>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
|
||||
<ZoneEditorDialog
|
||||
open={editZone}
|
||||
onOpenChange={setEditZone}
|
||||
zone={overview.zone}
|
||||
/>
|
||||
|
||||
<AlertDialog open={confirmDelete} onOpenChange={setConfirmDelete}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除 Zone</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除 {overview.zone.domain} 吗?其下域名须先解绑路由后才能删除,此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={deleteZone.isPending}>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-white hover:bg-destructive/90"
|
||||
disabled={deleteZone.isPending}
|
||||
onClick={(event) => {
|
||||
event.preventDefault();
|
||||
deleteZone.mutate();
|
||||
}}
|
||||
>
|
||||
{deleteZone.isPending ? '删除中…' : '确认删除'}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import {ZonePageClient} from './page-client'
|
||||
|
||||
export default async function ZonePage({params}: PageProps<'/websites/[zoneId]'>) {
|
||||
const {zoneId} = await params
|
||||
return <ZonePageClient zoneId={Number(zoneId)} />
|
||||
}
|
||||
@@ -1,33 +1,5 @@
|
||||
import {z} from 'zod';
|
||||
|
||||
export const managedDomainSchema = z.object({
|
||||
domain: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, '请输入域名')
|
||||
.max(255, '域名不能超过 255 个字符')
|
||||
.refine(
|
||||
(value) => !value.includes('://') && !value.includes('/'),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
/^(?:\*\.)?(?=.{1,253}$)(?:[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,63}$/.test(
|
||||
value,
|
||||
),
|
||||
'域名格式不合法',
|
||||
)
|
||||
.refine(
|
||||
(value) =>
|
||||
!value.includes('*') ||
|
||||
(value.startsWith('*.') && value.indexOf('*', 1) === -1),
|
||||
'通配符域名仅支持 *.example.com 格式',
|
||||
),
|
||||
cert_id: z.string(),
|
||||
enabled: z.boolean(),
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export const manualImportSchema = z.object({
|
||||
name: z
|
||||
.string()
|
||||
@@ -39,7 +11,6 @@ export const manualImportSchema = z.object({
|
||||
remark: z.string().max(255, '备注不能超过 255 个字符'),
|
||||
});
|
||||
|
||||
export type ManagedDomainFormValues = z.infer<typeof managedDomainSchema>;
|
||||
export type ManualImportFormValues = z.infer<typeof manualImportSchema>;
|
||||
|
||||
export type FileImportFormValues = {
|
||||
@@ -47,13 +18,6 @@ export type FileImportFormValues = {
|
||||
remark: string;
|
||||
};
|
||||
|
||||
export const defaultManagedDomainValues: ManagedDomainFormValues = {
|
||||
domain: '',
|
||||
cert_id: '',
|
||||
enabled: true,
|
||||
remark: '',
|
||||
};
|
||||
|
||||
export const defaultManualImportValues: ManualImportFormValues = {
|
||||
name: '',
|
||||
cert_pem: '',
|
||||
|
||||
@@ -1,250 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import {zodResolver} from '@hookform/resolvers/zod';
|
||||
import {useMutation, useQueryClient} from '@tanstack/react-query';
|
||||
import {useEffect} from 'react';
|
||||
import {useForm, useWatch} from 'react-hook-form';
|
||||
import {Loader2} from 'lucide-react';
|
||||
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {Label} from '@/components/ui/label';
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue,} from '@/components/ui/select';
|
||||
import {Switch} from '@/components/ui/switch';
|
||||
import type {ManagedDomainItem, TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {WebsiteService} from '@/lib/services/openflare';
|
||||
|
||||
import {defaultManagedDomainValues, type ManagedDomainFormValues, managedDomainSchema,} from './schemas';
|
||||
import {WebsiteStatusBadge} from './status-badge';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getErrorMessage,
|
||||
getMatchTypeMeta,
|
||||
toManagedDomainFormValues,
|
||||
toManagedDomainPayload,
|
||||
} from './website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
|
||||
interface WebsiteEditorDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
onSaved?: (domain: ManagedDomainItem, mode: 'create' | 'update') => void;
|
||||
onRequestImportCertificate: () => void;
|
||||
certificates: TlsCertificateItem[];
|
||||
certificatesLoading?: boolean;
|
||||
initialDomain?: ManagedDomainItem | null;
|
||||
preferredCertificateId?: number | null;
|
||||
}
|
||||
|
||||
export function WebsiteEditorDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
onSaved,
|
||||
onRequestImportCertificate,
|
||||
certificates,
|
||||
certificatesLoading = false,
|
||||
initialDomain = null,
|
||||
preferredCertificateId = null,
|
||||
}: WebsiteEditorDialogProps) {
|
||||
const queryClient = useQueryClient();
|
||||
const form = useForm<ManagedDomainFormValues>({
|
||||
resolver: zodResolver(managedDomainSchema),
|
||||
defaultValues: defaultManagedDomainValues,
|
||||
});
|
||||
|
||||
const watchedDomain = useWatch({control: form.control, name: 'domain'});
|
||||
const watchedCertId = useWatch({control: form.control, name: 'cert_id'});
|
||||
const watchedEnabled = useWatch({control: form.control, name: 'enabled'});
|
||||
|
||||
const saveMutation = useMutation({
|
||||
mutationFn: async (values: ManagedDomainFormValues) => {
|
||||
const payload = toManagedDomainPayload(values);
|
||||
return initialDomain
|
||||
? WebsiteService.update(initialDomain.id, payload)
|
||||
: WebsiteService.create(payload);
|
||||
},
|
||||
onSuccess: async (domain) => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
onSaved?.(domain, initialDomain ? 'update' : 'create');
|
||||
handleClose();
|
||||
},
|
||||
});
|
||||
|
||||
const currentCertificate = watchedCertId
|
||||
? (certificates.find((item) => item.id === Number(watchedCertId)) ?? null)
|
||||
: null;
|
||||
|
||||
const handleSubmit = form.handleSubmit((values) => {
|
||||
saveMutation.mutate(values);
|
||||
});
|
||||
|
||||
const handleClose = () => {
|
||||
saveMutation.reset();
|
||||
form.reset(defaultManagedDomainValues);
|
||||
onOpenChange(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
form.reset(
|
||||
initialDomain
|
||||
? toManagedDomainFormValues(initialDomain)
|
||||
: defaultManagedDomainValues,
|
||||
);
|
||||
}, [form, initialDomain, open]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !preferredCertificateId) return;
|
||||
form.setValue('cert_id', String(preferredCertificateId), {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
});
|
||||
}, [form, open, preferredCertificateId]);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !next && handleClose()}>
|
||||
<DialogContent className="max-w-2xl">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{initialDomain ? '编辑网站' : '新增网站'}</DialogTitle>
|
||||
<DialogDescription>
|
||||
录入域名并选择绑定证书。证书可在弹窗内直接新增,导入成功后会自动回填。
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form className="space-y-4" onSubmit={handleSubmit}>
|
||||
{saveMutation.isError ? (
|
||||
<p className="text-sm text-destructive">{getErrorMessage(saveMutation.error)}</p>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="domain">域名</Label>
|
||||
<Input
|
||||
id="domain"
|
||||
placeholder="example.com 或 *.example.com"
|
||||
{...form.register('domain')}
|
||||
/>
|
||||
{form.formState.errors.domain ? (
|
||||
<p className="text-xs text-destructive">
|
||||
{form.formState.errors.domain.message}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label>绑定证书</Label>
|
||||
<Select
|
||||
value={watchedCertId || 'none'}
|
||||
disabled={certificatesLoading}
|
||||
onValueChange={(value) =>
|
||||
form.setValue('cert_id', value === 'none' ? '' : value, {
|
||||
shouldDirty: true,
|
||||
shouldValidate: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="不绑定证书" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">不绑定证书</SelectItem>
|
||||
{certificates.map((certificate) => (
|
||||
<SelectItem key={certificate.id} value={String(certificate.id)}>
|
||||
{buildCertificateLabel(certificate)}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={onRequestImportCertificate}
|
||||
>
|
||||
添加证书
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-3 rounded-lg border border-dashed p-3 md:grid-cols-3">
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前域名</p>
|
||||
<p className="mt-1 text-sm">{watchedDomain?.trim() || '未填写域名'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">匹配类型</p>
|
||||
<div className="mt-1">
|
||||
{watchedDomain?.trim() ? (
|
||||
<WebsiteStatusBadge {...getMatchTypeMeta(watchedDomain.trim())} />
|
||||
) : (
|
||||
<WebsiteStatusBadge label="等待输入" tone="warning" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">当前证书</p>
|
||||
<div className="mt-1">
|
||||
<WebsiteStatusBadge
|
||||
label={currentCertificate ? currentCertificate.name : '未绑定证书'}
|
||||
tone={currentCertificate ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium">启用网站</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
停用后该网站不会参与自动匹配,但记录会保留。
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={watchedEnabled}
|
||||
onCheckedChange={(checked) =>
|
||||
form.setValue('enabled', checked, {shouldDirty: true, shouldValidate: true})
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="remark">备注</Label>
|
||||
<Input
|
||||
id="remark"
|
||||
placeholder="例如:主站 / 泛域名 / 生产"
|
||||
{...form.register('remark')}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
取消
|
||||
</Button>
|
||||
<Button type="submit" disabled={saveMutation.isPending}>
|
||||
{saveMutation.isPending ? (
|
||||
<>
|
||||
<Loader2 className="mr-1 size-3.5 animate-spin" />
|
||||
保存中...
|
||||
</>
|
||||
) : initialDomain ? (
|
||||
'保存网站'
|
||||
) : (
|
||||
'创建网站'
|
||||
)}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,11 @@
|
||||
import type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMutationPayload,
|
||||
ProxyRouteItem,
|
||||
TlsCertificateFileImportPayload,
|
||||
TlsCertificateItem,
|
||||
TlsCertificateMutationPayload,
|
||||
} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
|
||||
import type {FileImportFormValues, ManagedDomainFormValues, ManualImportFormValues,} from './schemas';
|
||||
import type {FileImportFormValues, ManualImportFormValues} from './schemas';
|
||||
|
||||
export type StatusTone = 'success' | 'warning' | 'danger' | 'info';
|
||||
|
||||
@@ -51,28 +48,6 @@ export function buildCertificateLabel(certificate: TlsCertificateItem) {
|
||||
: certificate.name;
|
||||
}
|
||||
|
||||
export function toManagedDomainPayload(
|
||||
values: ManagedDomainFormValues,
|
||||
): ManagedDomainMutationPayload {
|
||||
return {
|
||||
domain: values.domain.trim().toLowerCase(),
|
||||
cert_id: values.cert_id ? Number(values.cert_id) : null,
|
||||
enabled: values.enabled,
|
||||
remark: values.remark.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
export function toManagedDomainFormValues(
|
||||
domain: ManagedDomainItem,
|
||||
): ManagedDomainFormValues {
|
||||
return {
|
||||
domain: domain.domain,
|
||||
cert_id: domain.cert_id ? String(domain.cert_id) : '',
|
||||
enabled: domain.enabled,
|
||||
remark: domain.remark || '',
|
||||
};
|
||||
}
|
||||
|
||||
export function toManualPayload(
|
||||
values: ManualImportFormValues,
|
||||
): TlsCertificateMutationPayload {
|
||||
@@ -100,20 +75,3 @@ export function toFilePayload(
|
||||
keyFile,
|
||||
};
|
||||
}
|
||||
|
||||
export function isRouteRelatedToManagedDomain(
|
||||
managedDomain: string,
|
||||
route: ProxyRouteItem,
|
||||
) {
|
||||
const domains = route.domains.length > 0 ? route.domains : [route.primary_domain];
|
||||
return domains.some((routeDomain) => {
|
||||
if (managedDomain === routeDomain) {
|
||||
return true;
|
||||
}
|
||||
if (!managedDomain.startsWith('*.')) {
|
||||
return false;
|
||||
}
|
||||
const suffix = managedDomain.slice(2);
|
||||
return routeDomain.endsWith(`.${suffix}`);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,466 +0,0 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useRouter, useSearchParams} from 'next/navigation';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {ArrowLeft, Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import {Table, TableBody, TableCell, TableHead, TableHeader, TableRow,} from '@/components/ui/table';
|
||||
import type {TlsCertificateItem} from '@/lib/services/openflare';
|
||||
import {ProxyRouteService, TlsCertificateService, WebsiteService,} from '@/lib/services/openflare';
|
||||
import {formatDateTime} from '@/lib/utils';
|
||||
import {getUpstreamSummary} from '@/app/(main)/proxy-routes/components/helpers';
|
||||
|
||||
import {CertificateApplyDialog} from '../components/certificate-apply-dialog';
|
||||
import {CertificateDetailDialog} from '../components/certificate-detail-dialog';
|
||||
import {CertificateEditorDialog} from '../components/certificate-editor-dialog';
|
||||
import {CertificateImportDialog} from '../components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from '../components/status-badge';
|
||||
import {WebsiteEditorDialog} from '../components/website-editor-dialog';
|
||||
import {
|
||||
buildCertificateLabel,
|
||||
getCertificateStatus,
|
||||
getErrorMessage,
|
||||
isRouteRelatedToManagedDomain,
|
||||
} from '../components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
|
||||
export function WebsiteDetailPageClient() {
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
const queryClient = useQueryClient();
|
||||
const websiteId = searchParams.get('id')?.trim() ?? '';
|
||||
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [detailOpen, setDetailOpen] = useState(false);
|
||||
const [certEditorOpen, setCertEditorOpen] = useState(false);
|
||||
const [deleteWebsiteOpen, setDeleteWebsiteOpen] = useState(false);
|
||||
const [deleteCertOpen, setDeleteCertOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [convertCertificate, setConvertCertificate] = useState<TlsCertificateItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const routesQuery = useQuery({
|
||||
queryKey: ['openflare', 'proxy-routes'],
|
||||
queryFn: () => ProxyRouteService.list(),
|
||||
});
|
||||
|
||||
const website = useMemo(
|
||||
() =>
|
||||
(domainsQuery.data ?? []).find((item) => String(item.id) === websiteId) ?? null,
|
||||
[domainsQuery.data, websiteId],
|
||||
);
|
||||
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
|
||||
const certificate = website?.cert_id
|
||||
? (certificateMap.get(website.cert_id) ?? null)
|
||||
: null;
|
||||
|
||||
const relatedRoutes = useMemo(() => {
|
||||
if (!website) return [];
|
||||
return (routesQuery.data ?? []).filter((route) =>
|
||||
isRouteRelatedToManagedDomain(website.domain, route),
|
||||
);
|
||||
}, [routesQuery.data, website]);
|
||||
|
||||
const deleteDomainMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
router.push('/websites');
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const deleteCertificateMutation = useMutation({
|
||||
mutationFn: (id: number) => TlsCertificateService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('证书已删除');
|
||||
setDeleteCertOpen(false);
|
||||
await Promise.all([
|
||||
queryClient.invalidateQueries({queryKey: certificatesQueryKey}),
|
||||
queryClient.invalidateQueries({queryKey: domainsQueryKey}),
|
||||
]);
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
if (!websiteId) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="缺少网站 ID,请从网站列表进入详情页。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
domainsQuery.isLoading ||
|
||||
certificatesQuery.isLoading ||
|
||||
routesQuery.isLoading
|
||||
) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站详情中..." />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (domainsQuery.isError) {
|
||||
return (
|
||||
<div className="py-6 px-1">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!website) {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-4">
|
||||
<Button variant="ghost" size="sm" className="h-8 px-2" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-4 mr-1" />
|
||||
返回网站列表
|
||||
</Link>
|
||||
</Button>
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="网站不存在,可能已被删除或 ID 无效。"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const certificateStatus = certificate ? getCertificateStatus(certificate) : null;
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{website.domain}</h1>
|
||||
<p className="text-sm text-muted-foreground">网站详情</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href="/websites">
|
||||
<ArrowLeft className="size-3.5 mr-1" />
|
||||
返回
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setEditorOpen(true)}
|
||||
>
|
||||
编辑网站
|
||||
</Button>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setImportOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
添加证书
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDeleteWebsiteOpen(true)}
|
||||
>
|
||||
<Trash2 className="size-3.5 mr-1" />
|
||||
删除网站
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 xl:grid-cols-2">
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">网站信息</CardTitle>
|
||||
<CardDescription>当前网站的基础配置与托管信息。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="grid gap-3 md:grid-cols-2">
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">域名</p>
|
||||
<p className="mt-1 text-sm">{website.domain}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">
|
||||
创建时间
|
||||
</p>
|
||||
<p className="mt-1 text-sm">{formatDateTime(website.created_at)}</p>
|
||||
</div>
|
||||
<div className="rounded-lg border p-3 md:col-span-2">
|
||||
<p className="text-[10px] uppercase tracking-wider text-muted-foreground">备注</p>
|
||||
<p className="mt-1 text-sm">{website.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">证书信息</CardTitle>
|
||||
<CardDescription>
|
||||
当前网站绑定的默认证书信息。若还没有证书,可直接添加后回填。
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{certificate ? (
|
||||
<div className="space-y-3">
|
||||
{certificateStatus ? (
|
||||
<WebsiteStatusBadge
|
||||
label={certificateStatus.label}
|
||||
tone={certificateStatus.tone}
|
||||
/>
|
||||
) : null}
|
||||
<div className="space-y-1 text-xs text-muted-foreground">
|
||||
<p>证书名称:{buildCertificateLabel(certificate)}</p>
|
||||
<p>生效时间:{formatDateTime(certificate.not_before)}</p>
|
||||
<p>到期时间:{formatDateTime(certificate.not_after)}</p>
|
||||
<p>备注:{certificate.remark || '暂无备注'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs"
|
||||
onClick={() => setDetailOpen(true)}
|
||||
>
|
||||
查看
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteCertOpen(true)}
|
||||
>
|
||||
删除
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<EmptyStateWithBorder description="未绑定证书,点击右上角「添加证书」后可在编辑网站时直接选择。" />
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
<Card className="shadow-none">
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-sm">关联规则</CardTitle>
|
||||
<CardDescription>命中当前网站域名的代理规则。</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{relatedRoutes.length === 0 ? (
|
||||
<EmptyStateWithBorder description="暂无关联规则。" />
|
||||
) : (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>规则域名</TableHead>
|
||||
<TableHead>源站</TableHead>
|
||||
<TableHead>HTTPS</TableHead>
|
||||
<TableHead>证书</TableHead>
|
||||
<TableHead>状态</TableHead>
|
||||
<TableHead>备注</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{relatedRoutes.map((route) => {
|
||||
const routeCertificate = route.cert_id
|
||||
? (certificateMap.get(route.cert_id) ?? null)
|
||||
: null;
|
||||
const matchedDomain =
|
||||
route.domains.find((d) => d === website.domain) ?? route.primary_domain;
|
||||
|
||||
return (
|
||||
<TableRow key={route.id}>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
<p className="text-sm">{matchedDomain}</p>
|
||||
<WebsiteStatusBadge
|
||||
label={
|
||||
matchedDomain === website.domain
|
||||
? '直接关联'
|
||||
: '被网站覆盖'
|
||||
}
|
||||
tone={matchedDomain === website.domain ? 'info' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground max-w-56 break-all">
|
||||
{getUpstreamSummary(route)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enable_https ? '启用 HTTPS' : 'HTTP'}
|
||||
tone={route.enable_https ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs">
|
||||
{routeCertificate ? routeCertificate.name : '未绑定证书'}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<WebsiteStatusBadge
|
||||
label={route.enabled ? '启用' : '停用'}
|
||||
tone={route.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-xs text-muted-foreground">
|
||||
{route.remark || '暂无备注'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
initialDomain={website}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={() => toast.success('网站已更新')}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(imported) => {
|
||||
setPreferredCertificateId(imported.id);
|
||||
toast.success(`证书 ${imported.name} 已导入,可在编辑网站时直接应用`);
|
||||
setEditorOpen(true);
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateDetailDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={detailOpen}
|
||||
onOpenChange={setDetailOpen}
|
||||
onEdit={() => {
|
||||
setDetailOpen(false);
|
||||
setCertEditorOpen(true);
|
||||
}}
|
||||
onDelete={() => {
|
||||
setDetailOpen(false);
|
||||
setDeleteCertOpen(true);
|
||||
}}
|
||||
deleting={deleteCertificateMutation.isPending}
|
||||
/>
|
||||
|
||||
<CertificateEditorDialog
|
||||
certificateId={certificate?.id ?? null}
|
||||
open={certEditorOpen}
|
||||
onOpenChange={setCertEditorOpen}
|
||||
onSaved={(updated) => toast.success(`证书 ${updated.name} 已更新`)}
|
||||
onConvert={(manualCertificate) => {
|
||||
setCertEditorOpen(false);
|
||||
setConvertCertificate(manualCertificate);
|
||||
}}
|
||||
/>
|
||||
|
||||
{convertCertificate ? (
|
||||
<CertificateApplyDialog
|
||||
open
|
||||
onOpenChange={(open) => !open && setConvertCertificate(null)}
|
||||
mode="convert-upload"
|
||||
certificate={convertCertificate}
|
||||
onApplied={(converted) => {
|
||||
setConvertCertificate(null);
|
||||
toast.success(`证书 ${converted.name} 转换申请已提交`);
|
||||
}}
|
||||
/>
|
||||
) : null}
|
||||
|
||||
<AlertDialog open={deleteWebsiteOpen} onOpenChange={setDeleteWebsiteOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {website.domain} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteDomainMutation.mutate(website.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={deleteCertOpen} onOpenChange={setDeleteCertOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除证书</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除证书 {certificate?.name} 吗?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => certificate && deleteCertificateMutation.mutate(certificate.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
import {Suspense} from 'react';
|
||||
|
||||
import {Skeleton} from '@/components/ui/skeleton';
|
||||
|
||||
import {WebsiteDetailPageClient} from './page-client';
|
||||
|
||||
function WebsiteDetailPageFallback() {
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<Skeleton className="h-8 w-48" />
|
||||
<Skeleton className="h-10 w-full max-w-xl" />
|
||||
<Skeleton className="h-64 w-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function WebsiteDetailPage() {
|
||||
return (
|
||||
<Suspense fallback={<WebsiteDetailPageFallback />}>
|
||||
<WebsiteDetailPageClient />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -1,211 +1,119 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import {useMutation, useQuery, useQueryClient} from '@tanstack/react-query';
|
||||
import {useMemo, useState} from 'react';
|
||||
import {Globe, Plus, Trash2} from 'lucide-react';
|
||||
import {toast} from 'sonner';
|
||||
import {useQuery} from '@tanstack/react-query';
|
||||
import {Globe, Plus, Search} from 'lucide-react';
|
||||
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {Button} from '@/components/ui/button';
|
||||
import {Card, CardContent, CardDescription, CardHeader, CardTitle} from '@/components/ui/card';
|
||||
import {EmptyStateWithBorder} from '@/components/layout/empty';
|
||||
import {ErrorInline} from '@/components/layout/error';
|
||||
import {Input} from '@/components/ui/input';
|
||||
import {LoadingStateWithBorder} from '@/components/layout/loading';
|
||||
import type {ManagedDomainItem} from '@/lib/services/openflare';
|
||||
import {TlsCertificateService, WebsiteService} from '@/lib/services/openflare';
|
||||
import {ZoneService, zoneQueryKey} from '@/lib/services/openflare';
|
||||
|
||||
import {CertificateImportDialog} from './components/certificate-import-dialog';
|
||||
import {WebsiteStatusBadge} from './components/status-badge';
|
||||
import {WebsiteEditorDialog} from './components/website-editor-dialog';
|
||||
import {buildCertificateLabel, getErrorMessage, getMatchTypeMeta,} from './components/website-utils';
|
||||
|
||||
const domainsQueryKey = ['openflare', 'managed-domains'];
|
||||
const certificatesQueryKey = ['openflare', 'tls-certificates'];
|
||||
import {ZoneEditorDialog} from './[zoneId]/components/zone-editor-dialog';
|
||||
import {getErrorMessage} from './components/website-utils';
|
||||
|
||||
export default function WebsitesPage() {
|
||||
const queryClient = useQueryClient();
|
||||
const [search, setSearch] = useState('');
|
||||
const [editorOpen, setEditorOpen] = useState(false);
|
||||
const [importOpen, setImportOpen] = useState(false);
|
||||
const [preferredCertificateId, setPreferredCertificateId] = useState<number | null>(null);
|
||||
const [deleteTarget, setDeleteTarget] = useState<ManagedDomainItem | null>(null);
|
||||
|
||||
const domainsQuery = useQuery({
|
||||
queryKey: domainsQueryKey,
|
||||
queryFn: () => WebsiteService.list(),
|
||||
const zonesQuery = useQuery({
|
||||
queryKey: zoneQueryKey,
|
||||
queryFn: () => ZoneService.list(),
|
||||
});
|
||||
|
||||
const certificatesQuery = useQuery({
|
||||
queryKey: certificatesQueryKey,
|
||||
queryFn: () => TlsCertificateService.list(),
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (id: number) => WebsiteService.deleteById(id),
|
||||
onSuccess: async () => {
|
||||
toast.success('网站已删除');
|
||||
setDeleteTarget(null);
|
||||
await queryClient.invalidateQueries({queryKey: domainsQueryKey});
|
||||
},
|
||||
onError: (error) => toast.error(getErrorMessage(error)),
|
||||
});
|
||||
|
||||
const domains = useMemo(() => domainsQuery.data ?? [], [domainsQuery.data]);
|
||||
const certificates = useMemo(
|
||||
() => certificatesQuery.data ?? [],
|
||||
[certificatesQuery.data],
|
||||
);
|
||||
const certificateMap = useMemo(
|
||||
() => new Map(certificates.map((item) => [item.id, item])),
|
||||
[certificates],
|
||||
);
|
||||
const zones = useMemo(() => {
|
||||
const keyword = search.trim().toLowerCase();
|
||||
const list = zonesQuery.data ?? [];
|
||||
if (!keyword) {
|
||||
return list;
|
||||
}
|
||||
return list.filter((zone) => zone.domain.toLowerCase().includes(keyword));
|
||||
}, [search, zonesQuery.data]);
|
||||
|
||||
return (
|
||||
<div className="py-6 px-1 space-y-6">
|
||||
<div className="space-y-6 py-6 px-1">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Globe className="size-5 text-primary" />
|
||||
<h1 className="text-2xl font-semibold tracking-tight">网站</h1>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
||||
<Plus className="size-3.5 mr-1" />
|
||||
新增网站
|
||||
</Button>
|
||||
</div>
|
||||
<Button size="sm" className="h-7 text-xs" onClick={() => setEditorOpen(true)}>
|
||||
<Plus className="mr-1 size-3.5" />
|
||||
新增 Zone
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Card className="border-dashed shadow-none">
|
||||
<CardHeader className="pb-3">
|
||||
<CardTitle className="text-base font-semibold">网站列表</CardTitle>
|
||||
<CardDescription>查看网站绑定的证书、启用状态和更新时间。</CardDescription>
|
||||
<CardHeader className="gap-3 pb-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<CardTitle className="text-base font-semibold">Zone 列表</CardTitle>
|
||||
<CardDescription>
|
||||
以可注册根域组织网站,并在详情中维护明确 FQDN、证书与路由关联。
|
||||
</CardDescription>
|
||||
</div>
|
||||
<div className="relative w-full sm:w-64">
|
||||
<Search className="pointer-events-none absolute left-2.5 top-2.5 size-3.5 text-muted-foreground" />
|
||||
<Input
|
||||
aria-label="搜索 Zone 根域"
|
||||
placeholder="搜索 Zone 根域"
|
||||
value={search}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
className="h-8 pl-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{domainsQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载网站列表中..." />
|
||||
) : domainsQuery.isError ? (
|
||||
<div className="p-8 border border-dashed rounded-lg">
|
||||
{zonesQuery.isLoading ? (
|
||||
<LoadingStateWithBorder icon={Globe} description="加载 Zone 列表中..." />
|
||||
) : zonesQuery.isError ? (
|
||||
<div className="rounded-lg border border-dashed p-8">
|
||||
<ErrorInline
|
||||
message={getErrorMessage(domainsQuery.error)}
|
||||
onRetry={() => void domainsQuery.refetch()}
|
||||
className="justify-center"
|
||||
message={getErrorMessage(zonesQuery.error)}
|
||||
onRetry={() => void zonesQuery.refetch()}
|
||||
/>
|
||||
</div>
|
||||
) : domains.length === 0 ? (
|
||||
) : zones.length === 0 ? (
|
||||
<EmptyStateWithBorder
|
||||
icon={Globe}
|
||||
description="暂无网站,点击右上角「新增网站」开始录入。"
|
||||
description={
|
||||
search
|
||||
? '未找到匹配的 Zone。'
|
||||
: '暂无 Zone,点击右上角「新增 Zone」开始录入。'
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<div className="grid gap-3 lg:grid-cols-2">
|
||||
{domains.map((domain) => {
|
||||
const certificate = domain.cert_id
|
||||
? (certificateMap.get(domain.cert_id) ?? null)
|
||||
: null;
|
||||
const matchType = getMatchTypeMeta(domain.domain);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={domain.id}
|
||||
className="rounded-lg border bg-card p-4 space-y-3"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="space-y-2 min-w-0">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h2 className="text-sm font-semibold truncate">{domain.domain}</h2>
|
||||
<WebsiteStatusBadge label={matchType.label} tone={matchType.tone} />
|
||||
<WebsiteStatusBadge
|
||||
label={domain.enabled ? '启用' : '停用'}
|
||||
tone={domain.enabled ? 'success' : 'warning'}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{domain.remark || '暂无备注'}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
绑定证书:
|
||||
{certificate
|
||||
? buildCertificateLabel(certificate)
|
||||
: '未绑定证书'}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex shrink-0 gap-1">
|
||||
<Button variant="outline" size="sm" className="h-7 text-xs" asChild>
|
||||
<Link href={`/websites/detail?id=${domain.id}`}>
|
||||
详情
|
||||
</Link>
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-destructive"
|
||||
onClick={() => setDeleteTarget(domain)}
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{zones.map((zone) => (
|
||||
<div key={zone.id} className="rounded-lg border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<h2 className="truncate text-sm font-semibold">{zone.domain}</h2>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{zone.domain_count ?? 0} 个域名
|
||||
{zone.remark ? ` · ${zone.remark}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-7 shrink-0 text-xs"
|
||||
asChild
|
||||
>
|
||||
<Link href={`/websites/${zone.id}`}>管理</Link>
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<WebsiteEditorDialog
|
||||
open={editorOpen}
|
||||
onOpenChange={setEditorOpen}
|
||||
certificates={certificates}
|
||||
certificatesLoading={certificatesQuery.isLoading}
|
||||
preferredCertificateId={preferredCertificateId}
|
||||
onRequestImportCertificate={() => setImportOpen(true)}
|
||||
onSaved={(_, mode) => {
|
||||
setPreferredCertificateId(null);
|
||||
toast.success(mode === 'create' ? '网站已创建' : '网站已更新');
|
||||
}}
|
||||
/>
|
||||
|
||||
<CertificateImportDialog
|
||||
open={importOpen}
|
||||
onOpenChange={setImportOpen}
|
||||
onImported={(certificate) => {
|
||||
setPreferredCertificateId(certificate.id);
|
||||
toast.success(`证书 ${certificate.name} 已导入,可直接用于当前网站`);
|
||||
}}
|
||||
/>
|
||||
|
||||
<AlertDialog
|
||||
open={deleteTarget !== null}
|
||||
onOpenChange={(open) => !open && setDeleteTarget(null)}
|
||||
>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>确认删除网站</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
确认删除网站 {deleteTarget?.domain} 吗?此操作不可撤销。
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>取消</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
onClick={() => deleteTarget && deleteMutation.mutate(deleteTarget.id)}
|
||||
>
|
||||
删除
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
<ZoneEditorDialog open={editorOpen} onOpenChange={setEditorOpen} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -42,6 +42,8 @@ import {
|
||||
UptimeKumaService,
|
||||
WafService,
|
||||
WebsiteService,
|
||||
ZoneDomainService,
|
||||
ZoneService,
|
||||
} from './openflare';
|
||||
|
||||
const services = {
|
||||
@@ -67,6 +69,8 @@ const services = {
|
||||
openflareDashboard: DashboardService,
|
||||
openflareWaf: WafService,
|
||||
openflareWebsite: WebsiteService,
|
||||
openflareZone: ZoneService,
|
||||
openflareZoneDomain: ZoneDomainService,
|
||||
openflareTls: TlsCertificateService,
|
||||
openflareDns: DnsAccountService,
|
||||
openflarePages: PagesService,
|
||||
@@ -201,6 +205,7 @@ export {
|
||||
export type {
|
||||
NodeItem,
|
||||
ProxyRouteItem,
|
||||
ProxyRouteZoneDomain,
|
||||
ProxyRouteConfigSection,
|
||||
ConfigVersionSummary,
|
||||
ConfigVersionDetail,
|
||||
|
||||
@@ -7,6 +7,7 @@ export { ApplyLogService } from './apply-log.service';
|
||||
export { DashboardService } from './dashboard.service';
|
||||
export { WafService } from './waf.service';
|
||||
export { WebsiteService } from './website.service';
|
||||
export { ZoneDomainService, ZoneService, zoneQueryKey } from './zone.service';
|
||||
export { TlsCertificateService } from './tls-certificate.service';
|
||||
export { DnsAccountService } from './dns-account.service';
|
||||
export { PagesService } from './pages.service';
|
||||
@@ -49,6 +50,7 @@ export type {
|
||||
ProxyRouteItem,
|
||||
ProxyRouteMutationPayload,
|
||||
ProxyRoutePoWConfig,
|
||||
ProxyRouteZoneDomain,
|
||||
ReleaseChannel,
|
||||
SupportFile,
|
||||
DashboardOverview,
|
||||
@@ -105,6 +107,11 @@ export type {
|
||||
ManagedDomainItem,
|
||||
ManagedDomainMatchResult,
|
||||
ManagedDomainMutationPayload,
|
||||
ZoneDomainItem,
|
||||
ZoneDomainMutationPayload,
|
||||
ZoneItem,
|
||||
ZoneMutationPayload,
|
||||
ZoneOverview,
|
||||
TlsCertificateApplyPayload,
|
||||
TlsCertificateContentItem,
|
||||
TlsCertificateDetailItem,
|
||||
|
||||
@@ -228,13 +228,19 @@ export interface ProxyRoutePoWConfig {
|
||||
blacklist: ProxyRoutePoWListConfig;
|
||||
}
|
||||
|
||||
/** Route-bound Zone domain as returned by proxy-route APIs. */
|
||||
export interface ProxyRouteZoneDomain {
|
||||
id: number;
|
||||
zone_id: number;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
}
|
||||
|
||||
export interface ProxyRouteItem {
|
||||
id: number;
|
||||
site_name: string;
|
||||
domain: string;
|
||||
domains: string[];
|
||||
primary_domain: string;
|
||||
domain_count: number;
|
||||
zone_domain_ids: number[];
|
||||
zone_domains: ProxyRouteZoneDomain[];
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_host: string;
|
||||
@@ -242,9 +248,6 @@ export interface ProxyRouteItem {
|
||||
upstream_list: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids: number[];
|
||||
domain_cert_ids: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server: number;
|
||||
limit_conn_per_ip: number;
|
||||
@@ -271,8 +274,7 @@ export interface ProxyRouteItem {
|
||||
|
||||
export interface ProxyRouteMutationPayload {
|
||||
site_name?: string;
|
||||
domain: string;
|
||||
domains?: string[];
|
||||
zone_domain_ids: number[];
|
||||
origin_id: number | null;
|
||||
origin_url: string;
|
||||
origin_scheme: 'http' | 'https';
|
||||
@@ -283,9 +285,6 @@ export interface ProxyRouteMutationPayload {
|
||||
upstreams: string[];
|
||||
enabled: boolean;
|
||||
enable_https: boolean;
|
||||
cert_id: number | null;
|
||||
cert_ids?: number[];
|
||||
domain_cert_ids?: number[];
|
||||
redirect_http: boolean;
|
||||
limit_conn_per_server?: number;
|
||||
limit_conn_per_ip?: number;
|
||||
@@ -962,6 +961,43 @@ export interface DashboardOverviewCompact {
|
||||
|
||||
// ==================== Websites / TLS / DNS ====================
|
||||
|
||||
export interface ZoneItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
remark: string;
|
||||
/** Present on list API; may be omitted on nested zone objects. */
|
||||
domain_count?: number;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ZoneMutationPayload {
|
||||
domain: string;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ZoneDomainItem {
|
||||
id: number;
|
||||
zone_id: number;
|
||||
proxy_route_id: number | null;
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
remark: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface ZoneDomainMutationPayload {
|
||||
domain: string;
|
||||
cert_id: number | null;
|
||||
remark: string;
|
||||
}
|
||||
|
||||
export interface ZoneOverview {
|
||||
zone: ZoneItem;
|
||||
domains: ZoneDomainItem[];
|
||||
}
|
||||
|
||||
export interface ManagedDomainItem {
|
||||
id: number;
|
||||
domain: string;
|
||||
@@ -1078,4 +1114,4 @@ export interface DnsAccountMutationPayload {
|
||||
name: string;
|
||||
type: string;
|
||||
authorization: string;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import {OpenFlareBaseService} from './base.service'
|
||||
import type {ZoneDomainItem, ZoneDomainMutationPayload, ZoneItem, ZoneMutationPayload, ZoneOverview} from './types'
|
||||
|
||||
export const zoneQueryKey = ['openflare', 'zones'] as const
|
||||
|
||||
export class ZoneService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath = '/api/v1/d/zones'
|
||||
|
||||
static list(): Promise<ZoneItem[]> { return this.get<ZoneItem[]>('/') }
|
||||
static getOverview(id: number): Promise<ZoneOverview> { return this.get<ZoneOverview>(`/${id}/overview`) }
|
||||
static create(payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>('/', payload) }
|
||||
static update(id: number, payload: ZoneMutationPayload): Promise<ZoneItem> { return this.post<ZoneItem>(`/${id}/update`, payload) }
|
||||
static deleteById(id: number): Promise<void> { return this.post<void>(`/${id}/delete`) }
|
||||
}
|
||||
|
||||
export class ZoneDomainService extends OpenFlareBaseService {
|
||||
protected static override readonly basePath = '/api/v1/d/zones'
|
||||
|
||||
static create(zoneId: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains`, payload) }
|
||||
static update(zoneId: number, id: number, payload: ZoneDomainMutationPayload): Promise<ZoneDomainItem> { return this.post<ZoneDomainItem>(`/${zoneId}/domains/${id}/update`, payload) }
|
||||
static deleteById(zoneId: number, id: number): Promise<void> { return this.post<void>(`/${zoneId}/domains/${id}/delete`) }
|
||||
}
|
||||
@@ -82,13 +82,18 @@
|
||||
"@eslint/eslintrc": "^3",
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@tailwindcss/typography": "^0.5.19",
|
||||
"@testing-library/jest-dom": "^6.9.1",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@testing-library/user-event": "^14.6.1",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.5.6",
|
||||
"jsdom": "^29.1.1",
|
||||
"tailwindcss": "^4",
|
||||
"tw-animate-css": "^1.4.0",
|
||||
"typescript": "^5.9.3"
|
||||
"typescript": "^5.9.3",
|
||||
"vitest": "^4.1.10"
|
||||
}
|
||||
}
|
||||
|
||||
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"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Rain-kl/Wavelet/internal/db"
|
||||
"github.com/Rain-kl/Wavelet/internal/model"
|
||||
@@ -34,6 +35,16 @@ type Overview struct {
|
||||
Domains []model.ZoneDomain `json:"domains"`
|
||||
}
|
||||
|
||||
// ListItem is a Zone list row with denormalized domain count for the UI.
|
||||
type ListItem struct {
|
||||
ID uint `json:"id"`
|
||||
Domain string `json:"domain"`
|
||||
Remark string `json:"remark"`
|
||||
DomainCount int64 `json:"domain_count"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
}
|
||||
|
||||
func zoneRoot(domain string) (string, error) {
|
||||
return publicsuffix.EffectiveTLDPlusOne(strings.ToLower(strings.TrimSpace(domain)))
|
||||
}
|
||||
@@ -99,11 +110,41 @@ func Update(ctx context.Context, id uint, input Input) (*model.Zone, error) {
|
||||
return &zone, nil
|
||||
}
|
||||
|
||||
// List returns all Zones in stable domain order.
|
||||
func List(ctx context.Context) ([]model.Zone, error) {
|
||||
// List returns all Zones in stable domain order, with domain counts for list cards.
|
||||
func List(ctx context.Context) ([]ListItem, error) {
|
||||
var zones []model.Zone
|
||||
err := db.DB(ctx).Order("domain asc").Find(&zones).Error
|
||||
return zones, err
|
||||
if err := db.DB(ctx).Order("domain asc").Find(&zones).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
type countRow struct {
|
||||
ZoneID uint `gorm:"column:zone_id"`
|
||||
Count int64 `gorm:"column:count"`
|
||||
}
|
||||
var rows []countRow
|
||||
if err := db.DB(ctx).Model(&model.ZoneDomain{}).
|
||||
Select("zone_id, count(*) as count").
|
||||
Group("zone_id").
|
||||
Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
counts := make(map[uint]int64, len(rows))
|
||||
for _, row := range rows {
|
||||
counts[row.ZoneID] = row.Count
|
||||
}
|
||||
|
||||
items := make([]ListItem, 0, len(zones))
|
||||
for _, zone := range zones {
|
||||
items = append(items, ListItem{
|
||||
ID: zone.ID,
|
||||
Domain: zone.Domain,
|
||||
Remark: zone.Remark,
|
||||
DomainCount: counts[zone.ID],
|
||||
CreatedAt: zone.CreatedAt,
|
||||
UpdatedAt: zone.UpdatedAt,
|
||||
})
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// GetOverview returns a Zone and its domains.
|
||||
|
||||
@@ -33,7 +33,7 @@ func abort(c *gin.Context, err error, missing string) bool {
|
||||
// @Tags openflare-zone
|
||||
// @Produce json
|
||||
// @Security SessionCookie
|
||||
// @Success 200 {object} response.Any{data=[]model.Zone}
|
||||
// @Success 200 {object} response.Any{data=[]zone.ListItem}
|
||||
// @Router /api/v1/d/zones [get]
|
||||
func ListHandler(c *gin.Context) {
|
||||
items, err := List(c.Request.Context())
|
||||
|
||||
Reference in New Issue
Block a user