mirror of
https://github.com/Rain-kl/OpenFlare.git
synced 2026-09-28 05:46:36 +08:00
Compare commits
9 Commits
96abbf180d
...
c3606bc6f6
| Author | SHA1 | Date | |
|---|---|---|---|
| c3606bc6f6 | |||
| 2b3be6f3a3 | |||
| 11c8e5c7f3 | |||
| 4d52a5b097 | |||
| b1626d068f | |||
| 079fa7ee53 | |||
| 3867841a2f | |||
| 805eea5bd6 | |||
| aad059ab6e |
@@ -10,6 +10,11 @@ sidebar: false
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### 新增
|
||||||
|
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
|
||||||
|
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
|
||||||
|
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
|
||||||
|
|
||||||
## [v3.5.5] - 2026-09-19
|
## [v3.5.5] - 2026-09-19
|
||||||
|
|
||||||
### 🛠 修复
|
### 🛠 修复
|
||||||
|
|||||||
+218
-1
@@ -6362,6 +6362,190 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "批量移动 Cloudflare 指向成员",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "原分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "批量移动参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "批量移出 Cloudflare 指向成员",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "批量移出参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "移动 Cloudflare 指向成员到其他分组",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "原分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "成员 ID",
|
||||||
|
"name": "memberId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "目标分组参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberMoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -14260,7 +14444,7 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "用户名或密码错误、帐号已禁用等",
|
"description": "用户名或密码错误",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/response.Any"
|
"$ref": "#/definitions/response.Any"
|
||||||
}
|
}
|
||||||
@@ -15139,6 +15323,31 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cloudflare.MemberBatchMoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"member_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target_group_id": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloudflare.MemberBatchRemoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"member_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"cloudflare.MemberCreateInput": {
|
"cloudflare.MemberCreateInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -15185,6 +15394,14 @@ const docTemplate = `{
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cloudflare.MemberMoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_group_id": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"cloudflare.MemberUpdateInput": {
|
"cloudflare.MemberUpdateInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
@@ -0,0 +1,516 @@
|
|||||||
|
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索实现计划
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** 为 `/cloudflare/groups/[id]` 页面增加域名单条移动、批量移动与批量移出操作及后端支持,并为 `/ip-groups` 的查看 IP 组弹窗增加 IP 即时搜索过滤功能。
|
||||||
|
|
||||||
|
**Architecture:** 后端在 `internal/apps/openflare/cloudflare` 扩展单条移动及批量移动/删除接口并排队更新 DNS 同步;前端在服务层封装调用,在详情页增加多选框、批量工具栏与移动弹窗;IP 组查看弹窗增加输入框实时过滤 entries。
|
||||||
|
|
||||||
|
**Tech Stack:** Go 1.25+, Gin, GORM, Next.js App Router, TypeScript, React Query, Tailwind CSS, shadcn/ui, Vitest.
|
||||||
|
|
||||||
|
## Global Constraints
|
||||||
|
|
||||||
|
- 禁止删除 `frontend/node_modules`。
|
||||||
|
- 遵循 `AGENTS.md`:API 错误使用 `response.Abort*`,禁止使用 200 返回错误体。
|
||||||
|
- 业务路由仅在 `internal/router/v1/openflare/register_cloudflare.go` 挂载。
|
||||||
|
- i18n 变更修改 `frontend/messages/fragments`,通过 `node scripts/merge-i18n-fragments.mjs` 合并,禁止直接修改 `zh-CN.json` / `en.json`。
|
||||||
|
- 完成后必须执行 `make swagger` 与 `make code-check`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: 后端 DTO、业务逻辑及单测 (Backend Logic & Unit Tests)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/types.go`
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/errs.go`
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/logics.go`
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/logics_test.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `MemberMoveInput { TargetGroupID uint `json:"target_group_id"` }`
|
||||||
|
- `MemberBatchMoveInput { MemberIDs []uint `json:"member_ids"`, TargetGroupID uint `json:"target_group_id"` }`
|
||||||
|
- `MemberBatchRemoveInput { MemberIDs []uint `json:"member_ids"` }`
|
||||||
|
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
|
||||||
|
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
|
||||||
|
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 types.go 和 errs.go 中新增结构体与错误常量**
|
||||||
|
|
||||||
|
在 `internal/apps/openflare/cloudflare/types.go` 增加:
|
||||||
|
```go
|
||||||
|
// MemberMoveInput contains the target group ID for moving a member.
|
||||||
|
type MemberMoveInput struct {
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
|
||||||
|
type MemberBatchMoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberBatchRemoveInput contains the member IDs for batch deletion.
|
||||||
|
type MemberBatchRemoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
在 `internal/apps/openflare/cloudflare/errs.go` 增加:
|
||||||
|
```go
|
||||||
|
errTargetGroupSame = "目标分组不能为当前分组"
|
||||||
|
errTargetGroupInvalid = "目标分组不存在"
|
||||||
|
errNoMembersSelected = "未选择任何成员"
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 编写失败的单元测试**
|
||||||
|
|
||||||
|
在 `internal/apps/openflare/cloudflare/logics_test.go` 中新增测试函数 `TestMoveMemberAndBatchOperations(t *testing.T)`,测试:
|
||||||
|
1. 目标分组等于源分组时报错 `errTargetGroupSame`;
|
||||||
|
2. 目标分组不存在时报错;
|
||||||
|
3. 成功移动成员到目标分组,更新 `GroupID` 并将 `SyncStatus` 置为 `pending`;
|
||||||
|
4. 批量移动多个成员到目标分组;
|
||||||
|
5. 批量删除成员。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行测试验证失败**
|
||||||
|
|
||||||
|
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
|
||||||
|
预期:编译错误或未实现失败。
|
||||||
|
|
||||||
|
- [ ] **Step 4: 实现 MoveMember, BatchMoveMembers, BatchRemoveMembers**
|
||||||
|
|
||||||
|
在 `internal/apps/openflare/cloudflare/logics.go` 实现:
|
||||||
|
```go
|
||||||
|
// MoveMember transfers a member from sourceGroupID to targetGroupID.
|
||||||
|
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
|
||||||
|
if targetGroupID == 0 || targetGroupID == sourceGroupID {
|
||||||
|
return nil, errors.New(errTargetGroupSame)
|
||||||
|
}
|
||||||
|
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New(errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member.GroupID = targetGroupID
|
||||||
|
member.SyncStatus = model.CFMemberSyncPending
|
||||||
|
member.LastError = ""
|
||||||
|
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if targetGroup.Enabled {
|
||||||
|
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return memberItem(member, domain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
|
||||||
|
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
|
||||||
|
if len(input.MemberIDs) == 0 {
|
||||||
|
return errors.New(errNoMembersSelected)
|
||||||
|
}
|
||||||
|
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
|
||||||
|
return errors.New(errTargetGroupSame)
|
||||||
|
}
|
||||||
|
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New(errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, memberID := range input.MemberIDs {
|
||||||
|
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if getErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member.GroupID = input.TargetGroupID
|
||||||
|
member.SyncStatus = model.CFMemberSyncPending
|
||||||
|
member.LastError = ""
|
||||||
|
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if targetGroup.Enabled {
|
||||||
|
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchRemoveMembers deletes multiple members and their remote A records.
|
||||||
|
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
|
||||||
|
if len(input.MemberIDs) == 0 {
|
||||||
|
return errors.New(errNoMembersSelected)
|
||||||
|
}
|
||||||
|
for _, memberID := range input.MemberIDs {
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
|
||||||
|
}
|
||||||
|
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: 运行测试验证通过**
|
||||||
|
|
||||||
|
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestMoveMemberAndBatchOperations`
|
||||||
|
预期:PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/apps/openflare/cloudflare/
|
||||||
|
git commit -m "feat(cloudflare): add move and batch operation logics with tests"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 2: 后端 Handler、路由注册与 Swagger (Backend Handlers & Routing)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/routers.go`
|
||||||
|
- Modify: `internal/apps/openflare/cloudflare/routers_test.go`
|
||||||
|
- Modify: `internal/router/v1/openflare/register_cloudflare.go`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `MoveMemberHandler(c *gin.Context)`
|
||||||
|
- `BatchMoveMembersHandler(c *gin.Context)`
|
||||||
|
- `BatchRemoveMembersHandler(c *gin.Context)`
|
||||||
|
- 路由:`POST /groups/:id/members/:memberId/move`
|
||||||
|
- 路由:`POST /groups/:id/members/batch-move`
|
||||||
|
- 路由:`POST /groups/:id/members/batch-remove`
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 routers.go 中实现 Handler 并补充 Swagger 注解**
|
||||||
|
|
||||||
|
```go
|
||||||
|
// MoveMemberHandler moves a member to a target group.
|
||||||
|
// @Summary 移动 Cloudflare 指向成员到其他分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "原分组 ID"
|
||||||
|
// @Param memberId path int true "成员 ID"
|
||||||
|
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
|
||||||
|
func MoveMemberHandler(c *gin.Context) {
|
||||||
|
groupID, memberID, ok := memberParams(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberMoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMoveMembersHandler moves multiple members to a target group.
|
||||||
|
// @Summary 批量移动 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "原分组 ID"
|
||||||
|
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
|
||||||
|
func BatchMoveMembersHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberBatchMoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchRemoveMembersHandler removes multiple members.
|
||||||
|
// @Summary 批量移出 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
|
||||||
|
func BatchRemoveMembersHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberBatchRemoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 register_cloudflare.go 中挂载路由**
|
||||||
|
|
||||||
|
在 `internal/router/v1/openflare/register_cloudflare.go` 添加:
|
||||||
|
```go
|
||||||
|
route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler)
|
||||||
|
route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 在 routers_test.go 中补充接口级单测**
|
||||||
|
|
||||||
|
为新 Handler 添加单元测试,验证请求解析与响应状态码。
|
||||||
|
运行:`go test -v ./internal/apps/openflare/cloudflare -run TestRouters`
|
||||||
|
预期:PASS。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add internal/apps/openflare/cloudflare/routers.go internal/apps/openflare/cloudflare/routers_test.go internal/router/v1/openflare/register_cloudflare.go
|
||||||
|
git commit -m "feat(cloudflare): register move and batch member API routes"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 3: WAF IP 组「查看 IP 组」弹窗搜索功能 (IP Group View Dialog Search)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/messages/fragments/security.zh-CN.json`
|
||||||
|
- Modify: `frontend/messages/fragments/security.en.json`
|
||||||
|
- Modify: `frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- 搜索框 Input,支持清空按钮
|
||||||
|
- `filteredEntries` 动态过滤
|
||||||
|
- 数量摘要显示,如 `共 10 条 IP(匹配 3 条)`
|
||||||
|
- 过滤无匹配时的独立空状态提示
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 security.zh-CN.json 与 security.en.json 中补充文案**
|
||||||
|
|
||||||
|
在 `frontend/messages/fragments/security.zh-CN.json` 中的 `ipGroups.viewDialog` 增加:
|
||||||
|
```json
|
||||||
|
"searchPlaceholder": "搜索 IP 地址...",
|
||||||
|
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
|
||||||
|
"noSearchResult": "未找到匹配的 IP 地址。"
|
||||||
|
```
|
||||||
|
并在 `security.en.json` 对应增加英文文案。
|
||||||
|
执行 `node scripts/merge-i18n-fragments.mjs` 同步生成全量文件。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 修改 ip-group-view-dialog.tsx**
|
||||||
|
|
||||||
|
增加 `searchKeyword` state:
|
||||||
|
- 渲染包含 `Search` 图标和清空按钮的 Input;
|
||||||
|
- `filteredEntries`:使用 `entries.filter(e => e.ip.toLowerCase().includes(keyword.trim().toLowerCase()))`;
|
||||||
|
- 弹窗关闭时自动清空 `searchKeyword`;
|
||||||
|
- 当 `entries.length > 0` 且 `filteredEntries.length === 0` 时展示 `EmptyStateWithBorder`(使用 `noSearchResult`);
|
||||||
|
- DialogDescription 中根据是否有过滤展示 `summaryFiltered` 或 `summary`。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 运行 TypeScript 检查**
|
||||||
|
|
||||||
|
运行:`cd frontend && pnpm tsc --noEmit`
|
||||||
|
预期:无类型错误。
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/messages/ frontend/app/\(main\)/waf/components/ip-group-view-dialog.tsx
|
||||||
|
git commit -m "feat(waf): add search and filter in ip group view dialog"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 4: 前端 Cloudflare 分组单条移动与批量操作 (Frontend Move & Batch Operations)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `frontend/messages/fragments/cloudflare.zh-CN.json`
|
||||||
|
- Modify: `frontend/messages/fragments/cloudflare.en.json`
|
||||||
|
- Modify: `frontend/lib/services/openflare/cloudflare.service.ts`
|
||||||
|
- Create: `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`
|
||||||
|
- Modify: `frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`
|
||||||
|
- Modify: `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx`
|
||||||
|
|
||||||
|
**Interfaces:**
|
||||||
|
- Produces:
|
||||||
|
- `CloudflareService.moveMember(groupId, memberId, targetGroupId)`
|
||||||
|
- `CloudflareService.batchMoveMembers(groupId, memberIds, targetGroupId)`
|
||||||
|
- `CloudflareService.batchRemoveMembers(groupId, memberIds)`
|
||||||
|
- `<MemberMoveDialog />` 组件
|
||||||
|
- 多选框 Checkbox、全选/半选/清空、批量操作工具条
|
||||||
|
|
||||||
|
- [ ] **Step 1: 在 cloudflare.zh-CN.json 与 cloudflare.en.json 增加国际化文案**
|
||||||
|
|
||||||
|
增加:
|
||||||
|
- `move`: "移动"
|
||||||
|
- `batchMove`: "批量移动"
|
||||||
|
- `batchRemove`: "批量删除"
|
||||||
|
- `selectedCount`: "已选择 {count} 项"
|
||||||
|
- `clearSelection`: "取消选择"
|
||||||
|
- `moveDialog`:
|
||||||
|
- `titleSingle`: "移动域名「{domain}」"
|
||||||
|
- `titleBatch`: "批量移动域名(共 {count} 项)"
|
||||||
|
- `targetGroupLabel`: "目标指向分组"
|
||||||
|
- `targetGroupPlaceholder`: "请选择目标分组"
|
||||||
|
- `noOtherGroups`: "没有其他可用的指向分组,请先创建分组"
|
||||||
|
- `submit`: "确认移动"
|
||||||
|
- `moving`: "移动中..."
|
||||||
|
- `batchRemoveDialog`:
|
||||||
|
- `title`: "确认批量删除域名"
|
||||||
|
- `desc`: "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。"
|
||||||
|
- `confirm`: "确认删除"
|
||||||
|
- `removing`: "删除中..."
|
||||||
|
- `memberMoved`: "域名移动成功"
|
||||||
|
- `batchMoved`: "成功移动 {count} 个域名"
|
||||||
|
- `batchRemoved`: "成功删除 {count} 个域名"
|
||||||
|
|
||||||
|
执行 `node scripts/merge-i18n-fragments.mjs` 同步。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 在 cloudflare.service.ts 扩展 API 方法**
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
static async moveMember(
|
||||||
|
groupId: number,
|
||||||
|
memberId: number,
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<CloudflareMember> {
|
||||||
|
return this.post<CloudflareMember>(
|
||||||
|
`/cloudflare/groups/${groupId}/members/${memberId}/move`,
|
||||||
|
{ target_group_id: targetGroupId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async batchMoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(
|
||||||
|
`/cloudflare/groups/${groupId}/members/batch-move`,
|
||||||
|
{ member_ids: memberIds, target_group_id: targetGroupId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static async batchRemoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(
|
||||||
|
`/cloudflare/groups/${groupId}/members/batch-remove`,
|
||||||
|
{ member_ids: memberIds },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: 创建 MemberMoveDialog 组件**
|
||||||
|
|
||||||
|
在 `frontend/app/(main)/cloudflare/components/member-move-dialog.tsx`:
|
||||||
|
- Props: `open`, `onOpenChange`, `members: CloudflareMember[]`, `groups: CloudflareGroup[]`, `currentGroupId: number`, `pending: boolean`, `onSubmit: (targetGroupId: number) => void`
|
||||||
|
- 过滤 `availableGroups = groups.filter(g => g.id !== currentGroupId)`
|
||||||
|
- 下拉选择 Select 组件展示分组名称与主节点 IP
|
||||||
|
- 提交按钮
|
||||||
|
|
||||||
|
- [ ] **Step 4: 修改 CloudflareGroupDetailPageClient 实现多选与批量/单条操作**
|
||||||
|
|
||||||
|
在 `page-client.tsx`:
|
||||||
|
- 使用 React Query 获取全部分组列表 `CloudflareService.listGroups()`;
|
||||||
|
- 状态管理:
|
||||||
|
- `selectedMemberIDs: Set<number>`
|
||||||
|
- `moveDialogOpen: boolean`
|
||||||
|
- `movingMembers: CloudflareMember[]`
|
||||||
|
- `batchRemoveOpen: boolean`
|
||||||
|
- 多选逻辑:
|
||||||
|
- 表头 Checkbox 绑定当前页成员选中状态(`allSelected`, `indeterminate`);
|
||||||
|
- 每行首列增加 Checkbox;
|
||||||
|
- 批量工具栏:
|
||||||
|
- 在卡片内部当 `selectedMemberIDs.size > 0` 时展示浮动/嵌入的操作栏:包含数量、`批量移动`、`批量删除`、`取消选择`;
|
||||||
|
- 行操作区:
|
||||||
|
- 在原有 `同步`、`删除` 旁新增 `移动` 按钮(FolderInput 或 ArrowRightLeft 图标),点击打开 `MemberMoveDialog`(传入单条成员);
|
||||||
|
- Mutations:
|
||||||
|
- `moveMutation`:支持单条和批量移动,完成后清空 `selectedMemberIDs` 并刷新 query;
|
||||||
|
- `batchRemoveMutation`:批量移出,完成后清空并刷新。
|
||||||
|
|
||||||
|
- [ ] **Step 5: 更新前端测试**
|
||||||
|
|
||||||
|
在 `frontend/tests/cloudflare/cloudflare-group-detail.test.tsx` 增加测试用例覆盖移动与批量删除触发逻辑。
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add frontend/
|
||||||
|
git commit -m "feat(cloudflare): add domain move and batch operations in group detail page"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 5: 质量门禁、Swagger 与更新日志 (Quality Gates & Verification)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `docs/changelog/index.md`
|
||||||
|
- Generated: `internal/router/root/swagger/` (via make swagger)
|
||||||
|
|
||||||
|
- [ ] **Step 1: 生成 Swagger API 文档**
|
||||||
|
|
||||||
|
运行:`make swagger`
|
||||||
|
检查生成的 swagger 规范包含新端点。
|
||||||
|
|
||||||
|
- [ ] **Step 2: 执行全量代码检查**
|
||||||
|
|
||||||
|
运行:`make code-check`
|
||||||
|
确保 `golangci-lint`、`tsc` 与 `eslint` 均零警告零错误通过。
|
||||||
|
|
||||||
|
- [ ] **Step 3: 更新 docs/changelog/index.md**
|
||||||
|
|
||||||
|
在 `[Unreleased]` 下添加新增功能说明:
|
||||||
|
```markdown
|
||||||
|
### 新增
|
||||||
|
- Cloudflare 指向分组:支持将已添加的域名在不同分组之间移动,自动排队更新远程 DNS 指向。
|
||||||
|
- Cloudflare 指向分组:详情页支持批量勾选域名进行批量移动与批量移出操作。
|
||||||
|
- WAF IP 组:在查看 IP 组弹窗中新增即时搜索功能,支持快速过滤和定位 IP 地址。
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: 提交变更**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/changelog/index.md internal/router/root/swagger/
|
||||||
|
git commit -m "docs(changelog): document cloudflare member move and ip group search"
|
||||||
|
```
|
||||||
+138
@@ -0,0 +1,138 @@
|
|||||||
|
# Cloudflare 分组域名移动/批量操作与 IP 组查看搜索设计文档
|
||||||
|
|
||||||
|
- **日期**:2026-09-24
|
||||||
|
- **状态**:已批准 (Approved)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 1. 目标与背景 (Goals & Context)
|
||||||
|
|
||||||
|
1. **Cloudflare 分组域名管理增强**:
|
||||||
|
- 在 `/cloudflare/groups/[id]` 页面,为单个成员域名增加「移动」操作,支持迁移至其他可用分组;
|
||||||
|
- 增加表格行复选框多选机制与批量操作栏,支持「批量删除」与「批量移动」;
|
||||||
|
- 确保域名移动后,如果目标分组处于启用状态,自动重新排队向 Cloudflare 同步目标节点的新 IP。
|
||||||
|
2. **WAF IP 组查看弹窗增强**:
|
||||||
|
- 在 `/ip-groups` 的「查看 IP 组」弹窗(`IPGroupViewDialog`)内,新增即时 IP 搜索与过滤功能,方便用户在包含大量 IP 条目时快速查找与定位。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 2. 后端接口设计 (Backend API)
|
||||||
|
|
||||||
|
### 2.1 数据模型与 DTO
|
||||||
|
|
||||||
|
文件:`internal/apps/openflare/cloudflare/types.go`
|
||||||
|
|
||||||
|
```go
|
||||||
|
type MemberMoveInput struct {
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberBatchMoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberBatchRemoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2.2 路由端点
|
||||||
|
|
||||||
|
文件:`internal/apps/openflare/cloudflare/routers.go`
|
||||||
|
注册:`internal/router/v1/openflare/register_cloudflare.go`
|
||||||
|
|
||||||
|
1. **单条移动**:
|
||||||
|
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/:memberId/move`
|
||||||
|
- 请求体:`MemberMoveInput`
|
||||||
|
- 响应:`200 OK` + `cloudflare.MemberItem`
|
||||||
|
2. **批量移动**:
|
||||||
|
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-move`
|
||||||
|
- 请求体:`MemberBatchMoveInput`
|
||||||
|
- 响应:`200 OK`
|
||||||
|
3. **批量移出/删除**:
|
||||||
|
- 路径:`POST /api/v1/d/cloudflare/groups/:id/members/batch-remove`
|
||||||
|
- 请求体:`MemberBatchRemoveInput`
|
||||||
|
- 响应:`200 OK`
|
||||||
|
|
||||||
|
### 2.3 业务逻辑层
|
||||||
|
|
||||||
|
文件:`internal/apps/openflare/cloudflare/logics.go`
|
||||||
|
|
||||||
|
- `MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error)`
|
||||||
|
- 检查 `targetGroupID != sourceGroupID`;
|
||||||
|
- 检查目标分组是否存在且非空;
|
||||||
|
- 查询原成员,更新 `GroupID = targetGroupID`,将 `SyncStatus` 置为 `pending`,清除 `LastError`,保存到数据库;
|
||||||
|
- 若目标分组 `Enabled == true`,调用 `DispatchMemberSync(ctx, member.ID, "cloudflare_member_move")` 排队执行远程 A 记录同步;
|
||||||
|
- 返回更新后的 `MemberItem`。
|
||||||
|
- `BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error`
|
||||||
|
- 校验目标分组有效性;
|
||||||
|
- 针对 `input.MemberIDs` 过滤去重,对每个成员执行分组归属更新并重置同步状态;
|
||||||
|
- 若目标分组启用,分发同步任务。
|
||||||
|
- `BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error`
|
||||||
|
- 针对每个 `memberID`,校验属于 `sourceGroupID`,调用 `DeleteManagedRecord(ctx, member.ID)` 清除 Cloudflare 上的 A 记录,并调用 `repository.DeleteCFPointingMember(ctx, member)`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 3. 前端交互设计 (Frontend UI & Interaction)
|
||||||
|
|
||||||
|
### 3.1 Cloudflare 服务客户端扩展
|
||||||
|
|
||||||
|
文件:`frontend/lib/services/openflare/cloudflare.service.ts`
|
||||||
|
|
||||||
|
- `moveMember(groupId: number, memberId: number, targetGroupId: number): Promise<CloudflareMember>`
|
||||||
|
- `batchMoveMembers(groupId: number, memberIds: number[], targetGroupId: number): Promise<void>`
|
||||||
|
- `batchRemoveMembers(groupId: number, memberIds: number[]): Promise<void>`
|
||||||
|
|
||||||
|
### 3.2 域名成员列表交互 (`frontend/app/(main)/cloudflare/groups/[id]/page-client.tsx`)
|
||||||
|
|
||||||
|
1. **多选管理**:
|
||||||
|
- `selectedMemberIDs: Set<number>` 状态;
|
||||||
|
- 表头 Checkbox 支持全部选中/取消选中(含半选状态);
|
||||||
|
- 每行首列为 Checkbox,行内可单选。
|
||||||
|
2. **批量操作栏 (Batch Action Bar)**:
|
||||||
|
- 当 `selectedMemberIDs.size > 0` 时,在成员卡片头部或列表上方显示批量操作工具条:
|
||||||
|
- 已选条数提示(Badge);
|
||||||
|
- 「批量移动」按钮:唤起移动弹窗,包含选中的所有成员;
|
||||||
|
- 「批量删除」按钮:唤起确认弹窗,明确提示删除的数量与域名,确认后执行批量移出;
|
||||||
|
- 「取消选择」按钮:清空勾选。
|
||||||
|
3. **行级「移动」操作**:
|
||||||
|
- 行右侧操作按钮区新增「移动」操作按钮;
|
||||||
|
- 点击唤起 `MemberMoveDialog` 弹窗。
|
||||||
|
4. **移动域名弹窗 (`MemberMoveDialog`)**:
|
||||||
|
- 提取独立组件或子组件,接收待移动的成员列表和全部 Cloudflare 分组列表;
|
||||||
|
- 过滤排除当前分组,用户下拉选择目标分组;
|
||||||
|
- 点击确认后执行 `moveMutation` 或 `batchMoveMutation`。
|
||||||
|
|
||||||
|
### 3.3 WAF IP 组查看弹窗搜索 (`frontend/app/(main)/waf/components/ip-group-view-dialog.tsx`)
|
||||||
|
|
||||||
|
1. **搜索框**:
|
||||||
|
- 在 DialogHeader 和 IP 列表表格之间,增加搜索过滤条;
|
||||||
|
- 带 `Search` 图标,清除按钮,响应输入并去除首尾空格;
|
||||||
|
2. **过滤行为**:
|
||||||
|
- `searchKeyword` 过滤 `entries` 列表,匹配 `entry.ip`;
|
||||||
|
- 更新汇总文案:如 `共 50 条 IP (匹配 3 条)`;
|
||||||
|
- 无匹配时展示特定空状态「未找到匹配的 IP 地址」。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 4. 国际化与文案 (i18n)
|
||||||
|
|
||||||
|
- `frontend/messages/fragments/cloudflare.zh-CN.json` & `cloudflare.en.json`:
|
||||||
|
- 增加移动相关文案:`move`, `batchMove`, `batchRemove`, `moveTitle`, `targetGroup`, `selectTargetGroup`, `batchRemoveConfirm`, 等。
|
||||||
|
- `frontend/messages/fragments/security.zh-CN.json` & `security.en.json`:
|
||||||
|
- 增加 `viewDialog` 搜索框文案:`searchPlaceholder`, `matchedCount`, `noMatchFilter`。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 5. 验证标准 (Verification Criteria)
|
||||||
|
|
||||||
|
1. **后端单测**:
|
||||||
|
- 针对 `MoveMember`、`BatchMoveMembers`、`BatchRemoveMembers` 编写完整单元测试,验证成功与异常分支(如移到同一分组报错、不存在的分组报错等);
|
||||||
|
- 执行 `go test ./internal/apps/openflare/cloudflare/...` 全部通过。
|
||||||
|
2. **前端测试与校验**:
|
||||||
|
- 更新或新增前端测试用例覆盖移动与批量删除逻辑;
|
||||||
|
- 执行 `make code-check` 保证 TypeScript、ESLint 与后端架构规则通过。
|
||||||
|
3. **API 文档与更新日志**:
|
||||||
|
- 执行 `make swagger` 同步 OpenAPI 文档;
|
||||||
|
- 更新 `docs/changelog/index.md`。
|
||||||
+218
-1
@@ -6355,6 +6355,190 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/batch-move": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "批量移动 Cloudflare 指向成员",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "原分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "批量移动参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberBatchMoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/batch-remove": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "批量移出 Cloudflare 指向成员",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "批量移出参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberBatchRemoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move": {
|
||||||
|
"post": {
|
||||||
|
"security": [
|
||||||
|
{
|
||||||
|
"SessionCookie": []
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"consumes": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"produces": [
|
||||||
|
"application/json"
|
||||||
|
],
|
||||||
|
"tags": [
|
||||||
|
"openflare-cloudflare"
|
||||||
|
],
|
||||||
|
"summary": "移动 Cloudflare 指向成员到其他分组",
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "原分组 ID",
|
||||||
|
"name": "id",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "integer",
|
||||||
|
"description": "成员 ID",
|
||||||
|
"name": "memberId",
|
||||||
|
"in": "path",
|
||||||
|
"required": true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description": "目标分组参数",
|
||||||
|
"name": "body",
|
||||||
|
"in": "body",
|
||||||
|
"required": true,
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberMoveInput"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "OK",
|
||||||
|
"schema": {
|
||||||
|
"allOf": [
|
||||||
|
{
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"data": {
|
||||||
|
"$ref": "#/definitions/cloudflare.MemberItem"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Bad Request",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Not Found",
|
||||||
|
"schema": {
|
||||||
|
"$ref": "#/definitions/response.Any"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
"/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove": {
|
||||||
"post": {
|
"post": {
|
||||||
"security": [
|
"security": [
|
||||||
@@ -14253,7 +14437,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"400": {
|
"400": {
|
||||||
"description": "用户名或密码错误、帐号已禁用等",
|
"description": "用户名或密码错误",
|
||||||
"schema": {
|
"schema": {
|
||||||
"$ref": "#/definitions/response.Any"
|
"$ref": "#/definitions/response.Any"
|
||||||
}
|
}
|
||||||
@@ -15132,6 +15316,31 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cloudflare.MemberBatchMoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"member_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"target_group_id": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"cloudflare.MemberBatchRemoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"member_ids": {
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"cloudflare.MemberCreateInput": {
|
"cloudflare.MemberCreateInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
@@ -15178,6 +15387,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"cloudflare.MemberMoveInput": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"target_group_id": {
|
||||||
|
"type": "integer"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"cloudflare.MemberUpdateInput": {
|
"cloudflare.MemberUpdateInput": {
|
||||||
"type": "object",
|
"type": "object",
|
||||||
"properties": {
|
"properties": {
|
||||||
|
|||||||
+136
-1
@@ -354,6 +354,22 @@ definitions:
|
|||||||
updated_at:
|
updated_at:
|
||||||
type: string
|
type: string
|
||||||
type: object
|
type: object
|
||||||
|
cloudflare.MemberBatchMoveInput:
|
||||||
|
properties:
|
||||||
|
member_ids:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
target_group_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
|
cloudflare.MemberBatchRemoveInput:
|
||||||
|
properties:
|
||||||
|
member_ids:
|
||||||
|
items:
|
||||||
|
type: integer
|
||||||
|
type: array
|
||||||
|
type: object
|
||||||
cloudflare.MemberCreateInput:
|
cloudflare.MemberCreateInput:
|
||||||
properties:
|
properties:
|
||||||
proxied:
|
proxied:
|
||||||
@@ -384,6 +400,11 @@ definitions:
|
|||||||
zone_id:
|
zone_id:
|
||||||
type: integer
|
type: integer
|
||||||
type: object
|
type: object
|
||||||
|
cloudflare.MemberMoveInput:
|
||||||
|
properties:
|
||||||
|
target_group_id:
|
||||||
|
type: integer
|
||||||
|
type: object
|
||||||
cloudflare.MemberUpdateInput:
|
cloudflare.MemberUpdateInput:
|
||||||
properties:
|
properties:
|
||||||
proxied:
|
proxied:
|
||||||
@@ -8135,6 +8156,52 @@ paths:
|
|||||||
summary: 添加 Cloudflare 指向成员
|
summary: 添加 Cloudflare 指向成员
|
||||||
tags:
|
tags:
|
||||||
- openflare-cloudflare
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/move:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 原分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 成员 ID
|
||||||
|
in: path
|
||||||
|
name: memberId
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 目标分组参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberMoveInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
allOf:
|
||||||
|
- $ref: '#/definitions/response.Any'
|
||||||
|
- properties:
|
||||||
|
data:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberItem'
|
||||||
|
type: object
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 移动 Cloudflare 指向成员到其他分组
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove:
|
/api/v1/d/cloudflare/groups/{id}/members/{memberId}/remove:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -8228,6 +8295,74 @@ paths:
|
|||||||
summary: 更新 Cloudflare 指向成员
|
summary: 更新 Cloudflare 指向成员
|
||||||
tags:
|
tags:
|
||||||
- openflare-cloudflare
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/batch-move:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 原分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 批量移动参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberBatchMoveInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"404":
|
||||||
|
description: Not Found
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 批量移动 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
|
/api/v1/d/cloudflare/groups/{id}/members/batch-remove:
|
||||||
|
post:
|
||||||
|
consumes:
|
||||||
|
- application/json
|
||||||
|
parameters:
|
||||||
|
- description: 分组 ID
|
||||||
|
in: path
|
||||||
|
name: id
|
||||||
|
required: true
|
||||||
|
type: integer
|
||||||
|
- description: 批量移出参数
|
||||||
|
in: body
|
||||||
|
name: body
|
||||||
|
required: true
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/cloudflare.MemberBatchRemoveInput'
|
||||||
|
produces:
|
||||||
|
- application/json
|
||||||
|
responses:
|
||||||
|
"200":
|
||||||
|
description: OK
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
"400":
|
||||||
|
description: Bad Request
|
||||||
|
schema:
|
||||||
|
$ref: '#/definitions/response.Any'
|
||||||
|
security:
|
||||||
|
- SessionCookie: []
|
||||||
|
summary: 批量移出 Cloudflare 指向成员
|
||||||
|
tags:
|
||||||
|
- openflare-cloudflare
|
||||||
/api/v1/d/cloudflare/groups/{id}/sync:
|
/api/v1/d/cloudflare/groups/{id}/sync:
|
||||||
post:
|
post:
|
||||||
parameters:
|
parameters:
|
||||||
@@ -12869,7 +13004,7 @@ paths:
|
|||||||
$ref: '#/definitions/oauth.BasicUserInfo'
|
$ref: '#/definitions/oauth.BasicUserInfo'
|
||||||
type: object
|
type: object
|
||||||
"400":
|
"400":
|
||||||
description: 用户名或密码错误、帐号已禁用等
|
description: 用户名或密码错误
|
||||||
schema:
|
schema:
|
||||||
$ref: '#/definitions/response.Any'
|
$ref: '#/definitions/response.Any'
|
||||||
"500":
|
"500":
|
||||||
|
|||||||
@@ -0,0 +1,145 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useTranslations } from 'next-intl';
|
||||||
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import { Field, FieldGroup, FieldLabel } from '@/components/ui/field';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectGroup,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
import type {
|
||||||
|
CloudflareGroup,
|
||||||
|
CloudflareMember,
|
||||||
|
} from '@/lib/services/openflare';
|
||||||
|
|
||||||
|
export interface MemberMoveDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
members: CloudflareMember[];
|
||||||
|
groups: CloudflareGroup[];
|
||||||
|
currentGroupId: number;
|
||||||
|
pending: boolean;
|
||||||
|
onSubmit: (targetGroupId: number) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MemberMoveDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
members,
|
||||||
|
groups,
|
||||||
|
currentGroupId,
|
||||||
|
pending,
|
||||||
|
onSubmit,
|
||||||
|
}: MemberMoveDialogProps) {
|
||||||
|
const t = useTranslations('cloudflare');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
|
const [targetGroupId, setTargetGroupId] = useState('');
|
||||||
|
|
||||||
|
const availableGroups = useMemo(
|
||||||
|
() => groups.filter((g) => g.id !== currentGroupId),
|
||||||
|
[groups, currentGroupId],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
setTargetGroupId('');
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
const title =
|
||||||
|
members.length === 1
|
||||||
|
? t('moveDialog.titleSingle', { domain: members[0]?.domain ?? '' })
|
||||||
|
: t('moveDialog.titleBatch', { count: members.length });
|
||||||
|
|
||||||
|
const description =
|
||||||
|
members.length > 0
|
||||||
|
? members.map((m) => m.domain).join(', ')
|
||||||
|
: t('moveDialog.targetGroupLabel');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog
|
||||||
|
open={open}
|
||||||
|
onOpenChange={(next) => {
|
||||||
|
if (!pending) {
|
||||||
|
onOpenChange(next);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogContent
|
||||||
|
onPointerDownOutside={(e) => {
|
||||||
|
if (pending) e.preventDefault();
|
||||||
|
}}
|
||||||
|
onEscapeKeyDown={(e) => {
|
||||||
|
if (pending) e.preventDefault();
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription className='line-clamp-2'>
|
||||||
|
{description}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<FieldGroup>
|
||||||
|
{availableGroups.length === 0 ? (
|
||||||
|
<p className='text-sm text-muted-foreground'>
|
||||||
|
{t('moveDialog.noOtherGroups')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<Field>
|
||||||
|
<FieldLabel htmlFor='cf-move-target-group'>
|
||||||
|
{t('moveDialog.targetGroupLabel')}
|
||||||
|
</FieldLabel>
|
||||||
|
<Select value={targetGroupId} onValueChange={setTargetGroupId}>
|
||||||
|
<SelectTrigger id='cf-move-target-group' className='w-full'>
|
||||||
|
<SelectValue
|
||||||
|
placeholder={t('moveDialog.targetGroupPlaceholder')}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectGroup>
|
||||||
|
{availableGroups.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
{group.primary_node?.ip
|
||||||
|
? ` · ${group.primary_node.ip}`
|
||||||
|
: ''}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectGroup>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</Field>
|
||||||
|
)}
|
||||||
|
</FieldGroup>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
disabled={pending}
|
||||||
|
onClick={() => onOpenChange(false)}
|
||||||
|
>
|
||||||
|
{tCommon('cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={pending || !targetGroupId || availableGroups.length === 0}
|
||||||
|
onClick={() => onSubmit(Number(targetGroupId))}
|
||||||
|
>
|
||||||
|
{pending ? t('moveDialog.moving') : t('moveDialog.submit')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@
|
|||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
|
ArrowRightLeft,
|
||||||
Cloud,
|
Cloud,
|
||||||
Loader2,
|
Loader2,
|
||||||
Plus,
|
Plus,
|
||||||
@@ -16,6 +17,16 @@ import { usePathname } from 'next/navigation';
|
|||||||
import { useEffect, useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
|
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
} from '@/components/ui/alert-dialog';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +36,7 @@ import {
|
|||||||
CardHeader,
|
CardHeader,
|
||||||
CardTitle,
|
CardTitle,
|
||||||
} from '@/components/ui/card';
|
} from '@/components/ui/card';
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -41,10 +53,12 @@ import {
|
|||||||
cloudflareQueryKey,
|
cloudflareQueryKey,
|
||||||
NodeService,
|
NodeService,
|
||||||
type CloudflareGroupPayload,
|
type CloudflareGroupPayload,
|
||||||
|
type CloudflareMember,
|
||||||
} from '@/lib/services/openflare';
|
} from '@/lib/services/openflare';
|
||||||
import { getErrorMessage } from '../../../websites/components/website-utils';
|
import { getErrorMessage } from '../../../websites/components/website-utils';
|
||||||
import { GroupDialog } from '../../components/group-dialog';
|
import { GroupDialog } from '../../components/group-dialog';
|
||||||
import { MemberAddDialog } from '../../components/member-add-dialog';
|
import { MemberAddDialog } from '../../components/member-add-dialog';
|
||||||
|
import { MemberMoveDialog } from '../../components/member-move-dialog';
|
||||||
|
|
||||||
function getGroupIdFromPathname(pathname: string | null): number {
|
function getGroupIdFromPathname(pathname: string | null): number {
|
||||||
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
|
const match = pathname?.match(/^\/cloudflare\/groups\/([^/]+)$/);
|
||||||
@@ -53,6 +67,7 @@ function getGroupIdFromPathname(pathname: string | null): number {
|
|||||||
|
|
||||||
export function CloudflareGroupDetailPageClient() {
|
export function CloudflareGroupDetailPageClient() {
|
||||||
const t = useTranslations('cloudflare');
|
const t = useTranslations('cloudflare');
|
||||||
|
const tCommon = useTranslations('common');
|
||||||
const [mounted, setMounted] = useState(false);
|
const [mounted, setMounted] = useState(false);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setMounted(true);
|
setMounted(true);
|
||||||
@@ -63,6 +78,13 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
const [editOpen, setEditOpen] = useState(false);
|
const [editOpen, setEditOpen] = useState(false);
|
||||||
const [addOpen, setAddOpen] = useState(false);
|
const [addOpen, setAddOpen] = useState(false);
|
||||||
|
const [selectedMemberIDs, setSelectedMemberIDs] = useState<Set<number>>(
|
||||||
|
() => new Set(),
|
||||||
|
);
|
||||||
|
const [moveDialogOpen, setMoveDialogOpen] = useState(false);
|
||||||
|
const [movingMembers, setMovingMembers] = useState<CloudflareMember[]>([]);
|
||||||
|
const [batchRemoveOpen, setBatchRemoveOpen] = useState(false);
|
||||||
|
|
||||||
const detailQuery = useQuery({
|
const detailQuery = useQuery({
|
||||||
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
queryKey: [...cloudflareQueryKey, 'groups', groupID],
|
||||||
queryFn: () => CloudflareService.getGroup(groupID),
|
queryFn: () => CloudflareService.getGroup(groupID),
|
||||||
@@ -77,6 +99,10 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
queryKey: ['openflare', 'nodes'],
|
queryKey: ['openflare', 'nodes'],
|
||||||
queryFn: () => NodeService.listNodes(),
|
queryFn: () => NodeService.listNodes(),
|
||||||
});
|
});
|
||||||
|
const groupsQuery = useQuery({
|
||||||
|
queryKey: [...cloudflareQueryKey, 'groups'],
|
||||||
|
queryFn: () => CloudflareService.listGroups(),
|
||||||
|
});
|
||||||
const invalidate = async () =>
|
const invalidate = async () =>
|
||||||
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
queryClient.invalidateQueries({ queryKey: cloudflareQueryKey });
|
||||||
|
|
||||||
@@ -150,12 +176,80 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
const removeMutation = useMutation({
|
const removeMutation = useMutation({
|
||||||
mutationFn: (memberID: number) =>
|
mutationFn: (memberID: number) =>
|
||||||
CloudflareService.removeMember(groupID, memberID),
|
CloudflareService.removeMember(groupID, memberID),
|
||||||
onSuccess: async () => {
|
onSuccess: async (_, memberID) => {
|
||||||
toast.success(t('memberDeleted'));
|
toast.success(t('memberDeleted'));
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(memberID);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
await invalidate();
|
await invalidate();
|
||||||
},
|
},
|
||||||
onError: (error) => toast.error(getErrorMessage(error)),
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
});
|
});
|
||||||
|
const moveMutation = useMutation({
|
||||||
|
mutationFn: async ({
|
||||||
|
targetGroupId,
|
||||||
|
membersToMove,
|
||||||
|
}: {
|
||||||
|
targetGroupId: number;
|
||||||
|
membersToMove: CloudflareMember[];
|
||||||
|
}) => {
|
||||||
|
if (membersToMove.length === 1) {
|
||||||
|
return CloudflareService.moveMember(
|
||||||
|
groupID,
|
||||||
|
membersToMove[0].id,
|
||||||
|
targetGroupId,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return CloudflareService.batchMoveMembers(
|
||||||
|
groupID,
|
||||||
|
membersToMove.map((m) => m.id),
|
||||||
|
targetGroupId,
|
||||||
|
);
|
||||||
|
},
|
||||||
|
onSuccess: async (_, variables) => {
|
||||||
|
if (variables.membersToMove.length === 1) {
|
||||||
|
toast.success(t('memberMoved'));
|
||||||
|
} else {
|
||||||
|
toast.success(
|
||||||
|
t('batchMoved', { count: variables.membersToMove.length }),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
setMoveDialogOpen(false);
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
for (const m of variables.membersToMove) {
|
||||||
|
next.delete(m.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
const batchRemoveMutation = useMutation({
|
||||||
|
mutationFn: (memberIds: number[]) =>
|
||||||
|
CloudflareService.batchRemoveMembers(groupID, memberIds),
|
||||||
|
onSuccess: async (_, memberIds) => {
|
||||||
|
toast.success(t('batchRemoved', { count: memberIds.length }));
|
||||||
|
setBatchRemoveOpen(false);
|
||||||
|
setSelectedMemberIDs(new Set());
|
||||||
|
await invalidate();
|
||||||
|
},
|
||||||
|
onError: (error) => toast.error(getErrorMessage(error)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSingleMove = (member: CloudflareMember) => {
|
||||||
|
setMovingMembers([member]);
|
||||||
|
setMoveDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleBatchMove = (selectedList: CloudflareMember[]) => {
|
||||||
|
if (selectedList.length === 0) return;
|
||||||
|
setMovingMembers(selectedList);
|
||||||
|
setMoveDialogOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
if (!mounted || detailQuery.isLoading)
|
if (!mounted || detailQuery.isLoading)
|
||||||
return (
|
return (
|
||||||
@@ -174,6 +268,12 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
);
|
);
|
||||||
const { group, members } = detailQuery.data;
|
const { group, members } = detailQuery.data;
|
||||||
|
|
||||||
|
const selectedMembers = members.filter((m) => selectedMemberIDs.has(m.id));
|
||||||
|
const allSelected =
|
||||||
|
members.length > 0 && selectedMembers.length === members.length;
|
||||||
|
const isIndeterminate =
|
||||||
|
selectedMembers.length > 0 && selectedMembers.length < members.length;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
<div className='flex w-full flex-col gap-6 py-6 px-1'>
|
||||||
<div className='flex flex-col gap-4'>
|
<div className='flex flex-col gap-4'>
|
||||||
@@ -246,9 +346,41 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card className='border-dashed shadow-none'>
|
<Card className='border-dashed shadow-none'>
|
||||||
<CardHeader>
|
<CardHeader className='flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between'>
|
||||||
|
<div>
|
||||||
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
<CardTitle className='text-base'>{t('members')}</CardTitle>
|
||||||
<CardDescription>{t('membersDesc')}</CardDescription>
|
<CardDescription>{t('membersDesc')}</CardDescription>
|
||||||
|
</div>
|
||||||
|
{selectedMembers.length > 0 && (
|
||||||
|
<div className='flex flex-wrap items-center gap-2'>
|
||||||
|
<Badge variant='secondary' className='text-xs'>
|
||||||
|
{t('selectedCount', { count: selectedMembers.length })}
|
||||||
|
</Badge>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleBatchMove(selectedMembers)}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft data-icon='inline-start' />
|
||||||
|
{t('batchMove')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='destructive'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setBatchRemoveOpen(true)}
|
||||||
|
>
|
||||||
|
<Trash2 data-icon='inline-start' />
|
||||||
|
{t('batchRemove')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant='ghost'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => setSelectedMemberIDs(new Set())}
|
||||||
|
>
|
||||||
|
{t('clearSelection')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent>
|
<CardContent>
|
||||||
{members.length === 0 ? (
|
{members.length === 0 ? (
|
||||||
@@ -259,6 +391,27 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
<TableRow>
|
<TableRow>
|
||||||
|
<TableHead className='w-12'>
|
||||||
|
<Checkbox
|
||||||
|
checked={
|
||||||
|
allSelected
|
||||||
|
? true
|
||||||
|
: isIndeterminate
|
||||||
|
? 'indeterminate'
|
||||||
|
: false
|
||||||
|
}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
if (checked) {
|
||||||
|
setSelectedMemberIDs(
|
||||||
|
new Set(members.map((m) => m.id)),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
setSelectedMemberIDs(new Set());
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
aria-label='Select all'
|
||||||
|
/>
|
||||||
|
</TableHead>
|
||||||
<TableHead>{t('columns.domain')}</TableHead>
|
<TableHead>{t('columns.domain')}</TableHead>
|
||||||
<TableHead>{t('columns.desiredIp')}</TableHead>
|
<TableHead>{t('columns.desiredIp')}</TableHead>
|
||||||
<TableHead>{t('columns.status')}</TableHead>
|
<TableHead>{t('columns.status')}</TableHead>
|
||||||
@@ -270,7 +423,29 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{members.map((member) => (
|
{members.map((member) => (
|
||||||
<TableRow key={member.id}>
|
<TableRow
|
||||||
|
key={member.id}
|
||||||
|
data-state={
|
||||||
|
selectedMemberIDs.has(member.id) ? 'selected' : undefined
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<TableCell className='w-12'>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedMemberIDs.has(member.id)}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
setSelectedMemberIDs((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
if (checked) {
|
||||||
|
next.add(member.id);
|
||||||
|
} else {
|
||||||
|
next.delete(member.id);
|
||||||
|
}
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
aria-label={`Select ${member.domain}`}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='font-medium'>{member.domain}</div>
|
<div className='font-medium'>{member.domain}</div>
|
||||||
{member.last_error ? (
|
{member.last_error ? (
|
||||||
@@ -308,6 +483,14 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
</TableCell>
|
</TableCell>
|
||||||
<TableCell>
|
<TableCell>
|
||||||
<div className='flex justify-end gap-2'>
|
<div className='flex justify-end gap-2'>
|
||||||
|
<Button
|
||||||
|
variant='outline'
|
||||||
|
size='sm'
|
||||||
|
onClick={() => handleSingleMove(member)}
|
||||||
|
>
|
||||||
|
<ArrowRightLeft data-icon='inline-start' />
|
||||||
|
{t('move')}
|
||||||
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant='outline'
|
variant='outline'
|
||||||
size='sm'
|
size='sm'
|
||||||
@@ -352,6 +535,43 @@ export function CloudflareGroupDetailPageClient() {
|
|||||||
addMutation.mutate({ domainIDs, proxied })
|
addMutation.mutate({ domainIDs, proxied })
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
<MemberMoveDialog
|
||||||
|
open={moveDialogOpen}
|
||||||
|
onOpenChange={setMoveDialogOpen}
|
||||||
|
members={movingMembers}
|
||||||
|
groups={groupsQuery.data ?? []}
|
||||||
|
currentGroupId={groupID}
|
||||||
|
pending={moveMutation.isPending}
|
||||||
|
onSubmit={(targetGroupId) =>
|
||||||
|
moveMutation.mutate({
|
||||||
|
targetGroupId,
|
||||||
|
membersToMove: movingMembers,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<AlertDialog open={batchRemoveOpen} onOpenChange={setBatchRemoveOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('batchRemoveDialog.title')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('batchRemoveDialog.desc', { count: selectedMembers.length })}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{tCommon('cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
disabled={batchRemoveMutation.isPending}
|
||||||
|
onClick={() =>
|
||||||
|
batchRemoveMutation.mutate(selectedMembers.map((m) => m.id))
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{batchRemoveMutation.isPending
|
||||||
|
? t('batchRemoveDialog.removing')
|
||||||
|
: t('batchRemoveDialog.confirm')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
'use client';
|
'use client';
|
||||||
|
|
||||||
import { Loader2, Trash2 } from 'lucide-react';
|
import { Loader2, Search, Trash2, X } from 'lucide-react';
|
||||||
import { useTranslations } from 'next-intl';
|
import { useTranslations } from 'next-intl';
|
||||||
import { useMemo, useState } from 'react';
|
import { useEffect, useMemo, useState } from 'react';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
DialogTitle,
|
DialogTitle,
|
||||||
} from '@/components/ui/dialog';
|
} from '@/components/ui/dialog';
|
||||||
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
import { EmptyStateWithBorder } from '@/components/layout/empty';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
TableBody,
|
TableBody,
|
||||||
@@ -59,6 +60,14 @@ export function IPGroupViewDialog({
|
|||||||
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
|
const [deleteTarget, setDeleteTarget] = useState<IPGroupViewEntry | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [searchKeyword, setSearchKeyword] = useState('');
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) {
|
||||||
|
setSearchKeyword('');
|
||||||
|
setDeleteTarget(null);
|
||||||
|
}
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
const entries = useMemo(
|
const entries = useMemo(
|
||||||
() =>
|
() =>
|
||||||
@@ -68,6 +77,13 @@ export function IPGroupViewDialog({
|
|||||||
[group, t],
|
[group, t],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const trimmedKeyword = searchKeyword.trim().toLowerCase();
|
||||||
|
|
||||||
|
const filteredEntries = useMemo(() => {
|
||||||
|
if (!trimmedKeyword) return entries;
|
||||||
|
return entries.filter((e) => e.ip.toLowerCase().includes(trimmedKeyword));
|
||||||
|
}, [entries, trimmedKeyword]);
|
||||||
|
|
||||||
const showAutomaticMeta = group?.type === 'automatic';
|
const showAutomaticMeta = group?.type === 'automatic';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -78,6 +94,7 @@ export function IPGroupViewDialog({
|
|||||||
onOpenChange(nextOpen);
|
onOpenChange(nextOpen);
|
||||||
if (!nextOpen) {
|
if (!nextOpen) {
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
|
setSearchKeyword('');
|
||||||
}
|
}
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
@@ -90,7 +107,13 @@ export function IPGroupViewDialog({
|
|||||||
</DialogTitle>
|
</DialogTitle>
|
||||||
<DialogDescription>
|
<DialogDescription>
|
||||||
{group
|
{group
|
||||||
? t('viewDialog.summary', {
|
? trimmedKeyword
|
||||||
|
? t('viewDialog.summaryFiltered', {
|
||||||
|
type: t(`types.${group.type}`),
|
||||||
|
count: entries.length,
|
||||||
|
matched: filteredEntries.length,
|
||||||
|
})
|
||||||
|
: t('viewDialog.summary', {
|
||||||
type: t(`types.${group.type}`),
|
type: t(`types.${group.type}`),
|
||||||
count: entries.length,
|
count: entries.length,
|
||||||
})
|
})
|
||||||
@@ -122,6 +145,34 @@ export function IPGroupViewDialog({
|
|||||||
{t('viewDialog.subscriptionHint')}
|
{t('viewDialog.subscriptionHint')}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
|
<div className='relative'>
|
||||||
|
<Search className='pointer-events-none absolute left-2.5 top-1/2 size-4 -translate-y-1/2 text-muted-foreground' />
|
||||||
|
<Input
|
||||||
|
value={searchKeyword}
|
||||||
|
onChange={(e) => setSearchKeyword(e.target.value)}
|
||||||
|
placeholder={t('viewDialog.searchPlaceholder')}
|
||||||
|
className='pl-8 pr-8'
|
||||||
|
/>
|
||||||
|
{searchKeyword ? (
|
||||||
|
<Button
|
||||||
|
type='button'
|
||||||
|
variant='ghost'
|
||||||
|
size='icon'
|
||||||
|
className='absolute right-1 top-1/2 size-7 -translate-y-1/2 text-muted-foreground hover:text-foreground'
|
||||||
|
onClick={() => setSearchKeyword('')}
|
||||||
|
>
|
||||||
|
<X className='size-3.5' />
|
||||||
|
<span className='sr-only'>
|
||||||
|
{t('viewDialog.clearSearch')}
|
||||||
|
</span>
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{filteredEntries.length === 0 ? (
|
||||||
|
<EmptyStateWithBorder
|
||||||
|
description={t('viewDialog.noSearchResult')}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
<div className='rounded-lg border border-dashed'>
|
<div className='rounded-lg border border-dashed'>
|
||||||
<Table>
|
<Table>
|
||||||
<TableHeader>
|
<TableHeader>
|
||||||
@@ -130,7 +181,9 @@ export function IPGroupViewDialog({
|
|||||||
{showAutomaticMeta ? (
|
{showAutomaticMeta ? (
|
||||||
<>
|
<>
|
||||||
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
<TableHead>{t('viewDialog.capturedAt')}</TableHead>
|
||||||
<TableHead>{t('viewDialog.banRemaining')}</TableHead>
|
<TableHead>
|
||||||
|
{t('viewDialog.banRemaining')}
|
||||||
|
</TableHead>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
<TableHead className='w-[80px] text-right'>
|
<TableHead className='w-[80px] text-right'>
|
||||||
@@ -139,7 +192,7 @@ export function IPGroupViewDialog({
|
|||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHeader>
|
</TableHeader>
|
||||||
<TableBody>
|
<TableBody>
|
||||||
{entries.map((entry) => (
|
{filteredEntries.map((entry) => (
|
||||||
<TableRow key={entry.ip}>
|
<TableRow key={entry.ip}>
|
||||||
<TableCell className='font-mono text-sm'>
|
<TableCell className='font-mono text-sm'>
|
||||||
{entry.ip}
|
{entry.ip}
|
||||||
@@ -180,6 +233,7 @@ export function IPGroupViewDialog({
|
|||||||
</TableBody>
|
</TableBody>
|
||||||
</Table>
|
</Table>
|
||||||
</div>
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -102,4 +102,35 @@ export class CloudflareService extends OpenFlareBaseService {
|
|||||||
`/groups/${groupId}/members/${memberId}/sync`,
|
`/groups/${groupId}/members/${memberId}/sync`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static moveMember(
|
||||||
|
groupId: number,
|
||||||
|
memberId: number,
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<CloudflareMember> {
|
||||||
|
return this.post<CloudflareMember>(
|
||||||
|
`/groups/${groupId}/members/${memberId}/move`,
|
||||||
|
{ target_group_id: targetGroupId },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
static batchMoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
targetGroupId: number,
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${groupId}/members/batch-move`, {
|
||||||
|
member_ids: memberIds,
|
||||||
|
target_group_id: targetGroupId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
static batchRemoveMembers(
|
||||||
|
groupId: number,
|
||||||
|
memberIds: number[],
|
||||||
|
): Promise<void> {
|
||||||
|
return this.post<void>(`/groups/${groupId}/members/batch-remove`, {
|
||||||
|
member_ids: memberIds,
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cloudflare": {
|
||||||
|
"move": "Move",
|
||||||
|
"batchMove": "Batch Move",
|
||||||
|
"batchRemove": "Batch Delete",
|
||||||
|
"selectedCount": "{count} selected",
|
||||||
|
"clearSelection": "Clear selection",
|
||||||
|
"moveDialog": {
|
||||||
|
"titleSingle": "Move domain \"{domain}\"",
|
||||||
|
"titleBatch": "Batch move domains ({count} items)",
|
||||||
|
"targetGroupLabel": "Target pointing group",
|
||||||
|
"targetGroupPlaceholder": "Select target group",
|
||||||
|
"noOtherGroups": "No other pointing groups available. Please create one first.",
|
||||||
|
"submit": "Confirm move",
|
||||||
|
"moving": "Moving..."
|
||||||
|
},
|
||||||
|
"batchRemoveDialog": {
|
||||||
|
"title": "Confirm batch domain deletion",
|
||||||
|
"desc": "Are you sure you want to remove {count} selected domains from the current group? This will also clean up DNS records on Cloudflare.",
|
||||||
|
"confirm": "Confirm delete",
|
||||||
|
"removing": "Deleting..."
|
||||||
|
},
|
||||||
|
"memberMoved": "Domain moved successfully",
|
||||||
|
"batchMoved": "Successfully moved {count} domains",
|
||||||
|
"batchRemoved": "Successfully deleted {count} domains"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"cloudflare": {
|
||||||
|
"move": "移动",
|
||||||
|
"batchMove": "批量移动",
|
||||||
|
"batchRemove": "批量删除",
|
||||||
|
"selectedCount": "已选择 {count} 项",
|
||||||
|
"clearSelection": "取消选择",
|
||||||
|
"moveDialog": {
|
||||||
|
"titleSingle": "移动域名「{domain}」",
|
||||||
|
"titleBatch": "批量移动域名(共 {count} 项)",
|
||||||
|
"targetGroupLabel": "目标指向分组",
|
||||||
|
"targetGroupPlaceholder": "请选择目标分组",
|
||||||
|
"noOtherGroups": "没有其他可用的指向分组,请先创建分组",
|
||||||
|
"submit": "确认移动",
|
||||||
|
"moving": "移动中..."
|
||||||
|
},
|
||||||
|
"batchRemoveDialog": {
|
||||||
|
"title": "确认批量删除域名",
|
||||||
|
"desc": "确认从当前分组移出已选中的 {count} 个域名吗?这将同时清理 Cloudflare 上的解析记录。",
|
||||||
|
"confirm": "确认删除",
|
||||||
|
"removing": "删除中..."
|
||||||
|
},
|
||||||
|
"memberMoved": "域名移动成功",
|
||||||
|
"batchMoved": "成功移动 {count} 个域名",
|
||||||
|
"batchRemoved": "成功删除 {count} 个域名"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -257,6 +257,10 @@
|
|||||||
"titleNamed": "View {name}",
|
"titleNamed": "View {name}",
|
||||||
"title": "View IP group",
|
"title": "View IP group",
|
||||||
"summary": "{type} · {count} IPs",
|
"summary": "{type} · {count} IPs",
|
||||||
|
"summaryFiltered": "{type} · {count} IPs ({matched} matched)",
|
||||||
|
"searchPlaceholder": "Search IP addresses...",
|
||||||
|
"clearSearch": "Clear search",
|
||||||
|
"noSearchResult": "No matching IP addresses found.",
|
||||||
"fallbackDesc": "View IPs in this group and remove entries you no longer need.",
|
"fallbackDesc": "View IPs in this group and remove entries you no longer need.",
|
||||||
"loading": "Loading IP list...",
|
"loading": "Loading IP list...",
|
||||||
"noGroup": "No IP group selected.",
|
"noGroup": "No IP group selected.",
|
||||||
|
|||||||
@@ -257,6 +257,10 @@
|
|||||||
"titleNamed": "查看 {name}",
|
"titleNamed": "查看 {name}",
|
||||||
"title": "查看 IP 组",
|
"title": "查看 IP 组",
|
||||||
"summary": "{type} · 共 {count} 条 IP",
|
"summary": "{type} · 共 {count} 条 IP",
|
||||||
|
"summaryFiltered": "{type} · 共 {count} 条 IP(匹配 {matched} 条)",
|
||||||
|
"searchPlaceholder": "搜索 IP 地址...",
|
||||||
|
"clearSearch": "清空搜索",
|
||||||
|
"noSearchResult": "未找到匹配的 IP 地址。",
|
||||||
"fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。",
|
"fallbackDesc": "查看当前 IP 组中的 IP 列表,并可移除不需要的条目。",
|
||||||
"loading": "加载 IP 列表...",
|
"loading": "加载 IP 列表...",
|
||||||
"noGroup": "未选择 IP 组。",
|
"noGroup": "未选择 IP 组。",
|
||||||
|
|||||||
@@ -39,17 +39,74 @@ vi.mock('@/lib/services/openflare', async (importOriginal) => {
|
|||||||
CloudflareService: {
|
CloudflareService: {
|
||||||
...actual.CloudflareService,
|
...actual.CloudflareService,
|
||||||
getGroup: vi.fn(),
|
getGroup: vi.fn(),
|
||||||
|
listGroups: vi.fn(),
|
||||||
listAvailableDomains: vi.fn(),
|
listAvailableDomains: vi.fn(),
|
||||||
updateGroup: vi.fn(),
|
updateGroup: vi.fn(),
|
||||||
createMember: vi.fn(),
|
createMember: vi.fn(),
|
||||||
updateMember: vi.fn(),
|
updateMember: vi.fn(),
|
||||||
syncMember: vi.fn(),
|
syncMember: vi.fn(),
|
||||||
removeMember: vi.fn(),
|
removeMember: vi.fn(),
|
||||||
|
moveMember: vi.fn(),
|
||||||
|
batchMoveMembers: vi.fn(),
|
||||||
|
batchRemoveMembers: vi.fn(),
|
||||||
},
|
},
|
||||||
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
|
NodeService: { ...actual.NodeService, listNodes: vi.fn() },
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockGroup = {
|
||||||
|
id: 7,
|
||||||
|
name: '生产节点',
|
||||||
|
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
backup_node: null,
|
||||||
|
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
||||||
|
default_proxied: true,
|
||||||
|
enabled: true,
|
||||||
|
member_count: 2,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockTargetGroup = {
|
||||||
|
id: 8,
|
||||||
|
name: '备用分组',
|
||||||
|
primary_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
|
backup_node: null,
|
||||||
|
active_node: { id: 2, name: '备用节点', ip: '192.0.2.2' },
|
||||||
|
default_proxied: false,
|
||||||
|
enabled: true,
|
||||||
|
member_count: 0,
|
||||||
|
created_at: '',
|
||||||
|
updated_at: '',
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockMembers = [
|
||||||
|
{
|
||||||
|
id: 101,
|
||||||
|
group_id: 7,
|
||||||
|
zone_domain_id: 1,
|
||||||
|
domain: 'a.example.com',
|
||||||
|
zone_id: 10,
|
||||||
|
proxied: true,
|
||||||
|
desired_ip: '192.0.2.1',
|
||||||
|
sync_status: 'ok' as const,
|
||||||
|
last_error: '',
|
||||||
|
synced_at: null,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 102,
|
||||||
|
group_id: 7,
|
||||||
|
zone_domain_id: 2,
|
||||||
|
domain: 'b.example.com',
|
||||||
|
zone_id: 10,
|
||||||
|
proxied: false,
|
||||||
|
desired_ip: '192.0.2.1',
|
||||||
|
sync_status: 'ok' as const,
|
||||||
|
last_error: '',
|
||||||
|
synced_at: null,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
function renderPage() {
|
function renderPage() {
|
||||||
const client = new QueryClient({
|
const client = new QueryClient({
|
||||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||||
@@ -72,25 +129,36 @@ describe('Cloudflare group detail refresh', () => {
|
|||||||
mockGroupId = '7';
|
mockGroupId = '7';
|
||||||
mockParamId = '7';
|
mockParamId = '7';
|
||||||
vi.mocked(CloudflareService.getGroup).mockReset();
|
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||||
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||||
vi.mocked(NodeService.listNodes).mockReset();
|
vi.mocked(NodeService.listNodes).mockReset();
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||||
|
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||||
group: {
|
group: mockGroup,
|
||||||
id: 7,
|
|
||||||
name: '生产节点',
|
|
||||||
primary_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
|
||||||
backup_node: null,
|
|
||||||
active_node: { id: 1, name: '主节点', ip: '192.0.2.1' },
|
|
||||||
default_proxied: true,
|
|
||||||
enabled: true,
|
|
||||||
member_count: 0,
|
|
||||||
created_at: '',
|
|
||||||
updated_at: '',
|
|
||||||
},
|
|
||||||
members: [],
|
members: [],
|
||||||
});
|
});
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||||
|
mockGroup,
|
||||||
|
mockTargetGroup,
|
||||||
|
]);
|
||||||
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||||
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||||
|
...mockMembers[0],
|
||||||
|
group_id: 8,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -141,3 +209,174 @@ describe('Cloudflare group detail refresh', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('Cloudflare group detail move and batch operations', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
mockGroupId = '7';
|
||||||
|
mockParamId = '7';
|
||||||
|
vi.mocked(CloudflareService.getGroup).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockReset();
|
||||||
|
vi.mocked(CloudflareService.listAvailableDomains).mockReset();
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockReset();
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockReset();
|
||||||
|
vi.mocked(NodeService.listNodes).mockReset();
|
||||||
|
|
||||||
|
window.HTMLElement.prototype.hasPointerCapture = vi.fn(() => false);
|
||||||
|
window.HTMLElement.prototype.setPointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.releasePointerCapture = vi.fn();
|
||||||
|
window.HTMLElement.prototype.scrollIntoView = vi.fn();
|
||||||
|
|
||||||
|
vi.mocked(CloudflareService.getGroup).mockResolvedValue({
|
||||||
|
group: mockGroup,
|
||||||
|
members: mockMembers,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.listGroups).mockResolvedValue([
|
||||||
|
mockGroup,
|
||||||
|
mockTargetGroup,
|
||||||
|
]);
|
||||||
|
vi.mocked(CloudflareService.listAvailableDomains).mockResolvedValue([]);
|
||||||
|
vi.mocked(NodeService.listNodes).mockResolvedValue([]);
|
||||||
|
vi.mocked(CloudflareService.moveMember).mockResolvedValue({
|
||||||
|
...mockMembers[0],
|
||||||
|
group_id: 8,
|
||||||
|
});
|
||||||
|
vi.mocked(CloudflareService.batchMoveMembers).mockResolvedValue(undefined);
|
||||||
|
vi.mocked(CloudflareService.batchRemoveMembers).mockResolvedValue(
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('supports selecting all members and toggling selection', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
expect(screen.getByText('b.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Initially batch toolbar is not visible
|
||||||
|
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
// Select all via header checkbox
|
||||||
|
const selectAllCheckbox = screen.getByRole('checkbox', {
|
||||||
|
name: 'Select all',
|
||||||
|
});
|
||||||
|
fireEvent.click(selectAllCheckbox);
|
||||||
|
|
||||||
|
// Batch toolbar should appear with count 2
|
||||||
|
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '批量移动' })).toBeVisible();
|
||||||
|
expect(screen.getByRole('button', { name: '批量删除' })).toBeVisible();
|
||||||
|
|
||||||
|
// Clear selection
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '取消选择' }));
|
||||||
|
expect(screen.queryByText(/已选择/)).not.toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens single move dialog and triggers moveMember', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Click single move button on first row
|
||||||
|
const moveButtons = screen.getAllByRole('button', { name: '移动' });
|
||||||
|
fireEvent.click(moveButtons[0]);
|
||||||
|
|
||||||
|
// Move dialog should show single member title
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: '移动域名「a.example.com」',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Open target group select
|
||||||
|
const selectTrigger = screen.getByRole('combobox');
|
||||||
|
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
// Select target group
|
||||||
|
const targetOption = await screen.findByRole('option', {
|
||||||
|
name: /备用分组/,
|
||||||
|
});
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
// Submit move
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.moveMember).toHaveBeenCalledWith(7, 101, 8);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens batch move dialog and triggers batchMoveMembers', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Select all members
|
||||||
|
fireEvent.click(screen.getByRole('checkbox', { name: 'Select all' }));
|
||||||
|
expect(await screen.findByText('已选择 2 项')).toBeVisible();
|
||||||
|
|
||||||
|
// Click batch move button
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '批量移动' }));
|
||||||
|
|
||||||
|
// Dialog title should show batch title
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', {
|
||||||
|
name: '批量移动域名(共 2 项)',
|
||||||
|
}),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Open target group select
|
||||||
|
const selectTrigger = screen.getByRole('combobox');
|
||||||
|
fireEvent.keyDown(selectTrigger, { key: 'ArrowDown' });
|
||||||
|
|
||||||
|
// Select target group
|
||||||
|
const targetOption = await screen.findByRole('option', {
|
||||||
|
name: /备用分组/,
|
||||||
|
});
|
||||||
|
fireEvent.click(targetOption);
|
||||||
|
|
||||||
|
// Submit move
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认移动' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.batchMoveMembers).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
[101, 102],
|
||||||
|
8,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('opens batch remove dialog and triggers batchRemoveMembers', async () => {
|
||||||
|
renderPage();
|
||||||
|
|
||||||
|
expect(await screen.findByText('a.example.com')).toBeVisible();
|
||||||
|
|
||||||
|
// Select first member only
|
||||||
|
fireEvent.click(
|
||||||
|
screen.getByRole('checkbox', { name: 'Select a.example.com' }),
|
||||||
|
);
|
||||||
|
expect(await screen.findByText('已选择 1 项')).toBeVisible();
|
||||||
|
|
||||||
|
// Click batch remove
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '批量删除' }));
|
||||||
|
|
||||||
|
// Confirmation dialog appears
|
||||||
|
expect(
|
||||||
|
await screen.findByRole('heading', { name: '确认批量删除域名' }),
|
||||||
|
).toBeVisible();
|
||||||
|
expect(
|
||||||
|
screen.getByText(/确认从当前分组移出已选中的 1 个域名吗?/),
|
||||||
|
).toBeVisible();
|
||||||
|
|
||||||
|
// Confirm deletion
|
||||||
|
fireEvent.click(screen.getByRole('button', { name: '确认删除' }));
|
||||||
|
|
||||||
|
await waitFor(() => {
|
||||||
|
expect(CloudflareService.batchRemoveMembers).toHaveBeenCalledWith(
|
||||||
|
7,
|
||||||
|
[101],
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -55,4 +55,29 @@ describe('CloudflareService', () => {
|
|||||||
undefined,
|
undefined,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('calls move and batch member endpoints with correct paths and payloads', async () => {
|
||||||
|
vi.mocked(apiClient.post).mockResolvedValue(response({}));
|
||||||
|
|
||||||
|
await CloudflareService.moveMember(7, 9, 8);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/9/move',
|
||||||
|
{ target_group_id: 8 },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CloudflareService.batchMoveMembers(7, [9, 10], 8);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/batch-move',
|
||||||
|
{ member_ids: [9, 10], target_group_id: 8 },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
|
||||||
|
await CloudflareService.batchRemoveMembers(7, [9, 10]);
|
||||||
|
expect(apiClient.post).toHaveBeenCalledWith(
|
||||||
|
'/api/v1/d/cloudflare/groups/7/members/batch-remove',
|
||||||
|
{ member_ids: [9, 10] },
|
||||||
|
undefined,
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -19,4 +19,7 @@ const (
|
|||||||
errSyncFailed = "Cloudflare DNS 同步失败"
|
errSyncFailed = "Cloudflare DNS 同步失败"
|
||||||
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
errDeleteRemoteFailed = "删除 Cloudflare DNS 记录失败"
|
||||||
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
errTaskDispatchFailed = "无法投递 Cloudflare 同步任务"
|
||||||
|
errTargetGroupSame = "目标分组不能为当前分组"
|
||||||
|
errTargetGroupInvalid = "目标分组不存在"
|
||||||
|
errNoMembersSelected = "未选择任何成员"
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -387,6 +387,114 @@ func RemoveMember(ctx context.Context, groupID, memberID uint) error {
|
|||||||
return repository.DeleteCFPointingMember(ctx, member)
|
return repository.DeleteCFPointingMember(ctx, member)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveMember transfers a member from sourceGroupID to targetGroupID.
|
||||||
|
func MoveMember(ctx context.Context, sourceGroupID, memberID, targetGroupID uint) (*MemberItem, error) {
|
||||||
|
if targetGroupID == 0 || targetGroupID == sourceGroupID {
|
||||||
|
return nil, errors.New(errTargetGroupSame)
|
||||||
|
}
|
||||||
|
targetGroup, err := repository.GetCFPointingGroup(ctx, targetGroupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return nil, errors.New(errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
member.GroupID = targetGroupID
|
||||||
|
member.SyncStatus = model.CFMemberSyncPending
|
||||||
|
member.LastError = ""
|
||||||
|
if err = repository.SaveCFPointingMember(ctx, member); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if targetGroup.Enabled {
|
||||||
|
if _, err = DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); err != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] dispatch move sync failed: member_id=%d error=%v", member.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
domain, err := repository.GetZoneDomainByID(ctx, member.ZoneDomainID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return memberItem(member, domain), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMoveMembers transfers multiple members from sourceGroupID to targetGroupID.
|
||||||
|
func BatchMoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchMoveInput) error {
|
||||||
|
if len(input.MemberIDs) == 0 {
|
||||||
|
return errors.New(errNoMembersSelected)
|
||||||
|
}
|
||||||
|
if input.TargetGroupID == 0 || input.TargetGroupID == sourceGroupID {
|
||||||
|
return errors.New(errTargetGroupSame)
|
||||||
|
}
|
||||||
|
targetGroup, err := repository.GetCFPointingGroup(ctx, input.TargetGroupID)
|
||||||
|
if err != nil {
|
||||||
|
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||||
|
return errors.New(errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, memberID := range uniqueIDs(input.MemberIDs) {
|
||||||
|
member, getErr := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if getErr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
member.GroupID = input.TargetGroupID
|
||||||
|
member.SyncStatus = model.CFMemberSyncPending
|
||||||
|
member.LastError = ""
|
||||||
|
if saveErr := repository.SaveCFPointingMember(ctx, member); saveErr != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] batch move save member failed: member_id=%d error=%v", memberID, saveErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if targetGroup.Enabled {
|
||||||
|
if _, syncErr := DispatchMemberSync(ctx, member.ID, "cloudflare_member_move"); syncErr != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] dispatch batch move sync failed: member_id=%d error=%v", member.ID, syncErr)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchRemoveMembers deletes multiple members and their remote A records.
|
||||||
|
func BatchRemoveMembers(ctx context.Context, sourceGroupID uint, input MemberBatchRemoveInput) error {
|
||||||
|
if len(input.MemberIDs) == 0 {
|
||||||
|
return errors.New(errNoMembersSelected)
|
||||||
|
}
|
||||||
|
for _, memberID := range uniqueIDs(input.MemberIDs) {
|
||||||
|
member, err := repository.GetCFPointingMember(ctx, sourceGroupID, memberID)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if delErr := DeleteManagedRecord(ctx, member.ID); delErr != nil {
|
||||||
|
logger.WarnF(ctx, "[Cloudflare] delete remote record failed during batch remove: member_id=%d error=%v", member.ID, delErr)
|
||||||
|
}
|
||||||
|
if err = repository.DeleteCFPointingMember(ctx, member); err != nil {
|
||||||
|
logger.ErrorF(ctx, "[Cloudflare] delete member failed during batch remove: member_id=%d error=%v", member.ID, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniqueIDs(ids []uint) []uint {
|
||||||
|
if len(ids) == 0 {
|
||||||
|
return ids
|
||||||
|
}
|
||||||
|
seen := make(map[uint]struct{}, len(ids))
|
||||||
|
result := make([]uint, 0, len(ids))
|
||||||
|
for _, id := range ids {
|
||||||
|
if id == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := seen[id]; !exists {
|
||||||
|
seen[id] = struct{}{}
|
||||||
|
result = append(result, id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
// DeleteGroup removes every managed remote A record and then local state.
|
// DeleteGroup removes every managed remote A record and then local state.
|
||||||
func DeleteGroup(ctx context.Context, groupID uint) error {
|
func DeleteGroup(ctx context.Context, groupID uint) error {
|
||||||
if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil {
|
if _, err := repository.GetCFPointingGroup(ctx, groupID); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,193 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package cloudflare
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
db "github.com/Rain-kl/Wavelet/internal/infra/persistence"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/model"
|
||||||
|
"github.com/Rain-kl/Wavelet/internal/repository"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMoveMemberAndBatchOperations(t *testing.T) {
|
||||||
|
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||||
|
|
||||||
|
var dispatchedTasks []string
|
||||||
|
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||||
|
dispatchedTasks = append(dispatchedTasks, triggeredBy)
|
||||||
|
return "mock-task-id", nil
|
||||||
|
})
|
||||||
|
t.Cleanup(restoreDispatch)
|
||||||
|
|
||||||
|
fake := &fakeClient{}
|
||||||
|
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restoreClient)
|
||||||
|
|
||||||
|
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
sourceGroupID := member1.GroupID
|
||||||
|
|
||||||
|
// Create target group
|
||||||
|
targetGroup := model.CFPointingGroup{
|
||||||
|
Name: "secondary",
|
||||||
|
PrimaryNodeID: 1,
|
||||||
|
ActiveNodeID: 1,
|
||||||
|
DefaultProxied: true,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||||
|
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Target group equals source group -> errTargetGroupSame
|
||||||
|
t.Run("MoveMember target equals source", func(t *testing.T) {
|
||||||
|
_, err := MoveMember(ctx, sourceGroupID, member1.ID, sourceGroupID)
|
||||||
|
if err == nil || err.Error() != errTargetGroupSame {
|
||||||
|
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupSame)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 2. Target group does not exist -> errTargetGroupInvalid
|
||||||
|
t.Run("MoveMember target group invalid", func(t *testing.T) {
|
||||||
|
_, err := MoveMember(ctx, sourceGroupID, member1.ID, 99999)
|
||||||
|
if err == nil || err.Error() != errTargetGroupInvalid {
|
||||||
|
t.Fatalf("MoveMember() error = %v, want %s", err, errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 3. Successfully move member to target group
|
||||||
|
t.Run("MoveMember success", func(t *testing.T) {
|
||||||
|
item, err := MoveMember(ctx, sourceGroupID, member1.ID, targetGroup.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("MoveMember() error = %v", err)
|
||||||
|
}
|
||||||
|
if item == nil || item.GroupID != targetGroup.ID {
|
||||||
|
t.Fatalf("MoveMember() returned item group ID = %v, want %d", item, targetGroup.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := repository.GetCFPointingMemberByID(ctx, member1.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
if updated.GroupID != targetGroup.ID {
|
||||||
|
t.Errorf("member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
|
||||||
|
}
|
||||||
|
if updated.SyncStatus != model.CFMemberSyncPending {
|
||||||
|
t.Errorf("member SyncStatus = %s, want %s", updated.SyncStatus, model.CFMemberSyncPending)
|
||||||
|
}
|
||||||
|
if updated.LastError != "" {
|
||||||
|
t.Errorf("member LastError = %q, want empty", updated.LastError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 4. Batch move multiple members
|
||||||
|
t.Run("BatchMoveMembers", func(t *testing.T) {
|
||||||
|
// Test empty members error
|
||||||
|
emptyErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: nil, TargetGroupID: targetGroup.ID})
|
||||||
|
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
|
||||||
|
t.Fatalf("BatchMoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test target equals source error
|
||||||
|
sameErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: sourceGroupID})
|
||||||
|
if sameErr == nil || sameErr.Error() != errTargetGroupSame {
|
||||||
|
t.Fatalf("BatchMoveMembers() same group error = %v, want %s", sameErr, errTargetGroupSame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test invalid target group error
|
||||||
|
invalidErr := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{MemberIDs: []uint{1}, TargetGroupID: 99999})
|
||||||
|
if invalidErr == nil || invalidErr.Error() != errTargetGroupInvalid {
|
||||||
|
t.Fatalf("BatchMoveMembers() invalid group error = %v, want %s", invalidErr, errTargetGroupInvalid)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create 2 additional members in sourceGroup
|
||||||
|
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "test2.example.com"}
|
||||||
|
domain3 := model.ZoneDomain{ZoneID: 1, Domain: "test3.example.com"}
|
||||||
|
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain2) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&domain3).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain3) error = %v", err)
|
||||||
|
}
|
||||||
|
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
member3 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain3.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member2) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&member3).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member3) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform batch move
|
||||||
|
if err := BatchMoveMembers(ctx, sourceGroupID, MemberBatchMoveInput{
|
||||||
|
MemberIDs: []uint{member2.ID, member3.ID},
|
||||||
|
TargetGroupID: targetGroup.ID,
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("BatchMoveMembers() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify updated in DB
|
||||||
|
for _, mid := range []uint{member2.ID, member3.ID} {
|
||||||
|
m, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err)
|
||||||
|
}
|
||||||
|
if m.GroupID != targetGroup.ID {
|
||||||
|
t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID)
|
||||||
|
}
|
||||||
|
if m.SyncStatus != model.CFMemberSyncPending {
|
||||||
|
t.Errorf("member %d SyncStatus = %s, want %s", mid, m.SyncStatus, model.CFMemberSyncPending)
|
||||||
|
}
|
||||||
|
if m.LastError != "" {
|
||||||
|
t.Errorf("member %d LastError = %q, want empty", mid, m.LastError)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// 5. Batch remove members
|
||||||
|
t.Run("BatchRemoveMembers", func(t *testing.T) {
|
||||||
|
// Test empty members error
|
||||||
|
emptyErr := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{MemberIDs: nil})
|
||||||
|
if emptyErr == nil || emptyErr.Error() != errNoMembersSelected {
|
||||||
|
t.Fatalf("BatchRemoveMembers() empty error = %v, want %s", emptyErr, errNoMembersSelected)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create members to remove
|
||||||
|
domain4 := model.ZoneDomain{ZoneID: 1, Domain: "test4.example.com"}
|
||||||
|
domain5 := model.ZoneDomain{ZoneID: 1, Domain: "test5.example.com"}
|
||||||
|
if err := db.DB(ctx).Create(&domain4).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain4) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&domain5).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain5) error = %v", err)
|
||||||
|
}
|
||||||
|
member4 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain4.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
member5 := model.CFPointingMember{GroupID: targetGroup.ID, ZoneDomainID: domain5.ID, Proxied: true, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
if err := db.DB(ctx).Create(&member4).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member4) error = %v", err)
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&member5).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member5) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform batch remove
|
||||||
|
if err := BatchRemoveMembers(ctx, targetGroup.ID, MemberBatchRemoveInput{
|
||||||
|
MemberIDs: []uint{member4.ID, member5.ID},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("BatchRemoveMembers() error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify deleted from DB
|
||||||
|
for _, mid := range []uint{member4.ID, member5.ID} {
|
||||||
|
_, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("GetCFPointingMemberByID(%d) should be deleted, but found", mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -358,6 +358,88 @@ func SyncMemberHandler(c *gin.Context) {
|
|||||||
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
|
c.JSON(http.StatusOK, response.OK(&SyncReceipt{TaskID: taskID}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MoveMemberHandler moves a member to a target group.
|
||||||
|
// @Summary 移动 Cloudflare 指向成员到其他分组
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "原分组 ID"
|
||||||
|
// @Param memberId path int true "成员 ID"
|
||||||
|
// @Param body body cloudflare.MemberMoveInput true "目标分组参数"
|
||||||
|
// @Success 200 {object} response.Any{data=cloudflare.MemberItem}
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/{memberId}/move [post]
|
||||||
|
func MoveMemberHandler(c *gin.Context) {
|
||||||
|
groupID, memberID, ok := memberParams(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberMoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
item, err := MoveMember(c.Request.Context(), groupID, memberID, input.TargetGroupID)
|
||||||
|
if abortLogic(c, err) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OK(item))
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchMoveMembersHandler moves multiple members to a target group.
|
||||||
|
// @Summary 批量移动 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "原分组 ID"
|
||||||
|
// @Param body body cloudflare.MemberBatchMoveInput true "批量移动参数"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Failure 404 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-move [post]
|
||||||
|
func BatchMoveMembersHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberBatchMoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, BatchMoveMembers(c.Request.Context(), id, input)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
|
// BatchRemoveMembersHandler removes multiple members.
|
||||||
|
// @Summary 批量移出 Cloudflare 指向成员
|
||||||
|
// @Tags openflare-cloudflare
|
||||||
|
// @Accept json
|
||||||
|
// @Produce json
|
||||||
|
// @Security SessionCookie
|
||||||
|
// @Param id path int true "分组 ID"
|
||||||
|
// @Param body body cloudflare.MemberBatchRemoveInput true "批量移出参数"
|
||||||
|
// @Success 200 {object} response.Any
|
||||||
|
// @Failure 400 {object} response.Any
|
||||||
|
// @Router /api/v1/d/cloudflare/groups/{id}/members/batch-remove [post]
|
||||||
|
func BatchRemoveMembersHandler(c *gin.Context) {
|
||||||
|
id, ok := apiutil.IDParam(c)
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var input MemberBatchRemoveInput
|
||||||
|
if !apiutil.BindJSON(c, &input) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if abortLogic(c, BatchRemoveMembers(c.Request.Context(), id, input)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, response.OKNil())
|
||||||
|
}
|
||||||
|
|
||||||
// ListAvailableDomainsHandler lists ZoneDomains not assigned to another group.
|
// ListAvailableDomainsHandler lists ZoneDomains not assigned to another group.
|
||||||
// @Summary 获取可加入 Cloudflare 指向的域名
|
// @Summary 获取可加入 Cloudflare 指向的域名
|
||||||
// @Tags openflare-cloudflare
|
// @Tags openflare-cloudflare
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
package cloudflare
|
package cloudflare
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
@@ -130,3 +131,268 @@ func TestGetGroupHandlerWithMissingNodeStillSucceeds(t *testing.T) {
|
|||||||
t.Fatalf("GetGroupHandler body = %s, want group KR", recorder.Body.String())
|
t.Fatalf("GetGroupHandler body = %s, want group KR", recorder.Body.String())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRoutersMoveMemberHandler(t *testing.T) {
|
||||||
|
ctx, memberID := setupCloudflareLogicDB(t)
|
||||||
|
|
||||||
|
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||||
|
return "mock-task-id", nil
|
||||||
|
})
|
||||||
|
t.Cleanup(restoreDispatch)
|
||||||
|
|
||||||
|
fake := &fakeClient{}
|
||||||
|
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restoreClient)
|
||||||
|
|
||||||
|
member, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
sourceGroupID := member.GroupID
|
||||||
|
|
||||||
|
targetGroup := model.CFPointingGroup{
|
||||||
|
Name: "target-group",
|
||||||
|
PrimaryNodeID: 1,
|
||||||
|
ActiveNodeID: 1,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||||
|
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(response.ErrorHandlerMiddleware())
|
||||||
|
router.POST("/groups/:id/members/:memberId/move", MoveMemberHandler)
|
||||||
|
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", sourceGroupID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||||
|
}
|
||||||
|
if !strings.Contains(rec.Body.String(), fmt.Sprintf(`"group_id":%d`, targetGroup.ID)) {
|
||||||
|
t.Fatalf("MoveMemberHandler body = %s, want group_id %d", rec.Body.String(), targetGroup.ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
updated, err := repository.GetCFPointingMemberByID(ctx, memberID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
if updated.GroupID != targetGroup.ID {
|
||||||
|
t.Errorf("updated member GroupID = %d, want %d", updated.GroupID, targetGroup.ID)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidTargetGroupSameAsSource", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(fmt.Sprintf(`{"target_group_id":%d}`, targetGroup.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidTargetGroupNonExistent", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader(`{"target_group_id":99999}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidParams", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/groups/abc/members/1/move", strings.NewReader(`{"target_group_id":1}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidBody", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/%d/move", targetGroup.ID, memberID), strings.NewReader("invalid json"))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("MoveMemberHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutersBatchMoveMembersHandler(t *testing.T) {
|
||||||
|
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||||
|
|
||||||
|
restoreDispatch := SetDispatchTaskForTest(func(ctx context.Context, taskType string, payload []byte, triggeredBy string) (string, error) {
|
||||||
|
return "mock-task-id", nil
|
||||||
|
})
|
||||||
|
t.Cleanup(restoreDispatch)
|
||||||
|
|
||||||
|
fake := &fakeClient{}
|
||||||
|
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restoreClient)
|
||||||
|
|
||||||
|
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
sourceGroupID := member1.GroupID
|
||||||
|
|
||||||
|
targetGroup := model.CFPointingGroup{
|
||||||
|
Name: "batch-move-target",
|
||||||
|
PrimaryNodeID: 1,
|
||||||
|
ActiveNodeID: 1,
|
||||||
|
Enabled: true,
|
||||||
|
}
|
||||||
|
if err := db.DB(ctx).Create(&targetGroup).Error; err != nil {
|
||||||
|
t.Fatalf("Create(targetGroup) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "bm2.example.com"}
|
||||||
|
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain2) error = %v", err)
|
||||||
|
}
|
||||||
|
member2 := model.CFPointingMember{GroupID: sourceGroupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member2) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(response.ErrorHandlerMiddleware())
|
||||||
|
router.POST("/groups/:id/members/batch-move", BatchMoveMembersHandler)
|
||||||
|
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d],"target_group_id":%d}`, member1.ID, member2.ID, targetGroup.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range []uint{member1.ID, member2.ID} {
|
||||||
|
m, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID(%d) error = %v", mid, err)
|
||||||
|
}
|
||||||
|
if m.GroupID != targetGroup.ID {
|
||||||
|
t.Errorf("member %d GroupID = %d, want %d", mid, m.GroupID, targetGroup.ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyMemberIDs", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", sourceGroupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[],"target_group_id":%d}`, targetGroup.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("TargetGroupSame", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-move", targetGroup.ID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d],"target_group_id":%d}`, member1.ID, targetGroup.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidParams", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/groups/xyz/members/batch-move", strings.NewReader(`{"member_ids":[1],"target_group_id":2}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("BatchMoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRoutersBatchRemoveMembersHandler(t *testing.T) {
|
||||||
|
ctx, member1ID := setupCloudflareLogicDB(t)
|
||||||
|
|
||||||
|
fake := &fakeClient{}
|
||||||
|
restoreClient := SetClientFactoryForTest(func(string) Client { return fake })
|
||||||
|
t.Cleanup(restoreClient)
|
||||||
|
|
||||||
|
member1, err := repository.GetCFPointingMemberByID(ctx, member1ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetCFPointingMemberByID() error = %v", err)
|
||||||
|
}
|
||||||
|
groupID := member1.GroupID
|
||||||
|
|
||||||
|
domain2 := model.ZoneDomain{ZoneID: 1, Domain: "br2.example.com"}
|
||||||
|
if err := db.DB(ctx).Create(&domain2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(domain2) error = %v", err)
|
||||||
|
}
|
||||||
|
member2 := model.CFPointingMember{GroupID: groupID, ZoneDomainID: domain2.ID, Proxied: false, SyncStatus: model.CFMemberSyncOK}
|
||||||
|
if err := db.DB(ctx).Create(&member2).Error; err != nil {
|
||||||
|
t.Fatalf("Create(member2) error = %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
gin.SetMode(gin.TestMode)
|
||||||
|
router := gin.New()
|
||||||
|
router.Use(response.ErrorHandlerMiddleware())
|
||||||
|
router.POST("/groups/:id/members/batch-remove", BatchRemoveMembersHandler)
|
||||||
|
|
||||||
|
t.Run("Success", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(fmt.Sprintf(`{"member_ids":[%d,%d]}`, member1.ID, member2.ID)))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusOK {
|
||||||
|
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusOK)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, mid := range []uint{member1.ID, member2.ID} {
|
||||||
|
_, err := repository.GetCFPointingMemberByID(ctx, mid)
|
||||||
|
if err == nil {
|
||||||
|
t.Errorf("member %d should have been deleted, but still found in DB", mid)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyMemberIDs", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, fmt.Sprintf("/groups/%d/members/batch-remove", groupID), strings.NewReader(`{"member_ids":[]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("InvalidParams", func(t *testing.T) {
|
||||||
|
rec := httptest.NewRecorder()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/groups/invalid/members/batch-remove", strings.NewReader(`{"member_ids":[1]}`))
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
router.ServeHTTP(rec, req)
|
||||||
|
|
||||||
|
if rec.Code != http.StatusBadRequest {
|
||||||
|
t.Fatalf("BatchRemoveMembersHandler status = %d, body = %s, want %d", rec.Code, rec.Body.String(), http.StatusBadRequest)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -63,6 +63,22 @@ type MemberUpdateInput struct {
|
|||||||
Proxied bool `json:"proxied"`
|
Proxied bool `json:"proxied"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// MemberMoveInput contains the target group ID for moving a member.
|
||||||
|
type MemberMoveInput struct {
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberBatchMoveInput contains the member IDs and target group ID for batch moving.
|
||||||
|
type MemberBatchMoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
TargetGroupID uint `json:"target_group_id"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// MemberBatchRemoveInput contains the member IDs for batch deletion.
|
||||||
|
type MemberBatchRemoveInput struct {
|
||||||
|
MemberIDs []uint `json:"member_ids"`
|
||||||
|
}
|
||||||
|
|
||||||
// MemberItem is the admin-facing member state.
|
// MemberItem is the admin-facing member state.
|
||||||
type MemberItem struct {
|
type MemberItem struct {
|
||||||
ID uint `json:"id"`
|
ID uint `json:"id"`
|
||||||
|
|||||||
@@ -91,30 +91,32 @@ func (b *localBackend) Test(_ context.Context) error {
|
|||||||
return os.MkdirAll(b.root, storageDirPerm)
|
return os.MkdirAll(b.root, storageDirPerm)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isWithinRoot(root, target string) bool {
|
||||||
|
absRoot, err := filepath.Abs(root)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
absTarget, err := filepath.Abs(target)
|
||||||
|
if err != nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
rel, err := filepath.Rel(absRoot, absTarget)
|
||||||
|
return err == nil && !strings.HasPrefix(rel, "..")
|
||||||
|
}
|
||||||
|
|
||||||
func (b *localBackend) path(key string) (string, error) {
|
func (b *localBackend) path(key string) (string, error) {
|
||||||
if filepath.IsAbs(key) {
|
if filepath.IsAbs(key) {
|
||||||
cleanPath := filepath.Clean(key)
|
cleanPath := filepath.Clean(key)
|
||||||
absRoot, err := filepath.Abs(b.root)
|
if isWithinRoot(b.root, cleanPath) {
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
absPath, err := filepath.Abs(cleanPath)
|
|
||||||
if err != nil {
|
|
||||||
return "", err
|
|
||||||
}
|
|
||||||
rel, err := filepath.Rel(absRoot, absPath)
|
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
|
||||||
return "", errors.New("storage key escapes local root")
|
|
||||||
}
|
|
||||||
return cleanPath, nil
|
return cleanPath, nil
|
||||||
}
|
}
|
||||||
|
}
|
||||||
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/")))
|
cleanKey := filepath.Clean(filepath.FromSlash(strings.TrimPrefix(key, "/")))
|
||||||
if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") {
|
if cleanKey == "." || cleanKey == "" || strings.HasPrefix(cleanKey, "..") {
|
||||||
return "", fmt.Errorf("invalid local storage key %q", key)
|
return "", fmt.Errorf("invalid local storage key %q", key)
|
||||||
}
|
}
|
||||||
path := filepath.Join(b.root, cleanKey)
|
path := filepath.Join(b.root, cleanKey)
|
||||||
rel, err := filepath.Rel(b.root, path)
|
if !isWithinRoot(b.root, path) {
|
||||||
if err != nil || strings.HasPrefix(rel, "..") {
|
|
||||||
return "", errors.New("storage key escapes local root")
|
return "", errors.New("storage key escapes local root")
|
||||||
}
|
}
|
||||||
return path, nil
|
return path, nil
|
||||||
|
|||||||
@@ -48,4 +48,19 @@ func TestLocalBackendRoundTrip(t *testing.T) {
|
|||||||
if _, err := backend.Get(ctx, key); err == nil {
|
if _, err := backend.Get(ctx, key); err == nil {
|
||||||
t.Errorf("Get(%q) after Delete() returned nil error", key)
|
t.Errorf("Get(%q) after Delete() returned nil error", key)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Test key with leading slash
|
||||||
|
slashKey := "/uploads/2026/06/13/slash_test.txt"
|
||||||
|
putSlashRes, err := backend.Put(ctx, slashKey, bytes.NewBufferString(content), int64(len(content)), "text/plain")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Put(%q) returned error: %v", slashKey, err)
|
||||||
|
}
|
||||||
|
if putSlashRes.Key != slashKey {
|
||||||
|
t.Errorf("Put(%q) key = %q, want %q", slashKey, putSlashRes.Key, slashKey)
|
||||||
|
}
|
||||||
|
objSlash, err := backend.Get(ctx, slashKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Get(%q) returned error: %v", slashKey, err)
|
||||||
|
}
|
||||||
|
_ = objSlash.Body.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,11 +32,15 @@ type webDAVBackend struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) {
|
func newWebDAVBackend(cfg WebDAVConfig) (*webDAVBackend, error) {
|
||||||
|
basePath := strings.Trim(path.Clean("/"+cfg.BasePath), "/")
|
||||||
|
if basePath == "." {
|
||||||
|
basePath = ""
|
||||||
|
}
|
||||||
return &webDAVBackend{
|
return &webDAVBackend{
|
||||||
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
endpoint: strings.TrimRight(cfg.Endpoint, "/"),
|
||||||
username: cfg.Username,
|
username: cfg.Username,
|
||||||
password: cfg.Password,
|
password: cfg.Password,
|
||||||
basePath: strings.Trim(cfg.BasePath, "/"),
|
basePath: basePath,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -50,27 +54,27 @@ func (b *webDAVBackend) newClient(ctx context.Context) *gowebdav.Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) {
|
func (b *webDAVBackend) Put(ctx context.Context, key string, body io.Reader, size int64, _ string) (PutResult, error) {
|
||||||
key = b.key(key)
|
target := b.targetPath(key)
|
||||||
client := b.newClient(ctx)
|
client := b.newClient(ctx)
|
||||||
if dir := path.Dir(key); dir != "." && dir != "/" {
|
if dir := path.Dir(target); dir != "." && dir != "/" {
|
||||||
if err := client.MkdirAll(dir, storageDirPerm); err != nil {
|
if err := client.MkdirAll(dir, storageDirPerm); err != nil {
|
||||||
return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err)
|
return PutResult{}, fmt.Errorf("create WebDAV directory: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if err := client.WriteStreamWithLength(key, body, size, storageFilePerm); err != nil {
|
if err := client.WriteStreamWithLength(target, body, size, storageFilePerm); err != nil {
|
||||||
return PutResult{}, fmt.Errorf("put WebDAV object: %w", err)
|
return PutResult{}, fmt.Errorf("put WebDAV object: %w", err)
|
||||||
}
|
}
|
||||||
return PutResult{Key: key}, nil
|
return PutResult{Key: b.relKey(key)}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
|
func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
|
||||||
key = b.key(key)
|
target := b.targetPath(key)
|
||||||
client := b.newClient(ctx)
|
client := b.newClient(ctx)
|
||||||
info, err := client.Stat(key)
|
info, err := client.Stat(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("stat WebDAV object: %w", err)
|
return nil, fmt.Errorf("stat WebDAV object: %w", err)
|
||||||
}
|
}
|
||||||
body, err := client.ReadStream(key)
|
body, err := client.ReadStream(target)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("get WebDAV object: %w", err)
|
return nil, fmt.Errorf("get WebDAV object: %w", err)
|
||||||
}
|
}
|
||||||
@@ -83,7 +87,7 @@ func (b *webDAVBackend) Get(ctx context.Context, key string) (*Object, error) {
|
|||||||
|
|
||||||
func (b *webDAVBackend) Delete(ctx context.Context, key string) error {
|
func (b *webDAVBackend) Delete(ctx context.Context, key string) error {
|
||||||
client := b.newClient(ctx)
|
client := b.newClient(ctx)
|
||||||
if err := client.Remove(b.key(key)); err != nil {
|
if err := client.Remove(b.targetPath(key)); err != nil {
|
||||||
return fmt.Errorf("delete WebDAV object: %w", err)
|
return fmt.Errorf("delete WebDAV object: %w", err)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -97,6 +101,34 @@ func (b *webDAVBackend) Test(ctx context.Context) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *webDAVBackend) key(key string) string {
|
// relKey extracts the clean, normalized, relative logical key (e.g. "uploads/2026/09/17/xxx.jpg")
|
||||||
return "/" + path.Join(b.basePath, strings.TrimLeft(key, "/"))
|
// to be persisted in the database, stripping any driver-specific basePath and leading slashes.
|
||||||
|
func (b *webDAVBackend) relKey(key string) string {
|
||||||
|
cleanKey := strings.Trim(path.Clean("/"+strings.ReplaceAll(key, "\\", "/")), "/")
|
||||||
|
if cleanKey == "." {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if b.basePath != "" {
|
||||||
|
for cleanKey == b.basePath || strings.HasPrefix(cleanKey, b.basePath+"/") {
|
||||||
|
cleanKey = strings.TrimPrefix(cleanKey, b.basePath)
|
||||||
|
cleanKey = strings.TrimPrefix(cleanKey, "/")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cleanKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// targetPath resolves any key (relative, legacy with basePath, or corrupted with duplicate basePath)
|
||||||
|
// into the absolute path used to access the object on the WebDAV server.
|
||||||
|
func (b *webDAVBackend) targetPath(key string) string {
|
||||||
|
rel := b.relKey(key)
|
||||||
|
if b.basePath == "" {
|
||||||
|
if rel == "" {
|
||||||
|
return "/"
|
||||||
|
}
|
||||||
|
return "/" + rel
|
||||||
|
}
|
||||||
|
if rel == "" {
|
||||||
|
return "/" + b.basePath
|
||||||
|
}
|
||||||
|
return "/" + b.basePath + "/" + rel
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,257 @@
|
|||||||
|
// Copyright 2026 Arctel.net
|
||||||
|
// SPDX-License-Identifier: Apache-2.0
|
||||||
|
|
||||||
|
package objectstore
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"golang.org/x/net/webdav"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWebDAVTargetPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
basePath string
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "no base path, relative key",
|
||||||
|
basePath: "",
|
||||||
|
key: "uploads/2026/09/17/1.jpg",
|
||||||
|
expected: "/uploads/2026/09/17/1.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "no base path, leading slash key",
|
||||||
|
basePath: "",
|
||||||
|
key: "/uploads/2026/09/17/1.jpg",
|
||||||
|
expected: "/uploads/2026/09/17/1.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path, relative key (uploading)",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path, key with leading slash (uploading)",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path, key already has base path (reading from legacy DB)",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path without leading slash, key already has base path",
|
||||||
|
basePath: "DockerData/openflare_data_webdav",
|
||||||
|
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path with trailing slash, key already has base path",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav/",
|
||||||
|
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "with base path, key already has double base path from previous bug",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "key with similar prefix name that is not a directory match",
|
||||||
|
basePath: "/data",
|
||||||
|
key: "/data_backup/uploads/1.jpg",
|
||||||
|
expected: "/data/data_backup/uploads/1.jpg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||||
|
Endpoint: "http://127.0.0.1:5005",
|
||||||
|
BasePath: tt.basePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||||
|
}
|
||||||
|
actual := backend.targetPath(tt.key)
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("targetPath(%q) = %q, want %q", tt.key, actual, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebDAVRelKey(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
basePath string
|
||||||
|
key string
|
||||||
|
expected string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "relative key remains relative",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "leading slash key is stripped to relative",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "legacy key with basePath is stripped to relative",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "corrupted key with duplicate basePath is stripped to relative",
|
||||||
|
basePath: "/DockerData/openflare_data_webdav",
|
||||||
|
key: "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty basePath preserves relative key",
|
||||||
|
basePath: "",
|
||||||
|
key: "/uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
expected: "uploads/2026/09/17/105102954490499072.jpg",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||||
|
Endpoint: "http://127.0.0.1:5005",
|
||||||
|
BasePath: tt.basePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||||
|
}
|
||||||
|
actual := backend.relKey(tt.key)
|
||||||
|
if actual != tt.expected {
|
||||||
|
t.Errorf("relKey(%q) = %q, want %q", tt.key, actual, tt.expected)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWebDAVBackendRoundTrip(t *testing.T) {
|
||||||
|
wdHandler := &webdav.Handler{
|
||||||
|
FileSystem: webdav.NewMemFS(),
|
||||||
|
LockSystem: webdav.NewMemLS(),
|
||||||
|
}
|
||||||
|
server := httptest.NewServer(wdHandler)
|
||||||
|
defer server.Close()
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
basePath := "/DockerData/openflare_data_webdav"
|
||||||
|
backend, err := newWebDAVBackend(WebDAVConfig{
|
||||||
|
Endpoint: server.URL,
|
||||||
|
BasePath: basePath,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("newWebDAVBackend failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 1. Test connection
|
||||||
|
if err := backend.Test(ctx); err != nil {
|
||||||
|
t.Fatalf("backend.Test failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Put object using relative key (typical upload flow)
|
||||||
|
origContent := []byte("test image content 12345")
|
||||||
|
objectKey := "uploads/2026/09/17/105102954490499072.jpg"
|
||||||
|
putRes, err := backend.Put(ctx, objectKey, bytes.NewReader(origContent), int64(len(origContent)), "image/jpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backend.Put failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PutResult.Key MUST be the pure logical relative key, decoupled from basePath
|
||||||
|
expectedLogicalKey := "uploads/2026/09/17/105102954490499072.jpg"
|
||||||
|
if putRes.Key != expectedLogicalKey {
|
||||||
|
t.Errorf("putRes.Key = %q, want %q", putRes.Key, expectedLogicalKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. Get object using logical key (new standard upload flow)
|
||||||
|
obj, err := backend.Get(ctx, putRes.Key)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backend.Get with putRes.Key failed: %v", err)
|
||||||
|
}
|
||||||
|
defer obj.Body.Close()
|
||||||
|
|
||||||
|
bodyBytes, err := io.ReadAll(obj.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(bodyBytes, origContent) {
|
||||||
|
t.Errorf("read content = %q, want %q", string(bodyBytes), string(origContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 4. Get object using legacy key containing basePath (existing DB records from before fix)
|
||||||
|
legacyKey := "/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg"
|
||||||
|
obj2, err := backend.Get(ctx, legacyKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backend.Get with legacyKey failed: %v", err)
|
||||||
|
}
|
||||||
|
defer obj2.Body.Close()
|
||||||
|
bodyBytes2, err := io.ReadAll(obj2.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(bodyBytes2, origContent) {
|
||||||
|
t.Errorf("read content = %q, want %q", string(bodyBytes2), string(origContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 5. Get object using accidental double basePath (defensive recovery for corrupted DB records)
|
||||||
|
doubledKey := "/DockerData/openflare_data_webdav/DockerData/openflare_data_webdav/uploads/2026/09/17/105102954490499072.jpg"
|
||||||
|
obj3, err := backend.Get(ctx, doubledKey)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("backend.Get with doubledKey failed: %v", err)
|
||||||
|
}
|
||||||
|
defer obj3.Body.Close()
|
||||||
|
bodyBytes3, err := io.ReadAll(obj3.Body)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read body failed: %v", err)
|
||||||
|
}
|
||||||
|
if !bytes.Equal(bodyBytes3, origContent) {
|
||||||
|
t.Errorf("read content = %q, want %q", string(bodyBytes3), string(origContent))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 6. Delete object using logical key
|
||||||
|
if err := backend.Delete(ctx, putRes.Key); err != nil {
|
||||||
|
t.Fatalf("backend.Delete failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 7. Verify object is deleted
|
||||||
|
_, err = backend.Get(ctx, putRes.Key)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error after delete, got nil")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 8. Put again, and delete using legacy key format
|
||||||
|
_, err = backend.Put(ctx, objectKey, bytes.NewReader(origContent), int64(len(origContent)), "image/jpeg")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("second backend.Put failed: %v", err)
|
||||||
|
}
|
||||||
|
if err := backend.Delete(ctx, legacyKey); err != nil {
|
||||||
|
t.Fatalf("backend.Delete with legacyKey failed: %v", err)
|
||||||
|
}
|
||||||
|
_, err = backend.Get(ctx, objectKey)
|
||||||
|
if err == nil {
|
||||||
|
t.Fatalf("expected error after delete with legacyKey, got nil")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,6 +28,9 @@ func registerCloudflareRoutes(apiGroup *gin.RouterGroup) {
|
|||||||
route.GET("/groups/:id/members", cf.ListMembersHandler)
|
route.GET("/groups/:id/members", cf.ListMembersHandler)
|
||||||
route.POST("/groups/:id/members", cf.CreateMemberHandler)
|
route.POST("/groups/:id/members", cf.CreateMemberHandler)
|
||||||
route.POST("/groups/:id/members/:memberId/update", cf.UpdateMemberHandler)
|
route.POST("/groups/:id/members/:memberId/update", cf.UpdateMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/:memberId/move", cf.MoveMemberHandler)
|
||||||
route.POST("/groups/:id/members/:memberId/remove", cf.RemoveMemberHandler)
|
route.POST("/groups/:id/members/:memberId/remove", cf.RemoveMemberHandler)
|
||||||
route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler)
|
route.POST("/groups/:id/members/:memberId/sync", cf.SyncMemberHandler)
|
||||||
|
route.POST("/groups/:id/members/batch-move", cf.BatchMoveMembersHandler)
|
||||||
|
route.POST("/groups/:id/members/batch-remove", cf.BatchRemoveMembersHandler)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user