@@ -151,7 +167,7 @@ import { Search, RefreshLeft } from '@element-plus/icons-vue'
import { listSybProducts, getSybProduct, reparseSybProduct, correctSybProduct } from '@/api/goauto/syb-products'
import { listSybShops } from '@/api/goauto/syb-shops'
import { listDevices } from '@/api/goauto/devices'
-import { createPurchaseTasksBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
+import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks } from '@/api/goauto/purchase-tasks'
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { createRequestId } from '@/utils/request-id'
@@ -165,11 +181,12 @@ export default {
data() {
return {
loading: false, products: [], selectedProducts: [], total: 0,
- purchaseReadiness: {}, purchaseReadinessLoading: false, loadGeneration: 0,
+ purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
purchaseDevices: [],
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
+ specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
collectionBatch: this.emptyCollectionBatch(),
collectionBatchData: { ruleId: null, deviceId: null },
collectionBatchRules: { ruleId: [{ required: true, message: '请选择采集规则', trigger: 'change' }] },
@@ -183,6 +200,7 @@ export default {
computed: {
canPurchase() { const roles = this.$store.getters.roles || []; return roles.includes('admin') || roles.includes('purchaser') },
processStageOptions() { return [{ value: 'manual_action', label: '待人工处理' }, { value: 'pdd_unlinked', label: '未关联 PDD' }, { value: 'pdd_pending', label: 'PDD 待采集' }, { value: 'pdd_collecting', label: 'PDD 采集中' }, { value: 'pdd_collection_failed', label: 'PDD 采集失败' }, { value: 'color_mapping', label: '颜色待匹配' }, { value: 'purchase_ready', label: '可创建采购' }, { value: 'task_created', label: '已创建任务' }, { value: 'purchase_succeeded', label: '采购成功' }, { value: 'order_review', label: '待人工核对' }] },
+ aiMatchCandidates() { return this.selectedProducts.filter(row => this.purchaseReady(row).processStage === 'color_mapping') },
purchaseCandidates() { return this.selectedProducts.filter(row => this.isPurchaseCandidate(row)) },
collectionCandidateRows() { return this.selectedProducts.filter(row => this.isCollectionCandidate(row)) },
collectionCandidates() { return [...new Set(this.collectionCandidateRows.map(row => this.purchaseReady(row).pddProductId))] },
@@ -248,13 +266,16 @@ export default {
handleSelectionChange(rows) { this.selectedProducts = rows },
isPurchaseCandidate(row) { const ready = this.purchaseReady(row); return ready.eligible === true && ready.processStage === 'purchase_ready' },
isCollectionCandidate(row) { const ready = this.purchaseReady(row); return ready.collectionEligible === true && Number.isInteger(ready.pddProductId) && ready.pddProductId > 0 },
- rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && (this.isPurchaseCandidate(row) || this.isCollectionCandidate(row)) },
+ isAIMatchCandidate(row) { return this.purchaseReady(row).processStage === 'color_mapping' },
+ isSelectableCandidate(row) { return this.isPurchaseCandidate(row) || this.isCollectionCandidate(row) || this.isAIMatchCandidate(row) },
+ rowSelectable(row) { return this.canPurchase && !this.purchaseReadinessLoading && this.isSelectableCandidate(row) },
purchaseReady(row) { return this.purchaseReadiness[row.id] || { sybProductId: row.id, eligible: false, reason: this.purchaseReadinessLoading ? '正在检查' : '请刷新后重试' } },
purchasePriceText(item) { if (item.minUnitPriceCent === undefined || item.maxUnitPriceCent === undefined) return ''; return `允许单价 ¥${(item.minUnitPriceCent / 100).toFixed(2)}~¥${(item.maxUnitPriceCent / 100).toFixed(2)}` },
processMeta(stage) { return { manual_action: { label: '待人工处理', type: 'warning' }, pdd_unlinked: { label: '未关联 PDD', type: 'info' }, pdd_pending: { label: 'PDD 待采集', type: 'info' }, pdd_collecting: { label: 'PDD 采集中', type: 'primary' }, pdd_collection_failed: { label: 'PDD 采集失败', type: 'danger' }, color_mapping: { label: '颜色待匹配', type: 'warning' }, purchase_ready: { label: '可创建采购', type: 'success' }, task_created: { label: '已创建任务', type: 'primary' }, purchase_succeeded: { label: '采购成功', type: 'success' }, order_review: { label: '待人工核对', type: 'danger' }}[stage] || { label: '待人工处理', type: 'warning' } },
purchaseActionLabel(item) { return { open_pdd_link: '去关联', open_mapping: '去匹配', open_shopee: '查看蝦皮商品', open_pdd: '查看 PDD 商品', open_task: '查看任务', reparse: '查看并处理', select_device: '重新选择设备', refresh: '刷新' }[item.nextAction] || '' },
- async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration) {
+ async loadPurchaseReadiness(ids, requestOptions = {}, generation = this.loadGeneration, selectedIDsOverride = null) {
if (generation !== this.loadGeneration) return
+ const selectedIDs = selectedIDsOverride || new Set(this.selectedProducts.map(row => row.id))
this.purchaseReadiness = {}
if (!this.canPurchase || !ids.length) { this.purchaseReadinessLoading = false; return }
this.purchaseReadinessLoading = true
@@ -263,11 +284,9 @@ export default {
if (generation !== this.loadGeneration) return
this.purchaseReadiness = Object.fromEntries(r.data.items.map(item => [item.sybProductId, item]))
await this.$nextTick()
- const valid = this.selectedProducts.filter(row => this.rowSelectable(row))
- if (valid.length !== this.selectedProducts.length) {
- this.$refs.productTable?.clearSelection()
- valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
- }
+ const valid = this.products.filter(row => selectedIDs.has(row.id) && this.isSelectableCandidate(row))
+ this.$refs.productTable?.clearSelection()
+ valid.forEach(row => this.$refs.productTable?.toggleRowSelection(row, true))
} catch {
if (generation !== this.loadGeneration) return
this.purchaseReadiness = Object.fromEntries(ids.map(id => [id, { sybProductId: id, eligible: false, reason: '采购准备检查失败,请点击刷新重试', processStage: 'manual_action', processStageLabel: '待人工处理', processStageReason: '采购准备检查失败,请点击刷新重试', processNextAction: 'refresh' }]))
@@ -298,6 +317,25 @@ export default {
if (readiness.nextAction === 'refresh') return this.load()
},
+ async runBatchSpecMatch() {
+ if (!this.aiMatchCandidates.length || this.specMatchLoading) return
+ const selectedIDs = this.selectedProducts.map(row => row.id)
+ this.specMatchLoading = true
+ try {
+ const response = await matchPurchaseSpecsBatch({ sybProductIds: selectedIDs })
+ this.specMatchResult = { open: true, selectedIDs, ...response.data }
+ } finally {
+ this.specMatchLoading = false
+ }
+ },
+ async refreshAfterSpecMatch() {
+ await this.loadPurchaseReadiness(this.products.map(item => item.id), {}, this.loadGeneration, new Set(this.specMatchResult.selectedIDs))
+ },
+ specMatchStatusMeta(status) {
+ return { auto_confirmed: { label: '已确认', type: 'success' }, pending: { label: '待人工', type: 'warning' }, failed: { label: '失败', type: 'danger' }, skipped: { label: '已跳过', type: 'info' }}[status] || { label: status || '未知', type: 'info' }
+ },
+ formatConfidence(value) { return Number.isFinite(value) ? `${Math.round(value * 100)}%` : '—' },
+
// ---------------- 批量创建采购任务 ----------------
async openSinglePurchase(row) {
this.$refs.productTable?.clearSelection()
@@ -427,6 +465,8 @@ export default {
.shop-option-name{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.toolbar-actions :deep(.el-form-item__content){gap:8px}
.toolbar-actions :deep(.el-button + .el-button){margin-left:0}
+.action-count{display:inline-flex;align-items:center;justify-content:center;min-width:18px;height:18px;margin-left:6px;padding:0 5px;border-radius:9px;background:#eef2f7;color:#606266;font-size:11px;font-variant-numeric:tabular-nums;line-height:18px}
+.action-count-primary{background:rgba(255,255,255,.2);color:inherit}
.selection-count{display:inline-flex;align-items:center;min-height:32px;white-space:nowrap;color:#606266}
.selection-count strong{margin:0 4px;font-variant-numeric:tabular-nums;color:#1e40af}
.primary{font-weight:600;color:#1f2937}.muted{font-size:12px;color:#909399}
@@ -437,6 +477,7 @@ export default {
.notice{margin-bottom:16px}
.compact-notice{margin-bottom:12px}
.purchase-summary{display:flex;flex-wrap:wrap;gap:12px 24px;margin-bottom:16px;padding:12px 16px;border:1px solid #dbeafe;border-radius:8px;background:#f8fafc}.purchase-summary strong{font-variant-numeric:tabular-nums;color:#1e40af}.success-text{color:#166534}.danger-text,.purchase-reason{color:#b91c1c}.purchase-reason{margin-top:4px;font-size:12px;line-height:1.45}.purchase-settings{margin-bottom:12px}.field-help{margin-left:12px;color:#909399;font-size:12px}.ellipsis{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
+.spec-match-summary{gap:8px 24px}
.purchase-note{margin:0 0 16px}
.drawer-body{padding:0 4px 24px}
.split{display:grid;grid-template-columns:1fr 1fr;gap:24px}
diff --git a/web/tests/e2e/syb-product-layout.spec.ts b/web/tests/e2e/syb-product-layout.spec.ts
index 4b8cc9f..e4bead4 100644
--- a/web/tests/e2e/syb-product-layout.spec.ts
+++ b/web/tests/e2e/syb-product-layout.spec.ts
@@ -37,12 +37,12 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
const selectionCount = await page.getByText('已选择 0 条', { exact: true }).boundingBox()
const queryButton = await page.getByRole('button', { name: '查询', exact: true }).boundingBox()
const reset = await page.getByRole('button', { name: '重置' }).boundingBox()
- const purchase = await page.getByRole('button', { name: '创建采购(0)', exact: true }).boundingBox()
+ const purchase = await page.getByRole('button', { name: /创建采购\s*0/ }).boundingBox()
expect(selectionCount).not.toBeNull(); expect(queryButton).not.toBeNull(); expect(reset).not.toBeNull(); expect(purchase).not.toBeNull()
expect(Math.abs((selectionCount?.y || 0) - (queryButton?.y || 0))).toBeLessThan(2)
expect(Math.abs((reset?.y || 0) - (purchase?.y || 0))).toBeLessThan(2)
expect(purchase?.x || 0).toBeGreaterThan((reset?.x || 0) + (reset?.width || 0))
- await expect(page.getByRole('button', { name: '创建采购(0)', exact: true })).toBeDisabled()
+ await expect(page.getByRole('button', { name: /创建采购\s*0/ })).toBeDisabled()
const shopControl = page.getByPlaceholder('选择或输入店铺')
const shopInput = await shopControl.boundingBox()
const orderInput = await page.getByPlaceholder('每行一个订单号,最多 100 个').boundingBox()
@@ -56,8 +56,8 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
await expect(page.getByText('已选择 1 条', { exact: true })).toBeVisible()
- await expect(page.getByRole('button', { name: '创建采购(1)', exact: true })).toBeEnabled()
- await expect(page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
const widths = await page.locator('.el-table__header-wrapper colgroup col').evaluateAll(cols => cols.map(col => Number(col.getAttribute('width'))))
expect(widths[3]).toBe(260)
@@ -78,6 +78,55 @@ test('SYB 商品工具栏与列宽按紧凑布局显示', async({ page, context
await expect.poll(() => listURLs.at(-1)?.searchParams.get('processStage')).toBe('pdd_pending')
})
+test('批量 AI 匹配展示混合结果并在关闭后刷新采购候选', async({ page, context }) => {
+ await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
+ let matched = false
+ let matchBody: { sybProductIds: number[] } | null = null
+ await page.route('**/api/**', async route => {
+ const url = new URL(route.request().url())
+ if (url.pathname.startsWith('/src/api/')) return route.continue()
+ if (url.pathname.endsWith('/api/v1/menurole')) return route.fulfill({ json: { code: 200, data: sybProductRoute }})
+ if (url.pathname.endsWith('/api/v1/getinfo')) return route.fulfill({ json: { code: 200, data: { roles: ['admin'], name: '管理员', avatar: '', introduction: '', permissions: [] }}})
+ if (url.pathname.endsWith('/api/admin/v1/syb-products')) return route.fulfill({ json: { code: 200, data: { items: [
+ { id: 81, orderCode: 'MATCH-81', shopeeItemId: '1081', shopeeProductId: 181, productTitle: '高置信度候选', quantity: 1, parseStatus: 'success' },
+ { id: 82, orderCode: 'MATCH-82', shopeeItemId: '1082', shopeeProductId: 182, productTitle: '低置信度候选', quantity: 1, parseStatus: 'success' },
+ { id: 83, orderCode: 'READY-83', shopeeItemId: '1083', shopeeProductId: 183, productTitle: '已就绪候选', quantity: 1, parseStatus: 'success' }
+ ], total: 3, page: 1, pageSize: 20 }}})
+ if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-spec-match')) {
+ matchBody = route.request().postDataJSON()
+ matched = true
+ return route.fulfill({ json: { code: 200, data: {
+ items: [
+ { sybProductId: 81, orderCode: 'MATCH-81', status: 'auto_confirmed', source: 'ai_match', confidence: 0.96, reason: '唯一可靠候选' },
+ { sybProductId: 82, orderCode: 'MATCH-82', status: 'pending', source: 'ai_match', confidence: 0.61, reason: '未达到自动确认阈值' },
+ { sybProductId: 83, orderCode: 'READY-83', status: 'skipped', reason: '当前不是颜色待匹配阶段,无需处理' }
+ ], autoConfirmedCount: 1, pendingCount: 1, failedCount: 0, skippedCount: 1
+ }}})
+ }
+ if (url.pathname.endsWith('/api/admin/v1/purchase-tasks/batch-preview')) {
+ const stage = (id: number) => id === 83 || (id === 81 && matched) ? 'purchase_ready' : 'color_mapping'
+ const items = [81, 82, 83].map(id => ({ sybProductId: id, pddProductId: 200 + id, eligible: stage(id) === 'purchase_ready', collectionEligible: false, processStage: stage(id), processStageLabel: stage(id) === 'purchase_ready' ? '可创建采购' : '颜色待匹配', processStageReason: '' }))
+ return route.fulfill({ json: { code: 200, data: { items, eligibleCount: items.filter(item => item.eligible).length, skippedCount: items.filter(item => !item.eligible).length }}})
+ }
+ return route.fulfill({ json: { code: 200, data: [] }})
+ })
+
+ await page.goto('/#/syb-products')
+ const rows = page.locator('.el-table__body-wrapper tbody tr')
+ for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
+ await expect(page.getByRole('button', { name: /AI 匹配\s*2/ })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
+ await page.getByRole('button', { name: /AI 匹配\s*2/ }).click()
+ await expect(page.getByRole('dialog', { name: 'AI 匹配结果' })).toBeVisible()
+ await expect(page.getByText('自动确认 1 条', { exact: true })).toBeVisible()
+ await expect(page.getByText('待人工 1 条', { exact: true })).toBeVisible()
+ await expect(page.getByText('96%', { exact: true })).toBeVisible()
+ expect(matchBody?.sybProductIds).toEqual([81, 82, 83])
+ await page.getByRole('button', { name: '关闭', exact: true }).click()
+ await expect(page.getByRole('button', { name: /创建采购\s*2/ })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /AI 匹配\s*1/ })).toBeEnabled()
+})
+
test('店铺选项加载失败时仍可输入历史店铺查询', async({ page, context }) => {
await context.addCookies([{ name: 'Admin-Token', value: 'prototype-test-token', domain: 'localhost', path: '/' }])
const listURLs: URL[] = []
@@ -209,10 +258,10 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
await expect(rows).toHaveCount(3)
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
await expect(page.getByText('已选择 3 条', { exact: true })).toBeVisible()
- await expect(page.getByRole('button', { name: '创建采购(1)', exact: true })).toBeEnabled()
- await expect(page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /创建采购\s*1/ })).toBeEnabled()
+ await expect(page.getByRole('button', { name: /创建采集\s*1/ })).toBeEnabled()
- await page.getByRole('button', { name: '创建采购(1)', exact: true }).click()
+ await page.getByRole('button', { name: /创建采购\s*1/ }).click()
await expect(page.getByText('已选择 3 条 SYB 明细', { exact: true })).toBeVisible()
await page.getByRole('button', { name: '创建 1 个采购任务', exact: true }).click()
await expect.poll(() => purchaseBodies.length).toBe(1)
@@ -220,7 +269,7 @@ test('混选后采购与采集分别提交候选子集且采集按 PDD 去重',
await page.getByRole('button', { name: '返回 SYB 商品', exact: true }).click()
for (let index = 0; index < 3; index += 1) await rows.nth(index).locator('.el-checkbox').click()
- await page.getByRole('button', { name: '创建 PDD 采集(1)', exact: true }).click()
+ await page.getByRole('button', { name: /创建采集\s*1/ }).click()
await expect(page.getByText('其中 2 条适用于采集', { exact: true })).toBeVisible()
await expect(page.getByText('去重后 1 个 PDD 商品', { exact: true })).toBeVisible()
await page.getByRole('combobox', { name: '*采集规则' }).click()
@@ -252,8 +301,8 @@ test('去重后超过 100 个 PDD 商品时给出提示且不调用批量接口'
await page.goto('/#/syb-products')
await expect(page.locator('.el-table__body-wrapper tbody tr')).toHaveCount(101)
await page.locator('.el-table__header-wrapper .el-checkbox').first().click()
- await expect(page.getByRole('button', { name: '创建 PDD 采集(101)', exact: true })).toBeEnabled()
- await page.getByRole('button', { name: '创建 PDD 采集(101)', exact: true }).click()
+ await expect(page.getByRole('button', { name: /创建采集\s*101/ })).toBeEnabled()
+ await page.getByRole('button', { name: /创建采集\s*101/ }).click()
await expect(page.getByText('一次最多创建 100 个去重后的 PDD 商品采集任务', { exact: true })).toBeVisible()
await expect(page.getByRole('dialog', { name: '批量创建 PDD 采集任务' })).toHaveCount(0)
expect(collectionCalls).toHaveLength(0)