diff --git a/docs/03-business-rules-and-glossary.md b/docs/03-business-rules-and-glossary.md
index a2137ed..c93a508 100644
--- a/docs/03-business-rules-and-glossary.md
+++ b/docs/03-business-rules-and-glossary.md
@@ -2,8 +2,8 @@
generated: true (请先修改 Gitea Wiki,禁止直接编辑本文件)
wiki_page: Business-Rules-and-Glossary
wiki_url: https://git.ilapage.cn/OPC/goauto/wiki/Business-Rules-and-Glossary.-
-wiki_revision: 414e33d4e0db8a71925a93fcb6b733cf175c0a95
-synchronized_at: 2026-09-07T02:51:26Z
+wiki_revision: 670a5592a6db8301cd115295bf820f7f4b6e06d7
+synchronized_at: 2026-09-07T03:21:47Z
# 业务规则与术语
@@ -438,3 +438,9 @@ synchronized_at: 2026-09-07T02:51:26Z
- 直接启动失败(包括无可处理 Activity)时回退既有浏览器入口;启动请求被接受后仍由执行器验证稳定商品页面,停留首页不会仅因启动成功而判定完成。
- 清栈不用于改地址后返回、下单后回到 PDD、订单核查;采集入口继续使用既有浏览器流程,避免绕过规则中的浏览器步骤。
- 当前通用商品页面结构验证不能独立证明页面 goods_id;解析结果携带的任务 goodsId 不是页面回读证据。首次直接深链上线仍需以实际目标商品真机核对,ADB 实验不替代 Agent 上下文验证。
+## SYB 批量采购设备偏好(#233)
+
+- SYB 商品页“批量创建采购任务”按当前登录用户 ID、当前浏览器来源与 API 环境记住最近一次设备选择;主动清空也记忆为不指定设备。只保存设备 ID,不保存 Token、账号凭据或订单信息。
+- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
+- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
+- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
diff --git a/web/src/store/getters.js b/web/src/store/getters.js
index f7b1531..8a3d3fc 100644
--- a/web/src/store/getters.js
+++ b/web/src/store/getters.js
@@ -5,6 +5,7 @@ const getters = {
visitedViews: state => state.tagsView.visitedViews,
cachedViews: state => state.tagsView.cachedViews,
token: state => state.user.token,
+ userId: state => state.user.userId,
avatar: state => state.user.avatar,
name: state => state.user.name,
introduction: state => state.user.introduction,
diff --git a/web/src/store/modules/user.js b/web/src/store/modules/user.js
index a037d8a..4e86d21 100644
--- a/web/src/store/modules/user.js
+++ b/web/src/store/modules/user.js
@@ -5,6 +5,7 @@ import storage from '@/utils/storage'
const state = {
token: getToken(),
+ userId: null,
name: '',
avatar: '',
introduction: '',
@@ -16,6 +17,10 @@ const state = {
const mutations = {
SET_TOKEN: (state, token) => {
state.token = token
+ if (!token) state.userId = null
+ },
+ SET_USER_ID: (state, userId) => {
+ state.userId = Number(userId) || null
},
SET_INTRODUCTION: (state, introduction) => {
state.introduction = introduction
@@ -63,13 +68,14 @@ const actions = {
return resolve()
}
- const { roles, name, avatar, introduction, permissions } = response.data
+ const { roles, name, avatar, introduction, permissions, userId } = response.data
// roles must be a non-empty array
if (!roles || roles.length <= 0) {
return reject('getInfo: roles must be a non-null array!')
}
commit('SET_PERMISSIONS', permissions)
+ commit('SET_USER_ID', userId)
commit('SET_ROLES', roles)
commit('SET_NAME', name)
commit('SET_AVATAR', avatar)
diff --git a/web/src/utils/purchase-device-preference.js b/web/src/utils/purchase-device-preference.js
new file mode 100644
index 0000000..a9281be
--- /dev/null
+++ b/web/src/utils/purchase-device-preference.js
@@ -0,0 +1,29 @@
+function preferenceKey(userId) {
+ const id = Number(userId)
+ if (!Number.isSafeInteger(id) || id <= 0) return null
+ return `goauto:purchase-device:${encodeURIComponent(process.env.VUE_APP_BASE_API || '/')}:user:${id}`
+}
+
+export function readPurchaseDevice(userId) {
+ try {
+ const key = preferenceKey(userId)
+ if (!key) return null
+ const id = JSON.parse(localStorage.getItem(key) || 'null')
+ return Number.isSafeInteger(id) && id > 0 ? id : null
+ } catch {
+ return null
+ }
+}
+
+export function rememberPurchaseDevice(userId, deviceId) {
+ try {
+ const key = preferenceKey(userId)
+ if (!key) return false
+ const id = deviceId == null || deviceId === '' ? null : Number(deviceId)
+ if (id !== null && (!Number.isSafeInteger(id) || id <= 0)) return false
+ localStorage.setItem(key, JSON.stringify(id))
+ return true
+ } catch {
+ return false
+ }
+}
diff --git a/web/src/views/goauto/syb-products/index.vue b/web/src/views/goauto/syb-products/index.vue
index 07c5287..64bf4ac 100644
--- a/web/src/views/goauto/syb-products/index.vue
+++ b/web/src/views/goauto/syb-products/index.vue
@@ -37,8 +37,16 @@
已选择 {{ purchaseDialog.selectedCount }} 条 SYB 明细其中 {{ purchaseDialog.eligibleCount }} 条适用于采购预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建
- 默认人工指定;留空时由符合能力的空闲设备领取。
+
+
+
+
+
+ 上次选择的设备当前不可用,请重新选择,或清空后由空闲设备领取。
+ 记住当前用户在此浏览器的选择;留空时由符合能力的空闲设备领取。
+
+ 重试
{{ row.orderCode || `ID ${row.sybProductId}` }}{{ row.shopeeItemId || '未关联蝦皮商品' }}
{{ row.productTitle || '—' }}
PDD {{ row.pddGoodsId || '未关联' }}
@@ -47,7 +55,7 @@
如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。
- 返回列表创建 {{ purchaseDialog.eligibleCount }} 个采购任务
+ 返回列表创建 {{ purchaseDialog.eligibleCount }} 个采购任务
@@ -171,6 +179,7 @@ import { createPurchaseTasksBatch, matchPurchaseSpecsBatch, previewPurchaseTasks
import { batchCreateCollectionTasks } from '@/api/goauto/collection-tasks'
import { listCollectionRules } from '@/api/goauto/collection-rules'
import { createRequestId } from '@/utils/request-id'
+import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
import ShopeeProductDetailDrawer from '../shopee-products/ShopeeProductDetailDrawer.vue'
import PddProductDetailDrawer from '../pdd-products/PddProductDetailDrawer.vue'
@@ -183,8 +192,9 @@ export default {
loading: false, products: [], selectedProducts: [], total: 0,
purchaseReadiness: {}, purchaseReadinessLoading: false, specMatchLoading: false, loadGeneration: 0,
purchaseDevices: [],
+ purchasePreviewGeneration: 0,
shopOptions: [], shopOptionsLoaded: false, shopOptionsPromise: null,
- purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 },
+ purchaseDialog: { open: false, loading: false, saving: false, ids: [], selectedCount: 0, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0, previewError: '' },
purchaseResult: { open: false, items: [], createdCount: 0, failedCount: 0 },
specMatchResult: { open: false, items: [], selectedIDs: [], autoConfirmedCount: 0, pendingCount: 0, failedCount: 0, skippedCount: 0 },
collectionBatch: this.emptyCollectionBatch(),
@@ -198,6 +208,7 @@ export default {
}
},
computed: {
+ purchaseDeviceUnavailable() { return !!this.purchaseDialog.deviceId && !this.purchaseDevices.some(device => device.id === this.purchaseDialog.deviceId) },
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).aiMatchEligible === true) },
@@ -353,23 +364,46 @@ export default {
const candidates = this.purchaseCandidates
if (!candidates.length) return
const ids = candidates.map(item => item.id)
- this.purchaseDialog = { open: true, loading: true, saving: false, ids, selectedCount: this.selectedProducts.length, deviceId: null, items: [], eligibleCount: 0, skippedCount: 0 }
+ this.purchaseDevices = []
+ this.purchaseDialog = { open: true, loading: true, saving: false, ids, selectedCount: this.selectedProducts.length, deviceId: readPurchaseDevice(this.$store.getters.userId), items: [], eligibleCount: 0, skippedCount: 0, previewError: '' }
+ const dialog = this.purchaseDialog
try {
- const [preview, devices] = await Promise.all([
- previewPurchaseTasks({ sybProductIds: ids }),
- listDevices({ page: 1, pageSize: 100, status: 'online' })
- ])
- this.applyPurchasePreview(preview.data)
+ const devices = await listDevices({ page: 1, pageSize: 100, status: 'online' })
+ if (this.purchaseDialog !== dialog || !dialog.open) return
const required = ['purchase.live.v1', 'purchase.address-update.v1', 'purchase.order-create.v1', 'purchase.spec-probe.v1']
this.purchaseDevices = devices.data.items.filter(device => device.selectable && required.every(capability => (device.capabilities || []).includes(capability)))
- } finally { this.purchaseDialog.loading = false }
+ await this.refreshPurchasePreview()
+ } catch {
+ if (this.purchaseDialog === dialog) {
+ dialog.previewError = '设备列表加载失败,请重试。'
+ dialog.loading = false
+ }
+ }
},
applyPurchasePreview(data) { this.purchaseDialog.items = data.items; this.purchaseDialog.eligibleCount = data.eligibleCount; this.purchaseDialog.skippedCount = data.skippedCount },
+ changePurchaseDevice() {
+ if (!rememberPurchaseDevice(this.$store.getters.userId, this.purchaseDialog.deviceId)) ElMessage.warning('本次选择已生效,但未能保存设备偏好;下次可能需要重新选择。')
+ return this.refreshPurchasePreview()
+ },
async refreshPurchasePreview() {
- this.purchaseDialog.loading = true
- try { const r = await previewPurchaseTasks({ sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined }); this.applyPurchasePreview(r.data) } finally { this.purchaseDialog.loading = false }
+ const dialog = this.purchaseDialog
+ const generation = ++this.purchasePreviewGeneration
+ dialog.eligibleCount = 0
+ dialog.items = []
+ dialog.previewError = ''
+ if (this.purchaseDeviceUnavailable) { dialog.loading = false; return }
+ dialog.loading = true
+ try {
+ const r = await previewPurchaseTasks({ sybProductIds: dialog.ids, deviceId: dialog.deviceId || undefined })
+ if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) this.applyPurchasePreview(r.data)
+ } catch {
+ if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) dialog.previewError = '采购预检失败,请重试。'
+ } finally {
+ if (this.purchaseDialog === dialog && generation === this.purchasePreviewGeneration) dialog.loading = false
+ }
},
async submitPurchaseBatch() {
+ if (this.purchaseDialog.loading || this.purchaseDialog.saving || this.purchaseDeviceUnavailable || this.purchaseDialog.previewError || !this.purchaseDialog.eligibleCount) return
this.purchaseDialog.saving = true
try {
const r = await createPurchaseTasksBatch({ requestId: createRequestId(), sybProductIds: this.purchaseDialog.ids, deviceId: this.purchaseDialog.deviceId || undefined })
diff --git a/web/tests/e2e/purchase-device-preference.spec.ts b/web/tests/e2e/purchase-device-preference.spec.ts
new file mode 100644
index 0000000..0063792
--- /dev/null
+++ b/web/tests/e2e/purchase-device-preference.spec.ts
@@ -0,0 +1,132 @@
+import { expect, test } from '@playwright/test'
+
+const required = ['purchase.live.v1', 'purchase.address-update.v1', 'purchase.order-create.v1', 'purchase.spec-probe.v1']
+const routes = [{ path: '/workbench', component: 'Layout', menuName: 'Workbench', title: '工作台', visible: '0', children: [
+ { path: '/syb-products', component: '/goauto/syb-products/index', menuName: 'GoAutoSybProducts', title: 'SYB 商品', visible: '0' }
+] }]
+
+async function mock(page: any) {
+ const state = { userId: 1, available: true, previewFailed: false, previews: [] as any[], created: [] as any[] }
+ await page.context().addCookies([{ name: 'Admin-Token', value: 'mock-token', domain: 'localhost', path: '/' }])
+ await page.route('**/api/**', async (route: any) => {
+ const url = new URL(route.request().url())
+ const ok = (data: any) => route.fulfill({ json: { code: 200, data } })
+ if (url.pathname.startsWith('/src/api/')) return route.continue()
+ if (url.pathname.endsWith('/api/v1/getinfo')) return ok({ userId: state.userId, roles: ['admin'], name: '同名测试用户', avatar: '', introduction: '', permissions: [] })
+ if (url.pathname.endsWith('/api/v1/menurole')) return ok(routes)
+ if (url.pathname.endsWith('/api/admin/v1/syb-products')) return ok({ items: [
+ { id: 1, orderCode: 'TEST-1', shopeeItemId: '1001', shopeeProductId: 2, productTitle: '测试商品', targetColor: '黑色', targetSize: 'XL', quantity: 1, parseStatus: 'success' }
+ ], total: 1, page: 1, pageSize: 20 })
+ if (url.pathname.endsWith('/api/admin/v1/devices')) return ok({ items: [
+ { id: 10, name: '测试设备A', model: 'A', selectable: state.available, capabilities: required },
+ { id: 20, name: '测试设备B', model: 'B', selectable: true, capabilities: required }
+ ], total: 2 })
+ if (url.pathname.endsWith('/purchase-tasks/batch-preview')) {
+ const body = route.request().postDataJSON()
+ state.previews.push(body)
+ if (state.previewFailed) return route.fulfill({ status: 500, json: { code: 500, message: '模拟预检失败' } })
+ return ok({ items: [{ sybProductId: 1, eligible: true, processStage: 'purchase_ready', reason: '规格就绪', quantity: 1 }], eligibleCount: 1, skippedCount: 0 })
+ }
+ if (url.pathname.endsWith('/purchase-tasks/batch')) {
+ state.created.push(route.request().postDataJSON())
+ return ok({ items: [], createdCount: 1, failedCount: 0 })
+ }
+ return ok([])
+ })
+ return state
+}
+
+async function open(page: any, reload = false) {
+ if (reload) await page.reload()
+ else await page.goto('/#/syb-products')
+ await expect(page.locator('tbody').getByText('可创建采购', { exact: true })).toBeVisible()
+ await page.locator('.el-table__body-wrapper tbody tr').first().locator('.el-checkbox').click()
+ await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
+ const dialog = page.getByRole('dialog', { name: '批量创建采购任务' })
+ await expect(dialog).toBeVisible()
+ await expect(dialog.locator('.el-loading-mask')).toBeHidden()
+ return dialog
+}
+
+async function choose(page: any, dialog: any, name: string) {
+ await dialog.locator('.el-select').click()
+ await page.getByRole('option', { name, exact: true }).click()
+ await expect(dialog.locator('.el-loading-mask')).toBeHidden()
+}
+
+test('设备选择跨弹窗和刷新恢复,预检与创建使用同一设备', async ({ page }) => {
+ const state = await mock(page)
+ let dialog = await open(page)
+ await choose(page, dialog, '测试设备A · A')
+ await dialog.getByRole('button', { name: '返回列表' }).click()
+ await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
+ await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
+ await expect.poll(() => state.previews.at(-1)?.deviceId).toBe(10)
+ dialog = await open(page, true)
+ await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
+ await dialog.getByRole('button', { name: '创建 1 个采购任务' }).click()
+ await expect.poll(() => state.created.length).toBe(1)
+ expect(state.created[0].deviceId).toBe(10)
+})
+
+test('不可用设备不被静默替换,主动清空后保持不指定', async ({ page }) => {
+ const state = await mock(page)
+ let dialog = await open(page)
+ await choose(page, dialog, '测试设备A · A')
+ state.available = false
+ dialog = await open(page, true)
+ await expect(dialog.getByRole('alert').filter({ hasText: '上次选择的设备当前不可用' })).toBeVisible()
+ await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
+ await dialog.locator('.el-select').hover()
+ await dialog.locator('.el-select__clear').click()
+ await expect.poll(() => state.previews.at(-1)?.deviceId).toBeUndefined()
+ dialog = await open(page, true)
+ await expect(dialog.getByText('上次选择的设备(当前不可用)', { exact: true })).toHaveCount(0)
+ await expect(dialog.getByText('不指定,由空闲设备领取', { exact: true })).toBeVisible()
+})
+
+test('同名用户偏好按ID隔离,预检失败禁用创建', async ({ page }) => {
+ const state = await mock(page)
+ let dialog = await open(page)
+ await choose(page, dialog, '测试设备A · A')
+ state.userId = 2
+ dialog = await open(page, true)
+ await expect(dialog.getByText('不指定,由空闲设备领取', { exact: true })).toBeVisible()
+ await choose(page, dialog, '测试设备B · B')
+ state.userId = 1
+ dialog = await open(page, true)
+ await expect(dialog.getByText('测试设备A · A', { exact: true })).toBeVisible()
+ state.previewFailed = true
+ await choose(page, dialog, '测试设备B · B')
+ await expect(dialog.getByText('采购预检失败,请重试。', { exact: true })).toBeVisible()
+ await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
+ expect(state.created).toHaveLength(0)
+})
+
+test('关闭重开后旧预检响应不覆盖新设备结果', async ({ page }) => {
+ await mock(page)
+ const dialog = await open(page)
+ let held = false
+ let finished = false
+ let release: () => void = () => {}
+ const gate = new Promise(resolve => { release = resolve })
+ await page.route('**/purchase-tasks/batch-preview', async route => {
+ if (route.request().postDataJSON().deviceId !== 10 || held) return route.fallback()
+ held = true
+ await gate
+ await route.fulfill({ json: { code: 200, data: { items: [], eligibleCount: 0, skippedCount: 1 } } })
+ finished = true
+ })
+ await dialog.locator('.el-select').click()
+ await page.getByRole('option', { name: '测试设备A · A', exact: true }).click()
+ await expect.poll(() => held).toBe(true)
+ await expect(dialog.getByRole('button', { name: /创建 .* 个采购任务/ })).toBeDisabled()
+ await dialog.getByRole('button', { name: '返回列表' }).click()
+ await page.getByRole('button', { name: /^创建采购\s*1$/ }).click()
+ await expect(dialog.locator('.el-loading-mask')).toBeHidden()
+ await choose(page, dialog, '测试设备B · B')
+ release()
+ await expect.poll(() => finished).toBe(true)
+ await expect(dialog.getByText('测试设备B · B', { exact: true })).toBeVisible()
+ await expect(dialog.getByRole('button', { name: '创建 1 个采购任务' })).toBeEnabled()
+})
diff --git a/web/tests/unit/utils/purchase-device-preference.spec.js b/web/tests/unit/utils/purchase-device-preference.spec.js
new file mode 100644
index 0000000..22a43c4
--- /dev/null
+++ b/web/tests/unit/utils/purchase-device-preference.spec.js
@@ -0,0 +1,32 @@
+import { readPurchaseDevice, rememberPurchaseDevice } from '@/utils/purchase-device-preference'
+
+describe('purchase device preference', () => {
+ beforeEach(() => { localStorage.clear() })
+ afterEach(() => { jest.restoreAllMocks() })
+
+ it('isolates users and remembers an explicit empty selection', () => {
+ expect(rememberPurchaseDevice(1, 10)).toBe(true)
+ expect(rememberPurchaseDevice(2, 20)).toBe(true)
+ expect(readPurchaseDevice(1)).toBe(10)
+ expect(readPurchaseDevice(2)).toBe(20)
+ expect(rememberPurchaseDevice(1, '')).toBe(true)
+ expect(readPurchaseDevice(1)).toBeNull()
+ expect(readPurchaseDevice(2)).toBe(20)
+ })
+
+ it('does not share a fallback key when user identity is missing', () => {
+ expect(rememberPurchaseDevice(null, 10)).toBe(false)
+ expect(readPurchaseDevice(null)).toBeNull()
+ expect(localStorage.length).toBe(0)
+ })
+
+ it('ignores corrupt preferences and tolerates unavailable browser storage', () => {
+ rememberPurchaseDevice(1, 10)
+ localStorage.setItem(localStorage.key(0), '{broken')
+ expect(readPurchaseDevice(1)).toBeNull()
+ jest.spyOn(Storage.prototype, 'getItem').mockImplementation(() => { throw new Error('disabled') })
+ jest.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('disabled') })
+ expect(readPurchaseDevice(1)).toBeNull()
+ expect(rememberPurchaseDevice(1, 10)).toBe(false)
+ })
+})