diff --git a/server/app/goauto/task/admin_handler.go b/server/app/goauto/task/admin_handler.go index 98f7a6e..da64704 100644 --- a/server/app/goauto/task/admin_handler.go +++ b/server/app/goauto/task/admin_handler.go @@ -9,6 +9,7 @@ import ( "strings" "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" ) func (handler Handler) AdminList(c *gin.Context) { @@ -176,7 +177,7 @@ func (handler Handler) AdminCancel(c *gin.Context) { if !ok { return } - response, err := service.Cancel(c.Request.Context(), id, request) + response, err := service.CancelOwnedBy(c.Request.Context(), id, request, ownerScope(c)) if err != nil { writeError(c, err) return @@ -194,7 +195,7 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) { if !ok { return } - response, err := service.BatchCancel(c.Request.Context(), request) + response, err := service.BatchCancelOwnedBy(c.Request.Context(), request, ownerScope(c)) if err != nil { writeError(c, err) return @@ -202,6 +203,37 @@ func (handler Handler) AdminBatchCancel(c *gin.Context) { c.JSON(http.StatusOK, gin.H{"code": 200, "data": response}) } +// ownerScope 返回取消操作的归属限制:管理员返回 nil(不限制),其他角色返回 +// 当前用户 id(取不到时为 0,此时任何设备都不算本人设备)。读取方式与 +// device/handler.go 一致(JWT rolekey / identity,#333)。 +func ownerScope(c *gin.Context) *uint64 { + if role, _ := jwt.ExtractClaims(c)["rolekey"].(string); role == "admin" { + return nil + } + id := currentUserID(c) + return &id +} + +func currentUserID(c *gin.Context) uint64 { + switch id := jwt.ExtractClaims(c)["identity"].(type) { + case float64: + if id > 0 { + return uint64(id) + } + case int: + if id > 0 { + return uint64(id) + } + case int64: + if id > 0 { + return uint64(id) + } + case uint64: + return id + } + return 0 +} + func decodeStrict(c *gin.Context, destination any) error { c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, 1<<20) decoder := json.NewDecoder(c.Request.Body) diff --git a/server/app/goauto/task/cancel.go b/server/app/goauto/task/cancel.go index 589bb92..c80f178 100644 --- a/server/app/goauto/task/cancel.go +++ b/server/app/goauto/task/cancel.go @@ -13,6 +13,20 @@ import ( const maxBatchCancelItems = 500 +const messageNotOwnDevice = "只能取消归属自己设备的任务" + +// deviceOwnedBy 判断设备是否归属指定用户;设备为空、不存在或未归属都视为否。 +func deviceOwnedBy(db *gorm.DB, deviceID *uint64, ownerUserID uint64) (bool, error) { + if deviceID == nil || *deviceID == 0 || ownerUserID == 0 { + return false, nil + } + var count int64 + if err := db.Model(&models.AgentDevice{}).Where("id = ? AND owner_user_id = ?", *deviceID, ownerUserID).Count(&count).Error; err != nil { + return false, internalError(err) + } + return count > 0, nil +} + // CancelResponse describes the outcome of cancelling one pending collection task. type CancelResponse struct { TaskID uint64 `json:"taskId"` @@ -31,6 +45,9 @@ type BatchCancelRequest struct { // 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用 // 「筛选 + 一个按钮」想避免的事(#298)。 GoodsID string `json:"goodsId,omitempty"` + // DeviceID 按设备限定取消范围(#387)。管理员可不传(范围与之前一致,含 + // 未指定设备的任务);非管理员必须传,且必须是归属自己的设备。 + DeviceID uint64 `json:"deviceId,omitempty"` } type BatchCancelSkippedItem struct { @@ -57,6 +74,13 @@ type BatchCancelResponse struct { // failure #292 already paid for once. The semantics are "stop what has not // started, let what is running finish". func (service *Service) Cancel(ctx context.Context, taskID uint64, request ActionRequest) (CancelResponse, error) { + return service.CancelOwnedBy(ctx, taskID, request, nil) +} + +// CancelOwnedBy 与 Cancel 相同,但 ownerUserID 非空时只允许取消归属该用户设备 +// 的任务(#387)。未指定设备、设备不存在或归属他人的任务一律返回同一个拒绝, +// 不区分任务当前状态,避免向采购员泄露不属于他的任务信息。 +func (service *Service) CancelOwnedBy(ctx context.Context, taskID uint64, request ActionRequest, ownerUserID *uint64) (CancelResponse, error) { if err := validateAction(taskID, request); err != nil { return CancelResponse{}, err } @@ -69,6 +93,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio } return internalError(err) } + if ownerUserID != nil { + owned, err := deviceOwnedBy(tx, record.DeviceID, *ownerUserID) + if err != nil { + return err + } + if !owned { + return serviceError(CodeForbidden, messageNotOwnDevice) + } + } if record.Status == models.TaskStatusCancelled { response = CancelResponse{TaskID: taskID, Cancelled: true} return nil @@ -88,11 +121,15 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio // 只看 status 会把"已被领取、马上要开始"的任务误判成可取消。 // RowsAffected 为 0 说明任务已被领取或状态已变化,取消必须失败。 now := service.Now() - result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}). - Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now). - Updates(map[string]any{ - "status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil, - }) + update := tx.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}). + Where("id = ? AND status = ? AND (lease_expires_at IS NULL OR lease_expires_at <= ?)", record.ID, models.TaskStatusPending, now) + if ownerUserID != nil { + // 上面已校验归属;这里在写入条件里再兜一层,防止校验与更新之间归属变化。 + update = update.Where("device_id IN (SELECT id FROM agent_device WHERE owner_user_id = ?)", *ownerUserID) + } + result := update.Updates(map[string]any{ + "status": models.TaskStatusCancelled, "active_slot": nil, "device_run_slot": nil, + }) if result.Error != nil { return internalError(result.Error) } @@ -115,6 +152,25 @@ func (service *Service) Cancel(ctx context.Context, taskID uint64, request Actio // 回滚已经成功取消的其它任务。采购员要的是「能停多少停多少」,而不是全有或 // 全无——批量越大,中途有一条被领走的概率越高。 func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequest) (BatchCancelResponse, error) { + return service.BatchCancelOwnedBy(ctx, request, nil) +} + +// BatchCancelOwnedBy 与 BatchCancel 相同,但 ownerUserID 非空(非管理员)时 +// 必须指定 DeviceID,且该设备必须归属 ownerUserID,否则不取消任何任务(#387)。 +func (service *Service) BatchCancelOwnedBy(ctx context.Context, request BatchCancelRequest, ownerUserID *uint64) (BatchCancelResponse, error) { + if ownerUserID != nil { + if request.DeviceID == 0 { + return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "请先选择设备") + } + deviceID := request.DeviceID + owned, err := deviceOwnedBy(service.DB.WithContext(ctx), &deviceID, *ownerUserID) + if err != nil { + return BatchCancelResponse{}, err + } + if !owned { + return BatchCancelResponse{}, serviceError(CodeForbidden, messageNotOwnDevice) + } + } source := strings.TrimSpace(request.Source) if source != "" && source != models.CollectionTaskSourceAdmin && source != models.CollectionTaskSourceAgentCurrentPage && source != models.CollectionTaskSourceImageSearch { return BatchCancelResponse{}, serviceError(device.CodeInvalidRequest, "source 无效") @@ -135,6 +191,9 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" { query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%") } + if request.DeviceID > 0 { + query = query.Where("device_id = ?", request.DeviceID) + } var candidates []models.CollectionTask // 多取一条用于判断范围内是否还有未处理的任务,多出来的那条不参与取消。 if err := query.Order("id ASC").Limit(maxBatchCancelItems + 1).Find(&candidates).Error; err != nil { diff --git a/server/app/goauto/task/cancel_owner_test.go b/server/app/goauto/task/cancel_owner_test.go new file mode 100644 index 0000000..dcc8df9 --- /dev/null +++ b/server/app/goauto/task/cancel_owner_test.go @@ -0,0 +1,224 @@ +package task + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "testing" + + "go-admin/app/goauto/models" + + "github.com/gin-gonic/gin" + jwt "github.com/go-admin-team/go-admin-core/sdk/pkg/jwtauth" + "github.com/google/uuid" + "gorm.io/gorm" +) + +// #387:批量取消按设备限定,单条/批量取消对非管理员强制设备归属。 + +func ownDevice(t *testing.T, db *gorm.DB, name string, owner uint64) models.AgentDevice { + t.Helper() + record, _ := registerTaskDevice(t, db, name) + if owner > 0 { + if err := db.Model(&models.AgentDevice{}).Where("id = ?", record.ID).Update("owner_user_id", owner).Error; err != nil { + t.Fatalf("set owner: %v", err) + } + } + return record +} + +func taskStatus(t *testing.T, db *gorm.DB, id uint64) string { + t.Helper() + var stored models.CollectionTask + if err := db.First(&stored, id).Error; err != nil { + t.Fatalf("load task: %v", err) + } + return stored.Status +} + +func callCancel(t *testing.T, db *gorm.DB, claims jwt.MapClaims, path string, body any) (*httptest.ResponseRecorder, map[string]any) { + t.Helper() + gin.SetMode(gin.TestMode) + engine := gin.New() + handler := Handler{DB: db} + setClaims := func(c *gin.Context) { + c.Set(jwt.JwtPayloadKey, claims) + c.Set("userId", 0) + c.Next() + } + engine.POST("/batch-cancel", setClaims, handler.AdminBatchCancel) + engine.POST("/:taskId/cancel", setClaims, handler.AdminCancel) + payload, _ := json.Marshal(body) + recorder := httptest.NewRecorder() + engine.ServeHTTP(recorder, httptest.NewRequest(http.MethodPost, path, bytes.NewReader(payload))) + var decoded map[string]any + _ = json.Unmarshal(recorder.Body.Bytes(), &decoded) + return recorder, decoded +} + +func purchaser(id float64) jwt.MapClaims { + return jwt.MapClaims{"rolekey": "purchaser", "identity": id} +} + +func adminClaims() jwt.MapClaims { return jwt.MapClaims{"rolekey": "admin", "identity": float64(1)} } + +func TestBatchCancelWithDeviceIDOnlyCancelsThatDevice(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + one := ownDevice(t, db, "one", 3) + two := ownDevice(t, db, "two", 3) + onOne := createTask(t, db, &one.ID) + onTwo := createTask(t, db, &two.ID) + unassigned := createTask(t, db, nil) + + response, err := service.BatchCancel(context.Background(), BatchCancelRequest{DeviceID: one.ID}) + if err != nil { + t.Fatalf("batch cancel: %v", err) + } + if response.CancelledCount != 1 || response.CancelledIDs[0] != onOne.ID { + t.Fatalf("expected only device one task cancelled, got %+v", response) + } + if taskStatus(t, db, onTwo.ID) != models.TaskStatusPending || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending { + t.Fatalf("other device and unassigned tasks must stay pending") + } +} + +func TestBatchCancelWithoutDeviceIDKeepsFullScope(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + one := ownDevice(t, db, "one", 3) + createTask(t, db, &one.ID) + createTask(t, db, nil) + + response, err := service.BatchCancel(context.Background(), BatchCancelRequest{}) + if err != nil { + t.Fatalf("batch cancel: %v", err) + } + if response.CancelledCount != 2 { + t.Fatalf("expected both tasks cancelled including unassigned, got %+v", response) + } +} + +func TestBatchCancelPurchaserRequiresDeviceID(t *testing.T) { + db := openTaskDatabase(t) + one := ownDevice(t, db, "one", 3) + record := createTask(t, db, &one.ID) + + for _, body := range []map[string]any{{}, {"deviceId": 0}} { + recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", body) + if recorder.Code != http.StatusUnprocessableEntity || decoded["code"] != "INVALID_REQUEST" || decoded["message"] != "请先选择设备" { + t.Fatalf("expected INVALID_REQUEST, got %d %v", recorder.Code, decoded) + } + } + if taskStatus(t, db, record.ID) != models.TaskStatusPending { + t.Fatalf("no task may be cancelled") + } +} + +func TestBatchCancelPurchaserOwnDeviceSucceeds(t *testing.T) { + db := openTaskDatabase(t) + own := ownDevice(t, db, "own", 3) + other := ownDevice(t, db, "other", 4) + mine := createTask(t, db, &own.ID) + theirs := createTask(t, db, &other.ID) + + recorder, _ := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": own.ID}) + if recorder.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", recorder.Code, recorder.Body.String()) + } + if taskStatus(t, db, mine.ID) != models.TaskStatusCancelled || taskStatus(t, db, theirs.ID) != models.TaskStatusPending { + t.Fatalf("expected only own device task cancelled") + } +} + +func TestBatchCancelPurchaserRejectsForeignUnownedAndMissingDevices(t *testing.T) { + db := openTaskDatabase(t) + foreign := ownDevice(t, db, "foreign", 4) + unowned := ownDevice(t, db, "unowned", 0) + foreignTask := createTask(t, db, &foreign.ID) + unownedTask := createTask(t, db, &unowned.ID) + + for name, id := range map[string]uint64{"foreign": foreign.ID, "unowned": unowned.ID, "missing": 999999} { + recorder, decoded := callCancel(t, db, purchaser(3), "/batch-cancel", map[string]any{"deviceId": id}) + if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" { + t.Fatalf("%s: expected 403 FORBIDDEN, got %d %v", name, recorder.Code, decoded) + } + } + if taskStatus(t, db, foreignTask.ID) != models.TaskStatusPending || taskStatus(t, db, unownedTask.ID) != models.TaskStatusPending { + t.Fatalf("rejected requests must not cancel anything") + } +} + +func TestBatchCancelMissingIdentityIsRejected(t *testing.T) { + db := openTaskDatabase(t) + unowned := ownDevice(t, db, "unowned", 0) + record := createTask(t, db, &unowned.ID) + recorder, _ := callCancel(t, db, jwt.MapClaims{"rolekey": "purchaser"}, "/batch-cancel", map[string]any{"deviceId": unowned.ID}) + if recorder.Code != http.StatusForbidden || taskStatus(t, db, record.ID) != models.TaskStatusPending { + t.Fatalf("expected rejection, got %d", recorder.Code) + } +} + +func TestBatchCancelAdminAnyDeviceAndUnassigned(t *testing.T) { + db := openTaskDatabase(t) + foreign := ownDevice(t, db, "foreign", 4) + onForeign := createTask(t, db, &foreign.ID) + unassigned := createTask(t, db, nil) + + recorder, _ := callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{"deviceId": foreign.ID}) + if recorder.Code != http.StatusOK || taskStatus(t, db, onForeign.ID) != models.TaskStatusCancelled || taskStatus(t, db, unassigned.ID) != models.TaskStatusPending { + t.Fatalf("admin with deviceId should cancel only that device, code=%d", recorder.Code) + } + recorder, _ = callCancel(t, db, adminClaims(), "/batch-cancel", map[string]any{}) + if recorder.Code != http.StatusOK || taskStatus(t, db, unassigned.ID) != models.TaskStatusCancelled { + t.Fatalf("admin without deviceId should cancel unassigned tasks, code=%d", recorder.Code) + } +} + +func TestSingleCancelPurchaserOwnership(t *testing.T) { + db := openTaskDatabase(t) + own := ownDevice(t, db, "own", 3) + foreign := ownDevice(t, db, "foreign", 4) + mine := createTask(t, db, &own.ID) + theirs := createTask(t, db, &foreign.ID) + unassigned := createTask(t, db, nil) + body := map[string]any{"requestId": uuid.NewString()} + + for name, id := range map[string]uint64{"foreign": theirs.ID, "unassigned": unassigned.ID} { + recorder, decoded := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", id), body) + if recorder.Code != http.StatusForbidden || decoded["code"] != "FORBIDDEN" { + t.Fatalf("%s: expected 403, got %d %v", name, recorder.Code, decoded) + } + if taskStatus(t, db, id) != models.TaskStatusPending { + t.Fatalf("%s: status must be unchanged", name) + } + } + recorder, _ := callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", mine.ID), body) + if recorder.Code != http.StatusOK || taskStatus(t, db, mine.ID) != models.TaskStatusCancelled { + t.Fatalf("own device task should cancel, code=%d body=%s", recorder.Code, recorder.Body.String()) + } + // 他人设备上已取消的任务也不能通过幂等分支探测。 + if _, err := newTaskService(db).Cancel(context.Background(), theirs.ID, ActionRequest{RequestID: uuid.NewString()}); err != nil { + t.Fatalf("seed: %v", err) + } + recorder, _ = callCancel(t, db, purchaser(3), fmt.Sprintf("/%d/cancel", theirs.ID), body) + if recorder.Code != http.StatusForbidden { + t.Fatalf("foreign cancelled task must still be forbidden, got %d", recorder.Code) + } +} + +func TestSingleCancelAdminUnaffected(t *testing.T) { + db := openTaskDatabase(t) + foreign := ownDevice(t, db, "foreign", 4) + theirs := createTask(t, db, &foreign.ID) + unassigned := createTask(t, db, nil) + for _, id := range []uint64{theirs.ID, unassigned.ID} { + recorder, _ := callCancel(t, db, adminClaims(), fmt.Sprintf("/%d/cancel", id), map[string]any{"requestId": uuid.NewString()}) + if recorder.Code != http.StatusOK || taskStatus(t, db, id) != models.TaskStatusCancelled { + t.Fatalf("admin cancel of %d failed: %d", id, recorder.Code) + } + } +} diff --git a/server/app/goauto/task/handler.go b/server/app/goauto/task/handler.go index 3149ebe..8cea80a 100644 --- a/server/app/goauto/task/handler.go +++ b/server/app/goauto/task/handler.go @@ -317,7 +317,7 @@ func writeError(context *gin.Context, err error) { status = http.StatusUnprocessableEntity case device.CodeTokenInvalid: status = http.StatusUnauthorized - case device.CodeDeviceDisabled: + case device.CodeDeviceDisabled, CodeForbidden: status = http.StatusForbidden case CodeTaskNotFound: status = http.StatusNotFound diff --git a/server/app/goauto/task/service.go b/server/app/goauto/task/service.go index ce719e2..d6524de 100644 --- a/server/app/goauto/task/service.go +++ b/server/app/goauto/task/service.go @@ -20,6 +20,7 @@ const ( DefaultLeaseDuration = 2 * time.Minute DefaultTaskTimeout = 120 + CodeForbidden = "FORBIDDEN" CodeTaskNotFound = "TASK_NOT_FOUND" CodeTaskAlreadyClaimed = "TASK_ALREADY_CLAIMED" CodeTaskAssignedOther = "TASK_ASSIGNED_OTHER_DEVICE" diff --git a/web/src/views/goauto/collection-tasks/index.vue b/web/src/views/goauto/collection-tasks/index.vue index 3c8d0fa..ec973d0 100644 --- a/web/src/views/goauto/collection-tasks/index.vue +++ b/web/src/views/goauto/collection-tasks/index.vue @@ -14,7 +14,7 @@ 查询 重置 - 取消未开始的任务{{ pendingCancelCount }} + 取消未开始的任务{{ pendingCancelCount }} @@ -47,7 +47,7 @@

共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:

@@ -114,6 +114,16 @@ export default { cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 } } }, + computed: { + isAdmin() { return (this.$store.getters.roles || []).includes('admin') }, + // #387:采购员未选设备时不能批量取消(服务端同样拒绝);管理员不受限。 + cancelNeedsDevice() { return !this.isAdmin && !this.query.deviceId }, + cancelDeviceLabel() { + if (!this.query.deviceId) return '全部设备(含未指定设备)' + const found = this.deviceFilterOptions.find(item => item.id === this.query.deviceId) + return `设备「${found ? found.name : this.query.deviceId}」` + } + }, created() { this.getList(); this.refreshPendingCancelCount(); this.loadDeviceFilterOptions() }, watch: { // `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID @@ -135,20 +145,21 @@ export default { // 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选 // 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个 // 与实际可取消数量无关的数字误导采购员。 - // 批量取消接口不支持设备维度;选了设备筛选时禁用,避免取消范围超出眼前列表。 - cancelScopeStatus() { if (this.query.deviceId) return null; return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null }, + // #387:设备维度也参与取消范围;采购员必须先选设备,且服务端只允许取消本人设备的任务。 + cancelScopeStatus() { return this.query.status === '' || this.query.status === 'pending' ? 'pending' : null }, async refreshPendingCancelCount() { const status = this.cancelScopeStatus() - if (!status) { this.pendingCancelCount = 0; return } - const r = await listCollectionTasks({ page: 1, pageSize: 1, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined }) + if (!status || this.cancelNeedsDevice) { this.pendingCancelCount = 0; return } + const r = await listCollectionTasks({ page: 1, pageSize: 1, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined }) this.pendingCancelCount = r.data.total }, async openCancelBatch() { + if (this.cancelNeedsDevice) { ElMessage.warning('请先在「设备」筛选中选择要取消任务的设备'); return } const status = this.cancelScopeStatus() if (!status) return this.cancelBatch = { open: true, previewLoading: true, submitting: false, preview: [], previewTotal: 0 } try { - const r = await listCollectionTasks({ page: 1, pageSize: 50, status, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined }) + const r = await listCollectionTasks({ page: 1, pageSize: 50, status, deviceId: this.query.deviceId || undefined, source: this.query.source || undefined, goodsId: this.query.goodsId || undefined }) this.cancelBatch.preview = r.data.items this.cancelBatch.previewTotal = r.data.total } finally { this.cancelBatch.previewLoading = false } @@ -158,7 +169,7 @@ export default { if (!status) return this.cancelBatch.submitting = true try { - const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, goodsId: this.query.goodsId || undefined }) + const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, deviceId: this.query.deviceId || undefined, goodsId: this.query.goodsId || undefined }) const skippedText = response.data.skipped?.length ? `,${response.data.skipped.length} 个因执行中或已被设备领取而跳过(任务号:${response.data.skipped.map(item => item.taskId).join('、')})` : '' ElMessage.success(`已取消 ${response.data.cancelledCount} 个任务${skippedText}`) if (response.data.hasMore) {