feat(web): remember batch purchase device per user (#233)

This commit is contained in:
QiuSW
2026-09-07 11:25:12 +08:00
parent 0cb36b1e73
commit 1900dab32e
7 changed files with 255 additions and 15 deletions
+8 -2
View File
@@ -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
<!-- gitea-wiki-mirror:end -->
# 业务规则与术语
@@ -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、账号凭据或订单信息。
- 弹窗打开后先按现有在线/可选/采购能力条件加载设备,再恢复选择并以相同设备预检。记忆设备当前不可用时保留偏好并提示用户重新选择或明确清空,不静默切换设备或自动领取。
- 预检加载中、失败或记忆设备不可用时不能提交;过期预检结果不覆盖新的设备选择。服务端原有设备及采购资格校验不变。
- 偏好只作用于此入口,不影响采集、其他创建入口和采购重试。浏览器存储失败时仍允许手动操作;刷新或关闭再打开浏览器可恢复,清理浏览器数据或沿用现有退出登录清理存储行为后需重新选择。
+1
View File
@@ -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,
+7 -1
View File
@@ -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)
@@ -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
}
}
+46 -12
View File
@@ -37,8 +37,16 @@
<el-alert title="每条 SYB 商品分别创建一个采购任务和一个待付款 PDD 订单;系统永不支付。创建前服务端会再次逐条检查。" type="warning" :closable="false" show-icon class="notice" />
<div class="purchase-summary" aria-live="polite"><span>已选择 <strong>{{ purchaseDialog.selectedCount }}</strong> 条 SYB 明细</span><span class="success-text">其中 {{ purchaseDialog.eligibleCount }} 条适用于采购</span><span v-if="purchaseDialog.skippedCount" class="danger-text">预检后另有 {{ purchaseDialog.skippedCount }} 条不可创建</span></div>
<el-form label-width="110px" class="purchase-settings">
<el-form-item label="Android 设备"><el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" @change="refreshPurchasePreview"><el-option v-for="device in purchaseDevices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" /></el-select><span class="field-help">默认人工指定;留空时由符合能力的空闲设备领取。</span></el-form-item>
<el-form-item label="Android 设备">
<el-select v-model="purchaseDialog.deviceId" clearable placeholder="不指定,由空闲设备领取" style="width:360px" :disabled="purchaseDialog.saving" @change="changePurchaseDevice">
<el-option v-if="purchaseDeviceUnavailable" :value="purchaseDialog.deviceId" label="上次选择的设备(当前不可用)" disabled />
<el-option v-for="device in purchaseDevices" :key="device.id" :label="`${device.name} · ${device.model}`" :value="device.id" />
</el-select>
<span v-if="purchaseDeviceUnavailable" class="field-help danger-text" role="alert">上次选择的设备当前不可用,请重新选择,或清空后由空闲设备领取。</span>
<span v-else class="field-help">记住当前用户在此浏览器的选择;留空时由符合能力的空闲设备领取。</span>
</el-form-item>
</el-form>
<el-alert v-if="purchaseDialog.previewError" :title="purchaseDialog.previewError" type="error" :closable="false" show-icon class="notice"><el-button link type="primary" @click="openPurchaseBatch">重试</el-button></el-alert>
<el-table :data="purchaseDialog.items" border size="small" max-height="380" empty-text="没有可创建的商品">
<el-table-column label="SYB 商品" min-width="150"><template #default="{ row }"><strong>{{ row.orderCode || `ID ${row.sybProductId}` }}</strong><div class="muted">{{ row.shopeeItemId || '未关联蝦皮商品' }}</div></template></el-table-column>
<el-table-column label="商品" min-width="210"><template #default="{ row }"><div class="ellipsis">{{ row.productTitle || '—' }}</div><div class="muted">PDD {{ row.pddGoodsId || '未关联' }}</div></template></el-table-column>
@@ -47,7 +55,7 @@
</el-table>
<p class="muted">如果某条商品在确认后状态发生变化,只跳过该条,不撤销其他成功任务。</p>
</div>
<template #footer><el-button :disabled="purchaseDialog.saving" @click="purchaseDialog.open = false">返回列表</el-button><el-button type="primary" :loading="purchaseDialog.saving" :disabled="purchaseDialog.loading || purchaseDialog.eligibleCount === 0" @click="submitPurchaseBatch">创建 {{ purchaseDialog.eligibleCount }} 个采购任务</el-button></template>
<template #footer><el-button :disabled="purchaseDialog.saving" @click="purchaseDialog.open = false">返回列表</el-button><el-button type="primary" :loading="purchaseDialog.saving" :disabled="purchaseDialog.loading || purchaseDeviceUnavailable || !!purchaseDialog.previewError || purchaseDialog.eligibleCount === 0" @click="submitPurchaseBatch">创建 {{ purchaseDialog.eligibleCount }} 个采购任务</el-button></template>
</el-dialog>
<!-- 批量创建采购任务结果 -->
@@ -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 })
@@ -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<void>(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()
})
@@ -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)
})
})