feat(web): 采集任务页增加来源筛选与「取消未开始的任务」按钮 (#298)

页面混显三种来源却只有 goodsId 和状态两个筛选,批量取消时无法限定范围。

- 服务端 AdminList 增加 source 过滤,非法值报参数错误而非静默忽略。
- 前端增加来源筛选与来源列;状态补 cancelled(info 色,取消不是错误,不与
  failed 共用红色)。
- 「取消未开始的任务」按钮带实时数量,确认框列出将被取消的任务,并写明范围是
  整个筛选条件而非当前页。hasMore 时提示还有未处理的任务。

`[必须]` 取消范围包含 goodsId。少了这一维,按 goods_id 筛出两条、按钮却取消
三十几条——那正是当初放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事。
BatchCancel 的 goodsId 与 AdminList 用同一种匹配方式,否则两边范围会悄悄错开。

实施:sonnet 子代理;goodsId 范围一致性由复核补入。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NTDbDcwbDw1TSAcE6wfh2F
This commit is contained in:
QiuSW
2026-09-16 16:39:07 +08:00
co-authored by Claude Opus 5
parent 7c43dc6029
commit 03647dd7dd
7 changed files with 190 additions and 7 deletions
+1 -1
View File
@@ -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)
@@ -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)
}
}
+9
View File
@@ -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)
}
+10
View File
@@ -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 {
+35
View File
@@ -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)
}
}
+2
View File
@@ -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 }) }
@@ -9,12 +9,18 @@
<el-form :model="query" :inline="true" class="search-form" @submit.prevent="handleQuery">
<el-form-item label="Goods ID"><el-input v-model="query.goodsId" clearable placeholder="输入 goods_id" @keyup.enter="handleQuery" /></el-form-item>
<el-form-item label="状态"><el-select v-model="query.status" clearable placeholder="全部状态" style="width:160px"><el-option v-for="item in statuses" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item><el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button><el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button></el-form-item>
<el-form-item label="来源"><el-select v-model="query.source" clearable placeholder="全部来源" style="width:160px"><el-option v-for="item in sources" :key="item.value" :label="item.label" :value="item.value" /></el-select></el-form-item>
<el-form-item>
<el-button type="primary" :icon="Search" @click="handleQuery">查询</el-button>
<el-button :icon="RefreshLeft" @click="resetQuery">重置</el-button>
<el-button type="danger" plain :loading="cancelBatch.loading" :disabled="pendingCancelCount === 0" @click="openCancelBatch">取消未开始的任务<span class="action-count">{{ pendingCancelCount }}</span></el-button>
</el-form-item>
</el-form>
<el-table v-loading="loading" :data="tasks" border stripe empty-text="暂无采集任务">
<el-table-column label="任务 ID" prop="id" width="100" />
<el-table-column label="执行次数" width="90" align="center"><template #default="{ row }">第 {{ row.attemptNumber || 1 }} 次</template></el-table-column>
<el-table-column label="Goods ID" prop="goodsIdSnapshot" min-width="160" />
<el-table-column label="来源" width="130"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column label="规则" prop="ruleName" min-width="150" show-overflow-tooltip />
<el-table-column label="设备" min-width="150"><template #default="{ row }">{{ row.deviceName || '空闲设备领取' }}</template></el-table-column>
<el-table-column label="状态" width="120" align="center"><template #default="{ row }"><el-tag :type="statusType(row.status)">{{ statusLabel(row.status) }}</el-tag></template></el-table-column>
@@ -38,6 +44,21 @@
<template #footer><el-button @click="createDialog.open=false">取消</el-button><el-button type="primary" :loading="createDialog.saving" @click="createTask">创建</el-button></template>
</el-dialog>
<el-dialog v-model="cancelBatch.open" title="取消未开始的任务" width="680px" :close-on-click-modal="false">
<el-alert
:title="`将取消整个筛选条件(来源:${sourceLabel(query.source) || '全部'};状态:${statusLabel(query.status) || '全部'};Goods ID:${query.goodsId || '全部'})下所有待执行任务,不只是当前页。`"
type="warning" :closable="false" show-icon class="dialog-alert" />
<div v-loading="cancelBatch.previewLoading">
<p>共 {{ cancelBatch.previewTotal }} 个待执行任务将被取消{{ cancelBatch.previewTotal > cancelBatch.preview.length ? `(仅列出前 ${cancelBatch.preview.length} 个)` : '' }}:</p>
<el-table :data="cancelBatch.preview" border max-height="320" empty-text="暂无待执行任务">
<el-table-column label="任务 ID" prop="id" width="100" />
<el-table-column label="来源" width="130"><template #default="{ row }">{{ sourceLabel(row.source) }}</template></el-table-column>
<el-table-column label="Goods ID" prop="goodsIdSnapshot" min-width="160" />
</el-table>
</div>
<template #footer><el-button @click="cancelBatch.open=false">取消</el-button><el-button type="danger" :loading="cancelBatch.submitting" :disabled="cancelBatch.previewTotal === 0" @click="submitCancelBatch">确认取消</el-button></template>
</el-dialog>
<el-drawer v-model="detail.open" title="采集任务详情" size="720px">
<div v-if="detail.data" v-loading="detail.loading" class="detail-body">
<el-descriptions :column="2" border>
@@ -72,7 +93,7 @@
<script>
import { ElMessage, ElMessageBox } from 'element-plus'
import { Plus, RefreshLeft, Search } from '@element-plus/icons-vue'
import { createCollectionTask, deleteCollectionTask, getCollectionTask, listCollectionTasks, resetCollectionTask } from '@/api/goauto/collection-tasks'
import { batchCancelCollectionTasks, createCollectionTask, deleteCollectionTask, getCollectionTask, listCollectionTasks, resetCollectionTask } from '@/api/goauto/collection-tasks'
import { listPddProducts } from '@/api/goauto/pdd-products'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { listDevices } from '@/api/goauto/devices'
@@ -80,13 +101,69 @@ import { createRequestId } from '@/utils/request-id'
export default {
name: 'GoAutoCollectionTasks', setup() { return { Plus, RefreshLeft, Search } },
data() { return { loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '' }, statuses: [{ value: 'pending', label: '待执行' }, { value: 'running', label: '执行中' }, { value: 'completed', label: '已完成' }, { value: 'completed_partial', label: '部分完成' }, { value: 'failed', label: '失败' }], createDialog: { open: false, saving: false }, form: { pddProductId: null, ruleId: null, deviceId: null }, formRules: { pddProductId: [{ required: true, message: '请选择 PDD 商品', trigger: 'change' }], ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] }, options: { products: [], rules: [], devices: [] }, detail: { open: false, loading: false, data: null }} },
created() { this.getList() },
data() {
return {
loading: false, tasks: [], total: 0, query: { page: 1, pageSize: 20, goodsId: '', status: '', source: '' },
statuses: [{ value: 'pending', label: '待执行' }, { value: 'running', label: '执行中' }, { value: 'completed', label: '已完成' }, { value: 'completed_partial', label: '部分完成' }, { value: 'failed', label: '失败' }, { value: 'cancelled', label: '已取消' }],
sources: [{ value: 'admin', label: '手动创建' }, { value: 'agent_current_page', label: '拍照临时采集' }, { value: 'image_search', label: '图搜采集' }],
createDialog: { open: false, saving: false }, form: { pddProductId: null, ruleId: null, deviceId: null },
formRules: { pddProductId: [{ required: true, message: '请选择 PDD 商品', trigger: 'change' }], ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
options: { products: [], rules: [], devices: [] }, detail: { open: false, loading: false, data: null },
pendingCancelCount: 0,
cancelBatch: { open: false, previewLoading: false, submitting: false, preview: [], previewTotal: 0 }
}
},
created() { this.getList(); this.refreshPendingCancelCount() },
watch: {
// `[必须]` 取消范围必须与眼前的列表一致,三个筛选项都要联动。少了 Goods ID
// 那一维,按 goods_id 筛出两条、按钮却显示并取消三十几条——那正是当初
// 放弃「两个固定按钮」、改用「筛选 + 一个按钮」想避免的事(#298)。
'query.source'() { this.refreshPendingCancelCount() },
'query.status'() { this.refreshPendingCancelCount() },
'query.goodsId'() { this.refreshPendingCancelCount() }
},
methods: {
async getList() { this.loading = true; try { const r = await listCollectionTasks(this.query); this.tasks = r.data.items; this.total = r.data.total } finally { this.loading = false } },
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '' }; this.getList() },
statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger' }[value] || 'info' },
handleQuery() { this.query.page = 1; this.getList() }, resetQuery() { this.query = { page: 1, pageSize: 20, goodsId: '', status: '', source: '' }; this.getList() },
statusLabel(value) { return this.statuses.find(item => item.value === value)?.label || value }, statusType(value) { return { pending: 'info', running: 'warning', completed: 'success', completed_partial: 'warning', failed: 'danger', cancelled: 'info' }[value] || 'info' },
sourceLabel(value) { return this.sources.find(item => item.value === value)?.label || value || '' },
isTerminal(value) { return ['completed', 'completed_partial', 'failed'].includes(value) }, specsText(specs) { return Object.entries(specs || {}).map(([key, value]) => `${key}: ${value}`).join(' / ') },
// 取消范围只对「状态筛选为空或恰好是待执行」才有意义:其它状态下这个筛选
// 条件里不可能存在待执行任务,角标应该直接显示 0 并禁用按钮,而不是拿一个
// 与实际可取消数量无关的数字误导采购员。
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 })
this.pendingCancelCount = r.data.total
},
async openCancelBatch() {
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 })
this.cancelBatch.preview = r.data.items
this.cancelBatch.previewTotal = r.data.total
} finally { this.cancelBatch.previewLoading = false }
},
async submitCancelBatch() {
const status = this.cancelScopeStatus()
if (!status) return
this.cancelBatch.submitting = true
try {
const response = await batchCancelCollectionTasks({ source: this.query.source || undefined, status, 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) {
ElMessage.warning('范围内还有未处理的任务,请再次执行')
}
this.cancelBatch.open = false
await this.getList()
await this.refreshPendingCancelCount()
} finally { this.cancelBatch.submitting = false }
},
formatRuleSnapshot(value) { try { return JSON.stringify(typeof value === 'string' ? JSON.parse(value) : value, null, 2) } catch (_) { return String(value || '') } },
async openCreate() { this.form = { pddProductId: null, ruleId: null, deviceId: null }; this.createDialog.open = true; const [p, r, d] = await Promise.all([listPddProducts({ page: 1, pageSize: 100 }), listCollectionRules({ page: 1, pageSize: 100 }), listDevices({ page: 1, pageSize: 100 })]); this.options = { products: p.data.items, rules: r.data.items, devices: d.data.items }; this.$nextTick(() => this.$refs.taskForm?.clearValidate()) },
async createTask() { if (!await this.$refs.taskForm.validate().catch(() => false)) return; this.createDialog.saving = true; try { await createCollectionTask({ requestId: createRequestId(), pddProductId: this.form.pddProductId, ruleId: this.form.ruleId, deviceId: this.form.deviceId || null }); ElMessage.success('任务已创建'); this.createDialog.open = false; await this.getList() } finally { this.createDialog.saving = false } },