Feat: 前端改版 Web 端界面与业务作业流程

按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。

- 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、
  测试模式提示条、深色模式配色、窄屏适配
- 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、
  缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择
  MasterRefSelect,以及编号段解析等工具
- 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案
- 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览;
  分配用户对话框标注增减;7 个新增/编辑对话框统一布局
- 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为
  列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变
- 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查
- 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试,
  同步更新业务与工作台契约脚本

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-18 05:03:22 -07:00
co-authored by Claude Opus 5.5
parent 3050ccdc4e
commit 167df4adb8
71 changed files with 18396 additions and 2428 deletions
@@ -13,8 +13,13 @@ const template = compileTemplate({ source: descriptor.template.content, filename
assert.deepEqual(template.errors, [])
const apiSource = await read('api/business/usage.js')
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
const passport = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletPassport.js'))}`)
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
const masterDisplaySource = await read('utils/masterDisplay.js')
const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport })
// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言
const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] }
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
function deferred() {
let resolve, reject
@@ -46,6 +51,9 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines })
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: { recommended: null, unknownArrivalCount: 0, message: '请按实际接收时间先到先用' } })
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [], total: 0 })
if (config.url === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] })
if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard })
if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } })
if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } })
if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
throw new Error(`未处理请求 ${config.url}`)
@@ -63,7 +71,7 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
confirm(message) { confirmations.push(message); return confirm(message) }
}, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } }
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount']
const component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name]))
@@ -95,7 +103,8 @@ async function retry(page) {
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
const page = createPage()
await page.api.scanUsagePallet('托盘/A?#')
assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
// 页面启动后还会读取现场看板,按路径取最近一次扫码请求再核对编码
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
await page.scan('印刷托盘号')
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
page.close()
@@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它',
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
const twoSites = config => config.url === '/business/usage/site-options'
? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] })
: undefined
test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => {
const storage = new Map()
const page = createPage({ storage, request: twoSites })
await flush()
assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 })
assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 })
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
assert.equal(storage.size, 0, '自动选择不写入本机偏好')
page.state.handleSiteChange(22); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '22')
assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 })
page.close()
const again = createPage({ storage, request: twoSites })
assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场')
await flush()
assert.equal(again.state.selectedSiteId.value, 22)
again.close()
const otherUser = createPage({ storage, userId: 8, request: twoSites })
await flush()
assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离')
otherUser.close()
})
test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => {
const storage = new Map([['business:usage:site:v1:7', '99']])
const page = createPage({ storage, storageWriteError: true })
assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99)
await flush()
assert.equal(page.state.selectedSiteId.value, 21)
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
page.state.handleSiteChange(21); await flush()
assert.equal(storage.get('business:usage:site:v1:7'), '99')
assert.equal(page.state.storageError.value, '')
page.close()
})
test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => {
const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] }
const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined })
await flush()
assert.match(text(page), /看板隧道/)
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%')
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51)
page.state.toggleTunnelFilter(51); await flush()
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined)
page.close()
})
test('无查询权限不读取看板,无列表权限不读取现场选项', async () => {
const page = createPage({ canOperate: false })
await flush()
assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false)
page.state.openWork()
assert.equal(page.state.workOpen.value, false)
page.close()
})
test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => {
const page = createPage()
await flush()
page.state.openWork()
assert.equal(page.state.workOpen.value, true)
assert.equal(page.state.workStep.value, 1)
await page.scan()
assert.equal(page.state.workStep.value, 2)
page.state.selectTunnel(31)
assert.equal(page.state.form.value.tunnelId, 31)
assert.equal(page.state.selectedTunnelName.value, '一号隧道')
await page.act('enterTunnel')
assert.equal(page.state.workStep.value, 3)
page.state.selectTunnel(32)
assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道')
assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100'])
page.state.form.value.usedPackages = 30
assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false })
page.state.form.value.usedPackages = 120
assert.equal(page.state.usagePreview.value.bad, true)
page.state.form.value.usedPackages = 17
const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length
await page.act('submitUsage')
assert.equal(page.state.workStep.value, 4)
assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板')
page.state.continueSamePallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001')
page.state.closeWork(); await flush()
assert.equal(page.state.workOpen.value, false)
assert.equal(page.state.scannedPallet.value, null)
page.close()
})
test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', async () => {
const page = createPage({ request: config => config.url.endsWith('/recommendation')
? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 0, requiresSkipReason: true, message: '请填写跳过原因' } })
: undefined })
await page.scan()
assert.equal(page.state.recommendedOther.value, true)
assert.ok(button(page, '改用 较早托盘'))
page.state.pickFifoReason('建议托盘取不出')
assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出')
assert.equal(page.state.isReasonActive('建议托盘取不出'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '')
page.state.form.value.fifoSkipReason = '叉车暂时取不出'
assert.equal(page.state.isReasonActive('其他'), true)
page.state.pickFifoReason('其他')
assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空')
page.state.useRecommendedPallet(); await flush()
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`)
page.close()
})