mirror of
https://github.com/Sagit-chu/flvx.git
synced 2026-10-02 08:56:38 +08:00
feat(batch): add failure details to batch operations with expandable result modal (#312)
## Summary - Backend: Return per-item failure details (id, name, reason) for all batch operations (delete/pause/resume/redeploy/change-tunnel) on forwards and tunnels - Frontend: Add `BatchActionResultModal` component to display failures in an expandable list - Add contract tests for batch action failure details
This commit is contained in:
@@ -1096,16 +1096,24 @@ func (h *Handler) tunnelBatchDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
success := 0
|
success := 0
|
||||||
fail := 0
|
fail := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
|
tunnelName, _ := h.repo.GetTunnelName(id)
|
||||||
|
if _, err := h.getTunnelRecord(id); err != nil {
|
||||||
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
h.cleanupTunnelRuntime(id)
|
h.cleanupTunnelRuntime(id)
|
||||||
h.cleanupFederationRuntime(id)
|
h.cleanupFederationRuntime(id)
|
||||||
if err := h.deleteTunnelByID(id); err != nil {
|
if err := h.deleteTunnelByID(id); err != nil {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, tunnelName, err)
|
||||||
} else {
|
} else {
|
||||||
success++
|
success++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
|
func (h *Handler) reconstructTunnelState(tunnelID int64) (*tunnelCreateState, error) {
|
||||||
@@ -1241,6 +1249,60 @@ func (h *Handler) redeployTunnelAndForwards(tunnelID int64) error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type batchFailureDetail struct {
|
||||||
|
ID int64 `json:"id"`
|
||||||
|
Name string `json:"name,omitempty"`
|
||||||
|
Reason string `json:"reason"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchOperationResult struct {
|
||||||
|
SuccessCount int `json:"successCount"`
|
||||||
|
FailCount int `json:"failCount"`
|
||||||
|
Failures []batchFailureDetail `json:"failures,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBatchFailure(failures []batchFailureDetail, id int64, name string, err error) []batchFailureDetail {
|
||||||
|
reason := normalizeBatchFailureReason(errString(err))
|
||||||
|
if reason == "" {
|
||||||
|
reason = "未知错误"
|
||||||
|
}
|
||||||
|
return append(failures, batchFailureDetail{
|
||||||
|
ID: id,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
Reason: reason,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func appendBatchFailureReason(failures []batchFailureDetail, id int64, name, reason string) []batchFailureDetail {
|
||||||
|
normalized := normalizeBatchFailureReason(reason)
|
||||||
|
if normalized == "" {
|
||||||
|
normalized = "未知错误"
|
||||||
|
}
|
||||||
|
return append(failures, batchFailureDetail{
|
||||||
|
ID: id,
|
||||||
|
Name: strings.TrimSpace(name),
|
||||||
|
Reason: normalized,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func errString(err error) string {
|
||||||
|
if err == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return err.Error()
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeBatchFailureReason(reason string) string {
|
||||||
|
trimmed := strings.TrimSpace(reason)
|
||||||
|
if trimmed == "" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if strings.EqualFold(trimmed, errForwardNotFound.Error()) {
|
||||||
|
return "转发不存在"
|
||||||
|
}
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
ids := idsFromBody(r, w)
|
ids := idsFromBody(r, w)
|
||||||
if ids == nil {
|
if ids == nil {
|
||||||
@@ -1248,14 +1310,26 @@ func (h *Handler) tunnelBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
success := 0
|
success := 0
|
||||||
fail := 0
|
fail := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, tunnelID := range ids {
|
for _, tunnelID := range ids {
|
||||||
|
tunnel, tunnelErr := h.getTunnelRecord(tunnelID)
|
||||||
|
tunnelName := ""
|
||||||
|
if tunnelErr == nil && tunnel != nil {
|
||||||
|
tunnelName, _ = h.repo.GetTunnelName(tunnelID)
|
||||||
|
}
|
||||||
|
if tunnelErr != nil {
|
||||||
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, tunnelID, tunnelName, tunnelErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
if err := h.redeployTunnelAndForwards(tunnelID); err != nil {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, tunnelID, tunnelName, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
success++
|
success++
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) userTunnelAssign(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1806,23 +1880,27 @@ func (h *Handler) forwardBatchDelete(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s := 0
|
s := 0
|
||||||
f := 0
|
f := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, "", accessErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.controlForwardServices(forward, "DeleteService", true); err != nil {
|
if err := h.controlForwardServices(forward, "DeleteService", true); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.deleteForwardByID(id); err != nil {
|
if err := h.deleteForwardByID(id); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
} else {
|
} else {
|
||||||
s++
|
s++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1837,23 +1915,27 @@ func (h *Handler) forwardBatchPause(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s := 0
|
s := 0
|
||||||
f := 0
|
f := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, "", accessErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
|
if err := h.controlForwardServices(forward, "PauseService", false); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {
|
if err := h.repo.UpdateForwardStatus(id, 0, time.Now().UnixMilli()); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
} else {
|
} else {
|
||||||
s++
|
s++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1869,27 +1951,32 @@ func (h *Handler) forwardBatchResume(w http.ResponseWriter, r *http.Request) {
|
|||||||
s := 0
|
s := 0
|
||||||
f := 0
|
f := 0
|
||||||
now := time.Now().UnixMilli()
|
now := time.Now().UnixMilli()
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, "", accessErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
if err := h.ensureUserTunnelForwardAllowed(forward.UserID, forward.TunnelID, now); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
if err := h.controlForwardServices(forward, "ResumeService", false); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.repo.UpdateForwardStatus(id, 1, now); err != nil {
|
if err := h.repo.UpdateForwardStatus(id, 1, now); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
} else {
|
} else {
|
||||||
s++
|
s++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1904,19 +1991,22 @@ func (h *Handler) forwardBatchRedeploy(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
s := 0
|
s := 0
|
||||||
f := 0
|
f := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range ids {
|
for _, id := range ids {
|
||||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, "", accessErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
if err := h.syncForwardServices(forward, "UpdateService", true); err != nil {
|
||||||
f++
|
f++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
} else {
|
} else {
|
||||||
s++
|
s++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": s, "failCount": f}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: s, FailCount: f, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -1948,6 +2038,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
success := 0
|
success := 0
|
||||||
fail := 0
|
fail := 0
|
||||||
|
failures := make([]batchFailureDetail, 0)
|
||||||
for _, id := range req.ForwardIDs {
|
for _, id := range req.ForwardIDs {
|
||||||
if id <= 0 {
|
if id <= 0 {
|
||||||
continue
|
continue
|
||||||
@@ -1955,21 +2046,30 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
|||||||
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
forward, accessErr := h.ensureForwardAccessByActor(actorUserID, actorRole, id)
|
||||||
if accessErr != nil {
|
if accessErr != nil {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, "", accessErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if forward.TunnelID == req.TargetTunnelID {
|
if forward.TunnelID == req.TargetTunnelID {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailureReason(failures, id, forward.Name, "规则已在目标隧道中")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
oldPorts, listPortsErr := h.listForwardPorts(id)
|
oldPorts, listPortsErr := h.listForwardPorts(id)
|
||||||
if listPortsErr != nil {
|
if listPortsErr != nil {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, listPortsErr)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if len(oldPorts) == 0 {
|
||||||
|
fail++
|
||||||
|
failures = appendBatchFailureReason(failures, id, forward.Name, "转发入口端口不存在")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
oldNodeIDs := forwardPortNodeIDs(oldPorts)
|
oldNodeIDs := forwardPortNodeIDs(oldPorts)
|
||||||
port := h.repo.GetMinForwardPort(id)
|
port := h.repo.GetMinForwardPort(id)
|
||||||
if err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {
|
if err := h.repo.UpdateForwardTunnel(id, req.TargetTunnelID, time.Now().UnixMilli()); err != nil {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
p := 0
|
p := 0
|
||||||
@@ -1984,29 +2084,36 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
|||||||
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
|
removedNodeIDs := diffInt64s(oldNodeIDs, newNodeIDs)
|
||||||
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
|
keptNodeIDs := diffInt64s(oldNodeIDs, removedNodeIDs)
|
||||||
portRangeOk := true
|
portRangeOk := true
|
||||||
|
var portRangeErr error
|
||||||
for _, nid := range bctEntryNodes {
|
for _, nid := range bctEntryNodes {
|
||||||
nd, ndErr := h.getNodeRecord(nid)
|
nd, ndErr := h.getNodeRecord(nid)
|
||||||
if ndErr != nil {
|
if ndErr != nil {
|
||||||
|
portRangeErr = ndErr
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if validateRemoteNodePort(nd, p) != nil {
|
if validateErr := validateRemoteNodePort(nd, p); validateErr != nil {
|
||||||
portRangeOk = false
|
portRangeOk = false
|
||||||
|
portRangeErr = validateErr
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !portRangeOk {
|
if !portRangeOk {
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, portRangeErr)
|
||||||
|
h.rollbackForwardMutation(forward, oldPorts)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
|
if err := h.replaceForwardPorts(id, req.TargetTunnelID, p, ""); err != nil {
|
||||||
h.rollbackForwardMutation(forward, oldPorts)
|
h.rollbackForwardMutation(forward, oldPorts)
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
updatedForward, fetchErr := h.getForwardRecord(id)
|
updatedForward, fetchErr := h.getForwardRecord(id)
|
||||||
if fetchErr != nil {
|
if fetchErr != nil {
|
||||||
h.rollbackForwardMutation(forward, oldPorts)
|
h.rollbackForwardMutation(forward, oldPorts)
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, fetchErr)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(keptNodeIDs) > 0 {
|
if len(keptNodeIDs) > 0 {
|
||||||
@@ -2018,6 +2125,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
|||||||
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
|
if err := h.syncForwardServices(updatedForward, "UpdateService", true); err != nil {
|
||||||
h.rollbackForwardMutation(forward, oldPorts)
|
h.rollbackForwardMutation(forward, oldPorts)
|
||||||
fail++
|
fail++
|
||||||
|
failures = appendBatchFailure(failures, id, forward.Name, err)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if len(removedNodeIDs) > 0 {
|
if len(removedNodeIDs) > 0 {
|
||||||
@@ -2027,7 +2135,7 @@ func (h *Handler) forwardBatchChangeTunnel(w http.ResponseWriter, r *http.Reques
|
|||||||
}
|
}
|
||||||
success++
|
success++
|
||||||
}
|
}
|
||||||
response.WriteJSON(w, response.OK(map[string]interface{}{"successCount": success, "failCount": fail}))
|
response.WriteJSON(w, response.OK(batchOperationResult{SuccessCount: success, FailCount: fail, Failures: failures}))
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
func (h *Handler) speedLimitCreate(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -0,0 +1,202 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
"go-backend/internal/store/repo"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
|
||||||
|
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-delete", `{"ids":[999]}`)
|
||||||
|
result := mustBatchResult(t, out)
|
||||||
|
assertBatchFailureReasonContains(t, result, "转发不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardBatchPauseReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
|
||||||
|
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-pause", `{"ids":[999]}`)
|
||||||
|
result := mustBatchResult(t, out)
|
||||||
|
assertBatchFailureReasonContains(t, result, "转发不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardBatchResumeReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
|
||||||
|
Now: now,
|
||||||
|
TunnelName: "resume-detail-tunnel",
|
||||||
|
ForwardName: "resume-detail-forward",
|
||||||
|
CreateUserTunnel: true,
|
||||||
|
UserTunnelStatus: 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-resume", `{"ids":[`+jsonNumber(forwardID)+`]}`)
|
||||||
|
result := mustBatchResult(t, out)
|
||||||
|
assertBatchFailureNameAndReason(t, result, "resume-detail-forward", "该隧道已禁用")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestForwardBatchChangeTunnelReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
forwardID := seedForwardForBatchAction(t, repo, batchForwardSeedOptions{
|
||||||
|
Now: now,
|
||||||
|
TunnelName: "change-detail-tunnel",
|
||||||
|
ForwardName: "change-detail-forward",
|
||||||
|
})
|
||||||
|
tunnelID := mustQueryInt64(t, repo, `SELECT tunnel_id FROM forward WHERE id = ?`, forwardID)
|
||||||
|
|
||||||
|
payload := `{"forwardIds":[` + jsonNumber(forwardID) + `],"targetTunnelId":` + jsonNumber(tunnelID) + `}`
|
||||||
|
out := postBatchRequest(t, router, adminToken, "/api/v1/forward/batch-change-tunnel", payload)
|
||||||
|
result := mustBatchResult(t, out)
|
||||||
|
assertBatchFailureNameAndReason(t, result, "change-detail-forward", "规则已在目标隧道中")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelBatchDeleteReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, _ := setupContractRouter(t, secret)
|
||||||
|
adminToken := mustAdminToken(t, secret)
|
||||||
|
|
||||||
|
out := postBatchRequest(t, router, adminToken, "/api/v1/tunnel/batch-delete", `{"ids":[999]}`)
|
||||||
|
result := mustBatchResult(t, out)
|
||||||
|
assertBatchFailureReasonContains(t, result, "隧道不存在")
|
||||||
|
}
|
||||||
|
|
||||||
|
type batchForwardSeedOptions struct {
|
||||||
|
Now int64
|
||||||
|
TunnelName string
|
||||||
|
ForwardName string
|
||||||
|
CreateUserTunnel bool
|
||||||
|
UserTunnelStatus int
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustAdminToken(t *testing.T, secret string) string {
|
||||||
|
t.Helper()
|
||||||
|
token, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
return token
|
||||||
|
}
|
||||||
|
|
||||||
|
func postBatchRequest(t *testing.T, router http.Handler, token, path, payload string) response.R {
|
||||||
|
t.Helper()
|
||||||
|
req := httptest.NewRequest(http.MethodPost, path, bytes.NewBufferString(payload))
|
||||||
|
req.Header.Set("Authorization", token)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func mustBatchResult(t *testing.T, out response.R) map[string]interface{} {
|
||||||
|
t.Helper()
|
||||||
|
result, ok := out.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected map result, got %T", out.Data)
|
||||||
|
}
|
||||||
|
if int(result["failCount"].(float64)) != 1 {
|
||||||
|
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchFailureReasonContains(t *testing.T, result map[string]interface{}, snippet string) {
|
||||||
|
t.Helper()
|
||||||
|
failures, ok := result["failures"].([]interface{})
|
||||||
|
if !ok || len(failures) != 1 {
|
||||||
|
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||||
|
}
|
||||||
|
first, ok := failures[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||||
|
}
|
||||||
|
reason, _ := first["reason"].(string)
|
||||||
|
if !strings.Contains(reason, snippet) {
|
||||||
|
t.Fatalf("expected failure reason to contain %q, got %q", snippet, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertBatchFailureNameAndReason(t *testing.T, result map[string]interface{}, expectedName, reasonSnippet string) {
|
||||||
|
t.Helper()
|
||||||
|
failures, ok := result["failures"].([]interface{})
|
||||||
|
if !ok || len(failures) != 1 {
|
||||||
|
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||||
|
}
|
||||||
|
first, ok := failures[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||||
|
}
|
||||||
|
gotName, _ := first["name"].(string)
|
||||||
|
if strings.TrimSpace(gotName) != expectedName {
|
||||||
|
t.Fatalf("expected failure name %q, got %q", expectedName, gotName)
|
||||||
|
}
|
||||||
|
reason, _ := first["reason"].(string)
|
||||||
|
if !strings.Contains(reason, reasonSnippet) {
|
||||||
|
t.Fatalf("expected failure reason to contain %q, got %q", reasonSnippet, reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func seedForwardForBatchAction(t *testing.T, repo *repo.Repository, opts batchForwardSeedOptions) int64 {
|
||||||
|
t.Helper()
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(2, 'batch_action_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, opts.Now, opts.Now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, opts.TunnelName, 1.0, 1, "tls", 99999, opts.Now, opts.Now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, opts.TunnelName)
|
||||||
|
|
||||||
|
if opts.CreateUserTunnel {
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user_tunnel(id, user_id, tunnel_id, speed_id, num, flow, in_flow, out_flow, flow_reset_time, exp_time, status)
|
||||||
|
VALUES(20, 2, ?, NULL, 999, 99999, 0, 0, 1, 2727251700000, ?)
|
||||||
|
`, tunnelID, opts.UserTunnelStatus).Error; err != nil {
|
||||||
|
t.Fatalf("insert user_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(2, 'batch_action_user', ?, ?, '1.1.1.1:443', 'fifo', 0, 0, ?, ?, 1, 0)
|
||||||
|
`, opts.ForwardName, tunnelID, opts.Now, opts.Now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return mustLastInsertID(t, repo, opts.ForwardName)
|
||||||
|
}
|
||||||
@@ -0,0 +1,161 @@
|
|||||||
|
package contract_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/json"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"go-backend/internal/auth"
|
||||||
|
"go-backend/internal/http/response"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestForwardBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO user(id, user, pwd, role_id, exp_time, flow, in_flow, out_flow, flow_reset_time, num, created_time, updated_time, status)
|
||||||
|
VALUES(2, 'batch_redeploy_user', '3c85cdebade1c51cf64ca9f3c09d182d', 1, 2727251700000, 99999, 0, 0, 1, 99999, ?, ?, 1)
|
||||||
|
`, now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert user: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "batch-redeploy-tunnel", 1.0, 1, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "batch-redeploy-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO forward(user_id, user_name, name, tunnel_id, remote_addr, strategy, in_flow, out_flow, created_time, updated_time, status, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, 0, 0, ?, ?, 1, 0)
|
||||||
|
`, 2, "batch_redeploy_user", "redeploy-forward", tunnelID, "1.1.1.1:443", "fifo", now, now).Error; err != nil {
|
||||||
|
t.Fatalf("insert forward: %v", err)
|
||||||
|
}
|
||||||
|
forwardID := mustLastInsertID(t, repo, "redeploy-forward")
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/forward/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(forwardID)+`]}`))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := out.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected map result, got %T", out.Data)
|
||||||
|
}
|
||||||
|
if int(result["failCount"].(float64)) != 1 {
|
||||||
|
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||||
|
}
|
||||||
|
if int(result["successCount"].(float64)) != 0 {
|
||||||
|
t.Fatalf("expected successCount=0, got %v", result["successCount"])
|
||||||
|
}
|
||||||
|
|
||||||
|
failures, ok := result["failures"].([]interface{})
|
||||||
|
if !ok || len(failures) != 1 {
|
||||||
|
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||||
|
}
|
||||||
|
first, ok := failures[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||||
|
}
|
||||||
|
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "redeploy-forward" {
|
||||||
|
t.Fatalf("expected failure name redeploy-forward, got %q", gotName)
|
||||||
|
}
|
||||||
|
reason, _ := first["reason"].(string)
|
||||||
|
if !strings.Contains(reason, "转发入口端口不存在") {
|
||||||
|
t.Fatalf("expected forward failure reason to mention missing entry port, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTunnelBatchRedeployReturnsFailureReasonsContract(t *testing.T) {
|
||||||
|
secret := "contract-jwt-secret"
|
||||||
|
router, repo := setupContractRouter(t, secret)
|
||||||
|
now := time.Now().UnixMilli()
|
||||||
|
|
||||||
|
adminToken, err := auth.GenerateToken(1, "admin_user", 0, secret)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("generate admin token: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO tunnel(name, traffic_ratio, type, protocol, flow, created_time, updated_time, status, in_ip, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "broken-redeploy-tunnel", 1.0, 2, "tls", 99999, now, now, 1, nil, 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert tunnel: %v", err)
|
||||||
|
}
|
||||||
|
tunnelID := mustLastInsertID(t, repo, "broken-redeploy-tunnel")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO node(name, secret, server_ip, server_ip_v4, server_ip_v6, port, interface_name, version, http, tls, socks, created_time, updated_time, status, tcp_listen_addr, udp_listen_addr, inx)
|
||||||
|
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
|
`, "entry-only-node", "entry-only-secret", "10.0.0.20", "10.0.0.20", "", "20000-20010", "", "v1", 1, 1, 1, now, now, 1, "[::]", "[::]", 0).Error; err != nil {
|
||||||
|
t.Fatalf("insert node: %v", err)
|
||||||
|
}
|
||||||
|
entryNodeID := mustLastInsertID(t, repo, "entry-only-node")
|
||||||
|
|
||||||
|
if err := repo.DB().Exec(`
|
||||||
|
INSERT INTO chain_tunnel(tunnel_id, chain_type, node_id, port, strategy, inx, protocol)
|
||||||
|
VALUES(?, 1, ?, 20001, 'round', 1, 'tls')
|
||||||
|
`, tunnelID, entryNodeID).Error; err != nil {
|
||||||
|
t.Fatalf("insert chain_tunnel: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/api/v1/tunnel/batch-redeploy", bytes.NewBufferString(`{"ids":[`+jsonNumber(tunnelID)+`]}`))
|
||||||
|
req.Header.Set("Authorization", adminToken)
|
||||||
|
req.Header.Set("Content-Type", "application/json")
|
||||||
|
res := httptest.NewRecorder()
|
||||||
|
router.ServeHTTP(res, req)
|
||||||
|
|
||||||
|
var out response.R
|
||||||
|
if err := json.NewDecoder(res.Body).Decode(&out); err != nil {
|
||||||
|
t.Fatalf("decode response: %v", err)
|
||||||
|
}
|
||||||
|
if out.Code != 0 {
|
||||||
|
t.Fatalf("expected API success envelope, got code=%d msg=%q", out.Code, out.Msg)
|
||||||
|
}
|
||||||
|
|
||||||
|
result, ok := out.Data.(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected map result, got %T", out.Data)
|
||||||
|
}
|
||||||
|
if int(result["failCount"].(float64)) != 1 {
|
||||||
|
t.Fatalf("expected failCount=1, got %v", result["failCount"])
|
||||||
|
}
|
||||||
|
|
||||||
|
failures, ok := result["failures"].([]interface{})
|
||||||
|
if !ok || len(failures) != 1 {
|
||||||
|
t.Fatalf("expected exactly one failure detail, got %#v", result["failures"])
|
||||||
|
}
|
||||||
|
first, ok := failures[0].(map[string]interface{})
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("expected failure detail object, got %T", failures[0])
|
||||||
|
}
|
||||||
|
if gotName := strings.TrimSpace(first["name"].(string)); gotName != "broken-redeploy-tunnel" {
|
||||||
|
t.Fatalf("expected failure name broken-redeploy-tunnel, got %q", gotName)
|
||||||
|
}
|
||||||
|
reason, _ := first["reason"].(string)
|
||||||
|
if !strings.Contains(reason, "转发链目标不能为空") {
|
||||||
|
t.Fatalf("expected tunnel failure reason to mention missing target, got %q", reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 规则/隧道下发失败原因可见性修复计划
|
||||||
|
|
||||||
|
- [x] 检查规则与隧道批量重新下发链路,确认失败原因在哪一层被丢失
|
||||||
|
- [x] 为后端批量下发接口补充失败明细返回
|
||||||
|
- [x] 为前端规则/隧道批量下发提示补充具体失败原因展示
|
||||||
|
- [x] 运行针对性验证并更新结论
|
||||||
|
|
||||||
|
## 验证结论
|
||||||
|
|
||||||
|
- 已通过 `go test ./tests/contract/... -run BatchRedeploy` 验证后端会返回批量下发失败明细。
|
||||||
|
- 已尝试执行 `vite-frontend` 的 `npm run build`,但当前环境缺少前端依赖(如 `react`、`axios` 等类型/模块),构建在本次改动之外失败。
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
# 批量操作失败明细与可展开结果弹窗计划
|
||||||
|
|
||||||
|
- [x] 检查批量删除、启用、停用、换隧道及隧道删除链路,确认失败原因返回与前端展示缺口
|
||||||
|
- [x] 为后端相关批量接口补充逐项失败明细返回
|
||||||
|
- [x] 为前端批量操作增加结果弹窗,并支持展开查看失败详情
|
||||||
|
- [x] 跑针对性验证并记录结果
|
||||||
|
|
||||||
|
## 验证结论
|
||||||
|
|
||||||
|
- 已通过 `go test ./tests/contract/...` 验证后端合同测试全部通过。
|
||||||
|
- 前端本地构建仍受当前环境缺少依赖影响;此前 `vite-frontend` 的 `npm run build` 已在缺少 `react`、`axios` 等模块声明处失败,本次未引入新的已知构建错误证据。
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BatchOperationFailure } from "@/api/types";
|
||||||
|
|
||||||
import axios from "axios";
|
import axios from "axios";
|
||||||
|
|
||||||
interface ErrorPayload {
|
interface ErrorPayload {
|
||||||
@@ -5,6 +7,20 @@ interface ErrorPayload {
|
|||||||
message?: string;
|
message?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchFailurePayload {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
reason?: string;
|
||||||
|
msg?: string;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface BatchResultPayload {
|
||||||
|
failures?: unknown[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_BATCH_FAILURES_IN_TOAST = 3;
|
||||||
|
|
||||||
export const isUnauthorizedError = (error: unknown): boolean => {
|
export const isUnauthorizedError = (error: unknown): boolean => {
|
||||||
return axios.isAxiosError(error) && error.response?.status === 401;
|
return axios.isAxiosError(error) && error.response?.status === 401;
|
||||||
};
|
};
|
||||||
@@ -25,3 +41,92 @@ export const extractApiErrorMessage = (
|
|||||||
|
|
||||||
return fallback;
|
return fallback;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeBatchFailure = (
|
||||||
|
failure: unknown,
|
||||||
|
): BatchOperationFailure | null => {
|
||||||
|
if (typeof failure === "string") {
|
||||||
|
const reason = failure.trim();
|
||||||
|
|
||||||
|
return reason ? { reason } : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (failure ?? {}) as BatchFailurePayload;
|
||||||
|
const id =
|
||||||
|
typeof payload.id === "number" && Number.isFinite(payload.id)
|
||||||
|
? payload.id
|
||||||
|
: undefined;
|
||||||
|
const name = typeof payload.name === "string" ? payload.name.trim() : "";
|
||||||
|
const reasonSource = [payload.reason, payload.msg, payload.message].find(
|
||||||
|
(item) => typeof item === "string" && item.trim() !== "",
|
||||||
|
);
|
||||||
|
const reason = typeof reasonSource === "string" ? reasonSource.trim() : "";
|
||||||
|
|
||||||
|
if (!name && !reason && id === undefined) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(id !== undefined ? { id } : {}),
|
||||||
|
...(name ? { name } : {}),
|
||||||
|
...(reason ? { reason } : {}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalizeBatchFailureReason = (failure: BatchOperationFailure): string => {
|
||||||
|
const name = typeof failure.name === "string" ? failure.name.trim() : "";
|
||||||
|
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
||||||
|
|
||||||
|
if (name && reason) {
|
||||||
|
return `${name}: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reason) {
|
||||||
|
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
|
||||||
|
return `ID ${failure.id}: ${reason}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
|
||||||
|
return `ID ${failure.id} 下发失败`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
export const extractBatchFailures = (
|
||||||
|
result: unknown,
|
||||||
|
): BatchOperationFailure[] => {
|
||||||
|
const payload = (result ?? {}) as BatchResultPayload;
|
||||||
|
|
||||||
|
return Array.isArray(payload.failures)
|
||||||
|
? payload.failures
|
||||||
|
.map((item) => normalizeBatchFailure(item))
|
||||||
|
.filter((item): item is BatchOperationFailure => item !== null)
|
||||||
|
: [];
|
||||||
|
};
|
||||||
|
|
||||||
|
export const buildBatchFailureMessage = (
|
||||||
|
result: unknown,
|
||||||
|
fallbackSummary: string,
|
||||||
|
): string => {
|
||||||
|
const failures = extractBatchFailures(result)
|
||||||
|
.map((item) => normalizeBatchFailureReason(item))
|
||||||
|
.filter((item) => item !== "");
|
||||||
|
|
||||||
|
if (failures.length === 0) {
|
||||||
|
return fallbackSummary;
|
||||||
|
}
|
||||||
|
|
||||||
|
const visibleFailures = failures.slice(0, MAX_BATCH_FAILURES_IN_TOAST);
|
||||||
|
const hiddenCount = failures.length - visibleFailures.length;
|
||||||
|
const hiddenSuffix = hiddenCount > 0 ? ` 等 ${failures.length} 项` : "";
|
||||||
|
|
||||||
|
return `${fallbackSummary}:${visibleFailures.join(";")}${hiddenSuffix}`;
|
||||||
|
};
|
||||||
|
|||||||
@@ -210,6 +210,14 @@ export interface UserPackageInfoApiData {
|
|||||||
export interface BatchOperationResult {
|
export interface BatchOperationResult {
|
||||||
successCount: number;
|
successCount: number;
|
||||||
failCount: number;
|
failCount: number;
|
||||||
|
failures?: BatchOperationFailure[];
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchOperationFailure {
|
||||||
|
id?: number;
|
||||||
|
name?: string;
|
||||||
|
reason?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,148 @@
|
|||||||
|
import type { BatchOperationFailure } from "@/api/types";
|
||||||
|
|
||||||
|
import toast from "react-hot-toast";
|
||||||
|
|
||||||
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
|
import { Chip } from "@/shadcn-bridge/heroui/chip";
|
||||||
|
import {
|
||||||
|
Modal,
|
||||||
|
ModalBody,
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalHeader,
|
||||||
|
} from "@/shadcn-bridge/heroui/modal";
|
||||||
|
import { Alert } from "@/shadcn-bridge/heroui/alert";
|
||||||
|
|
||||||
|
interface BatchActionResultModalProps {
|
||||||
|
failures: BatchOperationFailure[];
|
||||||
|
isOpen: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
summary: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const getFailureTitle = (
|
||||||
|
failure: BatchOperationFailure,
|
||||||
|
index: number,
|
||||||
|
): string => {
|
||||||
|
const name = typeof failure.name === "string" ? failure.name.trim() : "";
|
||||||
|
|
||||||
|
if (name) {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof failure.id === "number" && Number.isFinite(failure.id)) {
|
||||||
|
return `ID ${failure.id}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `失败项 ${index + 1}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const getFailureReason = (failure: BatchOperationFailure): string => {
|
||||||
|
const reason = typeof failure.reason === "string" ? failure.reason.trim() : "";
|
||||||
|
|
||||||
|
return reason || "未知错误";
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildFailureCopyText = (
|
||||||
|
title: string,
|
||||||
|
summary: string,
|
||||||
|
failures: BatchOperationFailure[],
|
||||||
|
): string => {
|
||||||
|
return [
|
||||||
|
title,
|
||||||
|
summary,
|
||||||
|
"",
|
||||||
|
...failures.map(
|
||||||
|
(failure, index) =>
|
||||||
|
`${index + 1}. ${getFailureTitle(failure, index)}\n${getFailureReason(failure)}`,
|
||||||
|
),
|
||||||
|
].join("\n");
|
||||||
|
};
|
||||||
|
|
||||||
|
export function BatchActionResultModal({
|
||||||
|
failures,
|
||||||
|
isOpen,
|
||||||
|
onOpenChange,
|
||||||
|
summary,
|
||||||
|
title,
|
||||||
|
}: BatchActionResultModalProps) {
|
||||||
|
const handleCopy = async () => {
|
||||||
|
if (
|
||||||
|
typeof navigator === "undefined" ||
|
||||||
|
!navigator.clipboard ||
|
||||||
|
typeof navigator.clipboard.writeText !== "function"
|
||||||
|
) {
|
||||||
|
toast.error("当前环境不支持复制");
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(
|
||||||
|
buildFailureCopyText(title, summary, failures),
|
||||||
|
);
|
||||||
|
toast.success(`已复制 ${failures.length} 项失败原因`);
|
||||||
|
} catch {
|
||||||
|
toast.error("复制失败,请稍后重试");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
isOpen={isOpen}
|
||||||
|
scrollBehavior="inside"
|
||||||
|
size="2xl"
|
||||||
|
onOpenChange={onOpenChange}
|
||||||
|
>
|
||||||
|
<ModalContent>
|
||||||
|
{(onClose) => (
|
||||||
|
<>
|
||||||
|
<ModalHeader>{title}</ModalHeader>
|
||||||
|
<ModalBody className="space-y-4">
|
||||||
|
<Alert
|
||||||
|
color="warning"
|
||||||
|
description={summary}
|
||||||
|
title={`共 ${failures.length} 项需要处理`}
|
||||||
|
variant="flat"
|
||||||
|
/>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{failures.map((failure, index) => (
|
||||||
|
<details
|
||||||
|
key={`${failure.id ?? "unknown"}-${index}`}
|
||||||
|
className="group rounded-xl border border-divider bg-content2/40 px-4 py-3"
|
||||||
|
>
|
||||||
|
<summary className="flex cursor-pointer list-none items-center justify-between gap-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="truncate text-sm font-medium text-foreground">
|
||||||
|
{getFailureTitle(failure, index)}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-xs text-default-500 group-open:hidden">
|
||||||
|
点击展开查看失败原因
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Chip color="danger" size="sm" variant="flat">
|
||||||
|
失败
|
||||||
|
</Chip>
|
||||||
|
</summary>
|
||||||
|
<div className="mt-3 rounded-lg bg-background/70 p-3 text-sm leading-6 text-foreground/90">
|
||||||
|
{getFailureReason(failure)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</ModalBody>
|
||||||
|
<ModalFooter>
|
||||||
|
<Button variant="light" onPress={handleCopy}>
|
||||||
|
复制失败原因
|
||||||
|
</Button>
|
||||||
|
<Button color="primary" onPress={onClose}>
|
||||||
|
我知道了
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,4 +1,8 @@
|
|||||||
import type { ForwardApiItem, SpeedLimitApiItem } from "@/api/types";
|
import type {
|
||||||
|
BatchOperationFailure,
|
||||||
|
ForwardApiItem,
|
||||||
|
SpeedLimitApiItem,
|
||||||
|
} from "@/api/types";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
@@ -24,6 +28,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
import { SearchBar } from "@/components/search-bar";
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
|
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Input } from "@/shadcn-bridge/heroui/input";
|
import { Input } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -175,6 +180,20 @@ interface BatchProgressState {
|
|||||||
percent: number;
|
percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchResultModalState {
|
||||||
|
failures: BatchOperationFailure[];
|
||||||
|
open: boolean;
|
||||||
|
summary: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||||
|
failures: [],
|
||||||
|
open: false,
|
||||||
|
summary: "",
|
||||||
|
title: "",
|
||||||
|
};
|
||||||
|
|
||||||
type ForwardGroupOrderMap = Record<string, string[]>;
|
type ForwardGroupOrderMap = Record<string, string[]>;
|
||||||
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
type ForwardGroupCollapsedMap = Record<string, boolean>;
|
||||||
|
|
||||||
@@ -672,6 +691,8 @@ export default function ForwardPage() {
|
|||||||
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(
|
const [batchTargetTunnelId, setBatchTargetTunnelId] = useState<number | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
|
const [batchResultModal, setBatchResultModal] =
|
||||||
|
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -2440,6 +2461,36 @@ export default function ForwardPage() {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const presentBatchOutcome = useCallback(
|
||||||
|
(outcome: {
|
||||||
|
failureDetails?: BatchOperationFailure[];
|
||||||
|
resultSummary?: string;
|
||||||
|
resultTitle?: string;
|
||||||
|
toastMessage: string;
|
||||||
|
toastVariant: "success" | "error";
|
||||||
|
}) => {
|
||||||
|
const failureDetails = outcome.failureDetails || [];
|
||||||
|
|
||||||
|
if (failureDetails.length > 0) {
|
||||||
|
setBatchResultModal({
|
||||||
|
failures: failureDetails,
|
||||||
|
open: true,
|
||||||
|
summary: outcome.resultSummary || outcome.toastMessage,
|
||||||
|
title: outcome.resultTitle || "批量操作结果",
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (outcome.toastVariant === "success") {
|
||||||
|
toast.success(outcome.toastMessage);
|
||||||
|
} else {
|
||||||
|
toast.error(outcome.toastMessage);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
@@ -2451,11 +2502,7 @@ export default function ForwardPage() {
|
|||||||
try {
|
try {
|
||||||
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
|
const outcome = await executeForwardBatchDelete(Array.from(selectedIds));
|
||||||
|
|
||||||
if (outcome.toastVariant === "success") {
|
presentBatchOutcome(outcome);
|
||||||
toast.success(outcome.toastMessage);
|
|
||||||
} else {
|
|
||||||
toast.error(outcome.toastMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
@@ -2490,11 +2537,7 @@ export default function ForwardPage() {
|
|||||||
enable,
|
enable,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outcome.toastVariant === "success") {
|
presentBatchOutcome(outcome);
|
||||||
toast.success(outcome.toastMessage);
|
|
||||||
} else {
|
|
||||||
toast.error(outcome.toastMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
@@ -2525,11 +2568,7 @@ export default function ForwardPage() {
|
|||||||
Array.from(selectedIds),
|
Array.from(selectedIds),
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outcome.toastVariant === "success") {
|
presentBatchOutcome(outcome);
|
||||||
toast.success(outcome.toastMessage);
|
|
||||||
} else {
|
|
||||||
toast.error(outcome.toastMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
@@ -2561,11 +2600,7 @@ export default function ForwardPage() {
|
|||||||
batchTargetTunnelId,
|
batchTargetTunnelId,
|
||||||
);
|
);
|
||||||
|
|
||||||
if (outcome.toastVariant === "success") {
|
presentBatchOutcome(outcome);
|
||||||
toast.success(outcome.toastMessage);
|
|
||||||
} else {
|
|
||||||
toast.error(outcome.toastMessage);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outcome.shouldRefresh) {
|
if (outcome.shouldRefresh) {
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
@@ -5848,6 +5883,22 @@ export default function ForwardPage() {
|
|||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<BatchActionResultModal
|
||||||
|
failures={batchResultModal.failures}
|
||||||
|
isOpen={batchResultModal.open}
|
||||||
|
summary={batchResultModal.summary}
|
||||||
|
title={batchResultModal.title}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setBatchResultModal((prev) => ({ ...prev, open: true }));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 筛选模态框 */}
|
{/* 筛选模态框 */}
|
||||||
<Modal
|
<Modal
|
||||||
isOpen={isFilterModalOpen}
|
isOpen={isFilterModalOpen}
|
||||||
|
|||||||
@@ -1,4 +1,7 @@
|
|||||||
import type { BatchOperationResult } from "@/api/types";
|
import type {
|
||||||
|
BatchOperationFailure,
|
||||||
|
BatchOperationResult,
|
||||||
|
} from "@/api/types";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
batchChangeTunnel,
|
batchChangeTunnel,
|
||||||
@@ -7,12 +10,19 @@ import {
|
|||||||
batchRedeployForwards,
|
batchRedeployForwards,
|
||||||
batchResumeForwards,
|
batchResumeForwards,
|
||||||
} from "@/api";
|
} from "@/api";
|
||||||
import { extractApiErrorMessage } from "@/api/error-message";
|
import {
|
||||||
|
buildBatchFailureMessage,
|
||||||
|
extractBatchFailures,
|
||||||
|
extractApiErrorMessage,
|
||||||
|
} from "@/api/error-message";
|
||||||
|
|
||||||
export interface ForwardBatchActionOutcome {
|
export interface ForwardBatchActionOutcome {
|
||||||
toastVariant: "success" | "error";
|
toastVariant: "success" | "error";
|
||||||
toastMessage: string;
|
toastMessage: string;
|
||||||
shouldRefresh: boolean;
|
shouldRefresh: boolean;
|
||||||
|
resultTitle?: string;
|
||||||
|
resultSummary?: string;
|
||||||
|
failureDetails?: BatchOperationFailure[];
|
||||||
progressPercent?: number;
|
progressPercent?: number;
|
||||||
progressLabel?: string;
|
progressLabel?: string;
|
||||||
closeDeleteModal?: boolean;
|
closeDeleteModal?: boolean;
|
||||||
@@ -26,23 +36,37 @@ const normalizeBatchResult = (value: unknown): BatchOperationResult => {
|
|||||||
return {
|
return {
|
||||||
successCount: Number(raw.successCount ?? 0),
|
successCount: Number(raw.successCount ?? 0),
|
||||||
failCount: Number(raw.failCount ?? 0),
|
failCount: Number(raw.failCount ?? 0),
|
||||||
|
failures: extractBatchFailures(raw),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
const buildBatchToast = (
|
const buildBatchToast = (
|
||||||
result: BatchOperationResult,
|
result: BatchOperationResult,
|
||||||
successText: string,
|
successText: string,
|
||||||
): Pick<ForwardBatchActionOutcome, "toastVariant" | "toastMessage"> => {
|
resultTitle: string,
|
||||||
|
): Pick<
|
||||||
|
ForwardBatchActionOutcome,
|
||||||
|
"toastVariant" | "toastMessage" | "resultTitle" | "resultSummary" | "failureDetails"
|
||||||
|
> => {
|
||||||
if (result.failCount === 0) {
|
if (result.failCount === 0) {
|
||||||
return {
|
return {
|
||||||
toastVariant: "success",
|
toastVariant: "success",
|
||||||
toastMessage: successText,
|
toastMessage: successText,
|
||||||
|
resultTitle,
|
||||||
|
resultSummary: successText,
|
||||||
|
failureDetails: [],
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
toastVariant: "error",
|
toastVariant: "error",
|
||||||
toastMessage: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
toastMessage: buildBatchFailureMessage(
|
||||||
|
result,
|
||||||
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
),
|
||||||
|
resultTitle,
|
||||||
|
resultSummary: `成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
failureDetails: result.failures || [],
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -63,7 +87,11 @@ export const executeForwardBatchDelete = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功删除 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功删除 ${summary.successCount} 项`,
|
||||||
|
"批量删除结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
progressLabel: `删除完成:成功 ${summary.successCount} 项`,
|
||||||
@@ -105,6 +133,7 @@ export const executeForwardBatchToggleService = async (
|
|||||||
enable
|
enable
|
||||||
? `成功启用 ${summary.successCount} 项`
|
? `成功启用 ${summary.successCount} 项`
|
||||||
: `成功停用 ${summary.successCount} 项`,
|
: `成功停用 ${summary.successCount} 项`,
|
||||||
|
enable ? "批量启用结果" : "批量停用结果",
|
||||||
),
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
@@ -136,7 +165,11 @@ export const executeForwardBatchRedeploy = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功重新下发 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功重新下发 ${summary.successCount} 项`,
|
||||||
|
"批量下发结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
progressLabel: `重新下发完成:成功 ${summary.successCount} 项`,
|
||||||
@@ -171,7 +204,11 @@ export const executeForwardBatchChangeTunnel = async (
|
|||||||
const summary = normalizeBatchResult(response.data);
|
const summary = normalizeBatchResult(response.data);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...buildBatchToast(summary, `成功换隧道 ${summary.successCount} 项`),
|
...buildBatchToast(
|
||||||
|
summary,
|
||||||
|
`成功换隧道 ${summary.successCount} 项`,
|
||||||
|
"批量换隧道结果",
|
||||||
|
),
|
||||||
shouldRefresh: true,
|
shouldRefresh: true,
|
||||||
progressPercent: 100,
|
progressPercent: 100,
|
||||||
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
progressLabel: `批量换隧道完成:成功 ${summary.successCount} 项`,
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
|
import type { BatchOperationFailure } from "@/api/types";
|
||||||
|
|
||||||
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
import { useState, useEffect, useMemo, useRef, useCallback } from "react";
|
||||||
import toast from "react-hot-toast";
|
import toast from "react-hot-toast";
|
||||||
import {
|
import {
|
||||||
@@ -20,6 +22,7 @@ import { CSS } from "@dnd-kit/utilities";
|
|||||||
|
|
||||||
import { SearchBar } from "@/components/search-bar";
|
import { SearchBar } from "@/components/search-bar";
|
||||||
import { AnimatedPage } from "@/components/animated-page";
|
import { AnimatedPage } from "@/components/animated-page";
|
||||||
|
import { BatchActionResultModal } from "@/components/batch-action-result-modal";
|
||||||
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
import { Card, CardBody, CardHeader } from "@/shadcn-bridge/heroui/card";
|
||||||
import { Button } from "@/shadcn-bridge/heroui/button";
|
import { Button } from "@/shadcn-bridge/heroui/button";
|
||||||
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
import { Input, Textarea } from "@/shadcn-bridge/heroui/input";
|
||||||
@@ -63,7 +66,11 @@ import {
|
|||||||
} from "@/pages/tunnel/form";
|
} from "@/pages/tunnel/form";
|
||||||
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
import { useLocalStorageState } from "@/hooks/use-local-storage-state";
|
||||||
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
import { loadStoredOrder, saveOrder } from "@/utils/order-storage";
|
||||||
import { extractApiErrorMessage } from "@/api/error-message";
|
import {
|
||||||
|
buildBatchFailureMessage,
|
||||||
|
extractBatchFailures,
|
||||||
|
extractApiErrorMessage,
|
||||||
|
} from "@/api/error-message";
|
||||||
|
|
||||||
interface ChainTunnel {
|
interface ChainTunnel {
|
||||||
nodeId: number;
|
nodeId: number;
|
||||||
@@ -122,6 +129,20 @@ interface BatchProgressState {
|
|||||||
percent: number;
|
percent: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface BatchResultModalState {
|
||||||
|
failures: BatchOperationFailure[];
|
||||||
|
open: boolean;
|
||||||
|
summary: string;
|
||||||
|
title: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const EMPTY_BATCH_RESULT_MODAL_STATE: BatchResultModalState = {
|
||||||
|
failures: [],
|
||||||
|
open: false,
|
||||||
|
summary: "",
|
||||||
|
title: "",
|
||||||
|
};
|
||||||
|
|
||||||
const TUNNEL_ORDER_KEY = "tunnel-order";
|
const TUNNEL_ORDER_KEY = "tunnel-order";
|
||||||
|
|
||||||
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
const mapTunnelApiItems = (items: any[]): Tunnel[] => {
|
||||||
@@ -226,6 +247,8 @@ export default function TunnelPage() {
|
|||||||
const [selectMode, setSelectMode] = useState(false);
|
const [selectMode, setSelectMode] = useState(false);
|
||||||
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
|
||||||
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
const [batchDeleteModalOpen, setBatchDeleteModalOpen] = useState(false);
|
||||||
|
const [batchResultModal, setBatchResultModal] =
|
||||||
|
useState<BatchResultModalState>(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||||
const [batchLoading, setBatchLoading] = useState(false);
|
const [batchLoading, setBatchLoading] = useState(false);
|
||||||
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
const [batchProgress, setBatchProgress] = useState<BatchProgressState>({
|
||||||
active: false,
|
active: false,
|
||||||
@@ -842,6 +865,18 @@ export default function TunnelPage() {
|
|||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const openBatchResultModal = useCallback(
|
||||||
|
(title: string, summary: string, failures: BatchOperationFailure[]) => {
|
||||||
|
setBatchResultModal({
|
||||||
|
failures,
|
||||||
|
open: true,
|
||||||
|
summary,
|
||||||
|
title,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
[],
|
||||||
|
);
|
||||||
|
|
||||||
const handleBatchDelete = async () => {
|
const handleBatchDelete = async () => {
|
||||||
if (selectedIds.size === 0) return;
|
if (selectedIds.size === 0) return;
|
||||||
setBatchLoading(true);
|
setBatchLoading(true);
|
||||||
@@ -874,9 +909,17 @@ export default function TunnelPage() {
|
|||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
const failures = extractBatchFailures(result);
|
||||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
||||||
);
|
if (failures.length > 0) {
|
||||||
|
openBatchResultModal(
|
||||||
|
"批量删除结果",
|
||||||
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(`成功 ${result.successCount} 项,失败 ${result.failCount} 项`);
|
||||||
|
}
|
||||||
setBatchProgress({
|
setBatchProgress({
|
||||||
active: true,
|
active: true,
|
||||||
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
label: `部分完成:成功 ${result.successCount} 项,正在刷新列表...`,
|
||||||
@@ -915,9 +958,22 @@ export default function TunnelPage() {
|
|||||||
if (result.failCount === 0) {
|
if (result.failCount === 0) {
|
||||||
toast.success(`成功重新下发 ${result.successCount} 项`);
|
toast.success(`成功重新下发 ${result.successCount} 项`);
|
||||||
} else {
|
} else {
|
||||||
toast.error(
|
const failures = extractBatchFailures(result);
|
||||||
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
|
||||||
);
|
if (failures.length > 0) {
|
||||||
|
openBatchResultModal(
|
||||||
|
"批量下发结果",
|
||||||
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
failures,
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
toast.error(
|
||||||
|
buildBatchFailureMessage(
|
||||||
|
result,
|
||||||
|
`成功 ${result.successCount} 项,失败 ${result.failCount} 项`,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
setSelectedIds(new Set());
|
setSelectedIds(new Set());
|
||||||
setSelectMode(false);
|
setSelectMode(false);
|
||||||
@@ -3049,6 +3105,22 @@ export default function TunnelPage() {
|
|||||||
)}
|
)}
|
||||||
</ModalContent>
|
</ModalContent>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
<BatchActionResultModal
|
||||||
|
failures={batchResultModal.failures}
|
||||||
|
isOpen={batchResultModal.open}
|
||||||
|
summary={batchResultModal.summary}
|
||||||
|
title={batchResultModal.title}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
if (open) {
|
||||||
|
setBatchResultModal((prev) => ({ ...prev, open: true }));
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setBatchResultModal(EMPTY_BATCH_RESULT_MODAL_STATE);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
</AnimatedPage>
|
</AnimatedPage>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user