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 }} 条不可创建
- 默认人工指定;留空时由符合能力的空闲设备领取。 + + + + + + 上次选择的设备当前不可用,请重新选择,或清空后由空闲设备领取。 + 记住当前用户在此浏览器的选择;留空时由符合能力的空闲设备领取。 + + 重试 @@ -47,7 +55,7 @@

如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。

- + @@ -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) + }) +})