更新登录页及优化相关功能

This commit is contained in:
焦龙言
2026-10-08 14:13:05 +08:00
parent 9a14b692e3
commit 56e646bcc1
139 changed files with 77587 additions and 811 deletions
+10 -181
View File
@@ -17,6 +17,15 @@
height: 100%;
margin: 0px;
padding: 0px;
background-color: #f0f6f7;
}
@media (prefers-color-scheme: dark) {
html,
body,
#app {
background-color: #0b1420;
}
}
.chromeframe {
@@ -25,191 +34,11 @@
color: #000;
padding: 0.2em 0;
}
#loader-wrapper {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
z-index: 999999;
}
#loader {
display: block;
position: relative;
left: 50%;
top: 50%;
width: 150px;
height: 150px;
margin: -75px 0 0 -75px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: #FFF;
-webkit-animation: spin 2s linear infinite;
-ms-animation: spin 2s linear infinite;
-moz-animation: spin 2s linear infinite;
-o-animation: spin 2s linear infinite;
animation: spin 2s linear infinite;
z-index: 1001;
}
#loader:before {
content: "";
position: absolute;
top: 5px;
left: 5px;
right: 5px;
bottom: 5px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: #FFF;
-webkit-animation: spin 3s linear infinite;
-moz-animation: spin 3s linear infinite;
-o-animation: spin 3s linear infinite;
-ms-animation: spin 3s linear infinite;
animation: spin 3s linear infinite;
}
#loader:after {
content: "";
position: absolute;
top: 15px;
left: 15px;
right: 15px;
bottom: 15px;
border-radius: 50%;
border: 3px solid transparent;
border-top-color: #FFF;
-moz-animation: spin 1.5s linear infinite;
-o-animation: spin 1.5s linear infinite;
-ms-animation: spin 1.5s linear infinite;
-webkit-animation: spin 1.5s linear infinite;
animation: spin 1.5s linear infinite;
}
@-webkit-keyframes spin {
0% {
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
@keyframes spin {
0% {
-webkit-transform: rotate(0deg);
-ms-transform: rotate(0deg);
transform: rotate(0deg);
}
100% {
-webkit-transform: rotate(360deg);
-ms-transform: rotate(360deg);
transform: rotate(360deg);
}
}
#loader-wrapper .loader-section {
position: fixed;
top: 0;
width: 51%;
height: 100%;
background: #7171C6;
z-index: 1000;
-webkit-transform: translateX(0);
-ms-transform: translateX(0);
transform: translateX(0);
}
#loader-wrapper .loader-section.section-left {
left: 0;
}
#loader-wrapper .loader-section.section-right {
right: 0;
}
.loaded #loader-wrapper .loader-section.section-left {
-webkit-transform: translateX(-100%);
-ms-transform: translateX(-100%);
transform: translateX(-100%);
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader-wrapper .loader-section.section-right {
-webkit-transform: translateX(100%);
-ms-transform: translateX(100%);
transform: translateX(100%);
-webkit-transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
transition: all 0.7s 0.3s cubic-bezier(0.645, 0.045, 0.355, 1.000);
}
.loaded #loader {
opacity: 0;
-webkit-transition: all 0.3s ease-out;
transition: all 0.3s ease-out;
}
.loaded #loader-wrapper {
visibility: hidden;
-webkit-transform: translateY(-100%);
-ms-transform: translateY(-100%);
transform: translateY(-100%);
-webkit-transition: all 0.3s 1s ease-out;
transition: all 0.3s 1s ease-out;
}
.no-js #loader-wrapper {
display: none;
}
.no-js h1 {
color: #222222;
}
#loader-wrapper .load_title {
font-family: 'Open Sans';
color: #FFF;
font-size: 19px;
width: 100%;
text-align: center;
z-index: 9999999999999;
position: absolute;
top: 60%;
opacity: 1;
line-height: 30px;
}
#loader-wrapper .load_title span {
font-weight: normal;
font-style: italic;
font-size: 13px;
color: #FFF;
opacity: 0.5;
}
</style>
</head>
<body>
<div id="app">
<div id="loader-wrapper">
<div id="loader"></div>
<div class="loader-section section-left"></div>
<div class="loader-section section-right"></div>
<div class="load_title">正在加载系统资源,请耐心等待</div>
</div>
</div>
<div id="app"></div>
<script type="module" src="/src/main.js"></script>
</body>
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "1.9.0",
"description": "智链云枢管理系统",
"author": "insistence",
"license": "MIT",
"license": "UNLICENSED",
"type": "module",
"scripts": {
"dev": "vite",
@@ -11,7 +11,7 @@
"build:docker": "vite build --mode docker",
"build:stage": "vite build --mode staging",
"preview": "vite preview",
"test:business": "node --test scripts/verify-business-contract.mjs"
"test:business": "node --test scripts/verify-business-contract.mjs scripts/verify-report-*.mjs scripts/*-behavior.test.mjs scripts/filling-dates.test.mjs scripts/pallet-identity.test.mjs"
},
"repository": {
"type": "git",
@@ -0,0 +1,64 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { test } from 'node:test'
import { computed, reactive, ref, toRefs, watch, nextTick } from 'vue'
// 执行真实页面的 setup 与提交方法;只替代网络和运行平台边界。
async function fillingPage(platform) {
const source = await readFile(new URL(platform === 'pc'
? '../src/views/business/filling/index.vue'
: '../../shierfeng-fastapi-app/src/pages/business/filling/index.vue', import.meta.url), 'utf8')
const script = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/)[1]
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, '')
const submissions = []
let key = 0
const proxy = {
$auth: { hasPermi: () => true, hasPermiAnd: () => true },
$refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } },
$modal: { msgSuccess() {} },
resetForm() {},
}
const context = vm.createContext({
computed, reactive, ref, toRefs, watch,
getCurrentInstance: () => ({ proxy }),
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
listFilling: async () => ({ rows: [], total: 0 }),
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
})
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
const page = context.page
const form = platform === 'pc' ? page.form.value : page.form
Object.assign(form, { qrToken: 'qr-1', warehouseId: 1, strainId: 1, batchNo: 'B-1', packageSpec: '标准包', initialPackages: 100 })
page.scannedPallet.value = { palletId: 1, capacityPackages: 100 }
await nextTick()
return { page, form, submissions }
}
for (const platform of ['pc', 'app']) {
test(`${platform} 装填提交保留来源日期,失败后重试复用幂等键`, async () => {
const { page, form, submissions } = await fillingPage(platform)
Object.assign(form, { productionDate: '2026-10-01', expiryDate: '2027-03-31' })
await nextTick()
page.submitFilling()
await new Promise(resolve => setImmediate(resolve))
assert.equal(submissions.length, 1)
assert.equal(submissions[0].productionDate, '2026-10-01')
assert.equal(submissions[0].expiryDate, '2027-03-31')
page.submitFilling()
await new Promise(resolve => setImmediate(resolve))
assert.equal(submissions[1].idempotencyKey, submissions[0].idempotencyKey)
})
test(`${platform} 装填日期缺失或倒置时不发送请求`, async () => {
const { page, form, submissions } = await fillingPage(platform)
for (const dates of [{ productionDate: '', expiryDate: '' }, { productionDate: '2026-10-02', expiryDate: '2026-10-01' }]) {
Object.assign(form, dates)
page.submitFilling()
await new Promise(resolve => setImmediate(resolve))
assert.equal(submissions.length, 0)
assert.ok(page.formError.value)
}
})
}
@@ -0,0 +1,26 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import vm from 'node:vm'
import { test } from 'node:test'
import { nextTick, ref, watch } from 'vue'
import { isDuplicateScan, normalizeScanValue } from '../src/utils/palletScan.js'
test('共享扫码输入允许输入短托盘号且仍拒绝空值', async () => {
const source = await readFile(new URL('../src/components/PalletScanInput/index.vue', import.meta.url), 'utf8')
const script = source.match(/<script\b[^>]*>([\s\S]*?)<\/script>/)[1]
.replace(/^import[\s\S]*?from\s+['"][^'"]+['"];?\s*$/gm, '')
const events = []
const context = vm.createContext({
nextTick, ref, watch, onMounted() {},
normalizeScanValue, isDuplicateScan,
defineProps: definitions => Object.fromEntries(Object.entries(definitions).map(([key, value]) => [key, value.default])),
defineEmits: () => (...event) => events.push(event),
})
vm.runInContext(`${script}\nglobalThis.page = {scanValue, handleSubmit}`, context)
context.page.scanValue.value = ' 123 '
context.page.handleSubmit()
assert.deepEqual(events[0], ['scan', '123'])
context.page.scanValue.value = ' '
context.page.handleSubmit()
assert.equal(events[1][0], 'invalid')
})
@@ -0,0 +1,18 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
for (const platform of ['frontend', 'app']) {
for (const kind of ['inbound', 'filling', 'shipment', 'usage', 'return']) {
test(`${platform}/${kind} 托盘号中的路径和查询字符作为单个标识传输`, async () => {
const source = await readFile(new URL(`../../shierfeng-fastapi-${platform}/src/api/business/${kind}.js`, import.meta.url), 'utf8')
const requests = []
const name = `scan${kind[0].toUpperCase()}${kind.slice(1)}Pallet`
const load = new Function('request', `${source.replace(/^import .*$/gm, '').replace(/export /g, '')}; return ${name}`)
const scan = load(config => { requests.push(config); return Promise.resolve({}) })
await scan('托盘/2026?批#1', { warehouseId: 1, sourceWarehouseId: 1, targetSiteId: 9 })
assert.equal(requests[0].url, `/business/${kind}/scan/${encodeURIComponent('托盘/2026?批#1')}`)
assert.equal(requests[0].method, 'get')
})
}
}
@@ -0,0 +1,247 @@
import assert from 'node:assert/strict'
import { test } from 'node:test'
import { parseHTML } from 'linkedom'
import { readFile } from 'node:fs/promises'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
const utilityUrl = new URL('../src/utils/shipmentLabelPrint.js', import.meta.url)
async function printer() {
let module
try { module = await import(utilityUrl.href) } catch (error) {
assert.fail(`缺少可执行的标签打印工具:${error.message}`)
}
return module.printShipmentLabels
}
function popup() {
const { document } = parseHTML('<html><head></head><body></body></html>')
const events = new EventTarget()
const result = {
document, closed: false, printed: 0, focused: false,
close() { this.closed = true },
focus() { this.focused = true },
print() { this.printed += 1 },
addEventListener: events.addEventListener.bind(events),
dispatchEvent: events.dispatchEvent.bind(events)
}
return result
}
const label = {
shipmentNo: 'SH-原单号', shipmentStatus: 'CONFIRMED', palletCode: '原托盘-01',
customerName: '<script>globalThis.compromised=true</script>', siteName: '现场 & A',
strainName: '<img src=x onerror=alert(1)>', batchNo: '<svg/onload=alert(2)>',
productionDate: '2026-09-01', expiryDate: null, packageQty: 37,
qrCodeDataUrl: 'data:image/png;base64,aGVsbG8='
}
async function loadImages(win) {
// 模拟浏览器图片加载事件;标签节点和内容解析使用真实 DOM。
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
for (const img of win.document.querySelectorAll('img')) {
Object.defineProperty(img, 'naturalWidth', { value: 100 })
img.dispatchEvent(new win.document.defaultView.Event('load'))
}
}
test('多托标签保留原始内容且不执行 HTML,二维码加载后才打印并逐托分页', async () => {
const print = await printer()
const win = popup()
const errors = []
const pending = print({ openWindow: () => win, loadLabels: async () => [label, { ...label, palletCode: '原托盘-02' }], notifyError: e => errors.push(e) })
await Promise.resolve()
assert.equal(win.printed, 0)
await loadImages(win)
assert.equal(await pending, true)
assert.equal(win.printed, 1)
assert.equal(win.document.querySelectorAll('script, svg').length, 0)
assert.equal(win.document.querySelectorAll('img').length, 2)
assert.equal(win.document.querySelectorAll('[onerror], [onload]').length, 0)
assert.ok(win.document.body.textContent.includes(label.customerName))
assert.ok(win.document.body.textContent.includes(label.strainName))
assert.ok(win.document.body.textContent.includes(label.batchNo))
for (const text of ['原托盘-01', '原托盘-02', 'SH-原单号', '现场 & A', '2026-09-01', '未登记', '37']) {
assert.ok(win.document.body.textContent.includes(text), text)
}
const pages = [...win.document.querySelectorAll('section')]
assert.equal(pages.length, 2)
assert.equal(pages[0].style.breakAfter, 'page')
assert.equal(pages[1].style.breakAfter, 'auto')
assert.equal(pages[0].style.breakInside, 'avoid')
assert.equal(win.document.querySelector('img').src, label.qrCodeDataUrl)
assert.deepEqual(errors, [])
win.dispatchEvent(new Event('afterprint'))
assert.equal(win.closed, true)
})
test('同步打开窗口以保留用户手势,弹窗被拦截时提示且不请求接口', async () => {
const print = await printer()
const calls = []
const errors = []
const pending = print({ openWindow: () => { calls.push('open'); return null }, loadLabels: async () => { calls.push('load'); return [label] }, notifyError: e => errors.push(e) })
assert.deepEqual(calls, ['open'])
assert.equal(await pending, false)
assert.match(errors[0], /弹窗|弹出/)
})
test('接口失败和空数据会关闭空窗口并提示,绝不打印', async () => {
const print = await printer()
for (const loadLabels of [async () => { throw new Error('network') }, async () => []]) {
const win = popup()
const errors = []
assert.equal(await print({ openWindow: () => win, loadLabels, notifyError: e => errors.push(e) }), false)
assert.equal(win.closed, true)
assert.equal(win.printed, 0)
assert.equal(errors.length, 1)
}
})
test('草稿显式标注,旧记录的两个日期显示未登记', async () => {
const print = await printer()
const win = popup()
const pending = print({ openWindow: () => win, loadLabels: async () => [{ ...label, shipmentStatus: 'DRAFT', productionDate: null }], notifyError: assert.fail })
await loadImages(win)
assert.equal(await pending, true)
assert.match(win.document.body.textContent, /草稿/)
assert.equal(win.document.body.textContent.match(/未登记/g).length, 2)
})
test('拒绝远程地址或非 PNG 二维码,避免不可信属性载荷', async () => {
const print = await printer()
for (const qrCodeDataUrl of ['https://attacker.example/track', 'javascript:alert(1)', 'data:image/svg+xml,<svg onload=alert(1)>']) {
const win = popup()
const errors = []
assert.equal(await print({ openWindow: () => win, loadLabels: async () => [{ ...label, qrCodeDataUrl }], notifyError: e => errors.push(e) }), false)
assert.equal(win.closed, true)
assert.equal(win.printed, 0)
assert.equal(errors.length, 1)
}
})
test('二维码加载失败会关闭窗口并提示', async () => {
const print = await printer()
const win = popup()
const errors = []
const pending = print({ openWindow: () => win, loadLabels: async () => [label], notifyError: e => errors.push(e) })
for (let i = 0; i < 10 && !win.document.querySelector('img'); i++) await Promise.resolve()
win.document.querySelector('img').dispatchEvent(new win.document.defaultView.Event('error'))
assert.equal(await pending, false)
assert.equal(win.closed, true)
assert.equal(win.printed, 0)
assert.match(errors[0], /二维码/)
})
test('异步过程中切换数据批次时丢弃标签并关闭窗口', async () => {
const print = await printer()
const win = popup()
const errors = []
let current = true
let resolve
const pending = print({ openWindow: () => win, loadLabels: () => new Promise(r => { resolve = r }), isCurrent: () => current, notifyError: e => errors.push(e) })
current = false
resolve([label])
assert.equal(await pending, false)
assert.equal(win.closed, true)
assert.equal(win.printed, 0)
assert.equal(win.document.querySelectorAll('img').length, 0)
assert.equal(errors.length, 1)
})
test('图片加载期间切换范围或关闭弹窗时不打印', async () => {
const print = await printer()
for (const change of ['scope', 'close']) {
const win = popup()
const errors = []
let current = true
const pending = print({ openWindow: () => win, loadLabels: async () => [label], isCurrent: () => current, notifyError: e => errors.push(e) })
await Promise.resolve()
if (change === 'scope') current = false
else win.close()
await loadImages(win)
assert.equal(await pending, false)
assert.equal(win.printed, 0)
assert.equal(win.closed, true)
assert.equal(errors.length, 1)
}
})
// 执行真实 SFC setup 和 API 封装,仅替换网络、窗口及 UI 提示边界。
function bindImports(source) {
return source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => {
const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`
return `const ${binding} = modules[${JSON.stringify(path)}];`
})
}
async function shipmentPage(load) {
const source = await readFile(new URL('../src/views/business/shipment/index.vue', import.meta.url), 'utf8')
const apiSource = await readFile(new URL('../src/api/business/shipment.js', import.meta.url), 'utf8')
const { descriptor } = parse(source)
const script = compileScript(descriptor, { id: 'shipment-label-test', genDefaultAs: 'component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'shipment/index.vue', id: 'shipment-label-test', compilerOptions: { bindingMetadata: script.bindings } })
assert.deepEqual(template.errors, [])
const exports = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const requests = []
const api = new Function('modules', `${bindImports(apiSource).replaceAll('export function ', 'function ')}; return { ${exports.join(',')} };`)({
'@/utils/request': { default: config => {
requests.push(config)
return config.url.endsWith('/labels') ? load(config) : Promise.resolve({ rows: [], total: 0 })
} }
})
const win = popup()
const errors = []
const batch = { currentBatchId: '', scopeVersion: 0 }
const print = await printer()
const modules = {
'@/components/PalletScanInput': { default: {} },
'@/components/TestBatchAdoptionDialog': { default: {} },
'@/utils/palletScan': { createIdempotencyKey: () => 'not-used-for-printing' },
'@/store/modules/user': { default: () => ({ admin: false }) },
'@/store/modules/testBatch': { default: () => batch },
'@/utils/testBatchContext': { getStoredTestBatchId: () => batch.currentBatchId },
'@/utils/shipmentLabelPrint': { printShipmentLabels: options => print({ ...options, openWindow: () => win }) },
'@/api/business/shipment': api
}
const runtime = { ...Vue, getCurrentInstance: () => ({ proxy: { $auth: { hasPermiAnd: () => true }, $modal: { msgError: e => errors.push(e) } } }) }
const factory = new Function('modules', ...Object.keys(runtime), `${bindImports(script.content)}; return component;`)
const component = factory(modules, ...Object.values(runtime))
const scope = Vue.effectScope()
const page = scope.run(() => component.setup({}, { expose() {} }))
return { page, win, batch, requests, errors, dispose: () => scope.stop() }
}
test('页面真实处理器调用标签 GET 接口、阻止重复点击并在完成后恢复按钮', async () => {
let resolve
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
try {
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
assert.equal(context.page.labelLoadingId.value, 12)
await context.page.handlePrintLabels({ shipmentId: 13 })
const requests = context.requests.filter(request => request.url.endsWith('/labels'))
assert.deepEqual(requests, [{ url: '/business/shipment/12/labels', method: 'get' }])
resolve({ data: [label] })
await loadImages(context.win)
await pending
assert.equal(context.win.printed, 1)
assert.equal(context.page.labelLoadingId.value, null)
assert.deepEqual(context.errors, [])
} finally { context.dispose() }
})
test('批次切换后又切回原批次,页面仍丢弃旧请求并解除加载状态', async () => {
let resolve
const context = await shipmentPage(() => new Promise(r => { resolve = r }))
try {
const pending = context.page.handlePrintLabels({ shipmentId: 12 })
context.batch.scopeVersion += 2
resolve({ data: [label] })
await loadImages(context.win)
await pending
assert.equal(context.win.printed, 0)
assert.equal(context.win.closed, true)
assert.equal(context.page.labelLoadingId.value, null)
assert.equal(context.errors.length, 1)
} finally { context.dispose() }
})
@@ -0,0 +1,378 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
// 两端均编译、挂载真实 SFC,并执行真实 API;仅替换 HTTP、平台生命周期与 UI 宿主。
const roots = {
pc: new URL('../', import.meta.url),
app: new URL('../../shierfeng-fastapi-app/', import.meta.url)
}
const read = (platform, path) => readFile(new URL(path, roots[platform]), 'utf8').catch(error => {
if (error.code === 'ENOENT') assert.fail(`尚未实现:${platform}/${path}`)
throw error
})
const deferred = () => {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const flush = () => new Promise(resolve => setImmediate(resolve))
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, path) => {
const binding = names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`
return `const ${binding} = modules[${JSON.stringify(path)}];`
})
const item = (palletId = 11) => ({
palletId, palletCode: `原托盘-${palletId}`, qrToken: `QR-${palletId}`, activeFillingId: palletId + 100,
packageQty: 37, strainName: '原品种', batchNo: '原批号-2026',
productionDate: '2026-09-01', expiryDate: '2027-02-28'
})
const detail = (shipmentId = 7, overrides = {}) => ({
shipmentId, shipmentNo: `SH-${shipmentId}`, targetSiteId: 21, sourceWarehouseId: 31,
status: 'CONFIRMED', receiptState: 'PENDING', receivedAt: null, items: [item(11), item(12)], ...overrides
})
function hostRenderer(Vue) {
const node = (type, text = '') => ({ type, text, children: [], props: {}, style: {}, parent: null })
return Vue.createRenderer({
createElement: type => node(type), createText: text => node('#text', text), createComment: () => node('#comment'),
insert(child, parent, anchor) {
if (child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1)
const index = anchor ? parent.children.indexOf(anchor) : -1
parent.children.splice(index < 0 ? parent.children.length : index, 0, child)
child.parent = parent
},
remove(child) { child.parent?.children.splice(child.parent.children.indexOf(child), 1); child.parent = null },
setText: (el, text) => { el.text = text },
setElementText: (el, text) => { el.text = text; el.children = [] },
patchProp: (el, key, old, value) => { el.props[key] = value },
parentNode: el => el.parent, nextSibling: el => el.parent?.children[el.parent.children.indexOf(el) + 1] || null
})
}
const nodes = root => [root, ...(root.children || []).flatMap(nodes)]
const visibleText = root => nodes(root).map(el => el.text || '').join(' ')
const confirmButton = page => nodes(page.root).find(el => el.type === 'button' && /确认收货/.test(visibleText(el)))
async function mount(platform, { kind = 'panel', permissions = ['business:shipment:receive'], request: override } = {}) {
const require = createRequire(new URL('package.json', roots[platform]))
const Vue = require('vue')
const { parse, compileScript, compileTemplate } = require('@vue/compiler-sfc')
const calls = [], messages = [], emitted = [], navigations = []
const hooks = { onShow: [], onHide: [], onUnload: [], onActivated: [], onDeactivated: [] }
const request = config => {
calls.push(structuredClone(config))
const result = override?.(config)
if (result !== undefined) return result
if (config.url.endsWith('/list')) return Promise.resolve({ rows: [detail()], total: 1 })
if (config.url.includes('/scan/')) return Promise.resolve({ data: item(Number(decodeURIComponent(config.url.split('/scan/')[1]).replace('QR-', ''))) })
if (config.url.endsWith('/confirm')) return Promise.resolve({ msg: '收货成功' })
if (/\/shipment-receipt\/\d+$/.test(config.url)) return Promise.resolve({ data: detail(Number(config.url.split('/').at(-1))) })
if (config.url.endsWith('-options')) return Promise.resolve({ data: [] })
throw new Error(`出现意外请求 ${config.method} ${config.url}`)
}
async function api(path) {
const source = await read(platform, path)
const names = [...source.matchAll(/export function (\w+)/g)].map(match => match[1])
return new Function('modules', `${bindImports(source).replaceAll('export function ', 'function ')}; return {${names.join(',')}};`)({
'@/utils/request': { default: request }
})
}
const hasPermi = permission => permissions.includes('*:*:*') || permissions.includes(permission)
const auth = { hasPermi, hasPermiOr: values => values.some(hasPermi), hasPermiAnd: values => values.every(hasPermi) }
const empty = { render: () => null }
const receiptStub = { name: 'ShipmentReceiptPanel', render: () => Vue.h('receipt-panel') }
const runtime = {
...Vue,
// uni-app 原生 input 没有浏览器 DOM;宿主保留值与输入回调边界。
vModelText: {
mounted(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] },
beforeUpdate(el, { value }, vnode) { el.props.value = value; el.props.onInput = vnode.props['onUpdate:modelValue'] }
},
defineAsyncComponent: () => receiptStub,
onActivated: fn => hooks.onActivated.push(fn), onDeactivated: fn => hooks.onDeactivated.push(fn)
}
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read(platform, 'src/utils/palletScan.js'))}`)
const modules = {
vue: runtime,
'@dcloudio/uni-app': Object.fromEntries(Object.entries(hooks).map(([name, values]) => [name, fn => values.push(fn)])),
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
'@/components/PalletScanInput': { default: empty },
'@/components/business/PalletScanInput.vue': { default: empty },
'@/components/business/TestBatchBanner.vue': { default: empty },
'@/components/business/TestBatchModeSwitch.vue': { default: empty },
'@/components/TestBatchAdoptionDialog': { default: empty },
'@/components/ShipmentReceiptPanel': { default: receiptStub },
'@/components/business/ShipmentReceiptPanel.vue': { default: receiptStub },
'@/store/modules/user': { default: () => ({ admin: false }) },
'@/store/modules/testBatch': { default: () => ({ isTestMode: false }) },
'@/utils/testBatchContext': { getStoredTestBatchId: () => '' },
'@/utils/shipmentLabelPrint': { printShipmentLabels() {} }
}
if (kind === 'panel') modules['@/api/business/shipmentReceipt'] = await api('src/api/business/shipmentReceipt.js')
if (kind === 'page') modules['@/api/business/shipment'] = await api('src/api/business/shipment.js')
if (kind === 'work') {
const source = await read('app', 'src/config/businessMenu.js')
modules['@/config/businessMenu'] = new Function(`${source.replaceAll('export ', '')}; return { businessMenuEntries };`)()
}
const path = kind === 'work' ? 'src/pages/work/index.vue' : kind === 'page'
? `src/${platform === 'pc' ? 'views' : 'pages'}/business/shipment/index.vue`
: platform === 'pc' ? 'src/components/ShipmentReceiptPanel/index.vue' : 'src/components/business/ShipmentReceiptPanel.vue'
const { descriptor } = parse(await read(platform, path))
const script = compileScript(descriptor, { id: 'receipt-test', genDefaultAs: 'component' })
const template = compileTemplate({ source: descriptor.template.content, filename: path, id: 'receipt-test', compilerOptions: {
bindingMetadata: script.bindings, hoistStatic: false, isCustomElement: tag => ['view', 'text', 'scroll-view', 'picker'].includes(tag)
} })
assert.deepEqual(template.errors, [])
const component = new Function('modules', ...Object.keys(runtime), `return (function () { ${bindImports(script.content)}; return component; })();`)(modules, ...Object.values(runtime))
component.render = new Function('modules', `${bindImports(template.code).replace('export function render', 'function render')}; return render;`)(modules)
let state
const setup = component.setup
component.setup = (props, ctx) => { state = setup(props, ctx); return state }
const root = { children: [] }
const app = hostRenderer(Vue).createApp(component, { onReceived: () => emitted.push('received') })
app.config.globalProperties.$auth = auth
app.config.globalProperties.$modal = Object.fromEntries(['msg', 'msgWarning', 'msgError', 'msgSuccess'].map(name => [name, msg => messages.push(msg)]))
app.config.globalProperties.$tab = { navigateTo: path => navigations.push(path) }
app.config.globalProperties.parseTime = value => value || '—'
for (const directive of ['loading', 'hasPermi']) app.directive(directive, {})
// Element Plus 外观替身保留默认/表格作用域插槽,断言实际渲染的文本与按钮。
for (const tag of ['el-form', 'el-form-item', 'el-row', 'el-col', 'el-tag', 'el-tabs', 'el-tab-pane', 'el-select', 'el-option', 'el-input', 'el-input-number', 'el-descriptions', 'el-descriptions-item', 'pagination']) {
app.component(tag, { setup: (_, { slots }) => () => Vue.h('div', slots.default?.()) })
}
app.component('el-button', { setup: (_, { slots }) => () => Vue.h('button', slots.default?.()) })
app.component('el-alert', { props: ['title'], setup: props => () => Vue.h('div', props.title) })
app.component('el-dialog', { props: ['modelValue'], setup: (props, { slots }) => () => props.modelValue ? Vue.h('div', [slots.default?.(), slots.footer?.()]) : null })
app.component('el-table', { props: ['data'], setup(props, { slots }) { Vue.provide('rows', Vue.computed(() => props.data || [])); return () => Vue.h('div', slots.default?.()) } })
app.component('el-table-column', { props: ['prop'], setup(props, { slots }) { const rows = Vue.inject('rows'); return () => Vue.h('div', rows.value.map(row => slots.default ? slots.default({ row }) : Vue.h('span', String(row[props.prop] ?? '')))) } })
app.mount(root)
await flush()
return {
state, calls, messages, emitted, root, navigations,
writes: () => calls.filter(call => call.method === 'put'),
open: async (id = 7) => { await state.openReceipt({ shipmentId: id }); await flush() },
scan: async (value = 'QR-11') => { await state.handleReceiptScan(value); await flush() },
lifecycle: async name => { for (const fn of hooks[name]) fn(); await flush() },
close: () => app.unmount()
}
}
for (const platform of ['pc', 'app']) {
test(`${platform} 原发货扫码只编码标识路径,保持出库查询参数`, async t => {
const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:query', 'business:shipment:add'], request: config => config.url.includes('/scan/') ? Promise.resolve({ data: { ...item(), flowState: 'FILLED_IN_WAREHOUSE', remainingPackages: 37 } }) : undefined }); t.after(page.close)
const form = platform === 'pc' ? page.state.form.value : page.state.form
Object.assign(form, { sourceWarehouseId: 31, targetSiteId: 21 })
await flush()
page.state.handleShipmentScan('原托盘 /?#')
await flush()
const scans = page.calls.filter(call => call.url.includes('/scan/'))
assert.deepEqual(scans, [{ url: '/business/shipment/scan/%E5%8E%9F%E6%89%98%E7%9B%98%20%2F%3F%23', method: 'get', params: { sourceWarehouseId: 31, targetSiteId: 21 } }])
})
test(`${platform} receive-only 进入收货页签,不自动调用出库 API`, async t => {
const page = await mount(platform, { kind: 'page' }); t.after(page.close)
await page.lifecycle('onShow')
assert.equal(page.state.activeTab?.value, 'receipt')
assert.ok(nodes(page.root).some(node => node.type === 'receipt-panel'))
assert.deepEqual(page.calls, [])
})
test(`${platform} 收货列表按服务端状态、单号分页查询,不查询来源仓库权限`, async t => {
const page = await mount(platform); t.after(page.close)
assert.deepEqual(page.calls, [{ url: '/business/shipment-receipt/list', method: 'get', params: { pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined } }])
Object.assign(page.state.queryParams, { receiptState: 'LEGACY', shipmentNo: 'SH-9', pageNum: 3 })
await page.state.loadReceipts()
assert.deepEqual(page.calls.at(-1).params, { pageNum: 3, pageSize: 10, receiptState: 'LEGACY', shipmentNo: 'SH-9' })
assert.equal(page.state.total.value, 1)
page.state.queryParams.receiptState = platform === 'pc' ? '' : undefined
await page.state.handleQuery()
assert.equal(page.calls.at(-1).params.receiptState, undefined, '全部状态必须省略筛选,不能向枚举参数发送空字符串')
assert.equal(page.calls.at(-1).params.pageNum, 1)
})
test(`${platform} 无接收权限不能列单、打开、扫码或确认`, async t => {
const page = await mount(platform, { permissions: ['business:shipment:list'] }); t.after(page.close)
await page.open(); await page.scan(); await page.state.confirmReceipt()
assert.deepEqual(page.calls, [])
})
test(`${platform} 首次核对从零开始,扫码保留原品种批号日期包数,全数核对才解锁`, async t => {
const page = await mount(platform); t.after(page.close)
await page.open()
assert.deepEqual(page.state.scannedPalletIds.value, [])
assert.equal(confirmButton(page).props.disabled, true)
await page.state.confirmReceipt()
assert.equal(page.writes().length, 0)
await page.scan()
assert.deepEqual(page.state.scannedPalletIds.value, [11])
for (const text of ['原托盘-11', '原品种', '原批号-2026', '2026-09-01', '2027-02-28', '37']) assert.ok(visibleText(page.root).includes(text), text)
assert.equal(confirmButton(page).props.disabled, true)
await page.scan('QR-12')
assert.equal(confirmButton(page).props.disabled, false)
})
test(`${platform} 扫码编码路径,错单/旧装填/服务端拒绝不会计入,重复不追加`, async t => {
let result = item()
const page = await mount(platform, { request: config => config.url.includes('/scan/') ? Promise.resolve({ data: result }) : undefined }); t.after(page.close)
await page.open(); await page.scan('托盘 /?#')
assert.equal(page.calls.at(-1).url, '/business/shipment-receipt/7/scan/%E6%89%98%E7%9B%98%20%2F%3F%23')
await page.scan('QR-11')
assert.deepEqual(page.state.scannedPalletIds.value, [11])
for (const wrong of [item(99), { ...item(12), activeFillingId: 999 }]) {
result = wrong; await page.scan('wrong')
assert.deepEqual(page.state.scannedPalletIds.value, [11])
assert.ok(page.state.scanError.value)
}
})
test(`${platform} 网络失败重试同键,移除再扫后新键,确认锁阻止重复及修改集合`, async t => {
const pending = deferred()
let attempts = 0
const page = await mount(platform, { request: config => config.method === 'put' ? (++attempts < 3 ? Promise.reject(new Error('网络断开')) : pending.promise) : undefined }); t.after(page.close)
await page.open(); await page.scan(); await page.scan('QR-12')
await page.state.confirmReceipt(); await page.state.confirmReceipt()
assert.equal(page.writes().length, 2)
assert.ok(page.state.confirmError.value)
assert.equal(page.writes()[0].data.idempotencyKey, page.writes()[1].data.idempotencyKey)
page.state.removeScannedPallet(12); await page.scan('QR-12')
const confirming = page.state.confirmReceipt()
await page.state.confirmReceipt(); page.state.removeScannedPallet(11)
await page.scan('QR-11')
assert.equal(page.writes().length, 3)
assert.notEqual(page.writes()[2].data.idempotencyKey, page.writes()[1].data.idempotencyKey)
assert.deepEqual(page.writes()[2].data.scannedPalletIds, [11, 12])
assert.deepEqual(page.state.scannedPalletIds.value, [11, 12])
pending.reject(new Error('仍不可用')); await confirming
assert.equal(page.state.confirmLoading.value, false)
})
test(`${platform} 扫码进行中锁住重复请求,服务端拒绝保留已核对集合且允许重试`, async t => {
const pending = deferred()
let block = false
const page = await mount(platform, { request: config => block && config.url.includes('/scan/') ? pending.promise : undefined }); t.after(page.close)
await page.open(); await page.scan()
block = true
const scanning = page.state.handleReceiptScan('QR-12')
await page.state.handleReceiptScan('QR-12')
page.state.removeScannedPallet(11)
await page.state.confirmReceipt()
assert.equal(page.calls.filter(call => call.url.includes('/scan/')).length, 2)
assert.deepEqual(page.state.scannedPalletIds.value, [11])
assert.equal(page.writes().length, 0)
pending.reject(new Error('托盘状态已变化,拒绝核对')); await scanning
assert.match(page.state.scanError.value, /状态已变化/)
assert.equal(page.state.scanLoading.value, false)
block = false; await page.scan('QR-12')
assert.deepEqual(page.state.scannedPalletIds.value, [11, 12])
})
test(`${platform} 列表过期响应不覆盖新筛选,详情失败可重试,空明细不可确认`, async t => {
const oldList = deferred()
let lists = 0, failDetail = true
const page = await mount(platform, { request: config => {
if (config.url.endsWith('/list') && ++lists === 1) return oldList.promise
if (config.url.endsWith('/7')) return failDetail ? Promise.reject(new Error('详情暂不可用')) : Promise.resolve({ data: detail(7, { items: [] }) })
} }); t.after(page.close)
page.state.queryParams.receiptState = 'RECEIVED'
await page.state.handleQuery()
oldList.resolve({ rows: [detail(99)], total: 99 }); await flush()
assert.equal(page.state.receiptList.value[0].shipmentId, 7)
assert.equal(page.state.total.value, 1)
await page.open(); assert.ok(page.state.detailError.value)
await page.scan(); assert.equal(page.calls.some(call => call.url.includes('/scan/')), false)
failDetail = false; await page.state.retryDetail(); await flush()
assert.equal(page.state.detailError.value, '')
assert.equal(confirmButton(page).props.disabled, true)
await page.state.confirmReceipt(); assert.equal(page.writes().length, 0)
})
test(`${platform} 收货成功仅重新读取服务端状态,并通知出库列表刷新`, async t => {
let confirmed = false
const page = await mount(platform, { request: config => {
if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '收货成功' }) }
if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.resolve({ data: detail(7, { receiptState: 'RECEIVED', receivedAt: '2026-10-08 11:22:33' }) })
} }); t.after(page.close)
await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush()
assert.equal(page.state.receiptDetail.value.receiptState, 'RECEIVED')
assert.ok(visibleText(page.root).includes('2026-10-08 11:22:33'))
assert.deepEqual(page.emitted, ['received'])
assert.equal(page.calls.filter(call => call.url.endsWith('/list')).length, 2)
await page.state.confirmReceipt()
assert.equal(page.writes().length, 1)
})
test(`${platform} 成功后状态刷新失败不会伪造已收货或再次提交`, async t => {
let confirmed = false
const page = await mount(platform, { request: config => {
if (config.method === 'put') { confirmed = true; return Promise.resolve({ msg: '成功' }) }
if (confirmed && /\/shipment-receipt\/7$/.test(config.url)) return Promise.reject(new Error('读取失败'))
} }); t.after(page.close)
await page.open(); await page.scan(); await page.scan('QR-12'); await page.state.confirmReceipt(); await flush()
assert.ok(page.state.detailError.value)
assert.notEqual(page.state.receiptDetail.value?.receiptState, 'RECEIVED')
await page.state.confirmReceipt()
assert.equal(page.writes().length, 1)
})
for (const receiptState of ['LEGACY', 'RECEIVED']) {
test(`${platform} ${receiptState} 只读,历史记录不伪造收货时间`, async t => {
const page = await mount(platform, { request: config => /\/shipment-receipt\/7$/.test(config.url) ? Promise.resolve({ data: detail(7, { receiptState, receivedAt: receiptState === 'LEGACY' ? '错误历史日期' : '2026-10-08' }) }) : undefined }); t.after(page.close)
await page.open(); await page.scan(); await page.state.confirmReceipt()
assert.equal(page.calls.some(call => call.url.includes('/scan/') || call.method === 'put'), false)
assert.ok(visibleText(page.root).includes(receiptState === 'LEGACY' ? '历史未登记收货' : '已收货'))
assert.equal(visibleText(page.root).includes('错误历史日期'), false)
})
}
test(`${platform} 切换单据丢弃旧详情,关闭丢弃旧扫码,重开从零核对`, async t => {
const oldDetail = deferred(), oldScan = deferred()
let deferDetail = true
const page = await mount(platform, { request: config => {
if (deferDetail && config.url.endsWith('/7')) return oldDetail.promise
if (config.url.includes('/scan/')) return oldScan.promise
} }); t.after(page.close)
const opening = page.state.openReceipt({ shipmentId: 7 })
await page.open(8); oldDetail.resolve({ data: detail(7) }); await opening
assert.equal(page.state.receiptDetail.value.shipmentId, 8)
const scanning = page.state.handleReceiptScan('QR-11')
page.state.closeReceipt(); deferDetail = false
await page.open(7); oldScan.resolve({ data: item() }); await scanning
assert.deepEqual(page.state.scannedPalletIds.value, [])
assert.equal(page.state.scanLoading.value, false)
})
test(`${platform} 旧确认响应不能关闭新单或通知成功,离开页面使请求失效`, async t => {
const oldConfirm = deferred(), oldScan = deferred()
let deferScan = false
const page = await mount(platform, { request: config => {
if (config.method === 'put') return oldConfirm.promise
if (deferScan && config.url.includes('/scan/')) return oldScan.promise
} }); t.after(page.close)
await page.open(); await page.scan(); await page.scan('QR-12')
const confirming = page.state.confirmReceipt()
await page.open(8); oldConfirm.resolve({ msg: '旧单成功' }); await confirming
assert.equal(page.state.receiptDetail.value.shipmentId, 8)
assert.deepEqual(page.emitted, [])
deferScan = true
const scanning = page.state.handleReceiptScan('QR-11')
await page.lifecycle(platform === 'pc' ? 'onDeactivated' : 'onHide')
oldScan.resolve({ data: item() }); await scanning
assert.deepEqual(page.state.scannedPalletIds.value, [])
})
test(`${platform} 新发货与收货结果显示服务端状态`, async t => {
const page = await mount(platform, { kind: 'page', permissions: ['business:shipment:list'], request: config => config.url.endsWith('/list') ? Promise.resolve({ rows: [detail(), detail(8, { receiptState: 'RECEIVED' }), detail(9, { receiptState: 'LEGACY' })], total: 3 }) : undefined }); t.after(page.close)
await page.lifecycle('onShow')
const text = visibleText(page.root)
for (const label of ['待收货', '已收货', '历史未登记收货']) assert.ok(text.includes(label), label)
})
}
test('APP 工作台允许 receive-only 账号从原发货入口进入,未授权入口仍不可见', async t => {
const page = await mount('app', { kind: 'work' }); t.after(page.close)
const entries = page.state.visibleBusinessEntries.value
assert.equal(entries.length, 1)
assert.equal(entries[0].path, '/pages/business/shipment/index')
page.state.handleBusinessEntry(entries[0])
assert.deepEqual(page.navigations, ['/pages/business/shipment/index'])
})
@@ -0,0 +1,755 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { webcrypto } from 'node:crypto'
import { test } from 'node:test'
import * as Vue from 'vue'
import { compileScript, compileTemplate, parse } from '@vue/compiler-sfc'
// 执行真实 setup、模板和 API,仅替换网络、浏览器存储及组件宿主边界。
const read = path => readFile(new URL(`../src/${path}`, import.meta.url), 'utf8')
const { descriptor } = parse(await read('views/business/usage/index.vue'))
const script = compileScript(descriptor, { id: 'pc-usage-test', genDefaultAs: '__component' })
const template = compileTemplate({ source: descriptor.template.content, filename: 'usage/index.vue', id: 'pc-usage-test', compilerOptions: { bindingMetadata: script.bindings } })
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 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 flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
function deferred() {
let resolve, reject
const promise = new Promise((yes, no) => { resolve = yes; reject = no })
return { promise, resolve, reject }
}
const tunnels = [{ id: 31, name: '一号隧道', code: 'SD-01' }]
const lines = [{ id: 41, name: '一号产线', code: 'CX-01' }]
const pallet = (overrides = {}) => ({ palletId: 11, palletCode: '托盘-001', qrToken: 'QR-001', siteId: 21, strainId: 1, strainName: '菌种A', batchNo: 'B-01', tunnelId: null, currentLineId: null, remainingPackages: 100, flowState: 'AT_SITE_UNUSED', productionDate: null, expiryDate: null, ...overrides })
const boundPallet = (overrides = {}) => pallet({ tunnelId: 31, tunnelName: '一号隧道', tunnelCode: 'SD-01', traceNo: 'INTERNAL-TRACE', ...overrides })
const renderer = Vue.createRenderer({ createElement: () => ({}), createText: () => ({}), createComment: () => ({}), insert() {}, remove() {}, setText() {}, setElementText() {}, patchProp() {}, parentNode: () => null, nextSibling: () => null })
function flatten(vnode) {
if (Array.isArray(vnode)) return vnode.flatMap(flatten)
if (!vnode || typeof vnode !== 'object') return []
const children = vnode.children
const slots = children && !Array.isArray(children) && typeof children === 'object'
? Object.entries(children).filter(([key, value]) => !key.startsWith('_') && typeof value === 'function').flatMap(([, slot]) => flatten(slot({ row: {} }))) : []
return [vnode, ...flatten(children), ...slots]
}
function createPage({ scan = () => pallet(), request: override, storage = new Map(), storageWriteError = false, storageRemoveError = false, confirm = () => Promise.resolve(), canOperate = true, userId = 7, batchId = '' } = {}) {
const requests = [], messages = [], confirmations = [], successMessages = [], hooks = { onActivated: [], onDeactivated: [], onBeforeUnmount: [] }
const user = Vue.reactive({ id: userId, admin: false }), batch = Vue.reactive({ currentBatchId: batchId, scopeVersion: 0 })
const request = config => {
requests.push(structuredClone(config))
const custom = override?.(config)
if (custom !== undefined) return custom
if (config.url.includes('/scan/')) return Promise.resolve({ data: scan(decodeURIComponent(config.url.split('/scan/')[1])) })
if (config.url.endsWith('/tunnel-options')) return Promise.resolve({ data: tunnels })
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.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}`)
}
const names = [...apiSource.matchAll(/export function (\w+)/g)].map(match => match[1])
const api = new Function('__modules', `${bindImports(apiSource).replace(/export function /g, 'function ')}\nreturn { ${names.join(',')} };`)({ '@/utils/request': { default: request } })
const localStorage = {
getItem: key => storage.get(key) ?? null,
setItem(key, value) { if (storageWriteError) throw new Error('存储不可用'); storage.set(key, value) },
removeItem(key) { if (storageRemoveError) throw new Error('存储清理失败'); storage.delete(key) }
}
const proxy = { $auth: { hasPermiAnd: () => canOperate }, $modal: {
msgSuccess(msg) { successMessages.push(msg); messages.push(msg) },
msgWarning: msg => messages.push(msg),
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) } }
// 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]))
const scope = Vue.effectScope(), state = scope.run(() => component.setup({}, { expose() {} }))
const render = new Function('__modules', `${bindImports(template.code).replace('export function render', 'function render')}\nreturn render;`)(modules)
return {
state, api, requests, messages, confirmations, successMessages, storage, user, batch,
post: (url = '/business/usage') => requests.filter(item => item.method === 'post' && item.url === url),
async scan(value = 'QR-001') { state.handleUsageScan(value); await flush() },
async act(name) { state[name](); await flush() },
async lifecycle(name) { hooks[name].forEach(fn => fn()); await flush() },
nodes() {
let nodes
const app = renderer.createApp({ render() { nodes = flatten(render({ parseTime: value => value }, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
app.mount({}); app.unmount()
return nodes
},
close() { hooks.onBeforeUnmount.forEach(fn => fn()); scope.stop() }
}
}
const text = page => page.nodes().map(node => [typeof node.children === 'string' ? node.children : '', node.props?.title || '', node.props?.label || ''].join(' ')).join(' ')
const button = (page, label) => page.nodes().find(node => node.type?.name === 'el-button' && flatten(node).some(child => typeof child.children === 'string' && child.children.includes(label)))
async function retry(page) {
const entry = button(page, '恢复重试')
assert.ok(entry, '必须提供无需重新扫码的恢复重试入口')
entry.props.onClick(); await flush()
}
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')
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()
})
for (const failure of ['reject', 'pending']) test(`可选产线 ${failure} 不丢失或阻塞隧道成功结果`, async () => {
const slow = deferred()
const page = createPage({ request: config => config.url.endsWith('/line-options') ? (failure === 'reject' ? Promise.reject(new Error('产线不可用')) : slow.promise) : undefined })
await page.scan()
assert.deepEqual(page.state.tunnelOptions.value, tunnels)
assert.equal(page.state.optionLoading.value, false)
if (failure === 'reject') assert.match(text(page), /产线.*失败|产线.*不可用/)
page.state.form.value.tunnelId = 31
await page.act('enterTunnel')
page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.post()[0].data.lineId ?? null, null)
page.close(); slow.resolve({ data: [] }); await flush()
})
test('摘要展示历史日期未登记、真实日期及隧道名称编号,普通页无内部溯源输入列', async () => {
const page = createPage({ scan: () => boundPallet() })
await page.scan()
assert.match(text(page), /生产日期.*未登记/)
assert.match(text(page), /有效期.*未登记/)
assert.match(text(page), /一号隧道/)
assert.match(text(page), /SD-01/)
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
assert.equal(page.nodes().filter(node => node.props?.prop === 'traceNo').length, 0)
page.close()
const dated = createPage({ scan: () => boundPallet({ productionDate: '2026-10-01', expiryDate: '2027-04-01' }) })
await dated.scan()
assert.match(text(dated), /2026-10-01/); assert.match(text(dated), /2027-04-01/)
dated.close()
})
test('新入隧后摘要使用选项的名称与编号', async () => {
const page = createPage()
await page.scan(); page.state.form.value.tunnelId = 31
await page.act('enterTunnel')
assert.match(text(page), /一号隧道/); assert.match(text(page), /SD-01/)
page.close()
})
test('已入隧扫码仅返回 ID 时从选项补齐隧道名称编号', async () => {
const page = createPage({ scan: () => boundPallet({ tunnelName: undefined, tunnelCode: undefined }) })
await page.scan()
assert.match(text(page), /一号隧道/); assert.match(text(page), /SD-01/)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
test('入隧成功保留已填写的可选产线及包数,尚未使用时产线仍可调整', async () => {
const page = createPage()
await page.scan()
page.state.form.value.tunnelId = 31
page.state.form.value.lineId = 41
page.state.form.value.usedPackages = 10
await page.act('enterTunnel')
assert.equal(page.state.form.value.lineId, 41)
assert.equal(page.state.form.value.usedPackages, 10)
assert.equal(page.state.lineLocked.value, false)
await page.act('submitUsage')
assert.equal(page.post()[0].data.lineId, 41)
page.close()
})
for (const kind of ['entry', 'usage']) test(`${kind} 缺少确认结果时保留原凭据,不把空响应当作成功`, async () => {
const page = createPage({ scan: () => kind === 'usage' ? boundPallet() : pallet(), request: config => config.method === 'post' ? Promise.resolve({ data: {} }) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act(kind === 'usage' ? 'submitUsage' : 'enterTunnel')
assert.ok(button(page, '恢复重试'))
assert.deepEqual(page.messages, [])
assert.equal(page.state.usageResult.value, null)
page.close()
})
test('FIFO 展示服务端提示及推荐托盘,未知到货时间不能被当作排序依据', 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: 2, message: '另有到货时间未登记的物料,请人工核对' } }) : undefined })
await page.scan()
assert.match(text(page), /另有到货时间未登记的物料,请人工核对/)
assert.match(text(page), /较早托盘/); assert.match(text(page), /较早批次/)
page.close()
})
for (const outcome of ['pending', 'reject']) test(`FIFO ${outcome} 不阻止入隧及实际使用`, async () => {
const slow = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation') ? (outcome === 'reject' ? Promise.reject(new Error('FIFO 失败')) : slow.promise) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
if (outcome === 'reject') assert.match(text(page), /先到先用.*人工核对|FIFO.*人工核对/)
await page.act('enterTunnel'); page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.state.usageResult.value.remainingPackages, 83)
page.close(); slow.resolve({ data: { recommended: null, message: '晚到提示' } }); await flush()
})
for (const outcome of ['resolve', 'reject']) test(`旧 FIFO ${outcome} 不污染重扫及离页后的状态`, async () => {
const slow = deferred(); let count = 0
const page = createPage({ request: config => config.url.endsWith('/recommendation') && ++count === 1 ? slow.promise : undefined })
await page.scan(); await page.act('resetUsageOperation'); await page.scan('NEW')
assert.match(text(page), /请按实际接收时间先到先用/)
if (outcome === 'resolve') slow.resolve({ data: { recommended: null, message: 'OLD-FIFO' } })
else slow.reject(new Error('OLD-FIFO'))
await flush()
assert.match(text(page), /请按实际接收时间先到先用/)
assert.doesNotMatch(text(page), /OLD-FIFO|建议获取失败/)
page.close()
})
for (const amount of [10, 60, 100]) test(`扣减 ${amount} 包后响应丢失:清空、重扫、重建页面只重放原 payload/key`, async () => {
const storage = new Map(), applied = new Map(); let remaining = 100, deductions = 0
const request = config => {
if (config.url !== '/business/usage') return
if (applied.has(config.data.idempotencyKey)) return Promise.resolve(applied.get(config.data.idempotencyKey))
deductions++; remaining -= config.data.usedPackages
applied.set(config.data.idempotencyKey, { data: { usageId: 71, remainingPackages: remaining, flowState: remaining ? 'AT_SITE_PARTIAL' : 'AT_SITE_EMPTY' } })
return Promise.reject(new Error('提交成功后响应丢失'))
}
const first = createPage({ storage, request, scan: () => boundPallet() })
await first.scan(); first.state.form.value.usedPackages = amount
await first.act('submitUsage')
const original = first.post()[0].data
await first.act('resetUsageOperation'); await first.scan()
first.state.form.value.usedPackages = amount + 1
await first.act('submitUsage')
assert.equal(first.post().length, 1, '未确认操作不可用新键再次扣减')
first.close()
const second = createPage({ storage, request, scan: () => boundPallet({ remainingPackages: remaining }) })
assert.match(text(second), /托盘-001/)
await retry(second)
assert.deepEqual(second.post()[0].data, original)
assert.equal(deductions, 1)
assert.equal(second.state.usageResult.value.remainingPackages, 100 - amount)
assert.equal(button(second, '恢复重试'), undefined)
second.close()
})
test('未知入隧结果同样持久化,并保持独立于使用的幂等键', async () => {
const storage = new Map()
const first = createPage({ storage, request: config => config.url.endsWith('/enter-tunnel') ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.tunnelId = 31
await first.act('enterTunnel')
const original = first.post('/business/usage/enter-tunnel')[0].data
first.state.form.value.tunnelId = 32
await first.act('enterTunnel')
assert.equal(first.post('/business/usage/enter-tunnel').length, 1)
first.close()
const second = createPage({ storage })
await retry(second)
assert.deepEqual(second.post('/business/usage/enter-tunnel')[0].data, original)
assert.equal(second.state.tunnelLocked.value, true)
second.state.form.value.usedPackages = 10
await second.act('submitUsage')
assert.notEqual(second.post()[0].data.idempotencyKey, original.idempotencyKey)
second.close()
})
test('恢复记录按账号和 test batch 隔离,切回原范围可恢复', async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.usedPackages = 10; await first.act('submitUsage'); first.close()
for (const identity of [{ userId: 8, batchId: '' }, { userId: 7, batchId: '99' }]) {
const other = createPage({ storage, ...identity })
assert.equal(button(other, '恢复重试'), undefined)
other.close()
}
const page = createPage({ storage })
assert.ok(button(page, '恢复重试'))
page.batch.currentBatchId = '99'; await flush()
assert.equal(button(page, '恢复重试'), undefined)
page.batch.currentBatchId = ''; await flush()
await retry(page); assert.equal(page.post().length, 1)
page.close()
})
for (const outcome of ['resolve', 'reject']) test(`离页后旧写请求 ${outcome} 不能污染重建状态或换掉恢复键`, async () => {
const slow = deferred(), storage = new Map()
const page = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? slow.promise : undefined })
await page.scan(); page.state.form.value.usedPackages = 10
page.state.submitUsage(); await flush()
await page.lifecycle('onDeactivated'); await page.lifecycle('onActivated')
if (outcome === 'resolve') slow.resolve({ data: { remainingPackages: 90, flowState: 'AT_SITE_PARTIAL' } })
else slow.reject(new Error('OLD-ERROR'))
await flush()
assert.equal(page.state.usageResult.value, null)
assert.deepEqual(page.messages, [])
assert.ok(button(page, '恢复重试'))
page.close()
})
test('恢复凭据写入失败时不能先扣减', async () => {
const page = createPage({ storageWriteError: true, scan: () => boundPallet() })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
assert.equal(page.post().length, 0)
assert.match(text(page), /存储/)
page.close()
})
test('恢复记录损坏时提示人工核对且不静默覆盖', async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => boundPallet(), request: config => config.url === '/business/usage' ? Promise.reject(new Error('响应丢失')) : undefined })
await first.scan(); first.state.form.value.usedPackages = 10; await first.act('submitUsage'); first.close()
const key = [...storage.keys()][0]
storage.set(key, '损坏记录')
const second = createPage({ storage })
await second.scan(); await second.act('submitUsage')
assert.match(text(second), /损坏.*人工核对/)
assert.equal(second.post().length, 0)
assert.equal(storage.get(key), '损坏记录')
second.close()
})
for (const endpoint of ['/business/usage/scan/QR-001', '/business/usage/tunnel-options', '/business/usage/line-options', '/business/usage-fifo/recommendation', '/business/usage/list']) {
test(`${endpoint} 离页后晚到的读响应不污染新会话`, async () => {
const slow = deferred(); let count = 0
const page = createPage({ request: config => config.url === endpoint && ++count === 1 ? slow.promise : undefined })
await page.scan(); await page.lifecycle('onDeactivated'); await page.lifecycle('onActivated')
await page.scan('NEW')
const before = text(page)
slow.resolve({ rows: [{ usageId: 'OLD' }], total: 99, data: endpoint.includes('/scan/') ? pallet({ palletCode: 'OLD', palletId: 99 }) : endpoint.endsWith('-options') ? [{ id: 99, name: 'OLD' }] : { message: 'OLD', recommended: null } })
await flush()
assert.equal(text(page), before)
assert.equal(page.state.total.value, 0)
page.close()
})
}
test('既有权限、入隧顺序、包数校验与成功后防重复继续生效', async () => {
const denied = createPage({ canOperate: false })
await denied.scan(); denied.state.form.value.tunnelId = 31
await denied.act('enterTunnel'); await denied.act('submitUsage')
assert.equal(denied.requests.filter(item => item.method === 'post').length, 0)
denied.close()
const page = createPage()
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act('submitUsage'); assert.equal(page.post().length, 0)
await page.act('enterTunnel')
for (const value of [undefined, 0, -1, 1.5, 101]) {
page.state.form.value.usedPackages = value; await page.act('submitUsage')
}
assert.equal(page.post().length, 0)
page.state.form.value.usedPackages = 10; await page.act('submitUsage'); await page.act('submitUsage')
assert.equal(page.post().length, 1)
assert.equal(page.state.usageResult.value.remainingPackages, 83)
page.close()
})
test('已绑定产线保持锁定并提交原产线,余量状态完全采用后端结果', async () => {
const page = createPage({ scan: () => boundPallet({ currentLineId: 41 }) })
await page.scan()
assert.equal(page.state.lineLocked.value, true)
assert.equal(page.state.form.value.lineId, 41)
const lineSelect = page.nodes().find(node => node.type?.name === 'el-select' && node.props?.modelValue === 41)
assert.equal(lineSelect.props.disabled, true)
page.state.form.value.usedPackages = 10
await page.act('submitUsage')
assert.equal(page.post()[0].data.lineId, 41)
assert.deepEqual(page.state.usageResult.value, { remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' })
page.close()
})
async function seedUnknown(storage, identity = {}, kind = 'usage') {
const page = createPage({ storage, ...identity, scan: () => kind === 'usage' ? boundPallet() : pallet(), request: config => config.method === 'post' ? Promise.reject(new Error('后端持续拒绝')) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; page.state.form.value.usedPackages = 10
await page.act(kind === 'usage' ? 'submitUsage' : 'enterTunnel')
assert.equal(page.requests.filter(item => item.method === 'post').length, 1)
page.close()
}
function releaseButton(page) {
const entry = button(page, '人工核对后解除待确认')
assert.ok(entry, '持续拒绝时必须有二次确认的人工核对释放入口')
return entry
}
for (const cancel of ['cancel', 'close']) test(`人工释放 ${cancel} 不清理记录且不发送业务请求`, async () => {
const storage = new Map()
await seedUnknown(storage)
const before = [...storage.entries()]
const page = createPage({ storage, confirm: () => Promise.reject(cancel) })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before)
assert.ok(button(page, '恢复重试'))
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.messages, [])
page.close()
})
for (const kind of ['entry', 'usage']) test(`人工确认只释放 ${kind} 本机待确认记录,不重放或宣称成功`, async () => {
const storage = new Map(), confirmation = deferred()
await seedUnknown(storage, {}, kind)
const before = [...storage.entries()]
const page = createPage({ storage, confirm: () => confirmation.promise })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before, '弹窗出现时不能提前清理')
assert.match(page.confirmations[0], /使用记录/)
assert.match(page.confirmations[0], /库存/)
assert.match(page.confirmations[0], /重复.*扣减|重复.*提交/)
assert.match(page.confirmations[0], /本机/)
assert.match(page.confirmations[0], /托盘-001/)
assert.equal(button(page, '恢复重试').props.disabled, true)
await page.act('retryPendingOperation')
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
confirmation.resolve(); await flush()
assert.equal(storage.size, 0)
assert.equal(page.state.pendingOperation.value, null)
assert.equal(page.state.scannedPallet.value, null, '必须重新扫描核对当前库存,不能沿用旧快照')
assert.equal(page.state.form.value.qrToken, '')
assert.equal(page.state.usageResult.value, null)
assert.equal(page.state.recoveryRequired.value, false)
assert.equal(button(page, '恢复重试'), undefined)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
assert.match(page.messages.join(' '), /仅.*本机/)
page.close()
const rebuilt = createPage({ storage })
assert.equal(button(rebuilt, '恢复重试'), undefined)
rebuilt.close()
})
test('人工释放只影响当前账号及测试批次,新增权限撤销后仍可清理本机记录', async () => {
const storage = new Map()
for (const identity of [{}, { userId: 8 }, { batchId: '99' }]) await seedUnknown(storage, identity)
const page = createPage({ storage, canOperate: false })
releaseButton(page).props.onClick(); await flush()
assert.equal(storage.size, 2)
assert.equal(button(page, '恢复重试'), undefined)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
page.close()
for (const identity of [{ userId: 8 }, { batchId: '99' }]) {
const other = createPage({ storage, ...identity })
assert.ok(button(other, '恢复重试'))
other.close()
}
})
for (const change of ['user', 'batch', 'page', 'record', 'removed']) test(`人工确认期间 ${change} 变化后旧确认不能删除记录`, async () => {
const storage = new Map(), confirmation = deferred()
await seedUnknown(storage)
const page = createPage({ storage, confirm: () => confirmation.promise })
releaseButton(page).props.onClick(); await flush()
if (change === 'user') page.user.id = 8
if (change === 'batch') page.batch.currentBatchId = '99'
if (change === 'page') await page.lifecycle('onDeactivated')
if (change === 'record') {
const [key, raw] = [...storage.entries()][0]
const changed = JSON.parse(raw)
changed.payload.idempotencyKey = 'OTHER-OPERATION-KEY'
storage.set(key, JSON.stringify(changed))
}
if (change === 'removed') storage.delete([...storage.keys()][0])
const before = [...storage.entries()]
confirmation.resolve(); await flush()
assert.deepEqual([...storage.entries()], before)
if (change === 'record' || change === 'removed') assert.equal(page.state.recoveryRequired.value, true, '记录发生变化时不能把旧确认当作有效释放')
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
page.close()
})
test('人工释放遇到本机清理失败时保留凭据并显示错误', async () => {
const storage = new Map()
await seedUnknown(storage)
const before = [...storage.entries()]
const page = createPage({ storage, storageRemoveError: true })
releaseButton(page).props.onClick(); await flush()
assert.deepEqual([...storage.entries()], before)
assert.ok(button(page, '恢复重试'))
assert.match(text(page), /清理.*失败|解除.*失败/)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
assert.deepEqual(page.successMessages, [])
page.close()
})
const fifoData = (requiresSkipReason = true) => ({ recommended: null, unknownArrivalCount: 0, requiresSkipReason, message: '请按实际到货时间核对' })
const fifoRejection = () => Object.assign(new Error('服务端拒绝'), { response: { data: { code: 500, data: 'FIFO_SKIP_REASON_REQUIRED', msg: '请填写跳过原因' } } })
const fifoRequests = page => page.requests.filter(item => item.url.endsWith('/recommendation'))
for (const kind of ['entry', 'usage']) {
const action = kind === 'entry' ? 'enterTunnel' : 'submitUsage'
const endpoint = kind === 'entry' ? '/business/usage/enter-tunnel' : '/business/usage'
for (const reason of [' \n ', '字'.repeat(501)]) test(`FIFO ${kind} 空白或超长原因不发送且不持久化(${reason.length})`, async () => {
const page = createPage({ scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => config.url.endsWith('/recommendation') ? Promise.resolve({ data: fifoData() }) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
page.state.form.value.usedPackages = 10; page.state.form.value.fifoSkipReason = reason
await page.act(action)
assert.equal(page.post(endpoint).length, 0)
assert.equal(page.storage.size, 0)
assert.match(text(page), /原因|500/)
page.close()
})
test(`FIFO ${kind} trim 原因持久化,修改原因不能绕过待确认,重建后重放原原因及键`, async () => {
const storage = new Map()
const first = createPage({ storage, scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => config.url === endpoint ? Promise.reject(new Error('网络中断')) : undefined })
await first.scan(); first.state.form.value.tunnelId = 31; first.state.form.value.usedPackages = 10
first.state.form.value.fifoSkipReason = ' 先到托盘正在检验 \n'
await first.act(action)
const original = first.post(endpoint)[0].data
assert.equal(original.fifoSkipReason, '先到托盘正在检验')
assert.equal(JSON.parse([...storage.values()][0]).payload.fifoSkipReason, '先到托盘正在检验')
first.state.form.value.fifoSkipReason = '换了原因'; await first.act(action)
assert.equal(first.post(endpoint).length, 1)
first.close()
const second = createPage({ storage })
await retry(second)
assert.deepEqual(second.post(endpoint)[0].data, original)
assert.equal(second.state.form.value.fifoSkipReason, '先到托盘正在检验')
second.close()
})
test(`FIFO ${kind} 明确拒绝解除原凭据并刷新建议,补原因后新键提交`, async () => {
let rejected = false
const page = createPage({ scan: () => kind === 'entry' ? pallet() : boundPallet(), request: config => {
if (config.url === endpoint && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return Promise.resolve({ data: fifoData(rejected) })
} })
await page.scan(); page.state.form.value.tunnelId = 31; await flush(); page.state.form.value.usedPackages = 10
const before = fifoRequests(page).length
await page.act(action)
const original = page.post(endpoint)[0].data
assert.equal(page.storage.size, 0)
assert.equal(page.state.recoveryRequired.value, false)
assert.match(text(page), /原因/)
assert.ok(fifoRequests(page).length > before)
page.state.form.value.fifoSkipReason = ' 质量待核实 '
await page.act(action)
assert.equal(page.post(endpoint)[1].data.fifoSkipReason, '质量待核实')
assert.notEqual(page.post(endpoint)[1].data.idempotencyKey, original.idempotencyKey)
page.close()
})
}
test('FIFO 清理失败保留待确认,恢复重试收到明确拒绝后可编辑', async () => {
const storage = new Map(); await seedUnknown(storage)
const before = [...storage.entries()]
const blocked = createPage({ storage, storageRemoveError: true, request: config => config.method === 'post' ? Promise.reject(fifoRejection()) : undefined })
await retry(blocked)
assert.deepEqual([...storage.entries()], before)
assert.ok(button(blocked, '恢复重试'))
blocked.close()
const page = createPage({ storage, request: config => config.method === 'post' ? Promise.reject(fifoRejection()) : undefined })
await retry(page)
assert.equal(storage.size, 0)
assert.equal(page.state.form.value.usedPackages, 10)
assert.equal(page.state.scannedPallet.value.palletId, 11)
assert.ok(fifoRequests(page).length)
page.close()
})
for (const error of [new Error('FIFO_SKIP_REASON_REQUIRED'), { response: { data: { code: 503, data: 'FIFO_SKIP_REASON_REQUIRED' } } }, { response: { data: { code: 500, data: 'OTHER_BUSINESS_ERROR' } } }]) {
test(`FIFO 非明确拒绝保留原凭据 ${JSON.stringify(error)}`, async () => {
const page = createPage({ scan: () => boundPallet(), request: config => config.method === 'post' ? Promise.reject(error) : undefined })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
assert.equal(page.storage.size, 1)
assert.ok(button(page, '恢复重试'))
page.close()
})
}
for (const change of ['user', 'batch', 'page', 'record']) test(`FIFO 明确拒绝的旧回调不能清理 ${change} 变化后的凭据`, async () => {
const slow = deferred(), storage = new Map()
const page = createPage({ storage, scan: () => boundPallet(), request: config => config.method === 'post' ? slow.promise : undefined })
await page.scan(); page.state.form.value.usedPackages = 10; await page.act('submitUsage')
if (change === 'user') page.user.id = 8
if (change === 'batch') page.batch.currentBatchId = '99'
if (change === 'page') await page.lifecycle('onDeactivated')
if (change === 'record') {
const [key, raw] = [...storage.entries()][0], record = JSON.parse(raw)
record.payload.idempotencyKey = 'ANOTHER-KEY'; storage.set(key, JSON.stringify(record))
}
const before = [...storage.entries()]
slow.reject(fifoRejection()); await flush()
assert.deepEqual([...storage.entries()], before)
assert.equal(page.state.usageResult.value, null)
page.close()
})
for (const outcome of ['resolve', 'reject']) test(`FIFO 选择目标隧道重新请求并隔离旧 ${outcome} 响应`, async () => {
const old = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation')
? (config.params.tunnelId === 31 ? old.promise : Promise.resolve({ data: fifoData(false) })) : undefined })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
assert.deepEqual(fifoRequests(page).at(-1).params, { qrToken: 'QR-001', tunnelId: 31 })
page.state.form.value.tunnelId = 32; await flush()
if (outcome === 'resolve') old.resolve({ data: fifoData(true) }); else old.reject(new Error('旧建议失败'))
await flush()
page.state.form.value.fifoSkipReason = ''; await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel')[0].data.tunnelId, 32)
assert.equal(page.state.fifoRecommendation.value.requiresSkipReason, false)
assert.equal(page.state.fifoWarning.value, '')
page.close()
})
test('FIFO 原因多行输入绑定表单并只读展示列表原因', async () => {
const page = createPage(); await page.scan()
const input = page.nodes().find(node => node.type?.name === 'el-input' && node.props?.type === 'textarea')
assert.ok(input)
input.props['onUpdate:modelValue']('人工核对原因')
assert.equal(page.state.form.value.fifoSkipReason, '人工核对原因')
const column = page.nodes().find(node => node.type?.name === 'el-table-column' && node.props?.prop === 'fifoSkipReason')
assert.ok(column)
assert.ok(flatten(column.children.default({ row: { fifoSkipReason: '人工核对原因' } })).some(node => node.children === '人工核对原因'))
assert.ok(flatten(column.children.default({ row: {} })).some(node => node.children === '—'))
page.close()
})
test('PC 真实 request 拦截器透传 code500 响应供 FIFO 精确识别', async () => {
const source = await read('utils/request.js')
let onResponse
const service = { interceptors: { request: { use() {} }, response: { use(fn) { onResponse = fn } } } }
const modules = Object.fromEntries([...source.matchAll(/from\s+['"]([^'"]+)['"]/g)].map(([, name]) => [name, {}]))
modules.axios = { default: { defaults: { headers: {} }, create: () => service } }
modules['element-plus'] = { ElMessage() {} }
modules['@/utils/errorCode'] = { default: {} }
modules['@/utils/transportCrypto'] = { decryptTransportResponse: async response => response }
new Function('__modules', bindImports(source).replace(/import\.meta\.env\.VITE_APP_BASE_API/g, "''").replace(/export default service/g, '').replace(/export /g, ''))(modules)
const response = { data: { code: 500, data: 'FIFO_SKIP_REASON_REQUIRED', msg: '请填写原因' }, request: {} }
await assert.rejects(onResponse(response), error => error.response === response && error.message === '请填写原因')
})
for (const action of ['enterTunnel', 'submitUsage']) test(`FIFO ${action} 可选原因同样限制500字,trim后500字可提交`, async () => {
const page = createPage({ scan: () => action === 'enterTunnel' ? pallet() : boundPallet() })
await page.scan(); page.state.form.value.tunnelId = 31; await flush()
page.state.form.value.usedPackages = 10; page.state.form.value.fifoSkipReason = '字'.repeat(501)
await page.act(action)
assert.equal(page.requests.filter(item => item.method === 'post').length, 0)
page.state.form.value.fifoSkipReason = ` ${'字'.repeat(500)} \n`
await page.act(action)
assert.equal(page.requests.find(item => item.method === 'post').data.fifoSkipReason, '字'.repeat(500))
page.close()
})
for (const change of ['user', 'batch']) test(`FIFO ${change} 变化后的旧建议不能更新当前状态`, async () => {
const old = deferred()
const page = createPage({ request: config => config.url.endsWith('/recommendation') ? old.promise : undefined })
await page.scan()
if (change === 'user') page.user.id = 8
else page.batch.currentBatchId = '99'
await flush(); const before = text(page)
old.resolve({ data: { ...fifoData(), message: 'OLD-FIFO' } }); await flush()
assert.equal(text(page), before)
page.close()
})
test('FIFO 旧无原因入隧凭据恢复收到明确拒绝后可补原因重新入隧', async () => {
const storage = new Map(); await seedUnknown(storage, {}, 'entry')
const original = JSON.parse([...storage.values()][0]).payload
assert.equal(original.fifoSkipReason, undefined)
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
} })
await retry(page)
assert.equal(page.state.recoveryRequired.value, false)
assert.equal(page.state.tunnelLocked.value, false)
assert.equal(page.state.form.value.tunnelId, 31)
page.state.form.value.fifoSkipReason = '先到托盘待检验'
await page.act('enterTunnel')
const posts = page.post('/business/usage/enter-tunnel')
assert.deepEqual(posts[0].data, original)
assert.equal(posts[1].data.fifoSkipReason, '先到托盘待检验')
assert.notEqual(posts[1].data.idempotencyKey, original.idempotencyKey)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
for (const kind of ['entry', 'usage']) test(`F2 两页面共享凭据,旧${kind}成功不能删除另一页的新未知操作`, async () => {
const storage = new Map(), old = deferred()
await seedUnknown(storage, {}, kind)
const first = createPage({ storage, request: config => config.method === 'post' ? old.promise : undefined })
await retry(first)
let writes = 0
const second = createPage({ storage, scan: () => boundPallet({ palletId: 12, palletCode: '托盘-B', qrToken: 'QR-B' }), request: config => {
if (config.method === 'post' && ++writes > 1) return Promise.reject(new Error('新操作B响应丢失'))
} })
await retry(second)
await second.act('resetUsageOperation'); await second.scan('QR-B')
second.state.form.value.usedPackages = 12; second.state.form.value.fifoSkipReason = 'B原因'
await second.act('submitUsage')
const originalB = second.post().at(-1).data
const before = [...storage.entries()]
assert.equal(JSON.parse(before[0][1]).payload.idempotencyKey, originalB.idempotencyKey)
old.resolve({ data: kind === 'entry' ? { palletId: 11, tunnelId: 31, traceNo: 'TRACE-A' } : { usageId: 71, remainingPackages: 90, flowState: 'AT_SITE_PARTIAL' } })
await flush()
assert.deepEqual([...storage.entries()], before, '旧成功不能清理同范围后来保存的新记录')
assert.deepEqual({ ...first.state.pendingOperation.value.payload }, originalB, '旧页面应恢复新待确认记录')
assert.ok(button(first, '恢复重试'))
const third = createPage({ storage })
await third.scan('QR-B'); third.state.form.value.fifoSkipReason = '不同原因'; await third.act('submitUsage')
assert.equal(third.post().length, 0)
await retry(third)
assert.deepEqual(third.post()[0].data, originalB)
first.close(); second.close(); third.close()
})
for (const kind of ['entry', 'usage']) {
const action = kind === 'entry' ? 'enterTunnel' : 'submitUsage'
for (const outcome of ['reject', 'pending', 'incomplete']) test(`F3 恢复${kind}被FIFO拒绝后推荐${outcome}仍保留已知必填`, async () => {
const storage = new Map(), recommendation = deferred()
await seedUnknown(storage, {}, kind)
const page = createPage({ storage, request: config => {
if (config.method === 'post') return Promise.reject(fifoRejection())
if (config.url.endsWith('/recommendation')) return outcome === 'reject'
? Promise.reject(new Error('推荐服务不可用'))
: outcome === 'incomplete' ? Promise.resolve({ data: { message: '缺少必填标记' } }) : recommendation.promise
} })
await retry(page)
// 同一目标的表单重建不能被视为真实换托盘或换隧道。
page.state.form.value = { ...page.state.form.value }; await flush()
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
assert.equal(storage.size, 0)
page.state.form.value.fifoSkipReason = ' '
await page.act(action)
assert.equal(page.requests.filter(item => item.method === 'post').length, 1)
assert.equal(storage.size, 0, '已知必填时空原因不能重新保存待确认')
page.close(); recommendation.resolve({ data: fifoData(false) }); await flush()
})
}
test('F3 恢复拒绝的已知必填仅由有效推荐明确覆盖', async () => {
const storage = new Map(), recommendation = deferred()
await seedUnknown(storage, {}, 'entry')
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return recommendation.promise
} })
await retry(page)
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
recommendation.resolve({ data: fifoData(false) }); await flush()
assert.equal(page.state.fifoRecommendation.value.requiresSkipReason, false)
await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel').length, 2)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它', async () => {
const storage = new Map(), old = deferred()
await seedUnknown(storage, {}, 'entry')
let rejected = false
const page = createPage({ storage, request: config => {
if (config.method === 'post' && !rejected) { rejected = true; return Promise.reject(fifoRejection()) }
if (config.url.endsWith('/recommendation')) return config.params.tunnelId === 31 ? old.promise : Promise.reject(new Error('新目标建议不可用'))
} })
await retry(page)
assert.equal(page.state.fifoRecommendation.value?.requiresSkipReason, true)
page.state.form.value.tunnelId = 32; await flush()
old.resolve({ data: fifoData(true) }); await flush()
assert.equal(page.state.fifoRecommendation.value, null)
await page.act('enterTunnel')
assert.equal(page.post('/business/usage/enter-tunnel').at(-1).data.tunnelId, 32)
assert.equal(page.state.tunnelLocked.value, true)
page.close()
})
@@ -30,6 +30,48 @@ test('Web 端统一使用 Sylvan 横版标识、折叠图标和浏览器图标',
assert.doesNotMatch(`${fullLogo}\n${markLogo}`, /<script\b|<foreignObject\b|javascript:|\b(?:xlink:)?href=|\bonload=|\bonerror=/i)
})
test('PC 登录页在版权信息旁提供开源协议外链', async () => {
const [loginPage, router, permission] = await Promise.all([
readFile(path.join(frontendRoot, 'src/views/login.vue'), 'utf8'),
readFile(path.join(frontendRoot, 'src/router/index.js'), 'utf8'),
readFile(path.join(frontendRoot, 'src/permission.js'), 'utf8')
])
assert.match(loginPage, /开源协议/)
assert.match(loginPage, /to="\/open-source-notice"/)
assert.doesNotMatch(loginPage, /git\.yujiangrubber\.cn\/niuniu\/ShiErFeng/)
assert.match(router, /path: '\/open-source-notice'/)
assert.match(router, /@\/views\/openSourceNotice\/index/)
assert.match(permission, /whiteList = \['\/login', '\/register', '\/open-source-notice'\]/)
})
test('站内开源协议页展示 RuoYi 来源与 MIT 许可全文', async () => {
const noticePage = await readFile(path.join(frontendRoot, 'src/views/openSourceNotice/index.vue'), 'utf8')
assert.match(noticePage, /RuoYi-Vue3-FastAPI/)
assert.match(noticePage, /Copyright \(c\) 2018 RuoYi/)
assert.match(noticePage, /The MIT License \(MIT\)/)
assert.match(noticePage, /自有业务代码、界面设计和文档不因本声明而开放授权/)
})
test('版权归属与 RuoYi 第三方 MIT 许可范围明确', async () => {
const [settings, rootLicense, thirdPartyNotices, packageJson] = await Promise.all([
readFile(path.join(frontendRoot, 'src/settings.js'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'LICENSE'), 'utf8'),
readFile(path.join(frontendRoot, '..', 'THIRD-PARTY-NOTICES.md'), 'utf8'),
readFile(path.join(frontendRoot, 'package.json'), 'utf8')
])
assert.match(settings, /Copyright © 2024-2026 Shierfeng International Biotechnology Co\., Ltd\. All Rights Reserved\./)
assert.match(rootLicense, /Shierfeng International Biotechnology Co\., Ltd\./)
assert.match(rootLicense, /All rights reserved\./i)
assert.doesNotMatch(rootLicense, /^MIT License$/m)
assert.match(thirdPartyNotices, /RuoYi-Vue3-FastAPI/)
assert.match(thirdPartyNotices, /Copyright \(c\) 2018 RuoYi/)
assert.match(thirdPartyNotices, /The MIT License \(MIT\)/)
assert.equal(JSON.parse(packageJson).license, 'UNLICENSED')
})
const expectedRequests = {
'src/api/master/strain.js': [
{ url: "'/master/strain/list'", method: 'get' },
@@ -72,10 +114,16 @@ const expectedRequests = {
{ url: "'/master/pallet'", method: 'post' },
{ url: "'/master/pallet'", method: 'put' },
],
'src/api/master/tunnel.js': [
{ url: "'/master/tunnel/list'", method: 'get' },
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
{ url: "'/master/tunnel'", method: 'post' },
{ url: "'/master/tunnel'", method: 'put' },
],
'src/api/business/inbound.js': [
{ url: "'/business/inbound/list'", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/' + inboundId", method: 'get' },
{ url: "'/business/inbound'", method: 'post' },
{ url: "'/business/inbound'", method: 'put' },
@@ -84,7 +132,7 @@ const expectedRequests = {
'src/api/business/filling.js': [
{ url: "'/business/filling/list'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/' + fillingId", method: 'get' },
{ url: "'/business/filling'", method: 'post' }
],
@@ -92,8 +140,9 @@ const expectedRequests = {
{ url: "'/business/shipment/list'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/' + shipmentId", method: 'get' },
{ url: "'/business/shipment/' + shipmentId + '/labels'", method: 'get' },
{ url: "'/business/shipment'", method: 'post' },
{ url: "'/business/shipment'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' }
@@ -101,14 +150,17 @@ const expectedRequests = {
'src/api/business/usage.js': [
{ url: "'/business/usage/list'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
{ url: "'/business/usage/' + usageId", method: 'get' },
{ url: "'/business/usage'", method: 'post' }
],
'src/api/business/return.js': [
{ url: "'/business/return/list'", method: 'get' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/request'", method: 'post' },
{ url: "'/business/return/' + returnId", method: 'get' },
{ url: "'/business/return/' + returnId + '/confirm'", method: 'put' }
@@ -213,9 +265,11 @@ const businessPageContracts = {
payloads: {
addFilling: [
'batchNo',
'expiryDate',
'idempotencyKey',
'initialPackages',
'packageSpec',
'productionDate',
'qrToken',
'strainId',
'warehouseId'
@@ -228,13 +282,14 @@ const businessPageContracts = {
'addShipment',
'confirmShipment',
'getShipment',
'getShipmentLabels',
'getShipmentSiteOptions',
'getShipmentWarehouseOptions',
'listShipment',
'scanShipmentPallet',
'updateShipment'
],
permissions: ['add', 'confirm', 'edit', 'list', 'query'],
permissions: ['add', 'confirm', 'edit', 'list', 'query', 'receive'],
payloads: {
addShipment: ['palletIds', 'sourceWarehouseId', 'targetSiteId'],
updateShipment: ['palletIds', 'shipmentId', 'sourceWarehouseId', 'targetSiteId'],
@@ -243,11 +298,10 @@ const businessPageContracts = {
},
usage: {
relativePath: 'src/views/business/usage/index.vue',
apiNames: ['addUsage', 'getUsageLineOptions', 'listUsage', 'scanUsagePallet'],
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'],
permissions: ['add', 'list', 'query'],
payloads: {
addUsage: ['idempotencyKey', 'lineId', 'qrToken', 'usedPackages']
}
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
payloads: {}
},
return: {
relativePath: 'src/views/business/return/index.vue',
@@ -398,6 +452,7 @@ const expectedButtonPermissions = [
'business:shipment:add',
'business:shipment:edit',
'business:shipment:confirm',
'business:shipment:receive',
'business:usage:query',
'business:usage:add',
'business:return:query',
@@ -1166,8 +1221,8 @@ test('窄屏模式控件仍保留正式退出与测试批次切换入口', async
test('17 个页面的真实权限门禁逐项匹配独立硬编码契约', async () => {
assert.equal(expectedPagePermissions.length, 17)
assert.equal(expectedButtonPermissions.length, 37)
assert.equal(new Set(expectedPermissions).size, 54)
assert.equal(expectedButtonPermissions.length, 38)
assert.equal(new Set(expectedPermissions).size, 55)
const relativePaths = [
...Object.values(masterPageContracts),
...Object.values(businessPageContracts),
@@ -1467,9 +1522,9 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)|handleConfirmCancellation\(/)
})
test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 17)
assert.equal(Object.values(expectedRequests).flat().length, 66)
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
assert.equal(Object.keys(expectedRequests).length, 18)
assert.equal(Object.values(expectedRequests).flat().length, 74)
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
await t.test(relativePath, async () => {
@@ -1482,16 +1537,18 @@ test('17 个 API 文件逐一匹配硬编码请求契约', async (t) => {
test('关键扫码、选项、确认与二维码契约被显式覆盖', () => {
const requiredRequests = [
{ url: "'/business/inbound/scan/' + qrToken", method: 'get' },
{ url: "'/business/filling/scan/' + qrToken", method: 'get' },
{ url: "'/business/shipment/scan/' + qrToken", method: 'get' },
{ url: "'/business/usage/scan/' + qrToken", method: 'get' },
{ url: "'/business/return/scan/' + qrToken", method: 'get' },
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/shipment/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
{ url: "'/business/filling/strain-options'", method: 'get' },
{ url: "'/business/shipment/warehouse-options'", method: 'get' },
{ url: "'/business/shipment/site-options'", method: 'get' },
{ url: "'/business/usage/line-options'", method: 'get' },
{ url: "'/business/usage/tunnel-options'", method: 'get' },
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
{ url: "'/business/return/warehouse-options'", method: 'get' },
{ url: "'/business/inbound/' + inboundId + '/confirm'", method: 'put' },
{ url: "'/business/shipment/' + shipmentId + '/confirm'", method: 'put' },
@@ -1960,7 +2017,7 @@ test('五个业务页面逐页满足 API、精确权限、列表和交互基础
.map((match) => match[0])
assert.ok(businessControls.length > 0, `${resource} 页面缺少业务输入控件`)
for (const control of businessControls) {
assert.match(control, /:disabled="[^"]*operationPending[^"]*"/, `${resource} 提交期间必须禁用业务输入`)
assert.match(control, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*operationPending[^"]*")/, `${resource} 提交期间必须禁用业务输入`)
}
const businessActions = [...source.matchAll(/<el-button\b[^>]*class="business-action"[^>]*>/g)]
.map((match) => match[0])
@@ -2018,7 +2075,7 @@ test('五个业务页面的读取请求逐分支执行 stale guard,并在上
},
usage: {
flows: [
['loadLineOptions', ['getUsageLineOptions'], 'isCurrentOptionRequest'],
['loadUsageOptions', ['getUsageLineOptions', 'getUsageTunnelOptions'], 'isCurrentOptionRequest'],
['handleUsageScan', ['scanUsagePallet'], 'isCurrentScanRequest']
],
resets: [['resetUsageOperation', ['invalidateOptionRequest()', 'invalidateScanRequest()']]],
@@ -2172,7 +2229,7 @@ test('装填扫码 pending 期间禁用全部业务输入', async () => {
for (const field of ['strainId', 'batchNo', 'packageSpec', 'initialPackages']) {
const businessControl = source.match(new RegExp(`<[^>]+v-model="form\\.${field}"[^>]*>`))?.[0]
assert.ok(businessControl, `缺少装填 ${field} 业务控件`)
assert.match(businessControl, /:disabled="[^"]*scanLoading[^"]*"/, `装填扫码 pending 时必须禁用 ${field}`)
assert.match(businessControl, /(?:\sdisabled(?:\s|\/?>)|:disabled="[^"]*scanLoading[^"]*")/, `装填扫码 pending 时必须禁用 ${field}`)
}
})
@@ -2196,7 +2253,7 @@ test('装填页面锁定扫码上下文、校验正整数并在失败重试时
['strainId', 'undefined'],
['batchNo', "''"],
['packageSpec', "''"],
['initialPackages', 'undefined']
['initialPackages', '100']
]) {
const fieldPattern = new RegExp(`(?:form\\.value\\.${field}\\s*=\\s*${emptyValue}|${field}:\\s*${emptyValue})`)
assert.match(clearPayloadSource, fieldPattern, `装填载荷清理必须重置 ${field}`)
@@ -2366,56 +2423,13 @@ test('发货页面只允许 DRAFT 变更、展示只读余量并复用确认幂
assert.doesNotMatch(confirmCatch, /clearConfirmIdempotency\(\)[\s\S]*?(?:loadError|msgError)/)
})
test('使用页面依扫码结果锁定产线并只采用后端返回的余量和状态', async () => {
test('使用页面依扫码结果完成入隧、锁定产线并只采用后端返回的余量和状态', async () => {
const source = await readFile(path.join(frontendRoot, businessPageContracts.usage.relativePath), 'utf8')
assertButtonsUsePermission(source, 'submitUsage', 'business:usage:add')
assert.match(source, /proxy\.\$auth\.hasPermiAnd\(\['business:usage:query', 'business:usage:add'\]\)/)
assert.match(source, /pallet\.currentLineId == null/)
assert.match(source, /getUsageLineOptions\(pallet\.siteId\)/)
assert.match(source, /:disabled="operationPending \|\| lineLocked/)
assert.match(source, /validator:\s*validatePositiveInteger/)
const scanSource = extractNamedFunction(source, 'handleUsageScan')
const scanCallIndex = scanSource.indexOf('scanUsagePallet(')
assert.ok(scanCallIndex >= 0, '使用扫码必须调用 scanUsagePallet')
for (const [snippet, message] of [
['clearUsageIdempotencyKey()', '新使用扫码前必须清除旧幂等键'],
['scannedPallet.value = null', '新使用扫码前必须清除旧托盘'],
['usageResult.value = null', '新使用扫码前必须清除旧成功结果'],
['lineOptions.value = []', '新使用扫码前必须清除旧产线选项'],
['lineLocked.value = false', '新使用扫码前必须解除旧产线锁定'],
["form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }", '新使用扫码前必须清除旧提交 payload']
]) {
const index = scanSource.indexOf(snippet)
assert.ok(index >= 0 && index < scanCallIndex, message)
}
const invalidSource = extractNamedFunction(source, 'handleScanInvalid')
assert.match(invalidSource, /clearUsageIdempotencyKey\(\)/)
assert.match(invalidSource, /scannedPallet\.value = null/)
assert.match(invalidSource, /usageResult\.value = null/)
assert.match(invalidSource, /lineOptions\.value = \[\]/)
assert.match(invalidSource, /lineLocked\.value = false/)
assert.match(invalidSource, /form\.value = \{ qrToken: '', lineId: undefined, usedPackages: undefined \}/)
const submitSource = extractNamedFunction(source, 'submitUsage')
assert.ok(
submitSource.indexOf('submitLoading.value = true') < submitSource.indexOf('.validate('),
'使用提交必须在异步校验前加锁'
)
assert.match(submitSource, /if \(!valid\) \{\s*submitLoading\.value = false\s*return/)
assert.match(submitSource, /\.finally\(\(\) => \{\s*submitLoading\.value = false/)
assert.match(submitSource, /const idempotencyKey = ensureUsageIdempotencyKey\(\)/)
const successSource = extractPromiseCallback(submitSource, 'then')
assert.match(successSource, /const result = response\.data;/)
assert.match(successSource, /remainingPackages:\s*result\.remainingPackages/)
assert.match(successSource, /flowState:\s*result\.flowState/)
assert.doesNotMatch(source, /remainingPackages\s*[-+]=|remainingPackages\s*[-+]\s*form|Math\.(?:max|min)\([^)]*remainingPackages/)
assert.match(successSource, /clearUsageIdempotencyKey\(\)/)
const catchSource = extractPromiseCallback(submitSource, 'catch')
assert.doesNotMatch(catchSource, /clearUsageIdempotencyKey/)
assert.match(extractNamedFunction(source, 'resetUsageOperation'), /clearUsageIdempotencyKey\(\)/)
assert.match(source, /watch\([\s\S]*?clearUsageIdempotencyKey\(\)/)
// 执行真实页面/API行为,避免把“必须每次重扫清空幂等键”的旧实现误当成安全要求。
await execFileAsync(process.execPath, ['--test', 'scripts/usage-behavior.test.mjs'], { cwd: frontendRoot })
assert.match(source, /@media \(max-width: 767px\)[\s\S]*?min-height:\s*(?:48|52|56)px/)
})
@@ -0,0 +1,54 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import * as Vue from 'vue'
import { parse } from '@vue/compiler-sfc'
import { compile } from '@vue/compiler-dom'
import { renderToString } from '@vue/server-renderer'
// 单独编译真实日期列和其插槽,验证绑定字段与历史空值展示,不检索源码字符串。
async function renderDateColumns(resource, row) {
const file = new URL(`../src/views/report/${resource}/index.vue`, import.meta.url)
const { descriptor } = parse(await readFile(file, 'utf8'))
const { ast } = compile(descriptor.template.content)
const columns = []
function visit(node) {
if (node.tag === 'el-table-column' && node.props.some(prop =>
prop.name === 'prop' && ['productionDate', 'expiryDate'].includes(prop.value?.content))) {
columns.push(node.loc.source)
}
for (const child of node.children || []) visit(child)
}
visit(ast)
assert.equal(columns.length, 2, `${resource} 应展示生产日期和有效期至`)
const render = new Function('Vue', compile(`<div>${columns.join('')}</div>`, {
mode: 'function', prefixIdentifiers: true
}).code)(Vue)
const app = Vue.createSSRApp({ render })
app.component('el-table-column', {
props: ['prop', 'label'],
setup(props, { slots }) {
return () => Vue.h('section', { 'data-column': props.prop, 'aria-label': props.label },
slots.default ? slots.default({ row }) : String(row[props.prop] ?? ''))
}
})
return renderToString(app)
}
for (const resource of ['palletInventory', 'consumption', 'return', 'palletTrace']) {
test(`${resource} 展示接口的原装填日期,历史 null 为未登记`, async () => {
const dated = await renderDateColumns(resource, {
productionDate: '2025-12-31', expiryDate: '2026-12-31',
occurredAt: '2026-10-08T23:59:59', updatedAt: '2026-10-08T23:59:59'
})
assert.match(dated, /2025-12-31/)
assert.match(dated, /2026-12-31/)
assert.doesNotMatch(dated, /2026-10-08|00:00:00/)
const legacy = await renderDateColumns(resource, {
productionDate: null, expiryDate: null,
occurredAt: '2026-10-08T23:59:59', updatedAt: '2026-10-08T23:59:59'
})
assert.equal((legacy.match(/未登记/g) || []).length, 2)
assert.doesNotMatch(legacy, /2026-10-08/)
})
}
@@ -0,0 +1,167 @@
import assert from 'node:assert/strict'
import { readFile } from 'node:fs/promises'
import { test } from 'node:test'
import vm from 'node:vm'
import * as Vue from 'vue'
import { parse } from '@vue/compiler-sfc'
import { compile } from '@vue/compiler-dom'
import { renderToString } from '@vue/server-renderer'
const reports = {
palletInventory: 'listPalletInventory',
consumption: 'listConsumptionReport',
return: 'listReturnReport',
palletTrace: 'listPalletTrace'
}
async function loadPage(resource, rows = []) {
const source = await readFile(new URL(`../src/views/report/${resource}/index.vue`, import.meta.url), 'utf8')
const { descriptor } = parse(source)
const requests = []
const context = vm.createContext({
ref: Vue.ref, computed: Vue.computed,
getCurrentInstance: () => ({ proxy: { addDateRange: query => query, resetForm() {} } }),
onBeforeUnmount() {},
[reports[resource]]: async query => {
requests.push({ ...query })
return { rows, total: rows.length }
}
})
// 执行真实页面脚本,仅替换网络和组件生命周期边界。
vm.runInContext(`${descriptor.scriptSetup.content.replace(/^import .*$/gm, '')}
globalThis.page = { queryParams, reportList, showSearch, loading, loadError, total, dateRange,
handleQuery, resetQuery, getList, handleSizeChange, handleCurrentChange,
${resource === 'palletInventory' ? 'flowStateOptions, flowStateLabel, currentLocation, idFallback,' : ''}
${resource === 'palletTrace' ? 'eventTypeOptions, eventTypeLabel, sourceLabel, flowStateLabel, formatLocation,' : ''}
...(typeof traceSummary === 'undefined' ? {} : { traceSummary })
}`, context)
await new Promise(resolve => setImmediate(resolve))
const fields = new Map()
const render = new Function('Vue', compile(descriptor.template.content, { mode: 'function', prefixIdentifiers: true }).code)(Vue)
async function html() {
const app = Vue.createSSRApp({
setup: () => ({ ...context.page, parseTime: value => value || '' }),
render
})
app.directive('hasPermi', { getSSRProps: () => ({}) })
app.directive('loading', { getSSRProps: () => ({}) })
const box = { setup: (_, { slots }) => () => Vue.h('div', slots.default?.()) }
for (const name of ['el-form', 'el-row', 'el-select', 'el-option', 'el-button', 'right-toolbar', 'pagination', 'el-date-picker', 'el-empty']) {
app.component(name, box)
}
app.component('el-form-item', {
props: ['prop', 'label'],
setup(props, { slots }) {
Vue.provide('reportField', props.prop)
return () => Vue.h('label', { 'data-field': props.prop }, [props.label, slots.default?.()])
}
})
const input = {
props: ['modelValue'],
setup(props, { attrs }) {
fields.set(Vue.inject('reportField'), value => attrs['onUpdate:modelValue'](value))
return () => Vue.h('input', { value: props.modelValue })
}
}
app.component('el-input', input)
app.component('el-input-number', input)
app.component('el-alert', { props: ['title'], setup: props => () => Vue.h('p', props.title) })
app.component('el-table', {
props: ['data'],
setup(props, { slots }) {
Vue.provide('reportRows', props.data)
return () => Vue.h('div', slots.default?.())
}
})
app.component('el-table-column', {
props: ['prop', 'label'],
setup(props, { slots }) {
const data = Vue.inject('reportRows')
return () => Vue.h('section', { 'data-column': props.prop, 'aria-label': props.label },
data.map(row => Vue.h('span', slots.default ? slots.default({ row }) : String(row[props.prop] ?? ''))))
}
})
return renderToString(app)
}
return { page: context.page, requests, fields, html }
}
for (const resource of Object.keys(reports)) {
test(`${resource} 页面通过编号、名称和溯源号查询跨托盘明细`, async () => {
const report = await loadPage(resource)
await report.html()
for (const [key, value] of [['tunnelCode', 'T-01'], ['tunnelName', '一号隧道'], ['traceNo', 'TR-MIX']]) {
assert.ok(report.fields.has(key), `缺少可操作的 ${key} 输入框`)
report.fields.get(key)(value)
}
if (resource === 'return' || resource === 'palletTrace') {
assert.ok(report.fields.has('batchNo'))
report.fields.get('batchNo')('B-OLD')
}
report.page.handleQuery()
await new Promise(resolve => setImmediate(resolve))
const query = report.requests.at(-1)
assert.equal(query.tunnelCode, 'T-01')
assert.equal(query.tunnelName, '一号隧道')
assert.equal(query.traceNo, 'TR-MIX')
assert.equal(query.palletCode, undefined)
assert.equal(query.tunnelId, undefined)
assert.equal(query.pageNum, 1)
})
}
test('消耗页展示隧道且无产线历史记录不显示 #null', async () => {
const report = await loadPage('consumption', [{
palletCode: 'P-A', tunnelCode: 'T-01', tunnelName: '一号隧道', traceNo: 'TR-MIX',
customerId: 1, siteId: 9, strainId: 11,
lineId: null, lineName: null, batchNo: 'B-OLD', usedPackages: 30, remainingPackagesAfter: 70
}])
const html = await report.html()
assert.match(html, /T-01/)
assert.match(html, /一号隧道/)
assert.match(html, /TR-MIX/)
assert.doesNotMatch(html, /#null|#undefined/)
})
test('退回页按历史快照展示溯源关系和批次,不需当前库存', async () => {
const report = await loadPage('return', [{
palletCode: 'P-A', tunnelCode: 'T-OLD', tunnelName: '旧隧道', traceNo: 'TR-OLD',
batchNo: 'B-OLD', batchNoSnapshot: 'B-OLD', remainingPackagesSnapshot: 70, status: 'CONFIRMED'
}])
const html = await report.html()
for (const value of ['T-OLD', '旧隧道', 'TR-OLD', 'B-OLD', '70']) assert.ok(html.includes(value), value)
})
test('追溯页跨托盘混批逐笔显示,退回申请和确认不会重复计为使用量', async () => {
const common = { tunnelCode: 'T-01', tunnelName: '一号隧道', traceNo: 'TR-MIX', beforeTunnelCode: 'T-01', afterTunnelCode: null }
const report = await loadPage('palletTrace', [
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'TUNNEL_ENTERED', usedPackages: 0, returnedPackages: 0 },
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'USAGE_RECORDED', usedPackages: 30, returnedPackages: 0 },
{ ...common, palletCode: 'P-B', batchNoSnapshot: 'B-B', eventType: 'USAGE_RECORDED', usedPackages: 20, returnedPackages: 0 },
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'RETURN_REQUESTED', usedPackages: 0, returnedPackages: 0 },
{ ...common, palletCode: 'P-A', batchNoSnapshot: 'B-A', eventType: 'RETURN_CONFIRMED', usedPackages: 0, returnedPackages: 70 }
])
const html = await report.html()
assert.match(html, /入隧登记/)
assert.doesNotMatch(html, />TUNNEL_ENTERED</)
for (const value of ['P-A', 'P-B', 'B-A', 'B-B', 'TR-MIX']) assert.ok(html.includes(value), value)
assert.match(html, /本页实际使用 50 包,确认退回 70 包/)
})
test('报表兼容待收货状态与收货确认事件的中文显示', async () => {
const inventory = await loadPage('palletInventory', [{ palletCode: 'P-A', flowState: 'IN_TRANSIT' }])
const inventoryHtml = await inventory.html()
assert.ok(inventoryHtml.includes('待收货'), '库存报表应显示待收货')
assert.doesNotMatch(inventoryHtml, />IN_TRANSIT</)
const trace = await loadPage('palletTrace', [{
palletCode: 'P-A', eventType: 'SHIPMENT_RECEIVED', beforeState: 'IN_TRANSIT',
afterState: 'AT_SITE_UNUSED', usedPackages: 0, returnedPackages: 0
}])
const traceHtml = await trace.html()
assert.ok(traceHtml.includes('收货确认'), '追溯报表应显示收货确认')
assert.ok(traceHtml.includes('待收货'))
assert.doesNotMatch(traceHtml, />SHIPMENT_RECEIVED<|>IN_TRANSIT</)
assert.match(traceHtml, /本页实际使用 0 包,确认退回 0 包/)
})
@@ -20,7 +20,7 @@ export function getFillingStrainOptions() {
// 扫描待装填托盘
export function scanFillingPallet(qrToken) {
return request({
url: '/business/filling/scan/' + qrToken,
url: '/business/filling/scan/' + encodeURIComponent(qrToken),
method: 'get'
})
}
@@ -20,7 +20,7 @@ export function getInboundWarehouseOptions() {
// 扫描首次入库托盘
export function scanInboundPallet(qrToken, query) {
return request({
url: '/business/inbound/scan/' + qrToken,
url: '/business/inbound/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -20,7 +20,7 @@ export function getReturnWarehouseOptions() {
// 扫描退回托盘
export function scanReturnPallet(qrToken, query) {
return request({
url: '/business/return/scan/' + qrToken,
url: '/business/return/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -28,7 +28,7 @@ export function getShipmentSiteOptions() {
// 扫描整托发货托盘
export function scanShipmentPallet(qrToken, query) {
return request({
url: '/business/shipment/scan/' + qrToken,
url: '/business/shipment/scan/' + encodeURIComponent(qrToken),
method: 'get',
params: query
})
@@ -42,6 +42,14 @@ export function getShipment(shipmentId) {
})
}
// 获取发货标签(包含已授权生成的固定二维码)
export function getShipmentLabels(shipmentId) {
return request({
url: '/business/shipment/' + shipmentId + '/labels',
method: 'get'
})
}
// 新增整托发货草稿
export function addShipment(data) {
return request({
@@ -0,0 +1,22 @@
import request from '@/utils/request'
// 收货仅依赖目标现场范围,使用独立端点,不能复用出库查询。
export function listShipmentReceipts(query) {
return request({ url: '/business/shipment-receipt/list', method: 'get', params: query })
}
export function getShipmentReceipt(shipmentId) {
return request({ url: '/business/shipment-receipt/' + shipmentId, method: 'get' })
}
export function scanShipmentReceipt(shipmentId, identifier) {
return request({
url: '/business/shipment-receipt/' + shipmentId + '/scan/' + encodeURIComponent(identifier),
method: 'get'
})
}
export function confirmShipmentReceipt(shipmentId, data) {
return request({ url: '/business/shipment-receipt/' + shipmentId + '/confirm', method: 'put', data })
}
@@ -18,14 +18,41 @@ export function getUsageLineOptions(siteId) {
})
}
// 查询客户现场启用隧道选项
export function getUsageTunnelOptions(siteId) {
return request({
url: '/business/usage/tunnel-options',
method: 'get',
params: { siteId }
})
}
// 扫码入隧
export function enterUsageTunnel(data) {
return request({
url: '/business/usage/enter-tunnel',
method: 'post',
data: data
})
}
// 扫描客户现场待使用托盘
export function scanUsagePallet(qrToken) {
return request({
url: '/business/usage/scan/' + qrToken,
url: '/business/usage/scan/' + encodeURIComponent(qrToken),
method: 'get'
})
}
// 只读先到先用建议,不作为入隧或使用的前置条件
export function getUsageFifoRecommendation(qrToken, tunnelId) {
return request({
url: '/business/usage-fifo/recommendation',
method: 'get',
params: { qrToken, ...(tunnelId != null ? { tunnelId } : {}) }
})
}
// 查询客户现场使用记录详情
export function getUsage(usageId) {
return request({
@@ -0,0 +1,36 @@
import request from '@/utils/request'
// 查询隧道列表
export function listTunnel(query) {
return request({
url: '/master/tunnel/list',
method: 'get',
params: query
})
}
// 查询隧道详情
export function getTunnel(tunnelId) {
return request({
url: '/master/tunnel/' + tunnelId,
method: 'get'
})
}
// 新增隧道
export function addTunnel(data) {
return request({
url: '/master/tunnel',
method: 'post',
data: data
})
}
// 修改隧道
export function updateTunnel(data) {
return request({
url: '/master/tunnel',
method: 'put',
data: data
})
}
@@ -16,7 +16,7 @@
:readonly="loading"
:aria-busy="loading"
:aria-invalid="Boolean(errorMessage)"
placeholder="请扫描或输入托盘二维码后按 Enter"
placeholder="扫描二维码或输入托盘号后按 Enter"
@input="clearError"
@keyup.enter="handleSubmit"
>
@@ -47,7 +47,7 @@ const props = defineProps({
},
minLength: {
type: Number,
default: 8,
default: 1,
validator: (value) => Number.isInteger(value) && value > 0
},
duplicateWindowMs: {
@@ -88,7 +88,7 @@ function handleSubmit() {
try {
normalizedValue = normalizeScanValue(scanValue.value)
if (normalizedValue.length < props.minLength) {
throw new Error(`二维码长度不能少于 ${props.minLength} 个字符`)
throw new Error(`托盘标识长度不能少于 ${props.minLength} 个字符`)
}
} catch (error) {
errorMessage.value = error.message
@@ -0,0 +1,303 @@
<template>
<div v-if="canReceive" class="shipment-receipt-panel">
<el-form :model="queryParams" :inline="true" class="page-filter">
<el-form-item label="收货状态">
<el-select v-model="queryParams.receiptState" style="width: 190px" @change="handleQuery">
<el-option label="全部" value="" />
<el-option label="待收货" value="PENDING" />
<el-option label="已收货" value="RECEIVED" />
<el-option label="历史未登记收货" value="LEGACY" />
</el-select>
</el-form-item>
<el-form-item label="发货单号">
<el-input v-model="queryParams.shipmentNo" clearable maxlength="64" placeholder="请输入原发货单号" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" :loading="loading" @click="handleQuery">查询收货</el-button>
</el-form-item>
</el-form>
<el-alert v-if="listError" :title="listError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" :data="receiptList" empty-text="暂无收货单" class="data-table">
<el-table-column label="原发货单号" prop="shipmentNo" min-width="170" />
<el-table-column label="目标现场ID" prop="targetSiteId" width="130" />
<el-table-column label="来源仓库ID" prop="sourceWarehouseId" width="130" />
<el-table-column label="收货状态" min-width="170">
<template #default="{ row }">{{ receiptStateLabel(row.receiptState) }}</template>
</el-table-column>
<el-table-column label="收货时间" min-width="180">
<template #default="{ row }">{{ row.receiptState === 'RECEIVED' ? (row.receivedAt || '未登记') : '—' }}</template>
</el-table-column>
<el-table-column label="操作" width="130">
<template #default="{ row }">
<el-button link type="primary" @click="openReceipt(row)">{{ row.receiptState === 'PENDING' ? '核对收货' : '查看明细' }}</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="loadReceipts" />
<el-dialog :model-value="activeShipmentId !== null" title="收货核对" width="min(980px, 95vw)" append-to-body @update:model-value="value => { if (!value) closeReceipt() }">
<div v-loading="detailLoading">
<el-alert v-if="detailError" :title="detailError" type="error" :closable="false" show-icon class="mb8" />
<el-button v-if="detailError" :disabled="detailLoading || confirmLoading" @click="retryDetail">重新读取详情</el-button>
<template v-if="receiptDetail">
<el-descriptions :column="2" border class="mb8">
<el-descriptions-item label="原发货单号">{{ receiptDetail.shipmentNo }}</el-descriptions-item>
<el-descriptions-item label="收货状态">{{ receiptStateLabel(receiptDetail.receiptState) }}</el-descriptions-item>
<el-descriptions-item label="目标现场">#{{ receiptDetail.targetSiteId }}</el-descriptions-item>
<el-descriptions-item label="收货时间">{{ receiptDetail.receiptState === 'RECEIVED' ? (receiptDetail.receivedAt || '未登记') : '—' }}</el-descriptions-item>
</el-descriptions>
<el-alert v-if="receiptDetail.receiptState === 'LEGACY'" title="历史未登记收货:仅供查询原单明细,不补造收货时间。" type="info" :closable="false" class="mb8" />
<template v-if="receiptDetail.receiptState === 'PENDING'">
<pallet-scan-input :key="activeShipmentId" :disabled="!canScan" :loading="scanLoading" @scan="handleReceiptScan" @invalid="message => scanError = message" @duplicate="scanError = '扫码过快,请勿重复扫描'" />
<p class="receipt-tip">请逐托核对原标签,已核对 {{ scannedPalletIds.length }} / {{ receiptItems.length }} 托;全部核对后才可确认收货。</p>
</template>
<el-alert v-if="scanError" :title="scanError" type="warning" :closable="false" class="mb8" />
<el-table :data="receiptItems" empty-text="原单无托盘明细,无法确认收货" size="small" max-height="360">
<el-table-column label="原托盘号" prop="palletCode" min-width="150" />
<el-table-column label="品种" prop="strainName" min-width="120" />
<el-table-column label="批号" prop="batchNo" min-width="130" />
<el-table-column label="生产日期" min-width="120">
<template #default="{ row }">{{ row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="失效日期" min-width="120">
<template #default="{ row }">{{ row.expiryDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="原发货包数" prop="packageQty" width="110" />
<el-table-column v-if="receiptDetail.receiptState === 'PENDING'" label="本次核对" width="150">
<template #default="{ row }">
<span>{{ scannedPalletIds.includes(row.palletId) ? '已核对' : '待扫描' }}</span>
<el-button v-if="scannedPalletIds.includes(row.palletId)" link type="danger" :disabled="!canScan || scanLoading" @click="removeScannedPallet(row.palletId)">撤销</el-button>
</template>
</el-table-column>
</el-table>
</template>
</div>
<el-alert v-if="confirmError" :title="confirmError" type="error" :closable="false" class="mb8" />
<template #footer>
<el-button @click="closeReceipt">关闭</el-button>
<el-button v-if="receiptDetail?.receiptState === 'PENDING'" type="primary" :loading="confirmLoading" :disabled="!canConfirmReceipt" @click="confirmReceipt">确认收货</el-button>
</template>
</el-dialog>
</div>
</template>
<script setup>
import { computed, getCurrentInstance, onActivated, onBeforeUnmount, onDeactivated, onMounted, reactive, ref } from 'vue'
import PalletScanInput from '@/components/PalletScanInput'
import { listShipmentReceipts, getShipmentReceipt, scanShipmentReceipt, confirmShipmentReceipt } from '@/api/business/shipmentReceipt'
import { createIdempotencyKey } from '@/utils/palletScan'
const { proxy } = getCurrentInstance()
const emit = defineEmits(['received'])
const canReceive = computed(() => proxy.$auth.hasPermi('business:shipment:receive'))
const queryParams = reactive({ pageNum: 1, pageSize: 10, receiptState: 'PENDING', shipmentNo: undefined })
const receiptList = ref([])
const total = ref(0)
const loading = ref(false)
const listError = ref('')
const activeShipmentId = ref(null)
const receiptDetail = ref(null)
const detailLoading = ref(false)
const detailError = ref('')
const scannedPalletIds = ref([])
const scanLoading = ref(false)
const scanError = ref('')
const confirmLoading = ref(false)
const confirmError = ref('')
const receiptAccepted = ref(false)
let session = 0
let listGeneration = 0
let suspended = false
let idempotencyKey = ''
const receiptItems = computed(() => receiptDetail.value?.items || [])
const canScan = computed(() => canReceive.value && activeShipmentId.value !== null &&
receiptDetail.value?.status === 'CONFIRMED' && receiptDetail.value?.receiptState === 'PENDING' &&
!detailLoading.value && !detailError.value && !confirmLoading.value && !receiptAccepted.value)
const canConfirmReceipt = computed(() => canScan.value && !scanLoading.value &&
receiptItems.value.length > 0 && receiptItems.value.length === scannedPalletIds.value.length &&
receiptItems.value.every(item => scannedPalletIds.value.includes(item.palletId)))
function receiptStateLabel(state) {
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[state] || '未登记收货'
}
function errorMessage(error, fallback) {
return error?.response?.data?.msg || error?.message || fallback
}
function isCurrent(currentSession) {
return currentSession === session && !suspended && canReceive.value
}
async function loadReceipts() {
if (!canReceive.value || suspended) return
const generation = ++listGeneration
loading.value = true
listError.value = ''
try {
const response = await listShipmentReceipts({ ...queryParams, receiptState: queryParams.receiptState || undefined })
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = Array.isArray(response.rows) ? response.rows : []
total.value = response.total || 0
} catch (error) {
if (generation !== listGeneration || suspended || !canReceive.value) return
receiptList.value = []
total.value = 0
listError.value = errorMessage(error, '收货列表加载失败,请重试')
} finally {
if (generation === listGeneration) loading.value = false
}
}
function handleQuery() {
queryParams.pageNum = 1
return loadReceipts()
}
function closeReceipt() {
// 会话版本同时隔离详情、扫码、确认响应,旧请求不能写入新单据。
session += 1
activeShipmentId.value = null
receiptDetail.value = null
scannedPalletIds.value = []
detailLoading.value = false
scanLoading.value = false
confirmLoading.value = false
receiptAccepted.value = false
detailError.value = ''
scanError.value = ''
confirmError.value = ''
idempotencyKey = ''
}
async function loadReceiptDetail(shipmentId, currentSession) {
detailLoading.value = true
detailError.value = ''
try {
const response = await getShipmentReceipt(shipmentId)
if (!isCurrent(currentSession)) return
if (!response.data || String(response.data.shipmentId) !== String(shipmentId)) {
throw new Error('收货详情与当前单据不一致,请重新打开')
}
receiptDetail.value = response.data
} catch (error) {
if (!isCurrent(currentSession)) return
detailError.value = errorMessage(error, '收货详情加载失败,请重试')
} finally {
if (isCurrent(currentSession)) detailLoading.value = false
}
}
async function openReceipt(row) {
if (!canReceive.value || suspended || row.shipmentId == null) return
closeReceipt()
activeShipmentId.value = row.shipmentId
await loadReceiptDetail(row.shipmentId, session)
}
function retryDetail() {
if (activeShipmentId.value === null || detailLoading.value || confirmLoading.value) return
return loadReceiptDetail(activeShipmentId.value, session)
}
async function handleReceiptScan(identifier) {
if (!canScan.value || scanLoading.value) return
const value = String(identifier ?? '').trim()
if (!value) {
scanError.value = '请扫描二维码或输入托盘号'
return
}
const currentSession = session
scanLoading.value = true
scanError.value = ''
try {
const response = await scanShipmentReceipt(activeShipmentId.value, value)
if (!isCurrent(currentSession)) return
const scanned = response.data
const original = receiptItems.value.find(item => String(item.palletId) === String(scanned?.palletId))
if (!original || String(original.activeFillingId) !== String(scanned?.activeFillingId)) {
scanError.value = '该托盘或装填记录不属于当前发货单'
return
}
if (scannedPalletIds.value.includes(original.palletId)) {
scanError.value = '该托盘已核对,请勿重复扫描'
return
}
// 仅登记实际扫码通过的 ID;品种、批号、日期与包数始终展示原单快照。
scannedPalletIds.value.push(original.palletId)
idempotencyKey = ''
confirmError.value = ''
} catch (error) {
if (isCurrent(currentSession)) scanError.value = errorMessage(error, '托盘核对失败,请重试')
} finally {
if (isCurrent(currentSession)) scanLoading.value = false
}
}
function removeScannedPallet(palletId) {
if (!canScan.value || scanLoading.value) return
if (!scannedPalletIds.value.includes(palletId)) return
scannedPalletIds.value = scannedPalletIds.value.filter(id => id !== palletId)
idempotencyKey = ''
confirmError.value = ''
}
async function confirmReceipt() {
if (!canReceive.value || confirmLoading.value || receiptAccepted.value) return
if (!canConfirmReceipt.value) {
confirmError.value = '请逐托扫码核对全部明细后再确认收货'
return
}
const currentSession = session
const shipmentId = activeShipmentId.value
confirmLoading.value = true
confirmError.value = ''
try {
// 失败保留同一键;增删核对集合时作废,下一次提交生成新键。
if (!idempotencyKey) idempotencyKey = createIdempotencyKey()
const response = await confirmShipmentReceipt(shipmentId, {
scannedPalletIds: [...scannedPalletIds.value], idempotencyKey
})
if (!isCurrent(currentSession)) return
receiptAccepted.value = true
proxy.$modal.msgSuccess(response.msg || '收货成功')
emit('received')
// 不推算余量,也不伪造已收状态/时间。刷新失败仍锁定本次已接受的确认。
await Promise.all([loadReceiptDetail(shipmentId, currentSession), loadReceipts()])
} catch (error) {
if (isCurrent(currentSession)) {
confirmError.value = errorMessage(error, '收货确认失败') + ';未修改核对集合时重试使用同一幂等键'
}
} finally {
if (isCurrent(currentSession)) confirmLoading.value = false
}
}
function suspendPanel() {
suspended = true
listGeneration += 1
loading.value = false
closeReceipt()
}
function resumePanel() {
if (!suspended) return
suspended = false
loadReceipts()
}
onMounted(loadReceipts)
onBeforeUnmount(suspendPanel)
onActivated(resumePanel)
onDeactivated(suspendPanel)
</script>
<style scoped>
.receipt-tip {
margin: 10px 0;
color: var(--el-text-color-secondary);
font-size: 13px;
}
</style>
+1 -1
View File
@@ -11,7 +11,7 @@ import usePermissionStore from '@/store/modules/permission'
NProgress.configure({ showSpinner: false })
const whiteList = ['/login', '/register']
const whiteList = ['/login', '/register', '/open-source-notice']
const isWhiteList = (path) => {
return whiteList.some(pattern => isPathMatch(pattern, path))
@@ -47,6 +47,12 @@ export const constantRoutes = [
component: () => import('@/views/register'),
hidden: true
},
{
path: '/open-source-notice',
component: () => import('@/views/openSourceNotice/index'),
hidden: true,
meta: { title: '开源协议' }
},
{
path: "/:pathMatch(.*)*",
component: () => import('@/views/error/404'),
+1 -1
View File
@@ -52,5 +52,5 @@ export default {
/**
* 底部版权文本内容
*/
footerContent: 'Copyright © 2024-2026 insistence.tech All Rights Reserved.'
footerContent: 'Copyright © 2024-2026 Shierfeng International Biotechnology Co., Ltd. All Rights Reserved.'
}
@@ -149,7 +149,9 @@ service.interceptors.response.use(async res => {
return Promise.reject('无效的会话,或者会话已过期,请重新登录。')
} else if (code === 500) {
ElMessage({ message: msg, type: 'error' })
return Promise.reject(new Error(msg))
const error = new Error(msg)
error.response = res
return Promise.reject(error)
} else if (code === 601) {
ElMessage({ message: msg, type: 'warning' })
return Promise.reject(new Error(msg))
@@ -0,0 +1,103 @@
// 将窗口生命周期和 DOM 排版集中在这里,便于独立验证打印与不可信文本的行为。
function appendText(document, parent, tag, text) {
const element = document.createElement(tag)
element.textContent = String(text ?? '未登记')
parent.appendChild(element)
return element
}
function waitForImage(image) {
return new Promise((resolve, reject) => {
const finish = (error) => {
clearTimeout(timer)
image.removeEventListener('load', loaded)
image.removeEventListener('error', failed)
if (error) reject(error)
else resolve()
}
const loaded = () => finish()
const failed = () => finish(new Error('二维码加载失败,请重新打印'))
const timer = setTimeout(() => finish(new Error('二维码加载超时,请重新打印')), 15000)
image.addEventListener('load', loaded, { once: true })
image.addEventListener('error', failed, { once: true })
if (image.complete) {
if (image.naturalWidth > 0) loaded()
else failed()
}
})
}
export async function printShipmentLabels({
loadLabels,
notifyError,
isCurrent = () => true,
openWindow = () => window.open('', '_blank')
}) {
let printWindow
try {
// 必须在第一次 await 前打开窗口,保留点击手势,避免异步请求触发弹窗拦截。
printWindow = openWindow()
if (!printWindow) throw new Error('打印窗口被拦截,请允许此站点弹出窗口后重试')
const document = printWindow.document
document.title = '发货标签'
document.body.textContent = '正在加载发货标签…'
const labels = await loadLabels()
if (!isCurrent()) throw new Error('数据范围已切换,请重新打印标签')
if (printWindow.closed) throw new Error('打印窗口已关闭,请重新打印')
if (!Array.isArray(labels) || !labels.length) throw new Error('发货单没有可打印的标签')
if (labels.some(label => !/^data:image\/png;base64,[A-Za-z0-9+/]+={0,2}$/.test(label.qrCodeDataUrl))) {
throw new Error('二维码数据无效,请重新加载标签')
}
document.body.textContent = ''
const style = document.createElement('style')
style.textContent = `
@page { margin: 12mm; }
body { margin: 0; color: #000; font-family: sans-serif; }
section { box-sizing: border-box; padding: 8mm; border: 1px solid #000; }
h1 { margin: 0 0 6mm; font-size: 22pt; }
dl { margin: 0; display: grid; grid-template-columns: 30mm minmax(0, 1fr); gap: 3mm; font-size: 13pt; }
dt { font-weight: 600; }
dd { margin: 0; overflow-wrap: anywhere; white-space: pre-wrap; }
img { display: block; width: 38mm; height: 38mm; margin: 6mm auto 0; }
`
document.head.appendChild(style)
const images = []
labels.forEach((label, index) => {
const page = document.createElement('section')
page.style.breakInside = 'avoid'
page.style.pageBreakInside = 'avoid'
page.style.breakAfter = index === labels.length - 1 ? 'auto' : 'page'
page.style.pageBreakAfter = index === labels.length - 1 ? 'auto' : 'always'
appendText(document, page, 'h1', label.shipmentStatus === 'DRAFT' ? '发货标签(草稿)' : '发货标签')
const fields = document.createElement('dl')
for (const [name, value] of [
['原托盘编号', label.palletCode], ['发货单号', label.shipmentNo],
['客户', label.customerName], ['现场', label.siteName],
['品种', label.strainName], ['原生产批号', label.batchNo],
['生产日期', label.productionDate], ['有效期至', label.expiryDate], ['包数', label.packageQty]
]) {
appendText(document, fields, 'dt', name)
appendText(document, fields, 'dd', value)
}
page.appendChild(fields)
const image = document.createElement('img')
image.alt = '原托盘固定二维码'
image.src = label.qrCodeDataUrl
page.appendChild(image)
document.body.appendChild(page)
images.push(image)
})
await Promise.all(images.map(waitForImage))
if (!isCurrent()) throw new Error('数据范围已切换,请重新打印标签')
if (printWindow.closed) throw new Error('打印窗口已关闭,请重新打印')
printWindow.addEventListener('afterprint', () => printWindow.close(), { once: true })
printWindow.focus()
printWindow.print()
return true
} catch (error) {
if (printWindow && !printWindow.closed) printWindow.close()
notifyError(error?.message || '发货标签加载失败,请稍后重试')
return false
}
}
@@ -46,6 +46,12 @@
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
</el-table-column>
<el-table-column label="生产批次" prop="batchNo" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="生产日期" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="包装规格" prop="packageSpec" min-width="120" :show-overflow-tooltip="true" />
<el-table-column label="初始包数" prop="initialPackages" width="100" align="right" />
<el-table-column label="操作时间" prop="operatedAt" width="180">
@@ -123,6 +129,12 @@
:disabled="operationPending || scanLoading"
/>
</el-form-item>
<el-form-item label="生产日期" prop="productionDate">
<el-date-picker v-model="form.productionDate" type="date" value-format="YYYY-MM-DD" placeholder="选择来源生产日期" style="width: 100%" :disabled="operationPending || scanLoading" />
</el-form-item>
<el-form-item label="有效期至" prop="expiryDate">
<el-date-picker v-model="form.expiryDate" type="date" value-format="YYYY-MM-DD" placeholder="选择有效期截止日期" style="width: 100%" :disabled="operationPending || scanLoading" />
</el-form-item>
<el-form-item label="包装规格" prop="packageSpec">
<el-input
v-model="form.packageSpec"
@@ -137,11 +149,12 @@
<el-input-number
v-model="form.initialPackages"
class="business-control"
:min="1"
:min="100"
:max="100"
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending || scanLoading"
disabled
/>
</el-form-item>
</el-form>
@@ -207,19 +220,21 @@ function validateRequiredText(rule, value, callback) {
}
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
callback(new Error('初始包数必须为正整数'))
if (value !== 100) {
callback(new Error('新装填固定为 100 包'))
return
}
callback()
}
const data = reactive({
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', packageSpec: '', initialPackages: undefined },
form: { qrToken: '', warehouseId: undefined, strainId: undefined, batchNo: '', productionDate: '', expiryDate: '', packageSpec: '', initialPackages: 100 },
queryParams: { pageNum: 1, pageSize: 10, fillingNo: undefined, palletCode: undefined, batchNo: undefined },
rules: {
strainId: [{ required: true, message: '请选择菌种', trigger: 'change' }],
batchNo: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
productionDate: [{ required: true, message: '请选择生产日期', trigger: 'change' }],
expiryDate: [{ required: true, message: '请选择有效期截止日期', trigger: 'change' }],
packageSpec: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
initialPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
@@ -308,8 +323,10 @@ function clearFillingPayload() {
warehouseId: undefined,
strainId: undefined,
batchNo: '',
productionDate: '',
expiryDate: '',
packageSpec: '',
initialPackages: undefined
initialPackages: 100
}
}
@@ -382,7 +399,7 @@ function handleFillingScan(qrToken) {
flowState: pallet.flowState,
capacityPackages: pallet.capacityPackages
}
form.value.qrToken = qrToken
form.value.qrToken = pallet.qrToken || qrToken
form.value.warehouseId = pallet.warehouseId
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
@@ -405,6 +422,10 @@ function handleScanDuplicate() {
function submitFilling() {
if (operationPending.value || scanLoading.value || optionLoading.value) return
if (!form.value.productionDate || !form.value.expiryDate || form.value.expiryDate < form.value.productionDate) {
formError.value = '请填写生产日期和有效期,且有效期不能早于生产日期'
return
}
submitLoading.value = true
proxy.$refs.fillingRef.validate((valid) => {
if (!valid) {
@@ -423,6 +444,8 @@ function submitFilling() {
warehouseId: form.value.warehouseId,
strainId: form.value.strainId,
batchNo: form.value.batchNo.trim(),
productionDate: form.value.productionDate,
expiryDate: form.value.expiryDate,
packageSpec: form.value.packageSpec.trim(),
initialPackages: form.value.initialPackages,
idempotencyKey: idempotencyKey
@@ -441,7 +464,7 @@ function submitFilling() {
}
watch(
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.packageSpec, form.value.initialPackages],
() => [form.value.qrToken, form.value.warehouseId, form.value.strainId, form.value.batchNo, form.value.productionDate, form.value.expiryDate, form.value.packageSpec, form.value.initialPackages],
() => {
if (!submitLoading.value) clearFillingIdempotencyKey()
}
@@ -1,5 +1,11 @@
<template>
<div class="app-container business-page business-shipment">
<el-tabs v-if="canReceive" v-model="activeTab">
<el-tab-pane v-if="canShipment" label="发货出库" name="shipment" />
<el-tab-pane label="收货核对" name="receipt" :disabled="operationPending || open" />
</el-tabs>
<ShipmentReceiptPanel v-if="canReceive && activeTab === 'receipt'" @received="getList" />
<template v-if="canShipment && activeTab === 'shipment'">
<el-form ref="queryRef" :model="queryParams" :inline="true" class="page-filter">
<el-form-item label="发货单号" prop="shipmentNo">
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
@@ -50,10 +56,10 @@
<el-table-column label="目标现场" min-width="130">
<template #default="scope">{{ optionLabel(siteOptions, scope.row.targetSiteId) }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" width="110" align="center">
<el-table-column label="状态" prop="status" min-width="170" align="center">
<template #default="scope">
<el-tag :type="scope.row.status === 'DRAFT' ? 'warning' : 'success'">
{{ scope.row.status === 'DRAFT' ? '草稿' : '已确认' }}
<el-tag :type="scope.row.status === 'DRAFT' || scope.row.receiptState === 'PENDING' ? 'warning' : scope.row.receiptState === 'LEGACY' ? 'info' : 'success'">
{{ shipmentStatusLabel(scope.row) }}
</el-tag>
</template>
</el-table-column>
@@ -80,6 +86,15 @@
@click="handleConfirm(scope.row)"
v-hasPermi="['business:shipment:confirm']"
>确认</el-button>
<el-button
link
type="primary"
icon="Printer"
:loading="labelLoadingId === scope.row.shipmentId"
:disabled="operationPending || labelLoadingId !== null"
@click="handlePrintLabels(scope.row)"
v-hasPermi="['business:shipment:query']"
>打印标签</el-button>
</template>
</el-table-column>
</el-table>
@@ -200,12 +215,15 @@
</div>
</template>
</el-dialog>
</template>
</div>
</template>
<script setup name="BusinessShipment">
import PalletScanInput from '@/components/PalletScanInput'
import { createIdempotencyKey } from '@/utils/palletScan'
import { printShipmentLabels } from '@/utils/shipmentLabelPrint'
import { getStoredTestBatchId } from '@/utils/testBatchContext'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
@@ -213,6 +231,7 @@ import {
addShipment,
confirmShipment,
getShipment,
getShipmentLabels,
getShipmentSiteOptions,
getShipmentWarehouseOptions,
listShipment,
@@ -223,6 +242,16 @@ import {
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const testBatchStore = useTestBatchStore()
const ShipmentReceiptPanel = defineAsyncComponent(() => import('@/components/ShipmentReceiptPanel/index.vue'))
const canReceive = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:receive']))
const canListShipment = computed(() => proxy.$auth.hasPermiAnd(['business:shipment:list']))
const canShipment = computed(() =>
canListShipment.value ||
proxy.$auth.hasPermiAnd(['business:shipment:query']) ||
proxy.$auth.hasPermiAnd(['business:shipment:add']) ||
proxy.$auth.hasPermiAnd(['business:shipment:edit']) ||
proxy.$auth.hasPermiAnd(['business:shipment:confirm']))
const activeTab = ref(canShipment.value ? 'shipment' : 'receipt')
const adoptionDialogRef = ref()
const selectedIds = ref([])
const shipmentList = ref([])
@@ -235,6 +264,7 @@ const detailLoading = ref(false)
const scanLoading = ref(false)
const submitLoading = ref(false)
const confirmLoading = ref(false)
const labelLoadingId = ref(null)
const loadError = ref('')
const optionError = ref('')
const scanError = ref('')
@@ -270,11 +300,17 @@ function optionLabel(options, id) {
return option?.name || (id ? `#${id}` : '—')
}
function shipmentStatusLabel(row) {
if (row.status === 'DRAFT') return '草稿'
return { PENDING: '待收货', RECEIVED: '已收货', LEGACY: '历史未登记收货' }[row.receiptState] || '已确认'
}
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
}
function getList() {
if (!canListShipment.value) return
const listContext = {
generation: ++listGeneration,
query: { ...queryParams.value }
@@ -305,6 +341,22 @@ function getList() {
function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.shipmentId) }
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
async function handlePrintLabels(row) {
if (operationPending.value || labelLoadingId.value !== null) return
const batchId = getStoredTestBatchId()
const scopeVersion = testBatchStore.scopeVersion
labelLoadingId.value = row.shipmentId
try {
await printShipmentLabels({
loadLabels: () => getShipmentLabels(row.shipmentId).then(response => response.data),
isCurrent: () => scopeVersion === testBatchStore.scopeVersion && batchId === getStoredTestBatchId(),
notifyError: message => proxy.$modal.msgError(message)
})
} finally {
labelLoadingId.value = null
}
}
function invalidateOptionRequest() {
optionGeneration += 1
optionLoading.value = false
@@ -1,5 +1,17 @@
<template>
<div class="app-container business-page usage-page">
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="storageError" :title="storageError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="pendingOperation" type="warning" :closable="false" show-icon class="mb8">
<template #title>有一笔{{ pendingOperation.kind === 'usage' ? '使用' : '入隧' }}结果未确认,请恢复重试核对,勿重复操作。</template>
<div>
托盘 {{ pendingOperation.pallet.palletCode }},隧道 {{ formatTunnel(pendingOperation.pallet) }}
<span v-if="pendingOperation.kind === 'usage'">,本次使用 {{ pendingOperation.payload.usedPackages }} 包</span>
</div>
<div>持续失败时,请先查询使用记录及库存,确认原操作结果后再解除本机待确认。重复提交可能再次扣减库存。</div>
<el-button v-if="canAddUsage" type="warning" :disabled="operationPending || Boolean(storageError)" :loading="entryLoading || submitLoading" @click="retryPendingOperation" v-hasPermi="['business:usage:add']">恢复重试</el-button>
<el-button :disabled="operationPending || Boolean(storageError)" :loading="releaseLoading" @click="releasePendingOperation">人工核对后解除待确认</el-button>
</el-alert>
<el-card v-if="canAddUsage" class="usage-operation" shadow="never">
<template #header>
<div class="usage-operation__header">
@@ -8,7 +20,15 @@
</div>
</template>
<el-alert v-if="operationError" :title="operationError" type="error" :closable="false" show-icon class="mb8" />
<el-alert v-if="lineWarning" :title="lineWarning" type="warning" :closable="false" show-icon class="mb8" />
<el-alert v-if="fifoWarning" :title="fifoWarning" type="warning" :closable="false" show-icon class="mb8" />
<el-alert v-else-if="fifoRecommendation" :title="fifoRecommendation.message" type="info" :closable="false" show-icon class="mb8">
<div v-if="fifoRecommendation.recommended">
建议优先使用:{{ fifoRecommendation.recommended.palletCode }},批次 {{ fifoRecommendation.recommended.batchNo }},
余量 {{ fifoRecommendation.recommended.remainingPackages }} 包,接收时间 {{ fifoRecommendation.recommended.siteArrivedAt }}
</div>
<div v-if="fifoRecommendation.unknownArrivalCount">另有 {{ fifoRecommendation.unknownArrivalCount }} 个托盘接收时间未登记,请人工核对。</div>
</el-alert>
<el-alert
v-if="usageResult"
:title="`使用成功:后端返回余量 ${usageResult.remainingPackages} 包,状态 ${usageResult.flowState}`"
@@ -17,12 +37,20 @@
show-icon
class="mb8"
/>
<el-alert
v-if="scannedPallet && !tunnelLocked"
title="请先选择隧道并完成扫码入隧,再提交使用。"
type="warning"
:closable="false"
show-icon
class="mb8"
/>
<div class="usage-grid">
<section class="usage-panel">
<div class="usage-label">扫描托盘</div>
<pallet-scan-input
:disabled="operationPending || optionLoading"
:disabled="operationPending || recoveryRequired || optionLoading"
:loading="scanLoading"
@scan="handleUsageScan"
@invalid="handleScanInvalid"
@@ -36,6 +64,9 @@
<div class="summary-item"><span>托盘</span><strong>{{ scannedPallet.palletCode || `#${scannedPallet.palletId}` }}</strong></div>
<div class="summary-item"><span>菌种</span><strong>{{ scannedPallet.strainName || scannedPallet.strainCode || (scannedPallet.strainId ? `#${scannedPallet.strainId}` : '—') }}</strong></div>
<div class="summary-item"><span>批次</span><strong>{{ scannedPallet.batchNo || '—' }}</strong></div>
<div class="summary-item"><span>生产日期</span><strong>{{ scannedPallet.productionDate || '未登记' }}</strong></div>
<div class="summary-item"><span>有效期至</span><strong>{{ scannedPallet.expiryDate || '未登记' }}</strong></div>
<div class="summary-item"><span>隧道</span><strong>{{ formatTunnel(scannedPallet) }}</strong></div>
<div class="summary-item"><span>当前余量</span><strong>{{ scannedPallet.remainingPackages }} 包</strong></div>
</template>
<el-empty v-else description="等待扫描托盘" :image-size="72" />
@@ -45,15 +76,32 @@
<el-form ref="usageRef" :model="form" :rules="rules" label-position="top" class="usage-form">
<el-row :gutter="18">
<el-col :xs="24" :sm="12">
<el-form-item label="使用产线" prop="lineId">
<el-form-item label="使用隧道" prop="tunnelId">
<el-select
v-model="form.lineId"
v-model="form.tunnelId"
class="business-control"
placeholder="请先扫描托盘"
filterable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || lineLocked || optionLoading || !scannedPallet"
:disabled="operationPending || recoveryRequired || tunnelLocked || optionLoading || !scannedPallet"
>
<el-option v-for="option in tunnelOptions" :key="option.id" :label="formatTunnel({ tunnelId: option.id, tunnelName: option.name, tunnelCode: option.code })" :value="option.id" />
</el-select>
<div v-if="tunnelLocked" class="usage-tip">托盘已完成入隧,当前操作不可切换。</div>
</el-form-item>
</el-col>
<el-col :xs="24" :sm="12">
<el-form-item label="使用产线(可选)" prop="lineId">
<el-select
v-model="form.lineId"
class="business-control"
placeholder="请先扫描托盘"
filterable
clearable
style="width: 100%"
:loading="optionLoading"
:disabled="operationPending || recoveryRequired || lineLocked || !scannedPallet"
>
<el-option v-for="option in lineOptions" :key="option.id" :label="option.name || `#${option.id}`" :value="option.id" />
</el-select>
@@ -69,24 +117,42 @@
:precision="0"
controls-position="right"
style="width: 100%"
:disabled="operationPending || !scannedPallet"
:disabled="operationPending || recoveryRequired || !scannedPallet"
/>
</el-form-item>
</el-col>
<el-col :span="24">
<el-form-item label="跳过先到托盘原因" prop="fifoSkipReason" :required="fifoRecommendation?.requiresSkipReason === true">
<el-input v-model="form.fifoSkipReason" type="textarea" :rows="3" maxlength="500" show-word-limit
placeholder="跳过先到托盘时请填写原因,最多500字"
:disabled="operationPending || recoveryRequired || !scannedPallet" />
<div class="usage-tip">{{ fifoRecommendation?.requiresSkipReason ? '存在更早到货托盘,请填写跳过原因。' : '可选;提交时由服务端复核先到先用要求。' }}</div>
</el-form-item>
</el-col>
</el-row>
</el-form>
<div class="usage-actions">
<el-button
v-if="scannedPallet && !tunnelLocked"
class="business-action"
type="warning"
size="large"
:loading="entryLoading"
:disabled="operationPending || recoveryRequired || scanLoading || optionLoading || !scannedPallet || !form.tunnelId"
@click="enterTunnel"
v-hasPermi="['business:usage:add']"
>扫码入隧</el-button>
<el-button
class="business-action"
type="primary"
size="large"
:loading="submitLoading"
:disabled="operationPending || scanLoading || optionLoading || !scannedPallet || Boolean(usageResult)"
:disabled="operationPending || recoveryRequired || scanLoading || optionLoading || !scannedPallet || !tunnelLocked || Boolean(usageResult)"
@click="submitUsage"
v-hasPermi="['business:usage:add']"
>提交使用</el-button>
<el-button class="business-action" size="large" :disabled="operationPending" @click="resetUsageOperation">清空重扫</el-button>
<el-button class="business-action" size="large" :disabled="operationPending || recoveryRequired" @click="resetUsageOperation">清空重扫</el-button>
</div>
</el-card>
@@ -102,6 +168,9 @@
<el-form-item label="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="隧道ID" prop="tunnelId">
<el-input-number v-model="queryParams.tunnelId" :min="1" :controls="false" placeholder="请输入隧道ID" />
</el-form-item>
<el-form-item label="产线ID" prop="lineId">
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
</el-form-item>
@@ -125,9 +194,15 @@
<template #default="scope">{{ scope.row.palletCode || `#${scope.row.palletId}` }}</template>
</el-table-column>
<el-table-column label="现场ID" prop="siteId" width="100" />
<el-table-column label="隧道" min-width="150">
<template #default="scope">{{ formatTunnel(scope.row) }}</template>
</el-table-column>
<el-table-column label="产线ID" prop="lineId" width="100" />
<el-table-column label="使用包数" prop="usedPackages" width="105" align="right" />
<el-table-column label="使用后余量" prop="remainingPackagesAfter" width="115" align="right" />
<el-table-column label="跳过先到托盘原因" prop="fifoSkipReason" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.fifoSkipReason || '—' }}</template>
</el-table-column>
<el-table-column label="发生时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
</el-table-column>
@@ -150,7 +225,15 @@ import { createIdempotencyKey } from '@/utils/palletScan'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
import { addUsage, getUsageLineOptions, listUsage, scanUsagePallet } from '@/api/business/usage'
import {
addUsage,
enterUsageTunnel,
getUsageFifoRecommendation,
getUsageLineOptions,
getUsageTunnelOptions,
listUsage,
scanUsagePallet
} from '@/api/business/usage'
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
@@ -159,23 +242,39 @@ const adoptionDialogRef = ref()
const selectedIds = ref([])
const usageList = ref([])
const lineOptions = ref([])
const tunnelOptions = ref([])
const scannedPallet = ref(null)
const usageResult = ref(null)
const lineLocked = ref(false)
const tunnelLocked = ref(false)
const loading = ref(false)
const optionLoading = ref(false)
const scanLoading = ref(false)
const entryLoading = ref(false)
const submitLoading = ref(false)
const releaseLoading = ref(false)
const loadError = ref('')
const operationError = ref('')
const lineWarning = ref('')
const fifoRecommendation = ref(null)
const fifoWarning = ref('')
const pendingOperation = ref(null)
const storageError = ref('')
const total = ref(0)
const usageIdempotencyKey = ref('')
const entryIdempotencyKey = ref('')
const canAddUsage = computed(() => proxy.$auth.hasPermiAnd(['business:usage:query', 'business:usage:add']))
const operationPending = computed(() => submitLoading.value)
const operationPending = computed(() => submitLoading.value || entryLoading.value || releaseLoading.value)
const recoveryRequired = computed(() => Boolean(pendingOperation.value || storageError.value))
const pendingStorageKey = computed(() => userStore.id === '' || userStore.id == null
? ''
: `business:usage:pending:v1:${JSON.stringify([String(userStore.id), String(testBatchStore.currentBatchId || '')])}`)
let listGeneration = 0
let optionGeneration = 0
let scanGeneration = 0
let fifoGeneration = 0
let operationSession = 0
let pageActive = true
function validatePositiveInteger(rule, value, callback) {
if (!Number.isInteger(value) || value <= 0) {
@@ -186,17 +285,25 @@ function validatePositiveInteger(rule, value, callback) {
}
const data = reactive({
form: { qrToken: '', lineId: undefined, usedPackages: undefined },
queryParams: { pageNum: 1, pageSize: 10, usageNo: undefined, palletCode: undefined, siteId: undefined, lineId: undefined },
form: { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' },
queryParams: {
pageNum: 1,
pageSize: 10,
usageNo: undefined,
palletCode: undefined,
siteId: undefined,
tunnelId: undefined,
lineId: undefined
},
rules: {
lineId: [{ required: true, message: '请选择使用产线', trigger: 'change' }],
tunnelId: [{ required: true, message: '请选择使用隧道', trigger: 'change' }],
usedPackages: [{ required: true, validator: validatePositiveInteger, trigger: 'blur' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function isCurrentListRequest(listContext) {
return listContext.generation === listGeneration
return pageActive && listContext.generation === listGeneration
}
function getList() {
@@ -236,7 +343,7 @@ function invalidateOptionRequest() {
}
function isCurrentOptionRequest(optionContext) {
return optionContext.generation === optionGeneration &&
return pageActive && optionContext.generation === optionGeneration &&
optionContext.operationSession === operationSession &&
optionContext.scanGeneration === scanGeneration &&
optionContext.palletId === scannedPallet.value?.palletId
@@ -248,10 +355,48 @@ function invalidateScanRequest() {
}
function isCurrentScanRequest(scanContext) {
return scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
return pageActive && scanContext.generation === scanGeneration && scanContext.operationSession === operationSession
}
function loadLineOptions(pallet, scanContext) {
function formatTunnel(pallet) {
return [pallet.tunnelName, pallet.tunnelCode].filter(Boolean).join(' / ') || (pallet.tunnelId ? `#${pallet.tunnelId}` : '未入隧')
}
function loadFifoRecommendation() {
if (!pageActive || !scannedPallet.value || !form.value.qrToken) return
const generation = ++fifoGeneration
const scanContext = { generation: scanGeneration, operationSession }
const storageKey = pendingStorageKey.value
const { qrToken, tunnelId } = form.value
const isCurrent = () => generation === fifoGeneration && isCurrentScanRequest(scanContext) &&
storageKey === pendingStorageKey.value && qrToken === form.value.qrToken && tunnelId === form.value.tunnelId
fifoWarning.value = ''
getUsageFifoRecommendation(qrToken, tunnelId).then((response) => {
if (!isCurrent()) return
if (fifoRecommendation.value?.requiresSkipReason === true && typeof response.data?.requiresSkipReason !== 'boolean') {
throw new Error('先到先用建议缺少明确的原因要求')
}
fifoRecommendation.value = response.data
}).catch(() => {
if (!isCurrent()) return
fifoWarning.value = '先到先用建议获取失败,请人工核对接收时间;提交时由服务端复核,跳过先到托盘可能需要填写原因'
})
}
function validatedFifoSkipReason() {
const reason = form.value.fifoSkipReason.trim()
if (Array.from(reason).length > 500) {
operationError.value = '跳过先到托盘原因最多500字'
return null
}
if (fifoRecommendation.value?.requiresSkipReason === true && !reason) {
operationError.value = '请填写跳过先到托盘原因'
return null
}
return reason
}
function loadUsageOptions(pallet, scanContext) {
const optionContext = {
generation: ++optionGeneration,
operationSession,
@@ -260,41 +405,260 @@ function loadLineOptions(pallet, scanContext) {
siteId: pallet.siteId
}
optionLoading.value = true
getUsageLineOptions(pallet.siteId).then((response) => {
const tunnelRequest = getUsageTunnelOptions(pallet.siteId)
const lineRequest = pallet.currentLineId == null
? getUsageLineOptions(pallet.siteId)
: Promise.resolve({ data: [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }] })
// 产线是可选项,不能用 Promise.all 让失败或慢响应影响隧道。
tunnelRequest.then((tunnelResponse) => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = Array.isArray(response.data) ? response.data : []
if (!lineOptions.value.length) operationError.value = '该现场暂无可用产线'
tunnelOptions.value = Array.isArray(tunnelResponse.data) ? tunnelResponse.data : []
if (pallet.tunnelId != null) {
const tunnel = tunnelOptions.value.find(option => option.id === pallet.tunnelId)
if (tunnel) {
scannedPallet.value.tunnelName = tunnel.name
scannedPallet.value.tunnelCode = tunnel.code
}
}
if (pallet.tunnelId == null && !tunnelOptions.value.length) {
operationError.value = '该现场暂无可用隧道'
}
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = []
operationError.value = '产线选项加载失败,请稍后重新扫码'
tunnelOptions.value = []
operationError.value = '隧道选项加载失败,请稍后重新扫码'
}).finally(() => {
if (!isCurrentOptionRequest(optionContext)) return
optionLoading.value = false
})
lineRequest.then((lineResponse) => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = Array.isArray(lineResponse.data) ? lineResponse.data : []
}).catch(() => {
if (!isCurrentOptionRequest(optionContext)) return
lineOptions.value = []
lineWarning.value = '可选产线加载失败,可不选产线继续操作'
})
}
function clearUsageIdempotencyKey() {
usageIdempotencyKey.value = ''
}
function clearEntryIdempotencyKey() {
entryIdempotencyKey.value = ''
}
function ensureUsageIdempotencyKey() {
if (!usageIdempotencyKey.value) usageIdempotencyKey.value = createIdempotencyKey()
return usageIdempotencyKey.value
}
function ensureEntryIdempotencyKey() {
if (!entryIdempotencyKey.value) entryIdempotencyKey.value = createIdempotencyKey()
return entryIdempotencyKey.value
}
// 请求发送前持久化;未知结果按原 payload/key 核对,或经人工确认后显式释放,不能通过重扫换键。
function restorePendingOperation() {
storageError.value = ''
if (!pendingStorageKey.value) {
storageError.value = '用户信息未就绪,无法读取本机恢复记录,请重新进入页面'
return
}
try {
const raw = localStorage.getItem(pendingStorageKey.value)
if (!raw) return
const record = JSON.parse(raw)
if (!['entry', 'usage'].includes(record.kind) || !record.payload?.idempotencyKey ||
!record.payload.qrToken || !record.payload.tunnelId || !record.pallet?.palletId ||
(record.kind === 'usage' && (!Number.isInteger(record.payload.usedPackages) || record.payload.usedPackages <= 0))) {
throw new Error('恢复记录不完整')
}
pendingOperation.value = record
} catch {
storageError.value = '本机存储不可用或恢复记录损坏,请保留记录并人工核对后处理,暂不能发起新操作'
}
}
function requiresRecovery() {
restorePendingOperation()
if (pendingOperation.value) operationError.value = '上一笔结果未确认,请先使用“恢复重试”核对原操作'
return recoveryRequired.value
}
function savePendingOperation(kind, payload) {
try {
const tunnel = tunnelOptions.value.find(option => option.id === payload.tunnelId)
const record = JSON.parse(JSON.stringify({ kind, payload, pallet: {
...scannedPallet.value,
tunnelId: payload.tunnelId,
tunnelName: tunnel?.name || scannedPallet.value.tunnelName,
tunnelCode: tunnel?.code || scannedPallet.value.tunnelCode
} }))
localStorage.setItem(pendingStorageKey.value, JSON.stringify(record))
pendingOperation.value = record
return record
} catch {
storageError.value = '本机存储失败,尚未发送请求;请恢复浏览器存储后重试'
return null
}
}
function isCurrentOperationRequest(context) {
return pageActive && context.session === operationSession && context.storageKey === pendingStorageKey.value
}
function handleFifoRejection(error, record, context) {
const body = error?.response?.data
if (body?.code !== 500 || body.data !== 'FIFO_SKIP_REASON_REQUIRED') return false
// 服务端保证此错误未落业务数据;仅解除仍与原请求完全一致的本机凭据。
try {
if (localStorage.getItem(context.storageKey) !== JSON.stringify(record)) {
restorePendingOperation()
operationError.value = '本机待确认记录已变化,请重新核对后操作'
return true
}
localStorage.removeItem(context.storageKey)
} catch {
operationError.value = '本机待确认记录清理失败,原凭据已保留,请使用恢复重试'
return true
}
pendingOperation.value = null
if (record.kind === 'usage') clearUsageIdempotencyKey()
else clearEntryIdempotencyKey()
scannedPallet.value = { ...record.pallet, tunnelId: record.kind === 'usage' ? record.payload.tunnelId : null }
form.value = { qrToken: record.payload.qrToken, tunnelId: record.payload.tunnelId,
lineId: record.payload.lineId ?? form.value.lineId,
usedPackages: record.payload.usedPackages ?? form.value.usedPackages,
fifoSkipReason: record.payload.fifoSkipReason || '' }
tunnelLocked.value = record.kind === 'usage'
lineLocked.value = record.pallet.currentLineId != null
usageResult.value = null
operationError.value = '请补充跳过先到托盘原因后重新提交,本次操作未生效'
fifoRecommendation.value = { requiresSkipReason: true, message: '请填写跳过先到托盘原因' }
loadUsageOptions(scannedPallet.value, { generation: scanGeneration })
loadFifoRecommendation()
return true
}
async function executeOperation(record) {
const context = { session: operationSession, storageKey: pendingStorageKey.value }
const isUsage = record.kind === 'usage'
const requestLoading = isUsage ? submitLoading : entryLoading
requestLoading.value = true
operationError.value = ''
try {
const payload = { ...record.payload }
const response = await (isUsage ? addUsage(payload) : enterUsageTunnel(payload))
if (!isCurrentOperationRequest(context)) return
const result = response.data
if (!result || (isUsage
? !Number.isInteger(result.remainingPackages) || result.remainingPackages < 0 || !result.flowState
: result.tunnelId !== record.payload.tunnelId)) {
throw new Error('服务端确认结果不完整')
}
// 同账号/批次的其他页面可能已保存新操作;成功也只能清理完全匹配的原记录。
if (localStorage.getItem(context.storageKey) !== JSON.stringify(record)) {
pendingOperation.value = null
restorePendingOperation()
operationError.value = '本机待确认记录已变化,已保留并恢复当前记录,请核对后操作'
return
}
// 清理失败时仍保留恢复凭据;再次核对也只重放同一幂等命令。
localStorage.removeItem(context.storageKey)
pendingOperation.value = null
// 恢复入口不依赖重新扫码成功:托盘可能已用尽,扫描接口会拒绝它。
scannedPallet.value = { ...record.pallet, ...(isUsage
? { remainingPackages: result.remainingPackages, flowState: result.flowState }
: { tunnelId: result.tunnelId, traceNo: result.traceNo }) }
form.value = { qrToken: record.payload.qrToken, tunnelId: scannedPallet.value.tunnelId,
lineId: isUsage ? record.payload.lineId ?? undefined : record.pallet.currentLineId ?? form.value.lineId,
usedPackages: isUsage ? record.payload.usedPackages : form.value.usedPackages,
fifoSkipReason: record.payload.fifoSkipReason || '' }
tunnelLocked.value = true
lineLocked.value = record.pallet.currentLineId != null
if (isUsage) {
usageResult.value = { remainingPackages: result.remainingPackages, flowState: result.flowState }
getList()
} else {
loadUsageOptions(scannedPallet.value, { generation: scanGeneration })
}
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || (isUsage ? '使用记录提交成功' : '扫码入隧成功'))
} catch (error) {
if (!isCurrentOperationRequest(context)) return
if (handleFifoRejection(error, record, context)) return
operationError.value = '操作结果未确认,请使用“恢复重试”按原请求核对;持续失败请人工核对,勿重复操作'
} finally {
if (isCurrentOperationRequest(context)) requestLoading.value = false
}
}
function retryPendingOperation() {
if (!pageActive || !canAddUsage.value || operationPending.value) return
restorePendingOperation()
if (!pendingOperation.value || storageError.value) return
return executeOperation(pendingOperation.value)
}
async function releasePendingOperation() {
// 仅清理当前账号/批次的本机记录;新增权限撤销后也允许人工解除,不调用业务写接口。
if (!pageActive || operationPending.value) return
restorePendingOperation()
if (!pendingOperation.value || storageError.value) return
const context = { session: operationSession, storageKey: pendingStorageKey.value }
const record = pendingOperation.value
releaseLoading.value = true
try {
const original = localStorage.getItem(context.storageKey)
const operation = record.kind === 'usage' ? `使用 ${record.payload.usedPackages} 包` : '入隧'
await proxy.$modal.confirm(
`托盘 ${record.pallet.palletCode},${formatTunnel(record.pallet)},${operation}的结果尚未确认。` +
'请先查询当前账号、当前数据批次的使用记录及库存,确认原操作是否已生效。' +
'解除仅删除本机待确认记录,不重放请求、不撤销操作,也不代表原操作成功。' +
'解除后重复提交可能再次扣减库存。确认已完成人工核对并解除待确认?'
)
if (!isCurrentOperationRequest(context)) return
// 确认只针对弹窗打开时的记录,不能删除另一个页面后来保存的待确认操作。
if (localStorage.getItem(context.storageKey) !== original) {
restorePendingOperation()
operationError.value = '本机待确认记录已变化,请重新核对后操作'
return
}
localStorage.removeItem(context.storageKey)
pendingOperation.value = null
releaseLoading.value = false
resetUsageOperation()
proxy.$modal.msgWarning('仅清理了本机待确认记录,未发送业务请求;原操作结果以人工核对为准。请重新扫码,避免重复扣减。')
} catch (error) {
if (!isCurrentOperationRequest(context) || error === 'cancel' || error === 'close') return
operationError.value = '本机待确认记录清理失败,原凭据已保留,请稍后重试'
} finally {
if (isCurrentOperationRequest(context)) releaseLoading.value = false
}
}
function resetUsageOperation() {
if (operationPending.value) return
if (operationPending.value || requiresRecovery()) return
operationSession += 1
invalidateOptionRequest()
invalidateScanRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
operationError.value = ''
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
proxy.resetForm('usageRef')
}
@@ -309,17 +673,23 @@ function resetQuery() {
}
function handleUsageScan(qrToken) {
if (operationPending.value || scanLoading.value || optionLoading.value) return
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery()) return
invalidateOptionRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
const scanContext = { generation: ++scanGeneration, operationSession, qrToken }
scanLoading.value = true
operationError.value = ''
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
scanUsagePallet(qrToken).then((response) => {
if (!isCurrentScanRequest(scanContext)) return
const pallet = response.data
@@ -336,21 +706,29 @@ function handleUsageScan(qrToken) {
strainCode: pallet.strainCode,
strainName: pallet.strainName,
batchNo: pallet.batchNo,
productionDate: pallet.productionDate,
expiryDate: pallet.expiryDate,
tunnelId: pallet.tunnelId,
tunnelName: pallet.tunnelName,
tunnelCode: pallet.tunnelCode,
traceNo: pallet.traceNo,
remainingPackages: pallet.remainingPackages,
flowState: pallet.flowState
}
form.value.qrToken = qrToken
form.value.qrToken = pallet.qrToken || qrToken
form.value.usedPackages = undefined
tunnelLocked.value = pallet.tunnelId != null
form.value.tunnelId = pallet.tunnelId ?? undefined
if (pallet.currentLineId == null) {
lineLocked.value = false
form.value.lineId = undefined
lineOptions.value = []
loadLineOptions(pallet, scanContext)
} else {
lineLocked.value = true
form.value.lineId = pallet.currentLineId
lineOptions.value = [{ id: pallet.currentLineId, code: `#${pallet.currentLineId}`, name: `#${pallet.currentLineId}` }]
}
loadUsageOptions(pallet, scanContext)
}).catch(() => {
if (!isCurrentScanRequest(scanContext)) return
operationError.value = '托盘扫码校验失败,请检查后重试'
@@ -361,65 +739,164 @@ function handleUsageScan(qrToken) {
}
function handleScanInvalid(message) {
if (operationPending.value || requiresRecovery()) return
invalidateOptionRequest()
invalidateScanRequest()
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
scannedPallet.value = null
usageResult.value = null
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
form.value = { qrToken: '', lineId: undefined, usedPackages: undefined }
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
operationError.value = message
lineWarning.value = ''
fifoRecommendation.value = null
fifoWarning.value = ''
}
function handleScanDuplicate() {
proxy.$modal.msgWarning('扫码过快,请勿重复扫描')
}
function enterTunnel() {
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery() || !scannedPallet.value || tunnelLocked.value) return
if (!form.value.tunnelId || !form.value.qrToken) {
operationError.value = '请选择隧道后再扫码入隧'
return
}
const fifoSkipReason = validatedFifoSkipReason()
if (fifoSkipReason === null) return
const record = savePendingOperation('entry', {
qrToken: form.value.qrToken,
tunnelId: form.value.tunnelId,
...(fifoSkipReason ? { fifoSkipReason } : {}),
idempotencyKey: ensureEntryIdempotencyKey()
})
if (record) return executeOperation(record)
}
function submitUsage() {
if (operationPending.value || scanLoading.value || optionLoading.value || usageResult.value) return
if (!pageActive || !canAddUsage.value || operationPending.value || scanLoading.value || optionLoading.value || requiresRecovery() || usageResult.value) return
const context = { session: operationSession, storageKey: pendingStorageKey.value }
submitLoading.value = true
proxy.$refs.usageRef.validate((valid) => {
if (!isCurrentOperationRequest(context)) return
if (!valid) {
submitLoading.value = false
return
}
if (!scannedPallet.value || !form.value.qrToken) {
operationError.value = '请先扫描待使用托盘'
if (!scannedPallet.value || !form.value.qrToken || !tunnelLocked.value || !form.value.tunnelId) {
operationError.value = '请先扫描待使用托盘并完成入隧'
submitLoading.value = false
return
}
const idempotencyKey = ensureUsageIdempotencyKey()
operationError.value = ''
addUsage({
qrToken: form.value.qrToken,
lineId: form.value.lineId,
usedPackages: form.value.usedPackages,
idempotencyKey: idempotencyKey
}).then((response) => {
const result = response.data;
usageResult.value = {
remainingPackages: result.remainingPackages,
flowState: result.flowState
}
clearUsageIdempotencyKey()
proxy.$modal.msgSuccess(response.msg || '使用记录提交成功')
getList()
}).catch(() => {
operationError.value = '使用提交失败;直接重试将复用本次幂等键'
}).finally(() => {
if (!Number.isInteger(form.value.usedPackages) || form.value.usedPackages <= 0 || form.value.usedPackages > scannedPallet.value.remainingPackages) {
operationError.value = '使用包数必须是正整数且不能超过当前余量'
submitLoading.value = false
return
}
if (requiresRecovery()) {
submitLoading.value = false
return
}
const fifoSkipReason = validatedFifoSkipReason()
if (fifoSkipReason === null) {
submitLoading.value = false
return
}
const record = savePendingOperation('usage', {
qrToken: form.value.qrToken,
tunnelId: form.value.tunnelId,
lineId: form.value.lineId || null,
usedPackages: form.value.usedPackages,
...(fifoSkipReason ? { fifoSkipReason } : {}),
idempotencyKey: ensureUsageIdempotencyKey()
})
if (record) executeOperation(record)
else submitLoading.value = false
})
}
watch(
() => [form.value.qrToken, form.value.lineId, form.value.usedPackages],
() => [form.value.qrToken, form.value.tunnelId, form.value.lineId, form.value.usedPackages, form.value.fifoSkipReason],
() => {
if (!submitLoading.value) clearUsageIdempotencyKey()
if (!submitLoading.value && !pendingOperation.value) clearUsageIdempotencyKey()
}
)
getList();
watch(
() => [form.value.qrToken, form.value.tunnelId, form.value.fifoSkipReason],
() => {
if (!entryLoading.value && !pendingOperation.value) clearEntryIdempotencyKey()
}
)
watch(
() => [form.value.qrToken, form.value.tunnelId],
([qrToken, tunnelId], [oldQrToken, oldTunnelId]) => {
if (qrToken === oldQrToken && tunnelId === oldTunnelId) return
fifoRecommendation.value = null
fifoWarning.value = ''
loadFifoRecommendation()
},
// 目标失效必须同步完成,避免恢复表单后排队的 watcher 清掉写接口已确认的必填要求。
{ flush: 'sync' }
)
function leaveUsagePage() {
pageActive = false
operationSession += 1
listGeneration += 1
invalidateScanRequest()
invalidateOptionRequest()
entryLoading.value = false
submitLoading.value = false
loading.value = false
releaseLoading.value = false
pendingOperation.value = null
// 只清理页面状态,本机未确认记录留给再次进入时恢复。
scannedPallet.value = null
usageResult.value = null
fifoRecommendation.value = null
fifoWarning.value = ''
lineWarning.value = ''
operationError.value = ''
storageError.value = ''
lineOptions.value = []
tunnelOptions.value = []
lineLocked.value = false
tunnelLocked.value = false
form.value = { qrToken: '', tunnelId: undefined, lineId: undefined, usedPackages: undefined, fifoSkipReason: '' }
usageList.value = []
selectedIds.value = []
total.value = 0
clearUsageIdempotencyKey()
clearEntryIdempotencyKey()
}
watch(pendingStorageKey, () => {
const wasActive = pageActive
leaveUsagePage()
pageActive = wasActive
if (pageActive) {
restorePendingOperation()
getList()
}
}, { flush: 'sync' })
onDeactivated(leaveUsagePage)
onBeforeUnmount(leaveUsagePage)
onActivated(() => {
pageActive = true
restorePendingOperation()
getList()
})
restorePendingOperation()
getList()
</script>
<style lang="scss" scoped>
@@ -273,6 +273,7 @@ const inventoryStateLabels = {
UNSTOCKED: '未入库',
EMPTY_IN_WAREHOUSE: '仓库空托盘',
FILLED_IN_WAREHOUSE: '仓库有料',
IN_TRANSIT: '在途待收货',
AT_SITE_UNUSED: '现场未使用',
AT_SITE_PARTIAL: '现场部分使用',
AT_SITE_EMPTY: '现场空托盘',
+382 -179
View File
@@ -1,8 +1,8 @@
<template>
<div class="login login-wrapper">
<div class="login login-wrapper" @click="skipIntro" @keydown="skipIntro">
<canvas ref="canvasRef" class="bg-canvas"></canvas>
<div class="noise-overlay"></div>
<div class="login-box">
<div class="login-box" :class="{ 'is-visible': cardVisible }">
<div class="login-card">
<div class="brand-header">
<div class="brand-logo-wrap">
@@ -91,8 +91,13 @@
</div>
</div>
<!-- 底部 -->
<div class="el-login-footer">
<div class="el-login-footer" :class="{ 'is-visible': cardVisible }">
<span>{{ footerContent }}</span>
<span class="footer-divider" aria-hidden="true">|</span>
<router-link
class="open-source-link"
to="/open-source-notice"
>开源协议</router-link>
</div>
</div>
</template>
@@ -108,6 +113,29 @@ import brandLogoLight from '@/assets/logo/sylvan-logo-light.svg'
const title = import.meta.env.VITE_APP_TITLE;
const footerContent = defaultSettings.footerContent
const cardVisible = ref(false);
let bgController = null;
let cardTimer = null;
function revealCardSmoothly() {
if (!cardVisible.value) {
cardVisible.value = true;
if (cardTimer) {
clearTimeout(cardTimer);
cardTimer = null;
}
}
}
function skipIntro() {
if (cardVisible.value) return;
if (bgController && bgController.fastForward) {
bgController.fastForward();
}
revealCardSmoothly();
}
const userStore = useUserStore();
const route = useRoute();
const router = useRouter();
@@ -200,178 +228,297 @@ function getCookie() {
const canvasRef = ref(null);
let cleanupBackground = null;
class GeometricShape {
constructor(anchorXRatio, anchorYRatio, radiusRatio, color, style = 'fill', ringCount = 1) {
this.anchorXRatio = anchorXRatio;
this.anchorYRatio = anchorYRatio;
this.radiusRatio = radiusRatio;
this.color = color;
this.style = style;
this.ringCount = ringCount;
this.phaseX = Math.random() * Math.PI * 2;
this.phaseY = Math.random() * Math.PI * 2;
this.phaseR = Math.random() * Math.PI * 2;
this.speed = 0.0003 + Math.random() * 0.0002;
function initDynamicBackground(options = {}) {
const canvas = canvasRef.value;
if (!canvas) return null;
const ctx = canvas.getContext('2d');
if (!ctx) return null;
let w = 0;
let h = 0;
let dpr = 1;
let animationFrameId = null;
let started = performance.now();
let cardNotified = false;
const introHold = 850;
const scatterMs = 2900;
const SVG_NS = 'http://www.w3.org/2000/svg';
const sampler = document.createElementNS(SVG_NS, 'svg');
sampler.setAttribute('aria-hidden', 'true');
sampler.style.cssText = 'position:fixed;width:0;height:0;overflow:hidden;visibility:hidden;pointer-events:none;';
document.body.appendChild(sampler);
const leaves = [
{ at: [91, 35], r: 49, to: [.95, -.05], size: .20, delay: .02, arc: .09, rot: .04, color: '#00A89D', darkColor: '#008596' },
{ at: [28, 50], r: 35, to: [-.02, .15], size: .35, delay: .11, arc: -.10, rot: -.05, color: '#262464', darkColor: '#162238' },
{ at: [99, 77], r: 48, to: [.08, -.05], size: .22, delay: .07, arc: .08, rot: .04, color: '#00A89D', darkColor: '#008596' },
{ at: [95, 111], r: 31, to: [.98, .88], size: .15, delay: .14, arc: -.07, rot: -.04, color: '#00A89D', darkColor: '#008596' },
{ at: [76, 111], r: 34, to: [.85, .95], size: .25, delay: .04, arc: .06, rot: .05, color: '#262464', darkColor: '#162238' },
{ at: [32, 112], r: 37, to: [.05, 1.00], size: .18, delay: .09, arc: -.08, rot: -.04, color: '#6ec6c2', darkColor: '#0e4e5e' }
];
const rawLogoPaths = [
{ d: "M 57.828125 67.277344 C 57.792969 72.289062 56.96875 77.109375 55.484375 81.628906 L 55.503906 81.652344 L 55.519531 81.65625 C 66.308594 50.636719 93.671875 27.382812 127.007812 22.496094 C 130.695312 15.828125 132.804688 8.160156 132.804688 0.00390625 L 46.738281 0.00390625 C 30.890625 0.00390625 16.90625 7.925781 8.496094 20.019531 C 9.371094 19.976562 10.25 19.953125 11.136719 19.957031 C 37.097656 20.132812 58 41.316406 57.828125 67.277344 ", c: "#36bbab", leaf: 0 },
{ d: "M 0.1875 46.554688 C 0.1875 52.15625 1.1875 57.523438 3.003906 62.5 C 8.207031 61.726562 13.597656 61.628906 19.070312 62.328125 C 33.730469 64.207031 46.414062 71.359375 55.484375 81.628906 C 56.96875 77.109375 57.792969 72.289062 57.828125 67.277344 C 58 41.316406 37.097656 20.132812 11.136719 19.957031 C 10.25 19.953125 9.371094 19.976562 8.496094 20.019531 C 3.261719 27.546875 0.1875 36.6875 0.1875 46.554688 ", c: "#256d8c", leaf: 1 },
{ d: "M 55.519531 81.65625 L 55.53125 81.65625 C 61.101562 80.082031 66.972656 79.222656 73.046875 79.222656 C 96.988281 79.222656 117.855469 92.324219 128.914062 111.738281 C 131.410156 106.03125 132.804688 99.730469 132.804688 93.105469 C 132.804688 68.730469 114.074219 48.730469 90.21875 46.71875 C 89.996094 46.699219 89.996094 46.40625 90.21875 46.386719 C 106.085938 45.046875 119.679688 35.746094 127.007812 22.496094 C 93.671875 27.382812 66.308594 50.636719 55.519531 81.65625 ", c: "#2e8f99", leaf: 2 },
{ d: "M 73.046875 79.222656 C 66.972656 79.222656 61.101562 80.082031 55.53125 81.65625 C 79.742188 89.105469 97.339844 111.648438 97.339844 138.300781 L 97.339844 138.316406 C 111.53125 134.847656 123.164062 124.878906 128.914062 111.738281 C 117.855469 92.324219 96.988281 79.222656 73.046875 79.222656 ", c: "#26728b", leaf: 3 },
{ d: "M 86.253906 139.65625 L 66.613281 139.65625 C 68.121094 135.890625 69.183594 131.9375 69.710938 127.828125 C 70.03125 125.3125 70.1875 122.816406 70.1875 120.347656 C 70.191406 105.730469 64.71875 92.09375 55.515625 81.664062 L 55.53125 81.65625 C 79.742188 89.105469 97.339844 111.648438 97.339844 138.300781 L 97.339844 138.316406 C 93.785156 139.183594 90.078125 139.65625 86.253906 139.65625 ", c: "#27517b", leaf: 4 },
{ d: "M 55.53125 81.65625 L 55.515625 81.664062 C 66.273438 93.855469 71.933594 110.433594 69.710938 127.828125 C 69.183594 131.9375 68.121094 135.890625 66.613281 139.65625 L 86.253906 139.65625 C 90.078125 139.65625 93.785156 139.183594 97.339844 138.316406 L 97.339844 138.300781 C 97.339844 111.648438 79.742188 89.105469 55.53125 81.65625 ", c: "#2a4a7a", leaf: 4 },
{ d: "M 55.515625 81.664062 L 55.46875 81.675781 L 55.480469 81.644531 L 55.484375 81.628906 C 46.414062 71.359375 33.730469 64.203125 19.070312 62.328125 C 13.597656 61.628906 8.207031 61.726562 3 62.5 C 5.921875 70.5 10.964844 77.476562 17.460938 82.738281 C 22.5625 86.871094 28.554688 89.941406 35.105469 91.625 C 37.546875 92.253906 40.058594 92.703125 42.636719 92.925781 C 42.878906 92.945312 42.878906 93.261719 42.636719 93.28125 C 37.914062 93.695312 33.394531 94.824219 29.183594 96.539062 C 20.496094 100.082031 13.121094 106.167969 7.984375 113.878906 C 3.0625 121.261719 0.1875 130.117188 0.1875 139.65625 L 66.613281 139.65625 C 68.121094 135.890625 69.183594 131.9375 69.710938 127.828125 C 71.933594 110.433594 66.273438 93.855469 55.515625 81.664062 ", c: "#1e2567", leaf: 5 }
];
const logoPaths = rawLogoPaths.map(p => {
const v = document.createElementNS(SVG_NS, 'path');
v.setAttribute('d', p.d);
sampler.appendChild(v);
const length = v.getTotalLength();
const count = 240;
const points = [];
for (let i = 0; i < count; i++) {
const pt = v.getPointAtLength(length * i / count);
points.push([pt.x, pt.y]);
}
let area = 0;
for (let i = 0; i < count; i++) {
const a = points[i];
const b = points[(i + 1) % count];
area += a[0] * b[1] - b[0] * a[1];
}
return {
...p,
points,
direction: area >= 0 ? 1 : -1,
startAngle: Math.atan2(points[0][1] - leaves[p.leaf].at[1], points[0][0] - leaves[p.leaf].at[0])
};
});
sampler.remove();
const clamp = t => Math.max(0, Math.min(1, t));
const ease = t => {
t = clamp(t);
return t * t * t * (t * (t * 6 - 15) + 10);
};
function parseHex(hex) {
let c = hex.replace('#', '');
if (c.length === 3) c = c.split('').map(x => x + x).join('');
return [
parseInt(c.slice(0, 2), 16),
parseInt(c.slice(2, 4), 16),
parseInt(c.slice(4, 6), 16)
];
}
draw(ctx, w, h, t) {
const baseX = w * this.anchorXRatio;
const baseY = h * this.anchorYRatio;
const maxDim = Math.max(w, h);
const baseRadius = maxDim * this.radiusRatio;
function colorMix(from, to, t) {
const a = parseHex(from);
const b = parseHex(to);
const r = Math.round(a[0] + (b[0] - a[0]) * t);
const g = Math.round(a[1] + (b[1] - a[1]) * t);
const bl = Math.round(a[2] + (b[2] - a[2]) * t);
return `rgb(${r},${g},${bl})`;
}
const x = baseX + Math.sin(t * this.speed + this.phaseX) * (maxDim * 0.015);
const y = baseY + Math.cos(t * this.speed * 0.8 + this.phaseY) * (maxDim * 0.015);
const r = baseRadius + Math.sin(t * this.speed * 1.2 + this.phaseR) * (baseRadius * 0.03);
function shade(color, amount) {
const rgb = color.startsWith('#')
? color.match(/[\da-f]{2}/gi).map(v => parseInt(v, 16))
: color.match(/[\d.]+/g).slice(0, 3).map(Number);
return `rgb(${rgb.map(v => Math.round(amount >= 0 ? v + (255 - v) * amount : v * (1 + amount))).join(',')})`;
}
function resize() {
if (!canvas) return;
w = window.innerWidth;
h = window.innerHeight;
dpr = Math.min(window.devicePixelRatio || 1, 2);
canvas.width = w * dpr;
canvas.height = h * dpr;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
}
window.addEventListener('resize', resize);
resize();
function drawBackground(t, progress, isDark) {
const reveal = ease((progress - 0.66) / 0.34);
ctx.fillStyle = isDark ? '#0b1420' : '#f0f6f7';
ctx.fillRect(0, 0, w, h);
const m = Math.max(w, h);
const currentGlows = isDark ? [
{ x: .3, y: .7, r: .5, c: '0, 133, 150', a: .12, sx: .0001, sy: .00015 },
{ x: .7, y: .3, r: .6, c: '22, 34, 56', a: .18, sx: .00012, sy: .00008 },
{ x: .5, y: .5, r: .7, c: '14, 78, 94', a: .10, sx: -.00008, sy: .00011 }
] : [
{ x: .3, y: .7, r: .5, c: '0, 168, 157', a: .08, sx: .0001, sy: .00015 },
{ x: .7, y: .3, r: .6, c: '70, 80, 150', a: .06, sx: .00012, sy: .00008 },
{ x: .5, y: .5, r: .7, c: '110, 198, 194', a: .07, sx: -.00008, sy: .00011 }
];
for (const g of currentGlows) {
const x = w * g.x + Math.sin(t * g.sx) * w * 0.2;
const y = h * g.y + Math.cos(t * g.sy) * h * 0.2;
const r = m * g.r;
const gr = ctx.createRadialGradient(x, y, 0, x, y, r);
gr.addColorStop(0, `rgba(${g.c},${g.a})`);
gr.addColorStop(1, `rgba(${g.c},0)`);
ctx.globalAlpha = 0.5 + 0.5 * reveal;
ctx.fillStyle = gr;
ctx.fillRect(x - r, y - r, 2 * r, 2 * r);
}
ctx.globalAlpha = 1;
}
function drawLogo(progress, now, isDark) {
const scale = Math.min(w * 0.82 / 133, h * 0.64 / 140, 3.4);
const ox = (w - 133 * scale) / 2;
const oy = (h - 140 * scale) / 2;
const m = Math.max(w, h);
logoPaths.forEach(p => {
const q = leaves[p.leaf];
const targetColor = isDark ? q.darkColor : q.color;
const local = clamp((progress - q.delay) / (1 - q.delay));
const move = ease(local);
const sx = ox + q.at[0] * scale;
const sy = oy + q.at[1] * scale;
const phase = p.leaf * 1.71;
const driftX = Math.sin(now * 0.00035 + phase) * m * 0.008;
const driftY = Math.cos(now * 0.00029 + phase) * m * 0.008;
const tx = w * q.to[0] + driftX;
const ty = h * q.to[1] + driftY;
const dx = tx - sx;
const dy = ty - sy;
const len = Math.hypot(dx, dy) || 1;
const arc = Math.sin(Math.PI * move) * q.arc * m;
const pulse = 1 + 0.025 * Math.sin(now * 0.001 + phase);
const targetRadius = m * q.size * pulse;
const centerX = sx + dx * move - (dy / len) * arc;
const centerY = sy + dy * move + (dx / len) * arc;
const visualRadius = q.r * scale + (targetRadius - q.r * scale) * move;
if (this.style === 'fill') {
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fillStyle = this.color;
p.points.forEach(([px, py], i) => {
const theta = p.startAngle + p.direction * (Math.PI * 2 * i / p.points.length) + q.rot * move;
const x0 = ox + px * scale;
const y0 = oy + py * scale;
const x1 = tx + Math.cos(theta) * targetRadius;
const y1 = ty + Math.sin(theta) * targetRadius;
const x = x0 + (x1 - x0) * move - (dy / len) * arc;
const y = y0 + (y1 - y0) * move + (dx / len) * arc;
if (i === 0) ctx.moveTo(x, y);
else ctx.lineTo(x, y);
});
ctx.closePath();
ctx.save();
const base = colorMix(p.c, targetColor, move);
const light = ctx.createRadialGradient(centerX, centerY, 0, centerX, centerY, Math.max(2, visualRadius * 1.25));
light.addColorStop(0, base);
light.addColorStop(0.52, base);
light.addColorStop(0.82, shade(base, -0.10 * move));
light.addColorStop(1, shade(base, -0.24 * move));
ctx.fillStyle = light;
ctx.shadowColor = isDark ? 'rgba(0, 0, 0, 0.45)' : 'rgba(13, 23, 67, 0.32)';
ctx.shadowBlur = 28;
ctx.shadowOffsetX = 0;
ctx.shadowOffsetY = 13;
ctx.fill();
} else if (this.style === 'stroke') {
for (let i = 0; i < this.ringCount; i++) {
ctx.shadowColor = isDark ? 'rgba(0, 0, 0, 0.25)' : 'rgba(13, 23, 67, 0.18)';
ctx.shadowBlur = 4;
ctx.shadowOffsetY = 3;
ctx.fill();
ctx.restore();
});
}
function drawRings(progress, isDark) {
const reveal = ease((progress - 0.56) / 0.32);
const m = Math.max(w, h);
ctx.globalAlpha = reveal;
const currentRings = isDark ? [
[-.1, -.1, .25, 'rgba(56, 189, 248, .12)', 6],
[1.05, 1.05, .15, 'rgba(56, 189, 248, .18)', 4]
] : [
[-.1, -.1, .25, 'rgba(255, 255, 255, .24)', 6],
[1.05, 1.05, .15, 'rgba(0, 168, 157, .30)', 4]
];
for (const [a, b, r, c, n] of currentRings) {
const x = w * a;
const y = h * b;
for (let i = 0; i < n; i++) {
ctx.beginPath();
const ringR = r + i * (maxDim * 0.025);
ctx.arc(x, y, ringR, 0, Math.PI * 2);
ctx.strokeStyle = this.color;
ctx.arc(x, y, m * r + i * m * 0.025, 0, Math.PI * 2);
ctx.strokeStyle = c;
ctx.lineWidth = 1.5;
ctx.stroke();
}
}
}
}
class AmbientGlow {
constructor(xRatio, yRatio, radiusRatio, r, g, b, maxOpacity, speedX, speedY) {
this.xRatio = xRatio;
this.yRatio = yRatio;
this.radiusRatio = radiusRatio;
this.color = `${r}, ${g}, ${b}`;
this.maxOpacity = maxOpacity;
this.speedX = speedX;
this.speedY = speedY;
this.phaseX = Math.random() * Math.PI * 2;
this.phaseY = Math.random() * Math.PI * 2;
ctx.globalAlpha = 1;
}
draw(ctx, w, h, t) {
const maxDim = Math.max(w, h);
const radius = maxDim * this.radiusRatio;
const x = w * this.xRatio + Math.sin(t * this.speedX + this.phaseX) * (w * 0.2);
const y = h * this.yRatio + Math.cos(t * this.speedY + this.phaseY) * (h * 0.2);
const gradient = ctx.createRadialGradient(x, y, 0, x, y, radius);
gradient.addColorStop(0, `rgba(${this.color}, ${this.maxOpacity})`);
gradient.addColorStop(1, `rgba(${this.color}, 0)`);
ctx.beginPath();
ctx.arc(x, y, radius, 0, Math.PI * 2);
ctx.fillStyle = gradient;
ctx.fill();
}
}
function initDynamicBackground() {
const canvas = canvasRef.value;
if (!canvas) return null;
const ctx = canvas.getContext('2d', { alpha: false });
if (!ctx) return null;
let width = 0;
let height = 0;
let time = 0;
let animationFrameId = null;
function resizeCanvas() {
if (!canvas) return;
width = window.innerWidth;
height = window.innerHeight;
const dpr = window.devicePixelRatio || 1;
canvas.width = width * dpr;
canvas.height = height * dpr;
ctx.scale(dpr, dpr);
}
window.addEventListener('resize', resizeCanvas);
resizeCanvas();
const isDark = document.documentElement.classList.contains('dark');
const shapes = isDark ? [
new GeometricShape(-0.02, 0.15, 0.35, '#162238', 'fill'),
new GeometricShape(0.08, -0.05, 0.22, '#008596', 'fill'),
new GeometricShape(-0.1, -0.1, 0.25, 'rgba(56, 189, 248, 0.08)', 'stroke', 6),
new GeometricShape(0.85, 0.95, 0.25, '#162238', 'fill'),
new GeometricShape(0.98, 0.88, 0.15, '#008596', 'fill'),
new GeometricShape(1.05, 1.05, 0.15, 'rgba(56, 189, 248, 0.12)', 'stroke', 4),
new GeometricShape(0.05, 1.0, 0.18, '#0e4e5e', 'fill'),
new GeometricShape(0.95, -0.05, 0.2, '#008596', 'fill')
] : [
new GeometricShape(-0.02, 0.15, 0.35, '#262464', 'fill'),
new GeometricShape(0.08, -0.05, 0.22, '#00A89D', 'fill'),
new GeometricShape(-0.1, -0.1, 0.25, 'rgba(255, 255, 255, 0.08)', 'stroke', 6),
new GeometricShape(0.85, 0.95, 0.25, '#262464', 'fill'),
new GeometricShape(0.98, 0.88, 0.15, '#00A89D', 'fill'),
new GeometricShape(1.05, 1.05, 0.15, 'rgba(0, 168, 157, 0.15)', 'stroke', 4),
new GeometricShape(0.05, 1.0, 0.18, '#6ec6c2', 'fill'),
new GeometricShape(0.95, -0.05, 0.2, '#00A89D', 'fill')
];
const glows = isDark ? [
new AmbientGlow(0.3, 0.7, 0.5, 0, 133, 150, 0.12, 0.0001, 0.00015),
new AmbientGlow(0.7, 0.3, 0.6, 22, 34, 56, 0.18, 0.00012, 0.00008),
new AmbientGlow(0.5, 0.5, 0.7, 14, 78, 94, 0.10, -0.00008, 0.00011)
] : [
new AmbientGlow(0.3, 0.7, 0.5, 0, 168, 157, 0.08, 0.0001, 0.00015),
new AmbientGlow(0.7, 0.3, 0.6, 70, 80, 150, 0.06, 0.00012, 0.00008),
new AmbientGlow(0.5, 0.5, 0.7, 110, 198, 194, 0.07, -0.00008, 0.00011)
];
function drawBackground() {
const isNowDark = document.documentElement.classList.contains('dark');
ctx.fillStyle = isNowDark ? '#0b1420' : '#f0f6f7';
ctx.fillRect(0, 0, width, height);
}
function animate() {
drawBackground();
glows.forEach(glow => {
glow.draw(ctx, width, height, time);
});
ctx.lineCap = 'round';
ctx.lineJoin = 'round';
shapes.forEach(shape => {
shape.draw(ctx, width, height, time);
});
time += 16;
animationFrameId = requestAnimationFrame(animate);
}
animationFrameId = requestAnimationFrame(animate);
return () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
function frame(now) {
const isDark = document.documentElement.classList.contains('dark');
const progress = clamp((now - started - introHold) / scatterMs);
if (!cardNotified && progress >= 0.72) {
cardNotified = true;
if (typeof options.onCardReady === 'function') {
options.onCardReady();
}
}
window.removeEventListener('resize', resizeCanvas);
drawBackground(now, progress, isDark);
drawLogo(progress, now, isDark);
drawRings(progress, isDark);
animationFrameId = requestAnimationFrame(frame);
}
animationFrameId = requestAnimationFrame(frame);
function fastForward() {
started = performance.now() - introHold - scatterMs;
if (!cardNotified) {
cardNotified = true;
if (typeof options.onCardReady === 'function') {
options.onCardReady();
}
}
}
return {
cleanup: () => {
if (animationFrameId) {
cancelAnimationFrame(animationFrameId);
animationFrameId = null;
}
window.removeEventListener('resize', resize);
},
fastForward
};
}
onMounted(() => {
cleanupBackground = initDynamicBackground();
bgController = initDynamicBackground({
onCardReady: () => {
revealCardSmoothly();
}
});
cardTimer = setTimeout(revealCardSmoothly, 3200);
});
onBeforeUnmount(() => {
if (cleanupBackground) {
cleanupBackground();
cleanupBackground = null;
if (bgController && bgController.cleanup) {
bgController.cleanup();
bgController = null;
}
if (cardTimer) {
clearTimeout(cardTimer);
cardTimer = null;
}
});
@@ -421,25 +568,41 @@ html.dark .login-wrapper {
position: relative;
z-index: 2;
width: 100%;
max-width: 440px;
max-width: 430px;
padding: 20px;
opacity: 0;
filter: blur(10px);
transform: translateY(24px) scale(0.96);
transition:
opacity 1.05s cubic-bezier(0.22, 1, 0.36, 1),
transform 1.05s cubic-bezier(0.22, 1, 0.36, 1),
filter 0.95s cubic-bezier(0.22, 1, 0.36, 1);
pointer-events: none;
will-change: opacity, transform, filter;
&.is-visible {
opacity: 1;
filter: blur(0);
transform: translateY(0) scale(1);
pointer-events: auto;
}
}
.login-card {
background: rgba(255, 255, 255, 0.90);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border: 1px solid rgba(255, 255, 255, 0.85);
border-radius: 20px;
padding: 38px 36px 30px;
box-shadow: 0 20px 60px rgba(15, 34, 58, 0.12), 0 4px 16px rgba(15, 34, 58, 0.05);
background: rgba(255, 255, 255, 0.98);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
border: 1px solid #e2e8f0;
border-radius: 12px;
padding: 36px 36px 28px;
box-shadow: 0 4px 6px -1px rgba(15, 23, 42, 0.05), 0 20px 48px -10px rgba(15, 23, 42, 0.10);
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
html.dark .login-card {
background: rgba(23, 27, 34, 0.92);
background: rgba(21, 29, 44, 0.98);
border-color: rgba(255, 255, 255, 0.08);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.3);
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.55), 0 4px 16px rgba(0, 0, 0, 0.3);
}
.brand-header {
@@ -455,10 +618,10 @@ html.dark .login-card {
}
.brand-logo {
height: 56px;
height: 52px;
max-width: 240px;
object-fit: contain;
filter: drop-shadow(0 4px 10px rgba(15, 34, 58, 0.08));
filter: drop-shadow(0 2px 6px rgba(15, 34, 58, 0.08));
transition: transform 0.25s ease, filter 0.25s ease;
&:hover {
@@ -491,10 +654,10 @@ html.dark {
.system-title {
margin: 0;
font-size: 22px;
font-size: 21px;
font-weight: 700;
letter-spacing: 0.5px;
color: #1a2736;
color: #0f172a;
}
html.dark .system-title {
@@ -540,10 +703,10 @@ html.dark .badge-divider {
}
:deep(.el-input__wrapper) {
border-radius: 10px;
border-radius: 8px;
padding: 2px 14px;
height: 44px;
box-shadow: 0 0 0 1px #dcdfe6 inset;
box-shadow: 0 0 0 1px #cbd5e1 inset;
transition: box-shadow 0.2s ease, border-color 0.2s ease;
&:hover {
@@ -616,9 +779,9 @@ html.dark .login-form {
width: 120px;
height: 44px;
flex: 0 0 120px;
border-radius: 10px;
border-radius: 8px;
overflow: hidden;
border: 1px solid #dcdfe6;
border: 1px solid #cbd5e1;
background: #f8fafc;
cursor: pointer;
display: flex;
@@ -674,25 +837,25 @@ html.dark .register-link {
.login-submit-btn {
width: 100%;
height: 46px;
border-radius: 10px;
height: 44px;
border-radius: 8px;
font-size: 15px;
font-weight: 600;
letter-spacing: 2px;
border: none;
background: linear-gradient(135deg, #008596 0%, #0284c7 100%);
box-shadow: 0 8px 22px rgba(0, 133, 150, 0.32);
background: linear-gradient(135deg, #008596 0%, #009aa9 100%);
box-shadow: 0 4px 14px rgba(0, 133, 150, 0.28);
transition: all 0.25s ease;
&:hover {
background: linear-gradient(135deg, #009cb0 0%, #0396dc 100%);
box-shadow: 0 10px 26px rgba(0, 133, 150, 0.42);
background: linear-gradient(135deg, #009aa9 0%, #00adb9 100%);
box-shadow: 0 6px 18px rgba(0, 133, 150, 0.38);
transform: translateY(-1px);
}
&:active {
transform: translateY(1px);
box-shadow: 0 4px 12px rgba(0, 133, 150, 0.28);
box-shadow: 0 2px 8px rgba(0, 133, 150, 0.25);
}
}
@@ -701,18 +864,58 @@ html.dark .register-link {
bottom: 16px;
left: 0;
width: 100%;
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 8px;
text-align: center;
color: #64748b;
font-family: inherit;
font-size: 12px;
letter-spacing: 0.5px;
z-index: 2;
opacity: 0;
transform: translateY(10px);
transition:
opacity 0.85s ease 0.25s,
transform 0.85s cubic-bezier(0.22, 1, 0.36, 1) 0.25s;
&.is-visible {
opacity: 1;
transform: translateY(0);
}
}
html.dark .el-login-footer {
color: #94a3b8;
}
.footer-divider {
color: #cbd5e1;
user-select: none;
}
.open-source-link {
color: inherit;
text-decoration: none;
transition: color 0.2s ease, text-decoration-color 0.2s ease;
&:hover {
color: #008596;
text-decoration: underline;
text-underline-offset: 2px;
}
}
html.dark .footer-divider {
color: #475569;
}
html.dark .open-source-link:hover {
color: #38bdf8;
}
@media (max-width: 480px) {
.login-box {
padding: 14px;
@@ -0,0 +1,180 @@
<template>
<div class="app-container master-page">
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
<el-form-item label="现场ID" prop="siteId">
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="状态" prop="status">
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
</el-select>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:tunnel:list']">搜索</el-button>
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:tunnel:list']">重置</el-button>
</el-form-item>
</el-form>
<el-row :gutter="10" class="mb8 master-toolbar">
<template v-if="isMasterWriteAllowed">
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['master:tunnel:add']">新增</el-button></el-col>
<el-col :span="1.5">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
@click="handleUpdate()" v-hasPermi="['master:tunnel:edit']"
>修改</el-button>
</el-col>
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode" :span="1.5">
<el-button type="warning" plain icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
</el-col>
</template>
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="tunnelList" @selection-change="handleSelectionChange">
<el-table-column type="selection" width="55" align="center" />
<el-table-column label="隧道ID" prop="tunnelId" align="center" width="90" />
<el-table-column label="现场ID" prop="siteId" align="center" width="100" />
<el-table-column label="隧道编号" prop="tunnelCode" align="center" :show-overflow-tooltip="true" />
<el-table-column label="隧道名称" prop="tunnelName" align="center" :show-overflow-tooltip="true" />
<el-table-column label="当前溯源号" prop="currentTraceNo" align="center" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.currentTraceNo || '暂无物料' }}</template>
</el-table-column>
<el-table-column label="状态" prop="status" align="center" width="100">
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
</el-table-column>
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
</el-table-column>
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" class-name="small-padding fixed-width">
<template #default="scope">
<el-button
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
link type="primary" icon="Edit" :disabled="detailLoading" @click="handleUpdate(scope.row)" v-hasPermi="['master:tunnel:edit']"
>修改</el-button>
</template>
</el-table-column>
</el-table>
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'tunnel'" :resource-ids="selectedIds" @success="getList" />
<el-dialog v-model="open" :title="title" width="min(520px, 92vw)" append-to-body :close-on-click-modal="!submitLoading" :close-on-press-escape="!submitLoading" :show-close="!submitLoading">
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
<el-form ref="tunnelRef" :model="form" :rules="rules" label-width="90px">
<el-form-item label="现场ID" prop="siteId"><el-input-number v-model="form.siteId" :min="1" :controls="false" /></el-form-item>
<el-form-item label="隧道编号" prop="tunnelCode"><el-input v-model="form.tunnelCode" placeholder="请输入隧道编号" maxlength="64" show-word-limit /></el-form-item>
<el-form-item label="隧道名称" prop="tunnelName"><el-input v-model="form.tunnelName" placeholder="请输入隧道名称" maxlength="100" show-word-limit /></el-form-item>
<el-form-item label="状态" prop="status"><el-radio-group v-model="form.status"><el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio></el-radio-group></el-form-item>
<el-form-item label="备注" prop="remark"><el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" /></el-form-item>
</el-form>
<template #footer>
<div class="dialog-footer">
<el-button v-if="!form.tunnelId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:tunnel:add']">确 定</el-button>
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:tunnel:edit']">确 定</el-button>
<el-button :disabled="submitLoading" @click="cancel">取 消</el-button>
</div>
</template>
</el-dialog>
</div>
</template>
<style scoped>
.master-query { display:grid; grid-template-columns:repeat(auto-fit,minmax(220px,1fr)); gap:0 14px; padding:16px 18px 4px; margin-bottom:14px; background:var(--el-bg-color); border:1px solid var(--el-border-color-lighter); border-radius:8px; }
.master-query :deep(.el-form-item) { margin-right:0; width:100%; } .master-query :deep(.el-form-item__content) { flex:1; min-width:0; } .master-query :deep(.el-input), .master-query :deep(.el-input-number), .master-query :deep(.el-select) { width:100% !important; min-width:0; } .master-query :deep(.el-form-item:last-child) { align-self:end; }
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
.master-table :deep(.el-table__header th) { background:var(--el-fill-color-light); font-weight:600; } .master-table :deep(.el-table__cell) { padding:10px 0; } .master-table :deep(.fixed-width) { position:sticky; right:0; z-index:2; background:var(--el-bg-color); box-shadow:-4px 0 8px rgba(0,0,0,.04); }
@media (max-width:768px) { .master-query { grid-template-columns:1fr; padding:14px 14px 2px; } .master-query :deep(.el-form-item:last-child) { margin-bottom:12px; } .master-toolbar :deep(.el-button) { margin-left:0; } }
</style>
<script setup name="Tunnel">
import { addTunnel, getTunnel, listTunnel, updateTunnel } from '@/api/master/tunnel'
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
import useUserStore from '@/store/modules/user'
import useTestBatchStore from '@/store/modules/testBatch'
const { proxy } = getCurrentInstance()
const userStore = useUserStore()
const testBatchStore = useTestBatchStore()
const { sys_normal_disable } = proxy.useDict('sys_normal_disable')
const tunnelList = ref([])
const showSearch = ref(true)
const selectedIds = ref([])
const single = ref(true)
const total = ref(0)
const loading = ref(false)
const detailLoading = ref(false)
const submitLoading = ref(false)
const loadError = ref('')
const formError = ref('')
const open = ref(false)
const title = ref('')
const adoptionDialogRef = ref()
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
const data = reactive({
form: {},
queryParams: { pageNum: 1, pageSize: 10, siteId: undefined, tunnelCode: undefined, tunnelName: undefined, status: undefined },
rules: {
siteId: [{ required: true, message: '请输入现场ID', trigger: 'change' }],
tunnelCode: [{ required: true, message: '请输入隧道编号', trigger: 'blur' }],
tunnelName: [{ required: true, message: '请输入隧道名称', trigger: 'blur' }],
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
}
})
const { form, queryParams, rules } = toRefs(data)
function getList() {
loading.value = true
loadError.value = ''
listTunnel({ ...queryParams.value }).then((response) => {
tunnelList.value = response.rows
total.value = response.total
}).catch(() => {
tunnelList.value = []
total.value = 0
loadError.value = '隧道列表加载失败,请稍后重试'
}).finally(() => { loading.value = false })
}
function reset() {
formError.value = ''
form.value = { tunnelId: undefined, siteId: undefined, tunnelCode: '', tunnelName: '', status: '0', remark: '' }
proxy.resetForm('tunnelRef')
}
function handleQuery() { queryParams.value.pageNum = 1; getList() }
function resetQuery() { proxy.resetForm('queryRef'); handleQuery() }
function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.tunnelId); single.value = selectedIds.value.length !== 1 }
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
function handleAdd() { reset(); title.value = '新增隧道'; open.value = true }
function handleUpdate(row = {}) {
const tunnelId = row.tunnelId || selectedIds.value[0]
if (!tunnelId) return
detailLoading.value = true
getTunnel(tunnelId).then((response) => {
const detail = response.data
form.value = { tunnelId: detail.tunnelId, siteId: detail.siteId, tunnelCode: detail.tunnelCode, tunnelName: detail.tunnelName, status: detail.status, remark: detail.remark || '' }
title.value = '修改隧道'
open.value = true
}).catch(() => { formError.value = '隧道详情加载失败,请稍后重试' }).finally(() => { detailLoading.value = false })
}
function cancel() { if (submitLoading.value) return; open.value = false; reset() }
function submitCreate() { submitForm(addTunnel, '新增成功') }
function submitUpdate() { submitForm(updateTunnel, '修改成功') }
function submitForm(action, successMessage) {
if (submitLoading.value) return
proxy.$refs.tunnelRef.validate((valid) => {
if (!valid) return
submitLoading.value = true
action({ ...form.value }).then(() => { proxy.$modal.msgSuccess(successMessage); open.value = false; reset(); getList() }).catch(() => { formError.value = '保存失败,请检查现场和隧道编号后重试' }).finally(() => { submitLoading.value = false })
})
}
getList()
</script>
@@ -0,0 +1,405 @@
<template>
<main class="open-source-page">
<div class="notice-container">
<header class="notice-header">
<router-link to="/login" class="brand-link" aria-label="返回登录页">
<img :src="brandLogoLight" alt="Sylvan" class="brand-logo brand-logo-light" />
<img :src="brandLogoDark" alt="Sylvan" class="brand-logo brand-logo-dark" />
</router-link>
<router-link to="/login" class="back-link">
<span aria-hidden="true">←</span>
返回登录
</router-link>
</header>
<section class="notice-heading" aria-labelledby="notice-title">
<p class="notice-eyebrow">THIRD-PARTY SOFTWARE NOTICE</p>
<h1 id="notice-title">开源协议说明</h1>
<p class="notice-summary">
本页面用于说明施尔丰系统中使用的第三方开源软件及其许可证信息。
</p>
</section>
<article class="notice-card">
<div class="notice-card-header">
<div>
<p class="notice-label">第三方来源代码</p>
<h2>RuoYi-Vue3-FastAPI</h2>
</div>
<span class="license-badge">MIT License</span>
</div>
<p class="notice-description">
本项目 Web 前端部分包含来源于 RuoYi-Vue3-FastAPI 项目的代码及其衍生部分,
相关代码保留原项目的版权声明与 MIT 许可证。
</p>
<div class="notice-meta">
<div class="meta-item">
<span class="meta-label">版权声明</span>
<strong>Copyright (c) 2018 RuoYi</strong>
</div>
<div class="meta-item">
<span class="meta-label">来源项目</span>
<a
href="https://github.com/insistence/RuoYi-Vue3-FastAPI"
target="_blank"
rel="noopener noreferrer"
>github.com/insistence/RuoYi-Vue3-FastAPI</a>
</div>
</div>
<div class="scope-note">
<span class="scope-note-icon" aria-hidden="true">ⓘ</span>
<span>施尔丰自有业务代码、界面设计和文档不因本声明而开放授权。</span>
</div>
<section class="license-section" aria-labelledby="mit-title">
<h3 id="mit-title">The MIT License (MIT)</h3>
<pre class="license-text">{{ ruoyiLicenseText }}</pre>
</section>
</article>
<footer class="notice-footer">
<span>Copyright © 2024-2026 Shierfeng International Biotechnology Co., Ltd. All Rights Reserved.</span>
</footer>
</div>
</main>
</template>
<script setup>
import brandLogoDark from '@/assets/logo/sylvan-logo.svg'
import brandLogoLight from '@/assets/logo/sylvan-logo-light.svg'
const ruoyiLicenseText = `Copyright (c) 2018 RuoYi
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies
of the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.`
</script>
<style lang="scss" scoped>
.open-source-page {
min-height: 100vh;
color: #1a2736;
background:
radial-gradient(circle at 8% 12%, rgba(0, 168, 157, 0.14), transparent 32%),
radial-gradient(circle at 92% 86%, rgba(38, 36, 100, 0.13), transparent 34%),
#f5f9fa;
}
.notice-container {
width: min(100%, 1040px);
margin: 0 auto;
padding: 28px 24px 30px;
}
.notice-header,
.notice-card-header,
.notice-meta {
display: flex;
align-items: center;
}
.notice-header {
justify-content: space-between;
gap: 20px;
}
.brand-link {
display: inline-flex;
align-items: center;
}
.brand-logo {
width: 168px;
height: 48px;
object-fit: contain;
}
.brand-logo-light {
display: block;
}
.brand-logo-dark {
display: none;
}
.back-link {
display: inline-flex;
align-items: center;
gap: 7px;
color: #008596;
font-size: 14px;
text-decoration: none;
transition: color 0.2s ease;
&:hover {
color: #006d7a;
}
}
.notice-heading {
padding: 58px 0 28px;
text-align: center;
}
.notice-eyebrow,
.notice-label,
.meta-label {
margin: 0;
color: #008596;
font-size: 12px;
font-weight: 700;
letter-spacing: 1.6px;
text-transform: uppercase;
}
.notice-heading h1 {
margin: 12px 0 10px;
color: #152337;
font-size: clamp(28px, 4vw, 42px);
line-height: 1.2;
letter-spacing: 0.4px;
}
.notice-summary {
max-width: 620px;
margin: 0 auto;
color: #64748b;
font-size: 15px;
line-height: 1.8;
}
.notice-card {
padding: clamp(22px, 4vw, 40px);
border: 1px solid rgba(255, 255, 255, 0.9);
border-radius: 22px;
background: rgba(255, 255, 255, 0.88);
box-shadow: 0 22px 60px rgba(15, 34, 58, 0.1), 0 4px 14px rgba(15, 34, 58, 0.05);
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
}
.notice-card-header {
justify-content: space-between;
gap: 18px;
}
.notice-card h2 {
margin: 8px 0 0;
color: #152337;
font-size: clamp(21px, 3vw, 28px);
}
.license-badge {
flex: 0 0 auto;
padding: 7px 12px;
border: 1px solid rgba(0, 133, 150, 0.2);
border-radius: 999px;
color: #008596;
background: rgba(0, 168, 157, 0.08);
font-size: 12px;
font-weight: 700;
}
.notice-description {
margin: 22px 0 0;
color: #475569;
font-size: 15px;
line-height: 1.8;
}
.notice-meta {
flex-wrap: wrap;
gap: 18px 48px;
margin-top: 26px;
padding: 18px 0;
border-top: 1px solid #e2e8f0;
border-bottom: 1px solid #e2e8f0;
}
.meta-item {
min-width: 240px;
}
.meta-label {
display: block;
margin-bottom: 7px;
color: #94a3b8;
font-size: 11px;
letter-spacing: 1px;
}
.meta-item strong,
.meta-item a {
color: #334155;
font-size: 13px;
line-height: 1.6;
}
.meta-item a {
color: #008596;
text-decoration: none;
overflow-wrap: anywhere;
&:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
}
.scope-note {
display: flex;
align-items: flex-start;
gap: 9px;
margin-top: 22px;
padding: 13px 15px;
border-radius: 12px;
color: #475569;
background: #f0f8f8;
font-size: 13px;
line-height: 1.7;
}
.scope-note-icon {
flex: 0 0 auto;
color: #008596;
font-size: 15px;
line-height: 1.5;
}
.license-section {
margin-top: 30px;
}
.license-section h3 {
margin: 0 0 14px;
color: #1e293b;
font-size: 16px;
}
.license-text {
max-height: 430px;
margin: 0;
padding: 20px;
overflow: auto;
border: 1px solid #e2e8f0;
border-radius: 12px;
color: #475569;
background: #f8fafc;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
font-size: 12px;
line-height: 1.75;
white-space: pre-wrap;
overflow-wrap: anywhere;
}
.notice-footer {
padding: 26px 0 0;
color: #94a3b8;
font-size: 12px;
line-height: 1.6;
text-align: center;
}
html.dark .open-source-page {
color: #e2e8f0;
background:
radial-gradient(circle at 8% 12%, rgba(0, 133, 150, 0.2), transparent 32%),
radial-gradient(circle at 92% 86%, rgba(56, 189, 248, 0.12), transparent 34%),
#0b1420;
}
html.dark {
.brand-logo-light {
display: none;
}
.brand-logo-dark {
display: block;
}
.back-link,
.meta-item a {
color: #38bdf8;
}
.back-link:hover,
.meta-item a:hover {
color: #7dd3fc;
}
.notice-heading h1,
.notice-card h2,
.license-section h3 {
color: #f1f5f9;
}
.notice-summary,
.notice-description,
.meta-item strong,
.scope-note,
.license-text {
color: #cbd5e1;
}
.notice-card {
border-color: rgba(255, 255, 255, 0.08);
background: rgba(23, 27, 34, 0.92);
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.5);
}
.notice-meta {
border-color: #334155;
}
.scope-note {
background: rgba(0, 133, 150, 0.14);
}
.license-text {
border-color: #334155;
background: #111827;
}
}
@media (max-width: 640px) {
.notice-container {
padding: 20px 14px 24px;
}
.notice-heading {
padding: 42px 0 22px;
}
.notice-card-header {
align-items: flex-start;
flex-direction: column;
}
.meta-item {
min-width: 0;
width: 100%;
}
.license-text {
max-height: 500px;
padding: 15px;
font-size: 11px;
}
}
</style>
@@ -1,6 +1,15 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="客户ID" prop="customerId">
<el-input-number v-model="queryParams.customerId" :min="1" :controls="false" placeholder="请输入客户ID" />
</el-form-item>
@@ -8,7 +17,7 @@
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
<el-form-item label="产线ID" prop="lineId">
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="请输入产线ID" />
<el-input-number v-model="queryParams.lineId" :min="1" :controls="false" placeholder="可选,仅筛选已登记产线" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
@@ -36,7 +45,22 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<template #empty><el-empty description="暂无产线消耗数据" /></template>
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无隧道消耗数据" /></template>
<el-table-column label="使用记录号" prop="usageNo" min-width="150" :show-overflow-tooltip="true" />
<el-table-column label="使用时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
@@ -48,7 +72,7 @@
<template #default="scope">{{ scope.row.siteName || `#${scope.row.siteId}` }}</template>
</el-table-column>
<el-table-column label="产线" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.lineName || `#${scope.row.lineId}` }}</template>
<template #default="scope">{{ scope.row.lineName || (scope.row.lineId ? `#${scope.row.lineId}` : '未登记') }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="菌种" min-width="150" :show-overflow-tooltip="true">
@@ -149,6 +173,9 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
customerId: undefined,
siteId: undefined,
lineId: undefined,
@@ -182,7 +209,7 @@ function getList() {
if (!isCurrentListRequest(listContext)) return
reportList.value = []
total.value = 0
loadError.value = '产线消耗报表加载失败,请稍后重试'
loadError.value = '隧道消耗报表加载失败,请稍后重试'
}).finally(() => {
if (!isCurrentListRequest(listContext)) return
loading.value = false
@@ -1,6 +1,15 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
@@ -48,6 +57,21 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无托盘库存数据" /></template>
<el-table-column label="托盘编号" prop="palletCode" min-width="140" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="flowState" min-width="150">
@@ -157,6 +181,9 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
palletCode: undefined,
flowState: undefined,
warehouseId: undefined,
@@ -169,6 +196,7 @@ const flowStateOptions = [
{ value: 'UNSTOCKED', label: '未入库' },
{ value: 'EMPTY_IN_WAREHOUSE', label: '仓库空托盘' },
{ value: 'FILLED_IN_WAREHOUSE', label: '仓库有料' },
{ value: 'IN_TRANSIT', label: '待收货' },
{ value: 'AT_SITE_UNUSED', label: '现场未使用' },
{ value: 'AT_SITE_PARTIAL', label: '现场部分使用' },
{ value: 'AT_SITE_EMPTY', label: '现场空托盘' },
@@ -185,7 +213,7 @@ function flowStateLabel(value) {
}
function currentLocation(row) {
return row.lineName || row.siteName || row.warehouseName ||
return row.tunnelName || row.tunnelCode || row.lineName || row.siteName || row.warehouseName ||
idFallback(row.lineId || row.siteId || row.warehouseId)
}
@@ -1,9 +1,21 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="托盘编号" prop="palletCode">
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input v-model="queryParams.batchNo" placeholder="请输入事件生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="事件类型" prop="eventType">
<el-select v-model="queryParams.eventType" placeholder="请选择事件类型" clearable>
<el-option v-for="option in eventTypeOptions" :key="option.value" :label="option.label" :value="option.value" />
@@ -25,7 +37,23 @@
</el-row>
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-alert :title="traceSummary" description="按隧道或完整溯源号可查询跨托盘、跨批次明细。仅统计当前页,退回申请不重复计入确认退回量。" type="info" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无托盘追溯数据" /></template>
<el-table-column label="发生时间" prop="occurredAt" width="180">
<template #default="scope">{{ parseTime(scope.row.occurredAt) }}</template>
@@ -37,6 +65,17 @@
<template #default="scope">{{ sourceLabel(scope.row.sourceType, scope.row.sourceId) }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="生产批次快照" prop="batchNoSnapshot" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.batchNoSnapshot || '未登记' }}</template>
</el-table-column>
<el-table-column label="实际使用包数" prop="usedPackages" width="120" align="right" />
<el-table-column label="确认退回包数" prop="returnedPackages" width="120" align="right" />
<el-table-column label="变更前隧道" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ [scope.row.beforeTunnelCode, scope.row.beforeTunnelName].filter(Boolean).join(' / ') || '未登记' }}</template>
</el-table-column>
<el-table-column label="变更后隧道" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ [scope.row.afterTunnelCode, scope.row.afterTunnelName].filter(Boolean).join(' / ') || '未登记' }}</template>
</el-table-column>
<el-table-column label="变更前状态" prop="beforeState" min-width="140">
<template #default="scope">{{ flowStateLabel(scope.row.beforeState) }}</template>
</el-table-column>
@@ -140,6 +179,11 @@ import { listPalletTrace } from '@/api/report/palletTrace'
const { proxy } = getCurrentInstance()
const reportList = ref([])
const traceSummary = computed(() => {
const used = reportList.value.reduce((sum, row) => sum + (row.usedPackages ?? 0), 0)
const returned = reportList.value.reduce((sum, row) => sum + (row.returnedPackages ?? 0), 0)
return `本页实际使用 ${used} 包,确认退回 ${returned} 包`
})
const showSearch = ref(true)
const loading = ref(false)
const loadError = ref('')
@@ -150,13 +194,19 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
palletCode: undefined,
batchNo: undefined,
eventType: undefined
})
const eventTypeOptions = [
{ value: 'INBOUND_CONFIRMED', label: '入库确认' },
{ value: 'FILLING_CONFIRMED', label: '装填确认' },
{ value: 'SHIPMENT_CONFIRMED', label: '发货确认' },
{ value: 'SHIPMENT_RECEIVED', label: '收货确认' },
{ value: 'TUNNEL_ENTERED', label: '入隧登记' },
{ value: 'USAGE_RECORDED', label: '使用记录' },
{ value: 'RETURN_REQUESTED', label: '退回申请' },
{ value: 'RETURN_CONFIRMED', label: '退回确认' }
@@ -165,6 +215,7 @@ const sourceTypeOptions = [
{ value: 'INBOUND', label: '入库单' },
{ value: 'FILLING', label: '装填记录' },
{ value: 'SHIPMENT', label: '发货单' },
{ value: 'TUNNEL', label: '隧道' },
{ value: 'USAGE', label: '使用记录' },
{ value: 'RETURN', label: '退回单' }
]
@@ -172,6 +223,7 @@ const flowStateOptions = [
{ value: 'UNSTOCKED', label: '未入库' },
{ value: 'EMPTY_IN_WAREHOUSE', label: '仓库空托盘' },
{ value: 'FILLED_IN_WAREHOUSE', label: '仓库有料' },
{ value: 'IN_TRANSIT', label: '待收货' },
{ value: 'AT_SITE_UNUSED', label: '现场未使用' },
{ value: 'AT_SITE_PARTIAL', label: '现场部分使用' },
{ value: 'AT_SITE_EMPTY', label: '现场空托盘' },
@@ -1,9 +1,21 @@
<template>
<div class="app-container report-page">
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
<el-form-item label="隧道编号" prop="tunnelCode">
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="隧道名称" prop="tunnelName">
<el-input v-model="queryParams.tunnelName" placeholder="请输入隧道名称" clearable maxlength="100" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="内部溯源号" prop="traceNo">
<el-input v-model="queryParams.traceNo" placeholder="完整溯源号,查询同组多托盘" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="退回单号" prop="returnNo">
<el-input v-model="queryParams.returnNo" placeholder="请输入退回单号" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="生产批次" prop="batchNo">
<el-input v-model="queryParams.batchNo" placeholder="请输入退回生产批次" clearable maxlength="64" @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="来源现场ID" prop="sourceSiteId">
<el-input-number v-model="queryParams.sourceSiteId" :min="1" :controls="false" placeholder="请输入现场ID" />
</el-form-item>
@@ -36,6 +48,21 @@
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
<el-table v-loading="loading" class="report-table data-table" stripe border height="calc(100vh - 330px)" :data="reportList">
<el-table-column label="隧道编号" prop="tunnelCode" min-width="130" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelCode || '未登记' }}</template>
</el-table-column>
<el-table-column label="隧道名称" prop="tunnelName" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.tunnelName || '未登记' }}</template>
</el-table-column>
<el-table-column label="内部溯源号" prop="traceNo" min-width="180" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.traceNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="生产日期" prop="productionDate" width="120">
<template #default="scope">{{ scope.row.productionDate || '未登记' }}</template>
</el-table-column>
<el-table-column label="有效期至" prop="expiryDate" width="120">
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
</el-table-column>
<template #empty><el-empty description="暂无退回仓库数据" /></template>
<el-table-column label="退回单号" prop="returnNo" min-width="160" :show-overflow-tooltip="true" />
<el-table-column label="状态" prop="status" width="100">
@@ -54,6 +81,9 @@
<template #default="scope">{{ scope.row.targetWarehouseName || `#${scope.row.targetWarehouseId}` }}</template>
</el-table-column>
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
<el-table-column label="退回生产批次" prop="batchNo" min-width="140" :show-overflow-tooltip="true">
<template #default="scope">{{ scope.row.batchNoSnapshot || scope.row.batchNo || '未登记' }}</template>
</el-table-column>
<el-table-column label="剩余包数快照" prop="remainingPackagesSnapshot" width="130" align="right" />
</el-table>
@@ -147,7 +177,11 @@ const queryParams = ref({
pageSize: 10,
beginTime: undefined,
endTime: undefined,
tunnelCode: undefined,
tunnelName: undefined,
traceNo: undefined,
returnNo: undefined,
batchNo: undefined,
sourceSiteId: undefined,
targetWarehouseId: undefined,
palletCode: undefined,