From 03647dd7dd33baa5aaaa871750dfb2921bd66f7e Mon Sep 17 00:00:00 2001 From: QiuSW <105186638@qq.com> Date: Wed, 16 Sep 2026 16:39:07 +0800 Subject: [PATCH] =?UTF-8?q?feat(web):=20=E9=87=87=E9=9B=86=E4=BB=BB?= =?UTF-8?q?=E5=8A=A1=E9=A1=B5=E5=A2=9E=E5=8A=A0=E6=9D=A5=E6=BA=90=E7=AD=9B?= =?UTF-8?q?=E9=80=89=E4=B8=8E=E3=80=8C=E5=8F=96=E6=B6=88=E6=9C=AA=E5=BC=80?= =?UTF-8?q?=E5=A7=8B=E7=9A=84=E4=BB=BB=E5=8A=A1=E3=80=8D=E6=8C=89=E9=92=AE?= =?UTF-8?q?=20(#298)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 页面混显三种来源却只有 goodsId 和状态两个筛选,批量取消时无法限定范围。 - 服务端 AdminList 增加 source 过滤,非法值报参数错误而非静默忽略。 - 前端增加来源筛选与来源列;状态补 cancelled(info 色,取消不是错误,不与 failed 共用红色)。 - 「取消未开始的任务」按钮带实时数量,确认框列出将被取消的任务,并写明范围是 整个筛选条件而非当前页。hasMore 时提示还有未处理的任务。 `[必须]` 取消范围包含 goodsId。少了这一维,按 goods_id 筛出两条、按钮却取消 三十几条——那正是当初放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事。 BatchCancel 的 goodsId 与 AdminList 用同一种匹配方式,否则两边范围会悄悄错开。 实施:sonnet 子代理;goodsId 范围一致性由复核补入。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F --- server/app/goauto/task/admin_handler.go | 2 +- .../app/goauto/task/admin_list_source_test.go | 50 +++++++++++ server/app/goauto/task/admin_service.go | 9 ++ server/app/goauto/task/cancel.go | 10 +++ server/app/goauto/task/cancel_test.go | 35 ++++++++ web/src/api/goauto/collection-tasks.js | 2 + .../views/goauto/collection-tasks/index.vue | 89 +++++++++++++++++-- 7 files changed, 190 insertions(+), 7 deletions(-) create mode 100644 server/app/goauto/task/admin_list_source_test.go diff --git a/server/app/goauto/task/admin_handler.go b/server/app/goauto/task/admin_handler.go index 18acac3..98f7a6e 100644 --- a/server/app/goauto/task/admin_handler.go +++ b/server/app/goauto/task/admin_handler.go @@ -33,7 +33,7 @@ func (handler Handler) AdminList(c *gin.Context) { } response, err := service.AdminList(c.Request.Context(), AdminListRequest{ Page: page, PageSize: pageSize, Status: strings.TrimSpace(c.Query("status")), - GoodsID: strings.TrimSpace(c.Query("goodsId")), DeviceID: deviceID, + GoodsID: strings.TrimSpace(c.Query("goodsId")), Source: strings.TrimSpace(c.Query("source")), DeviceID: deviceID, }) if err != nil { writeError(c, err) diff --git a/server/app/goauto/task/admin_list_source_test.go b/server/app/goauto/task/admin_list_source_test.go new file mode 100644 index 0000000..fa922c5 --- /dev/null +++ b/server/app/goauto/task/admin_list_source_test.go @@ -0,0 +1,50 @@ +package task + +import ( + "context" + "testing" + + "go-admin/app/goauto/models" + + "gorm.io/gorm" +) + +// TestAdminListFiltersBySource 验证 #298 新增的 source 过滤:只返回指定来源的任务, +// 不影响其它来源。 +func TestAdminListFiltersBySource(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + + adminTask := createTask(t, db, nil) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", adminTask.ID). + Update("source", models.CollectionTaskSourceAdmin).Error; err != nil { + t.Fatalf("set admin source: %v", err) + } + imageSearchTask := createTask(t, db, nil) + if err := db.Session(&gorm.Session{SkipHooks: true}).Model(&models.CollectionTask{}).Where("id = ?", imageSearchTask.ID). + Update("source", models.CollectionTaskSourceImageSearch).Error; err != nil { + t.Fatalf("set image_search source: %v", err) + } + + response, err := service.AdminList(context.Background(), AdminListRequest{Source: models.CollectionTaskSourceAdmin}) + if err != nil { + t.Fatalf("admin list: %v", err) + } + if response.Total != 1 || len(response.Items) != 1 || response.Items[0].ID != adminTask.ID { + t.Fatalf("expected only admin task, got %+v", response) + } +} + +// TestAdminListRejectsInvalidSource 验证非法 source 直接报参数错误,不能静默忽略 +// 掉过滤条件返回全量列表。 +func TestAdminListRejectsInvalidSource(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + createTask(t, db, nil) + + if _, err := service.AdminList(context.Background(), AdminListRequest{Source: "not-a-real-source"}); err == nil { + t.Fatalf("expected invalid source error") + } else if code := taskErrorCode(t, err); code != "INVALID_REQUEST" { + t.Fatalf("expected INVALID_REQUEST, got %s", code) + } +} diff --git a/server/app/goauto/task/admin_service.go b/server/app/goauto/task/admin_service.go index 83246b0..78bf4db 100644 --- a/server/app/goauto/task/admin_service.go +++ b/server/app/goauto/task/admin_service.go @@ -32,6 +32,7 @@ type AdminListRequest struct { Page, PageSize int Status string GoodsID string + Source string DeviceID uint64 } @@ -189,6 +190,14 @@ func (service *Service) AdminList(ctx context.Context, request AdminListRequest) if request.GoodsID != "" { query = query.Where("collection_task.goods_id_snapshot LIKE ?", "%"+strings.TrimSpace(request.GoodsID)+"%") } + if request.Source != "" { + // `[必须]` 来源必须是三个合法值之一才下发过滤条件;非法值直接报错, + // 不能静默忽略——否则筛选框传错值会让采购员误以为看到的是全量列表。 + if request.Source != models.CollectionTaskSourceAdmin && request.Source != models.CollectionTaskSourceAgentCurrentPage && request.Source != models.CollectionTaskSourceImageSearch { + return AdminListResponse{}, serviceError("INVALID_REQUEST", "source 无效") + } + query = query.Where("collection_task.source = ?", request.Source) + } if request.DeviceID > 0 { query = query.Where("collection_task.device_id = ?", request.DeviceID) } diff --git a/server/app/goauto/task/cancel.go b/server/app/goauto/task/cancel.go index e6a4253..589bb92 100644 --- a/server/app/goauto/task/cancel.go +++ b/server/app/goauto/task/cancel.go @@ -25,6 +25,12 @@ type CancelResponse struct { type BatchCancelRequest struct { Source string `json:"source,omitempty"` Status string `json:"status,omitempty"` + // GoodsID 与列表页的同名筛选一致。 + // + // `[必须]` 取消范围必须与采购员眼前看到的列表一致。少了这一维,按 goods_id + // 筛出两条、按钮却取消三十几条——那正是当初放弃「两个固定按钮」、改用 + // 「筛选 + 一个按钮」想避免的事(#298)。 + GoodsID string `json:"goodsId,omitempty"` } type BatchCancelSkippedItem struct { @@ -125,6 +131,10 @@ func (service *Service) BatchCancel(ctx context.Context, request BatchCancelRequ if status != "" { query = query.Where("status = ?", status) } + // 与 AdminList 的 goodsId 过滤保持同一种匹配方式,否则两边范围会悄悄错开。 + if goodsID := strings.TrimSpace(request.GoodsID); goodsID != "" { + query = query.Where("goods_id_snapshot LIKE ?", "%"+goodsID+"%") + } 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_test.go b/server/app/goauto/task/cancel_test.go index abb495d..4190185 100644 --- a/server/app/goauto/task/cancel_test.go +++ b/server/app/goauto/task/cancel_test.go @@ -261,3 +261,38 @@ func TestBatchCancelWithinLimitDoesNotReportMore(t *testing.T) { t.Fatalf("cancelled=%d hasMore=%v, want 1 / false", response.CancelledCount, response.HasMore) } } + +// `[必须]` 取消范围必须与采购员眼前的列表一致。列表页的 Goods ID 搜索框若不参与 +// 取消范围,就会出现「筛出两条、按钮取消三十几条」——那正是当初放弃「两个固定 +// 按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。 +func TestBatchCancelHonoursTheGoodsIDFilter(t *testing.T) { + db := openTaskDatabase(t) + service := newTaskService(db) + + target := createTask(t, db, nil) + other := createTask(t, db, nil) + + var targetRecord models.CollectionTask + if err := db.First(&targetRecord, target.ID).Error; err != nil { + t.Fatalf("load target: %v", err) + } + + response, err := service.BatchCancel(context.Background(), BatchCancelRequest{ + Status: models.TaskStatusPending, GoodsID: targetRecord.GoodsIDSnapshot, + }) + if err != nil { + t.Fatalf("batch cancel: %v", err) + } + if response.CancelledCount != 1 || len(response.CancelledIDs) != 1 || response.CancelledIDs[0] != target.ID { + t.Fatalf("只应取消命中 goods_id 的那一条,实际 %+v", response) + } + + // 范围外的任务必须原样不动。 + var stored models.CollectionTask + if err := db.First(&stored, other.ID).Error; err != nil { + t.Fatalf("load other: %v", err) + } + if stored.Status != models.TaskStatusPending { + t.Fatalf("goods_id 范围外的任务被取消了,status=%q", stored.Status) + } +} diff --git a/web/src/api/goauto/collection-tasks.js b/web/src/api/goauto/collection-tasks.js index b1e9d6e..b24eb31 100644 --- a/web/src/api/goauto/collection-tasks.js +++ b/web/src/api/goauto/collection-tasks.js @@ -7,3 +7,5 @@ export function batchCreateImageSearchCollectionTasks(data) { return request({ u export function getCollectionTask(taskId) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'get' }) } export function resetCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/reset`, method: 'post', data }) } export function deleteCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}`, method: 'delete', data }) } +export function cancelCollectionTask(taskId, data) { return request({ url: `/api/admin/v1/collection-tasks/${taskId}/cancel`, method: 'post', data }) } +export function batchCancelCollectionTasks(data) { return request({ url: '/api/admin/v1/collection-tasks/batch-cancel', method: 'post', data }) } diff --git a/web/src/views/goauto/collection-tasks/index.vue b/web/src/views/goauto/collection-tasks/index.vue index b99841f..ca36ec9 100644 --- a/web/src/views/goauto/collection-tasks/index.vue +++ b/web/src/views/goauto/collection-tasks/index.vue @@ -9,12 +9,18 @@ - 查询重置 + + + 查询 + 重置 + 取消未开始的任务{{ pendingCancelCount }} + + @@ -38,6 +44,21 @@ + + +
+

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

+ + + + + +
+ +
+
@@ -72,7 +93,7 @@