Feat: 前端改版 Web 端界面与业务作业流程
按设计稿改版登录后的全部页面,登录页与 Logo 资源未改动。 - 全局外壳:浅色侧边栏与分组菜单、左下角用户卡片、Ctrl/⌘K 搜索面板、 测试模式提示条、深色模式配色、窄屏适配 - 共用组件:页头 PageHeader、托盘流程条 FlowStepper、扫码反馈 ScanFeedback、 缩短单号 DocNo、托盘档案链接 PalletCodeLink、图标 SfIcon、上级资料选择 MasterRefSelect,以及编号段解析等工具 - 工作台重做;新增托盘档案页;托盘追溯增加单托盘入口,报表托盘号可跳转档案 - 基础资料维护:各页加指标条、名称代替 ID、详情面板;三步式批量导入预览; 分配用户对话框标注增减;7 个新增/编辑对话框统一布局 - 业务管理:发货出库、仓库入库、工厂装填、客户现场使用、退回仓库改为 列表 + 详情面板 + 全屏作业区;写入流程、幂等键与防串单逻辑保持不变 - 修复入库页扫码反馈条因同名状态变量遮蔽组件而不显示的问题,并新增契约检查 - 新增入库粘贴解析、退回、装填的行为测试与现场使用页新流程测试, 同步更新业务与工作台契约脚本 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,7 +11,7 @@
|
|||||||
"build:docker": "vite build --mode docker",
|
"build:docker": "vite build --mode docker",
|
||||||
"build:stage": "vite build --mode staging",
|
"build:stage": "vite build --mode staging",
|
||||||
"preview": "vite preview",
|
"preview": "vite preview",
|
||||||
"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"
|
"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 scripts/pallet-ranges.test.mjs"
|
||||||
},
|
},
|
||||||
"repository": {
|
"repository": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
|
|||||||
@@ -0,0 +1,350 @@
|
|||||||
|
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/filling/index.vue'))
|
||||||
|
const script = compileScript(descriptor, { id: 'pc-filling-test', genDefaultAs: '__component' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename: 'filling/index.vue', id: 'pc-filling-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const apiSource = await read('api/business/filling.js')
|
||||||
|
const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`)
|
||||||
|
const scanUtils = await importText('utils/palletScan.js')
|
||||||
|
const passport = await importText('utils/palletPassport.js')
|
||||||
|
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
|
||||||
|
`const ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
|
||||||
|
function loadModule(source, modules) {
|
||||||
|
const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1])
|
||||||
|
return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules)
|
||||||
|
}
|
||||||
|
const masterDisplay = loadModule(await read('utils/masterDisplay.js'), {
|
||||||
|
'@/router': { default: { getRoutes: () => [{ path: '/businessOperation/shipment' }] } },
|
||||||
|
'@/utils/palletPassport': passport
|
||||||
|
})
|
||||||
|
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
||||||
|
|
||||||
|
const isoDate = offset => {
|
||||||
|
const now = new Date()
|
||||||
|
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate() + offset)
|
||||||
|
return `${day.getFullYear()}-${String(day.getMonth() + 1).padStart(2, '0')}-${String(day.getDate()).padStart(2, '0')}`
|
||||||
|
}
|
||||||
|
const STRAINS = [{ id: 3, code: 'A15', name: '平菇 A15' }]
|
||||||
|
const SUMMARY = {
|
||||||
|
todayPalletCount: 6, todayPackageSum: 600, todayBatchCount: 1, monthStart: '2026-10-01', monthPalletCount: 99, monthBatchCount: 8,
|
||||||
|
monthTopStrains: [{ strainName: '平菇 A15', palletCount: 31 }, { strainName: '白灵菇 B22', palletCount: 27 }],
|
||||||
|
emptyPalletCount: 13, emptyByWarehouse: [{ warehouseId: 4, warehouseName: '成品一仓', palletCount: 13 }],
|
||||||
|
nearExpiryDays: 14, nearExpiryPalletCount: 4, nearExpiryBatchCount: 1, nearestExpiryBatchNo: 'B260923-01', nearestExpiryDate: isoDate(7)
|
||||||
|
}
|
||||||
|
const BATCH = {
|
||||||
|
strainId: 3, strainCode: 'A15', strainName: '平菇 A15', batchNo: 'B261017-01', productionDate: isoDate(-1), expiryDate: isoDate(44),
|
||||||
|
packageSpec: '1kg/包', palletCount: 6, packageSum: 600, lastOperatedAt: '2026-10-17T09:10:00', lastOperatorName: '年糕'
|
||||||
|
}
|
||||||
|
const NEAR_BATCH = { ...BATCH, batchNo: 'B260923-01', expiryDate: isoDate(7) }
|
||||||
|
const DETAIL = {
|
||||||
|
...BATCH,
|
||||||
|
distribution: [{ state: 'FILLED_IN_WAREHOUSE', palletCount: 4 }, { state: 'IN_TRANSIT', palletCount: 1 }, { state: 'CONSUMED', palletCount: 1 }],
|
||||||
|
recent: [{ fillingId: 90, fillingNo: 'FL-90', palletId: 501, palletCode: 'TP-10501', warehouseName: '成品一仓', operatorName: '年糕', operatedAt: '2026-10-17T09:10:00' }]
|
||||||
|
}
|
||||||
|
const EMPTY_PALLETS = {
|
||||||
|
'TP-10501': { palletId: 501, palletCode: 'TP-10501', qrToken: 'QR-501', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 },
|
||||||
|
'TP-10502': { palletId: 502, palletCode: 'TP-10502', qrToken: 'QR-502', flowState: 'EMPTY_IN_WAREHOUSE', warehouseId: 4, capacityPackages: 100 }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScan(token) {
|
||||||
|
const pallet = EMPTY_PALLETS[token]
|
||||||
|
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ request: override, permissions = () => true, batches = [BATCH] } = {}) {
|
||||||
|
const requests = []
|
||||||
|
const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false })
|
||||||
|
const request = config => {
|
||||||
|
requests.push(structuredClone(config))
|
||||||
|
const custom = override?.(config)
|
||||||
|
if (custom !== undefined) return custom
|
||||||
|
if (config.url === '/business/filling/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
if (config.url === '/business/filling/batches') return Promise.resolve({ rows: structuredClone(batches), total: batches.length })
|
||||||
|
if (config.url === '/business/filling/batches/detail') return Promise.resolve({ data: structuredClone({ ...DETAIL, ...batches.find(item => item.batchNo === config.params.batchNo) }) })
|
||||||
|
if (config.url === '/business/filling/list') return Promise.resolve({ rows: [], total: 0 })
|
||||||
|
if (config.url === '/business/filling/strain-options') return Promise.resolve({ data: STRAINS })
|
||||||
|
if (config.url.startsWith('/business/filling/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]))
|
||||||
|
if (config.url === '/business/filling' && config.method === 'post') return Promise.resolve({ msg: '装填成功' })
|
||||||
|
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 proxy = {
|
||||||
|
$auth: { hasPermiAnd: perms => permissions(perms) },
|
||||||
|
$modal: { msgSuccess() {}, msgWarning() {} },
|
||||||
|
$refs: { fillingRef: { validate: callback => callback(true), clearValidate() {} } },
|
||||||
|
resetForm() {}
|
||||||
|
}
|
||||||
|
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
|
||||||
|
const stub = name => ({ default: { name } })
|
||||||
|
const modules = {
|
||||||
|
vue,
|
||||||
|
'@/components/PalletScanInput': stub('PalletScanInput'),
|
||||||
|
'@/components/ScanFeedback': stub('ScanFeedback'),
|
||||||
|
'@/components/SfIcon': stub('SfIcon'),
|
||||||
|
'@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'),
|
||||||
|
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
|
||||||
|
'@/utils/masterDisplay': masterDisplay,
|
||||||
|
'@/utils/palletPassport': passport,
|
||||||
|
'@/store/modules/user': { default: () => user },
|
||||||
|
'@/store/modules/testBatch': { default: () => batch },
|
||||||
|
'@/api/business/filling': api
|
||||||
|
}
|
||||||
|
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
||||||
|
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance']
|
||||||
|
const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...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, requests,
|
||||||
|
posts: () => requests.filter(item => item.method === 'post'),
|
||||||
|
async scan(value) { state.handleFillingScan(value); await flush() },
|
||||||
|
nodes() {
|
||||||
|
let nodes
|
||||||
|
const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
|
||||||
|
app.mount({}); app.unmount()
|
||||||
|
return nodes
|
||||||
|
},
|
||||||
|
close() { scope.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('')
|
||||||
|
const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text))
|
||||||
|
const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput')
|
||||||
|
|
||||||
|
async function openStation(page, batch) {
|
||||||
|
await flush()
|
||||||
|
page.state.handleOpenFilling(batch)
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function lockParams(page, overrides = {}) {
|
||||||
|
Object.assign(page.state.form.value, {
|
||||||
|
strainId: 3, batchNo: ' B261018-01 ', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: ' 1kg/包 ', ...overrides
|
||||||
|
})
|
||||||
|
await flush()
|
||||||
|
page.state.lockFillingParams()
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
test('装填工位:未锁定参数不能扫码,锁定前校验必填与日期顺序', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await openStation(page)
|
||||||
|
assert.equal(page.state.open.value, true)
|
||||||
|
assert.equal(scanInput(page).props.disabled, true)
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
|
||||||
|
assert.equal(page.state.scanNotice.value.type, 'warn')
|
||||||
|
|
||||||
|
page.state.lockFillingParams()
|
||||||
|
assert.equal(page.state.paramError.value, '请选择菌种')
|
||||||
|
await lockParams(page, { expiryDate: isoDate(-1) })
|
||||||
|
assert.equal(page.state.paramError.value, '有效期不能早于生产日期')
|
||||||
|
assert.equal(page.state.paramsLocked.value, false)
|
||||||
|
|
||||||
|
await lockParams(page)
|
||||||
|
assert.equal(page.state.paramsLocked.value, true)
|
||||||
|
assert.deepEqual(
|
||||||
|
{ ...page.state.lockedParams.value },
|
||||||
|
{ strainId: 3, strainLabel: '平菇 A15', batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45), packageSpec: '1kg/包', initialPackages: 100 }
|
||||||
|
)
|
||||||
|
assert.equal(scanInput(page).props.disabled, false)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('扫码后确认:载荷由扫码结果与锁定参数组成,成功后进入本班记录且参数保持锁定', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await openStation(page)
|
||||||
|
await lockParams(page)
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
assert.equal(page.posts().length, 0, '默认模式扫码后等待确认')
|
||||||
|
assert.equal(page.state.scannedPallet.value.palletCode, 'TP-10501')
|
||||||
|
assert.equal(page.state.form.value.qrToken, 'QR-501')
|
||||||
|
assert.equal(page.state.form.value.warehouseId, 4)
|
||||||
|
assert.equal(button(page, '确认装填').props.disabled, false)
|
||||||
|
|
||||||
|
page.state.submitFilling()
|
||||||
|
await flush()
|
||||||
|
const [post] = page.posts()
|
||||||
|
assert.deepEqual(post.data, {
|
||||||
|
qrToken: 'QR-501', warehouseId: 4, strainId: 3, batchNo: 'B261018-01', productionDate: isoDate(0), expiryDate: isoDate(45),
|
||||||
|
packageSpec: '1kg/包', initialPackages: 100, idempotencyKey: post.data.idempotencyKey
|
||||||
|
})
|
||||||
|
assert.ok(post.data.idempotencyKey)
|
||||||
|
assert.equal(page.state.shiftRecords.value.length, 1)
|
||||||
|
assert.deepEqual(
|
||||||
|
[page.state.shiftRecords.value[0].palletCode, page.state.shiftRecords.value[0].warehouseName, page.state.shiftRecords.value[0].batchNo],
|
||||||
|
['TP-10501', '成品一仓', 'B261018-01']
|
||||||
|
)
|
||||||
|
assert.equal(page.state.scannedPallet.value, null)
|
||||||
|
assert.equal(page.state.paramsLocked.value, true)
|
||||||
|
assert.equal(page.state.scanNotice.value.type, 'ok')
|
||||||
|
|
||||||
|
await page.scan('TP-10502')
|
||||||
|
page.state.submitFilling()
|
||||||
|
await flush()
|
||||||
|
const keys = page.posts().map(item => item.data.idempotencyKey)
|
||||||
|
assert.equal(keys.length, 2)
|
||||||
|
assert.notEqual(keys[0], keys[1], '不同托盘使用不同幂等键')
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('扫码即装填:校验通过立即提交,失败后重试复用同一幂等键', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const page = createPage({
|
||||||
|
request: config => {
|
||||||
|
if (config.method !== 'post') return undefined
|
||||||
|
attempts += 1
|
||||||
|
return attempts === 1 ? Promise.reject(new Error('历史非 100 包托盘不允许新增装填')) : Promise.resolve({ msg: '装填成功' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await openStation(page)
|
||||||
|
await lockParams(page)
|
||||||
|
page.state.fillMode.value = 'auto'
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
assert.equal(page.posts().length, 1)
|
||||||
|
assert.match(page.state.formError.value, /直接重试将复用原键/)
|
||||||
|
assert.equal(page.state.scanNotice.value.message, '历史非 100 包托盘不允许新增装填')
|
||||||
|
assert.ok(page.state.scannedPallet.value, '失败后保留待装填托盘以便重试')
|
||||||
|
assert.ok(button(page, '重试装填'))
|
||||||
|
|
||||||
|
page.state.submitFilling()
|
||||||
|
await flush()
|
||||||
|
const keys = page.posts().map(item => item.data.idempotencyKey)
|
||||||
|
assert.equal(keys[0], keys[1])
|
||||||
|
assert.equal(page.state.shiftRecords.value.length, 1)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('后端拒绝与非空托盘进入未装填记录,本班已装填的托盘提示无需重复', async () => {
|
||||||
|
const filled = new Set()
|
||||||
|
const page = createPage({
|
||||||
|
request: config => {
|
||||||
|
if (config.method === 'post') {
|
||||||
|
filled.add(config.data.qrToken)
|
||||||
|
return Promise.resolve({ msg: '装填成功' })
|
||||||
|
}
|
||||||
|
if (config.url.endsWith('/scan/TP-10288')) {
|
||||||
|
return Promise.resolve({ data: { palletId: 288, palletCode: 'TP-10288', flowState: 'FILLED_IN_WAREHOUSE', warehouseId: 4 } })
|
||||||
|
}
|
||||||
|
// 已装填的托盘不再是空托盘,服务端扫码会拒绝
|
||||||
|
const token = decodeURIComponent(config.url.split('/scan/')[1] || '')
|
||||||
|
if (filled.has(EMPTY_PALLETS[token]?.qrToken)) return Promise.reject(new Error('托盘不符合仓库空托盘装填条件'))
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await openStation(page)
|
||||||
|
await lockParams(page)
|
||||||
|
await page.scan('TP-10999')
|
||||||
|
assert.deepEqual([page.state.scanRejects.value[0].code, page.state.scanRejects.value[0].message], ['TP-10999', '托盘不符合仓库空托盘装填条件'])
|
||||||
|
await page.scan('TP-10288')
|
||||||
|
assert.equal(page.state.scanRejects.value[0].message, '该托盘为「仓库有料」,只有仓库空托盘可以装填')
|
||||||
|
assert.equal(page.state.scannedPallet.value, null)
|
||||||
|
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
page.state.submitFilling()
|
||||||
|
await flush()
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
assert.equal(page.state.scanNotice.value.type, 'info')
|
||||||
|
assert.match(page.state.scanNotice.value.message, /^本班 \d{2}:\d{2} 已装填,无需重复$/)
|
||||||
|
assert.equal(page.state.scanRejects.value.length, 2, '重复扫本班托盘不计入未装填记录')
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('修改参数会放弃待装填托盘,沿用批次只带入参数仍需锁定', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await openStation(page, BATCH)
|
||||||
|
assert.equal(page.state.paramsLocked.value, false)
|
||||||
|
assert.deepEqual(
|
||||||
|
[page.state.form.value.strainId, page.state.form.value.batchNo, page.state.form.value.packageSpec],
|
||||||
|
[3, 'B261017-01', '1kg/包']
|
||||||
|
)
|
||||||
|
page.state.lockFillingParams()
|
||||||
|
await flush()
|
||||||
|
await page.scan('TP-10501')
|
||||||
|
assert.ok(page.state.scannedPallet.value)
|
||||||
|
|
||||||
|
page.state.unlockFillingParams()
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.paramsLocked.value, false)
|
||||||
|
assert.equal(page.state.scannedPallet.value, null)
|
||||||
|
assert.equal(page.state.form.value.batchNo, 'B261017-01', '解锁后参数回到表单供修改')
|
||||||
|
assert.equal(page.state.form.value.qrToken, '')
|
||||||
|
page.state.applyShelfLife(30)
|
||||||
|
assert.equal(page.state.form.value.expiryDate, isoDate(29))
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('结束本班后显示装填结果并刷新列表与概况,再次打开工位从空白开始', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await openStation(page, BATCH)
|
||||||
|
page.state.lockFillingParams()
|
||||||
|
await flush()
|
||||||
|
await page.scan('TP-10502')
|
||||||
|
page.state.submitFilling()
|
||||||
|
await flush()
|
||||||
|
const before = page.requests.length
|
||||||
|
page.state.cancelFilling()
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.open.value, false)
|
||||||
|
assert.match(page.state.shiftResultText.value, /^本班已装填 1 托 · 100 包(B261017-01)/)
|
||||||
|
const refreshed = page.requests.slice(before).map(item => item.url)
|
||||||
|
assert.ok(refreshed.includes('/business/filling/batches'))
|
||||||
|
assert.ok(refreshed.includes('/business/filling/summary'))
|
||||||
|
|
||||||
|
await openStation(page)
|
||||||
|
assert.equal(page.state.shiftRecords.value.length, 0)
|
||||||
|
assert.equal(page.state.form.value.batchNo, '')
|
||||||
|
assert.equal(page.state.lockedParams.value, null)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('列表:默认按生产批次,切到装填明细使用同一筛选;临期批次不能沿用', async () => {
|
||||||
|
const page = createPage({ batches: [BATCH, NEAR_BATCH] })
|
||||||
|
await flush()
|
||||||
|
const urls = page.requests.map(item => item.url)
|
||||||
|
assert.deepEqual(urls.slice(0, 2), ['/business/filling/summary', '/business/filling/batches'])
|
||||||
|
assert.equal(urls.includes('/business/filling/strain-options'), false, '启动阶段不请求 query 权限接口')
|
||||||
|
assert.equal(page.state.todayHint.value, '600 包 · 1 个批次')
|
||||||
|
assert.equal(page.state.monthHint.value, '8 个批次 · 平菇 A15 / 白灵菇 B22')
|
||||||
|
assert.equal(page.state.nearExpiryHint.value, 'B260923-01 · 7 天后到期,请优先发货')
|
||||||
|
assert.deepEqual(page.state.specPresets.value, ['1kg/包'])
|
||||||
|
|
||||||
|
page.state.openBatchPanel(page.state.batchList.value[0])
|
||||||
|
await flush()
|
||||||
|
assert.deepEqual(page.state.distributionRows.value.map(item => [item.label, item.palletCount]), [['仓库有料', 4], ['在途待收货', 1], ['已用完', 1]])
|
||||||
|
assert.equal(page.state.reuseBlocked.value, false)
|
||||||
|
assert.equal(button(page, '沿用此批参数').props.disabled, false)
|
||||||
|
page.state.openBatchPanel(page.state.batchList.value[1])
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.reuseBlocked.value, true)
|
||||||
|
assert.equal(button(page, '沿用此批参数').props.disabled, true)
|
||||||
|
|
||||||
|
page.state.queryParams.value.keyword = 'TP-105'
|
||||||
|
page.state.viewMode.value = 'record'
|
||||||
|
page.state.handleViewChange()
|
||||||
|
await flush()
|
||||||
|
const last = page.requests.filter(item => item.url === '/business/filling/list').at(-1)
|
||||||
|
assert.equal(last.params.keyword, 'TP-105')
|
||||||
|
assert.equal(last.params.pageNum, 1)
|
||||||
|
assert.equal(page.state.panelBatch.value, null)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
@@ -25,6 +25,9 @@ async function fillingPage(platform) {
|
|||||||
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
|
useUserStore: () => ({ admin: true }), useTestBatchStore: () => ({ isTestMode: false }),
|
||||||
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
|
onShow() {}, createIdempotencyKey: () => `date-command-${++key}`,
|
||||||
listFilling: async () => ({ rows: [], total: 0 }),
|
listFilling: async () => ({ rows: [], total: 0 }),
|
||||||
|
listFillingBatches: async () => ({ rows: [], total: 0 }),
|
||||||
|
getFillingSummary: async () => ({ data: null }),
|
||||||
|
getFillingBatchDetail: async () => ({ data: null }),
|
||||||
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
|
addFilling: async payload => { submissions.push(payload); throw new Error('网络暂不可用') },
|
||||||
})
|
})
|
||||||
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
|
vm.runInContext(`${script}\nglobalThis.page = {form, scannedPallet, submitFilling, formError};`, context)
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
import assert from 'node:assert/strict'
|
||||||
|
import { test } from 'node:test'
|
||||||
|
import { groupCodeRanges, parsePasteTokens, splitPalletCode } from '../src/utils/palletRanges.js'
|
||||||
|
|
||||||
|
test('托盘号拆分为前缀与末尾数字,无数字结尾时返回空', () => {
|
||||||
|
assert.deepEqual(splitPalletCode(' TP-10501 '), { prefix: 'TP-', digits: '10501', number: 10501 })
|
||||||
|
assert.deepEqual(splitPalletCode('PLT-007'), { prefix: 'PLT-', digits: '007', number: 7 })
|
||||||
|
assert.equal(splitPalletCode('TP-A'), null)
|
||||||
|
assert.equal(splitPalletCode(''), null)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('连续编号合并成段,乱序与重复不影响结果', () => {
|
||||||
|
const ranges = groupCodeRanges(['TP-10503', 'TP-10501', 'TP-10502', 'TP-10502', 'TP-10520', 'TP-10521'])
|
||||||
|
assert.deepEqual(
|
||||||
|
ranges.map(({ label, count }) => [label, count]),
|
||||||
|
[['TP-10501 – 10503', 3], ['TP-10520 – 10521', 2]]
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('补零位数保持连续,不同前缀与无数字托盘号单独成段', () => {
|
||||||
|
const ranges = groupCodeRanges(['PLT-0999', 'PLT-1000', 'A-9', 'A-10', 'B-001', 'TP-X'])
|
||||||
|
assert.deepEqual(
|
||||||
|
ranges.map(({ label, count }) => [label, count]),
|
||||||
|
[['A-9 – 10', 2], ['B-001', 1], ['PLT-0999 – 1000', 2], ['TP-X', 1]]
|
||||||
|
)
|
||||||
|
// 位数不同且带补零的编号不是同一段
|
||||||
|
assert.equal(groupCodeRanges(['C-09', 'C-010']).length, 2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('粘贴内容按换行、空格与中英文标点切分并去重', () => {
|
||||||
|
const result = parsePasteTokens('TP-10501\nTP-10502,TP-10503、TP-10501;TP-10504 TP-10505')
|
||||||
|
assert.deepEqual(result.codes, ['TP-10501', 'TP-10502', 'TP-10503', 'TP-10504', 'TP-10505'])
|
||||||
|
assert.equal(result.duplicates, 1)
|
||||||
|
assert.deepEqual(result.errors, [])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('区间按起始托盘号的前缀与位数展开', () => {
|
||||||
|
assert.deepEqual(parsePasteTokens('TP-10512-10514').codes, ['TP-10512', 'TP-10513', 'TP-10514'])
|
||||||
|
assert.deepEqual(parsePasteTokens('10512-10513').codes, ['10512', '10513'])
|
||||||
|
assert.deepEqual(parsePasteTokens('PLT-008 ~ PLT-010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
|
||||||
|
assert.deepEqual(parsePasteTokens('PLT-008–010').codes, ['PLT-008', 'PLT-009', 'PLT-010'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('普通带连字符的托盘号不会被误当作区间', () => {
|
||||||
|
assert.deepEqual(parsePasteTokens('TP-10501').codes, ['TP-10501'])
|
||||||
|
// 右侧不大于左侧时不构成区间
|
||||||
|
assert.deepEqual(parsePasteTokens('A1-0001').codes, ['A1-0001'])
|
||||||
|
})
|
||||||
|
|
||||||
|
test('超长区间与无法识别的波浪号区间给出错误且不展开', () => {
|
||||||
|
const result = parsePasteTokens('TP-1-300 TP-5~TP-3 TP-9', { rangeLimit: 200 })
|
||||||
|
assert.deepEqual(result.codes, ['TP-9'])
|
||||||
|
assert.equal(result.errors.length, 2)
|
||||||
|
assert.match(result.errors[0].message, /最多 200 个/)
|
||||||
|
assert.match(result.errors[1].message, /无法识别/)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('总数超过上限时截断并标记', () => {
|
||||||
|
const result = parsePasteTokens('T-1-10', { totalLimit: 4 })
|
||||||
|
assert.deepEqual(result.codes, ['T-1', 'T-2', 'T-3', 'T-4'])
|
||||||
|
assert.equal(result.truncated, true)
|
||||||
|
})
|
||||||
@@ -0,0 +1,361 @@
|
|||||||
|
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/return/index.vue'))
|
||||||
|
const script = compileScript(descriptor, { id: 'pc-return-test', genDefaultAs: '__component' })
|
||||||
|
const template = compileTemplate({ source: descriptor.template.content, filename: 'return/index.vue', id: 'pc-return-test', compilerOptions: { bindingMetadata: script.bindings } })
|
||||||
|
assert.deepEqual(template.errors, [])
|
||||||
|
const apiSource = await read('api/business/return.js')
|
||||||
|
const importText = async path => import(`data:text/javascript,${encodeURIComponent(await read(path))}`)
|
||||||
|
const scanUtils = await importText('utils/palletScan.js')
|
||||||
|
const passport = await importText('utils/palletPassport.js')
|
||||||
|
const docNo = await importText('utils/docNo.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)}];`)
|
||||||
|
function loadModule(source, modules) {
|
||||||
|
const names = [...source.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1])
|
||||||
|
return new Function('__modules', `${bindImports(source).replace(/export (function|const) /g, '$1 ')}\nreturn { ${names.join(',')} };`)(modules)
|
||||||
|
}
|
||||||
|
const masterDisplay = loadModule(await read('utils/masterDisplay.js'), {
|
||||||
|
'@/router': { default: { getRoutes: () => [{ path: '/businessOperation/filling' }] } },
|
||||||
|
'@/utils/palletPassport': passport
|
||||||
|
})
|
||||||
|
const flush = async () => { for (let index = 0; index < 4; index += 1) await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
||||||
|
|
||||||
|
const ROW = {
|
||||||
|
returnId: 13,
|
||||||
|
returnNo: 'RE-3f8a6d1c0b9e42f7a5d3c8e1b06fc21d',
|
||||||
|
status: 'REQUESTED',
|
||||||
|
sourceSiteId: 9,
|
||||||
|
targetWarehouseId: 7,
|
||||||
|
sourceSiteName: '南厂',
|
||||||
|
customerName: '金禾菌业',
|
||||||
|
targetWarehouseName: '成品一仓',
|
||||||
|
palletCount: 3,
|
||||||
|
packageSum: 60,
|
||||||
|
filledCount: 1,
|
||||||
|
requestedAt: '2026-10-17T10:20:00',
|
||||||
|
requesterName: '赵强'
|
||||||
|
}
|
||||||
|
const DETAIL = {
|
||||||
|
...ROW,
|
||||||
|
items: [
|
||||||
|
{ returnItemId: 1, palletId: 101, palletCode: 'TP-10466', remainingPackagesSnapshot: 60, tunnelIdSnapshot: 31, tunnelName: '2 号隧道' },
|
||||||
|
{ returnItemId: 2, palletId: 102, palletCode: 'TP-10468', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null },
|
||||||
|
{ returnItemId: 3, palletId: 103, palletCode: 'TP-10471', remainingPackagesSnapshot: 0, tunnelIdSnapshot: null, tunnelName: null }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
const SITES = [
|
||||||
|
{ id: 9, code: 'JH-S', name: '南厂', customerName: '金禾菌业' },
|
||||||
|
{ id: 10, code: 'LY-1', name: '一号基地', customerName: '绿源农业' }
|
||||||
|
]
|
||||||
|
const WAREHOUSES = [{ id: 7, code: 'WH-NB01', name: '成品一仓' }, { id: 8, code: 'WH-NB02', name: '成品二仓' }]
|
||||||
|
const SUMMARY = {
|
||||||
|
pendingCount: 1, pendingPalletCount: 3, pendingFilledCount: 1, pendingPackageSum: 60, oldestPendingAt: '2026-10-17T10:20:00',
|
||||||
|
monthStart: '2026-10-01', monthConfirmedCount: 2, monthConfirmedPalletCount: 26, monthFilledPalletCount: 4, monthPackageSum: 210, monthAvgConfirmHours: 20
|
||||||
|
}
|
||||||
|
const CONFIRM_PALLETS = Object.fromEntries(DETAIL.items.map(item => [item.palletCode, { palletId: item.palletId, palletCode: item.palletCode, flowState: 'RETURNING', siteId: 9, remainingPackages: item.remainingPackagesSnapshot }]))
|
||||||
|
const REQUEST_PALLETS = {
|
||||||
|
'TP-10475': { palletId: 201, palletCode: 'TP-10475', flowState: 'AT_SITE_PARTIAL', siteId: 9, remainingPackages: 30, tunnelId: 31, traceNo: 'TR-0042' },
|
||||||
|
'TP-10474': { palletId: 202, palletCode: 'TP-10474', flowState: 'AT_SITE_EMPTY', siteId: 9, remainingPackages: 0, tunnelId: null, traceNo: null },
|
||||||
|
'TP-10520': { palletId: 203, palletCode: 'TP-10520', flowState: 'AT_SITE_EMPTY', siteId: 10, remainingPackages: 0, tunnelId: null, traceNo: null }
|
||||||
|
}
|
||||||
|
|
||||||
|
function defaultScan(token, params) {
|
||||||
|
if (params.mode === 'confirm') {
|
||||||
|
const pallet = CONFIRM_PALLETS[token]
|
||||||
|
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('扫码托盘不属于该退回单'))
|
||||||
|
}
|
||||||
|
if (token === 'TP-10288') return Promise.reject(new Error('托盘当前状态不允许发起退回'))
|
||||||
|
const pallet = REQUEST_PALLETS[token]
|
||||||
|
return pallet ? Promise.resolve({ data: pallet }) : Promise.reject(new Error('无权访问该托盘'))
|
||||||
|
}
|
||||||
|
|
||||||
|
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({ request: override, permissions = () => true } = {}) {
|
||||||
|
const requests = [], messages = []
|
||||||
|
const user = Vue.reactive({ admin: false }), batch = Vue.reactive({ isTestMode: false })
|
||||||
|
const request = config => {
|
||||||
|
requests.push(structuredClone(config))
|
||||||
|
const custom = override?.(config)
|
||||||
|
if (custom !== undefined) return custom
|
||||||
|
if (config.url === '/business/return/list') return Promise.resolve({ rows: [structuredClone(ROW)], total: 1 })
|
||||||
|
if (config.url === '/business/return/summary') return Promise.resolve({ data: SUMMARY })
|
||||||
|
if (config.url === '/business/return/site-options') return Promise.resolve({ data: SITES })
|
||||||
|
if (config.url === '/business/return/warehouse-options') return Promise.resolve({ data: WAREHOUSES })
|
||||||
|
if (config.url === '/business/return/13') return Promise.resolve({ data: structuredClone(DETAIL) })
|
||||||
|
if (config.url.startsWith('/business/return/scan/')) return defaultScan(decodeURIComponent(config.url.split('/scan/')[1]), config.params)
|
||||||
|
if (config.url === '/business/return/13/confirm') return Promise.resolve({ msg: '退回确认成功' })
|
||||||
|
if (config.url === '/business/return/request') return Promise.resolve({ msg: '退回申请提交成功' })
|
||||||
|
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 proxy = {
|
||||||
|
$auth: { hasPermiAnd: perms => permissions(perms) },
|
||||||
|
$modal: { msgSuccess: msg => messages.push(msg), msgWarning: msg => messages.push(msg) },
|
||||||
|
resetForm() {}
|
||||||
|
}
|
||||||
|
const vue = { ...Vue, getCurrentInstance: () => ({ proxy }), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
|
||||||
|
const stub = name => ({ default: { name } })
|
||||||
|
const modules = {
|
||||||
|
vue,
|
||||||
|
'@/components/PalletScanInput': stub('PalletScanInput'),
|
||||||
|
'@/components/ScanFeedback': stub('ScanFeedback'),
|
||||||
|
'@/components/SfIcon': stub('SfIcon'),
|
||||||
|
'@/components/TestBatchAdoptionDialog': stub('TestBatchAdoptionDialog'),
|
||||||
|
'@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) },
|
||||||
|
'@/utils/docNo': docNo,
|
||||||
|
'@/utils/masterDisplay': masterDisplay,
|
||||||
|
'@/utils/palletPassport': passport,
|
||||||
|
'@/store/modules/user': { default: () => user },
|
||||||
|
'@/store/modules/testBatch': { default: () => batch },
|
||||||
|
'@/api/business/return': api
|
||||||
|
}
|
||||||
|
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
||||||
|
const globals = ['ref', 'reactive', 'computed', 'watch', 'getCurrentInstance']
|
||||||
|
const component = new Function('__modules', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, ...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, requests, messages,
|
||||||
|
listCalls: () => requests.filter(item => item.url === '/business/return/list' && item.params.pageSize !== 1),
|
||||||
|
async confirmScan(value) { state.handleConfirmScan(value); await flush() },
|
||||||
|
async requestScan(value) { state.handleRequestScan(value); await flush() },
|
||||||
|
nodes() {
|
||||||
|
let nodes
|
||||||
|
const app = renderer.createApp({ render() { nodes = flatten(render({}, [], {}, Vue.proxyRefs(state), {}, {})); return null } })
|
||||||
|
app.mount({}); app.unmount()
|
||||||
|
return nodes
|
||||||
|
},
|
||||||
|
close() { scope.stop() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const label = node => flatten(node).filter(child => typeof child.children === 'string').map(child => child.children).join('')
|
||||||
|
const button = (page, text) => page.nodes().find(node => node.type?.name === 'el-button' && label(node).includes(text))
|
||||||
|
const scanInput = page => page.nodes().find(node => node.type?.name === 'PalletScanInput')
|
||||||
|
|
||||||
|
async function openConfirm(page) {
|
||||||
|
await flush()
|
||||||
|
page.state.startConfirmFromRow(page.state.returnList.value[0])
|
||||||
|
await flush()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function scanAll(page) {
|
||||||
|
for (const code of ['TP-10466', 'TP-10468', 'TP-10471']) await page.confirmScan(code)
|
||||||
|
}
|
||||||
|
|
||||||
|
test('到仓核对:全部对上前禁止确认,非本单托盘不计入,提交前重新读取详情', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await openConfirm(page)
|
||||||
|
assert.equal(page.state.jobOpen.value, true)
|
||||||
|
assert.equal(page.state.jobKind.value, 'confirm')
|
||||||
|
assert.deepEqual(page.state.confirmExpectedPalletIds.value, [101, 102, 103])
|
||||||
|
assert.deepEqual(page.state.confirmOutcome.value, { filled: 1, packages: 60, empty: 2, inTunnel: 1 })
|
||||||
|
const confirmButton = () => button(page, '确认退回 ·')
|
||||||
|
assert.equal(confirmButton().props.disabled, true)
|
||||||
|
|
||||||
|
await page.confirmScan('TP-10466')
|
||||||
|
await page.confirmScan('TP-10468')
|
||||||
|
assert.equal(confirmButton().props.disabled, true)
|
||||||
|
assert.equal(page.state.confirmTodoCount.value, 1)
|
||||||
|
assert.equal(page.state.confirmFeedback.value.message, '核对通过 · 还差 1 托')
|
||||||
|
|
||||||
|
await page.confirmScan('TP-10999')
|
||||||
|
assert.equal(page.state.confirmScannedPallets.value.length, 2, '非本单托盘不能计入')
|
||||||
|
assert.deepEqual(page.state.confirmStrays.value.map(item => [item.code, item.message]), [['TP-10999', '扫码托盘不属于该退回单']])
|
||||||
|
await page.confirmScan('TP-10466')
|
||||||
|
assert.equal(page.state.confirmFeedback.value.type, 'info')
|
||||||
|
assert.equal(page.state.confirmScannedPallets.value.length, 2, '重复扫码不追加')
|
||||||
|
|
||||||
|
await page.confirmScan('TP-10471')
|
||||||
|
assert.equal(page.state.confirmComplete.value, true)
|
||||||
|
assert.equal(confirmButton().props.disabled, false)
|
||||||
|
|
||||||
|
const before = page.requests.length
|
||||||
|
page.state.submitReturnConfirm()
|
||||||
|
await flush()
|
||||||
|
const writes = page.requests.slice(before, before + 2)
|
||||||
|
assert.deepEqual(writes.map(item => `${item.method} ${item.url}`), ['get /business/return/13', 'put /business/return/13/confirm'])
|
||||||
|
assert.deepEqual(Object.keys(writes[1].data).sort(), ['idempotencyKey', 'scannedPalletIds'])
|
||||||
|
assert.deepEqual([...writes[1].data.scannedPalletIds].sort(), [101, 102, 103])
|
||||||
|
assert.equal(page.state.jobOpen.value, false)
|
||||||
|
assert.match(page.state.jobResultText.value, /RE-3F8A…C21D 已确认 · 1 托带料(60 包)已回到成品一仓有料库存,2 托空托盘回到成品一仓,可再次装填。/)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('到仓核对:确认失败后重试复用幂等键,撤销一托后重新锁定确认', async () => {
|
||||||
|
let attempts = 0
|
||||||
|
const page = createPage({
|
||||||
|
request: config => {
|
||||||
|
if (config.url !== '/business/return/13/confirm') return undefined
|
||||||
|
attempts += 1
|
||||||
|
return attempts === 1 ? Promise.reject(new Error('网络异常')) : Promise.resolve({ msg: '退回确认成功' })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await openConfirm(page)
|
||||||
|
await scanAll(page)
|
||||||
|
|
||||||
|
page.state.removeConfirmPallet(102)
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.confirmComplete.value, false)
|
||||||
|
assert.equal(button(page, '确认退回 ·').props.disabled, true)
|
||||||
|
assert.equal(page.state.confirmFeedback.value.message, '已撤销核对,需要重新扫码')
|
||||||
|
await page.confirmScan('TP-10468')
|
||||||
|
|
||||||
|
page.state.submitReturnConfirm()
|
||||||
|
await flush()
|
||||||
|
assert.match(page.state.confirmError.value, /复用本次幂等键/)
|
||||||
|
assert.equal(page.state.jobOpen.value, true, '失败时保留作业区与已核对集合')
|
||||||
|
assert.equal(page.state.confirmScannedPallets.value.length, 3)
|
||||||
|
page.state.submitReturnConfirm()
|
||||||
|
await flush()
|
||||||
|
const keys = page.requests.filter(item => item.method === 'put').map(item => item.data.idempotencyKey)
|
||||||
|
assert.equal(keys.length, 2)
|
||||||
|
assert.equal(keys[0], keys[1])
|
||||||
|
assert.equal(page.state.jobOpen.value, false)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('到仓核对:详情已变化时不提交确认', async () => {
|
||||||
|
let reads = 0
|
||||||
|
const page = createPage({
|
||||||
|
request: config => {
|
||||||
|
if (config.url !== '/business/return/13') return undefined
|
||||||
|
reads += 1
|
||||||
|
const detail = structuredClone(DETAIL)
|
||||||
|
if (reads > 1) detail.items = detail.items.slice(0, 2)
|
||||||
|
return Promise.resolve({ data: detail })
|
||||||
|
}
|
||||||
|
})
|
||||||
|
await openConfirm(page)
|
||||||
|
await scanAll(page)
|
||||||
|
page.state.submitReturnConfirm()
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.requests.filter(item => item.method === 'put').length, 0)
|
||||||
|
assert.match(page.state.confirmError.value, /明细已变化/)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('发起申请:先选仓库再扫码,首托锁定来源现场,跨现场与后端拒绝原因进入被拒记录', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await flush()
|
||||||
|
page.state.startRequestJob()
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.jobKind.value, 'request')
|
||||||
|
assert.deepEqual(page.state.warehouseOptions.value, WAREHOUSES)
|
||||||
|
assert.equal(scanInput(page).props.disabled, true, '未选目标仓库时扫码框禁用')
|
||||||
|
|
||||||
|
await page.requestScan('TP-10475')
|
||||||
|
assert.equal(page.requests.filter(item => item.url.includes('/scan/')).length, 0)
|
||||||
|
assert.equal(page.state.requestFeedback.value.type, 'warn')
|
||||||
|
|
||||||
|
page.state.requestForm.value.targetWarehouseId = 7
|
||||||
|
await flush()
|
||||||
|
assert.equal(scanInput(page).props.disabled, false)
|
||||||
|
await page.requestScan('TP-10475')
|
||||||
|
assert.deepEqual(page.requests.filter(item => item.url.includes('/scan/')).at(-1).params, { mode: 'request' })
|
||||||
|
assert.equal(page.state.requestForm.value.sourceSiteId, 9)
|
||||||
|
assert.equal(page.state.requestSiteText.value, '金禾菌业 · 南厂')
|
||||||
|
assert.equal(page.state.requestFeedback.value.message, '已添加 · 现场部分使用 · 余 30 包')
|
||||||
|
|
||||||
|
await page.requestScan('TP-10520')
|
||||||
|
assert.equal(page.state.requestRejects.value[0].message, '同一退回申请只能包含同一客户现场的托盘:该托盘在绿源农业 · 一号基地')
|
||||||
|
await page.requestScan('TP-10288')
|
||||||
|
assert.deepEqual([page.state.requestRejects.value[0].code, page.state.requestRejects.value[0].message], ['TP-10288', '托盘当前状态不允许发起退回'])
|
||||||
|
assert.equal(page.state.requestRejectCount.value, 2)
|
||||||
|
await page.requestScan('TP-10474')
|
||||||
|
await page.requestScan('TP-10474')
|
||||||
|
assert.equal(page.state.requestFeedback.value.message, '已在本单中,未重复添加')
|
||||||
|
assert.deepEqual(page.state.requestScannedPallets.value.map(item => item.palletCode), ['TP-10474', 'TP-10475'])
|
||||||
|
assert.deepEqual(page.state.requestSummary.value, { count: 2, packages: 30, filled: 1, empty: 1 })
|
||||||
|
|
||||||
|
page.state.submitReturnRequest()
|
||||||
|
await flush()
|
||||||
|
const post = page.requests.find(item => item.method === 'post')
|
||||||
|
assert.deepEqual(Object.keys(post.data).sort(), ['idempotencyKey', 'palletIds', 'sourceSiteId', 'targetWarehouseId'])
|
||||||
|
assert.deepEqual([...post.data.palletIds].sort(), [201, 202])
|
||||||
|
assert.equal(post.data.sourceSiteId, 9)
|
||||||
|
assert.equal(post.data.targetWarehouseId, 7)
|
||||||
|
assert.equal(page.state.jobOpen.value, false)
|
||||||
|
assert.equal(page.state.jobResultText.value, '退回申请已提交 · 2 托已变为「退回中」,现场不能再使用。成品一仓收到实物后逐托核对确认。')
|
||||||
|
assert.equal(page.state.activeStatusTab.value, 'requested')
|
||||||
|
assert.equal(page.listCalls().at(-1).params.status, 'REQUESTED')
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('移除最后一托后释放来源现场,可改扫其他现场', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await flush()
|
||||||
|
page.state.startRequestJob()
|
||||||
|
await flush()
|
||||||
|
page.state.requestForm.value.targetWarehouseId = 7
|
||||||
|
await flush()
|
||||||
|
await page.requestScan('TP-10475')
|
||||||
|
page.state.removeRequestPallet(201)
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.requestForm.value.sourceSiteId, undefined)
|
||||||
|
await page.requestScan('TP-10520')
|
||||||
|
assert.equal(page.state.requestForm.value.sourceSiteId, 10)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('列表:关键字、页签与概况来自真实接口,名称与余量按行展示', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await flush()
|
||||||
|
assert.deepEqual(page.requests.slice(0, 3).map(item => item.url), ['/business/return/summary', '/business/return/site-options', '/business/return/list'])
|
||||||
|
assert.equal(page.requests.some(item => item.url === '/business/return/warehouse-options'), false, '启动阶段不请求 query 权限接口')
|
||||||
|
assert.equal(page.state.sourceLabel(ROW), '金禾菌业 · 南厂')
|
||||||
|
assert.equal(page.state.targetLabel(ROW), '成品一仓')
|
||||||
|
assert.equal(page.state.packagesText(ROW), '余 60 包')
|
||||||
|
assert.equal(page.state.packagesText({ packageSum: 0 }), '均已用完')
|
||||||
|
assert.equal(page.state.returningHint.value, '其中带料 1 托 · 共 60 包')
|
||||||
|
assert.equal(page.state.monthEmptyCount.value, 22)
|
||||||
|
assert.equal(page.state.monthHint.value, '平均申请到确认 20 小时')
|
||||||
|
assert.equal(page.state.statusCounts.all, 1)
|
||||||
|
|
||||||
|
page.state.queryParams.value.keyword = 'TP-104'
|
||||||
|
page.state.selectStatusTab('confirmed')
|
||||||
|
await flush()
|
||||||
|
const last = page.listCalls().at(-1)
|
||||||
|
assert.equal(last.params.keyword, 'TP-104')
|
||||||
|
assert.equal(last.params.status, 'CONFIRMED')
|
||||||
|
assert.equal(last.params.pageNum, 1)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无确认权限时详情面板与行内都不提供到仓核对入口', async () => {
|
||||||
|
const page = createPage({ permissions: perms => !perms.includes('business:return:confirm') })
|
||||||
|
await flush()
|
||||||
|
page.state.openReturnPanel(page.state.returnList.value[0])
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.panelItems.value.length, 3)
|
||||||
|
assert.equal(button(page, '开始到仓核对'), undefined)
|
||||||
|
page.state.startConfirmFromRow(page.state.returnList.value[0])
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.jobOpen.value, false)
|
||||||
|
assert.equal(page.requests.some(item => item.url.includes('/scan/')), false)
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
const allowed = createPage()
|
||||||
|
await flush()
|
||||||
|
allowed.state.openReturnPanel(allowed.state.returnList.value[0])
|
||||||
|
await flush()
|
||||||
|
assert.ok(button(allowed, '开始到仓核对'))
|
||||||
|
allowed.close()
|
||||||
|
})
|
||||||
@@ -13,8 +13,13 @@ const template = compileTemplate({ source: descriptor.template.content, filename
|
|||||||
assert.deepEqual(template.errors, [])
|
assert.deepEqual(template.errors, [])
|
||||||
const apiSource = await read('api/business/usage.js')
|
const apiSource = await read('api/business/usage.js')
|
||||||
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
|
const scanUtils = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletScan.js'))}`)
|
||||||
|
const passport = await import(`data:text/javascript,${encodeURIComponent(await read('utils/palletPassport.js'))}`)
|
||||||
const bindImports = source => source.replace(/import\s+([\s\S]*?)\s+from\s+['"]([^'"]+)['"];?/g, (_, names, module) =>
|
const 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 ${names.startsWith('{') ? names.replace(/\s+as\s+/g, ': ') : `{ default: ${names} }`} = __modules[${JSON.stringify(module)}];`)
|
||||||
|
const masterDisplaySource = await read('utils/masterDisplay.js')
|
||||||
|
const masterDisplay = new Function('__modules', `${bindImports(masterDisplaySource).replace(/export (function|const) /g, '$1 ')}\nreturn { ${[...masterDisplaySource.matchAll(/export (?:function|const) (\w+)/g)].map(match => match[1]).join(',')} };`)({ '@/router': { default: { getRoutes: () => [] } }, '@/utils/palletPassport': passport })
|
||||||
|
// 看板默认不含隧道和产线,避免看板名称掩盖托盘摘要中的隧道断言
|
||||||
|
const siteBoard = { siteId: 21, siteCode: 'XC-21', siteName: '一号现场', customerName: '客户A', palletCount: 0, packageSum: 0, unusedPalletCount: 0, partialPalletCount: 0, emptyPalletCount: 0, todayUsageCount: 0, todayUsedPackages: 0, todayByLine: [], tunnelCount: 0, activeTunnelCount: 0, todayFifoSkipCount: 0, weekFifoSkipCount: 0, tunnels: [], lines: [] }
|
||||||
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
const flush = async () => { await new Promise(resolve => setImmediate(resolve)); await Vue.nextTick() }
|
||||||
function deferred() {
|
function deferred() {
|
||||||
let resolve, reject
|
let resolve, reject
|
||||||
@@ -46,6 +51,9 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
|
|||||||
if (config.url.endsWith('/line-options')) return Promise.resolve({ data: lines })
|
if (config.url.endsWith('/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('/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('/list')) return Promise.resolve({ rows: [], total: 0 })
|
||||||
|
if (config.url === '/business/usage/site-options') return Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }] })
|
||||||
|
if (config.url === '/business/usage/site-board') return Promise.resolve({ data: siteBoard })
|
||||||
|
if (config.url === '/business/usage-fifo/queue') return Promise.resolve({ data: { siteId: 21, strains: [] } })
|
||||||
if (config.url.endsWith('/enter-tunnel')) return Promise.resolve({ data: { palletId: 11, tunnelId: config.data.tunnelId, traceNo: 'INTERNAL-TRACE' } })
|
if (config.url.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' } })
|
if (config.url === '/business/usage') return Promise.resolve({ data: { usageId: 71, remainingPackages: 83, flowState: 'AT_SITE_PARTIAL' } })
|
||||||
throw new Error(`未处理请求 ${config.url}`)
|
throw new Error(`未处理请求 ${config.url}`)
|
||||||
@@ -63,7 +71,7 @@ function createPage({ scan = () => pallet(), request: override, storage = new Ma
|
|||||||
confirm(message) { confirmations.push(message); return confirm(message) }
|
confirm(message) { confirmations.push(message); return confirm(message) }
|
||||||
}, resetForm() {}, $refs: { usageRef: { validate: callback => callback(true) } } }
|
}, 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 vue = { ...Vue, getCurrentInstance: () => ({ proxy }), ...Object.fromEntries(Object.keys(hooks).map(name => [name, fn => hooks[name].push(fn)])), resolveComponent: name => ({ name }), resolveDirective: () => ({}) }
|
||||||
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
|
const modules = { vue, '@/components/PalletScanInput': { default: { name: 'PalletScanInput' } }, '@/components/ScanFeedback': { default: { name: 'ScanFeedback' } }, '@/components/SfIcon': { default: { name: 'SfIcon' } }, '@/utils/masterDisplay': masterDisplay, '@/utils/palletPassport': passport, '@/components/TestBatchAdoptionDialog': { default: { name: 'TestBatchAdoptionDialog' } }, '@/store/modules/user': { default: () => user }, '@/store/modules/testBatch': { default: () => batch }, '@/api/business/usage': api, '@/utils/palletScan': { ...scanUtils, createIdempotencyKey: () => scanUtils.createIdempotencyKey(webcrypto) } }
|
||||||
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
// PC 的 Vue API 由 Vite 自动导入;测试以相同名字提供真实响应式 API。
|
||||||
const globals = ['ref', 'reactive', 'computed', 'toRefs', 'watch', 'getCurrentInstance', 'onActivated', 'onDeactivated', 'onBeforeUnmount']
|
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 component = new Function('__modules', 'localStorage', ...globals, `${bindImports(script.content)}\nreturn __component;`)(modules, localStorage, ...globals.map(name => vue[name]))
|
||||||
@@ -95,7 +103,8 @@ async function retry(page) {
|
|||||||
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
|
test('扫码 API 编码路径,FIFO 使用独立 GET 与规范 qrToken', async () => {
|
||||||
const page = createPage()
|
const page = createPage()
|
||||||
await page.api.scanUsagePallet('托盘/A?#')
|
await page.api.scanUsagePallet('托盘/A?#')
|
||||||
assert.equal(page.requests.at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
|
// 页面启动后还会读取现场看板,按路径取最近一次扫码请求再核对编码
|
||||||
|
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/%E6%89%98%E7%9B%98%2FA%3F%23')
|
||||||
await page.scan('印刷托盘号')
|
await page.scan('印刷托盘号')
|
||||||
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
|
assert.deepEqual(page.requests.find(item => item.url.endsWith('/recommendation')), { url: '/business/usage-fifo/recommendation', method: 'get', params: { qrToken: 'QR-001' } })
|
||||||
page.close()
|
page.close()
|
||||||
@@ -753,3 +762,121 @@ test('F3 真正更换隧道可清除原目标必填,旧推荐不能恢复它',
|
|||||||
assert.equal(page.state.tunnelLocked.value, true)
|
assert.equal(page.state.tunnelLocked.value, true)
|
||||||
page.close()
|
page.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const twoSites = config => config.url === '/business/usage/site-options'
|
||||||
|
? Promise.resolve({ data: [{ id: 21, code: 'XC-21', name: '一号现场', customerName: '客户A', tunnelCount: 1, lineCount: 1 }, { id: 22, code: 'XC-22', name: '二号现场', customerName: '客户A', tunnelCount: 0, lineCount: 0 }] })
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
test('现场看板默认选第一个可访问现场,只有主动切换才记住,记住的现场用于首次查询', async () => {
|
||||||
|
const storage = new Map()
|
||||||
|
const page = createPage({ storage, request: twoSites })
|
||||||
|
await flush()
|
||||||
|
assert.deepEqual(page.requests.find(item => item.url === '/business/usage/site-board').params, { siteId: 21 })
|
||||||
|
assert.deepEqual(page.requests.find(item => item.url === '/business/usage-fifo/queue').params, { siteId: 21 })
|
||||||
|
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
|
||||||
|
assert.equal(storage.size, 0, '自动选择不写入本机偏好')
|
||||||
|
page.state.handleSiteChange(22); await flush()
|
||||||
|
assert.equal(storage.get('business:usage:site:v1:7'), '22')
|
||||||
|
assert.deepEqual(page.requests.filter(item => item.url === '/business/usage/site-board').at(-1).params, { siteId: 22 })
|
||||||
|
page.close()
|
||||||
|
|
||||||
|
const again = createPage({ storage, request: twoSites })
|
||||||
|
assert.equal(again.requests.find(item => item.url === '/business/usage/list').params.siteId, 22, '首次查询直接使用记住的现场')
|
||||||
|
await flush()
|
||||||
|
assert.equal(again.state.selectedSiteId.value, 22)
|
||||||
|
again.close()
|
||||||
|
const otherUser = createPage({ storage, userId: 8, request: twoSites })
|
||||||
|
await flush()
|
||||||
|
assert.equal(otherUser.state.selectedSiteId.value, 21, '偏好按账号隔离')
|
||||||
|
otherUser.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('偏好写入失败不影响切换,读取到已无权访问的现场时回到第一个现场', async () => {
|
||||||
|
const storage = new Map([['business:usage:site:v1:7', '99']])
|
||||||
|
const page = createPage({ storage, storageWriteError: true })
|
||||||
|
assert.equal(page.requests.find(item => item.url === '/business/usage/list').params.siteId, 99)
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.state.selectedSiteId.value, 21)
|
||||||
|
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.siteId, 21)
|
||||||
|
page.state.handleSiteChange(21); await flush()
|
||||||
|
assert.equal(storage.get('business:usage:site:v1:7'), '99')
|
||||||
|
assert.equal(page.state.storageError.value, '')
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('隧道卡片切换记录筛选,看板只展示隧道在用情况不展示溯源号', async () => {
|
||||||
|
const board = { ...siteBoard, tunnelCount: 1, activeTunnelCount: 1, tunnels: [{ tunnelId: 51, tunnelCode: 'KB-01', tunnelName: '看板隧道', inUse: true, palletCount: 2, remainingPackages: 150, startedAt: '2026-10-17T08:00:00', lastUsedAt: '2026-10-17T10:00:00', lastLineName: '看板产线' }] }
|
||||||
|
const page = createPage({ request: config => config.url === '/business/usage/site-board' ? Promise.resolve({ data: board }) : undefined })
|
||||||
|
await flush()
|
||||||
|
assert.match(text(page), /看板隧道/)
|
||||||
|
assert.doesNotMatch(text(page), /INTERNAL-TRACE|溯源号/)
|
||||||
|
assert.equal(page.state.tunnelFill(board.tunnels[0]), '75%')
|
||||||
|
page.state.toggleTunnelFilter(51); await flush()
|
||||||
|
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, 51)
|
||||||
|
page.state.toggleTunnelFilter(51); await flush()
|
||||||
|
assert.equal(page.requests.filter(item => item.url === '/business/usage/list').at(-1).params.tunnelId, undefined)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('无查询权限不读取看板,无列表权限不读取现场选项', async () => {
|
||||||
|
const page = createPage({ canOperate: false })
|
||||||
|
await flush()
|
||||||
|
assert.equal(page.requests.some(item => ['/business/usage/site-options', '/business/usage/site-board', '/business/usage-fifo/queue'].includes(item.url)), false)
|
||||||
|
page.state.openWork()
|
||||||
|
assert.equal(page.state.workOpen.value, false)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('作业步骤由扫码、入隧和登记结果推导,快捷包数与预览按当前余量计算', async () => {
|
||||||
|
const page = createPage()
|
||||||
|
await flush()
|
||||||
|
page.state.openWork()
|
||||||
|
assert.equal(page.state.workOpen.value, true)
|
||||||
|
assert.equal(page.state.workStep.value, 1)
|
||||||
|
await page.scan()
|
||||||
|
assert.equal(page.state.workStep.value, 2)
|
||||||
|
page.state.selectTunnel(31)
|
||||||
|
assert.equal(page.state.form.value.tunnelId, 31)
|
||||||
|
assert.equal(page.state.selectedTunnelName.value, '一号隧道')
|
||||||
|
await page.act('enterTunnel')
|
||||||
|
assert.equal(page.state.workStep.value, 3)
|
||||||
|
page.state.selectTunnel(32)
|
||||||
|
assert.equal(page.state.form.value.tunnelId, 31, '入隧后不能再切换隧道')
|
||||||
|
assert.deepEqual(page.state.quickAmounts.value.map(item => item.label), ['10', '20', '50', '全部 100'])
|
||||||
|
page.state.form.value.usedPackages = 30
|
||||||
|
assert.deepEqual({ ...page.state.usagePreview.value }, { text: '提交后余 70 包', bad: false })
|
||||||
|
page.state.form.value.usedPackages = 120
|
||||||
|
assert.equal(page.state.usagePreview.value.bad, true)
|
||||||
|
page.state.form.value.usedPackages = 17
|
||||||
|
const boards = page.requests.filter(item => item.url === '/business/usage/site-board').length
|
||||||
|
await page.act('submitUsage')
|
||||||
|
assert.equal(page.state.workStep.value, 4)
|
||||||
|
assert.equal(page.requests.filter(item => item.url === '/business/usage/site-board').length, boards + 1, '登记成功后刷新看板')
|
||||||
|
page.state.continueSamePallet(); await flush()
|
||||||
|
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, '/business/usage/scan/QR-001')
|
||||||
|
page.state.closeWork(); await flush()
|
||||||
|
assert.equal(page.state.workOpen.value, false)
|
||||||
|
assert.equal(page.state.scannedPallet.value, null)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|
||||||
|
test('先到先用:可一键改用建议托盘,快捷原因写入同一原因字段', async () => {
|
||||||
|
const page = createPage({ request: config => config.url.endsWith('/recommendation')
|
||||||
|
? Promise.resolve({ data: { recommended: { palletId: 9, palletCode: '较早托盘', batchNo: '较早批次', remainingPackages: 25, siteArrivedAt: '2026-10-01 09:00:00' }, unknownArrivalCount: 0, requiresSkipReason: true, message: '请填写跳过原因' } })
|
||||||
|
: undefined })
|
||||||
|
await page.scan()
|
||||||
|
assert.equal(page.state.recommendedOther.value, true)
|
||||||
|
assert.ok(button(page, '改用 较早托盘'))
|
||||||
|
page.state.pickFifoReason('建议托盘取不出')
|
||||||
|
assert.equal(page.state.form.value.fifoSkipReason, '建议托盘取不出')
|
||||||
|
assert.equal(page.state.isReasonActive('建议托盘取不出'), true)
|
||||||
|
page.state.pickFifoReason('其他')
|
||||||
|
assert.equal(page.state.form.value.fifoSkipReason, '')
|
||||||
|
page.state.form.value.fifoSkipReason = '叉车暂时取不出'
|
||||||
|
assert.equal(page.state.isReasonActive('其他'), true)
|
||||||
|
page.state.pickFifoReason('其他')
|
||||||
|
assert.equal(page.state.form.value.fifoSkipReason, '叉车暂时取不出', '自填原因不被「其他」清空')
|
||||||
|
page.state.useRecommendedPallet(); await flush()
|
||||||
|
assert.equal(page.requests.filter(item => item.url.startsWith('/business/usage/scan/')).at(-1).url, `/business/usage/scan/${encodeURIComponent('较早托盘')}`)
|
||||||
|
page.close()
|
||||||
|
})
|
||||||
|
|||||||
@@ -91,13 +91,15 @@ const expectedRequests = {
|
|||||||
{ url: "'/master/site'", method: 'post' },
|
{ url: "'/master/site'", method: 'post' },
|
||||||
{ url: "'/master/site'", method: 'put' },
|
{ url: "'/master/site'", method: 'put' },
|
||||||
{ url: "'/master/site/' + siteId + '/users'", method: 'get' },
|
{ url: "'/master/site/' + siteId + '/users'", method: 'get' },
|
||||||
{ url: "'/master/site/' + siteId + '/users'", method: 'put' }
|
{ url: "'/master/site/' + siteId + '/users'", method: 'put' },
|
||||||
|
{ url: "'/master/site/customer-options'", method: 'get' }
|
||||||
],
|
],
|
||||||
'src/api/master/line.js': [
|
'src/api/master/line.js': [
|
||||||
{ url: "'/master/line/list'", method: 'get' },
|
{ url: "'/master/line/list'", method: 'get' },
|
||||||
{ url: "'/master/line/' + lineId", method: 'get' },
|
{ url: "'/master/line/' + lineId", method: 'get' },
|
||||||
{ url: "'/master/line'", method: 'post' },
|
{ url: "'/master/line'", method: 'post' },
|
||||||
{ url: "'/master/line'", method: 'put' },
|
{ url: "'/master/line'", method: 'put' },
|
||||||
|
{ url: "'/master/line/site-options'", method: 'get' }
|
||||||
],
|
],
|
||||||
'src/api/master/warehouse.js': [
|
'src/api/master/warehouse.js': [
|
||||||
{ url: "'/master/warehouse/list'", method: 'get' },
|
{ url: "'/master/warehouse/list'", method: 'get' },
|
||||||
@@ -119,9 +121,11 @@ const expectedRequests = {
|
|||||||
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
|
{ url: "'/master/tunnel/' + tunnelId", method: 'get' },
|
||||||
{ url: "'/master/tunnel'", method: 'post' },
|
{ url: "'/master/tunnel'", method: 'post' },
|
||||||
{ url: "'/master/tunnel'", method: 'put' },
|
{ url: "'/master/tunnel'", method: 'put' },
|
||||||
|
{ url: "'/master/tunnel/site-options'", method: 'get' }
|
||||||
],
|
],
|
||||||
'src/api/business/inbound.js': [
|
'src/api/business/inbound.js': [
|
||||||
{ url: "'/business/inbound/list'", method: 'get' },
|
{ url: "'/business/inbound/list'", method: 'get' },
|
||||||
|
{ url: "'/business/inbound/summary'", method: 'get' },
|
||||||
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
{ url: "'/business/inbound/warehouse-options'", method: 'get' },
|
||||||
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
{ url: "'/business/inbound/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||||
{ url: "'/business/inbound/' + inboundId", method: 'get' },
|
{ url: "'/business/inbound/' + inboundId", method: 'get' },
|
||||||
@@ -131,6 +135,9 @@ const expectedRequests = {
|
|||||||
],
|
],
|
||||||
'src/api/business/filling.js': [
|
'src/api/business/filling.js': [
|
||||||
{ url: "'/business/filling/list'", method: 'get' },
|
{ url: "'/business/filling/list'", method: 'get' },
|
||||||
|
{ url: "'/business/filling/summary'", method: 'get' },
|
||||||
|
{ url: "'/business/filling/batches'", method: 'get' },
|
||||||
|
{ url: "'/business/filling/batches/detail'", method: 'get' },
|
||||||
{ url: "'/business/filling/strain-options'", method: 'get' },
|
{ url: "'/business/filling/strain-options'", method: 'get' },
|
||||||
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
{ url: "'/business/filling/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||||
{ url: "'/business/filling/' + fillingId", method: 'get' },
|
{ url: "'/business/filling/' + fillingId", method: 'get' },
|
||||||
@@ -151,6 +158,9 @@ const expectedRequests = {
|
|||||||
{ url: "'/business/usage/list'", method: 'get' },
|
{ url: "'/business/usage/list'", method: 'get' },
|
||||||
{ url: "'/business/usage/line-options'", method: 'get' },
|
{ url: "'/business/usage/line-options'", method: 'get' },
|
||||||
{ url: "'/business/usage/tunnel-options'", method: 'get' },
|
{ url: "'/business/usage/tunnel-options'", method: 'get' },
|
||||||
|
{ url: "'/business/usage/site-options'", method: 'get' },
|
||||||
|
{ url: "'/business/usage/site-board'", method: 'get' },
|
||||||
|
{ url: "'/business/usage-fifo/queue'", method: 'get' },
|
||||||
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
|
{ url: "'/business/usage/enter-tunnel'", method: 'post' },
|
||||||
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
{ url: "'/business/usage/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||||
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
|
{ url: "'/business/usage-fifo/recommendation'", method: 'get' },
|
||||||
@@ -159,6 +169,8 @@ const expectedRequests = {
|
|||||||
],
|
],
|
||||||
'src/api/business/return.js': [
|
'src/api/business/return.js': [
|
||||||
{ url: "'/business/return/list'", method: 'get' },
|
{ url: "'/business/return/list'", method: 'get' },
|
||||||
|
{ url: "'/business/return/summary'", method: 'get' },
|
||||||
|
{ url: "'/business/return/site-options'", method: 'get' },
|
||||||
{ url: "'/business/return/warehouse-options'", method: 'get' },
|
{ url: "'/business/return/warehouse-options'", method: 'get' },
|
||||||
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
{ url: "'/business/return/scan/' + encodeURIComponent(qrToken)", method: 'get' },
|
||||||
{ url: "'/business/return/request'", method: 'post' },
|
{ url: "'/business/return/request'", method: 'post' },
|
||||||
@@ -211,14 +223,14 @@ const masterPageContracts = {
|
|||||||
},
|
},
|
||||||
site: {
|
site: {
|
||||||
relativePath: 'src/views/master/site/index.vue',
|
relativePath: 'src/views/master/site/index.vue',
|
||||||
apiNames: ['addSite', 'getSite', 'listSite', 'updateSite'],
|
apiNames: ['addSite', 'getSite', 'getSiteCustomerOptions', 'listSite', 'updateSite'],
|
||||||
permissions: ['add', 'assign', 'edit', 'list', 'query'],
|
permissions: ['add', 'assign', 'edit', 'list', 'query'],
|
||||||
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
|
addPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteName', 'status'],
|
||||||
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
|
editPayloadKeys: ['address', 'customerId', 'remark', 'siteCode', 'siteId', 'siteName', 'status']
|
||||||
},
|
},
|
||||||
line: {
|
line: {
|
||||||
relativePath: 'src/views/master/line/index.vue',
|
relativePath: 'src/views/master/line/index.vue',
|
||||||
apiNames: ['addLine', 'getLine', 'listLine', 'updateLine'],
|
apiNames: ['addLine', 'getLine', 'getLineSiteOptions', 'listLine', 'updateLine'],
|
||||||
permissions: ['add', 'edit', 'list', 'query'],
|
permissions: ['add', 'edit', 'list', 'query'],
|
||||||
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
|
addPayloadKeys: ['lineCode', 'lineName', 'remark', 'siteId', 'status'],
|
||||||
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
|
editPayloadKeys: ['lineCode', 'lineId', 'lineName', 'remark', 'siteId', 'status']
|
||||||
@@ -246,6 +258,7 @@ const businessPageContracts = {
|
|||||||
'addInbound',
|
'addInbound',
|
||||||
'confirmInbound',
|
'confirmInbound',
|
||||||
'getInbound',
|
'getInbound',
|
||||||
|
'getInboundSummary',
|
||||||
'getInboundWarehouseOptions',
|
'getInboundWarehouseOptions',
|
||||||
'listInbound',
|
'listInbound',
|
||||||
'scanInboundPallet',
|
'scanInboundPallet',
|
||||||
@@ -260,7 +273,15 @@ const businessPageContracts = {
|
|||||||
},
|
},
|
||||||
filling: {
|
filling: {
|
||||||
relativePath: 'src/views/business/filling/index.vue',
|
relativePath: 'src/views/business/filling/index.vue',
|
||||||
apiNames: ['addFilling', 'getFillingStrainOptions', 'listFilling', 'scanFillingPallet'],
|
apiNames: [
|
||||||
|
'addFilling',
|
||||||
|
'getFillingBatchDetail',
|
||||||
|
'getFillingStrainOptions',
|
||||||
|
'getFillingSummary',
|
||||||
|
'listFilling',
|
||||||
|
'listFillingBatches',
|
||||||
|
'scanFillingPallet'
|
||||||
|
],
|
||||||
permissions: ['add', 'list', 'query'],
|
permissions: ['add', 'list', 'query'],
|
||||||
payloads: {
|
payloads: {
|
||||||
addFilling: [
|
addFilling: [
|
||||||
@@ -298,7 +319,18 @@ const businessPageContracts = {
|
|||||||
},
|
},
|
||||||
usage: {
|
usage: {
|
||||||
relativePath: 'src/views/business/usage/index.vue',
|
relativePath: 'src/views/business/usage/index.vue',
|
||||||
apiNames: ['addUsage', 'enterUsageTunnel', 'getUsageFifoRecommendation', 'getUsageLineOptions', 'getUsageTunnelOptions', 'listUsage', 'scanUsagePallet'],
|
apiNames: [
|
||||||
|
'addUsage',
|
||||||
|
'enterUsageTunnel',
|
||||||
|
'getUsageFifoQueue',
|
||||||
|
'getUsageFifoRecommendation',
|
||||||
|
'getUsageLineOptions',
|
||||||
|
'getUsageSiteBoard',
|
||||||
|
'getUsageSiteOptions',
|
||||||
|
'getUsageTunnelOptions',
|
||||||
|
'listUsage',
|
||||||
|
'scanUsagePallet'
|
||||||
|
],
|
||||||
permissions: ['add', 'list', 'query'],
|
permissions: ['add', 'list', 'query'],
|
||||||
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
|
// 使用请求需原样持久化以安全重试,载荷边界由 usage-behavior 的真实请求断言验证。
|
||||||
payloads: {}
|
payloads: {}
|
||||||
@@ -308,6 +340,8 @@ const businessPageContracts = {
|
|||||||
apiNames: [
|
apiNames: [
|
||||||
'confirmReturn',
|
'confirmReturn',
|
||||||
'getReturn',
|
'getReturn',
|
||||||
|
'getReturnSiteOptions',
|
||||||
|
'getReturnSummary',
|
||||||
'getReturnWarehouseOptions',
|
'getReturnWarehouseOptions',
|
||||||
'listReturn',
|
'listReturn',
|
||||||
'requestReturn',
|
'requestReturn',
|
||||||
@@ -1538,7 +1572,7 @@ test('首次入库确认失败后取消重试不丢弃原幂等键', async () =>
|
|||||||
|
|
||||||
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
test('18 个 API 文件逐一匹配硬编码请求契约', async (t) => {
|
||||||
assert.equal(Object.keys(expectedRequests).length, 18)
|
assert.equal(Object.keys(expectedRequests).length, 18)
|
||||||
assert.equal(Object.values(expectedRequests).flat().length, 74)
|
assert.equal(Object.values(expectedRequests).flat().length, 86)
|
||||||
|
|
||||||
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
|
for (const [relativePath, expected] of Object.entries(expectedRequests)) {
|
||||||
await t.test(relativePath, async () => {
|
await t.test(relativePath, async () => {
|
||||||
@@ -2923,3 +2957,20 @@ test('三个业务菜单公共样式统一使用 Element Plus 主题变量', asy
|
|||||||
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
|
assert.doesNotMatch(styles, /background:\s*#f7f9fc\s*!important/)
|
||||||
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
|
assert.doesNotMatch(styles, /background:\s*#fbfcfe\s*;/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('业务页面导入的组件不被同名响应式状态遮蔽', async (t) => {
|
||||||
|
// 非 .vue 路径导入的组件按 maybe-ref 绑定,模板标签会优先解析为同名 camelCase 状态(如 scanFeedback 遮蔽 ScanFeedback)
|
||||||
|
const { compileScript, parse } = await import('@vue/compiler-sfc')
|
||||||
|
for (const [resource, contract] of Object.entries(businessPageContracts)) {
|
||||||
|
await t.test(resource, async () => {
|
||||||
|
const source = await readFile(path.join(frontendRoot, contract.relativePath), 'utf8')
|
||||||
|
const { descriptor } = parse(source)
|
||||||
|
const compiled = compileScript(descriptor, { id: `contract-${resource}`, inlineTemplate: true }).content
|
||||||
|
const components = [...source.matchAll(/^import\s+([A-Z]\w*)\s+from\s+['"]@\/components\/[^'"]+['"]/gm)].map((match) => match[1])
|
||||||
|
for (const component of components) {
|
||||||
|
const shadow = component[0].toLowerCase() + component.slice(1)
|
||||||
|
assert.doesNotMatch(compiled, new RegExp(`_create(?:VNode|Block)\\(_unref\\(${shadow}\\)`), `${component} 被同名状态 ${shadow} 遮蔽`)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|||||||
@@ -8,27 +8,27 @@ const scriptDir = path.dirname(fileURLToPath(import.meta.url))
|
|||||||
const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue')
|
const dashboardPath = path.resolve(scriptDir, '../src/views/dashboard/index.vue')
|
||||||
const dashboardSource = fs.readFileSync(dashboardPath, 'utf8')
|
const dashboardSource = fs.readFileSync(dashboardPath, 'utf8')
|
||||||
|
|
||||||
test('首页包含仓储运营总览的核心模块', () => {
|
test('首页工作台包含扫码入口、流转看板与核心运营模块', () => {
|
||||||
for (const label of [
|
for (const label of [
|
||||||
'仓储运营总览',
|
'扫描托盘码',
|
||||||
'当前库存托盘',
|
'快捷作业',
|
||||||
'剩余包数',
|
'新建作业',
|
||||||
'今日入库单',
|
'在库托盘',
|
||||||
'今日出库单',
|
'客户现场在用',
|
||||||
'待确认单据',
|
'今日发货',
|
||||||
'新增入库',
|
'待处理',
|
||||||
'托盘填充',
|
'托盘流转',
|
||||||
|
'装填与发货',
|
||||||
|
'临期预警',
|
||||||
|
'仓库库存',
|
||||||
|
'最近动态',
|
||||||
|
'仓库入库',
|
||||||
|
'工厂装填',
|
||||||
'发货出库',
|
'发货出库',
|
||||||
'托盘使用',
|
'现场使用',
|
||||||
'托盘退回',
|
'退回仓库',
|
||||||
'托盘追踪',
|
'托盘追溯',
|
||||||
'近 7 天入库 / 出库趋势',
|
'扫码异常'
|
||||||
'托盘流转状态分布',
|
|
||||||
'仓库库存排行',
|
|
||||||
'最近入库单',
|
|
||||||
'最近出库单',
|
|
||||||
'扫码异常',
|
|
||||||
'待确认业务'
|
|
||||||
]) {
|
]) {
|
||||||
assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
assert.match(dashboardSource, new RegExp(label.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,32 @@ export function listFilling(query) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询工厂装填概况
|
||||||
|
export function getFillingSummary() {
|
||||||
|
return request({
|
||||||
|
url: '/business/filling/summary',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询按生产批次汇总的装填列表
|
||||||
|
export function listFillingBatches(query) {
|
||||||
|
return request({
|
||||||
|
url: '/business/filling/batches',
|
||||||
|
method: 'get',
|
||||||
|
params: query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询生产批次详情
|
||||||
|
export function getFillingBatchDetail(query) {
|
||||||
|
return request({
|
||||||
|
url: '/business/filling/batches/detail',
|
||||||
|
method: 'get',
|
||||||
|
params: query
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 查询装填菌种选项
|
// 查询装填菌种选项
|
||||||
export function getFillingStrainOptions() {
|
export function getFillingStrainOptions() {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export function listInbound(query) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询首次入库概况
|
||||||
|
export function getInboundSummary() {
|
||||||
|
return request({
|
||||||
|
url: '/business/inbound/summary',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 查询首次入库仓库选项
|
// 查询首次入库仓库选项
|
||||||
export function getInboundWarehouseOptions() {
|
export function getInboundWarehouseOptions() {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ export function listReturn(query) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询托盘退回概况
|
||||||
|
export function getReturnSummary() {
|
||||||
|
return request({
|
||||||
|
url: '/business/return/summary',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询退回来源现场选项
|
||||||
|
export function getReturnSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: '/business/return/site-options',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 查询退回目标仓库选项
|
// 查询退回目标仓库选项
|
||||||
export function getReturnWarehouseOptions() {
|
export function getReturnWarehouseOptions() {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -27,6 +27,32 @@ export function getUsageTunnelOptions(siteId) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询当前用户可访问的启用现场
|
||||||
|
export function getUsageSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: '/business/usage/site-options',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询现场看板(库存、今日用量、隧道)
|
||||||
|
export function getUsageSiteBoard(siteId) {
|
||||||
|
return request({
|
||||||
|
url: '/business/usage/site-board',
|
||||||
|
method: 'get',
|
||||||
|
params: { siteId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询现场待使用托盘队列,与先到先用建议同序
|
||||||
|
export function getUsageFifoQueue(siteId) {
|
||||||
|
return request({
|
||||||
|
url: '/business/usage-fifo/queue',
|
||||||
|
method: 'get',
|
||||||
|
params: { siteId }
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// 扫码入隧
|
// 扫码入隧
|
||||||
export function enterUsageTunnel(data) {
|
export function enterUsageTunnel(data) {
|
||||||
return request({
|
return request({
|
||||||
|
|||||||
@@ -34,3 +34,11 @@ export function updateLine(data) {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询新增或修改产线时可选的现场
|
||||||
|
export function getLineSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: '/master/line/site-options',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,3 +51,11 @@ export function assignSiteUsers(siteId, data) {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询新增或修改现场时可选的客户
|
||||||
|
export function getSiteCustomerOptions() {
|
||||||
|
return request({
|
||||||
|
url: '/master/site/customer-options',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -34,3 +34,11 @@ export function updateTunnel(data) {
|
|||||||
data: data
|
data: data
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 查询新增或修改隧道时可选的现场
|
||||||
|
export function getTunnelSiteOptions() {
|
||||||
|
return request({
|
||||||
|
url: '/master/tunnel/site-options',
|
||||||
|
method: 'get'
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,6 +6,8 @@
|
|||||||
@use './shierfeng.scss';
|
@use './shierfeng.scss';
|
||||||
|
|
||||||
@use './data-pages.scss';
|
@use './data-pages.scss';
|
||||||
|
@use './sf-shell.scss';
|
||||||
|
@use './sf-master.scss';
|
||||||
|
|
||||||
body {
|
body {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,8 @@
|
|||||||
@use './variables.module.scss' as vars;
|
@use './variables.module.scss' as vars;
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// 侧边导航:位于底色之上,内容区为右侧内嵌面板
|
||||||
|
// ==========================================
|
||||||
#app {
|
#app {
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
@@ -14,20 +17,50 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
|
--sf-menu-text: var(--sf-text-2, #3d4350);
|
||||||
|
--sf-menu-icon: var(--sf-text-4, #6b717d);
|
||||||
|
--sf-menu-group: var(--sf-text-4, #6b717d);
|
||||||
|
--sf-menu-hover: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||||
|
--sf-menu-active-bg: var(--sf-surface, #fff);
|
||||||
|
--sf-menu-active-text: var(--sf-text-1, #0f1115);
|
||||||
|
--sf-menu-active-icon: var(--sf-accent, #007787);
|
||||||
|
--sf-menu-active-ring: var(--sf-ring, 0 0 0 1px #e7e9ec);
|
||||||
|
--el-menu-bg-color: transparent;
|
||||||
|
--el-menu-hover-bg-color: var(--sf-menu-hover);
|
||||||
|
--el-menu-text-color: var(--sf-menu-text);
|
||||||
|
--el-menu-active-color: var(--sf-menu-active-text);
|
||||||
|
--el-menu-item-height: 36px;
|
||||||
|
--el-menu-sub-item-height: 36px;
|
||||||
|
--el-menu-base-level-padding: 12px;
|
||||||
|
--el-menu-level-padding: 14px;
|
||||||
|
--el-menu-item-font-size: 13.5px;
|
||||||
|
|
||||||
transition: width 0.28s;
|
transition: width 0.28s;
|
||||||
width: vars.$base-sidebar-width !important;
|
width: vars.$base-sidebar-width !important;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
font-size: 0px;
|
font-size: 0;
|
||||||
top: 0;
|
top: 0;
|
||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 1001;
|
z-index: 1001;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-right: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
background: var(--sf-ground, #f5f6f7);
|
||||||
box-shadow: 2px 0 10px 0 rgba(15, 34, 58, 0.05);
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
&.theme-dark {
|
||||||
|
--sf-menu-text: #c9cdd4;
|
||||||
|
--sf-menu-icon: #8d939d;
|
||||||
|
--sf-menu-group: #8d939d;
|
||||||
|
--sf-menu-hover: rgba(255, 255, 255, 0.06);
|
||||||
|
--sf-menu-active-bg: rgba(255, 255, 255, 0.1);
|
||||||
|
--sf-menu-active-text: #ffffff;
|
||||||
|
--sf-menu-active-icon: var(--el-color-primary-light-3, #4daab6);
|
||||||
|
--sf-menu-active-ring: none;
|
||||||
|
background: #15171c;
|
||||||
|
}
|
||||||
|
|
||||||
// reset element-ui css
|
|
||||||
.horizontal-collapse-transition {
|
.horizontal-collapse-transition {
|
||||||
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
|
transition: 0s width ease-in-out, 0s padding-left ease-in-out, 0s padding-right ease-in-out;
|
||||||
}
|
}
|
||||||
@@ -37,175 +70,168 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.el-scrollbar__bar.is-vertical {
|
.el-scrollbar__bar.is-vertical {
|
||||||
right: 0px;
|
right: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-scrollbar {
|
.el-scrollbar {
|
||||||
height: 100%;
|
flex: 1;
|
||||||
}
|
min-height: 0;
|
||||||
|
|
||||||
&.has-logo {
|
|
||||||
.el-scrollbar {
|
|
||||||
height: calc(100% - 50px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.is-horizontal {
|
.is-horizontal {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
.el-menu a {
|
||||||
display: inline-block;
|
display: block;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
margin-right: 10px !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-menu {
|
.el-menu {
|
||||||
border: none;
|
border: none;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100% !important;
|
width: 100% !important;
|
||||||
|
padding: 4px 0 16px;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
&:not(.el-menu--collapse) {
|
// 菜单项:圆角胶囊
|
||||||
.el-menu-item,
|
.el-menu-item,
|
||||||
.el-sub-menu__title {
|
.el-sub-menu__title {
|
||||||
height: 46px;
|
height: 36px;
|
||||||
line-height: 46px;
|
line-height: 36px;
|
||||||
margin: 0;
|
margin: 1px 10px;
|
||||||
border-radius: 0;
|
padding-right: 10px;
|
||||||
transition: background-color 0.2s ease, color 0.2s ease;
|
border-radius: 8px;
|
||||||
|
color: var(--sf-menu-text);
|
||||||
|
font-size: 13.5px;
|
||||||
|
transition: background-color 0.15s ease, color 0.15s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--sf-menu-hover) !important;
|
||||||
|
color: var(--sf-menu-active-text);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-menu-icon,
|
||||||
|
.svg-icon {
|
||||||
|
flex: none;
|
||||||
|
width: 16px !important;
|
||||||
|
height: 16px !important;
|
||||||
|
margin-right: 10px !important;
|
||||||
|
color: var(--sf-menu-icon);
|
||||||
|
font-size: 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item.is-active {
|
.el-menu-item.is-active {
|
||||||
font-weight: 600;
|
background-color: var(--sf-menu-active-bg) !important;
|
||||||
background-color: rgba(0, 133, 150, 0.12) !important;
|
box-shadow: var(--sf-menu-active-ring);
|
||||||
color: var(--current-color, #008596) !important;
|
color: var(--sf-menu-active-text) !important;
|
||||||
border-radius: 0;
|
font-weight: 500;
|
||||||
}
|
|
||||||
|
.sf-menu-icon,
|
||||||
|
.svg-icon {
|
||||||
|
color: var(--sf-menu-active-icon);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item, .menu-title {
|
.menu-title,
|
||||||
|
.el-menu-item {
|
||||||
overflow: hidden !important;
|
overflow: hidden !important;
|
||||||
text-overflow: ellipsis !important;
|
text-overflow: ellipsis !important;
|
||||||
white-space: nowrap !important;
|
white-space: nowrap !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu-item .el-menu-tooltip__trigger {
|
.el-menu-item .el-menu-tooltip__trigger {
|
||||||
display: inline-block !important;
|
display: inline-flex !important;
|
||||||
}
|
align-items: center;
|
||||||
|
|
||||||
.el-sub-menu {
|
|
||||||
.el-sub-menu__icon-arrow {
|
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
|
||||||
right: var(--el-menu-base-level-padding, 20px);
|
|
||||||
margin-top: -6px;
|
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// menu hover
|
|
||||||
.sub-menu-title-noDropdown,
|
|
||||||
.el-sub-menu__title {
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
& .theme-dark .is-active > .el-sub-menu__title {
|
|
||||||
color: vars.$base-menu-color-active !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .theme-dark .el-menu:not(.el-menu--collapse) .el-menu-item.is-active {
|
|
||||||
background-color: rgba(0, 133, 150, 0.26) !important;
|
|
||||||
color: #38bdf8 !important;
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
& .nest-menu .el-sub-menu>.el-sub-menu__title,
|
|
||||||
& .el-sub-menu .el-menu-item {
|
|
||||||
min-width: vars.$base-sidebar-width !important;
|
|
||||||
width: 100% !important;
|
|
||||||
margin: 0 !important;
|
|
||||||
border-radius: 0 !important;
|
|
||||||
|
|
||||||
&:hover {
|
|
||||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-sub-menu__icon-arrow {
|
.el-sub-menu__icon-arrow {
|
||||||
position: absolute;
|
right: 10px;
|
||||||
top: 50%;
|
width: 12px !important;
|
||||||
right: var(--el-menu-base-level-padding, 20px);
|
font-size: 12px;
|
||||||
margin-top: -6px;
|
color: var(--sf-menu-group);
|
||||||
width: auto !important;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
& .theme-dark .nest-menu .el-sub-menu>.el-sub-menu__title,
|
// 一级目录展开时作为分组标题:小字号、无图标
|
||||||
& .theme-dark .el-sub-menu .el-menu-item {
|
.el-menu:not(.el-menu--collapse) {
|
||||||
background-color: vars.$base-sub-menu-background;
|
& > div > .el-sub-menu {
|
||||||
|
margin-top: 10px;
|
||||||
|
|
||||||
|
& > .el-sub-menu__title {
|
||||||
|
height: 28px;
|
||||||
|
line-height: 28px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--sf-menu-group);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
|
||||||
|
.sf-menu-icon,
|
||||||
|
.svg-icon {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background-color: vars.$base-sub-menu-hover !important;
|
background-color: transparent !important;
|
||||||
|
color: var(--sf-menu-active-text);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.is-active {
|
& > .el-menu {
|
||||||
background-color: rgba(0, 133, 150, 0.28) !important;
|
padding: 2px 0 0;
|
||||||
color: #38bdf8 !important;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 分组内一级子项与顶层菜单项左对齐
|
||||||
|
& > .el-menu > div > a > .el-menu-item,
|
||||||
|
& > .el-menu > div > .el-sub-menu > .el-sub-menu__title {
|
||||||
|
padding-left: 12px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.nest-menu .el-sub-menu .el-menu-item {
|
||||||
|
padding-left: 38px !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-sub-menu.is-active > .el-sub-menu__title {
|
||||||
|
color: var(--sf-menu-active-text);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.hideSidebar {
|
.hideSidebar {
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
width: 54px !important;
|
width: vars.$collapsed-sidebar-width !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container {
|
.main-container {
|
||||||
margin-left: 54px;
|
margin-left: vars.$collapsed-sidebar-width;
|
||||||
}
|
|
||||||
|
|
||||||
.sub-menu-title-noDropdown {
|
|
||||||
padding: 0 !important;
|
|
||||||
position: relative;
|
|
||||||
|
|
||||||
.el-tooltip {
|
|
||||||
padding: 0 !important;
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-sub-menu {
|
|
||||||
overflow: hidden;
|
|
||||||
|
|
||||||
&>.el-sub-menu__title {
|
|
||||||
padding: 0 !important;
|
|
||||||
|
|
||||||
.svg-icon {
|
|
||||||
margin-left: 20px;
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.el-menu--collapse {
|
.el-menu--collapse {
|
||||||
|
width: 100% !important;
|
||||||
|
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu__title {
|
||||||
|
justify-content: center;
|
||||||
|
margin: 1px 10px;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-tooltip__trigger {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-menu-icon,
|
||||||
|
.svg-icon {
|
||||||
|
margin-right: 0 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.el-sub-menu {
|
.el-sub-menu {
|
||||||
& > .el-sub-menu__title {
|
& > .el-sub-menu__title {
|
||||||
&>span {
|
& > span,
|
||||||
height: 0;
|
|
||||||
width: 0;
|
|
||||||
overflow: hidden;
|
|
||||||
visibility: hidden;
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
& > i {
|
& > i {
|
||||||
height: 0;
|
height: 0;
|
||||||
width: 0;
|
width: 0;
|
||||||
@@ -222,15 +248,16 @@
|
|||||||
min-width: vars.$base-sidebar-width !important;
|
min-width: vars.$base-sidebar-width !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// mobile responsive
|
// 移动端:抽屉式导航
|
||||||
.mobile {
|
.mobile {
|
||||||
.main-container {
|
.main-container {
|
||||||
margin-left: 0px;
|
margin-left: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-container {
|
.sidebar-container {
|
||||||
transition: transform .28s;
|
transition: transform .28s;
|
||||||
width: vars.$base-sidebar-width !important;
|
width: vars.$base-sidebar-width !important;
|
||||||
|
box-shadow: 8px 0 32px rgba(16, 24, 40, 0.12);
|
||||||
}
|
}
|
||||||
|
|
||||||
&.hideSidebar {
|
&.hideSidebar {
|
||||||
@@ -251,47 +278,60 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// when menu collapsed
|
// 折叠态弹出的子菜单
|
||||||
.el-menu--vertical {
|
.el-menu--vertical {
|
||||||
.el-sub-menu {
|
--el-menu-item-height: 36px;
|
||||||
.el-sub-menu__icon-arrow {
|
--el-menu-sub-item-height: 36px;
|
||||||
position: absolute;
|
|
||||||
top: 50%;
|
.el-menu--popup {
|
||||||
right: var(--el-menu-base-level-padding, 20px);
|
padding: 6px;
|
||||||
margin-top: -6px;
|
border-radius: 10px;
|
||||||
width: auto !important;
|
min-width: 200px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.el-menu-item,
|
||||||
|
.el-sub-menu__title {
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 13.5px;
|
||||||
|
|
||||||
|
.sf-menu-icon,
|
||||||
|
.svg-icon {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
margin-right: 10px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&>.el-menu {
|
.el-menu-item.is-active {
|
||||||
.svg-icon {
|
color: var(--sf-accent, #007787);
|
||||||
margin-right: 16px;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.el-sub-menu .el-sub-menu__icon-arrow {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
right: 10px;
|
||||||
|
margin-top: -6px;
|
||||||
|
width: auto !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nest-menu .el-sub-menu > .el-sub-menu__title,
|
.nest-menu .el-sub-menu > .el-sub-menu__title,
|
||||||
.el-menu-item {
|
.el-menu-item {
|
||||||
&:hover {
|
&:hover {
|
||||||
// you can use $sub-menuHover
|
background-color: var(--sf-hover, rgba(0, 0, 0, 0.05)) !important;
|
||||||
background-color: rgba(0, 0, 0, 0.06) !important;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// the scroll bar appears when the sub-menu is too long
|
|
||||||
> .el-menu--popup {
|
> .el-menu--popup {
|
||||||
max-height: 100vh;
|
max-height: 100vh;
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
|
|
||||||
&::-webkit-scrollbar-track-piece {
|
|
||||||
background: #d3dce6;
|
|
||||||
}
|
|
||||||
|
|
||||||
&::-webkit-scrollbar {
|
&::-webkit-scrollbar {
|
||||||
width: 6px;
|
width: 6px;
|
||||||
}
|
}
|
||||||
|
|
||||||
&::-webkit-scrollbar-thumb {
|
&::-webkit-scrollbar-thumb {
|
||||||
background: #99a9bf;
|
background: var(--sf-border-strong, #c0c4cc);
|
||||||
border-radius: 20px;
|
border-radius: 20px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,8 +21,9 @@ $menuLightText: #303133;
|
|||||||
$menuLightActiveText: #409EFF;
|
$menuLightActiveText: #409EFF;
|
||||||
|
|
||||||
// 基础变量
|
// 基础变量
|
||||||
$base-sidebar-width: 200px;
|
$base-sidebar-width: 232px;
|
||||||
$sideBarWidth: 200px;
|
$collapsed-sidebar-width: 64px;
|
||||||
|
$sideBarWidth: 232px;
|
||||||
|
|
||||||
// 菜单暗色变量
|
// 菜单暗色变量
|
||||||
$base-menu-color: #bfcbd9;
|
$base-menu-color: #bfcbd9;
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ function getBreadcrumb() {
|
|||||||
}
|
}
|
||||||
// 判断是否为首页
|
// 判断是否为首页
|
||||||
if (!isDashboard(matched[0])) {
|
if (!isDashboard(matched[0])) {
|
||||||
matched = [{ path: "/index", meta: { title: "首页" } }].concat(matched)
|
matched = [{ path: "/index", meta: { title: "工作台" } }].concat(matched)
|
||||||
}
|
}
|
||||||
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
|
levelList.value = matched.filter(item => item.meta && item.meta.title && item.meta.breadcrumb !== false)
|
||||||
}
|
}
|
||||||
@@ -86,22 +86,27 @@ getBreadcrumb()
|
|||||||
<style lang='scss' scoped>
|
<style lang='scss' scoped>
|
||||||
.app-breadcrumb.el-breadcrumb {
|
.app-breadcrumb.el-breadcrumb {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
font-size: 14px;
|
font-size: 13.5px;
|
||||||
line-height: 50px;
|
line-height: 56px;
|
||||||
margin-left: 8px;
|
margin-left: 4px;
|
||||||
|
|
||||||
|
:deep(.el-breadcrumb__separator) {
|
||||||
|
margin: 0 8px;
|
||||||
|
color: var(--sf-border-strong, #c0c4cc);
|
||||||
|
}
|
||||||
|
|
||||||
.no-redirect {
|
.no-redirect {
|
||||||
color: var(--el-text-color-regular, #606266);
|
color: var(--sf-text-1, var(--el-text-color-regular, #606266));
|
||||||
cursor: text;
|
cursor: text;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
a {
|
a {
|
||||||
color: var(--el-text-color-secondary, #909399);
|
color: var(--sf-text-4, var(--el-text-color-secondary, #909399));
|
||||||
transition: color 0.2s ease;
|
transition: color 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
color: var(--el-color-primary, #008596);
|
color: var(--sf-text-1, var(--el-color-primary, #008596));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,22 +6,32 @@
|
|||||||
top="6vh"
|
top="6vh"
|
||||||
append-to-body
|
append-to-body
|
||||||
destroy-on-close
|
destroy-on-close
|
||||||
|
class="sf-assign-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
@closed="resetDialogState"
|
@closed="resetDialogState"
|
||||||
>
|
>
|
||||||
|
<p class="assign-intro">{{ dialogIntro }}</p>
|
||||||
<el-form :inline="true" class="assign-search" @submit.prevent>
|
<el-form :inline="true" class="assign-search" @submit.prevent>
|
||||||
<el-form-item label="用户搜索">
|
<el-form-item>
|
||||||
<el-input
|
<el-input
|
||||||
v-model="keyword"
|
v-model="keyword"
|
||||||
class="assign-search__input"
|
class="assign-search__input"
|
||||||
placeholder="请输入账号或昵称"
|
placeholder="搜索账号或昵称"
|
||||||
|
prefix-icon="Search"
|
||||||
clearable
|
clearable
|
||||||
:disabled="loading || submitLoading || loadFailed"
|
:disabled="loading || submitLoading || loadFailed"
|
||||||
/>
|
/>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<el-form-item>
|
||||||
|
<el-radio-group v-model="assignFilter" class="sf-segmented" aria-label="用户筛选" :disabled="loading || submitLoading || loadFailed">
|
||||||
|
<el-radio-button value="all">全部 {{ users.length }}</el-radio-button>
|
||||||
|
<el-radio-button value="assigned">已授权 {{ originalAssignedIds.length }}</el-radio-button>
|
||||||
|
<el-radio-button value="unassigned">未授权 {{ users.length - originalAssignedIds.length }}</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-alert
|
<el-alert
|
||||||
@@ -40,44 +50,51 @@
|
|||||||
row-key="userId"
|
row-key="userId"
|
||||||
height="360"
|
height="360"
|
||||||
empty-text="暂无可分配用户"
|
empty-text="暂无可分配用户"
|
||||||
|
class="assign-table"
|
||||||
@row-click="handleRowClick"
|
@row-click="handleRowClick"
|
||||||
@selection-change="handleSelectionChange"
|
@selection-change="handleSelectionChange"
|
||||||
>
|
>
|
||||||
<el-table-column type="selection" width="55" :reserve-selection="true" />
|
<el-table-column type="selection" width="48" :reserve-selection="true" />
|
||||||
<el-table-column label="用户账号" prop="userName" :show-overflow-tooltip="true" />
|
<el-table-column label="用户" min-width="220">
|
||||||
<el-table-column label="用户昵称" prop="nickName" :show-overflow-tooltip="true">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<span>{{ scope.row.nickName || "-" }}</span>
|
<span class="assign-user">
|
||||||
|
<span class="assign-user__avatar" :style="{ background: avatarColor(scope.row.userId) }" aria-hidden="true">{{ userInitial(scope.row) }}</span>
|
||||||
|
<span class="assign-user__text">
|
||||||
|
<strong>{{ scope.row.nickName || scope.row.userName }}</strong>
|
||||||
|
<small>{{ scope.row.userName }}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="当前状态" width="100" align="center">
|
<el-table-column label="授权变化" width="120" align="right">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-tag :type="scope.row.assigned ? 'success' : 'info'">
|
<span class="assign-change" :class="`is-${changeOf(scope.row)}`">{{ CHANGE_LABELS[changeOf(scope.row)] }}</span>
|
||||||
{{ scope.row.assigned ? "已分配" : "未分配" }}
|
|
||||||
</el-tag>
|
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer assign-footer">
|
||||||
|
<span class="assign-footer__summary">
|
||||||
|
已授权 {{ selectedUserIds.length }} 人<span v-if="assignmentChanged" class="assign-footer__diff">({{ diffText }})</span>
|
||||||
|
</span>
|
||||||
<el-button :disabled="submitLoading" @click="closeDialog">取消</el-button>
|
<el-button :disabled="submitLoading" @click="closeDialog">取消</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-if="resourceType === 'site'"
|
v-if="resourceType === 'site'"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
:disabled="loading || submitLoading || loadFailed"
|
:disabled="loading || submitLoading || loadFailed || !assignmentChanged"
|
||||||
@click="submitAssignment"
|
@click="submitAssignment"
|
||||||
v-hasPermi="['master:site:assign']"
|
v-hasPermi="['master:site:assign']"
|
||||||
>确 定</el-button>
|
>保存授权</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
v-else-if="resourceType === 'warehouse'"
|
v-else-if="resourceType === 'warehouse'"
|
||||||
type="primary"
|
type="primary"
|
||||||
:loading="submitLoading"
|
:loading="submitLoading"
|
||||||
:disabled="loading || submitLoading || loadFailed"
|
:disabled="loading || submitLoading || loadFailed || !assignmentChanged"
|
||||||
@click="submitAssignment"
|
@click="submitAssignment"
|
||||||
v-hasPermi="['master:warehouse:assign']"
|
v-hasPermi="['master:warehouse:assign']"
|
||||||
>确 定</el-button>
|
>保存授权</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -108,6 +125,9 @@ const users = ref([]);
|
|||||||
const selectedUserIds = ref([]);
|
const selectedUserIds = ref([]);
|
||||||
const userTableRef = ref();
|
const userTableRef = ref();
|
||||||
const restoringSelection = ref(false);
|
const restoringSelection = ref(false);
|
||||||
|
const assignFilter = ref("all");
|
||||||
|
const CHANGE_LABELS = { add: "将新增", remove: "将移除", kept: "已授权", none: "—" };
|
||||||
|
const AVATAR_COLORS = ["#00909F", "#6A7FE8", "#C2410C", "#8E6BD9", "#0F766E", "#3549A8", "#9A3A05", "#5B616E"];
|
||||||
let loadGeneration = 0;
|
let loadGeneration = 0;
|
||||||
let submitGeneration = 0;
|
let submitGeneration = 0;
|
||||||
|
|
||||||
@@ -116,16 +136,44 @@ const dialogTitle = computed(() => {
|
|||||||
return `分配${label}用户${resourceName.value ? ` - ${resourceName.value}` : ""}`;
|
return `分配${label}用户${resourceName.value ? ` - ${resourceName.value}` : ""}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const dialogIntro = computed(() => (resourceType.value === "site"
|
||||||
|
? "被授权的用户才能在该现场收货核对、登记使用和发起退回,并在报表中看到它的数据。"
|
||||||
|
: "被授权的用户才能在入库、装填、发货和退回中选择该仓库,并在报表中看到它的数据。"));
|
||||||
|
|
||||||
|
// 筛选按打开时的授权状态,勾选变化不会让行从列表中消失
|
||||||
const filteredUsers = computed(() => {
|
const filteredUsers = computed(() => {
|
||||||
const normalizedKeyword = keyword.value.trim().toLowerCase();
|
const normalizedKeyword = keyword.value.trim().toLowerCase();
|
||||||
|
const byState = users.value.filter((user) => assignFilter.value === "all"
|
||||||
|
|| (assignFilter.value === "assigned" ? user.assigned : !user.assigned));
|
||||||
if (!normalizedKeyword) {
|
if (!normalizedKeyword) {
|
||||||
return users.value;
|
return byState;
|
||||||
}
|
}
|
||||||
return users.value.filter((user) =>
|
return byState.filter((user) =>
|
||||||
user.userName.toLowerCase().includes(normalizedKeyword)
|
user.userName.toLowerCase().includes(normalizedKeyword)
|
||||||
|| (user.nickName || "").toLowerCase().includes(normalizedKeyword)
|
|| (user.nickName || "").toLowerCase().includes(normalizedKeyword)
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
const originalAssignedIds = computed(() => users.value.filter((user) => user.assigned).map((user) => user.userId));
|
||||||
|
const addedCount = computed(() => selectedUserIds.value.filter((userId) => !originalAssignedIds.value.includes(userId)).length);
|
||||||
|
const removedCount = computed(() => originalAssignedIds.value.filter((userId) => !selectedUserIds.value.includes(userId)).length);
|
||||||
|
const assignmentChanged = computed(() => addedCount.value > 0 || removedCount.value > 0);
|
||||||
|
const diffText = computed(() => [
|
||||||
|
addedCount.value ? `新增 ${addedCount.value}` : "",
|
||||||
|
removedCount.value ? `移除 ${removedCount.value}` : ""
|
||||||
|
].filter(Boolean).join(","));
|
||||||
|
|
||||||
|
function changeOf(user) {
|
||||||
|
const selected = selectedUserIds.value.includes(user.userId);
|
||||||
|
if (user.assigned) return selected ? "kept" : "remove";
|
||||||
|
return selected ? "add" : "none";
|
||||||
|
}
|
||||||
|
function userInitial(user) {
|
||||||
|
const name = String(user.nickName || user.userName || "").trim();
|
||||||
|
return name ? name.slice(-1).toUpperCase() : "?";
|
||||||
|
}
|
||||||
|
function avatarColor(userId) {
|
||||||
|
return AVATAR_COLORS[Math.abs(Number(userId) || 0) % AVATAR_COLORS.length];
|
||||||
|
}
|
||||||
|
|
||||||
function normalizeUserRows(rows) {
|
function normalizeUserRows(rows) {
|
||||||
return rows
|
return rows
|
||||||
@@ -226,6 +274,7 @@ function resetDialogState() {
|
|||||||
submitLoading.value = false;
|
submitLoading.value = false;
|
||||||
loadError.value = "";
|
loadError.value = "";
|
||||||
keyword.value = "";
|
keyword.value = "";
|
||||||
|
assignFilter.value = "all";
|
||||||
resourceType.value = "";
|
resourceType.value = "";
|
||||||
resourceId.value = undefined;
|
resourceId.value = undefined;
|
||||||
resourceName.value = "";
|
resourceName.value = "";
|
||||||
@@ -301,17 +350,122 @@ function isCurrentSubmit(submitContext) {
|
|||||||
&& submitContext.resourceId === resourceId.value;
|
&& submitContext.resourceId === resourceId.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
watch(keyword, restoreSelection);
|
watch([keyword, assignFilter], restoreSelection);
|
||||||
|
|
||||||
defineExpose({ show });
|
defineExpose({ show });
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
.assign-intro {
|
||||||
|
margin: -4px 0 14px;
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-search {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-search :deep(.el-form-item) {
|
||||||
|
margin-right: 0;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
.assign-search__input {
|
.assign-search__input {
|
||||||
width: 260px;
|
width: 260px;
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.assign-table :deep(.el-table__row) {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-user {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-user__avatar {
|
||||||
|
display: inline-flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 50%;
|
||||||
|
color: #ffffff;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-user__text {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-user__text strong {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-user__text small {
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-family: var(--sf-font-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-change {
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-change.is-add {
|
||||||
|
color: var(--sf-accent-strong, #006a78);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-change.is-remove {
|
||||||
|
color: var(--sf-danger-fg, #b42318);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-change.is-kept {
|
||||||
|
color: var(--sf-text-2, #3d4350);
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-footer {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-footer .el-button + .el-button {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-footer__summary {
|
||||||
|
margin-right: auto;
|
||||||
|
color: var(--sf-text-2, #3d4350);
|
||||||
|
font-size: 13px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
.assign-footer__diff {
|
||||||
|
color: var(--sf-accent-strong, #006a78);
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.assign-search :deep(.el-form-item),
|
.assign-search :deep(.el-form-item),
|
||||||
.assign-search__input {
|
.assign-search__input {
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
<template>
|
||||||
|
<span class="sf-doc-no sf-mono" :title="value">{{ shortDocNo(value) }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { shortDocNo } from '@/utils/docNo'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
value: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sf-doc-no {
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<nav class="sf-flow" aria-label="托盘流程">
|
||||||
|
<template v-for="(step, index) in steps" :key="step.key">
|
||||||
|
<svg v-if="index" class="sf-flow__sep" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m9 18 6-6-6-6" /></svg>
|
||||||
|
<span v-if="step.key === current" class="sf-flow__step is-current" aria-current="step">
|
||||||
|
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
|
||||||
|
</span>
|
||||||
|
<router-link v-else-if="step.path" class="sf-flow__step" :to="step.path">
|
||||||
|
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
|
||||||
|
</router-link>
|
||||||
|
<!-- 没有该菜单权限时只显示环节,不提供跳转 -->
|
||||||
|
<span v-else class="sf-flow__step is-plain">
|
||||||
|
<span class="sf-flow__no">{{ index + 1 }}</span>{{ step.label }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</nav>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { masterRoutePath } from '@/utils/masterDisplay'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
// 当前所在环节:inbound / filling / shipment / usage / return
|
||||||
|
current: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const FLOW_STEPS = [
|
||||||
|
{ key: 'inbound', label: '入库' },
|
||||||
|
{ key: 'filling', label: '装填' },
|
||||||
|
{ key: 'shipment', label: '发货' },
|
||||||
|
{ key: 'usage', label: '现场使用' },
|
||||||
|
{ key: 'return', label: '退回' }
|
||||||
|
]
|
||||||
|
|
||||||
|
// 业务菜单由后端动态下发,按路由末段查找实际路径
|
||||||
|
const steps = computed(() => FLOW_STEPS.map((step) => ({ ...step, path: masterRoutePath(step.key) })))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sf-flow {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-flow__sep {
|
||||||
|
flex: none;
|
||||||
|
color: var(--sf-border-strong, #c3c8cf);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-flow__step {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
height: 26px;
|
||||||
|
padding: 0 9px;
|
||||||
|
border-radius: 7px;
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
text-decoration: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
|
&:hover:not(.is-current):not(.is-plain) {
|
||||||
|
background: var(--sf-surface-3, #f1f2f4);
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-current {
|
||||||
|
background: var(--sf-accent-soft, #e6f2f3);
|
||||||
|
color: var(--sf-accent-strong, #005a66);
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
.sf-flow__no {
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-flow__no {
|
||||||
|
color: var(--sf-text-4, #9aa1ac);
|
||||||
|
font-size: 11px;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sf-flow {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,132 @@
|
|||||||
|
<template>
|
||||||
|
<el-select
|
||||||
|
v-if="!failed"
|
||||||
|
:model-value="modelValue"
|
||||||
|
class="sf-ref-select"
|
||||||
|
popper-class="sf-ref-select__popper"
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
:filter-method="setQuery"
|
||||||
|
:loading="loading"
|
||||||
|
:placeholder="placeholder"
|
||||||
|
:no-match-text="`没有匹配的${kind}`"
|
||||||
|
:no-data-text="loading ? '加载中…' : `暂无可选${kind}`"
|
||||||
|
@update:model-value="emit('update:modelValue', $event)"
|
||||||
|
@visible-change="(visible) => { if (!visible) setQuery('') }"
|
||||||
|
>
|
||||||
|
<el-option v-for="option in shownOptions" :key="option.id" :value="option.id" :label="labelOf(option)">
|
||||||
|
<span class="sf-ref-option">
|
||||||
|
<span class="sf-ref-option__name">{{ option.name }}<em v-if="option.unavailable">已停用或不在当前数据范围</em></span>
|
||||||
|
<span class="sf-ref-option__meta">{{ [option.parentName, option.code].filter(Boolean).join(' · ') }}</span>
|
||||||
|
</span>
|
||||||
|
</el-option>
|
||||||
|
</el-select>
|
||||||
|
<span v-else class="sf-ref-fallback">
|
||||||
|
<el-input-number
|
||||||
|
:model-value="modelValue"
|
||||||
|
:min="1"
|
||||||
|
:step="1"
|
||||||
|
step-strictly
|
||||||
|
:controls="false"
|
||||||
|
:placeholder="`${kind}ID`"
|
||||||
|
@update:model-value="emit('update:modelValue', $event ?? undefined)"
|
||||||
|
/>
|
||||||
|
<small>{{ kind }}列表加载失败,可直接输入{{ kind }}ID,保存时校验</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
modelValue: { type: Number, default: undefined },
|
||||||
|
options: { type: Array, default: () => [] },
|
||||||
|
loading: { type: Boolean, default: false },
|
||||||
|
failed: { type: Boolean, default: false },
|
||||||
|
kind: { type: String, default: '资料' },
|
||||||
|
placeholder: { type: String, default: '搜索名称或编码' },
|
||||||
|
// 当前值不在可选列表时(例如上级已停用)用于回显的名称
|
||||||
|
currentLabel: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
const emit = defineEmits(['update:modelValue'])
|
||||||
|
const query = ref('')
|
||||||
|
|
||||||
|
const mergedOptions = computed(() => {
|
||||||
|
const value = props.modelValue
|
||||||
|
if (!value || props.options.some((option) => option.id === value)) return props.options
|
||||||
|
return [{ id: value, name: props.currentLabel || `${props.kind} #${value}`, code: '', unavailable: true }, ...props.options]
|
||||||
|
})
|
||||||
|
const shownOptions = computed(() => {
|
||||||
|
const text = query.value.trim().toLowerCase()
|
||||||
|
if (!text) return mergedOptions.value
|
||||||
|
return mergedOptions.value.filter((option) =>
|
||||||
|
[option.name, option.code, option.parentName].some((field) => String(field || '').toLowerCase().includes(text))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
function setQuery(value) {
|
||||||
|
query.value = value || ''
|
||||||
|
}
|
||||||
|
function labelOf(option) {
|
||||||
|
return [option.parentName, option.name].filter(Boolean).join(' · ')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.sf-ref-select {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-ref-select__popper {
|
||||||
|
.el-select-dropdown__item {
|
||||||
|
height: auto;
|
||||||
|
padding-top: 6px;
|
||||||
|
padding-bottom: 6px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-ref-option {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-ref-option__name {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-size: 13.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
|
||||||
|
em {
|
||||||
|
margin-left: 6px;
|
||||||
|
color: var(--sf-warn-fg, #b54708);
|
||||||
|
font-size: 12px;
|
||||||
|
font-style: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-ref-option__meta {
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-family: var(--sf-font-mono, monospace);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-ref-fallback {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
.el-input-number {
|
||||||
|
width: 160px;
|
||||||
|
}
|
||||||
|
|
||||||
|
small {
|
||||||
|
color: var(--sf-warn-fg, #b54708);
|
||||||
|
font-size: 12px;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// 页面说明:按菜单路由末段匹配,用于页面标题下方的一句话说明
|
||||||
|
export const PAGE_DESCRIPTIONS = {
|
||||||
|
// 基础资料
|
||||||
|
strain: '菌种是装填时选择的物料品类。这里能看到每个菌种此刻在仓库与在外的量、最近有效期和装填情况。',
|
||||||
|
customer: '客户 → 现场 → 产线与隧道的完整层级。现场是发货的目的地,产线和隧道用于现场使用登记和同批溯源。',
|
||||||
|
site: '现场是发货的目的地,也是使用登记与退回的发起地。授权用户只能在自己负责的现场收货、使用和退回。',
|
||||||
|
line: '产线隶属于客户现场。现场使用登记时选择产线,用于按产线统计用量。',
|
||||||
|
warehouse: '仓库是入库、装填、发货与退回的归属地。授权用户只能在自己负责的仓库中作业,也只能看到这些仓库的数据。',
|
||||||
|
pallet: '托盘是循环使用的资产。在这里登记编号、二维码与容量,并随时看到每个托盘此刻在哪、处于哪个环节。',
|
||||||
|
tunnel: '隧道隶属于客户现场。托盘入隧后生成内部溯源号,同一隧道同一批次的托盘共享溯源号,用于同批追溯。',
|
||||||
|
// 业务作业
|
||||||
|
inbound: '空托盘首次入库登记。草稿可继续扫码修改,确认后托盘成为仓库空托。',
|
||||||
|
filling: '扫描仓库空托盘,登记菌种、生产批次与包数,完成后托盘变为仓库有料。',
|
||||||
|
shipment: '从仓库发往客户现场的托盘单据。确认后托盘进入在途,由现场扫码完成收货核对。',
|
||||||
|
usage: '客户现场扫码入隧并登记使用包数,系统按到货先后提示优先使用的托盘。',
|
||||||
|
return: '客户现场发起空托或余料退回申请,仓库扫码核对后确认退回。',
|
||||||
|
// 报表查询
|
||||||
|
palletInventory: '每个托盘当前所处的流转状态、位置与剩余包数。',
|
||||||
|
reportFilling: '按时间、仓库、菌种与批次查询工厂装填记录。',
|
||||||
|
reportShipment: '已确认发货的托盘明细,包含发货时的包数快照与客户现场。',
|
||||||
|
consumption: '客户现场按产线、隧道统计的实际使用包数。',
|
||||||
|
reportReturn: '退回申请与确认的托盘明细,可按溯源号与批次追溯。',
|
||||||
|
palletTrace: '托盘的每一次状态、位置与数量变化。点击托盘编号查看完整的托盘档案。'
|
||||||
|
}
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
<template>
|
||||||
|
<header class="sf-page-header">
|
||||||
|
<div class="sf-page-header__main">
|
||||||
|
<h1 class="sf-page-header__title">{{ resolvedTitle }}</h1>
|
||||||
|
<p v-if="resolvedDescription" class="sf-page-header__desc">{{ resolvedDescription }}</p>
|
||||||
|
<flow-stepper v-if="flow" :current="flow" />
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
|
<div v-if="$slots.actions" class="sf-page-header__actions">
|
||||||
|
<slot name="actions" />
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import FlowStepper from '@/components/FlowStepper'
|
||||||
|
import { PAGE_DESCRIPTIONS } from './descriptions'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
title: { type: String, default: '' },
|
||||||
|
description: { type: String, default: undefined },
|
||||||
|
// 业务作业页传入当前环节,标题下方显示托盘流程
|
||||||
|
flow: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
|
const resolvedTitle = computed(() => props.title || route.meta?.title || '')
|
||||||
|
// 未显式传入说明时,按页面路由末段读取统一的页面说明
|
||||||
|
const resolvedDescription = computed(() => {
|
||||||
|
if (props.description !== undefined) return props.description
|
||||||
|
const segment = route.path.split('/').filter(Boolean).pop()
|
||||||
|
return PAGE_DESCRIPTIONS[segment] || ''
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sf-page-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-end;
|
||||||
|
justify-content: space-between;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin: 0 0 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 420px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 720px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__title {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__desc {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__actions {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
margin-left: auto;
|
||||||
|
|
||||||
|
:deep(.el-button + .el-button) {
|
||||||
|
margin-left: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sf-page-header {
|
||||||
|
margin-bottom: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__title {
|
||||||
|
font-size: 19px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-page-header__actions {
|
||||||
|
width: 100%;
|
||||||
|
|
||||||
|
:deep(.el-button) {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 40px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
<template>
|
||||||
|
<router-link v-if="available && code" class="sf-pallet-link sf-mono" :to="palletPassportLocation(code)" :title="`查看 ${code} 的托盘档案`" @click.stop>
|
||||||
|
<slot>{{ code }}</slot>
|
||||||
|
</router-link>
|
||||||
|
<span v-else class="sf-mono"><slot>{{ code }}</slot></span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { PALLET_PASSPORT_PATH, palletPassportLocation } from '@/utils/palletPassport'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
code: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
// 无托盘追溯权限时档案路由未注册,退化为纯文本
|
||||||
|
const available = computed(() => router.resolve(PALLET_PASSPORT_PATH).matched.some((record) => !record.path.includes(':pathMatch')))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sf-pallet-link {
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: underline;
|
||||||
|
text-decoration-color: var(--sf-border-strong, #dde0e4);
|
||||||
|
text-underline-offset: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-pallet-link:hover {
|
||||||
|
color: var(--sf-accent, #007787);
|
||||||
|
text-decoration-color: currentColor;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sf-scan-feedback" :class="`is-${type}`" role="status" aria-live="polite">
|
||||||
|
<sf-icon :name="ICONS[type] || 'info'" :size="18" :stroke-width="2" />
|
||||||
|
<span class="sf-scan-feedback__text">
|
||||||
|
<strong v-if="code" class="sf-mono">{{ code }}</strong>
|
||||||
|
{{ message }}
|
||||||
|
</span>
|
||||||
|
<span v-if="$slots.default" class="sf-scan-feedback__extra"><slot /></span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SfIcon from '@/components/SfIcon'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
// ok:已加入;warn:需要注意但未失败;error:被拒绝;idle:等待扫码
|
||||||
|
type: { type: String, default: 'idle' },
|
||||||
|
code: { type: String, default: '' },
|
||||||
|
message: { type: String, default: '' }
|
||||||
|
})
|
||||||
|
|
||||||
|
const ICONS = { ok: 'check', warn: 'alert', error: 'alert', idle: 'scan', info: 'info' }
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sf-scan-feedback {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 46px;
|
||||||
|
padding: 10px 14px;
|
||||||
|
border-radius: var(--sf-radius-md, 10px);
|
||||||
|
background: var(--sf-surface-3, #f1f2f4);
|
||||||
|
color: var(--sf-text-2, #3d4350);
|
||||||
|
font-size: 13.5px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
&.is-ok {
|
||||||
|
background: var(--sf-accent-soft, #e6f2f3);
|
||||||
|
color: var(--sf-accent-strong, #005a66);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-warn {
|
||||||
|
background: var(--sf-warn-bg, #fff3e8);
|
||||||
|
color: var(--sf-warn-fg, #b54708);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.is-error {
|
||||||
|
background: var(--sf-danger-bg, #fef3f2);
|
||||||
|
color: var(--sf-danger-fg, #9f1a10);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-scan-feedback__text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
margin-right: 4px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-scan-feedback__extra {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,116 @@
|
|||||||
|
// 线性图标路径(24×24 视窗,描边绘制),供菜单与工作区页面统一使用
|
||||||
|
export const SF_ICONS = {
|
||||||
|
layout: '<rect x="3" y="3" width="7" height="9" rx="1.5"/><rect x="14" y="3" width="7" height="5" rx="1.5"/><rect x="14" y="12" width="7" height="9" rx="1.5"/><rect x="3" y="16" width="7" height="5" rx="1.5"/>',
|
||||||
|
search: '<circle cx="11" cy="11" r="7.5"/><path d="m20.5 20.5-4-4"/>',
|
||||||
|
scan: '<path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/>',
|
||||||
|
route: '<circle cx="6" cy="19" r="2.5"/><circle cx="18" cy="5" r="2.5"/><path d="M8.5 19h8a3.5 3.5 0 0 0 0-7h-9a3.5 3.5 0 0 1 0-7h8"/>',
|
||||||
|
inbound: '<path d="M12 3v12"/><path d="m7 10 5 5 5-5"/><path d="M5 21h14"/>',
|
||||||
|
layers: '<path d="m12 3 9 4.5-9 4.5-9-4.5L12 3Z"/><path d="m3 12 9 4.5 9-4.5"/><path d="m3 16.5 9 4.5 9-4.5"/>',
|
||||||
|
truck: '<path d="M14 17V6a1 1 0 0 0-1-1H3v12h2"/><path d="M14 9h4l3 4v4h-2"/><path d="M9 17h6"/><circle cx="7" cy="17.5" r="2"/><circle cx="17" cy="17.5" r="2"/>',
|
||||||
|
clipboard: '<rect x="8" y="2.5" width="8" height="4" rx="1"/><path d="M16 4.5h2a2 2 0 0 1 2 2V20a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6.5a2 2 0 0 1 2-2h2"/><path d="m9 14 2 2 4-4"/>',
|
||||||
|
sprout: '<path d="M7 20h10"/><path d="M12 20v-8"/><path d="M12 12c0-4 2.5-6.5 7-7 0 4.5-2.5 7-7 7Z"/><path d="M12 14c0-3-2-5-6-5.5 0 3.5 2 5.5 6 5.5Z"/>',
|
||||||
|
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
||||||
|
warehouse: '<path d="M21 8.5V20H3V8.5L12 4l9 4.5Z"/><path d="M7 20v-7h10v7"/><path d="M7 16.5h10"/>',
|
||||||
|
chart: '<path d="M3 3v18h18"/><path d="M8 17v-4"/><path d="M13 17V7"/><path d="M18 17v-7"/>',
|
||||||
|
gauge: '<path d="M12 14 15.5 9.5"/><path d="M3.5 18a9 9 0 1 1 17 0"/><circle cx="12" cy="14" r="1.2"/>',
|
||||||
|
database: '<ellipse cx="12" cy="5.5" rx="8" ry="2.5"/><path d="M4 5.5v13c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5v-13"/><path d="M4 12c0 1.4 3.6 2.5 8 2.5s8-1.1 8-2.5"/>',
|
||||||
|
flask: '<path d="M9.5 3v6.5L4.6 18a2 2 0 0 0 1.7 3h11.4a2 2 0 0 0 1.7-3l-4.9-8.5V3"/><path d="M8 3h8"/><path d="M7 15h10"/>',
|
||||||
|
building: '<rect x="4" y="3" width="16" height="18" rx="1.5"/><path d="M9 7h1M14 7h1M9 11h1M14 11h1M9 15h1M14 15h1"/><path d="M10 21v-3h4v3"/>',
|
||||||
|
pin: '<path d="M20 10c0 5-5.5 10.2-7.4 11.8a1 1 0 0 1-1.2 0C9.5 20.2 4 15 4 10a8 8 0 0 1 16 0Z"/><circle cx="12" cy="10" r="3"/>',
|
||||||
|
line: '<path d="M3 20h18"/><path d="M5 20v-8l4 3v-3l4 3v-3l4 3V6h2v14"/>',
|
||||||
|
package: '<path d="M21 8 12 3 3 8v8l9 5 9-5V8Z"/><path d="m3 8 9 5 9-5"/><path d="M12 13v8"/>',
|
||||||
|
tunnel: '<path d="M3 20v-7a9 9 0 0 1 18 0v7"/><path d="M7 20v-6a5 5 0 0 1 10 0v6"/><path d="M2 20h20"/>',
|
||||||
|
settings: '<path d="M20 7h-9"/><path d="M14 17H4"/><circle cx="17" cy="17" r="3"/><circle cx="7" cy="7" r="3"/>',
|
||||||
|
user: '<circle cx="12" cy="8" r="4"/><path d="M4 21a8 8 0 0 1 16 0"/>',
|
||||||
|
users: '<circle cx="9" cy="8" r="3.5"/><path d="M2.5 20a6.5 6.5 0 0 1 13 0"/><path d="M16 4.5a3.5 3.5 0 0 1 0 7"/><path d="M18 14.2a6.5 6.5 0 0 1 3.5 5.8"/>',
|
||||||
|
shield: '<path d="M12 3 4 6v6c0 4.5 3.4 8.3 8 9 4.6-.7 8-4.5 8-9V6l-8-3Z"/><path d="m9 12 2 2 4-4"/>',
|
||||||
|
menu: '<path d="M4 6h16"/><path d="M4 12h16"/><path d="M4 18h10"/>',
|
||||||
|
tree: '<rect x="9" y="3" width="6" height="5" rx="1"/><rect x="3" y="16" width="6" height="5" rx="1"/><rect x="15" y="16" width="6" height="5" rx="1"/><path d="M12 8v4M6 16v-4h12v4"/>',
|
||||||
|
badge: '<rect x="4" y="3" width="16" height="18" rx="2"/><circle cx="12" cy="10" r="2.5"/><path d="M8 17a4 4 0 0 1 8 0"/>',
|
||||||
|
book: '<path d="M5 4.5A1.5 1.5 0 0 1 6.5 3H20v15H6.5A1.5 1.5 0 0 0 5 19.5v-15Z"/><path d="M5 19.5A1.5 1.5 0 0 0 6.5 21H20"/>',
|
||||||
|
sliders: '<path d="M4 6h10M18 6h2M4 12h4M12 12h8M4 18h12M20 18h0"/><circle cx="16" cy="6" r="2"/><circle cx="10" cy="12" r="2"/><circle cx="18" cy="18" r="2"/>',
|
||||||
|
megaphone: '<path d="M3 10v4a1 1 0 0 0 1 1h2l5 4V5L6 9H4a1 1 0 0 0-1 1Z"/><path d="M15.5 8.5a5 5 0 0 1 0 7"/><path d="M18.5 5.5a9 9 0 0 1 0 13"/>',
|
||||||
|
file: '<path d="M14 3H6a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V9l-6-6Z"/><path d="M14 3v6h6"/><path d="M8 13h8M8 17h5"/>',
|
||||||
|
login: '<path d="M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4"/><path d="m10 17 5-5-5-5"/><path d="M15 12H3"/>',
|
||||||
|
activity: '<path d="M3 12h4l3-8 4 16 3-8h4"/>',
|
||||||
|
clock: '<circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/>',
|
||||||
|
server: '<rect x="3" y="4" width="18" height="7" rx="1.5"/><rect x="3" y="13" width="18" height="7" rx="1.5"/><path d="M7 7.5h.01M7 16.5h.01"/>',
|
||||||
|
zap: '<path d="M13 2 4 14h7l-1 8 9-12h-7l1-8Z"/>',
|
||||||
|
lock: '<rect x="4" y="10" width="16" height="11" rx="2"/><path d="M8 10V7a4 4 0 0 1 8 0v3"/>',
|
||||||
|
wrench: '<path d="M14.7 6.3a4 4 0 0 0 5 5L21 13l-8 8-3-3 6.7-6.7Z"/><path d="M14.7 6.3 9 12l-6 6 3 3 6-6"/>',
|
||||||
|
grid: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/>',
|
||||||
|
code: '<path d="m8 8-5 4 5 4"/><path d="m16 8 5 4-5 4"/><path d="m14 4-4 16"/>',
|
||||||
|
link: '<path d="M10 14a4 4 0 0 0 5.7 0l3-3a4 4 0 0 0-5.7-5.7l-1 1"/><path d="M14 10a4 4 0 0 0-5.7 0l-3 3a4 4 0 0 0 5.7 5.7l1-1"/>',
|
||||||
|
plus: '<path d="M12 5v14M5 12h14"/>',
|
||||||
|
check: '<path d="M20 6 9 17l-5-5"/>',
|
||||||
|
x: '<path d="M18 6 6 18M6 6l12 12"/>',
|
||||||
|
'chevron-right': '<path d="m9 18 6-6-6-6"/>',
|
||||||
|
'chevron-down': '<path d="m6 9 6 6 6-6"/>',
|
||||||
|
'arrow-right': '<path d="M5 12h14"/><path d="m13 6 6 6-6 6"/>',
|
||||||
|
'arrow-left': '<path d="m15 18-6-6 6-6"/>',
|
||||||
|
alert: '<path d="M10.3 3.9 2.4 18a2 2 0 0 0 1.7 3h15.8a2 2 0 0 0 1.7-3L13.7 3.9a2 2 0 0 0-3.4 0Z"/><path d="M12 9v4"/><path d="M12 17h.01"/>',
|
||||||
|
info: '<circle cx="12" cy="12" r="9"/><path d="M12 11v5"/><path d="M12 8h.01"/>',
|
||||||
|
'check-circle': '<circle cx="12" cy="12" r="9"/><path d="m8 12 3 3 5-6"/>',
|
||||||
|
refresh: '<path d="M20 11a8 8 0 0 0-14.7-4.3L3 9"/><path d="M3 4v5h5"/><path d="M4 13a8 8 0 0 0 14.7 4.3L21 15"/><path d="M21 20v-5h-5"/>',
|
||||||
|
printer: '<path d="M6 17H4a1 1 0 0 1-1-1v-5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2v5a1 1 0 0 1-1 1h-2"/><path d="M6 9V3h12v6"/><rect x="6" y="13" width="12" height="8" rx="1"/>',
|
||||||
|
copy: '<rect x="8" y="8" width="13" height="13" rx="2"/><path d="M16 8V5a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h3"/>',
|
||||||
|
calendar: '<rect x="3" y="4.5" width="18" height="16.5" rx="2"/><path d="M8 2.5v4M16 2.5v4M3 10h18"/>',
|
||||||
|
enter: '<path d="M20 5v7a3 3 0 0 1-3 3H5"/><path d="m9 11-4 4 4 4"/>'
|
||||||
|
}
|
||||||
|
|
||||||
|
// 菜单路由名(路径末段)到图标的映射;未命中时回退到菜单配置的 svg 图标
|
||||||
|
export const MENU_ICON_MAP = {
|
||||||
|
index: 'layout',
|
||||||
|
businessMaster: 'database',
|
||||||
|
strain: 'flask',
|
||||||
|
customer: 'building',
|
||||||
|
site: 'pin',
|
||||||
|
line: 'line',
|
||||||
|
warehouse: 'warehouse',
|
||||||
|
pallet: 'package',
|
||||||
|
tunnel: 'tunnel',
|
||||||
|
businessOperation: 'layers',
|
||||||
|
inbound: 'inbound',
|
||||||
|
filling: 'layers',
|
||||||
|
shipment: 'truck',
|
||||||
|
usage: 'sprout',
|
||||||
|
return: 'undo',
|
||||||
|
businessReport: 'chart',
|
||||||
|
palletInventory: 'warehouse',
|
||||||
|
reportFilling: 'layers',
|
||||||
|
reportShipment: 'truck',
|
||||||
|
consumption: 'gauge',
|
||||||
|
reportReturn: 'undo',
|
||||||
|
palletTrace: 'route',
|
||||||
|
system: 'settings',
|
||||||
|
user: 'user',
|
||||||
|
role: 'shield',
|
||||||
|
menu: 'menu',
|
||||||
|
dept: 'tree',
|
||||||
|
post: 'badge',
|
||||||
|
dict: 'book',
|
||||||
|
config: 'sliders',
|
||||||
|
notice: 'megaphone',
|
||||||
|
log: 'file',
|
||||||
|
operlog: 'file',
|
||||||
|
logininfor: 'login',
|
||||||
|
testBatch: 'flask',
|
||||||
|
monitor: 'activity',
|
||||||
|
online: 'users',
|
||||||
|
job: 'clock',
|
||||||
|
druid: 'database',
|
||||||
|
server: 'server',
|
||||||
|
cache: 'zap',
|
||||||
|
cacheList: 'zap',
|
||||||
|
transportCrypto: 'lock',
|
||||||
|
tool: 'wrench',
|
||||||
|
build: 'grid',
|
||||||
|
gen: 'code',
|
||||||
|
swagger: 'book'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resolveMenuIcon(path) {
|
||||||
|
if (!path || typeof path !== 'string') return ''
|
||||||
|
const segment = path.split('/').filter(Boolean).pop() || 'index'
|
||||||
|
return MENU_ICON_MAP[segment] || ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
<template>
|
||||||
|
<svg
|
||||||
|
class="sf-icon"
|
||||||
|
:width="size"
|
||||||
|
:height="size"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
fill="none"
|
||||||
|
stroke="currentColor"
|
||||||
|
:stroke-width="strokeWidth"
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
aria-hidden="true"
|
||||||
|
focusable="false"
|
||||||
|
v-html="paths"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { SF_ICONS } from './icons'
|
||||||
|
|
||||||
|
const props = defineProps({
|
||||||
|
name: { type: String, required: true },
|
||||||
|
size: { type: [Number, String], default: 16 },
|
||||||
|
strokeWidth: { type: [Number, String], default: 1.8 }
|
||||||
|
})
|
||||||
|
|
||||||
|
// 路径仅来自本地常量表,不接收外部输入
|
||||||
|
const paths = computed(() => SF_ICONS[props.name] || '')
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.sf-icon {
|
||||||
|
display: inline-block;
|
||||||
|
flex: none;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -46,73 +46,25 @@ async function syncTestBatchScope() {
|
|||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
|
/* 内容面板负责滚动,app-main 只占据页头以下的空间 */
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 50= navbar 50 */
|
min-height: calc(100% - 56px);
|
||||||
min-height: calc(100vh - 50px);
|
|
||||||
width: 100%;
|
width: 100%;
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
background-color: var(--el-bg-color-page);
|
||||||
background-color: var(--el-bg-color-page, #f5f7fb);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-header + .app-main {
|
|
||||||
overflow-y: auto;
|
|
||||||
scrollbar-gutter: auto;
|
|
||||||
height: calc(100vh - 50px);
|
|
||||||
min-height: 0px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.app-main:has(.copyright) {
|
.app-main:has(.copyright) {
|
||||||
padding-bottom: 36px;
|
padding-bottom: 36px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-header + .app-main {
|
.hasTagsView .app-main {
|
||||||
margin-top: 50px;
|
min-height: calc(100% - 92px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hasTagsView {
|
@media screen and (max-width: 991px) {
|
||||||
.app-main {
|
.app-main {
|
||||||
/* 84 = navbar + tags-view = 50 + 34 */
|
padding-bottom: max(24px, env(safe-area-inset-bottom));
|
||||||
min-height: calc(100vh - 84px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.fixed-header + .app-main {
|
|
||||||
margin-top: 84px;
|
|
||||||
height: calc(100vh - 84px);
|
|
||||||
min-height: 0px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/* 移动端fixed-header优化 */
|
|
||||||
@media screen and (max-width: 991px) {
|
|
||||||
.fixed-header + .app-main {
|
|
||||||
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
|
|
||||||
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hasTagsView .fixed-header + .app-main {
|
|
||||||
padding-bottom: max(60px, calc(constant(safe-area-inset-bottom) + 40px));
|
|
||||||
padding-bottom: max(60px, calc(env(safe-area-inset-bottom) + 40px));
|
|
||||||
overscroll-behavior-y: none;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@supports (-webkit-touch-callout: none) {
|
|
||||||
@media screen and (max-width: 991px) {
|
|
||||||
.fixed-header + .app-main {
|
|
||||||
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
|
|
||||||
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
|
|
||||||
height: calc(100svh - 50px);
|
|
||||||
height: calc(100dvh - 50px);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hasTagsView .fixed-header + .app-main {
|
|
||||||
padding-bottom: max(17px, calc(constant(safe-area-inset-bottom) + 10px));
|
|
||||||
padding-bottom: max(17px, calc(env(safe-area-inset-bottom) + 10px));
|
|
||||||
height: calc(100svh - 84px);
|
|
||||||
height: calc(100dvh - 84px);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -0,0 +1,376 @@
|
|||||||
|
<template>
|
||||||
|
<teleport to="body">
|
||||||
|
<transition name="sf-palette-fade">
|
||||||
|
<div v-if="visible" class="sf-palette-overlay" @mousedown.self="close">
|
||||||
|
<div class="sf-palette" role="dialog" aria-modal="true" aria-label="搜索或扫码">
|
||||||
|
<div class="sf-palette__input">
|
||||||
|
<sf-icon name="scan" :size="18" />
|
||||||
|
<label for="sf-palette-input" class="sf-visually-hidden">搜索页面或扫描托盘码</label>
|
||||||
|
<input
|
||||||
|
id="sf-palette-input"
|
||||||
|
ref="inputRef"
|
||||||
|
v-model="keyword"
|
||||||
|
type="text"
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
placeholder="扫描托盘码,或搜索页面名称"
|
||||||
|
role="combobox"
|
||||||
|
aria-controls="sf-palette-list"
|
||||||
|
:aria-expanded="results.length > 0"
|
||||||
|
:aria-activedescendant="results.length ? `sf-palette-item-${activeIndex}` : undefined"
|
||||||
|
@keydown.down.prevent="move(1)"
|
||||||
|
@keydown.up.prevent="move(-1)"
|
||||||
|
@keydown.enter.prevent="choose(results[activeIndex])"
|
||||||
|
@keydown.esc.prevent="close"
|
||||||
|
/>
|
||||||
|
<kbd class="sf-palette__kbd">Esc</kbd>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-if="results.length" id="sf-palette-list" class="sf-palette__list" role="listbox">
|
||||||
|
<li
|
||||||
|
v-for="(item, index) in results"
|
||||||
|
:id="`sf-palette-item-${index}`"
|
||||||
|
:key="item.key"
|
||||||
|
role="option"
|
||||||
|
:aria-selected="index === activeIndex"
|
||||||
|
:class="['sf-palette__item', { 'is-active': index === activeIndex }]"
|
||||||
|
@mousemove="activeIndex = index"
|
||||||
|
@click="choose(item)"
|
||||||
|
>
|
||||||
|
<span class="sf-palette__item-icon"><sf-icon :name="item.icon" :size="16" /></span>
|
||||||
|
<span class="sf-palette__item-main">
|
||||||
|
<span class="sf-palette__item-title" :class="{ 'sf-mono': item.mono }">{{ item.title }}</span>
|
||||||
|
<span v-if="item.description" class="sf-palette__item-desc">{{ item.description }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="sf-palette__item-kind">{{ item.kind }}</span>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div v-else class="sf-palette__empty">
|
||||||
|
<template v-if="keyword.trim()">未找到与「{{ keyword.trim() }}」相关的页面</template>
|
||||||
|
<template v-else>输入页面名称快速跳转,或直接扫描托盘码查看托盘档案</template>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="sf-palette__footer">
|
||||||
|
<span><kbd>↑</kbd><kbd>↓</kbd> 选择</span>
|
||||||
|
<span><kbd>Enter</kbd> 打开</span>
|
||||||
|
<span class="sf-palette__footer-tip">随时按 <kbd>Ctrl</kbd><kbd>K</kbd> 唤起</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</transition>
|
||||||
|
</teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import SfIcon from '@/components/SfIcon'
|
||||||
|
import { resolveMenuIcon } from '@/components/SfIcon/icons'
|
||||||
|
import auth from '@/plugins/auth'
|
||||||
|
import usePermissionStore from '@/store/modules/permission'
|
||||||
|
import { getNormalPath } from '@/utils/shierfeng'
|
||||||
|
import { isHttp } from '@/utils/validate'
|
||||||
|
import { palletPassportLocation } from '@/utils/palletPassport'
|
||||||
|
|
||||||
|
const MAX_PAGE_RESULTS = 8
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const permissionStore = usePermissionStore()
|
||||||
|
const visible = ref(false)
|
||||||
|
const keyword = ref('')
|
||||||
|
const activeIndex = ref(0)
|
||||||
|
const inputRef = ref(null)
|
||||||
|
let restoreFocus = null
|
||||||
|
|
||||||
|
function flattenRoutes(routes, basePath = '', group = '') {
|
||||||
|
const pages = []
|
||||||
|
for (const item of routes || []) {
|
||||||
|
if (item.hidden) continue
|
||||||
|
const segment = item.path && item.path[0] === '/' ? item.path : `/${item.path || ''}`
|
||||||
|
const path = isHttp(item.path) ? item.path : getNormalPath(basePath + segment)
|
||||||
|
const title = item.meta?.title
|
||||||
|
const visibleChildren = (item.children || []).filter((child) => !child.hidden)
|
||||||
|
if (visibleChildren.length) {
|
||||||
|
pages.push(...flattenRoutes(visibleChildren, path, title || group))
|
||||||
|
} else if (title) {
|
||||||
|
pages.push({ path, title, group, query: item.query })
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return pages
|
||||||
|
}
|
||||||
|
|
||||||
|
const pages = computed(() => flattenRoutes(permissionStore.sidebarRouters))
|
||||||
|
|
||||||
|
const results = computed(() => {
|
||||||
|
const term = keyword.value.trim()
|
||||||
|
const lowered = term.toLowerCase()
|
||||||
|
const items = []
|
||||||
|
// 含中文的输入视为页面搜索,只有像托盘码的输入才提供托盘档案入口
|
||||||
|
const looksLikeCode = term && !/[\u4e00-\u9fa5]/.test(term)
|
||||||
|
if (looksLikeCode && auth.hasPermi('report:pallettrace:list')) {
|
||||||
|
items.push({
|
||||||
|
key: `pallet:${term}`,
|
||||||
|
kind: '托盘档案',
|
||||||
|
icon: 'route',
|
||||||
|
title: term,
|
||||||
|
mono: true,
|
||||||
|
description: '查看该托盘的当前状态与完整流转记录',
|
||||||
|
action: () => router.push(palletPassportLocation(term))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
const matchedPages = term
|
||||||
|
? pages.value.filter((page) => `${page.title}${page.group}${page.path}`.toLowerCase().includes(lowered))
|
||||||
|
: pages.value
|
||||||
|
matchedPages.slice(0, MAX_PAGE_RESULTS).forEach((page) => {
|
||||||
|
items.push({
|
||||||
|
key: `page:${page.path}`,
|
||||||
|
kind: '页面',
|
||||||
|
icon: resolveMenuIcon(page.path) || 'arrow-right',
|
||||||
|
title: page.title,
|
||||||
|
description: page.group,
|
||||||
|
action: () => openPage(page)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return items
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(results, () => {
|
||||||
|
activeIndex.value = 0
|
||||||
|
})
|
||||||
|
|
||||||
|
function openPage(page) {
|
||||||
|
if (isHttp(page.path)) {
|
||||||
|
window.open(page.path, '_blank', 'noopener')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (page.query) {
|
||||||
|
router.push({ path: page.path, query: JSON.parse(page.query) })
|
||||||
|
} else {
|
||||||
|
router.push(page.path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function move(step) {
|
||||||
|
const count = results.value.length
|
||||||
|
if (!count) return
|
||||||
|
activeIndex.value = (activeIndex.value + step + count) % count
|
||||||
|
}
|
||||||
|
|
||||||
|
function choose(item) {
|
||||||
|
if (!item) return
|
||||||
|
close()
|
||||||
|
item.action()
|
||||||
|
}
|
||||||
|
|
||||||
|
function open() {
|
||||||
|
if (visible.value) return
|
||||||
|
restoreFocus = document.activeElement
|
||||||
|
keyword.value = ''
|
||||||
|
visible.value = true
|
||||||
|
nextTick(() => inputRef.value?.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
function close() {
|
||||||
|
if (!visible.value) return
|
||||||
|
visible.value = false
|
||||||
|
if (restoreFocus && typeof restoreFocus.focus === 'function') {
|
||||||
|
restoreFocus.focus()
|
||||||
|
}
|
||||||
|
restoreFocus = null
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleShortcut(event) {
|
||||||
|
if ((event.ctrlKey || event.metaKey) && !event.altKey && event.key.toLowerCase() === 'k') {
|
||||||
|
event.preventDefault()
|
||||||
|
visible.value ? close() : open()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => window.addEventListener('keydown', handleShortcut))
|
||||||
|
onBeforeUnmount(() => window.removeEventListener('keydown', handleShortcut))
|
||||||
|
|
||||||
|
defineExpose({ open, close })
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sf-palette-overlay {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: flex-start;
|
||||||
|
padding: 12vh 16px 16px;
|
||||||
|
background: rgba(15, 17, 21, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 640px;
|
||||||
|
max-height: 70vh;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
|
border-radius: 14px;
|
||||||
|
background: var(--sf-surface, #fff);
|
||||||
|
box-shadow: 0 24px 64px rgba(16, 24, 40, 0.24);
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-family: var(--sf-font-sans);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__input {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
height: 56px;
|
||||||
|
padding: 0 16px;
|
||||||
|
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
|
color: var(--sf-accent, #007787);
|
||||||
|
|
||||||
|
input {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
height: 100%;
|
||||||
|
border: 0;
|
||||||
|
outline: none;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 15px;
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder, #8a909b);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kbd {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
padding: 0 5px;
|
||||||
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
|
border-radius: 5px;
|
||||||
|
background: var(--sf-surface-2, #f7f8f9);
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
font-family: var(--sf-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__list {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
padding: 6px;
|
||||||
|
overflow-y: auto;
|
||||||
|
list-style: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
padding: 9px 10px;
|
||||||
|
border-radius: 9px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&.is-active {
|
||||||
|
background: var(--sf-surface-2, #f7f8f9);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item-icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 30px;
|
||||||
|
height: 30px;
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--sf-surface-3, #f1f2f4);
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
|
||||||
|
.is-active & {
|
||||||
|
background: var(--sf-accent-soft, #e6f3f5);
|
||||||
|
color: var(--sf-accent, #007787);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item-main {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item-title {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item-desc {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-size: 12.5px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__item-kind {
|
||||||
|
flex: none;
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__empty {
|
||||||
|
padding: 28px 20px;
|
||||||
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
font-size: 13.5px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__footer {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 10px 16px;
|
||||||
|
border-top: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font-size: 12px;
|
||||||
|
|
||||||
|
span {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__footer-tip {
|
||||||
|
margin-left: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette-fade-enter-active,
|
||||||
|
.sf-palette-fade-leave-active {
|
||||||
|
transition: opacity 0.15s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette-fade-enter-from,
|
||||||
|
.sf-palette-fade-leave-to {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.sf-palette-overlay {
|
||||||
|
padding-top: 8vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-palette__footer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -9,13 +9,18 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<div class="right-menu">
|
<div class="right-menu">
|
||||||
|
<button type="button" class="right-menu-item search-trigger" aria-label="搜索或扫码(Ctrl K)" @click="openSearch">
|
||||||
|
<sf-icon name="search" :size="15" />
|
||||||
|
<span class="search-trigger__text">搜索或扫码</span>
|
||||||
|
<kbd class="search-trigger__kbd">Ctrl K</kbd>
|
||||||
|
</button>
|
||||||
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
|
<test-batch-mode-switch class="right-menu-item test-batch-mode" aria-label="测试数据模式" />
|
||||||
<template v-if="appStore.device !== 'mobile'">
|
<template v-if="appStore.device !== 'mobile'">
|
||||||
<header-search id="header-search" class="right-menu-item" />
|
<el-tooltip content="全屏" effect="dark" placement="bottom">
|
||||||
|
|
||||||
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
<screenfull id="screenfull" class="right-menu-item hover-effect" />
|
||||||
|
</el-tooltip>
|
||||||
|
|
||||||
<el-tooltip content="主题模式" effect="dark" placement="bottom">
|
<el-tooltip :content="settingsStore.isDark ? '切换浅色模式' : '切换深色模式'" effect="dark" placement="bottom">
|
||||||
<div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
|
<div class="right-menu-item hover-effect theme-switch-wrapper" @click="toggleTheme">
|
||||||
<svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
|
<svg-icon v-if="settingsStore.isDark" icon-class="sunny" />
|
||||||
<svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
|
<svg-icon v-if="!settingsStore.isDark" icon-class="moon" />
|
||||||
@@ -27,9 +32,9 @@
|
|||||||
</el-tooltip>
|
</el-tooltip>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<el-dropdown @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
|
<el-dropdown v-if="!sidebarShowsUser" @command="handleCommand" class="avatar-container right-menu-item hover-effect" trigger="hover">
|
||||||
<div class="avatar-wrapper">
|
<div class="avatar-wrapper">
|
||||||
<img :src="userStore.avatar" class="user-avatar" />
|
<img :src="userStore.avatar" class="user-avatar" alt="" />
|
||||||
<span class="user-nickname"> {{ userStore.nickName }} </span>
|
<span class="user-nickname"> {{ userStore.nickName }} </span>
|
||||||
</div>
|
</div>
|
||||||
<template #dropdown>
|
<template #dropdown>
|
||||||
@@ -59,7 +64,7 @@ import Logo from './Sidebar/Logo'
|
|||||||
import Hamburger from '@/components/Hamburger'
|
import Hamburger from '@/components/Hamburger'
|
||||||
import Screenfull from '@/components/Screenfull'
|
import Screenfull from '@/components/Screenfull'
|
||||||
import SizeSelect from '@/components/SizeSelect'
|
import SizeSelect from '@/components/SizeSelect'
|
||||||
import HeaderSearch from '@/components/HeaderSearch'
|
import SfIcon from '@/components/SfIcon'
|
||||||
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
|
import TestBatchModeSwitch from '@/components/TestBatchModeSwitch'
|
||||||
import useAppStore from '@/store/modules/app'
|
import useAppStore from '@/store/modules/app'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
@@ -68,6 +73,8 @@ import useSettingsStore from '@/store/modules/settings'
|
|||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
|
// 左侧导航底部已展示当前用户时,顶部不再重复显示头像
|
||||||
|
const sidebarShowsUser = computed(() => appStore.device !== 'mobile' && !appStore.sidebar.hide && settingsStore.navType == 1)
|
||||||
|
|
||||||
function toggleSideBar() {
|
function toggleSideBar() {
|
||||||
appStore.toggleSideBar()
|
appStore.toggleSideBar()
|
||||||
@@ -98,11 +105,15 @@ function logout() {
|
|||||||
}).catch(() => { })
|
}).catch(() => { })
|
||||||
}
|
}
|
||||||
|
|
||||||
const emits = defineEmits(['setLayout'])
|
const emits = defineEmits(['setLayout', 'openSearch'])
|
||||||
function setLayout() {
|
function setLayout() {
|
||||||
emits('setLayout')
|
emits('setLayout')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openSearch() {
|
||||||
|
emits('openSearch')
|
||||||
|
}
|
||||||
|
|
||||||
async function toggleTheme(event) {
|
async function toggleTheme(event) {
|
||||||
const x = event?.clientX || window.innerWidth / 2
|
const x = event?.clientX || window.innerWidth / 2
|
||||||
const y = event?.clientY || window.innerHeight / 2
|
const y = event?.clientY || window.innerHeight / 2
|
||||||
@@ -152,35 +163,46 @@ async function toggleTheme(event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.navbar {
|
.navbar {
|
||||||
height: 50px;
|
height: 56px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
position: relative;
|
position: relative;
|
||||||
background: var(--navbar-bg);
|
background: var(--navbar-bg);
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
box-shadow: 0 1px 4px rgba(15, 34, 58, 0.04);
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
// padding: 0 8px;
|
gap: 4px;
|
||||||
|
padding: 0 12px 0 8px;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
|
|
||||||
.hamburger-container {
|
.hamburger-container {
|
||||||
line-height: 46px;
|
height: 34px;
|
||||||
height: 100%;
|
border-radius: 8px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s;
|
color: var(--sf-text-3, #5b616e);
|
||||||
|
transition: background 0.15s;
|
||||||
-webkit-tap-highlight-color: transparent;
|
-webkit-tap-highlight-color: transparent;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
margin-right: 8px;
|
margin-right: 4px;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.025);
|
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(div) {
|
||||||
|
padding: 0 8px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
:deep(.hamburger) {
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.breadcrumb-container {
|
.breadcrumb-container {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
|
margin-left: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.topmenu-container {
|
.topmenu-container {
|
||||||
@@ -197,16 +219,11 @@ async function toggleTheme(event) {
|
|||||||
margin-left: 8px;
|
margin-left: 8px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.errLog-container {
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: top;
|
|
||||||
}
|
|
||||||
|
|
||||||
.right-menu {
|
.right-menu {
|
||||||
height: 100%;
|
height: 100%;
|
||||||
line-height: 50px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
margin-left: auto;
|
margin-left: auto;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
flex-shrink: 1;
|
flex-shrink: 1;
|
||||||
@@ -220,75 +237,94 @@ async function toggleTheme(event) {
|
|||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
min-width: 34px;
|
||||||
|
height: 34px;
|
||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
height: 100%;
|
border-radius: 8px;
|
||||||
font-size: 18px;
|
font-size: 17px;
|
||||||
color: var(--el-text-color-regular, #5a5e66);
|
color: var(--sf-text-3, #5b616e);
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|
||||||
&.hover-effect {
|
&.hover-effect {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
transition: background 0.3s, color 0.2s;
|
transition: background 0.15s, color 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.035);
|
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||||
color: var(--el-color-primary, #008596);
|
color: var(--sf-text-1, #0f1115);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.theme-switch-wrapper {
|
&.theme-switch-wrapper {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
svg {
|
.search-trigger {
|
||||||
transition: transform 0.3s;
|
gap: 8px;
|
||||||
|
width: 248px;
|
||||||
|
height: 34px;
|
||||||
|
margin-right: 8px;
|
||||||
|
padding: 0 8px 0 10px;
|
||||||
|
border: 1px solid var(--sf-border-strong, #dde0e4);
|
||||||
|
background: var(--sf-surface, #fff);
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
|
font: inherit;
|
||||||
|
font-size: 13px;
|
||||||
|
cursor: pointer;
|
||||||
|
justify-content: flex-start;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
transform: scale(1.15);
|
border-color: #c3c8cf;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-trigger__text {
|
||||||
|
flex: 1;
|
||||||
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-trigger__kbd {
|
||||||
|
padding: 1px 6px;
|
||||||
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
|
border-radius: 5px;
|
||||||
|
font-family: var(--sf-font-mono);
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--sf-text-4, #6b717d);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.avatar-container {
|
.avatar-container {
|
||||||
margin-right: 4px;
|
margin-left: 4px;
|
||||||
padding: 0 4px;
|
padding: 0;
|
||||||
|
|
||||||
.avatar-wrapper {
|
.avatar-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 8px;
|
gap: 8px;
|
||||||
padding: 4px 8px;
|
padding: 2px 8px 2px 2px;
|
||||||
border-radius: 20px;
|
border-radius: 18px;
|
||||||
transition: background 0.25s ease;
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
&:hover {
|
&:hover {
|
||||||
background: rgba(0, 0, 0, 0.04);
|
background: var(--sf-hover, rgba(15, 17, 21, 0.045));
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-avatar {
|
.user-avatar {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
width: 32px;
|
width: 28px;
|
||||||
height: 32px;
|
height: 28px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
border: 1.5px solid var(--el-color-primary-light-7, #b2e0e6);
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.12);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.user-nickname {
|
.user-nickname {
|
||||||
font-size: 13px;
|
font-size: 13px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--el-text-color-primary, #303133);
|
color: var(--sf-text-1, #0f1115);
|
||||||
}
|
|
||||||
|
|
||||||
i {
|
|
||||||
cursor: pointer;
|
|
||||||
position: absolute;
|
|
||||||
right: -20px;
|
|
||||||
top: 25px;
|
|
||||||
font-size: 12px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -297,7 +333,20 @@ async function toggleTheme(event) {
|
|||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
margin-right: 6px;
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
color: inherit;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media screen and (max-width: 1200px) {
|
||||||
|
.navbar .right-menu .search-trigger {
|
||||||
|
width: auto;
|
||||||
|
|
||||||
|
.search-trigger__text,
|
||||||
|
.search-trigger__kbd {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -315,6 +364,11 @@ async function toggleTheme(event) {
|
|||||||
padding: 0;
|
padding: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.search-trigger {
|
||||||
|
margin-right: 2px;
|
||||||
|
border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.avatar-container .avatar-wrapper .user-nickname {
|
.avatar-container .avatar-wrapper .user-nickname {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,9 @@
|
|||||||
<img :src="logoMark" :alt="title" class="sidebar-logo sidebar-logo-mark" />
|
<img :src="logoMark" :alt="title" class="sidebar-logo sidebar-logo-mark" />
|
||||||
</router-link>
|
</router-link>
|
||||||
<router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/">
|
<router-link v-else key="expand" class="sidebar-logo-link" :aria-label="title" to="/">
|
||||||
<img :src="logo" :alt="title" class="sidebar-logo sidebar-logo-full" />
|
<img :src="onDarkSurface ? logo : logoLight" alt="Sylvan" class="sidebar-logo sidebar-logo-full" />
|
||||||
|
<span class="sidebar-logo-divider" aria-hidden="true" />
|
||||||
|
<span class="sidebar-logo-title">{{ shortTitle }}</span>
|
||||||
</router-link>
|
</router-link>
|
||||||
</transition>
|
</transition>
|
||||||
</div>
|
</div>
|
||||||
@@ -13,8 +15,9 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import logo from '@/assets/logo/sylvan-logo.svg'
|
import logo from '@/assets/logo/sylvan-logo.svg'
|
||||||
|
import logoLight from '@/assets/logo/sylvan-logo-light.svg'
|
||||||
import logoMark from '@/assets/logo/sylvan-mark.svg'
|
import logoMark from '@/assets/logo/sylvan-mark.svg'
|
||||||
import variables from '@/assets/styles/variables.module.scss'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
defineProps({
|
defineProps({
|
||||||
collapse: {
|
collapse: {
|
||||||
@@ -23,8 +26,11 @@ defineProps({
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
const title = import.meta.env.VITE_APP_TITLE;
|
const settingsStore = useSettingsStore()
|
||||||
const logoBackground = variables.menuBg;
|
const title = import.meta.env.VITE_APP_TITLE
|
||||||
|
const shortTitle = (title || '').replace(/管理系统$/, '') || title
|
||||||
|
// 原版横版标识文字为白色,仅用于深色侧栏;浅色侧栏使用品牌深蓝文字版本
|
||||||
|
const onDarkSurface = computed(() => settingsStore.isDark || settingsStore.sideTheme === 'theme-dark')
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -39,21 +45,18 @@ const logoBackground = variables.menuBg;
|
|||||||
|
|
||||||
.sidebar-logo-container {
|
.sidebar-logo-container {
|
||||||
position: relative;
|
position: relative;
|
||||||
height: 50px;
|
flex: none;
|
||||||
line-height: 50px;
|
height: 60px;
|
||||||
background: v-bind(logoBackground);
|
|
||||||
text-align: center;
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
||||||
|
|
||||||
& .sidebar-logo-link {
|
& .sidebar-logo-link {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
gap: 10px;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
box-sizing: border-box;
|
box-sizing: border-box;
|
||||||
padding: 4px 12px;
|
padding: 0 20px;
|
||||||
|
|
||||||
& .sidebar-logo {
|
& .sidebar-logo {
|
||||||
display: block;
|
display: block;
|
||||||
@@ -61,20 +64,46 @@ const logoBackground = variables.menuBg;
|
|||||||
}
|
}
|
||||||
|
|
||||||
& .sidebar-logo-full {
|
& .sidebar-logo-full {
|
||||||
width: 132px;
|
width: auto;
|
||||||
height: 42px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
|
|
||||||
& .sidebar-logo-mark {
|
& .sidebar-logo-mark {
|
||||||
width: 32px;
|
width: 30px;
|
||||||
height: 32px;
|
height: 30px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
&.collapse {
|
.sidebar-logo-divider {
|
||||||
.sidebar-logo-link {
|
flex: none;
|
||||||
padding: 4px;
|
width: 1px;
|
||||||
|
height: 18px;
|
||||||
|
background: var(--sf-border-strong, #dde0e4);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-title {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sf-text-1, #0f1115);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.collapse .sidebar-logo-link {
|
||||||
|
justify-content: center;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .sidebar-logo-container {
|
||||||
|
.sidebar-logo-divider {
|
||||||
|
background: rgba(255, 255, 255, 0.16);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-logo-title {
|
||||||
|
color: #ffffff;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
<template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow">
|
<template v-if="hasOneShowingChild(item.children, item) && (!onlyOneChild.children || onlyOneChild.noShowingChildren) && !item.alwaysShow">
|
||||||
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
|
<app-link v-if="onlyOneChild.meta" :to="resolvePath(onlyOneChild.path, onlyOneChild.query)">
|
||||||
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
|
<el-menu-item :index="resolvePath(onlyOneChild.path)" :class="{ 'submenu-title-noDropdown': !isNest }">
|
||||||
<svg-icon :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/>
|
<sf-icon v-if="menuIcon(resolvePath(onlyOneChild.path))" class="sf-menu-icon" :name="menuIcon(resolvePath(onlyOneChild.path))" />
|
||||||
|
<svg-icon v-else :icon-class="onlyOneChild.meta.icon || (item.meta && item.meta.icon)"/>
|
||||||
<template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template>
|
<template #title><span class="menu-title" :title="hasTitle(onlyOneChild.meta.title)">{{ onlyOneChild.meta.title }}</span></template>
|
||||||
</el-menu-item>
|
</el-menu-item>
|
||||||
</app-link>
|
</app-link>
|
||||||
@@ -11,7 +12,8 @@
|
|||||||
|
|
||||||
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
|
<el-sub-menu v-else ref="subMenu" :index="resolvePath(item.path)" teleported>
|
||||||
<template v-if="item.meta" #title>
|
<template v-if="item.meta" #title>
|
||||||
<svg-icon :icon-class="item.meta && item.meta.icon" />
|
<sf-icon v-if="menuIcon(resolvePath(item.path))" class="sf-menu-icon" :name="menuIcon(resolvePath(item.path))" />
|
||||||
|
<svg-icon v-else :icon-class="item.meta && item.meta.icon" />
|
||||||
<span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
|
<span class="menu-title" :title="hasTitle(item.meta.title)">{{ item.meta.title }}</span>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -30,6 +32,8 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { isExternal } from '@/utils/validate'
|
import { isExternal } from '@/utils/validate'
|
||||||
import AppLink from './Link'
|
import AppLink from './Link'
|
||||||
|
import SfIcon from '@/components/SfIcon'
|
||||||
|
import { resolveMenuIcon } from '@/components/SfIcon/icons'
|
||||||
import { getNormalPath } from '@/utils/shierfeng'
|
import { getNormalPath } from '@/utils/shierfeng'
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
@@ -90,6 +94,10 @@ function resolvePath(routePath, routeQuery) {
|
|||||||
return getNormalPath(props.basePath + '/' + routePath)
|
return getNormalPath(props.basePath + '/' + routePath)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function menuIcon(target) {
|
||||||
|
return resolveMenuIcon(typeof target === 'string' ? target : target?.path)
|
||||||
|
}
|
||||||
|
|
||||||
function hasTitle(title){
|
function hasTitle(title){
|
||||||
if (title.length > 5) {
|
if (title.length > 5) {
|
||||||
return title;
|
return title;
|
||||||
|
|||||||
@@ -0,0 +1,186 @@
|
|||||||
|
<template>
|
||||||
|
<div class="sidebar-user" :class="{ 'is-collapse': collapse }">
|
||||||
|
<el-dropdown trigger="click" placement="top-start" popper-class="sidebar-user-menu" @command="handleCommand">
|
||||||
|
<button type="button" class="sidebar-user__trigger" :aria-label="`当前用户 ${displayName},打开账户菜单`">
|
||||||
|
<img :src="userStore.avatar" class="sidebar-user__avatar" alt="" />
|
||||||
|
<span v-if="!collapse" class="sidebar-user__text">
|
||||||
|
<span class="sidebar-user__name">{{ displayName }}</span>
|
||||||
|
<span class="sidebar-user__meta">{{ subtitle }}</span>
|
||||||
|
</span>
|
||||||
|
<svg v-if="!collapse" class="sidebar-user__chevron" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
|
||||||
|
<path d="m7 15 5 5 5-5" />
|
||||||
|
<path d="m7 9 5-5 5 5" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
<template #dropdown>
|
||||||
|
<el-dropdown-menu>
|
||||||
|
<div class="sidebar-user-menu__head">
|
||||||
|
<strong>{{ displayName }}</strong>
|
||||||
|
<span>{{ userStore.name }}</span>
|
||||||
|
</div>
|
||||||
|
<el-dropdown-item command="profile">个人中心</el-dropdown-item>
|
||||||
|
<el-dropdown-item v-if="settingsStore.showSettings" command="setLayout">布局设置</el-dropdown-item>
|
||||||
|
<el-dropdown-item divided command="logout">退出登录</el-dropdown-item>
|
||||||
|
</el-dropdown-menu>
|
||||||
|
</template>
|
||||||
|
</el-dropdown>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import useUserStore from '@/store/modules/user'
|
||||||
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
|
defineProps({
|
||||||
|
collapse: {
|
||||||
|
type: Boolean,
|
||||||
|
default: false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const emit = defineEmits(['setLayout'])
|
||||||
|
const router = useRouter()
|
||||||
|
const userStore = useUserStore()
|
||||||
|
const settingsStore = useSettingsStore()
|
||||||
|
|
||||||
|
const displayName = computed(() => userStore.nickName || userStore.name || '未命名用户')
|
||||||
|
// 优先展示部门与角色,缺失时回退到登录账号
|
||||||
|
const subtitle = computed(() => [userStore.deptName, userStore.roleNames[0]].filter(Boolean).join(' · ') || userStore.name)
|
||||||
|
|
||||||
|
function handleCommand(command) {
|
||||||
|
if (command === 'profile') {
|
||||||
|
router.push('/user/profile')
|
||||||
|
} else if (command === 'setLayout') {
|
||||||
|
emit('setLayout')
|
||||||
|
} else if (command === 'logout') {
|
||||||
|
logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
ElMessageBox.confirm('确定注销并退出系统吗?', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
}).then(() => {
|
||||||
|
userStore.logOut().then(() => {
|
||||||
|
location.href = '/index'
|
||||||
|
})
|
||||||
|
}).catch(() => { })
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.sidebar-user {
|
||||||
|
flex: none;
|
||||||
|
padding: 8px 10px 12px;
|
||||||
|
border-top: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
|
|
||||||
|
:deep(.el-dropdown) {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__trigger {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 10px;
|
||||||
|
width: 100%;
|
||||||
|
padding: 8px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: transparent;
|
||||||
|
color: var(--sf-menu-active-text, #0f1115);
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background 0.15s ease;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&[aria-expanded='true'] {
|
||||||
|
background: var(--sf-menu-hover, rgba(15, 17, 21, 0.045));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__avatar {
|
||||||
|
flex: none;
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
|
border-radius: 50%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__name {
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__meta {
|
||||||
|
overflow: hidden;
|
||||||
|
color: var(--sf-menu-group, #6b717d);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-user__chevron {
|
||||||
|
flex: none;
|
||||||
|
color: var(--sf-menu-icon, #6b717d);
|
||||||
|
}
|
||||||
|
|
||||||
|
.is-collapse {
|
||||||
|
padding: 8px 0 12px;
|
||||||
|
|
||||||
|
.sidebar-user__trigger {
|
||||||
|
justify-content: center;
|
||||||
|
width: 44px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.theme-dark .sidebar-user {
|
||||||
|
border-top-color: rgba(255, 255, 255, 0.08);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
.sidebar-user-menu {
|
||||||
|
min-width: 200px;
|
||||||
|
|
||||||
|
.sidebar-user-menu__head {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
padding: 8px 12px 10px;
|
||||||
|
margin-bottom: 4px;
|
||||||
|
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
|
line-height: 1.4;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
color: var(--sf-text-1, #303133);
|
||||||
|
font-size: 13.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
color: var(--sf-text-4, #909399);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,17 +1,15 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="{ 'has-logo': showLogo }" class="sidebar-container">
|
<div :class="['sidebar-container', sideThemeClass, { 'has-logo': showLogo }]">
|
||||||
<logo v-if="showLogo" :collapse="isCollapse" />
|
<logo v-if="showLogo" :collapse="isCollapse" />
|
||||||
<el-scrollbar wrap-class="scrollbar-wrapper">
|
<el-scrollbar wrap-class="scrollbar-wrapper">
|
||||||
<el-menu
|
<el-menu
|
||||||
:default-active="activeMenu"
|
:default-active="activeMenu"
|
||||||
|
:default-openeds="defaultOpeneds"
|
||||||
:collapse="isCollapse"
|
:collapse="isCollapse"
|
||||||
:background-color="getMenuBackground"
|
:unique-opened="false"
|
||||||
:text-color="getMenuTextColor"
|
|
||||||
:unique-opened="true"
|
|
||||||
:active-text-color="theme"
|
|
||||||
:collapse-transition="false"
|
:collapse-transition="false"
|
||||||
mode="vertical"
|
mode="vertical"
|
||||||
:class="sideTheme"
|
class="sf-menu"
|
||||||
>
|
>
|
||||||
<sidebar-item
|
<sidebar-item
|
||||||
v-for="(route, index) in sidebarRouters"
|
v-for="(route, index) in sidebarRouters"
|
||||||
@@ -21,84 +19,45 @@
|
|||||||
/>
|
/>
|
||||||
</el-menu>
|
</el-menu>
|
||||||
</el-scrollbar>
|
</el-scrollbar>
|
||||||
|
<user-card :collapse="isCollapse" @setLayout="emit('setLayout')" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import Logo from './Logo'
|
import Logo from './Logo'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
import variables from '@/assets/styles/variables.module.scss'
|
import UserCard from './UserCard'
|
||||||
import useAppStore from '@/store/modules/app'
|
import useAppStore from '@/store/modules/app'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
import usePermissionStore from '@/store/modules/permission'
|
import usePermissionStore from '@/store/modules/permission'
|
||||||
|
import { getNormalPath } from '@/utils/shierfeng'
|
||||||
|
|
||||||
const route = useRoute();
|
// 业务作业与查询分组默认展开,其余分组按需展开
|
||||||
|
const DEFAULT_OPEN_GROUPS = ['/businessOperation', '/businessReport']
|
||||||
|
|
||||||
|
const emit = defineEmits(['setLayout'])
|
||||||
|
const route = useRoute()
|
||||||
const appStore = useAppStore()
|
const appStore = useAppStore()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const permissionStore = usePermissionStore()
|
const permissionStore = usePermissionStore()
|
||||||
|
|
||||||
const sidebarRouters = computed(() => permissionStore.sidebarRouters);
|
const sidebarRouters = computed(() => permissionStore.sidebarRouters)
|
||||||
const showLogo = computed(() => settingsStore.sidebarLogo);
|
const showLogo = computed(() => settingsStore.sidebarLogo)
|
||||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
const isCollapse = computed(() => !appStore.sidebar.opened)
|
||||||
const theme = computed(() => settingsStore.theme);
|
const sideThemeClass = computed(() => (settingsStore.sideTheme === 'theme-dark' && !settingsStore.isDark ? 'theme-dark' : 'theme-light'))
|
||||||
const isCollapse = computed(() => !appStore.sidebar.opened);
|
|
||||||
|
|
||||||
// 获取菜单背景色
|
// 与 SidebarItem.resolvePath 对一级目录生成的 index 保持一致
|
||||||
const getMenuBackground = computed(() => {
|
const defaultOpeneds = computed(() =>
|
||||||
if (settingsStore.isDark) {
|
sidebarRouters.value
|
||||||
return 'var(--sidebar-bg)';
|
.filter((item) => !item.hidden && DEFAULT_OPEN_GROUPS.includes(item.path))
|
||||||
}
|
.map((item) => getNormalPath(`${item.path}/${item.path}`))
|
||||||
return sideTheme.value === 'theme-dark' ? variables.menuBg : variables.menuLightBg;
|
)
|
||||||
});
|
|
||||||
|
|
||||||
// 获取菜单文字颜色
|
|
||||||
const getMenuTextColor = computed(() => {
|
|
||||||
if (settingsStore.isDark) {
|
|
||||||
return 'var(--sidebar-text)';
|
|
||||||
}
|
|
||||||
return sideTheme.value === 'theme-dark' ? variables.menuText : variables.menuLightText;
|
|
||||||
});
|
|
||||||
|
|
||||||
const activeMenu = computed(() => {
|
const activeMenu = computed(() => {
|
||||||
const { meta, path } = route;
|
const { meta, path } = route
|
||||||
if (meta.activeMenu) {
|
if (meta.activeMenu) {
|
||||||
return meta.activeMenu;
|
return meta.activeMenu
|
||||||
}
|
}
|
||||||
return path;
|
return path
|
||||||
});
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
|
||||||
.sidebar-container {
|
|
||||||
background-color: v-bind(getMenuBackground);
|
|
||||||
|
|
||||||
.scrollbar-wrapper {
|
|
||||||
background-color: v-bind(getMenuBackground);
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-menu {
|
|
||||||
border: none;
|
|
||||||
height: 100%;
|
|
||||||
width: 100% !important;
|
|
||||||
|
|
||||||
.el-menu-item, .el-sub-menu__title {
|
|
||||||
&:hover {
|
|
||||||
background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-menu-item {
|
|
||||||
color: v-bind(getMenuTextColor);
|
|
||||||
|
|
||||||
&.is-active {
|
|
||||||
color: var(--menu-active-text, #409eff);
|
|
||||||
background-color: var(--menu-hover, rgba(0, 0, 0, 0.06)) !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.el-sub-menu__title {
|
|
||||||
color: v-bind(getMenuTextColor);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
|
|||||||
@@ -87,8 +87,7 @@ function isActive(r) {
|
|||||||
function activeStyle(tag) {
|
function activeStyle(tag) {
|
||||||
if (!isActive(tag)) return {};
|
if (!isActive(tag)) return {};
|
||||||
return {
|
return {
|
||||||
"background-color": theme.value || '#008596',
|
"border-color": 'var(--sf-border-strong, #dde0e4)'
|
||||||
"border-color": theme.value || '#008596'
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
function isAffix(tag) {
|
function isAffix(tag) {
|
||||||
@@ -241,11 +240,10 @@ function handleScroll() {
|
|||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
.tags-view-container {
|
.tags-view-container {
|
||||||
height: 34px;
|
height: 36px;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: var(--tags-bg, #fff);
|
background: var(--tags-bg, #fff);
|
||||||
border-bottom: 1px solid var(--el-border-color-lighter, #e4e7ed);
|
border-bottom: 1px solid var(--sf-border-soft, #eef0f2);
|
||||||
box-shadow: 0 1px 4px 0 rgba(15, 34, 58, 0.04);
|
|
||||||
.tags-view-wrapper {
|
.tags-view-wrapper {
|
||||||
.tags-view-item {
|
.tags-view-item {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -272,26 +270,25 @@ function handleScroll() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:not(.active):hover {
|
&:not(.active):hover {
|
||||||
background-color: var(--el-fill-color-light, #f5f7fa);
|
background-color: var(--sf-surface-2, #f7f8f9);
|
||||||
border-color: var(--el-color-primary-light-7, #b2e0e6);
|
color: var(--sf-text-1, #0f1115);
|
||||||
color: var(--el-color-primary, #008596);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
background-color: #008596;
|
background-color: var(--sf-surface-3, #f1f2f4);
|
||||||
color: #fff;
|
color: var(--sf-text-1, #0f1115);
|
||||||
border-color: #008596;
|
font-weight: 500;
|
||||||
box-shadow: 0 2px 6px rgba(0, 133, 150, 0.22);
|
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
background: #fff;
|
background: var(--sf-accent, #007787);
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
width: 7px;
|
width: 6px;
|
||||||
height: 7px;
|
height: 6px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
position: relative;
|
position: relative;
|
||||||
margin-right: 6px;
|
margin-right: 6px;
|
||||||
|
vertical-align: 1px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
<template>
|
||||||
|
<div v-if="testBatchStore.isTestMode" class="test-mode-banner" role="status">
|
||||||
|
<sf-icon name="flask" :size="16" />
|
||||||
|
<strong>测试数据模式</strong>
|
||||||
|
<span class="test-mode-banner__text">
|
||||||
|
当前批次 {{ testBatchStore.currentBatchName || '已选批次' }} · 本模式下的业务操作只写入测试批次,不影响正式库存
|
||||||
|
</span>
|
||||||
|
<button type="button" class="test-mode-banner__exit" :disabled="leaving" @click="leaveTestMode">退出测试模式</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { ElMessageBox } from 'element-plus'
|
||||||
|
import SfIcon from '@/components/SfIcon'
|
||||||
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
|
||||||
|
const testBatchStore = useTestBatchStore()
|
||||||
|
const leaving = ref(false)
|
||||||
|
|
||||||
|
// 与顶部模式切换保持一致:先确认会清空未提交内容,再退出测试批次
|
||||||
|
async function leaveTestMode() {
|
||||||
|
if (leaving.value) return
|
||||||
|
leaving.value = true
|
||||||
|
try {
|
||||||
|
await ElMessageBox.confirm('切换数据模式将清空当前页面未提交内容', '提示', {
|
||||||
|
confirmButtonText: '确定',
|
||||||
|
cancelButtonText: '取消',
|
||||||
|
type: 'warning'
|
||||||
|
})
|
||||||
|
testBatchStore.leaveTestMode()
|
||||||
|
} catch {
|
||||||
|
// 用户取消,保持测试模式
|
||||||
|
} finally {
|
||||||
|
leaving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style lang="scss" scoped>
|
||||||
|
.test-mode-banner {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 8px 10px;
|
||||||
|
padding: 8px 24px;
|
||||||
|
border-bottom: 1px solid var(--sf-warn-border, #f9ddba);
|
||||||
|
background: var(--sf-warn-bg, #fff3e8);
|
||||||
|
color: var(--sf-warn-fg, #b54708);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.5;
|
||||||
|
|
||||||
|
strong {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-mode-banner__text {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-mode-banner__exit {
|
||||||
|
margin-left: auto;
|
||||||
|
height: 28px;
|
||||||
|
padding: 0 10px;
|
||||||
|
border: 1px solid var(--sf-warn-border, #f9ddba);
|
||||||
|
border-radius: 7px;
|
||||||
|
background: var(--sf-surface, #fff);
|
||||||
|
color: inherit;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 12.5px;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover:not(:disabled) {
|
||||||
|
border-color: var(--sf-warn-dot, #f79009);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
cursor: progress;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.test-mode-banner {
|
||||||
|
padding: 8px 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.test-mode-banner__text {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -1,32 +1,42 @@
|
|||||||
<template>
|
<template>
|
||||||
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
<div :class="classObj" class="app-wrapper" :style="{ '--current-color': theme }">
|
||||||
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
<div v-if="device === 'mobile' && sidebar.opened" class="drawer-bg" @click="handleClickOutside"/>
|
||||||
<sidebar v-if="!sidebar.hide" class="sidebar-container" />
|
<sidebar v-if="!sidebar.hide" class="sidebar-container" @setLayout="setLayout" />
|
||||||
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
<div :class="{ hasTagsView: needTagsView, sidebarHide: sidebar.hide }" class="main-container">
|
||||||
<div :class="{ 'fixed-header': fixedHeader }">
|
<div ref="panelRef" class="sf-panel">
|
||||||
<navbar @setLayout="setLayout" />
|
<div :class="{ 'fixed-header': fixedHeader }" class="sf-panel__header">
|
||||||
|
<test-mode-banner />
|
||||||
|
<navbar @setLayout="setLayout" @openSearch="openCommandPalette" />
|
||||||
<tags-view v-if="needTagsView" />
|
<tags-view v-if="needTagsView" />
|
||||||
</div>
|
</div>
|
||||||
<app-main />
|
<app-main />
|
||||||
|
</div>
|
||||||
<settings ref="settingRef" />
|
<settings ref="settingRef" />
|
||||||
</div>
|
</div>
|
||||||
|
<command-palette ref="commandPaletteRef" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { useWindowSize } from '@vueuse/core'
|
import { useWindowSize } from '@vueuse/core'
|
||||||
import Sidebar from './components/Sidebar/index.vue'
|
import Sidebar from './components/Sidebar/index.vue'
|
||||||
|
import TestModeBanner from './components/TestModeBanner/index.vue'
|
||||||
|
import CommandPalette from './components/CommandPalette/index.vue'
|
||||||
import { AppMain, Navbar, Settings, TagsView } from './components'
|
import { AppMain, Navbar, Settings, TagsView } from './components'
|
||||||
import useAppStore from '@/store/modules/app'
|
import useAppStore from '@/store/modules/app'
|
||||||
import useSettingsStore from '@/store/modules/settings'
|
import useSettingsStore from '@/store/modules/settings'
|
||||||
|
|
||||||
|
const SHELL_CLASS = 'sf-shell'
|
||||||
|
|
||||||
|
const route = useRoute()
|
||||||
const settingsStore = useSettingsStore()
|
const settingsStore = useSettingsStore()
|
||||||
const theme = computed(() => settingsStore.theme);
|
const theme = computed(() => settingsStore.theme);
|
||||||
const sideTheme = computed(() => settingsStore.sideTheme);
|
|
||||||
const sidebar = computed(() => useAppStore().sidebar);
|
const sidebar = computed(() => useAppStore().sidebar);
|
||||||
const device = computed(() => useAppStore().device);
|
const device = computed(() => useAppStore().device);
|
||||||
const needTagsView = computed(() => settingsStore.tagsView);
|
const needTagsView = computed(() => settingsStore.tagsView);
|
||||||
const fixedHeader = computed(() => settingsStore.fixedHeader);
|
const fixedHeader = computed(() => settingsStore.fixedHeader);
|
||||||
|
const panelRef = ref(null)
|
||||||
|
const commandPaletteRef = ref(null)
|
||||||
|
|
||||||
const classObj = computed(() => ({
|
const classObj = computed(() => ({
|
||||||
hideSidebar: !sidebar.value.opened,
|
hideSidebar: !sidebar.value.opened,
|
||||||
@@ -53,14 +63,32 @@ watchEffect(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 内容面板是滚动容器,切换页面时回到顶部
|
||||||
|
watch(() => route.path, () => {
|
||||||
|
if (panelRef.value) panelRef.value.scrollTop = 0
|
||||||
|
})
|
||||||
|
|
||||||
function handleClickOutside() {
|
function handleClickOutside() {
|
||||||
useAppStore().closeSideBar({ withoutAnimation: false })
|
useAppStore().closeSideBar({ withoutAnimation: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function openCommandPalette() {
|
||||||
|
commandPaletteRef.value?.open()
|
||||||
|
}
|
||||||
|
|
||||||
const settingRef = ref(null);
|
const settingRef = ref(null);
|
||||||
function setLayout() {
|
function setLayout() {
|
||||||
settingRef.value.openSetting();
|
settingRef.value.openSetting();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 工作区设计令牌只作用于登录后的布局,登录页等独立页面不受影响
|
||||||
|
onMounted(() => {
|
||||||
|
document.documentElement.classList.add(SHELL_CLASS)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
document.documentElement.classList.remove(SHELL_CLASS)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style lang="scss" scoped>
|
<style lang="scss" scoped>
|
||||||
@@ -72,6 +100,7 @@ function setLayout() {
|
|||||||
position: relative;
|
position: relative;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
|
background: var(--sf-ground, #f5f6f7);
|
||||||
|
|
||||||
&.mobile.openSidebar {
|
&.mobile.openSidebar {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
@@ -79,14 +108,42 @@ function setLayout() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.main-container:has(.fixed-header) {
|
.main-container {
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
overflow: hidden;
|
height: 100dvh;
|
||||||
|
padding: 8px 8px 8px 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebarHide.main-container {
|
||||||
|
padding-left: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-panel {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--sf-border, #e7e9ec);
|
||||||
|
border-radius: var(--sf-radius-xl, 14px);
|
||||||
|
background: var(--sf-surface, #fff);
|
||||||
|
// 面板内的页面底色即白色表面
|
||||||
|
--el-bg-color-page: var(--sf-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.sf-panel__header {
|
||||||
|
background: var(--sf-surface, #fff);
|
||||||
|
}
|
||||||
|
|
||||||
|
.fixed-header {
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
z-index: 9;
|
||||||
}
|
}
|
||||||
|
|
||||||
.drawer-bg {
|
.drawer-bg {
|
||||||
background: #000;
|
background: #0f1115;
|
||||||
opacity: 0.3;
|
opacity: 0.32;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
@@ -94,24 +151,14 @@ function setLayout() {
|
|||||||
z-index: 999;
|
z-index: 999;
|
||||||
}
|
}
|
||||||
|
|
||||||
.fixed-header {
|
.mobile {
|
||||||
position: fixed;
|
.main-container {
|
||||||
top: 0;
|
padding: 0;
|
||||||
right: 0;
|
|
||||||
z-index: 9;
|
|
||||||
width: calc(100% - #{vars.$base-sidebar-width});
|
|
||||||
transition: width 0.28s;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.hideSidebar .fixed-header {
|
.sf-panel {
|
||||||
width: calc(100% - 54px);
|
border: 0;
|
||||||
|
border-radius: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebarHide .fixed-header {
|
|
||||||
width: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
.mobile .fixed-header {
|
|
||||||
width: 100%;
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
@@ -44,6 +44,10 @@ import ImagePreview from "@/components/ImagePreview"
|
|||||||
// 字典标签组件
|
// 字典标签组件
|
||||||
import DictTag from '@/components/DictTag'
|
import DictTag from '@/components/DictTag'
|
||||||
import MasterExcelActions from '@/components/MasterExcelActions'
|
import MasterExcelActions from '@/components/MasterExcelActions'
|
||||||
|
// 页面标题与托盘档案链接
|
||||||
|
import PageHeader from '@/components/PageHeader'
|
||||||
|
import PalletCodeLink from '@/components/PalletCodeLink'
|
||||||
|
import DocNo from '@/components/DocNo'
|
||||||
|
|
||||||
const app = createApp(App)
|
const app = createApp(App)
|
||||||
|
|
||||||
@@ -67,6 +71,9 @@ app.component('ImagePreview', ImagePreview)
|
|||||||
app.component('RightToolbar', RightToolbar)
|
app.component('RightToolbar', RightToolbar)
|
||||||
app.component('Editor', Editor)
|
app.component('Editor', Editor)
|
||||||
app.component('MasterExcelActions', MasterExcelActions)
|
app.component('MasterExcelActions', MasterExcelActions)
|
||||||
|
app.component('PageHeader', PageHeader)
|
||||||
|
app.component('PalletCodeLink', PalletCodeLink)
|
||||||
|
app.component('DocNo', DocNo)
|
||||||
|
|
||||||
app.use(router)
|
app.use(router)
|
||||||
app.use(store)
|
app.use(store)
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ export const constantRoutes = [
|
|||||||
path: '/index',
|
path: '/index',
|
||||||
component: () => import('@/views/dashboard/index'),
|
component: () => import('@/views/dashboard/index'),
|
||||||
name: 'Index',
|
name: 'Index',
|
||||||
meta: { title: '首页', icon: 'dashboard', affix: true }
|
meta: { title: '工作台', icon: 'dashboard', affix: true }
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
@@ -94,6 +94,23 @@ export const constantRoutes = [
|
|||||||
|
|
||||||
// 动态路由,基于用户权限动态去加载
|
// 动态路由,基于用户权限动态去加载
|
||||||
export const dynamicRoutes = [
|
export const dynamicRoutes = [
|
||||||
|
{
|
||||||
|
path: '/pallet',
|
||||||
|
component: Layout,
|
||||||
|
hidden: true,
|
||||||
|
// 面包屑显示“托盘追溯 / 托盘档案”,点击返回追溯页
|
||||||
|
redirect: '/businessReport/palletTrace',
|
||||||
|
meta: { title: '托盘追溯' },
|
||||||
|
permissions: ['report:pallettrace:list'],
|
||||||
|
children: [
|
||||||
|
{
|
||||||
|
path: 'passport',
|
||||||
|
component: () => import('@/views/pallet/passport/index'),
|
||||||
|
name: 'PalletPassport',
|
||||||
|
meta: { title: '托盘档案', activeMenu: '/businessReport/palletTrace' }
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/system/user-auth',
|
path: '/system/user-auth',
|
||||||
component: Layout,
|
component: Layout,
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
|
* 侧边栏主题 深色主题theme-dark,浅色主题theme-light
|
||||||
*/
|
*/
|
||||||
sideTheme: 'theme-dark',
|
sideTheme: 'theme-light',
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 是否系统布局配置
|
* 是否系统布局配置
|
||||||
@@ -22,7 +22,7 @@ export default {
|
|||||||
/**
|
/**
|
||||||
* 是否显示 tagsView
|
* 是否显示 tagsView
|
||||||
*/
|
*/
|
||||||
tagsView: true,
|
tagsView: false,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 显示页签图标
|
* 显示页签图标
|
||||||
|
|||||||
@@ -15,6 +15,8 @@ const useUserStore = defineStore(
|
|||||||
name: '',
|
name: '',
|
||||||
nickName: '',
|
nickName: '',
|
||||||
avatar: '',
|
avatar: '',
|
||||||
|
deptName: '',
|
||||||
|
roleNames: [],
|
||||||
admin: false,
|
admin: false,
|
||||||
roles: [],
|
roles: [],
|
||||||
permissions: []
|
permissions: []
|
||||||
@@ -55,6 +57,8 @@ const useUserStore = defineStore(
|
|||||||
this.name = user.userName
|
this.name = user.userName
|
||||||
this.nickName = user.nickName
|
this.nickName = user.nickName
|
||||||
this.avatar = avatar
|
this.avatar = avatar
|
||||||
|
this.deptName = user.dept?.deptName || ''
|
||||||
|
this.roleNames = (Array.isArray(user.role) ? user.role : []).map(role => role?.roleName).filter(Boolean)
|
||||||
this.admin = user.admin === true
|
this.admin = user.admin === true
|
||||||
/* 初始密码提示 */
|
/* 初始密码提示 */
|
||||||
if(res.isDefaultModifyPwd) {
|
if(res.isDefaultModifyPwd) {
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
// 后端单号与溯源号为“前缀-32 位随机串”,列表中保留首尾 4 位便于人工核对,完整值放在 title 中
|
||||||
|
export function shortDocNo(value) {
|
||||||
|
const text = String(value ?? '')
|
||||||
|
const match = text.match(/^([A-Z]{2,4}-)([0-9a-f]{20,})$/i)
|
||||||
|
if (!match) return text
|
||||||
|
const body = match[2].toUpperCase()
|
||||||
|
return `${match[1]}${body.slice(0, 4)}…${body.slice(-4)}`
|
||||||
|
}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
import router from '@/router'
|
||||||
|
import { flowStateMeta } from '@/utils/palletPassport'
|
||||||
|
|
||||||
|
// 流转阶段对应的胶囊样式,与工作台和托盘档案保持同一套颜色
|
||||||
|
const PHASE_CLASS = { warehouse: 'is-wh', transit: 'is-transit', site: 'is-site', return: 'is-return' }
|
||||||
|
|
||||||
|
export function flowPillClass(flowState) {
|
||||||
|
return PHASE_CLASS[flowStateMeta(flowState).phase] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatCount(value) {
|
||||||
|
return Number.isFinite(Number(value)) ? Number(value).toLocaleString('zh-CN') : '—'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function daysSince(value, now = new Date()) {
|
||||||
|
if (!value) return null
|
||||||
|
const time = new Date(value).getTime()
|
||||||
|
if (Number.isNaN(time)) return null
|
||||||
|
return Math.max(0, Math.floor((now.getTime() - time) / 86400000))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 人名取末字、机构名取首字,作为头像占位
|
||||||
|
export function personInitial(user) {
|
||||||
|
const name = String(user?.nickName || user?.userName || '').trim()
|
||||||
|
return name ? name.slice(-1).toUpperCase() : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
export function orgInitial(name) {
|
||||||
|
const text = String(name || '').trim()
|
||||||
|
return text ? text.slice(0, 1).toUpperCase() : '?'
|
||||||
|
}
|
||||||
|
|
||||||
|
const AVATAR_TINTS = ['#00909F', '#6A7FE8', '#C2410C', '#8E6BD9', '#0F766E', '#3549A8', '#9A3A05', '#5B616E']
|
||||||
|
|
||||||
|
export function avatarTint(seed) {
|
||||||
|
const key = Number(seed) || String(seed || '').split('').reduce((sum, char) => sum + char.charCodeAt(0), 0)
|
||||||
|
return AVATAR_TINTS[Math.abs(key) % AVATAR_TINTS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function userDisplayName(user) {
|
||||||
|
return user?.nickName || user?.userName || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
// 基础资料菜单由后端动态下发,按路由末段查找实际路径;当前用户没有该菜单时返回空串
|
||||||
|
export function masterRoutePath(segment) {
|
||||||
|
const route = router.getRoutes().find((item) => {
|
||||||
|
const parts = item.path.split('/').filter(Boolean)
|
||||||
|
return parts.length >= 2 && parts[parts.length - 1] === segment && !item.redirect
|
||||||
|
})
|
||||||
|
return route?.path || ''
|
||||||
|
}
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
import { watch } from 'vue'
|
||||||
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
|
|
||||||
|
// 基础资料页之间的跳转意图:客户详情跳到现场、产线、隧道页时带上上级 ID 与动作。
|
||||||
|
// 页面只在自身路由上响应;带 action 的一次性意图处理后从地址栏移除,刷新不会重复触发。
|
||||||
|
export function positiveId(value) {
|
||||||
|
const id = Number(value)
|
||||||
|
return Number.isInteger(id) && id > 0 ? id : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useMasterRouteIntent(handler) {
|
||||||
|
const route = useRoute()
|
||||||
|
const router = useRouter()
|
||||||
|
const ownPath = route.path
|
||||||
|
|
||||||
|
function consume({ initial = false } = {}) {
|
||||||
|
if (route.path !== ownPath) return
|
||||||
|
const { action, ...rest } = route.query
|
||||||
|
handler({ ...route.query }, { initial })
|
||||||
|
if (action) router.replace({ path: ownPath, query: rest })
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(() => route.fullPath, () => consume())
|
||||||
|
return consume
|
||||||
|
}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// 托盘档案页入口与流转状态的统一定义
|
||||||
|
export const PALLET_PASSPORT_PATH = '/pallet/passport'
|
||||||
|
|
||||||
|
export function palletPassportLocation(code) {
|
||||||
|
return { path: PALLET_PASSPORT_PATH, query: { code: String(code || '').trim() } }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 托盘流转状态:按业务阶段分组,phase 决定圆点颜色
|
||||||
|
export const FLOW_STATES = [
|
||||||
|
{ value: 'UNSTOCKED', label: '未入库', phase: 'idle' },
|
||||||
|
{ value: 'EMPTY_IN_WAREHOUSE', label: '仓库空托盘', short: '仓库空托', phase: 'warehouse' },
|
||||||
|
{ value: 'FILLED_IN_WAREHOUSE', label: '仓库有料', phase: 'warehouse' },
|
||||||
|
{ value: 'IN_TRANSIT', label: '在途待收货', phase: 'transit' },
|
||||||
|
{ value: 'AT_SITE_UNUSED', label: '现场未使用', phase: 'site' },
|
||||||
|
{ value: 'AT_SITE_PARTIAL', label: '现场部分使用', phase: 'site' },
|
||||||
|
{ value: 'AT_SITE_EMPTY', label: '现场空托盘', short: '现场空托', phase: 'site' },
|
||||||
|
{ value: 'RETURNING', label: '退回中', phase: 'return' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export const FLOW_PHASES = [
|
||||||
|
{ value: 'warehouse', label: '仓库', states: ['UNSTOCKED', 'EMPTY_IN_WAREHOUSE', 'FILLED_IN_WAREHOUSE'] },
|
||||||
|
{ value: 'transit', label: '运输', states: ['IN_TRANSIT'] },
|
||||||
|
{ value: 'site', label: '客户现场', states: ['AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY'] },
|
||||||
|
{ value: 'return', label: '回收', states: ['RETURNING'] }
|
||||||
|
]
|
||||||
|
|
||||||
|
export function flowStateMeta(value) {
|
||||||
|
return FLOW_STATES.find((item) => item.value === value) || { value, label: value || '未知状态', phase: 'idle' }
|
||||||
|
}
|
||||||
|
|
||||||
|
export function flowStateLabel(value) {
|
||||||
|
return flowStateMeta(value).label
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
// 托盘号按“前缀 + 末尾数字”拆分:连续编号合并成段,便于与实物标签逐段核对;
|
||||||
|
// 批量粘贴时把编号区间展开成逐个托盘号,仍由后端逐个校验。
|
||||||
|
|
||||||
|
export const PASTE_RANGE_LIMIT = 200
|
||||||
|
export const PASTE_TOTAL_LIMIT = 500
|
||||||
|
|
||||||
|
const CODE_PATTERN = /^(.*?)(\d+)$/
|
||||||
|
|
||||||
|
export function splitPalletCode(code) {
|
||||||
|
const text = String(code ?? '').trim()
|
||||||
|
const match = CODE_PATTERN.exec(text)
|
||||||
|
if (!match) return null
|
||||||
|
const number = Number(match[2])
|
||||||
|
if (!Number.isSafeInteger(number)) return null
|
||||||
|
return { prefix: match[1], digits: match[2], number }
|
||||||
|
}
|
||||||
|
|
||||||
|
// 相邻编号位数相同(含补零),或都没有补零(如 99 → 100)时视为连续
|
||||||
|
function isNextCode(previous, current) {
|
||||||
|
if (current.prefix !== previous.prefix || current.number !== previous.number + 1) return false
|
||||||
|
if (current.digits.length === previous.digits.length) return true
|
||||||
|
return String(previous.number) === previous.digits && String(current.number) === current.digits
|
||||||
|
}
|
||||||
|
|
||||||
|
function rangeLabel(first, last) {
|
||||||
|
return first === last ? first.code : `${first.code} – ${last.digits}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupCodeRanges(codes) {
|
||||||
|
const seen = new Set()
|
||||||
|
const parsed = []
|
||||||
|
const loose = []
|
||||||
|
for (const raw of codes || []) {
|
||||||
|
const code = String(raw ?? '').trim()
|
||||||
|
if (!code || seen.has(code)) continue
|
||||||
|
seen.add(code)
|
||||||
|
const parts = splitPalletCode(code)
|
||||||
|
if (parts) parsed.push({ ...parts, code })
|
||||||
|
else loose.push(code)
|
||||||
|
}
|
||||||
|
parsed.sort((left, right) =>
|
||||||
|
left.prefix.localeCompare(right.prefix) || left.number - right.number || left.digits.length - right.digits.length
|
||||||
|
)
|
||||||
|
|
||||||
|
const ranges = []
|
||||||
|
let first = null
|
||||||
|
let last = null
|
||||||
|
let count = 0
|
||||||
|
const flush = () => {
|
||||||
|
if (!first) return
|
||||||
|
ranges.push({ key: first.code, from: first.code, to: last.code, count, label: rangeLabel(first, last) })
|
||||||
|
}
|
||||||
|
for (const item of parsed) {
|
||||||
|
if (last && isNextCode(last, item)) {
|
||||||
|
last = item
|
||||||
|
count += 1
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
first = item
|
||||||
|
last = item
|
||||||
|
count = 1
|
||||||
|
}
|
||||||
|
flush()
|
||||||
|
for (const code of loose.sort()) {
|
||||||
|
ranges.push({ key: code, from: code, to: code, count: 1, label: code })
|
||||||
|
}
|
||||||
|
return ranges
|
||||||
|
}
|
||||||
|
|
||||||
|
// 区间的结束端可以只写数字(10512-10518),也可以写完整托盘号(TP-10512~TP-10518)
|
||||||
|
function parseRangeEnd(start, endText) {
|
||||||
|
if (/^\d+$/.test(endText)) {
|
||||||
|
const number = Number(endText)
|
||||||
|
return Number.isSafeInteger(number) ? number : null
|
||||||
|
}
|
||||||
|
const end = splitPalletCode(endText)
|
||||||
|
return end && end.prefix === start.prefix ? end.number : null
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildRange(startText, endText) {
|
||||||
|
const start = splitPalletCode(startText)
|
||||||
|
if (!start) return null
|
||||||
|
const endNumber = parseRangeEnd(start, endText.trim())
|
||||||
|
if (endNumber === null || endNumber <= start.number) return null
|
||||||
|
return { start, endNumber }
|
||||||
|
}
|
||||||
|
|
||||||
|
// “~”与全角连接符一定表示区间;“-”同时出现在托盘号里,只有左侧以数字结尾且右侧能构成递增区间时才按区间理解
|
||||||
|
function readRange(token) {
|
||||||
|
const tildeIndex = token.indexOf('~')
|
||||||
|
if (tildeIndex > 0) {
|
||||||
|
return buildRange(token.slice(0, tildeIndex), token.slice(tildeIndex + 1)) || { invalid: true }
|
||||||
|
}
|
||||||
|
for (let index = token.indexOf('-'); index > 0; index = token.indexOf('-', index + 1)) {
|
||||||
|
if (!/\d/.test(token[index - 1])) continue
|
||||||
|
const range = buildRange(token.slice(0, index), token.slice(index + 1))
|
||||||
|
if (range) return range
|
||||||
|
}
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function parsePasteTokens(text, { rangeLimit = PASTE_RANGE_LIMIT, totalLimit = PASTE_TOTAL_LIMIT } = {}) {
|
||||||
|
const normalized = String(text ?? '').replace(/\s*[~~–—]\s*/g, '~')
|
||||||
|
const tokens = normalized.split(/[\s,,、;;]+/).map((token) => token.trim()).filter(Boolean)
|
||||||
|
const codes = []
|
||||||
|
const seen = new Set()
|
||||||
|
const errors = []
|
||||||
|
let duplicates = 0
|
||||||
|
let truncated = false
|
||||||
|
|
||||||
|
const push = (code) => {
|
||||||
|
if (seen.has(code)) {
|
||||||
|
duplicates += 1
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if (codes.length >= totalLimit) {
|
||||||
|
truncated = true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
seen.add(code)
|
||||||
|
codes.push(code)
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const token of tokens) {
|
||||||
|
if (truncated) break
|
||||||
|
const range = readRange(token)
|
||||||
|
if (range?.invalid) {
|
||||||
|
errors.push({ token, message: '区间写法无法识别,请写成 10512-10518' })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if (!range) {
|
||||||
|
push(token)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const count = range.endNumber - range.start.number + 1
|
||||||
|
if (count > rangeLimit) {
|
||||||
|
errors.push({ token, message: `区间包含 ${count} 个编号,单个区间最多 ${rangeLimit} 个` })
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
const width = range.start.digits.length
|
||||||
|
for (let number = range.start.number; number <= range.endNumber; number += 1) {
|
||||||
|
if (!push(`${range.start.prefix}${String(number).padStart(width, '0')}`)) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return { codes, errors, duplicates, truncated }
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,14 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-customer">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header title="客户与现场">
|
||||||
<el-form-item label="客户编码" prop="customerCode">
|
<template #actions>
|
||||||
<el-input v-model="queryParams.customerCode" placeholder="请输入客户编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:customer:add']">新增客户</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="customer" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="客户概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">客户</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">{{ disabledCustomerCount === undefined ? '合作中与已停用' : `合作中 ${formatCount(Math.max(0, total - disabledCustomerCount))} · 已停用 ${formatCount(disabledCustomerCount)}` }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页现场</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.sites) }}</span>
|
||||||
|
<span class="mp-kpi__hint">产线 {{ formatCount(pageTotals.lines) }} 条 · 隧道 {{ formatCount(pageTotals.tunnels) }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页在用托盘</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.pallets) }}</span>
|
||||||
|
<span class="mp-kpi__hint">另有在途 {{ formatCount(pageTotals.transit) }} 托</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">未建现场的客户</span>
|
||||||
|
<span class="mp-kpi__value" :class="{ 'is-warn': pageTotals.withoutSite }">{{ formatCount(pageTotals.withoutSite) }}</span>
|
||||||
|
<span class="mp-kpi__hint">建好现场后才能向客户发货</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="customerName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.customerName" placeholder="搜索客户名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="客户名称" prop="customerName">
|
<el-form-item label="编码" prop="customerCode">
|
||||||
<el-input v-model="queryParams.customerName" placeholder="请输入客户名称" clearable maxlength="100" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.customerCode" placeholder="客户编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
|
||||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -16,39 +54,53 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:customer:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:customer:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:customer:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:customer:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span class="mp-filter__summary">{{ formatCount(total) }} 个客户 · 本页 {{ formatCount(pageSiteCount) }} 个现场</span>
|
||||||
</el-form>
|
</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:customer:add']">新增</el-button></el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
|
|
||||||
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
|
|
||||||
@click="handleUpdate()" v-hasPermi="['master:customer: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>
|
|
||||||
<master-excel-actions resource="customer" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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="customerList" @selection-change="handleSelectionChange">
|
<div class="mp-workspace" :class="{ 'is-wide-panel': panelRow }">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<div class="mp-workspace__list">
|
||||||
<el-table-column label="客户ID" prop="customerId" align="center" />
|
<el-table
|
||||||
<el-table-column label="客户编码" prop="customerCode" align="center" :show-overflow-tooltip="true" />
|
class="master-table data-table"
|
||||||
<el-table-column label="客户名称" prop="customerName" align="center" :show-overflow-tooltip="true" />
|
v-loading="loading"
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
empty-text="暂无数据"
|
||||||
|
:data="customerList"
|
||||||
|
:row-class-name="customerRowClass"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
|
<el-table-column label="客户" min-width="200">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell">
|
||||||
|
<span class="mp-initial" :class="{ 'is-off': scope.row.status === '1' }">{{ orgInitial(scope.row.customerName) }}</span>
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong>{{ scope.row.customerName }}</strong>
|
||||||
|
<small><span class="mp-mono">{{ scope.row.customerCode }}</span> · {{ scope.row.siteCount ?? 0 }} 个现场</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="在用托盘" width="96" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="customer-pallets">
|
||||||
|
<strong class="mp-num">{{ formatCount(scope.row.sitePalletCount ?? 0) }}</strong>
|
||||||
|
<small v-if="scope.row.transitPalletCount">在途 {{ formatCount(scope.row.transitPalletCount) }}</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="!panelRow" label="层级" min-width="170">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-count-cell"><span><strong>{{ scope.row.lineCount ?? 0 }}</strong> 条产线</span><span><strong>{{ scope.row.tunnelCount ?? 0 }}</strong> 条隧道</span></span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="!panelRow" label="授权用户" width="96" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ scope.row.userCount ?? 0 }} 人</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column v-if="isMasterWriteAllowed && !panelRow" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<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">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
|
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
|
||||||
@@ -62,40 +114,160 @@
|
|||||||
v-show="total > 0" :total="total"
|
v-show="total > 0" :total="total"
|
||||||
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
|
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
|
||||||
/>
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="panelRow" class="mp-panel customer-panel" aria-label="客户详情">
|
||||||
|
<div class="mp-panel__head">
|
||||||
|
<span class="mp-initial is-lg" :class="{ 'is-off': panelRow.status === '1' }">{{ orgInitial(panelRow.customerName) }}</span>
|
||||||
|
<div class="mp-panel__title">
|
||||||
|
<span class="mp-panel__name">
|
||||||
|
{{ panelRow.customerName }}
|
||||||
|
<span class="mp-mono mp-muted">{{ panelRow.customerCode }}</span>
|
||||||
|
<span class="mp-pill" :class="panelRow.status === '1' ? '' : 'is-ok'">{{ panelRow.status === '1' ? '停用' : '正常合作' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="mp-panel__sub">{{ panelRow.remark || '暂无备注' }}</span>
|
||||||
|
</div>
|
||||||
|
<span class="customer-panel__actions">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-button
|
||||||
|
v-if="proxy.$auth.hasPermiAnd(['master:customer:query', 'master:customer:edit'])"
|
||||||
|
icon="Edit" :disabled="detailLoading" @click="handleUpdate(panelRow)" v-hasPermi="['master:customer:edit']"
|
||||||
|
>编辑客户</el-button>
|
||||||
|
</template>
|
||||||
|
<el-button v-if="sitePath && isMasterWriteAllowed" icon="Plus" @click="goMaster('site', { customerId: panelRow.customerId, customerName: panelRow.customerName, action: 'add' })">新增现场</el-button>
|
||||||
|
<button type="button" class="mp-panel__close" aria-label="关闭详情" @click="closePanel">×</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="panelLoading" class="mp-panel__body">
|
||||||
|
<div class="mp-stats customer-panel__stats">
|
||||||
|
<div><span>现场</span><strong>{{ panelTotals.sites }}</strong></div>
|
||||||
|
<div><span>产线</span><strong>{{ panelTotals.lines }}</strong></div>
|
||||||
|
<div><span>隧道</span><strong>{{ panelTotals.tunnels }}</strong></div>
|
||||||
|
<div><span>在用托盘</span><strong>{{ formatCount(panelTotals.pallets) }}</strong></div>
|
||||||
|
<div><span>授权用户</span><strong>{{ panelTotals.users }}</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<template v-if="canQueryCustomer">
|
||||||
|
<div class="mp-section__head">
|
||||||
|
<strong>现场</strong>
|
||||||
|
<button v-if="panelSites.length" type="button" class="mp-link" @click="toggleAllSites">{{ allSitesExpanded ? '全部收起' : '全部展开' }}</button>
|
||||||
|
</div>
|
||||||
|
<p v-if="!panelLoading && !panelSites.length" class="mp-empty">还没有现场。现场是发货的目的地,新增后即可安排发货。</p>
|
||||||
|
<section v-for="site in panelSites" :key="site.siteId" class="customer-site" :class="{ 'is-open': expandedSites.includes(site.siteId) }">
|
||||||
|
<button type="button" class="customer-site__head" :aria-expanded="expandedSites.includes(site.siteId)" @click="toggleSite(site.siteId)">
|
||||||
|
<span class="customer-site__chevron" aria-hidden="true">›</span>
|
||||||
|
<span class="customer-site__title">
|
||||||
|
<span>
|
||||||
|
<strong>{{ site.siteName }}</strong>
|
||||||
|
<span class="mp-mono mp-muted">{{ site.siteCode }}</span>
|
||||||
|
<span v-if="site.status === '1'" class="mp-pill is-outline">停用</span>
|
||||||
|
<span v-if="site.emptyPalletCount" class="mp-pill is-warn">空托 {{ site.emptyPalletCount }} 待退回</span>
|
||||||
|
</span>
|
||||||
|
<small>{{ site.address || '未登记地址' }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="mp-count-cell customer-site__counts">
|
||||||
|
<span>{{ site.lines.length }} 条产线</span>
|
||||||
|
<span>{{ site.tunnels.length }} 条隧道</span>
|
||||||
|
<span><strong>{{ sitePalletCount(site) }}</strong> 托在用</span>
|
||||||
|
</span>
|
||||||
|
<span class="mp-avatars" :aria-label="`授权用户 ${site.users.length} 人`">
|
||||||
|
<span v-for="user in site.users.slice(0, 3)" :key="user.userId" class="mp-avatar" :style="{ '--mp-avatar': avatarTint(user.userId) }" :title="userDisplayName(user)">{{ personInitial(user) }}</span>
|
||||||
|
<span v-if="site.users.length > 3" class="mp-avatar is-more">+{{ site.users.length - 3 }}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<div v-if="expandedSites.includes(site.siteId)" class="customer-site__body">
|
||||||
|
<div class="customer-site__col">
|
||||||
|
<span class="customer-site__col-head"><strong>产线</strong><button v-if="linePath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('line', { siteId: site.siteId, siteName: site.siteName, action: 'add' })">+ 新增</button></span>
|
||||||
|
<span v-for="line in site.lines" :key="line.lineId" class="customer-site__row">
|
||||||
|
<span>{{ line.lineName }}</span>
|
||||||
|
<span class="mp-mono mp-muted">{{ line.lineCode }}</span>
|
||||||
|
<span class="mp-muted mp-num customer-site__note">{{ line.status === '1' ? '停用' : line.activePalletCount ? `${line.activePalletCount} 托在线` : '空闲' }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-if="!site.lines.length" class="mp-muted customer-site__none">未登记产线</span>
|
||||||
|
</div>
|
||||||
|
<div class="customer-site__col">
|
||||||
|
<span class="customer-site__col-head"><strong>隧道</strong><button v-if="tunnelPath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('tunnel', { siteId: site.siteId, siteName: site.siteName, action: 'add' })">+ 新增</button></span>
|
||||||
|
<span v-for="tunnel in site.tunnels" :key="tunnel.tunnelId" class="customer-site__tunnel">
|
||||||
|
<span class="customer-site__row">
|
||||||
|
<span class="mp-mono">{{ tunnel.tunnelCode }}</span>
|
||||||
|
<span>{{ tunnel.tunnelName }}</span>
|
||||||
|
<span class="mp-pill customer-site__note" :class="tunnel.currentTraceNo ? 'is-site' : ''">{{ tunnel.status === '1' ? '停用' : tunnel.currentTraceNo ? '有物料' : '空闲' }}</span>
|
||||||
|
</span>
|
||||||
|
<small v-if="tunnel.currentTraceNo" class="mp-muted mp-num">溯源号 <span class="mp-mono">{{ shortTrace(tunnel.currentTraceNo) }}</span><template v-if="tunnel.currentTraceStartedAt"> · {{ parseTime(tunnel.currentTraceStartedAt, '{m}-{d}') }} 起</template> · {{ tunnel.activePalletCount }} 托</small>
|
||||||
|
</span>
|
||||||
|
<span v-if="!site.tunnels.length" class="mp-muted customer-site__none">未登记隧道</span>
|
||||||
|
</div>
|
||||||
|
<div class="customer-site__col">
|
||||||
|
<span class="customer-site__col-head"><strong>现场托盘</strong><span class="mp-muted mp-num">剩余 {{ formatCount(site.remainingPackageQty) }} 包</span></span>
|
||||||
|
<span class="mp-stack" role="img" :aria-label="`未使用 ${site.unusedPalletCount} 托,部分使用 ${site.partialPalletCount} 托,空托 ${site.emptyPalletCount} 托`">
|
||||||
|
<span :style="{ flex: site.unusedPalletCount, background: 'var(--sf-site)' }"></span>
|
||||||
|
<span :style="{ flex: site.partialPalletCount, background: 'var(--sf-transit)' }"></span>
|
||||||
|
<span :style="{ flex: site.emptyPalletCount, background: 'var(--sf-neutral-dot)' }"></span>
|
||||||
|
</span>
|
||||||
|
<span class="mp-legend">
|
||||||
|
<span><i style="background: var(--sf-site)"></i>未使用<b>{{ site.unusedPalletCount }}</b></span>
|
||||||
|
<span><i style="background: var(--sf-transit)"></i>部分使用<b>{{ site.partialPalletCount }}</b></span>
|
||||||
|
<span><i style="background: var(--sf-neutral-dot)"></i>空托<b>{{ site.emptyPalletCount }}</b></span>
|
||||||
|
</span>
|
||||||
|
<small v-if="site.transitPalletCount" class="mp-muted mp-num">另有 {{ site.transitPalletCount }} 托在途</small>
|
||||||
|
<span class="customer-site__users">
|
||||||
|
<span class="mp-muted">授权用户 {{ site.users.length ? site.users.map(userDisplayName).join('、') : '未分配' }}</span>
|
||||||
|
<button v-if="sitePath && isMasterWriteAllowed" type="button" class="mp-link" @click="goMaster('site', { siteId: site.siteId, siteName: site.siteName, action: 'assign' })">分配</button>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
<p v-else class="mp-empty">需要客户查询权限才能查看现场、产线与隧道层级。</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'customer'" :resource-ids="selectedIds" @success="getList" />
|
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'customer'" :resource-ids="selectedIds" @success="getList" />
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(520px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">客户是现场的上级。先建客户,再在客户下添加现场,现场才能作为发货目的地。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="customerRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="customerRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
|
<div class="sf-form-grid">
|
||||||
<el-form-item label="客户编码" prop="customerCode">
|
<el-form-item label="客户编码" prop="customerCode">
|
||||||
<el-input v-model="form.customerCode" placeholder="请输入客户编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.customerCode" class="sf-form-mono" placeholder="如 C-0012" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">全局唯一,导入现场时用它关联客户</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="客户名称" prop="customerName">
|
<el-form-item label="客户名称" prop="customerName">
|
||||||
<el-input v-model="form.customerName" placeholder="请输入客户名称" maxlength="100" show-word-limit />
|
<el-input v-model="form.customerName" placeholder="对外的客户全称或简称" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">发货单、报表与客户详情中显示</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="form.status">
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,例如结算方式、对接人" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.customerId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:customer:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:customer:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!form.customerId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:customer:add']">创建客户</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:customer:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -103,17 +275,41 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { margin-right:0; width:100%; }
|
.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); }
|
||||||
.master-query :deep(.el-form-item__content) { flex:1; min-width:0; }
|
.customer-pallets { display:inline-flex; flex-direction:column; align-items:flex-end; line-height:1.3; }
|
||||||
.master-query :deep(.el-input), .master-query :deep(.el-input-number), .master-query :deep(.el-select) { width:100% !important; min-width:0; }
|
.customer-pallets strong { font-weight:600; }
|
||||||
.master-query :deep(.el-form-item:last-child) { align-self:end; }
|
.customer-pallets small { color:var(--sf-text-4, #6b717d); font-size:12px; }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; }
|
.customer-panel__actions { display:flex; flex:none; align-items:center; gap:8px; }
|
||||||
.master-toolbar :deep(.el-col) { width:auto; }
|
.customer-panel__actions .el-button + .el-button { margin-left:0; }
|
||||||
.master-table :deep(.el-table__header th) { background:var(--el-fill-color-light); font-weight:600; }
|
.master-customer .customer-panel__stats.mp-stats { grid-template-columns:repeat(5, minmax(0, 1fr)); }
|
||||||
.master-table :deep(.el-table__cell) { padding:10px 0; }
|
.customer-site { overflow:hidden; border:1px solid var(--sf-border, #e7e9ec); border-radius:10px; }
|
||||||
.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); }
|
.customer-site__head { display:flex; align-items:center; gap:12px; width:100%; padding:12px 14px; border:0; background:transparent; color:inherit; font:inherit; text-align:left; cursor:pointer; }
|
||||||
@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; } }
|
.customer-site__head:hover { background:var(--sf-surface-2, #f7f8f9); }
|
||||||
|
.customer-site__chevron { flex:none; width:12px; color:var(--sf-text-4, #6b717d); font-size:18px; line-height:1; transition:transform .15s ease; }
|
||||||
|
.customer-site.is-open .customer-site__chevron { transform:rotate(90deg); }
|
||||||
|
.customer-site__title { display:flex; flex:1; flex-direction:column; gap:2px; min-width:0; }
|
||||||
|
.customer-site__title > span { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
|
||||||
|
.customer-site__title strong { font-size:14px; font-weight:600; }
|
||||||
|
.customer-site__title small { overflow:hidden; color:var(--sf-text-4, #6b717d); font-size:12.5px; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
.customer-site__counts { flex:none; }
|
||||||
|
.customer-site__body { display:grid; grid-template-columns:repeat(auto-fit, minmax(220px, 1fr)); gap:16px 24px; padding:4px 16px 16px 38px; }
|
||||||
|
.customer-site__col { display:flex; flex-direction:column; gap:8px; min-width:0; }
|
||||||
|
.customer-site__col-head { display:flex; align-items:baseline; justify-content:space-between; gap:8px; font-size:12.5px; }
|
||||||
|
.customer-site__col-head strong { color:var(--sf-text-3, #5b616e); font-size:12.5px; font-weight:500; }
|
||||||
|
.customer-site__row { display:flex; align-items:center; gap:8px; min-width:0; font-size:13px; }
|
||||||
|
.customer-site__note { margin-left:auto; font-size:12px; }
|
||||||
|
.customer-site__tunnel { display:flex; flex-direction:column; gap:2px; }
|
||||||
|
.customer-site__tunnel small, .customer-site__col > small { font-size:12px; }
|
||||||
|
.customer-site__none { font-size:12.5px; }
|
||||||
|
.customer-site__users { display:flex; align-items:baseline; justify-content:space-between; gap:8px; padding-top:8px; border-top:1px solid var(--sf-border-soft, #eef0f2); font-size:12.5px; }
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.master-customer .customer-panel__stats.mp-stats { grid-template-columns:repeat(3, minmax(0, 1fr)); }
|
||||||
|
.customer-site__head { flex-wrap:wrap; }
|
||||||
|
.customer-site__counts { width:100%; padding-left:24px; }
|
||||||
|
.customer-site__body { padding-left:16px; }
|
||||||
|
.mp-panel__head { flex-wrap:wrap; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Customer">
|
<script setup name="Customer">
|
||||||
@@ -121,6 +317,7 @@ import { addCustomer, getCustomer, listCustomer, updateCustomer } from "@/api/ma
|
|||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { avatarTint, formatCount, masterRoutePath, orgInitial, personInitial, userDisplayName } from '@/utils/masterDisplay'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -142,6 +339,50 @@ const total = ref(0);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '可以在客户下新增现场', '1': '不能再新增现场;已有现场与历史单据保留' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
|
||||||
|
const panelRow = ref(null)
|
||||||
|
const panelDetail = ref(null)
|
||||||
|
const panelLoading = ref(false)
|
||||||
|
const expandedSites = ref([])
|
||||||
|
let panelGeneration = 0
|
||||||
|
const canQueryCustomer = computed(() => proxy.$auth.hasPermiAnd(['master:customer:query']))
|
||||||
|
const sitePath = computed(() => masterRoutePath('site'))
|
||||||
|
const linePath = computed(() => masterRoutePath('line'))
|
||||||
|
const tunnelPath = computed(() => masterRoutePath('tunnel'))
|
||||||
|
const pageSiteCount = computed(() => customerList.value.reduce((sum, row) => sum + (row.siteCount || 0), 0))
|
||||||
|
const disabledCustomerCount = ref(undefined)
|
||||||
|
let statusCountGeneration = 0
|
||||||
|
const pageTotals = computed(() => customerList.value.reduce((totals, row) => {
|
||||||
|
totals.sites += row.siteCount || 0
|
||||||
|
totals.lines += row.lineCount || 0
|
||||||
|
totals.tunnels += row.tunnelCount || 0
|
||||||
|
totals.pallets += row.sitePalletCount || 0
|
||||||
|
totals.transit += row.transitPalletCount || 0
|
||||||
|
if (!row.siteCount && row.status !== '1') totals.withoutSite += 1
|
||||||
|
return totals
|
||||||
|
}, { sites: 0, lines: 0, tunnels: 0, pallets: 0, transit: 0, withoutSite: 0 }))
|
||||||
|
const panelSites = computed(() => panelDetail.value?.sites || [])
|
||||||
|
const allSitesExpanded = computed(() => panelSites.value.length > 0 && panelSites.value.every((site) => expandedSites.value.includes(site.siteId)))
|
||||||
|
const panelTotals = computed(() => {
|
||||||
|
const row = panelRow.value || {}
|
||||||
|
if (!panelDetail.value) {
|
||||||
|
return { sites: row.siteCount ?? 0, lines: row.lineCount ?? 0, tunnels: row.tunnelCount ?? 0, pallets: row.sitePalletCount ?? 0, users: row.userCount ?? 0 }
|
||||||
|
}
|
||||||
|
const users = new Set(panelSites.value.flatMap((site) => site.users.map((user) => user.userId)))
|
||||||
|
return {
|
||||||
|
sites: panelSites.value.length,
|
||||||
|
lines: panelSites.value.reduce((sum, site) => sum + site.lines.length, 0),
|
||||||
|
tunnels: panelSites.value.reduce((sum, site) => sum + site.tunnels.length, 0),
|
||||||
|
pallets: panelSites.value.reduce((sum, site) => sum + sitePalletCount(site), 0),
|
||||||
|
users: users.size
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
if (typeof value !== "string" || value.trim() === "") {
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
@@ -179,6 +420,8 @@ function getList() {
|
|||||||
}
|
}
|
||||||
customerList.value = response.rows;
|
customerList.value = response.rows;
|
||||||
total.value = response.total;
|
total.value = response.total;
|
||||||
|
syncPanel();
|
||||||
|
loadDisabledCount(listContext.query);
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!isCurrentListRequest(listContext)) {
|
if (!isCurrentListRequest(listContext)) {
|
||||||
return;
|
return;
|
||||||
@@ -196,6 +439,7 @@ function getList() {
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
formMeta.value = null;
|
||||||
form.value = { customerId: undefined, customerCode: "", customerName: "", status: "0", remark: "" };
|
form.value = { customerId: undefined, customerCode: "", customerName: "", status: "0", remark: "" };
|
||||||
proxy.resetForm("customerRef");
|
proxy.resetForm("customerRef");
|
||||||
}
|
}
|
||||||
@@ -246,6 +490,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
customerId: detail.customerId,
|
customerId: detail.customerId,
|
||||||
customerCode: detail.customerCode,
|
customerCode: detail.customerCode,
|
||||||
@@ -304,5 +549,89 @@ function submitUpdate() {
|
|||||||
}).finally(() => { submitLoading.value = false; });
|
}).finally(() => { submitLoading.value = false; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 停用客户数随当前筛选条件变化;已按状态筛选时无需再查
|
||||||
|
function loadDisabledCount(query) {
|
||||||
|
const generation = ++statusCountGeneration
|
||||||
|
if (query.status) {
|
||||||
|
disabledCustomerCount.value = query.status === '1' ? total.value : 0
|
||||||
|
return
|
||||||
|
}
|
||||||
|
listCustomer({ ...query, pageNum: 1, pageSize: 1, status: '1' }).then((response) => {
|
||||||
|
if (generation === statusCountGeneration) disabledCustomerCount.value = response.total
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation === statusCountGeneration) disabledCustomerCount.value = undefined
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 客户详情:现场层级 ----------
|
||||||
|
function customerRowClass({ row }) {
|
||||||
|
return row.customerId === panelRow.value?.customerId ? 'is-panel-row' : ''
|
||||||
|
}
|
||||||
|
function handleRowClick(row, column, event) {
|
||||||
|
if (column?.type === 'selection' || event?.target?.closest?.('button, a, .el-checkbox')) return
|
||||||
|
openPanel(row)
|
||||||
|
}
|
||||||
|
// 列表刷新后保留当前客户;首次进入时默认打开第一位客户
|
||||||
|
function syncPanel() {
|
||||||
|
const current = panelRow.value && customerList.value.find((row) => row.customerId === panelRow.value.customerId)
|
||||||
|
if (current) {
|
||||||
|
panelRow.value = current
|
||||||
|
loadPanelDetail(current, { keepExpanded: true })
|
||||||
|
} else if (customerList.value.length && window.matchMedia?.('(min-width: 1024px)').matches !== false) {
|
||||||
|
openPanel(customerList.value[0])
|
||||||
|
} else {
|
||||||
|
closePanel()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function openPanel(row) {
|
||||||
|
panelRow.value = row
|
||||||
|
loadPanelDetail(row)
|
||||||
|
}
|
||||||
|
function closePanel() {
|
||||||
|
panelGeneration += 1
|
||||||
|
panelRow.value = null
|
||||||
|
panelDetail.value = null
|
||||||
|
panelLoading.value = false
|
||||||
|
}
|
||||||
|
function loadPanelDetail(row, { keepExpanded = false } = {}) {
|
||||||
|
const generation = ++panelGeneration
|
||||||
|
if (!keepExpanded) {
|
||||||
|
panelDetail.value = null
|
||||||
|
expandedSites.value = []
|
||||||
|
}
|
||||||
|
if (!canQueryCustomer.value) return
|
||||||
|
panelLoading.value = true
|
||||||
|
getCustomer(row.customerId).then((response) => {
|
||||||
|
if (generation !== panelGeneration) return
|
||||||
|
panelDetail.value = response.data || null
|
||||||
|
if (!keepExpanded && panelSites.value.length) expandedSites.value = [panelSites.value[0].siteId]
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation !== panelGeneration) return
|
||||||
|
panelDetail.value = null
|
||||||
|
}).finally(() => {
|
||||||
|
if (generation === panelGeneration) panelLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function toggleSite(siteId) {
|
||||||
|
expandedSites.value = expandedSites.value.includes(siteId)
|
||||||
|
? expandedSites.value.filter((id) => id !== siteId)
|
||||||
|
: [...expandedSites.value, siteId]
|
||||||
|
}
|
||||||
|
function toggleAllSites() {
|
||||||
|
expandedSites.value = allSitesExpanded.value ? [] : panelSites.value.map((site) => site.siteId)
|
||||||
|
}
|
||||||
|
function sitePalletCount(site) {
|
||||||
|
return (site.unusedPalletCount || 0) + (site.partialPalletCount || 0) + (site.emptyPalletCount || 0)
|
||||||
|
}
|
||||||
|
function shortTrace(traceNo) {
|
||||||
|
const text = String(traceNo || '')
|
||||||
|
return text.length > 14 ? `${text.slice(0, 6)}…${text.slice(-4)}` : text
|
||||||
|
}
|
||||||
|
// 现场、产线、隧道由各自页面维护,这里带着上级 ID 跳转过去
|
||||||
|
function goMaster(segment, query) {
|
||||||
|
const path = masterRoutePath(segment)
|
||||||
|
if (path) proxy.$router.push({ path, query })
|
||||||
|
}
|
||||||
getList();
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-line">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
|
<template #actions>
|
||||||
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:line:add']">新增产线</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="line" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="产线概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">产线</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">{{ routeFilter.siteName ? `现场:${routeFilter.siteName}` : '全部现场' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页在线托盘</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.pallets) }}</span>
|
||||||
|
<span class="mp-kpi__hint">当前登记在这些产线上的托盘</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页近 30 天用量</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.packages) }}<small>包</small></span>
|
||||||
|
<span class="mp-kpi__hint">共 {{ formatCount(pageTotals.usages) }} 次使用登记</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">近 30 天未使用</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.idle) }}</span>
|
||||||
|
<span class="mp-kpi__hint">本页正常状态但近期没有使用登记的产线</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="lineName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.lineName" placeholder="搜索产线名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="编码" prop="lineCode">
|
||||||
|
<el-input v-model="queryParams.lineCode" placeholder="产线编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="现场ID" prop="siteId">
|
<el-form-item label="现场ID" prop="siteId">
|
||||||
<el-input-number v-model="queryParams.siteId" :min="1" :step="1" step-strictly controls-position="right" placeholder="请输入现场ID" />
|
<el-input-number v-model="queryParams.siteId" :min="1" :step="1" step-strictly :controls="false" placeholder="全部" style="width: 110px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="产线编码" prop="lineCode">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-input v-model="queryParams.lineCode" placeholder="请输入产线编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="产线名称" prop="lineName">
|
|
||||||
<el-input v-model="queryParams.lineName" 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-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -19,40 +57,48 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:line:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:line:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:line:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:line:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span v-if="queryParams.siteId && routeFilter.siteName" class="master-filter-chip">
|
||||||
|
现场:{{ routeFilter.siteName }}
|
||||||
|
<button type="button" aria-label="清除现场筛选" @click="clearRouteFilter">×</button>
|
||||||
|
</span>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 条产线</span>
|
||||||
</el-form>
|
</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:line:add']">新增</el-button></el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:line:query', 'master:line:edit'])"
|
|
||||||
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
|
|
||||||
@click="handleUpdate()" v-hasPermi="['master:line: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>
|
|
||||||
<master-excel-actions resource="line" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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="lineList" @selection-change="handleSelectionChange">
|
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="lineList" :row-class-name="lineRowClass" @selection-change="handleSelectionChange">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column label="产线ID" prop="lineId" align="center" />
|
<el-table-column label="产线" min-width="170">
|
||||||
<el-table-column label="现场ID" prop="siteId" align="center" />
|
<template #default="scope">
|
||||||
<el-table-column label="产线编码" prop="lineCode" align="center" :show-overflow-tooltip="true" />
|
<span class="mp-name-cell__text">
|
||||||
<el-table-column label="产线名称" prop="lineName" align="center" :show-overflow-tooltip="true" />
|
<strong>{{ scope.row.lineName }}</strong>
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
<small class="mp-mono">{{ scope.row.lineCode }}</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="所属现场" min-width="190" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong>{{ scope.row.siteName || `现场 #${scope.row.siteId}` }}</strong>
|
||||||
|
<small>{{ scope.row.customerName || '—' }}<template v-if="scope.row.siteCode"> · <span class="mp-mono">{{ scope.row.siteCode }}</span></template></small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="在线托盘" width="96" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ scope.row.activePalletCount ?? 0 }} 托</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="近 30 天" min-width="130" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.usageCount30d" class="mp-num">{{ scope.row.usageCount30d }} 次<span class="mp-muted"> · {{ formatCount(scope.row.usedPackageQty30d) }} 包</span></span>
|
||||||
|
<span v-else class="mp-muted">未使用</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近使用" width="120">
|
||||||
|
<template #default="scope"><span class="mp-num" :class="{ 'mp-muted': !scope.row.lastUsedAt }">{{ scope.row.lastUsedAt ? parseTime(scope.row.lastUsedAt, '{m}-{d} {h}:{i}') : '—' }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column v-if="isMasterWriteAllowed" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<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">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:line:query', 'master:line:edit'])"
|
v-if="proxy.$auth.hasPermiAnd(['master:line:query', 'master:line:edit'])"
|
||||||
@@ -71,38 +117,48 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(520px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">产线隶属于客户现场,使用登记时按产线统计用量。产线编码在同一现场下唯一。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="lineRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="lineRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
<el-form-item label="现场ID" prop="siteId">
|
<el-form-item label="所属现场" prop="siteId">
|
||||||
<el-input-number v-model="form.siteId" :min="1" :step="1" step-strictly controls-position="right" />
|
<master-ref-select v-model="form.siteId" kind="现场" :options="siteOptions" :loading="siteOptionLoading" :failed="siteOptionFailed" :current-label="formSiteName" placeholder="搜索现场名称或编码" />
|
||||||
|
<div class="sf-form-hint">只列出状态正常且所属客户正常的现场</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="sf-form-grid">
|
||||||
<el-form-item label="产线编码" prop="lineCode">
|
<el-form-item label="产线编码" prop="lineCode">
|
||||||
<el-input v-model="form.lineCode" placeholder="请输入产线编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.lineCode" class="sf-form-mono" placeholder="如 L-01" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">同一现场下唯一</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="产线名称" prop="lineName">
|
<el-form-item label="产线名称" prop="lineName">
|
||||||
<el-input v-model="form.lineName" placeholder="请输入产线名称" maxlength="100" show-word-limit />
|
<el-input v-model="form.lineName" placeholder="如 A 线" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">使用登记与产线消耗报表中显示</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="form.status">
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.lineId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:line:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:line:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!form.lineId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:line:add']">创建产线</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:line:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -110,18 +166,21 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
|
||||||
.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); }
|
.master-filter-chip { display:inline-flex; align-items:center; gap:6px; height:28px; margin:0 0 10px; padding:0 6px 0 10px; border-radius:7px; background:var(--sf-accent-soft, #e6f3f5); color:var(--sf-accent-strong, #006a78); font-size:12.5px; }
|
||||||
@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; } }
|
.master-filter-chip button { width:20px; height:20px; border:0; border-radius:5px; background:transparent; color:inherit; font-size:15px; line-height:1; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Line">
|
<script setup name="Line">
|
||||||
import { addLine, getLine, listLine, updateLine } from "@/api/master/line";
|
import { addLine, getLine, getLineSiteOptions, listLine, updateLine } from "@/api/master/line";
|
||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
|
import MasterRefSelect from '@/components/MasterRefSelect'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { formatCount } from '@/utils/masterDisplay'
|
||||||
|
import { positiveId, useMasterRouteIntent } from '@/utils/masterRouteIntent'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -143,6 +202,15 @@ const total = ref(0);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '现场使用登记时可以选择', '1': '使用登记不能再选择该产线;历史记录保留' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
|
||||||
|
const routeFilter = ref({ siteName: '' })
|
||||||
|
const routeSiteNames = ref({})
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
const labels = { lineCode: "产线编码", lineName: "产线名称" };
|
const labels = { lineCode: "产线编码", lineName: "产线名称" };
|
||||||
@@ -172,6 +240,21 @@ const data = reactive({
|
|||||||
});
|
});
|
||||||
const { form, queryParams, rules } = toRefs(data);
|
const { form, queryParams, rules } = toRefs(data);
|
||||||
|
|
||||||
|
const pageTotals = computed(() => lineList.value.reduce((totals, row) => {
|
||||||
|
totals.pallets += row.activePalletCount || 0
|
||||||
|
totals.usages += row.usageCount30d || 0
|
||||||
|
totals.packages += row.usedPackageQty30d || 0
|
||||||
|
if (!row.usageCount30d && row.status !== '1') totals.idle += 1
|
||||||
|
return totals
|
||||||
|
}, { pallets: 0, usages: 0, packages: 0, idle: 0 }))
|
||||||
|
const formSiteName = computed(() => {
|
||||||
|
const names = { ...routeSiteNames.value }
|
||||||
|
lineList.value.forEach((row) => {
|
||||||
|
if (row.siteName) names[row.siteId] = [row.customerName, row.siteName].filter(Boolean).join(' · ')
|
||||||
|
})
|
||||||
|
return names[form.value.siteId] || ''
|
||||||
|
})
|
||||||
|
|
||||||
function isCurrentListRequest(listContext) {
|
function isCurrentListRequest(listContext) {
|
||||||
return listContext.generation === listGeneration;
|
return listContext.generation === listGeneration;
|
||||||
}
|
}
|
||||||
@@ -204,6 +287,7 @@ function getList() {
|
|||||||
}
|
}
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
formMeta.value = null;
|
||||||
form.value = { lineId: undefined, siteId: undefined, lineCode: "", lineName: "", status: "0", remark: "" };
|
form.value = { lineId: undefined, siteId: undefined, lineCode: "", lineName: "", status: "0", remark: "" };
|
||||||
proxy.resetForm("lineRef");
|
proxy.resetForm("lineRef");
|
||||||
}
|
}
|
||||||
@@ -227,7 +311,7 @@ function handleDialogBeforeClose(done) {
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function handleQuery() { queryParams.value.pageNum = 1; getList(); }
|
function handleQuery() { queryParams.value.pageNum = 1; getList(); }
|
||||||
function resetQuery() { proxy.resetForm("queryRef"); handleQuery(); }
|
function resetQuery() { proxy.resetForm("queryRef"); routeFilter.value = { siteName: '' }; handleQuery(); }
|
||||||
function handleSelectionChange(selection) {
|
function handleSelectionChange(selection) {
|
||||||
selectedIds.value = selection.map((item) => item.lineId);
|
selectedIds.value = selection.map((item) => item.lineId);
|
||||||
single.value = selectedIds.value.length !== 1;
|
single.value = selectedIds.value.length !== 1;
|
||||||
@@ -237,7 +321,7 @@ function openAdoptionDialog() { adoptionDialogRef.value?.open() }
|
|||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
invalidateDetailRequest();
|
invalidateDetailRequest();
|
||||||
reset();
|
reset();
|
||||||
title.value = "新增生产线";
|
title.value = "新增产线";
|
||||||
open.value = true;
|
open.value = true;
|
||||||
}
|
}
|
||||||
function handleUpdate(row = {}) {
|
function handleUpdate(row = {}) {
|
||||||
@@ -253,6 +337,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
lineId: detail.lineId,
|
lineId: detail.lineId,
|
||||||
siteId: detail.siteId,
|
siteId: detail.siteId,
|
||||||
@@ -261,7 +346,7 @@ function handleUpdate(row = {}) {
|
|||||||
status: detail.status,
|
status: detail.status,
|
||||||
remark: detail.remark || ""
|
remark: detail.remark || ""
|
||||||
};
|
};
|
||||||
title.value = "修改生产线";
|
title.value = "修改产线";
|
||||||
open.value = true;
|
open.value = true;
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!isCurrentDetailRequest(detailContext)) {
|
if (!isCurrentDetailRequest(detailContext)) {
|
||||||
@@ -313,5 +398,53 @@ function submitUpdate() {
|
|||||||
}).finally(() => { submitLoading.value = false; });
|
}).finally(() => { submitLoading.value = false; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function lineRowClass({ row }) {
|
||||||
|
return row.status === '1' ? 'is-muted-row' : ''
|
||||||
|
}
|
||||||
|
function clearRouteFilter() {
|
||||||
|
queryParams.value.siteId = undefined
|
||||||
|
routeFilter.value = { siteName: '' }
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
// 从客户详情跳转:按现场筛选,可直接打开新增并预填现场
|
||||||
|
// 表单里的上级现场选项:每次打开弹窗时刷新,加载失败时退回为直接输入 ID
|
||||||
|
const siteOptions = ref([]);
|
||||||
|
const siteOptionLoading = ref(false);
|
||||||
|
const siteOptionFailed = ref(false);
|
||||||
|
let siteOptionGeneration = 0;
|
||||||
|
function loadSiteOptions() {
|
||||||
|
const generation = ++siteOptionGeneration;
|
||||||
|
siteOptionLoading.value = true;
|
||||||
|
siteOptionFailed.value = false;
|
||||||
|
getLineSiteOptions().then((response) => {
|
||||||
|
if (generation !== siteOptionGeneration) return;
|
||||||
|
siteOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation !== siteOptionGeneration) return;
|
||||||
|
siteOptions.value = [];
|
||||||
|
siteOptionFailed.value = true;
|
||||||
|
}).finally(() => {
|
||||||
|
if (generation === siteOptionGeneration) siteOptionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
watch(open, (visible) => {
|
||||||
|
if (visible) loadSiteOptions();
|
||||||
|
});
|
||||||
|
const consumeRouteIntent = useMasterRouteIntent((intent, { initial }) => {
|
||||||
|
const siteId = positiveId(intent.siteId)
|
||||||
|
const changed = Boolean(siteId) && siteId !== queryParams.value.siteId
|
||||||
|
if (siteId) {
|
||||||
|
queryParams.value.siteId = siteId
|
||||||
|
routeFilter.value = { siteName: String(intent.siteName || '') }
|
||||||
|
if (intent.siteName) routeSiteNames.value = { ...routeSiteNames.value, [siteId]: String(intent.siteName) }
|
||||||
|
}
|
||||||
|
if (intent.action === 'add' && isMasterWriteAllowed.value && proxy.$auth.hasPermiAnd(['master:line:add'])) {
|
||||||
|
handleAdd()
|
||||||
|
form.value.siteId = siteId
|
||||||
|
}
|
||||||
|
if (!initial && changed) handleQuery()
|
||||||
|
})
|
||||||
|
consumeRouteIntent({ initial: true })
|
||||||
getList();
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,51 +1,114 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-pallet">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
<el-form-item label="托盘编码" prop="palletCode">
|
<template #actions>
|
||||||
<el-input v-model="queryParams.palletCode" placeholder="请输入托盘编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
</el-form-item>
|
<template v-if="isMasterWriteAllowed">
|
||||||
<el-form-item label="资产状态" prop="assetStatus">
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
<el-select v-model="queryParams.assetStatus" placeholder="请选择资产状态" clearable style="width: 160px">
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
</el-col>
|
||||||
</el-select>
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:pallet:add']">登记托盘</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="pallet" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="托盘资产概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">托盘资产</span>
|
||||||
|
<span class="mp-kpi__value">{{ countText('all') }}</span>
|
||||||
|
<span class="mp-kpi__hint">正常 {{ formatCount(normalCount) }} · 停用 {{ countText('disabled') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">在仓库</span>
|
||||||
|
<span class="mp-kpi__value">{{ countText('warehouse') }}</span>
|
||||||
|
<span class="mp-kpi__hint">可装填或直接发货</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">在外流转</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(outsideCount) }}</span>
|
||||||
|
<span class="mp-kpi__hint">在途 {{ countText('transit') }} · 客户现场 {{ countText('site') }} · 退回中 {{ countText('returning') }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">待首次入库</span>
|
||||||
|
<span class="mp-kpi__value">{{ countText('unstocked') }}</span>
|
||||||
|
<span class="mp-kpi__hint">新登记、尚未入库的托盘</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mp-tabs" role="tablist" aria-label="托盘状态">
|
||||||
|
<button
|
||||||
|
v-for="tab in PALLET_TABS"
|
||||||
|
:key="tab.key"
|
||||||
|
type="button"
|
||||||
|
role="tab"
|
||||||
|
class="mp-tab"
|
||||||
|
:class="{ 'is-active': activeTab === tab.key }"
|
||||||
|
:aria-selected="activeTab === tab.key"
|
||||||
|
@click="selectTab(tab.key)"
|
||||||
|
>
|
||||||
|
<span class="pallet-tab__dot" :style="{ background: tab.dot }" aria-hidden="true"></span>
|
||||||
|
{{ tab.label }}
|
||||||
|
<span class="mp-tab__count">{{ countText(tab.key) }}</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="palletCode" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.palletCode" placeholder="扫码,或输入托盘编码、二维码标识" prefix-icon="Search" clearable maxlength="64" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item>
|
<el-form-item>
|
||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:pallet:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:pallet:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:pallet:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:pallet:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 个托盘</span>
|
||||||
</el-form>
|
</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:pallet:add']">新增</el-button></el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:pallet:query', 'master:pallet:edit'])"
|
|
||||||
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
|
|
||||||
@click="handleUpdate()" v-hasPermi="['master:pallet: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>
|
|
||||||
<master-excel-actions resource="pallet" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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="palletList" @selection-change="handleSelectionChange">
|
<div class="mp-workspace">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<div class="mp-workspace__list">
|
||||||
<el-table-column label="托盘ID" prop="palletId" align="center" />
|
<el-table
|
||||||
<el-table-column label="托盘编码" prop="palletCode" align="center" :show-overflow-tooltip="true" />
|
ref="tableRef"
|
||||||
<el-table-column label="容量(包)" prop="capacityPackages" align="center" />
|
class="master-table data-table"
|
||||||
<el-table-column label="资产状态" prop="assetStatus" align="center" width="110">
|
v-loading="loading"
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.assetStatus" /></template>
|
empty-text="暂无数据"
|
||||||
|
:data="palletList"
|
||||||
|
:row-class-name="palletRowClass"
|
||||||
|
@row-click="handleRowClick"
|
||||||
|
@selection-change="handleSelectionChange"
|
||||||
|
>
|
||||||
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
|
<el-table-column label="托盘编码" prop="palletCode" min-width="120">
|
||||||
|
<template #default="scope"><button type="button" class="mp-code" @click.stop="openPanel(scope.row)">{{ scope.row.palletCode }}</button></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column label="当前状态" min-width="150">
|
||||||
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
|
<template #default="scope">
|
||||||
|
<span class="pallet-state">
|
||||||
|
<span class="mp-pill" :class="flowPillClass(scope.row.flowState)">{{ flowStateLabel(scope.row.flowState || 'UNSTOCKED') }}</span>
|
||||||
|
<span v-if="scope.row.assetStatus === '1'" class="mp-pill is-outline">已停用</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" width="250" class-name="small-padding fixed-width">
|
<el-table-column label="当前位置" min-width="200" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope">{{ palletLocation(scope.row) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="余量" width="96" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-meter">
|
||||||
|
<span>{{ scope.row.remainingPackages ?? 0 }} / {{ scope.row.capacityPackages }}</span>
|
||||||
|
<span class="mp-meter__bar"><span :style="{ width: remainingPercent(scope.row) }"></span></span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="!panelRow" label="二维码标识" prop="qrToken" min-width="150">
|
||||||
|
<template #default="scope"><span class="mp-mono mp-muted" :title="scope.row.qrToken">{{ shortToken(scope.row.qrToken) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="!panelRow" label="本轮 / 累计" width="116" align="right" class-name="nowrap-cell">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ cycleText(scope.row) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="isMasterWriteAllowed && !panelRow" label="操作" width="150" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
link type="primary" icon="Printer" :loading="isPrinting(scope.row.palletId)"
|
link type="primary" icon="Printer" :loading="isPrinting(scope.row.palletId)"
|
||||||
@@ -63,46 +126,150 @@
|
|||||||
v-show="total > 0" :total="total"
|
v-show="total > 0" :total="total"
|
||||||
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
|
v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<div v-if="selectedIds.length" class="mp-bulk" role="region" aria-label="批量操作">
|
||||||
|
<span class="mp-bulk__count">已选 {{ selectedIds.length }} 个托盘</span>
|
||||||
|
<el-button icon="Printer" :loading="batchPrinting" @click="printSelectedLabels" v-hasPermi="['master:pallet:print']">批量打印标签</el-button>
|
||||||
|
<el-button v-if="isMasterWriteAllowed" icon="CircleClose" :loading="bulkStatusLoading" @click="disableSelectedPallets" v-hasPermi="['master:pallet:edit']">停用</el-button>
|
||||||
|
<span class="mp-bulk__sep" aria-hidden="true"></span>
|
||||||
|
<el-button icon="Close" aria-label="取消选择" @click="clearSelection" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section v-if="panelRow" class="mp-panel pallet-panel" aria-label="托盘详情">
|
||||||
|
<div class="mp-panel__head">
|
||||||
|
<div class="mp-panel__title">
|
||||||
|
<span class="mp-panel__name">
|
||||||
|
<span class="sf-mono">{{ panelRow.palletCode }}</span>
|
||||||
|
<span class="mp-pill" :class="flowPillClass(panelView.flowState)">{{ flowStateLabel(panelView.flowState || 'UNSTOCKED') }}</span>
|
||||||
|
<span v-if="panelView.assetStatus === '1'" class="mp-pill is-outline">已停用</span>
|
||||||
|
</span>
|
||||||
|
<span class="mp-panel__sub">{{ palletLocation(panelView) }}</span>
|
||||||
|
</div>
|
||||||
|
<button type="button" class="mp-panel__close" aria-label="关闭详情" @click="closePanel">×</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-loading="panelLoading" class="mp-panel__body">
|
||||||
|
<section class="mp-section">
|
||||||
|
<div class="mp-section__head"><strong>标签预览</strong><span>70 × 50 mm · 热敏纸</span></div>
|
||||||
|
<div class="pallet-label">
|
||||||
|
<div class="pallet-label__paper">
|
||||||
|
<span class="pallet-label__qr">
|
||||||
|
<img v-if="labelImageUrl" :src="labelImageUrl" :alt="`托盘 ${panelRow.palletCode} 二维码`" />
|
||||||
|
<span v-else class="pallet-label__qr-empty">{{ canPrintPallet ? '二维码加载中' : '无打印权限' }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="pallet-label__text">
|
||||||
|
<img :src="sylvanLogo" alt="Sylvan" class="pallet-label__logo" />
|
||||||
|
<strong class="sf-mono">{{ panelRow.palletCode }}</strong>
|
||||||
|
<small>容量 {{ panelRow.capacityPackages }} 包</small>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="mp-stats">
|
||||||
|
<div><span>余量</span><strong>{{ panelView.remainingPackages ?? 0 }} / {{ panelRow.capacityPackages }}</strong></div>
|
||||||
|
<div><span>{{ isOutside(panelView.flowState) ? '本轮在外' : '本轮' }}</span><strong>{{ currentDays(panelView) ?? '—' }} 天</strong></div>
|
||||||
|
<div><span>累计周转</span><strong>{{ panelView.cycleCount ?? 0 }} 轮</strong></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl class="mp-dl">
|
||||||
|
<dt>二维码标识</dt><dd class="sf-mono">{{ panelRow.qrToken }}</dd>
|
||||||
|
<dt>资产状态</dt><dd><dict-tag :options="sys_normal_disable" :value="panelView.assetStatus" /></dd>
|
||||||
|
<template v-if="panelView.strainName">
|
||||||
|
<dt>当前物料</dt><dd>{{ panelView.strainName }}<span v-if="panelView.batchNo" class="mp-muted"> · {{ panelView.batchNo }}</span></dd>
|
||||||
|
<dt>有效期至</dt><dd class="mp-num">{{ panelView.expiryDate || '—' }}</dd>
|
||||||
|
</template>
|
||||||
|
<dt>登记时间</dt><dd class="mp-num">{{ parseTime(panelRow.createTime, '{y}-{m}-{d}') || '—' }}</dd>
|
||||||
|
<dt>备注</dt><dd>{{ panelView.remark || '—' }}</dd>
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<section class="mp-section">
|
||||||
|
<div class="mp-section__head">
|
||||||
|
<strong>最近流转</strong>
|
||||||
|
<router-link v-if="passportAvailable" :to="palletPassportLocation(panelRow.palletCode)">查看完整档案</router-link>
|
||||||
|
</div>
|
||||||
|
<ol v-if="panelEvents.length" class="mp-timeline">
|
||||||
|
<li v-for="event in panelEvents" :key="event.eventId" class="mp-timeline__item">
|
||||||
|
<span class="mp-timeline__rail" :style="{ '--mp-dot': eventColor(event) }" aria-hidden="true"></span>
|
||||||
|
<span class="mp-timeline__text">
|
||||||
|
<span><strong>{{ eventTitle(event) }}</strong><small>{{ parseTime(event.occurredAt, '{m}-{d} {h}:{i}') }}</small></span>
|
||||||
|
<p>{{ eventDetail(event) }}</p>
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
</ol>
|
||||||
|
<p v-else class="mp-muted pallet-panel__note">{{ canQueryPallet ? (panelLoading ? '加载中…' : '还没有流转记录,首次入库后开始记录。') : '需要托盘查询权限才能查看流转记录。' }}</p>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mp-panel__foot">
|
||||||
|
<el-button type="primary" icon="Printer" :loading="isPrinting(panelRow.palletId)" @click="handlePrint(panelRow)" v-hasPermi="['master:pallet:print']">打印标签</el-button>
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-button
|
||||||
|
v-if="proxy.$auth.hasPermiAnd(['master:pallet:query', 'master:pallet:edit'])"
|
||||||
|
icon="Edit" :disabled="detailLoading" @click="handleUpdate(panelRow)" v-hasPermi="['master:pallet:edit']"
|
||||||
|
>编辑</el-button>
|
||||||
|
<el-button
|
||||||
|
class="mp-panel__foot-end" :type="panelView.assetStatus === '1' ? 'default' : 'danger'" plain :loading="bulkStatusLoading"
|
||||||
|
title="停用后不可再入库、装填或发货,历史记录保留"
|
||||||
|
@click="togglePalletStatus(panelView)" v-hasPermi="['master:pallet:edit']"
|
||||||
|
>{{ panelView.assetStatus === '1' ? '恢复启用' : '停用托盘' }}</el-button>
|
||||||
|
</template>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
|
||||||
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'pallet'" :resource-ids="selectedIds" @success="getList" />
|
<test-batch-adoption-dialog ref="adoptionDialogRef" :root-type="'pallet'" :resource-ids="selectedIds" @success="getList" />
|
||||||
|
|
||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(560px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">{{ isEdit ? '托盘编码与二维码标识登记后固定不变,标签损坏时直接补打即可。' : '登记后系统生成固定的二维码标识,打印标签贴到托盘上,即可扫码入库。' }}</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="palletRef" :model="form" :rules="rules" label-width="100px">
|
<el-form ref="palletRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
<el-form-item v-if="!isEdit" label="托盘编码" prop="palletCode">
|
<el-form-item v-if="!isEdit" label="托盘编码" prop="palletCode">
|
||||||
<el-input v-model="form.palletCode" placeholder="请输入托盘编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.palletCode" class="sf-form-mono" placeholder="如 TP-10601,也可以用扫码枪录入" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">全局唯一,印在标签上供人工核对</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" label="托盘编码" prop="palletCode">
|
<div v-if="isEdit" class="sf-form-grid">
|
||||||
<el-input v-model="form.palletCode" readonly />
|
<el-form-item label="托盘编码" prop="palletCode">
|
||||||
|
<el-input v-model="form.palletCode" class="sf-form-mono" readonly />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" label="二维码标识">
|
<el-form-item label="二维码标识">
|
||||||
<el-input v-model="form.qrToken" readonly />
|
<el-input v-model="form.qrToken" class="sf-form-mono" readonly />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="容量(包)" prop="capacityPackages">
|
<el-form-item label="容量(包)" prop="capacityPackages">
|
||||||
<el-input-number v-model="form.capacityPackages" :min="1" :step="1" step-strictly controls-position="right" />
|
<div class="sf-form-fixed">
|
||||||
|
<strong>{{ form.capacityPackages }} 包</strong>
|
||||||
|
<small>{{ isEdit ? '托盘容量登记后不可修改' : '新托盘容量固定为 100 包' }}</small>
|
||||||
|
</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item v-if="isEdit" label="资产状态" prop="assetStatus">
|
<el-form-item v-if="isEdit" label="资产状态" prop="assetStatus">
|
||||||
<el-radio-group v-model="form.assetStatus">
|
<el-radio-group v-model="form.assetStatus" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,例如采购批次、维修记录" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!isEdit" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:pallet:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:pallet:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!isEdit" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:pallet:add']">登记托盘</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:pallet:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -110,11 +277,22 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.pallet-tab__dot { width:7px; height:7px; border-radius:50%; }
|
||||||
.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); }
|
.pallet-state { display:inline-flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||||
@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; } }
|
.pallet-label { display:flex; justify-content:center; padding:16px; border-radius:10px; background:var(--sf-surface-3, #f1f2f4); }
|
||||||
|
.pallet-label__paper { display:flex; gap:14px; width:280px; max-width:100%; padding:14px; border-radius:6px; background:#fff; color:#0f1115; box-shadow:0 1px 3px rgba(16,24,40,.12); }
|
||||||
|
.pallet-label__qr { display:inline-flex; flex:none; align-items:center; justify-content:center; width:104px; height:104px; }
|
||||||
|
.pallet-label__qr img { width:100%; height:100%; object-fit:contain; image-rendering:pixelated; }
|
||||||
|
.pallet-label__qr-empty { color:#6b717d; font-size:11px; text-align:center; }
|
||||||
|
.pallet-label__text { display:flex; flex:1; flex-direction:column; justify-content:space-between; min-width:0; }
|
||||||
|
.pallet-label__logo { align-self:flex-start; width:auto; height:18px; }
|
||||||
|
.pallet-label__text strong { overflow-wrap:anywhere; font-size:18px; font-weight:600; line-height:1.2; }
|
||||||
|
.pallet-label__text small { color:#3d4350; font-size:11px; }
|
||||||
|
.pallet-panel__note { margin:0; font-size:12.5px; }
|
||||||
|
.master-pallet .pallet-panel.mp-panel { flex-basis:384px; }
|
||||||
|
@media (max-width:768px) { .master-query :deep(.el-form-item:last-child) { margin-bottom:12px; } }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Pallet">
|
<script setup name="Pallet">
|
||||||
@@ -122,12 +300,16 @@ import { addPallet, getPallet, getPalletQrcode, listPallet, updatePallet } from
|
|||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import sylvanLogo from '@/assets/logo/sylvan-logo-light.svg'
|
||||||
|
import { PALLET_PASSPORT_PATH, flowStateLabel, flowStateMeta, palletPassportLocation } from '@/utils/palletPassport'
|
||||||
|
import { daysSince, flowPillClass, formatCount } from '@/utils/masterDisplay'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
const testBatchStore = useTestBatchStore()
|
const testBatchStore = useTestBatchStore()
|
||||||
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
|
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
|
||||||
const adoptionDialogRef = ref()
|
const adoptionDialogRef = ref()
|
||||||
|
const tableRef = ref()
|
||||||
const { sys_normal_disable } = proxy.useDict("sys_normal_disable");
|
const { sys_normal_disable } = proxy.useDict("sys_normal_disable");
|
||||||
const palletList = ref([]);
|
const palletList = ref([]);
|
||||||
const showSearch = ref(true);
|
const showSearch = ref(true);
|
||||||
@@ -144,6 +326,54 @@ const title = ref("");
|
|||||||
const printingIds = ref([]);
|
const printingIds = ref([]);
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '可以正常装填、发货与流转', '1': '装填、发货、收货、使用与退回都会被拒绝;历史记录保留' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
|
||||||
|
// 状态页签:按当前流转环节或资产状态筛选
|
||||||
|
const PALLET_TABS = [
|
||||||
|
{ key: 'all', label: '全部', dot: 'var(--sf-border-strong)' },
|
||||||
|
{ key: 'unstocked', label: '未入库', dot: 'var(--sf-neutral-dot)', flowGroup: 'unstocked' },
|
||||||
|
{ key: 'warehouse', label: '仓库', dot: 'var(--sf-accent)', flowGroup: 'warehouse' },
|
||||||
|
{ key: 'transit', label: '在途', dot: 'var(--sf-transit)', flowGroup: 'transit' },
|
||||||
|
{ key: 'site', label: '客户现场', dot: 'var(--sf-site)', flowGroup: 'site' },
|
||||||
|
{ key: 'returning', label: '退回中', dot: 'var(--sf-return)', flowGroup: 'returning' },
|
||||||
|
{ key: 'disabled', label: '已停用', dot: 'var(--sf-danger-dot)', assetStatus: '1' }
|
||||||
|
]
|
||||||
|
const OUTSIDE_STATES = ['IN_TRANSIT', 'AT_SITE_UNUSED', 'AT_SITE_PARTIAL', 'AT_SITE_EMPTY', 'RETURNING']
|
||||||
|
const EVENT_TITLES = {
|
||||||
|
INBOUND_CONFIRMED: '入库确认',
|
||||||
|
FILLING_CONFIRMED: '装填确认',
|
||||||
|
SHIPMENT_CONFIRMED: '发货确认',
|
||||||
|
SHIPMENT_RECEIVED: '收货核对',
|
||||||
|
TUNNEL_ENTERED: '入隧登记',
|
||||||
|
USAGE_RECORDED: '使用记录',
|
||||||
|
RETURN_REQUESTED: '退回申请',
|
||||||
|
RETURN_CONFIRMED: '退回确认'
|
||||||
|
}
|
||||||
|
const PHASE_COLORS = { warehouse: 'var(--sf-accent)', transit: 'var(--sf-transit)', site: 'var(--sf-site)', return: 'var(--sf-return)' }
|
||||||
|
const activeTab = ref('all')
|
||||||
|
const tabCounts = ref({})
|
||||||
|
const panelRow = ref(null)
|
||||||
|
const panelDetail = ref(null)
|
||||||
|
const panelLoading = ref(false)
|
||||||
|
const labelImageUrl = ref('')
|
||||||
|
const batchPrinting = ref(false)
|
||||||
|
const bulkStatusLoading = ref(false)
|
||||||
|
let countGeneration = 0
|
||||||
|
let panelGeneration = 0
|
||||||
|
let labelGeneration = 0
|
||||||
|
|
||||||
|
const canQueryPallet = computed(() => proxy.$auth.hasPermiAnd(['master:pallet:query']))
|
||||||
|
const canPrintPallet = computed(() => proxy.$auth.hasPermiAnd(['master:pallet:print']))
|
||||||
|
const passportAvailable = computed(() => proxy.$router.resolve(PALLET_PASSPORT_PATH).matched.some((record) => !record.path.includes(':pathMatch')))
|
||||||
|
const panelView = computed(() => ({ ...(panelRow.value || {}), ...(panelDetail.value || {}) }))
|
||||||
|
const panelEvents = computed(() => panelDetail.value?.recentEvents || [])
|
||||||
|
const normalCount = computed(() => Math.max(0, (tabCounts.value.all ?? 0) - (tabCounts.value.disabled ?? 0)))
|
||||||
|
const outsideCount = computed(() => ['transit', 'site', 'returning'].reduce((sum, key) => sum + (tabCounts.value[key] ?? 0), 0))
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
if (typeof value !== "string" || value.trim() === "") {
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
@@ -162,7 +392,7 @@ function validatePositiveInteger(rule, value, callback) {
|
|||||||
|
|
||||||
const data = reactive({
|
const data = reactive({
|
||||||
form: {},
|
form: {},
|
||||||
queryParams: { pageNum: 1, pageSize: 10, palletCode: undefined, assetStatus: undefined },
|
queryParams: { pageNum: 1, pageSize: 20, palletCode: undefined, assetStatus: undefined, flowGroup: undefined },
|
||||||
rules: {
|
rules: {
|
||||||
palletCode: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
|
palletCode: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
|
||||||
capacityPackages: [{ required: true, validator: validatePositiveInteger, trigger: "change" }],
|
capacityPackages: [{ required: true, validator: validatePositiveInteger, trigger: "change" }],
|
||||||
@@ -188,6 +418,8 @@ function getList() {
|
|||||||
}
|
}
|
||||||
palletList.value = response.rows;
|
palletList.value = response.rows;
|
||||||
total.value = response.total;
|
total.value = response.total;
|
||||||
|
syncPanelRow();
|
||||||
|
if (listContext.query.pageNum === 1) loadTabCounts();
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!isCurrentListRequest(listContext)) {
|
if (!isCurrentListRequest(listContext)) {
|
||||||
return;
|
return;
|
||||||
@@ -204,9 +436,32 @@ function getList() {
|
|||||||
}
|
}
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
form.value = { palletId: undefined, palletCode: "", qrToken: "", capacityPackages: 1, assetStatus: "0", remark: "" };
|
formMeta.value = null;
|
||||||
|
form.value = { palletId: undefined, palletCode: "", qrToken: "", capacityPackages: 100, assetStatus: "0", remark: "" };
|
||||||
proxy.resetForm("palletRef");
|
proxy.resetForm("palletRef");
|
||||||
}
|
}
|
||||||
|
// 页签计数不随搜索词变化,反映当前数据范围内的全部托盘
|
||||||
|
function loadTabCounts() {
|
||||||
|
const generation = ++countGeneration
|
||||||
|
Promise.all(PALLET_TABS.map((tab) =>
|
||||||
|
listPallet({ pageNum: 1, pageSize: 1, flowGroup: tab.flowGroup, assetStatus: tab.assetStatus })
|
||||||
|
.then((response) => [tab.key, response.total])
|
||||||
|
.catch(() => [tab.key, undefined])
|
||||||
|
)).then((entries) => {
|
||||||
|
if (generation !== countGeneration) return
|
||||||
|
tabCounts.value = Object.fromEntries(entries)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function countText(key) {
|
||||||
|
return tabCounts.value[key] === undefined ? '—' : formatCount(tabCounts.value[key])
|
||||||
|
}
|
||||||
|
function selectTab(key) {
|
||||||
|
const tab = PALLET_TABS.find((item) => item.key === key) || PALLET_TABS[0]
|
||||||
|
activeTab.value = tab.key
|
||||||
|
queryParams.value.flowGroup = tab.flowGroup
|
||||||
|
queryParams.value.assetStatus = tab.assetStatus
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
function invalidateDetailRequest() {
|
function invalidateDetailRequest() {
|
||||||
detailGeneration += 1;
|
detailGeneration += 1;
|
||||||
detailLoading.value = false;
|
detailLoading.value = false;
|
||||||
@@ -237,7 +492,7 @@ function openAdoptionDialog() { adoptionDialogRef.value?.open() }
|
|||||||
function handleAdd() {
|
function handleAdd() {
|
||||||
invalidateDetailRequest();
|
invalidateDetailRequest();
|
||||||
reset();
|
reset();
|
||||||
title.value = "新增托盘";
|
title.value = "登记托盘";
|
||||||
open.value = true;
|
open.value = true;
|
||||||
}
|
}
|
||||||
function handleUpdate(row = {}) {
|
function handleUpdate(row = {}) {
|
||||||
@@ -253,6 +508,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
palletId: detail.palletId,
|
palletId: detail.palletId,
|
||||||
palletCode: detail.palletCode,
|
palletCode: detail.palletCode,
|
||||||
@@ -279,6 +535,7 @@ function afterSubmit(response) {
|
|||||||
proxy.$modal.msgSuccess(response.msg || "操作成功");
|
proxy.$modal.msgSuccess(response.msg || "操作成功");
|
||||||
open.value = false;
|
open.value = false;
|
||||||
getList();
|
getList();
|
||||||
|
if (panelRow.value) loadPanelDetail(panelRow.value);
|
||||||
}
|
}
|
||||||
function submitCreate() {
|
function submitCreate() {
|
||||||
proxy.$refs.palletRef.validate((valid) => {
|
proxy.$refs.palletRef.validate((valid) => {
|
||||||
@@ -309,6 +566,240 @@ function submitUpdate() {
|
|||||||
}).finally(() => { submitLoading.value = false; });
|
}).finally(() => { submitLoading.value = false; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------- 列表展示 ----------
|
||||||
|
function palletRowClass({ row }) {
|
||||||
|
return [row.palletId === panelRow.value?.palletId ? 'is-panel-row' : '', row.assetStatus === '1' ? 'is-muted-row' : ''].filter(Boolean).join(' ')
|
||||||
|
}
|
||||||
|
function palletLocation(row) {
|
||||||
|
const site = [row.customerName, row.siteName].filter(Boolean).join(' · ')
|
||||||
|
const phase = flowStateMeta(row.flowState || 'UNSTOCKED').phase
|
||||||
|
if (!row.flowState || row.flowState === 'UNSTOCKED') return '新登记,待首次入库'
|
||||||
|
if (phase === 'warehouse') return row.warehouseName || '仓库'
|
||||||
|
if (phase === 'transit') return site ? `发往 ${site}` : '运输中'
|
||||||
|
if (phase === 'return') return `${site || '客户现场'} → ${row.returnWarehouseName || '仓库'}`
|
||||||
|
return [site, row.lineName, row.tunnelName].filter(Boolean).join(' / ') || '客户现场'
|
||||||
|
}
|
||||||
|
function remainingPercent(row) {
|
||||||
|
const capacity = Number(row.capacityPackages) || 0
|
||||||
|
return capacity ? `${Math.min(100, Math.round(((row.remainingPackages || 0) / capacity) * 100))}%` : '0%'
|
||||||
|
}
|
||||||
|
function shortToken(token) {
|
||||||
|
const text = String(token || '')
|
||||||
|
return text.length > 16 ? `${text.slice(0, 8)}…${text.slice(-4)}` : text || '—'
|
||||||
|
}
|
||||||
|
function isOutside(flowState) {
|
||||||
|
return OUTSIDE_STATES.includes(flowState)
|
||||||
|
}
|
||||||
|
// 在外的托盘按最近一次发货计算本轮天数,其余按最近一次状态变化
|
||||||
|
function currentDays(row) {
|
||||||
|
return daysSince(isOutside(row.flowState) ? row.lastShippedAt : row.lastEventAt)
|
||||||
|
}
|
||||||
|
function cycleText(row) {
|
||||||
|
const days = currentDays(row)
|
||||||
|
return `${days ?? '—'} 天 · ${row.cycleCount ?? 0} 轮`
|
||||||
|
}
|
||||||
|
function handleRowClick(row, column, event) {
|
||||||
|
if (column?.type === 'selection' || event?.target?.closest?.('button, a, .el-checkbox')) return
|
||||||
|
openPanel(row)
|
||||||
|
}
|
||||||
|
function clearSelection() {
|
||||||
|
tableRef.value?.clearSelection()
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 详情面板 ----------
|
||||||
|
function openPanel(row) {
|
||||||
|
if (panelRow.value?.palletId === row.palletId) {
|
||||||
|
closePanel()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
panelRow.value = row
|
||||||
|
loadPanelDetail(row)
|
||||||
|
loadLabelImage(row)
|
||||||
|
}
|
||||||
|
function closePanel() {
|
||||||
|
panelGeneration += 1
|
||||||
|
panelRow.value = null
|
||||||
|
panelDetail.value = null
|
||||||
|
panelLoading.value = false
|
||||||
|
releaseLabelImage()
|
||||||
|
}
|
||||||
|
function syncPanelRow() {
|
||||||
|
if (!panelRow.value) return
|
||||||
|
const fresh = palletList.value.find((row) => row.palletId === panelRow.value.palletId)
|
||||||
|
if (fresh) panelRow.value = fresh
|
||||||
|
}
|
||||||
|
function loadPanelDetail(row) {
|
||||||
|
const generation = ++panelGeneration
|
||||||
|
panelDetail.value = null
|
||||||
|
if (!canQueryPallet.value) return
|
||||||
|
panelLoading.value = true
|
||||||
|
getPallet(row.palletId).then((response) => {
|
||||||
|
if (generation !== panelGeneration) return
|
||||||
|
panelDetail.value = response.data || null
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation !== panelGeneration) return
|
||||||
|
panelDetail.value = null
|
||||||
|
}).finally(() => {
|
||||||
|
if (generation === panelGeneration) panelLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function releaseLabelImage() {
|
||||||
|
labelGeneration += 1
|
||||||
|
if (labelImageUrl.value) URL.revokeObjectURL(labelImageUrl.value)
|
||||||
|
labelImageUrl.value = ''
|
||||||
|
}
|
||||||
|
function loadLabelImage(row) {
|
||||||
|
releaseLabelImage()
|
||||||
|
if (!canPrintPallet.value) return
|
||||||
|
const generation = labelGeneration
|
||||||
|
getPalletQrcode(row.palletId).then((qrCodeBlob) => {
|
||||||
|
if (generation !== labelGeneration) return
|
||||||
|
const blob = qrCodeBlob instanceof Blob ? qrCodeBlob : new Blob([qrCodeBlob], { type: "image/png" })
|
||||||
|
labelImageUrl.value = URL.createObjectURL(blob)
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation === labelGeneration) labelImageUrl.value = ''
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function eventTitle(event) {
|
||||||
|
return EVENT_TITLES[event.eventType] || flowStateLabel(event.afterState)
|
||||||
|
}
|
||||||
|
function eventColor(event) {
|
||||||
|
return PHASE_COLORS[flowStateMeta(event.afterState).phase] || 'var(--sf-neutral-dot)'
|
||||||
|
}
|
||||||
|
function eventDetail(event) {
|
||||||
|
const site = [event.customerName, event.siteName].filter(Boolean).join(' · ') || '客户现场'
|
||||||
|
const used = (event.beforeRemainingPackages ?? 0) - (event.afterRemainingPackages ?? 0)
|
||||||
|
switch (event.eventType) {
|
||||||
|
case 'INBOUND_CONFIRMED': return `${event.warehouseName || '仓库'}空托入库`
|
||||||
|
case 'FILLING_CONFIRMED': return `${event.warehouseName || '仓库'}装填 ${event.afterRemainingPackages} 包`
|
||||||
|
case 'SHIPMENT_CONFIRMED': return `发往 ${site},${event.afterRemainingPackages} 包`
|
||||||
|
case 'SHIPMENT_RECEIVED': return `${site}扫码收货核对通过`
|
||||||
|
case 'TUNNEL_ENTERED': return `进入${event.tunnelName || '隧道'}${event.traceNo ? `,溯源号 ${shortToken(event.traceNo)}` : ''}`
|
||||||
|
case 'USAGE_RECORDED': return `${[event.lineName, event.tunnelName].filter(Boolean).join(' · ') || site}使用 ${used} 包,余 ${event.afterRemainingPackages} 包`
|
||||||
|
case 'RETURN_REQUESTED': return `${site}申请退回,余 ${event.afterRemainingPackages} 包`
|
||||||
|
case 'RETURN_CONFIRMED': return `退回至 ${event.warehouseName || '仓库'}`
|
||||||
|
default: return flowStateLabel(event.afterState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 停用与恢复 ----------
|
||||||
|
function confirmCancelled(error) {
|
||||||
|
return error === 'cancel' || error === 'close'
|
||||||
|
}
|
||||||
|
function togglePalletStatus(row) {
|
||||||
|
const nextStatus = row.assetStatus === '1' ? '0' : '1'
|
||||||
|
const message = nextStatus === '1'
|
||||||
|
? `停用托盘 ${row.palletCode}?停用后不可再入库、装填或发货,历史记录保留。`
|
||||||
|
: `恢复启用托盘 ${row.palletCode}?`
|
||||||
|
proxy.$modal.confirm(message).then(() => {
|
||||||
|
bulkStatusLoading.value = true
|
||||||
|
return updatePallet({ palletId: row.palletId, capacityPackages: row.capacityPackages, assetStatus: nextStatus, remark: row.remark || undefined })
|
||||||
|
}).then((response) => {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || "操作成功")
|
||||||
|
getList()
|
||||||
|
loadPanelDetail(row)
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!confirmCancelled(error)) loadError.value = "托盘状态更新失败,请稍后重试"
|
||||||
|
}).finally(() => { bulkStatusLoading.value = false })
|
||||||
|
}
|
||||||
|
function disableSelectedPallets() {
|
||||||
|
const rows = palletList.value.filter((row) => selectedIds.value.includes(row.palletId) && row.assetStatus !== '1')
|
||||||
|
if (!rows.length) {
|
||||||
|
proxy.$modal.msgWarning("所选托盘均已停用")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
proxy.$modal.confirm(`停用所选 ${rows.length} 个托盘?停用后不可再入库、装填或发货,历史记录保留。`).then(() => {
|
||||||
|
bulkStatusLoading.value = true
|
||||||
|
return Promise.allSettled(rows.map((row) => updatePallet({ palletId: row.palletId, capacityPackages: row.capacityPackages, assetStatus: '1', remark: row.remark || undefined })))
|
||||||
|
}).then((results) => {
|
||||||
|
const failed = results.filter((result) => result.status === 'rejected').length
|
||||||
|
if (failed) proxy.$modal.msgWarning(`已停用 ${results.length - failed} 个,${failed} 个未能停用`)
|
||||||
|
else proxy.$modal.msgSuccess(`已停用 ${results.length} 个托盘`)
|
||||||
|
clearSelection()
|
||||||
|
getList()
|
||||||
|
}).catch((error) => {
|
||||||
|
if (!confirmCancelled(error)) loadError.value = "批量停用失败,请稍后重试"
|
||||||
|
}).finally(() => { bulkStatusLoading.value = false })
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- 批量打印:同一窗口排版多张标签 ----------
|
||||||
|
function printSelectedLabels() {
|
||||||
|
const rows = palletList.value.filter((row) => selectedIds.value.includes(row.palletId))
|
||||||
|
if (!rows.length) return
|
||||||
|
const labelWindow = window.open('', '_blank')
|
||||||
|
if (!labelWindow) {
|
||||||
|
proxy.$modal.msgError("打印窗口被浏览器拦截,请允许弹出窗口后重试")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const labelUrls = []
|
||||||
|
let labelMonitorId
|
||||||
|
let released = false
|
||||||
|
const releaseLabels = () => {
|
||||||
|
if (labelMonitorId !== undefined) window.clearInterval(labelMonitorId)
|
||||||
|
labelMonitorId = undefined
|
||||||
|
labelUrls.splice(0).forEach((url) => URL.revokeObjectURL(url))
|
||||||
|
if (!released) {
|
||||||
|
released = true
|
||||||
|
batchPrinting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
batchPrinting.value = true
|
||||||
|
labelMonitorId = window.setInterval(() => {
|
||||||
|
if (labelWindow.closed) releaseLabels()
|
||||||
|
}, 250)
|
||||||
|
Promise.all(rows.map((row) => getPalletQrcode(row.palletId).then((qrCodeBlob) => ({ row, qrCodeBlob })))).then((labels) => {
|
||||||
|
if (released || labelWindow.closed) {
|
||||||
|
releaseLabels()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const doc = labelWindow.document
|
||||||
|
doc.title = `托盘标签 · ${labels.length} 张`
|
||||||
|
const styleElement = doc.createElement("style")
|
||||||
|
styleElement.textContent = "@page{margin:8mm}body{margin:0;font-family:sans-serif;color:#0f1115}main{display:grid;grid-template-columns:repeat(auto-fill,70mm);gap:4mm}section{display:flex;gap:4mm;box-sizing:border-box;width:70mm;height:50mm;padding:4mm;border:1px dashed #c3c8cf;break-inside:avoid}img{width:34mm;height:34mm}h2{margin:0;font:600 16px monospace;word-break:break-all}p{margin:2mm 0 0;font-size:11px}"
|
||||||
|
const sheet = doc.createElement("main")
|
||||||
|
let pending = labels.length
|
||||||
|
const settle = () => {
|
||||||
|
pending -= 1
|
||||||
|
if (pending === 0) {
|
||||||
|
labelWindow.focus()
|
||||||
|
labelWindow.print()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
labels.forEach(({ row, qrCodeBlob }) => {
|
||||||
|
const blob = qrCodeBlob instanceof Blob ? qrCodeBlob : new Blob([qrCodeBlob], { type: "image/png" })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
labelUrls.push(url)
|
||||||
|
const card = doc.createElement("section")
|
||||||
|
const image = doc.createElement("img")
|
||||||
|
image.alt = `托盘 ${row.palletCode} 二维码`
|
||||||
|
image.addEventListener("load", settle, { once: true })
|
||||||
|
image.addEventListener("error", settle, { once: true })
|
||||||
|
const text = doc.createElement("div")
|
||||||
|
const code = doc.createElement("h2")
|
||||||
|
code.textContent = row.palletCode
|
||||||
|
const capacity = doc.createElement("p")
|
||||||
|
capacity.textContent = `容量 ${row.capacityPackages} 包`
|
||||||
|
text.append(code, capacity)
|
||||||
|
card.append(image, text)
|
||||||
|
sheet.append(card)
|
||||||
|
image.src = url
|
||||||
|
})
|
||||||
|
labelWindow.addEventListener("afterprint", () => {
|
||||||
|
releaseLabels()
|
||||||
|
labelWindow.close()
|
||||||
|
}, { once: true })
|
||||||
|
doc.head.append(styleElement)
|
||||||
|
doc.body.replaceChildren(sheet)
|
||||||
|
}).catch(() => {
|
||||||
|
releaseLabels()
|
||||||
|
if (!labelWindow.closed) labelWindow.close()
|
||||||
|
proxy.$modal.msgError("二维码获取失败")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onBeforeUnmount(releaseLabelImage)
|
||||||
|
|
||||||
function isPrinting(palletId) {
|
function isPrinting(palletId) {
|
||||||
return printingIds.value.includes(palletId);
|
return printingIds.value.includes(palletId);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-site">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
|
<template #actions>
|
||||||
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:site:add']">新增现场</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="site" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="本页现场概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">现场</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">{{ routeFilterLabel || '全部客户' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页在用托盘</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.pallets) }}</span>
|
||||||
|
<span class="mp-kpi__hint">未使用 {{ formatCount(pageTotals.unused) }} · 部分使用 {{ formatCount(pageTotals.partial) }} · 空托 {{ formatCount(pageTotals.empty) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页在途</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.transit) }}</span>
|
||||||
|
<span class="mp-kpi__hint">已发货、待现场扫码收货</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">未分配用户的现场</span>
|
||||||
|
<span class="mp-kpi__value" :class="{ 'is-warn': pageTotals.unassigned }">{{ formatCount(pageTotals.unassigned) }}</span>
|
||||||
|
<span class="mp-kpi__hint">没有授权用户时,现场无法收货与登记使用</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="siteName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.siteName" placeholder="搜索现场名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="编码" prop="siteCode">
|
||||||
|
<el-input v-model="queryParams.siteCode" placeholder="现场编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="客户ID" prop="customerId">
|
<el-form-item label="客户ID" prop="customerId">
|
||||||
<el-input-number v-model="queryParams.customerId" :min="1" :step="1" step-strictly controls-position="right" placeholder="请输入客户ID" />
|
<el-input-number v-model="queryParams.customerId" :min="1" :step="1" step-strictly :controls="false" placeholder="全部" style="width: 110px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="现场编码" prop="siteCode">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-input v-model="queryParams.siteCode" placeholder="请输入现场编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
|
||||||
</el-form-item>
|
|
||||||
<el-form-item label="现场名称" prop="siteName">
|
|
||||||
<el-input v-model="queryParams.siteName" 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-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -19,42 +57,62 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:site:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:site:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:site:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:site:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span v-if="routeFilterLabel" class="site-filter-chip">
|
||||||
|
{{ routeFilterLabel }}
|
||||||
|
<button type="button" aria-label="清除筛选" @click="clearRouteFilter">×</button>
|
||||||
|
</span>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 个现场</span>
|
||||||
</el-form>
|
</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:site:add']">新增</el-button></el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:site:query', 'master:site:edit'])"
|
|
||||||
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
|
|
||||||
@click="handleUpdate()" v-hasPermi="['master:site:edit']"
|
|
||||||
>修改</el-button>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="1.5"><el-button type="warning" plain icon="User" :disabled="single" @click="handleAssign()" v-hasPermi="['master:site:assign']">分配用户</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>
|
|
||||||
<master-excel-actions resource="site" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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="siteList" @selection-change="handleSelectionChange">
|
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="siteList" :row-class-name="siteRowClass" @selection-change="handleSelectionChange">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column label="现场ID" prop="siteId" align="center" />
|
<el-table-column label="现场" min-width="200">
|
||||||
<el-table-column label="客户ID" prop="customerId" align="center" />
|
<template #default="scope">
|
||||||
<el-table-column label="现场编码" prop="siteCode" align="center" :show-overflow-tooltip="true" />
|
<span class="mp-name-cell__text">
|
||||||
<el-table-column label="现场名称" prop="siteName" align="center" :show-overflow-tooltip="true" />
|
<strong>{{ scope.row.siteName }} <span class="mp-mono mp-muted">{{ scope.row.siteCode }}</span></strong>
|
||||||
<el-table-column label="地址" prop="address" align="center" :show-overflow-tooltip="true" />
|
<small>{{ scope.row.address || '未登记地址' }}</small>
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="所属客户" min-width="150" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong>{{ scope.row.customerName || `客户 #${scope.row.customerId}` }}</strong>
|
||||||
|
<small class="mp-mono">{{ scope.row.customerCode || '—' }}</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="产线 / 隧道" width="110" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ scope.row.lineCount ?? 0 }} / {{ scope.row.tunnelCount ?? 0 }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="现场托盘" min-width="190">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="site-pallets">
|
||||||
|
<span class="mp-stack" role="img" :aria-label="sitePalletLabel(scope.row)">
|
||||||
|
<span :style="{ flex: scope.row.unusedPalletCount || 0, background: 'var(--sf-site)' }"></span>
|
||||||
|
<span :style="{ flex: scope.row.partialPalletCount || 0, background: 'var(--sf-transit)' }"></span>
|
||||||
|
<span :style="{ flex: scope.row.emptyPalletCount || 0, background: 'var(--sf-neutral-dot)' }"></span>
|
||||||
|
</span>
|
||||||
|
<small class="mp-num">{{ sitePalletCount(scope.row) }} 托在用<template v-if="scope.row.emptyPalletCount"> · <span class="site-pallets__warn">空托 {{ scope.row.emptyPalletCount }}</span></template><template v-if="scope.row.transitPalletCount"> · 在途 {{ scope.row.transitPalletCount }}</template></small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="授权用户" min-width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.userCount" class="site-users">
|
||||||
|
<span class="mp-avatars">
|
||||||
|
<span v-for="user in (scope.row.users || []).slice(0, 3)" :key="user.userId" class="mp-avatar" :style="{ '--mp-avatar': avatarTint(user.userId) }" :title="userDisplayName(user)">{{ personInitial(user) }}</span>
|
||||||
|
</span>
|
||||||
|
<small class="mp-muted">{{ scope.row.userCount }} 人</small>
|
||||||
|
</span>
|
||||||
|
<span v-else class="mp-pill is-warn">未分配</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column v-if="isMasterWriteAllowed" label="操作" width="150" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" width="260" class-name="small-padding fixed-width">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" icon="User" @click="handleAssign(scope.row)" v-hasPermi="['master:site:assign']">分配</el-button>
|
<el-button link type="primary" icon="User" @click="handleAssign(scope.row)" v-hasPermi="['master:site:assign']">分配</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -74,41 +132,51 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(560px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">现场是发货的目的地,也是使用登记与退回的发起地。现场编码在同一客户下唯一。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="siteRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="siteRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
<el-form-item label="客户ID" prop="customerId">
|
<el-form-item label="所属客户" prop="customerId">
|
||||||
<el-input-number v-model="form.customerId" :min="1" :step="1" step-strictly controls-position="right" />
|
<master-ref-select v-model="form.customerId" kind="客户" :options="customerOptions" :loading="customerOptionLoading" :failed="customerOptionFailed" :current-label="formCustomerName" placeholder="搜索客户名称或编码" />
|
||||||
|
<div class="sf-form-hint">只列出状态正常的客户</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<div class="sf-form-grid">
|
||||||
<el-form-item label="现场编码" prop="siteCode">
|
<el-form-item label="现场编码" prop="siteCode">
|
||||||
<el-input v-model="form.siteCode" placeholder="请输入现场编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.siteCode" class="sf-form-mono" placeholder="如 S-0012-01" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">同一客户下唯一,导入产线和隧道时用它关联现场</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="现场名称" prop="siteName">
|
<el-form-item label="现场名称" prop="siteName">
|
||||||
<el-input v-model="form.siteName" placeholder="请输入现场名称" maxlength="100" show-word-limit />
|
<el-input v-model="form.siteName" placeholder="如 南厂、二号基地" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">发货、收货与报表中显示</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="地址" prop="address">
|
<el-form-item label="地址" prop="address">
|
||||||
<el-input v-model="form.address" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入地址" />
|
<el-input v-model="form.address" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="选填,便于司机送货与现场核对" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="form.status">
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.siteId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:site:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:site:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!form.siteId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:site:add']">创建现场</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:site:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -118,19 +186,27 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
|
||||||
.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); }
|
.site-pallets { display:flex; flex-direction:column; gap:6px; max-width:200px; }
|
||||||
@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; } }
|
.site-pallets small { color:var(--sf-text-3, #5b616e); font-size:12px; }
|
||||||
|
.site-pallets__warn { color:var(--sf-warn-fg, #b54708); }
|
||||||
|
.site-users { display:inline-flex; align-items:center; gap:8px; }
|
||||||
|
.site-filter-chip { display:inline-flex; align-items:center; gap:6px; height:28px; margin:0 0 10px; padding:0 6px 0 10px; border-radius:7px; background:var(--sf-accent-soft, #e6f3f5); color:var(--sf-accent-strong, #006a78); font-size:12.5px; }
|
||||||
|
.site-filter-chip button { width:20px; height:20px; border:0; border-radius:5px; background:transparent; color:inherit; font-size:15px; line-height:1; cursor:pointer; }
|
||||||
|
.site-filter-chip button:hover { background:rgba(0,0,0,.06); }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Site">
|
<script setup name="Site">
|
||||||
import BusinessUserAssignDialog from "@/components/BusinessUserAssignDialog";
|
import BusinessUserAssignDialog from "@/components/BusinessUserAssignDialog";
|
||||||
import { addSite, getSite, listSite, updateSite } from "@/api/master/site";
|
import { addSite, getSite, getSiteCustomerOptions, listSite, updateSite } from "@/api/master/site";
|
||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
|
import MasterRefSelect from '@/components/MasterRefSelect'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { avatarTint, formatCount, personInitial, userDisplayName } from '@/utils/masterDisplay'
|
||||||
|
import { positiveId, useMasterRouteIntent } from '@/utils/masterRouteIntent'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -154,6 +230,17 @@ const total = ref(0);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '可发货、收货核对、登记使用与退回', '1': '发货、收货、使用登记与退回都会被拒绝;历史记录保留' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
|
||||||
|
// 从客户详情跳转来的筛选与待办动作
|
||||||
|
const routeFilter = ref({ customerName: '', siteName: '' })
|
||||||
|
const routeCustomerNames = ref({})
|
||||||
|
let pendingAssignSiteId
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
const labels = { siteCode: "现场编码", siteName: "现场名称" };
|
const labels = { siteCode: "现场编码", siteName: "现场名称" };
|
||||||
@@ -173,7 +260,7 @@ function validatePositiveInteger(rule, value, callback) {
|
|||||||
|
|
||||||
const data = reactive({
|
const data = reactive({
|
||||||
form: {},
|
form: {},
|
||||||
queryParams: { pageNum: 1, pageSize: 10, customerId: undefined, siteCode: undefined, siteName: undefined, status: undefined },
|
queryParams: { pageNum: 1, pageSize: 10, customerId: undefined, siteCode: undefined, siteName: undefined, status: undefined, ids: undefined },
|
||||||
rules: {
|
rules: {
|
||||||
customerId: [{ required: true, validator: validatePositiveInteger, trigger: "change" }],
|
customerId: [{ required: true, validator: validatePositiveInteger, trigger: "change" }],
|
||||||
siteCode: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
|
siteCode: [{ required: true, validator: validateRequiredText, trigger: "blur" }],
|
||||||
@@ -183,6 +270,30 @@ const data = reactive({
|
|||||||
});
|
});
|
||||||
const { form, queryParams, rules } = toRefs(data);
|
const { form, queryParams, rules } = toRefs(data);
|
||||||
|
|
||||||
|
const pageTotals = computed(() => siteList.value.reduce((totals, row) => {
|
||||||
|
totals.unused += row.unusedPalletCount || 0
|
||||||
|
totals.partial += row.partialPalletCount || 0
|
||||||
|
totals.empty += row.emptyPalletCount || 0
|
||||||
|
totals.transit += row.transitPalletCount || 0
|
||||||
|
totals.pallets += sitePalletCount(row)
|
||||||
|
if (!row.userCount && row.status !== '1') totals.unassigned += 1
|
||||||
|
return totals
|
||||||
|
}, { unused: 0, partial: 0, empty: 0, transit: 0, pallets: 0, unassigned: 0 }))
|
||||||
|
const routeFilterLabel = computed(() => {
|
||||||
|
if (queryParams.value.ids) return `仅显示:${routeFilter.value.siteName || '指定现场'}`
|
||||||
|
if (queryParams.value.customerId && routeFilter.value.customerName) return `客户:${routeFilter.value.customerName}`
|
||||||
|
return ''
|
||||||
|
})
|
||||||
|
// 已知客户名:来自当前列表行与跳转参数,用于表单里回显所属客户
|
||||||
|
const knownCustomerNames = computed(() => {
|
||||||
|
const names = { ...routeCustomerNames.value }
|
||||||
|
siteList.value.forEach((row) => {
|
||||||
|
if (row.customerName) names[row.customerId] = `${row.customerName}${row.customerCode ? ` · ${row.customerCode}` : ''}`
|
||||||
|
})
|
||||||
|
return names
|
||||||
|
})
|
||||||
|
const formCustomerName = computed(() => knownCustomerNames.value[form.value.customerId] || '')
|
||||||
|
|
||||||
function isCurrentListRequest(listContext) {
|
function isCurrentListRequest(listContext) {
|
||||||
return listContext.generation === listGeneration;
|
return listContext.generation === listGeneration;
|
||||||
}
|
}
|
||||||
@@ -199,6 +310,7 @@ function getList() {
|
|||||||
}
|
}
|
||||||
siteList.value = response.rows;
|
siteList.value = response.rows;
|
||||||
total.value = response.total;
|
total.value = response.total;
|
||||||
|
runPendingAssign({ listLoaded: true });
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
if (!isCurrentListRequest(listContext)) {
|
if (!isCurrentListRequest(listContext)) {
|
||||||
return;
|
return;
|
||||||
@@ -215,6 +327,7 @@ function getList() {
|
|||||||
}
|
}
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
formMeta.value = null;
|
||||||
form.value = { siteId: undefined, customerId: undefined, siteCode: "", siteName: "", address: "", status: "0", remark: "" };
|
form.value = { siteId: undefined, customerId: undefined, siteCode: "", siteName: "", address: "", status: "0", remark: "" };
|
||||||
proxy.resetForm("siteRef");
|
proxy.resetForm("siteRef");
|
||||||
}
|
}
|
||||||
@@ -238,7 +351,7 @@ function handleDialogBeforeClose(done) {
|
|||||||
done();
|
done();
|
||||||
}
|
}
|
||||||
function handleQuery() { queryParams.value.pageNum = 1; getList(); }
|
function handleQuery() { queryParams.value.pageNum = 1; getList(); }
|
||||||
function resetQuery() { proxy.resetForm("queryRef"); handleQuery(); }
|
function resetQuery() { proxy.resetForm("queryRef"); queryParams.value.ids = undefined; routeFilter.value = { customerName: '', siteName: '' }; handleQuery(); }
|
||||||
function handleSelectionChange(selection) {
|
function handleSelectionChange(selection) {
|
||||||
selectedRows.value = selection;
|
selectedRows.value = selection;
|
||||||
selectedIds.value = selection.map((item) => item.siteId);
|
selectedIds.value = selection.map((item) => item.siteId);
|
||||||
@@ -265,6 +378,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
siteId: detail.siteId,
|
siteId: detail.siteId,
|
||||||
customerId: detail.customerId,
|
customerId: detail.customerId,
|
||||||
@@ -337,5 +451,71 @@ function submitUpdate() {
|
|||||||
}).finally(() => { submitLoading.value = false; });
|
}).finally(() => { submitLoading.value = false; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function siteRowClass({ row }) {
|
||||||
|
return row.status === '1' ? 'is-muted-row' : ''
|
||||||
|
}
|
||||||
|
function sitePalletCount(row) {
|
||||||
|
return (row.unusedPalletCount || 0) + (row.partialPalletCount || 0) + (row.emptyPalletCount || 0)
|
||||||
|
}
|
||||||
|
function sitePalletLabel(row) {
|
||||||
|
return `未使用 ${row.unusedPalletCount || 0} 托,部分使用 ${row.partialPalletCount || 0} 托,空托 ${row.emptyPalletCount || 0} 托`
|
||||||
|
}
|
||||||
|
function clearRouteFilter() {
|
||||||
|
queryParams.value.customerId = undefined
|
||||||
|
queryParams.value.ids = undefined
|
||||||
|
routeFilter.value = { customerName: '', siteName: '' }
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
// 列表请求进行中时保留待办,等这次列表返回后再打开分配窗口
|
||||||
|
function runPendingAssign({ listLoaded = false } = {}) {
|
||||||
|
if (!pendingAssignSiteId || (loading.value && !listLoaded)) return
|
||||||
|
const row = siteList.value.find((item) => item.siteId === pendingAssignSiteId)
|
||||||
|
pendingAssignSiteId = undefined
|
||||||
|
if (row && isMasterWriteAllowed.value && proxy.$auth.hasPermiAnd(['master:site:assign'])) handleAssign(row)
|
||||||
|
}
|
||||||
|
// 表单里的上级客户选项:每次打开弹窗时刷新,加载失败时退回为直接输入 ID
|
||||||
|
const customerOptions = ref([]);
|
||||||
|
const customerOptionLoading = ref(false);
|
||||||
|
const customerOptionFailed = ref(false);
|
||||||
|
let customerOptionGeneration = 0;
|
||||||
|
function loadCustomerOptions() {
|
||||||
|
const generation = ++customerOptionGeneration;
|
||||||
|
customerOptionLoading.value = true;
|
||||||
|
customerOptionFailed.value = false;
|
||||||
|
getSiteCustomerOptions().then((response) => {
|
||||||
|
if (generation !== customerOptionGeneration) return;
|
||||||
|
customerOptions.value = Array.isArray(response.data) ? response.data : [];
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation !== customerOptionGeneration) return;
|
||||||
|
customerOptions.value = [];
|
||||||
|
customerOptionFailed.value = true;
|
||||||
|
}).finally(() => {
|
||||||
|
if (generation === customerOptionGeneration) customerOptionLoading.value = false;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
watch(open, (visible) => {
|
||||||
|
if (visible) loadCustomerOptions();
|
||||||
|
});
|
||||||
|
const consumeRouteIntent = useMasterRouteIntent((intent, { initial }) => {
|
||||||
|
const customerId = positiveId(intent.customerId)
|
||||||
|
const siteId = positiveId(intent.siteId)
|
||||||
|
const nextIds = siteId ? String(siteId) : undefined
|
||||||
|
const changed = customerId !== queryParams.value.customerId || nextIds !== queryParams.value.ids
|
||||||
|
if (customerId || siteId) {
|
||||||
|
queryParams.value.customerId = customerId
|
||||||
|
queryParams.value.ids = nextIds
|
||||||
|
routeFilter.value = { customerName: String(intent.customerName || ''), siteName: String(intent.siteName || '') }
|
||||||
|
if (customerId && intent.customerName) routeCustomerNames.value = { ...routeCustomerNames.value, [customerId]: String(intent.customerName) }
|
||||||
|
}
|
||||||
|
if (intent.action === 'assign' && siteId) pendingAssignSiteId = siteId
|
||||||
|
if (intent.action === 'add' && isMasterWriteAllowed.value && proxy.$auth.hasPermiAnd(['master:site:add'])) {
|
||||||
|
handleAdd()
|
||||||
|
form.value.customerId = customerId
|
||||||
|
}
|
||||||
|
if (!initial && changed) handleQuery()
|
||||||
|
else if (!initial) runPendingAssign()
|
||||||
|
})
|
||||||
|
consumeRouteIntent({ initial: true })
|
||||||
getList();
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-strain">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
<el-form-item label="菌种编码" prop="strainCode">
|
<template #actions>
|
||||||
<el-input v-model="queryParams.strainCode" placeholder="请输入菌种编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:strain:add']">新增菌种</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="strain" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="菌种物料概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">菌种</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">本页正常 {{ formatCount(pageTotals.active) }} 个</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页仓库在库</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.warehousePackages) }}<small>包</small></span>
|
||||||
|
<span class="mp-kpi__hint">有料托盘 {{ formatCount(pageTotals.warehousePallets) }} 托</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页在外</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.fieldPackages) }}<small>包</small></span>
|
||||||
|
<span class="mp-kpi__hint">在途与客户现场 {{ formatCount(pageTotals.fieldPallets) }} 托</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">{{ EXPIRY_WARNING_DAYS }} 天内到期</span>
|
||||||
|
<span class="mp-kpi__value" :class="{ 'is-warn': pageTotals.expiring }">{{ formatCount(pageTotals.expiring) }}</span>
|
||||||
|
<span class="mp-kpi__hint">仓库在库物料最近有效期临近的菌种</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="strainName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.strainName" placeholder="搜索菌种名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="菌种名称" prop="strainName">
|
<el-form-item label="编码" prop="strainCode">
|
||||||
<el-input v-model="queryParams.strainName" placeholder="请输入菌种名称" clearable maxlength="100" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.strainCode" placeholder="菌种编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
|
||||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -16,49 +54,58 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:strain:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:strain:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:strain:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:strain:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 个菌种</span>
|
||||||
</el-form>
|
</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:strain:add']">新增</el-button>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:strain:query', 'master:strain:edit'])"
|
|
||||||
type="success"
|
|
||||||
plain
|
|
||||||
icon="Edit"
|
|
||||||
:disabled="single || detailLoading"
|
|
||||||
:loading="detailLoading"
|
|
||||||
@click="handleUpdate()"
|
|
||||||
v-hasPermi="['master:strain: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>
|
|
||||||
<master-excel-actions resource="strain" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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="strainList" :row-class-name="strainRowClass" @selection-change="handleSelectionChange">
|
||||||
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="strainList" @selection-change="handleSelectionChange">
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column label="菌种" min-width="180">
|
||||||
<el-table-column label="菌种ID" prop="strainId" align="center" />
|
|
||||||
<el-table-column label="菌种编码" prop="strainCode" align="center" :show-overflow-tooltip="true" />
|
|
||||||
<el-table-column label="菌种名称" prop="strainName" align="center" :show-overflow-tooltip="true" />
|
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<dict-tag :options="sys_normal_disable" :value="scope.row.status" />
|
<span class="mp-name-cell">
|
||||||
|
<span class="mp-initial" :class="{ 'is-off': scope.row.status === '1' }">{{ orgInitial(scope.row.strainName) }}</span>
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong>{{ scope.row.strainName }}</strong>
|
||||||
|
<small class="mp-mono">{{ scope.row.strainCode }}</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
</template>
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column label="仓库在库" min-width="170">
|
||||||
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
|
<template #default="scope">
|
||||||
|
<span class="strain-stock">
|
||||||
|
<span class="mp-num"><strong>{{ formatCount(scope.row.warehousePackageQty ?? 0) }}</strong> 包<span class="mp-muted"> · {{ scope.row.warehousePalletCount ?? 0 }} 托</span></span>
|
||||||
|
<span class="mp-stack strain-stock__bar" role="img" :aria-label="`仓库 ${scope.row.warehousePackageQty ?? 0} 包,在外 ${scope.row.fieldPackageQty ?? 0} 包`">
|
||||||
|
<span :style="{ flex: scope.row.warehousePackageQty || 0, background: 'var(--sf-accent)' }"></span>
|
||||||
|
<span :style="{ flex: scope.row.fieldPackageQty || 0, background: 'var(--sf-site)' }"></span>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" class-name="small-padding fixed-width">
|
<el-table-column label="在途与现场" min-width="130" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ formatCount(scope.row.fieldPackageQty ?? 0) }} 包<span class="mp-muted"> · {{ scope.row.fieldPalletCount ?? 0 }} 托</span></span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近有效期" width="150">
|
||||||
|
<template #default="scope">
|
||||||
|
<span v-if="scope.row.nearestExpiryDate" class="strain-expiry">
|
||||||
|
<span class="mp-num">{{ scope.row.nearestExpiryDate }}</span>
|
||||||
|
<span v-if="expiryDays(scope.row) !== null && expiryDays(scope.row) <= EXPIRY_WARNING_DAYS" class="mp-pill" :class="expiryDays(scope.row) < 0 ? 'is-danger' : 'is-warn'">{{ expiryText(scope.row) }}</span>
|
||||||
|
</span>
|
||||||
|
<span v-else class="mp-muted">—</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近装填" width="140">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong class="mp-num">{{ scope.row.lastFilledAt ? parseTime(scope.row.lastFilledAt, '{m}-{d} {h}:{i}') : '—' }}</strong>
|
||||||
|
<small>近 30 天 {{ scope.row.fillingCount30d ?? 0 }} 托</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column v-if="isMasterWriteAllowed" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:strain:query', 'master:strain:edit'])"
|
v-if="proxy.$auth.hasPermiAnd(['master:strain:query', 'master:strain:edit'])"
|
||||||
@@ -85,35 +132,44 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(520px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">菌种是工厂装填时选择的物料品类。编码全局唯一,Excel 导入导出按编码匹配。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="strainRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="strainRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
|
<div class="sf-form-grid">
|
||||||
<el-form-item label="菌种编码" prop="strainCode">
|
<el-form-item label="菌种编码" prop="strainCode">
|
||||||
<el-input v-model="form.strainCode" placeholder="请输入菌种编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.strainCode" class="sf-form-mono" placeholder="如 A15" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">全局唯一,最长 64 字</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="菌种名称" prop="strainName">
|
<el-form-item label="菌种名称" prop="strainName">
|
||||||
<el-input v-model="form.strainName" placeholder="请输入菌种名称" maxlength="100" show-word-limit />
|
<el-input v-model="form.strainName" placeholder="装填和报表中显示的名称" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">修改后不影响已装填托盘记录的菌种快照</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="form.status">
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填,例如适用季节或注意事项" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.strainId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:strain:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:strain:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!form.strainId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:strain:add']">创建菌种</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:strain:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -121,11 +177,13 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
|
||||||
.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); }
|
.strain-stock { display:flex; flex-direction:column; gap:6px; max-width:180px; }
|
||||||
@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; } }
|
.strain-stock strong { font-weight:600; }
|
||||||
|
.strain-stock__bar { height:6px; }
|
||||||
|
.strain-expiry { display:inline-flex; flex-wrap:wrap; align-items:center; gap:6px; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Strain">
|
<script setup name="Strain">
|
||||||
@@ -133,6 +191,7 @@ import { addStrain, getStrain, listStrain, updateStrain } from "@/api/master/str
|
|||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { formatCount, orgInitial } from '@/utils/masterDisplay'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -155,6 +214,13 @@ const total = ref(0);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '工厂装填时可以选择该菌种', '1': '不能再用于装填;已装填的托盘和历史记录不受影响' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
const EXPIRY_WARNING_DAYS = 7;
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
if (typeof value !== "string" || value.trim() === "") {
|
if (typeof value !== "string" || value.trim() === "") {
|
||||||
@@ -175,6 +241,17 @@ const data = reactive({
|
|||||||
});
|
});
|
||||||
const { form, queryParams, rules } = toRefs(data);
|
const { form, queryParams, rules } = toRefs(data);
|
||||||
|
|
||||||
|
const pageTotals = computed(() => strainList.value.reduce((totals, row) => {
|
||||||
|
if (row.status !== "1") totals.active += 1;
|
||||||
|
totals.warehousePallets += row.warehousePalletCount || 0;
|
||||||
|
totals.warehousePackages += row.warehousePackageQty || 0;
|
||||||
|
totals.fieldPallets += row.fieldPalletCount || 0;
|
||||||
|
totals.fieldPackages += row.fieldPackageQty || 0;
|
||||||
|
const days = expiryDays(row);
|
||||||
|
if (days !== null && days <= EXPIRY_WARNING_DAYS) totals.expiring += 1;
|
||||||
|
return totals;
|
||||||
|
}, { active: 0, warehousePallets: 0, warehousePackages: 0, fieldPallets: 0, fieldPackages: 0, expiring: 0 }));
|
||||||
|
|
||||||
function isCurrentListRequest(listContext) {
|
function isCurrentListRequest(listContext) {
|
||||||
return listContext.generation === listGeneration;
|
return listContext.generation === listGeneration;
|
||||||
}
|
}
|
||||||
@@ -209,6 +286,7 @@ function getList() {
|
|||||||
|
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
formMeta.value = null;
|
||||||
form.value = { strainId: undefined, strainCode: "", strainName: "", status: "0", remark: "" };
|
form.value = { strainId: undefined, strainCode: "", strainName: "", status: "0", remark: "" };
|
||||||
proxy.resetForm("strainRef");
|
proxy.resetForm("strainRef");
|
||||||
}
|
}
|
||||||
@@ -273,6 +351,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
strainId: detail.strainId,
|
strainId: detail.strainId,
|
||||||
strainCode: detail.strainCode,
|
strainCode: detail.strainCode,
|
||||||
@@ -338,5 +417,23 @@ function submitUpdate() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function strainRowClass({ row }) {
|
||||||
|
return row.status === "1" ? "is-muted-row" : "";
|
||||||
|
}
|
||||||
|
// 有效期按自然日比较,返回距今天数(已过期为负数)
|
||||||
|
function expiryDays(row) {
|
||||||
|
if (!row.nearestExpiryDate) return null;
|
||||||
|
const [year, month, day] = String(row.nearestExpiryDate).split("-").map(Number);
|
||||||
|
const expiry = new Date(year, month - 1, day);
|
||||||
|
const today = new Date();
|
||||||
|
today.setHours(0, 0, 0, 0);
|
||||||
|
return Math.round((expiry.getTime() - today.getTime()) / 86400000);
|
||||||
|
}
|
||||||
|
function expiryText(row) {
|
||||||
|
const days = expiryDays(row);
|
||||||
|
if (days < 0) return "已过期";
|
||||||
|
return days === 0 ? "今天到期" : `${days} 天后到期`;
|
||||||
|
}
|
||||||
|
|
||||||
getList();
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,17 +1,55 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-tunnel">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
|
<template #actions>
|
||||||
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:tunnel:add']">新增隧道</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="tunnel" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="隧道概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">隧道</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">{{ routeFilter.siteName ? `现场:${routeFilter.siteName}` : '全部现场' }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页有物料</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.loaded) }}</span>
|
||||||
|
<span class="mp-kpi__hint">空闲 {{ formatCount(pageTotals.idle) }} 条</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页隧道内托盘</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.pallets) }}</span>
|
||||||
|
<span class="mp-kpi__hint">剩余 {{ formatCount(pageTotals.packages) }} 包</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">本页近 30 天使用</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.usages) }}<small>次</small></span>
|
||||||
|
<span class="mp-kpi__hint">按隧道登记的使用记录</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="tunnelName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.tunnelName" placeholder="搜索隧道名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="编号" prop="tunnelCode">
|
||||||
|
<el-input v-model="queryParams.tunnelCode" placeholder="隧道编号" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
|
</el-form-item>
|
||||||
<el-form-item label="现场ID" prop="siteId">
|
<el-form-item label="现场ID" prop="siteId">
|
||||||
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="请输入现场ID" />
|
<el-input-number v-model="queryParams.siteId" :min="1" :controls="false" placeholder="全部" style="width: 110px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="隧道编号" prop="tunnelCode">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="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-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -19,43 +57,60 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:tunnel:list']">搜索</el-button>
|
<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-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:tunnel:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span v-if="queryParams.siteId && routeFilter.siteName" class="master-filter-chip">
|
||||||
|
现场:{{ routeFilter.siteName }}
|
||||||
|
<button type="button" aria-label="清除现场筛选" @click="clearRouteFilter">×</button>
|
||||||
|
</span>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 条隧道</span>
|
||||||
</el-form>
|
</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>
|
|
||||||
<master-excel-actions resource="tunnel" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
|
||||||
<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-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 class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="tunnelList" :row-class-name="tunnelRowClass" @selection-change="handleSelectionChange">
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column label="隧道ID" prop="tunnelId" align="center" width="90" />
|
<el-table-column label="隧道" min-width="160">
|
||||||
<el-table-column label="现场ID" prop="siteId" align="center" width="100" />
|
<template #default="scope">
|
||||||
<el-table-column label="隧道编号" prop="tunnelCode" align="center" :show-overflow-tooltip="true" />
|
<span class="mp-name-cell__text">
|
||||||
<el-table-column label="隧道名称" prop="tunnelName" align="center" :show-overflow-tooltip="true" />
|
<strong>{{ scope.row.tunnelName }}</strong>
|
||||||
<el-table-column label="当前溯源号" prop="currentTraceNo" align="center" min-width="180" :show-overflow-tooltip="true">
|
<small class="mp-mono">{{ scope.row.tunnelCode }}</small>
|
||||||
<template #default="scope">{{ scope.row.currentTraceNo || '暂无物料' }}</template>
|
</span>
|
||||||
|
</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
<el-table-column label="所属现场" min-width="180" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong>{{ scope.row.siteName || `现场 #${scope.row.siteId}` }}</strong>
|
||||||
|
<small>{{ scope.row.customerName || '—' }}<template v-if="scope.row.siteCode"> · <span class="mp-mono">{{ scope.row.siteCode }}</span></template></small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="当前物料" min-width="210">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="tunnel-trace">
|
||||||
|
<span class="mp-pill" :class="scope.row.currentTraceNo ? 'is-site' : ''">{{ scope.row.currentTraceNo ? '有物料' : '空闲' }}</span>
|
||||||
|
<span v-if="scope.row.currentTraceNo" class="tunnel-trace__text">
|
||||||
|
<span class="mp-mono" :title="scope.row.currentTraceNo">{{ shortTrace(scope.row.currentTraceNo) }}</span>
|
||||||
|
<small v-if="scope.row.currentTraceStartedAt" class="mp-muted mp-num">{{ parseTime(scope.row.currentTraceStartedAt, '{m}-{d} {h}:{i}') }} 起</small>
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="隧道内托盘" width="130" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-num">{{ scope.row.activePalletCount ?? 0 }} 托<span class="mp-muted"> · {{ formatCount(scope.row.activePackageQty ?? 0) }} 包</span></span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最近使用" width="130">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text">
|
||||||
|
<strong class="mp-num">{{ scope.row.lastUsedAt ? parseTime(scope.row.lastUsedAt, '{m}-{d} {h}:{i}') : '—' }}</strong>
|
||||||
|
<small v-if="scope.row.usageCount30d">近 30 天 {{ scope.row.usageCount30d }} 次</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column v-if="isMasterWriteAllowed" label="操作" width="90" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<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">
|
<template #default="scope">
|
||||||
<el-button
|
<el-button
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
|
v-if="proxy.$auth.hasPermiAnd(['master:tunnel:query', 'master:tunnel:edit'])"
|
||||||
@@ -68,20 +123,55 @@
|
|||||||
<pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
|
<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" />
|
<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-dialog
|
||||||
|
v-model="open"
|
||||||
|
:title="title"
|
||||||
|
width="min(620px, 94vw)"
|
||||||
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
|
:close-on-click-modal="!submitLoading"
|
||||||
|
:close-on-press-escape="!submitLoading"
|
||||||
|
:show-close="!submitLoading"
|
||||||
|
:before-close="handleDialogBeforeClose"
|
||||||
|
>
|
||||||
|
<p class="sf-form-intro">隧道隶属于客户现场。托盘入隧后生成内部溯源号,同一隧道同一批次的托盘共享溯源号。隧道编号在同一现场下唯一。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<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 ref="tunnelRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
<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="siteId">
|
||||||
<el-form-item label="隧道编号" prop="tunnelCode"><el-input v-model="form.tunnelCode" placeholder="请输入隧道编号" maxlength="64" show-word-limit /></el-form-item>
|
<master-ref-select v-model="form.siteId" kind="现场" :options="siteOptions" :loading="siteOptionLoading" :failed="siteOptionFailed" :current-label="formSiteName" placeholder="搜索现场名称或编码" />
|
||||||
<el-form-item label="隧道名称" prop="tunnelName"><el-input v-model="form.tunnelName" placeholder="请输入隧道名称" maxlength="100" show-word-limit /></el-form-item>
|
<div class="sf-form-hint">只列出状态正常且所属客户正常的现场</div>
|
||||||
<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>
|
||||||
<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>
|
<div class="sf-form-grid">
|
||||||
|
<el-form-item label="隧道编号" prop="tunnelCode">
|
||||||
|
<el-input v-model="form.tunnelCode" class="sf-form-mono" placeholder="如 T-03" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">同一现场下唯一</div>
|
||||||
|
</el-form-item>
|
||||||
|
<el-form-item label="隧道名称" prop="tunnelName">
|
||||||
|
<el-input v-model="form.tunnelName" placeholder="如 三号隧道" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">入隧登记与追溯中显示</div>
|
||||||
|
</el-form-item>
|
||||||
|
</div>
|
||||||
|
<el-form-item label="状态" prop="status">
|
||||||
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</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-item v-if="form.tunnelId" label="当前物料溯源号">
|
||||||
|
<div class="sf-form-fixed"><span class="sf-form-fixed__value">{{ formMeta?.currentTraceNo || '暂无物料' }}</span></div>
|
||||||
|
<div class="sf-form-hint">由入隧登记自动维护,这里不能修改</div>
|
||||||
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.tunnelId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:tunnel:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<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>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<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>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -89,18 +179,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
|
||||||
.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); }
|
.tunnel-trace { display:inline-flex; align-items:center; gap:10px; min-width:0; }
|
||||||
@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; } }
|
.tunnel-trace__text { display:flex; flex-direction:column; min-width:0; line-height:1.35; }
|
||||||
|
.tunnel-trace__text small { font-size:12px; }
|
||||||
|
.master-filter-chip { display:inline-flex; align-items:center; gap:6px; height:28px; margin:0 0 10px; padding:0 6px 0 10px; border-radius:7px; background:var(--sf-accent-soft, #e6f3f5); color:var(--sf-accent-strong, #006a78); font-size:12.5px; }
|
||||||
|
.master-filter-chip button { width:20px; height:20px; border:0; border-radius:5px; background:transparent; color:inherit; font-size:15px; line-height:1; cursor:pointer; }
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Tunnel">
|
<script setup name="Tunnel">
|
||||||
import { addTunnel, getTunnel, listTunnel, updateTunnel } from '@/api/master/tunnel'
|
import { addTunnel, getTunnel, getTunnelSiteOptions, listTunnel, updateTunnel } from '@/api/master/tunnel'
|
||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
|
import MasterRefSelect from '@/components/MasterRefSelect'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { formatCount } from '@/utils/masterDisplay'
|
||||||
|
import { positiveId, useMasterRouteIntent } from '@/utils/masterRouteIntent'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -120,62 +216,251 @@ const open = ref(false)
|
|||||||
const title = ref('')
|
const title = ref('')
|
||||||
const adoptionDialogRef = ref()
|
const adoptionDialogRef = ref()
|
||||||
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
|
const isMasterWriteAllowed = computed(() => userStore.admin === true || !testBatchStore.isTestMode)
|
||||||
|
const routeFilter = ref({ siteName: '' })
|
||||||
|
const routeSiteNames = ref({})
|
||||||
|
let listGeneration = 0
|
||||||
|
let detailGeneration = 0
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '入隧登记与使用登记时可以选择', '1': '不能再入隧或登记使用;历史溯源记录保留' }
|
||||||
|
const formMeta = ref(null)
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项')
|
||||||
|
|
||||||
|
function validateRequiredText(rule, value, callback) {
|
||||||
|
const labels = { tunnelCode: '隧道编号', tunnelName: '隧道名称' }
|
||||||
|
if (typeof value !== 'string' || value.trim() === '') {
|
||||||
|
callback(new Error(`${labels[rule.field]}不能为空`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
function validatePositiveInteger(rule, value, callback) {
|
||||||
|
if (!Number.isInteger(value) || value <= 0) {
|
||||||
|
callback(new Error('现场ID必须为正整数'))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
callback()
|
||||||
|
}
|
||||||
|
|
||||||
const data = reactive({
|
const data = reactive({
|
||||||
form: {},
|
form: {},
|
||||||
queryParams: { pageNum: 1, pageSize: 10, siteId: undefined, tunnelCode: undefined, tunnelName: undefined, status: undefined },
|
queryParams: { pageNum: 1, pageSize: 10, siteId: undefined, tunnelCode: undefined, tunnelName: undefined, status: undefined },
|
||||||
rules: {
|
rules: {
|
||||||
siteId: [{ required: true, message: '请输入现场ID', trigger: 'change' }],
|
siteId: [{ required: true, validator: validatePositiveInteger, trigger: 'change' }],
|
||||||
tunnelCode: [{ required: true, message: '请输入隧道编号', trigger: 'blur' }],
|
tunnelCode: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
|
||||||
tunnelName: [{ required: true, message: '请输入隧道名称', trigger: 'blur' }],
|
tunnelName: [{ required: true, validator: validateRequiredText, trigger: 'blur' }],
|
||||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
status: [{ required: true, message: '请选择状态', trigger: 'change' }]
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
const { form, queryParams, rules } = toRefs(data)
|
const { form, queryParams, rules } = toRefs(data)
|
||||||
|
|
||||||
|
const pageTotals = computed(() => tunnelList.value.reduce((totals, row) => {
|
||||||
|
if (row.currentTraceNo) totals.loaded += 1
|
||||||
|
else totals.idle += 1
|
||||||
|
totals.pallets += row.activePalletCount || 0
|
||||||
|
totals.packages += row.activePackageQty || 0
|
||||||
|
totals.usages += row.usageCount30d || 0
|
||||||
|
return totals
|
||||||
|
}, { loaded: 0, idle: 0, pallets: 0, packages: 0, usages: 0 }))
|
||||||
|
const formSiteName = computed(() => {
|
||||||
|
const names = { ...routeSiteNames.value }
|
||||||
|
tunnelList.value.forEach((row) => {
|
||||||
|
if (row.siteName) names[row.siteId] = [row.customerName, row.siteName].filter(Boolean).join(' · ')
|
||||||
|
})
|
||||||
|
return names[form.value.siteId] || ''
|
||||||
|
})
|
||||||
|
|
||||||
|
function isCurrentListRequest(listContext) {
|
||||||
|
return listContext.generation === listGeneration
|
||||||
|
}
|
||||||
function getList() {
|
function getList() {
|
||||||
|
const listContext = {
|
||||||
|
generation: ++listGeneration,
|
||||||
|
query: { ...queryParams.value }
|
||||||
|
}
|
||||||
loading.value = true
|
loading.value = true
|
||||||
loadError.value = ''
|
loadError.value = ''
|
||||||
listTunnel({ ...queryParams.value }).then((response) => {
|
listTunnel(listContext.query).then((response) => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tunnelList.value = response.rows
|
tunnelList.value = response.rows
|
||||||
total.value = response.total
|
total.value = response.total
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
tunnelList.value = []
|
tunnelList.value = []
|
||||||
total.value = 0
|
total.value = 0
|
||||||
loadError.value = '隧道列表加载失败,请稍后重试'
|
loadError.value = '隧道列表加载失败,请稍后重试'
|
||||||
}).finally(() => { loading.value = false })
|
}).finally(() => {
|
||||||
|
if (!isCurrentListRequest(listContext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loading.value = false
|
||||||
|
})
|
||||||
}
|
}
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = ''
|
formError.value = ''
|
||||||
|
formMeta.value = null
|
||||||
form.value = { tunnelId: undefined, siteId: undefined, tunnelCode: '', tunnelName: '', status: '0', remark: '' }
|
form.value = { tunnelId: undefined, siteId: undefined, tunnelCode: '', tunnelName: '', status: '0', remark: '' }
|
||||||
proxy.resetForm('tunnelRef')
|
proxy.resetForm('tunnelRef')
|
||||||
}
|
}
|
||||||
|
function invalidateDetailRequest() {
|
||||||
|
detailGeneration += 1
|
||||||
|
detailLoading.value = false
|
||||||
|
}
|
||||||
|
function isCurrentDetailRequest(detailContext) {
|
||||||
|
return detailContext.generation === detailGeneration
|
||||||
|
}
|
||||||
|
function cancel() {
|
||||||
|
if (submitLoading.value) return
|
||||||
|
invalidateDetailRequest()
|
||||||
|
open.value = false
|
||||||
|
reset()
|
||||||
|
}
|
||||||
|
function handleDialogBeforeClose(done) {
|
||||||
|
if (submitLoading.value) return
|
||||||
|
invalidateDetailRequest()
|
||||||
|
reset()
|
||||||
|
done()
|
||||||
|
}
|
||||||
function handleQuery() { queryParams.value.pageNum = 1; getList() }
|
function handleQuery() { queryParams.value.pageNum = 1; getList() }
|
||||||
function resetQuery() { proxy.resetForm('queryRef'); handleQuery() }
|
function resetQuery() { proxy.resetForm('queryRef'); routeFilter.value = { siteName: '' }; handleQuery() }
|
||||||
function handleSelectionChange(selection) { selectedIds.value = selection.map((item) => item.tunnelId); single.value = selectedIds.value.length !== 1 }
|
function handleSelectionChange(selection) {
|
||||||
|
selectedIds.value = selection.map((item) => item.tunnelId)
|
||||||
|
single.value = selectedIds.value.length !== 1
|
||||||
|
}
|
||||||
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
|
function openAdoptionDialog() { adoptionDialogRef.value?.open() }
|
||||||
function handleAdd() { reset(); title.value = '新增隧道'; open.value = true }
|
function handleAdd() {
|
||||||
|
invalidateDetailRequest()
|
||||||
|
reset()
|
||||||
|
title.value = '新增隧道'
|
||||||
|
open.value = true
|
||||||
|
}
|
||||||
function handleUpdate(row = {}) {
|
function handleUpdate(row = {}) {
|
||||||
|
reset()
|
||||||
const tunnelId = row.tunnelId || selectedIds.value[0]
|
const tunnelId = row.tunnelId || selectedIds.value[0]
|
||||||
if (!tunnelId) return
|
if (!tunnelId) return
|
||||||
|
const detailContext = {
|
||||||
|
generation: ++detailGeneration,
|
||||||
|
resourceId: tunnelId
|
||||||
|
}
|
||||||
detailLoading.value = true
|
detailLoading.value = true
|
||||||
getTunnel(tunnelId).then((response) => {
|
getTunnel(detailContext.resourceId).then((response) => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
const detail = response.data
|
const detail = response.data
|
||||||
|
formMeta.value = { currentTraceNo: detail.currentTraceNo, by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime }
|
||||||
form.value = { tunnelId: detail.tunnelId, siteId: detail.siteId, tunnelCode: detail.tunnelCode, tunnelName: detail.tunnelName, status: detail.status, remark: detail.remark || '' }
|
form.value = { tunnelId: detail.tunnelId, siteId: detail.siteId, tunnelCode: detail.tunnelCode, tunnelName: detail.tunnelName, status: detail.status, remark: detail.remark || '' }
|
||||||
title.value = '修改隧道'
|
title.value = '修改隧道'
|
||||||
open.value = true
|
open.value = true
|
||||||
}).catch(() => { formError.value = '隧道详情加载失败,请稍后重试' }).finally(() => { detailLoading.value = false })
|
}).catch(() => {
|
||||||
|
if (!isCurrentDetailRequest(detailContext)) {
|
||||||
|
return
|
||||||
}
|
}
|
||||||
function cancel() { if (submitLoading.value) return; open.value = false; reset() }
|
loadError.value = '隧道详情加载失败,请稍后重试'
|
||||||
function submitCreate() { submitForm(addTunnel, '新增成功') }
|
}).finally(() => {
|
||||||
function submitUpdate() { submitForm(updateTunnel, '修改成功') }
|
if (!isCurrentDetailRequest(detailContext)) {
|
||||||
function submitForm(action, successMessage) {
|
return
|
||||||
|
}
|
||||||
|
detailLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function afterSubmit(response) {
|
||||||
|
proxy.$modal.msgSuccess(response.msg || '操作成功')
|
||||||
|
open.value = false
|
||||||
|
reset()
|
||||||
|
getList()
|
||||||
|
}
|
||||||
|
function submitCreate() {
|
||||||
if (submitLoading.value) return
|
if (submitLoading.value) return
|
||||||
proxy.$refs.tunnelRef.validate((valid) => {
|
proxy.$refs.tunnelRef.validate((valid) => {
|
||||||
if (!valid) return
|
if (!valid) return
|
||||||
submitLoading.value = true
|
submitLoading.value = true
|
||||||
action({ ...form.value }).then(() => { proxy.$modal.msgSuccess(successMessage); open.value = false; reset(); getList() }).catch(() => { formError.value = '保存失败,请检查现场和隧道编号后重试' }).finally(() => { submitLoading.value = false })
|
formError.value = ''
|
||||||
|
addTunnel({
|
||||||
|
siteId: form.value.siteId,
|
||||||
|
tunnelCode: form.value.tunnelCode.trim(),
|
||||||
|
tunnelName: form.value.tunnelName.trim(),
|
||||||
|
status: form.value.status,
|
||||||
|
remark: form.value.remark?.trim() || undefined
|
||||||
|
}).then(afterSubmit).catch(() => {
|
||||||
|
formError.value = '保存失败,请检查现场和隧道编号后重试'
|
||||||
|
}).finally(() => { submitLoading.value = false })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
function submitUpdate() {
|
||||||
|
if (submitLoading.value) return
|
||||||
|
proxy.$refs.tunnelRef.validate((valid) => {
|
||||||
|
if (!valid) return
|
||||||
|
submitLoading.value = true
|
||||||
|
formError.value = ''
|
||||||
|
updateTunnel({
|
||||||
|
tunnelId: form.value.tunnelId,
|
||||||
|
siteId: form.value.siteId,
|
||||||
|
tunnelCode: form.value.tunnelCode.trim(),
|
||||||
|
tunnelName: form.value.tunnelName.trim(),
|
||||||
|
status: form.value.status,
|
||||||
|
remark: form.value.remark?.trim() || undefined
|
||||||
|
}).then(afterSubmit).catch(() => {
|
||||||
|
formError.value = '保存失败,请检查现场和隧道编号后重试'
|
||||||
|
}).finally(() => { submitLoading.value = false })
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function tunnelRowClass({ row }) {
|
||||||
|
return row.status === '1' ? 'is-muted-row' : ''
|
||||||
|
}
|
||||||
|
function shortTrace(traceNo) {
|
||||||
|
const text = String(traceNo || '')
|
||||||
|
return text.length > 18 ? `${text.slice(0, 8)}…${text.slice(-6)}` : text
|
||||||
|
}
|
||||||
|
function clearRouteFilter() {
|
||||||
|
queryParams.value.siteId = undefined
|
||||||
|
routeFilter.value = { siteName: '' }
|
||||||
|
handleQuery()
|
||||||
|
}
|
||||||
|
// 从客户详情跳转:按现场筛选,可直接打开新增并预填现场
|
||||||
|
// 表单里的上级现场选项:每次打开弹窗时刷新,加载失败时退回为直接输入 ID
|
||||||
|
const siteOptions = ref([])
|
||||||
|
const siteOptionLoading = ref(false)
|
||||||
|
const siteOptionFailed = ref(false)
|
||||||
|
let siteOptionGeneration = 0
|
||||||
|
function loadSiteOptions() {
|
||||||
|
const generation = ++siteOptionGeneration
|
||||||
|
siteOptionLoading.value = true
|
||||||
|
siteOptionFailed.value = false
|
||||||
|
getTunnelSiteOptions().then((response) => {
|
||||||
|
if (generation !== siteOptionGeneration) return
|
||||||
|
siteOptions.value = Array.isArray(response.data) ? response.data : []
|
||||||
|
}).catch(() => {
|
||||||
|
if (generation !== siteOptionGeneration) return
|
||||||
|
siteOptions.value = []
|
||||||
|
siteOptionFailed.value = true
|
||||||
|
}).finally(() => {
|
||||||
|
if (generation === siteOptionGeneration) siteOptionLoading.value = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
watch(open, (visible) => {
|
||||||
|
if (visible) loadSiteOptions()
|
||||||
|
})
|
||||||
|
const consumeRouteIntent = useMasterRouteIntent((intent, { initial }) => {
|
||||||
|
const siteId = positiveId(intent.siteId)
|
||||||
|
const changed = Boolean(siteId) && siteId !== queryParams.value.siteId
|
||||||
|
if (siteId) {
|
||||||
|
queryParams.value.siteId = siteId
|
||||||
|
routeFilter.value = { siteName: String(intent.siteName || '') }
|
||||||
|
if (intent.siteName) routeSiteNames.value = { ...routeSiteNames.value, [siteId]: String(intent.siteName) }
|
||||||
|
}
|
||||||
|
if (intent.action === 'add' && isMasterWriteAllowed.value && proxy.$auth.hasPermiAnd(['master:tunnel:add'])) {
|
||||||
|
handleAdd()
|
||||||
|
form.value.siteId = siteId
|
||||||
|
}
|
||||||
|
if (!initial && changed) handleQuery()
|
||||||
|
})
|
||||||
|
consumeRouteIntent({ initial: true })
|
||||||
getList()
|
getList()
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -1,14 +1,52 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container master-page">
|
<div class="app-container master-page master-warehouse">
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter" :model="queryParams" :inline="true">
|
<page-header>
|
||||||
<el-form-item label="仓库编码" prop="warehouseCode">
|
<template #actions>
|
||||||
<el-input v-model="queryParams.warehouseCode" placeholder="请输入仓库编码" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-row :gutter="10" class="mb8 master-toolbar">
|
||||||
|
<template v-if="isMasterWriteAllowed">
|
||||||
|
<el-col v-if="userStore.admin === true && !testBatchStore.isTestMode && selectedIds.length" :span="1.5">
|
||||||
|
<el-button icon="FolderAdd" :disabled="!selectedIds.length" @click="openAdoptionDialog" v-hasPermi="['system:testbatch:adopt']">纳入测试批次</el-button>
|
||||||
|
</el-col>
|
||||||
|
<el-col :span="1.5" class="mp-primary-col"><el-button type="primary" icon="Plus" @click="handleAdd" v-hasPermi="['master:warehouse:add']">新增仓库</el-button></el-col>
|
||||||
|
</template>
|
||||||
|
<master-excel-actions resource="warehouse" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
||||||
|
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
||||||
|
</el-row>
|
||||||
|
</template>
|
||||||
|
</page-header>
|
||||||
|
|
||||||
|
<section class="mp-kpis" aria-label="仓库概况">
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">仓库</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(total) }}</span>
|
||||||
|
<span class="mp-kpi__hint">本页正常 {{ formatCount(pageTotals.active) }} · 停用 {{ formatCount(pageTotals.disabled) }}</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">库存</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.packages) }}<small>包</small></span>
|
||||||
|
<span class="mp-kpi__hint">有料 {{ formatCount(pageTotals.filled) }} 托 · 空托 {{ formatCount(pageTotals.empty) }} 托</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">今日发货</span>
|
||||||
|
<span class="mp-kpi__value">{{ formatCount(pageTotals.shipped) }}<small>托</small></span>
|
||||||
|
<span class="mp-kpi__hint">今日装填 {{ formatCount(pageTotals.filledToday) }} 托</span>
|
||||||
|
</div>
|
||||||
|
<div class="mp-kpi">
|
||||||
|
<span class="mp-kpi__label">待处理</span>
|
||||||
|
<span class="mp-kpi__value" :class="{ 'is-warn': pageTotals.pending }">{{ formatCount(pageTotals.pending) }}<small>单</small></span>
|
||||||
|
<span class="mp-kpi__hint">草稿与待确认退回 · {{ EXPIRY_WARNING_DAYS }} 天内到期 {{ formatCount(pageTotals.expiring) }} 托</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<el-form ref="queryRef" v-show="showSearch" class="master-query page-filter mp-filter" :model="queryParams" :inline="true" @submit.prevent>
|
||||||
|
<el-form-item prop="warehouseName" class="mp-filter__search">
|
||||||
|
<el-input v-model="queryParams.warehouseName" placeholder="搜索仓库名称" prefix-icon="Search" clearable maxlength="100" @keyup.enter="handleQuery" @clear="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="仓库名称" prop="warehouseName">
|
<el-form-item label="编码" prop="warehouseCode">
|
||||||
<el-input v-model="queryParams.warehouseName" placeholder="请输入仓库名称" clearable maxlength="100" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.warehouseCode" placeholder="仓库编码" clearable maxlength="64" style="width: 150px" @keyup.enter="handleQuery" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status" class="mp-filter__select">
|
||||||
<el-select v-model="queryParams.status" placeholder="请选择状态" clearable style="width: 160px">
|
<el-select v-model="queryParams.status" placeholder="全部" clearable @change="handleQuery">
|
||||||
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
<el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
|
||||||
</el-select>
|
</el-select>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
@@ -16,41 +54,94 @@
|
|||||||
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:warehouse:list']">搜索</el-button>
|
<el-button type="primary" icon="Search" @click="handleQuery" v-hasPermi="['master:warehouse:list']">搜索</el-button>
|
||||||
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:warehouse:list']">重置</el-button>
|
<el-button icon="Refresh" @click="resetQuery" v-hasPermi="['master:warehouse:list']">重置</el-button>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
<span class="mp-filter__summary">共 {{ formatCount(total) }} 个仓库</span>
|
||||||
|
<el-radio-group v-model="viewMode" class="sf-segmented warehouse-view" aria-label="显示方式" @change="rememberViewMode">
|
||||||
|
<el-radio-button value="card">卡片</el-radio-button>
|
||||||
|
<el-radio-button value="list">列表</el-radio-button>
|
||||||
|
</el-radio-group>
|
||||||
</el-form>
|
</el-form>
|
||||||
|
|
||||||
<el-row :gutter="10" class="mb8 master-toolbar">
|
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
|
|
||||||
|
<div v-if="viewMode === 'card'" v-loading="loading" class="warehouse-cards">
|
||||||
|
<section v-for="warehouse in warehouseList" :key="warehouse.warehouseId" class="warehouse-card" :class="{ 'is-off': warehouse.status === '1' }">
|
||||||
|
<div class="warehouse-card__head">
|
||||||
|
<span class="warehouse-card__icon" aria-hidden="true"><sf-icon name="warehouse" :size="18" /></span>
|
||||||
|
<span class="warehouse-card__title">
|
||||||
|
<span><strong>{{ warehouse.warehouseName }}</strong><span class="mp-mono mp-muted">{{ warehouse.warehouseCode }}</span></span>
|
||||||
|
<small>{{ warehouse.address || '未登记地址' }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="mp-pill" :class="warehouse.status === '1' ? '' : 'is-ok'">{{ warehouse.status === '1' ? '停用' : '正常' }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warehouse-card__stock">
|
||||||
|
<span class="warehouse-card__stock-line">
|
||||||
|
<span><strong class="mp-num">{{ formatCount(warehouse.filledPackageQty ?? 0) }}</strong> 包库存</span>
|
||||||
|
<small class="mp-num">有料 {{ formatCount(warehouse.filledPalletCount ?? 0) }} · 空托 {{ formatCount(warehouse.emptyPalletCount ?? 0) }}</small>
|
||||||
|
</span>
|
||||||
|
<span class="mp-stack" role="img" :aria-label="`有料 ${warehouse.filledPalletCount ?? 0} 托,空托 ${warehouse.emptyPalletCount ?? 0} 托`">
|
||||||
|
<span :style="{ flex: warehouse.filledPalletCount || 0, background: 'var(--sf-accent)' }"></span>
|
||||||
|
<span :style="{ flex: warehouse.emptyPalletCount || 0, background: 'var(--sf-border-strong)' }"></span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warehouse-card__stats">
|
||||||
|
<span><small>今日装填</small><strong class="mp-num">{{ warehouse.todayFillingCount ?? 0 }} 托</strong></span>
|
||||||
|
<span><small>今日发货</small><strong class="mp-num">{{ warehouse.todayShipmentPalletCount ?? 0 }} 托</strong></span>
|
||||||
|
<span :title="`入库与发货草稿 ${warehouse.draftCount ?? 0} 单,待确认退回 ${warehouse.incomingReturnCount ?? 0} 单`">
|
||||||
|
<small>待处理</small><strong class="mp-num" :class="{ 'is-warn': pendingCount(warehouse) }">{{ pendingCount(warehouse) }} 单</strong>
|
||||||
|
</span>
|
||||||
|
<span><small>{{ EXPIRY_WARNING_DAYS }} 天内到期</small><strong class="mp-num" :class="{ 'is-warn': warehouse.expiringPalletCount }">{{ warehouse.expiringPalletCount ?? 0 }} 托</strong></span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="warehouse-card__foot">
|
||||||
|
<span v-if="warehouse.userCount" class="mp-avatars">
|
||||||
|
<span v-for="user in (warehouse.users || []).slice(0, 4)" :key="user.userId" class="mp-avatar" :style="{ '--mp-avatar': avatarTint(user.userId) }" :title="userDisplayName(user)">{{ personInitial(user) }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="warehouse-card__users" :class="{ 'is-empty': !warehouse.userCount }">{{ userSummary(warehouse) }}</span>
|
||||||
<template v-if="isMasterWriteAllowed">
|
<template v-if="isMasterWriteAllowed">
|
||||||
<el-col :span="1.5"><el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['master:warehouse:add']">新增</el-button></el-col>
|
<el-button size="small" @click="handleAssign(warehouse)" v-hasPermi="['master:warehouse:assign']">分配用户</el-button>
|
||||||
<el-col :span="1.5">
|
|
||||||
<el-button
|
<el-button
|
||||||
v-if="proxy.$auth.hasPermiAnd(['master:warehouse:query', 'master:warehouse:edit'])"
|
v-if="proxy.$auth.hasPermiAnd(['master:warehouse:query', 'master:warehouse:edit'])"
|
||||||
type="success" plain icon="Edit" :disabled="single || detailLoading" :loading="detailLoading"
|
size="small" text icon="Edit" :aria-label="`编辑 ${warehouse.warehouseName}`" :disabled="detailLoading" @click="handleUpdate(warehouse)" v-hasPermi="['master:warehouse:edit']"
|
||||||
@click="handleUpdate()" v-hasPermi="['master:warehouse:edit']"
|
/>
|
||||||
>修改</el-button>
|
|
||||||
</el-col>
|
|
||||||
<el-col :span="1.5"><el-button type="warning" plain icon="User" :disabled="single" @click="handleAssign()" v-hasPermi="['master:warehouse:assign']">分配用户</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>
|
</template>
|
||||||
<master-excel-actions resource="warehouse" :allow-import="isMasterWriteAllowed" :query-params="queryParams" :selected-ids="selectedIds" @imported="getList" />
|
</div>
|
||||||
<right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
|
</section>
|
||||||
</el-row>
|
<p v-if="!loading && !warehouseList.length" class="mp-empty warehouse-cards__empty">暂无数据</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<el-alert v-if="loadError" :title="loadError" type="error" :closable="false" show-icon class="mb8" />
|
<el-table v-else class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="warehouseList" :row-class-name="warehouseRowClass" @selection-change="handleSelectionChange">
|
||||||
<el-table class="master-table data-table" v-loading="loading" empty-text="暂无数据" :data="warehouseList" @selection-change="handleSelectionChange">
|
<el-table-column type="selection" width="44" align="center" />
|
||||||
<el-table-column type="selection" width="55" align="center" />
|
<el-table-column label="仓库" min-width="200">
|
||||||
<el-table-column label="仓库ID" prop="warehouseId" align="center" />
|
<template #default="scope">
|
||||||
<el-table-column label="仓库编码" prop="warehouseCode" align="center" :show-overflow-tooltip="true" />
|
<span class="mp-name-cell__text">
|
||||||
<el-table-column label="仓库名称" prop="warehouseName" align="center" :show-overflow-tooltip="true" />
|
<strong>{{ scope.row.warehouseName }} <span class="mp-mono mp-muted">{{ scope.row.warehouseCode }}</span></strong>
|
||||||
<el-table-column label="地址" prop="address" align="center" :show-overflow-tooltip="true" />
|
<small>{{ scope.row.address || '未登记地址' }}</small>
|
||||||
<el-table-column label="状态" prop="status" align="center" width="100">
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="库存" min-width="150" align="right">
|
||||||
|
<template #default="scope">
|
||||||
|
<span class="mp-name-cell__text warehouse-table__stock">
|
||||||
|
<strong class="mp-num">{{ formatCount(scope.row.filledPackageQty ?? 0) }} 包</strong>
|
||||||
|
<small class="mp-num">有料 {{ scope.row.filledPalletCount ?? 0 }} · 空托 {{ scope.row.emptyPalletCount ?? 0 }}</small>
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="今日装填 / 发货" width="130" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num">{{ scope.row.todayFillingCount ?? 0 }} / {{ scope.row.todayShipmentPalletCount ?? 0 }} 托</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="待处理" width="90" align="right">
|
||||||
|
<template #default="scope"><span class="mp-num" :class="{ 'warehouse-table__warn': pendingCount(scope.row) }">{{ pendingCount(scope.row) }} 单</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="授权用户" min-width="140" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><span :class="{ 'mp-muted': !scope.row.userCount }">{{ userSummary(scope.row) }}</span></template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="状态" prop="status" width="84">
|
||||||
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
<template #default="scope"><dict-tag :options="sys_normal_disable" :value="scope.row.status" /></template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="创建时间" prop="createTime" align="center" width="180">
|
<el-table-column v-if="isMasterWriteAllowed" label="操作" width="150" align="right" class-name="small-padding fixed-width nowrap-cell">
|
||||||
<template #default="scope"><span>{{ parseTime(scope.row.createTime) }}</span></template>
|
|
||||||
</el-table-column>
|
|
||||||
<el-table-column v-if="isMasterWriteAllowed" label="操作" align="center" width="260" class-name="small-padding fixed-width">
|
|
||||||
<template #default="scope">
|
<template #default="scope">
|
||||||
<el-button link type="primary" icon="User" @click="handleAssign(scope.row)" v-hasPermi="['master:warehouse:assign']">分配</el-button>
|
<el-button link type="primary" icon="User" @click="handleAssign(scope.row)" v-hasPermi="['master:warehouse:assign']">分配</el-button>
|
||||||
<el-button
|
<el-button
|
||||||
@@ -70,38 +161,47 @@
|
|||||||
<el-dialog
|
<el-dialog
|
||||||
v-model="open"
|
v-model="open"
|
||||||
:title="title"
|
:title="title"
|
||||||
width="min(560px, 92vw)"
|
width="min(620px, 94vw)"
|
||||||
append-to-body
|
append-to-body
|
||||||
|
class="sf-form-dialog"
|
||||||
:close-on-click-modal="!submitLoading"
|
:close-on-click-modal="!submitLoading"
|
||||||
:close-on-press-escape="!submitLoading"
|
:close-on-press-escape="!submitLoading"
|
||||||
:show-close="!submitLoading"
|
:show-close="!submitLoading"
|
||||||
:before-close="handleDialogBeforeClose"
|
:before-close="handleDialogBeforeClose"
|
||||||
>
|
>
|
||||||
|
<p class="sf-form-intro">仓库是入库、装填、发货与退回的归属地。创建后在仓库卡片上分配用户,授权用户才能在该仓库作业。</p>
|
||||||
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
<el-alert v-if="formError" :title="formError" type="error" :closable="false" show-icon class="mb8" />
|
||||||
<el-form ref="warehouseRef" :model="form" :rules="rules" label-width="90px">
|
<el-form ref="warehouseRef" :model="form" :rules="rules" label-position="top" class="sf-form">
|
||||||
|
<div class="sf-form-grid">
|
||||||
<el-form-item label="仓库编码" prop="warehouseCode">
|
<el-form-item label="仓库编码" prop="warehouseCode">
|
||||||
<el-input v-model="form.warehouseCode" placeholder="请输入仓库编码" maxlength="64" show-word-limit />
|
<el-input v-model="form.warehouseCode" class="sf-form-mono" placeholder="如 W-01" maxlength="64" />
|
||||||
|
<div class="sf-form-hint">全局唯一,最长 64 字</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="仓库名称" prop="warehouseName">
|
<el-form-item label="仓库名称" prop="warehouseName">
|
||||||
<el-input v-model="form.warehouseName" placeholder="请输入仓库名称" maxlength="100" show-word-limit />
|
<el-input v-model="form.warehouseName" placeholder="如 成品一仓" maxlength="100" />
|
||||||
|
<div class="sf-form-hint">各作业页面选择仓库时显示</div>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
|
</div>
|
||||||
<el-form-item label="地址" prop="address">
|
<el-form-item label="地址" prop="address">
|
||||||
<el-input v-model="form.address" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="请输入地址" />
|
<el-input v-model="form.address" type="textarea" :rows="2" maxlength="500" show-word-limit placeholder="选填,例如库区位置、温控条件" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="状态" prop="status">
|
<el-form-item label="状态" prop="status">
|
||||||
<el-radio-group v-model="form.status">
|
<el-radio-group v-model="form.status" class="sf-choice">
|
||||||
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
|
<el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value" border>
|
||||||
|
<strong>{{ dict.label }}</strong><small>{{ STATUS_HINTS[dict.value] }}</small>
|
||||||
|
</el-radio>
|
||||||
</el-radio-group>
|
</el-radio-group>
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
<el-form-item label="备注" prop="remark">
|
<el-form-item label="备注" prop="remark">
|
||||||
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="请输入备注" />
|
<el-input v-model="form.remark" type="textarea" :rows="3" maxlength="500" show-word-limit placeholder="选填" />
|
||||||
</el-form-item>
|
</el-form-item>
|
||||||
</el-form>
|
</el-form>
|
||||||
<template #footer>
|
<template #footer>
|
||||||
<div class="dialog-footer">
|
<div class="dialog-footer sf-form-footer">
|
||||||
<el-button v-if="!form.warehouseId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:warehouse:add']">确 定</el-button>
|
<span class="sf-form-footer__note">{{ formMetaText }}</span>
|
||||||
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:warehouse:edit']">确 定</el-button>
|
|
||||||
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
<el-button :disabled="submitLoading" @click="cancel">取消</el-button>
|
||||||
|
<el-button v-if="!form.warehouseId" type="primary" :loading="submitLoading" @click="submitCreate" v-hasPermi="['master:warehouse:add']">创建仓库</el-button>
|
||||||
|
<el-button v-else type="primary" :loading="submitLoading" @click="submitUpdate" v-hasPermi="['master:warehouse:edit']">保存修改</el-button>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</el-dialog>
|
</el-dialog>
|
||||||
@@ -111,19 +211,50 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<style scoped>
|
<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; } .master-query :deep(.el-form-item__content) { 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) { 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-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); }
|
||||||
.master-toolbar { display:flex; flex-wrap:wrap; gap:8px; } .master-toolbar :deep(.el-col) { width:auto; }
|
.master-table :deep(.is-muted-row .cell) { opacity:.62; }
|
||||||
.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); }
|
.warehouse-view { margin:0 0 10px 12px; }
|
||||||
@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; } }
|
.warehouse-cards { display:grid; grid-template-columns:repeat(auto-fill, minmax(330px, 1fr)); gap:16px; min-height:120px; }
|
||||||
|
.warehouse-cards__empty { grid-column:1 / -1; }
|
||||||
|
.warehouse-card { display:flex; flex-direction:column; min-width:0; border:1px solid var(--sf-border, #e7e9ec); border-radius:12px; background:var(--sf-surface, #fff); }
|
||||||
|
.warehouse-card.is-off { opacity:.62; }
|
||||||
|
.warehouse-card__head { display:flex; align-items:flex-start; gap:12px; padding:16px 18px 12px; }
|
||||||
|
.warehouse-card__icon { display:inline-flex; flex:none; align-items:center; justify-content:center; width:38px; height:38px; border-radius:10px; background:var(--sf-accent-soft, #e6f3f5); color:var(--sf-accent, #007787); }
|
||||||
|
.warehouse-card.is-off .warehouse-card__icon { background:var(--sf-surface-3, #f1f2f4); color:var(--sf-text-4, #6b717d); }
|
||||||
|
.warehouse-card__title { display:flex; flex:1; flex-direction:column; min-width:0; line-height:1.4; }
|
||||||
|
.warehouse-card__title > span { display:flex; flex-wrap:wrap; align-items:center; gap:8px; }
|
||||||
|
.warehouse-card__title strong { font-size:15px; font-weight:600; }
|
||||||
|
.warehouse-card__title small { overflow:hidden; color:var(--sf-text-4, #6b717d); font-size:12.5px; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
.warehouse-card__stock { display:flex; flex-direction:column; gap:8px; padding:0 18px 14px; }
|
||||||
|
.warehouse-card__stock-line { display:flex; align-items:baseline; justify-content:space-between; gap:8px; color:var(--sf-text-3, #5b616e); font-size:12.5px; }
|
||||||
|
.warehouse-card__stock-line strong { color:var(--sf-text-1, #0f1115); font-size:22px; font-weight:600; letter-spacing:-.02em; }
|
||||||
|
.warehouse-card__stock-line small { color:var(--sf-text-2, #4b5260); font-size:12.5px; }
|
||||||
|
.warehouse-card__stats { display:grid; grid-template-columns:repeat(4, minmax(0, 1fr)); border-top:1px solid var(--sf-border-soft, #f1f2f4); border-bottom:1px solid var(--sf-border-soft, #f1f2f4); }
|
||||||
|
.warehouse-card__stats > span { display:flex; flex-direction:column; min-width:0; padding:10px 12px 10px 18px; line-height:1.35; }
|
||||||
|
.warehouse-card__stats small { color:var(--sf-text-4, #6b717d); font-size:12px; white-space:nowrap; }
|
||||||
|
.warehouse-card__stats strong { font-size:15px; font-weight:600; }
|
||||||
|
.warehouse-card__stats strong.is-warn, .warehouse-table__warn { color:var(--sf-warn-fg, #b54708); }
|
||||||
|
.warehouse-card__foot { display:flex; align-items:center; gap:10px; margin-top:auto; padding:12px 14px 12px 18px; }
|
||||||
|
.warehouse-card__foot .el-button + .el-button { margin-left:0; }
|
||||||
|
.warehouse-card__users { flex:1; min-width:0; overflow:hidden; color:var(--sf-text-2, #4b5260); font-size:12.5px; white-space:nowrap; text-overflow:ellipsis; }
|
||||||
|
.warehouse-card__users.is-empty { color:var(--sf-warn-fg, #b54708); }
|
||||||
|
.warehouse-table__stock { align-items:flex-end; }
|
||||||
|
@media (max-width:768px) {
|
||||||
|
.warehouse-cards { grid-template-columns:1fr; }
|
||||||
|
.warehouse-card__stats { grid-template-columns:repeat(2, minmax(0, 1fr)); }
|
||||||
|
.warehouse-view { margin-left:0; }
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script setup name="Warehouse">
|
<script setup name="Warehouse">
|
||||||
import BusinessUserAssignDialog from "@/components/BusinessUserAssignDialog";
|
import BusinessUserAssignDialog from "@/components/BusinessUserAssignDialog";
|
||||||
|
import SfIcon from "@/components/SfIcon";
|
||||||
import { addWarehouse, getWarehouse, listWarehouse, updateWarehouse } from "@/api/master/warehouse";
|
import { addWarehouse, getWarehouse, listWarehouse, updateWarehouse } from "@/api/master/warehouse";
|
||||||
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
import TestBatchAdoptionDialog from '@/components/TestBatchAdoptionDialog'
|
||||||
import useUserStore from '@/store/modules/user'
|
import useUserStore from '@/store/modules/user'
|
||||||
import useTestBatchStore from '@/store/modules/testBatch'
|
import useTestBatchStore from '@/store/modules/testBatch'
|
||||||
|
import { avatarTint, formatCount, personInitial, userDisplayName } from '@/utils/masterDisplay'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance();
|
const { proxy } = getCurrentInstance();
|
||||||
const userStore = useUserStore()
|
const userStore = useUserStore()
|
||||||
@@ -147,6 +278,27 @@ const total = ref(0);
|
|||||||
const title = ref("");
|
const title = ref("");
|
||||||
let listGeneration = 0;
|
let listGeneration = 0;
|
||||||
let detailGeneration = 0;
|
let detailGeneration = 0;
|
||||||
|
// 状态选项的后果说明,与服务端校验保持一致
|
||||||
|
const STATUS_HINTS = { '0': '可以在该仓库装填、发货和确认退回', '1': '装填、发货与退回确认都会被拒绝;历史记录保留' };
|
||||||
|
const formMeta = ref(null);
|
||||||
|
const formMetaText = computed(() => formMeta.value
|
||||||
|
? [`最后修改${formMeta.value.by ? ` ${formMeta.value.by}` : ''}`, proxy.parseTime(formMeta.value.time, '{y}-{m}-{d} {h}:{i}')].filter(Boolean).join(' · ')
|
||||||
|
: '带 * 的为必填项');
|
||||||
|
const EXPIRY_WARNING_DAYS = 7;
|
||||||
|
const VIEW_STORAGE_KEY = "sf.master.warehouse.view";
|
||||||
|
const viewMode = ref(readViewMode());
|
||||||
|
const pageTotals = computed(() => warehouseList.value.reduce((totals, row) => {
|
||||||
|
if (row.status === "1") totals.disabled += 1;
|
||||||
|
else totals.active += 1;
|
||||||
|
totals.packages += row.filledPackageQty || 0;
|
||||||
|
totals.filled += row.filledPalletCount || 0;
|
||||||
|
totals.empty += row.emptyPalletCount || 0;
|
||||||
|
totals.shipped += row.todayShipmentPalletCount || 0;
|
||||||
|
totals.filledToday += row.todayFillingCount || 0;
|
||||||
|
totals.pending += pendingCount(row);
|
||||||
|
totals.expiring += row.expiringPalletCount || 0;
|
||||||
|
return totals;
|
||||||
|
}, { active: 0, disabled: 0, packages: 0, filled: 0, empty: 0, shipped: 0, filledToday: 0, pending: 0, expiring: 0 }));
|
||||||
|
|
||||||
function validateRequiredText(rule, value, callback) {
|
function validateRequiredText(rule, value, callback) {
|
||||||
const labels = { warehouseCode: "仓库编码", warehouseName: "仓库名称" };
|
const labels = { warehouseCode: "仓库编码", warehouseName: "仓库名称" };
|
||||||
@@ -200,6 +352,7 @@ function getList() {
|
|||||||
}
|
}
|
||||||
function reset() {
|
function reset() {
|
||||||
formError.value = "";
|
formError.value = "";
|
||||||
|
formMeta.value = null;
|
||||||
form.value = { warehouseId: undefined, warehouseCode: "", warehouseName: "", address: "", status: "0", remark: "" };
|
form.value = { warehouseId: undefined, warehouseCode: "", warehouseName: "", address: "", status: "0", remark: "" };
|
||||||
proxy.resetForm("warehouseRef");
|
proxy.resetForm("warehouseRef");
|
||||||
}
|
}
|
||||||
@@ -250,6 +403,7 @@ function handleUpdate(row = {}) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const detail = response.data;
|
const detail = response.data;
|
||||||
|
formMeta.value = { by: detail.updateBy || detail.createBy, time: detail.updateTime || detail.createTime };
|
||||||
form.value = {
|
form.value = {
|
||||||
warehouseId: detail.warehouseId,
|
warehouseId: detail.warehouseId,
|
||||||
warehouseCode: detail.warehouseCode,
|
warehouseCode: detail.warehouseCode,
|
||||||
@@ -319,5 +473,32 @@ function submitUpdate() {
|
|||||||
}).finally(() => { submitLoading.value = false; });
|
}).finally(() => { submitLoading.value = false; });
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
function readViewMode() {
|
||||||
|
try {
|
||||||
|
return localStorage.getItem(VIEW_STORAGE_KEY) === "list" ? "list" : "card";
|
||||||
|
} catch {
|
||||||
|
return "card";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function rememberViewMode(mode) {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(VIEW_STORAGE_KEY, mode);
|
||||||
|
} catch {
|
||||||
|
// 浏览器禁用本地存储时只影响下次进入的默认视图
|
||||||
|
}
|
||||||
|
}
|
||||||
|
function warehouseRowClass({ row }) {
|
||||||
|
return row.status === "1" ? "is-muted-row" : "";
|
||||||
|
}
|
||||||
|
function pendingCount(row) {
|
||||||
|
return (row.draftCount || 0) + (row.incomingReturnCount || 0);
|
||||||
|
}
|
||||||
|
function userSummary(row) {
|
||||||
|
const names = (row.users || []).map(userDisplayName).filter(Boolean);
|
||||||
|
if (!row.userCount) return "尚未授权用户";
|
||||||
|
const more = row.userCount - names.length;
|
||||||
|
return `${names.join("、")}${more > 0 ? ` 等 ${row.userCount} 人` : ""}`;
|
||||||
|
}
|
||||||
|
|
||||||
getList();
|
getList();
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<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-form-item label="隧道编号" prop="tunnelCode">
|
||||||
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
@@ -74,7 +75,9 @@
|
|||||||
<el-table-column label="产线" min-width="130" :show-overflow-tooltip="true">
|
<el-table-column label="产线" min-width="130" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.lineName || (scope.row.lineId ? `#${scope.row.lineId}` : '未登记') }}</template>
|
<template #default="scope">{{ scope.row.lineName || (scope.row.lineId ? `#${scope.row.lineId}` : '未登记') }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="菌种" min-width="150" :show-overflow-tooltip="true">
|
<el-table-column label="菌种" min-width="150" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.strainName || scope.row.strainCode || `#${scope.row.strainId}` }}</template>
|
<template #default="scope">{{ scope.row.strainName || scope.row.strainCode || `#${scope.row.strainId}` }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
||||||
<el-form-item label="仓库ID" prop="warehouseId">
|
<el-form-item label="仓库ID" prop="warehouseId">
|
||||||
<el-input-number v-model="queryParams.warehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
|
<el-input-number v-model="queryParams.warehouseId" :min="1" :controls="false" placeholder="请输入仓库ID" />
|
||||||
@@ -38,7 +39,9 @@
|
|||||||
<el-table-column label="仓库" min-width="140" :show-overflow-tooltip="true">
|
<el-table-column label="仓库" min-width="140" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.warehouseName || `#${scope.row.warehouseId}` }}</template>
|
<template #default="scope">{{ scope.row.warehouseName || `#${scope.row.warehouseId}` }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="菌种" min-width="170" :show-overflow-tooltip="true">
|
<el-table-column label="菌种" min-width="170" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
|
<template #default="scope">{{ scope.row.strainNameSnapshot || scope.row.strainCodeSnapshot || `#${scope.row.strainId}` }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<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-form-item label="隧道编号" prop="tunnelCode">
|
||||||
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
@@ -73,7 +74,9 @@
|
|||||||
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
|
<template #default="scope">{{ scope.row.expiryDate || '未登记' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<template #empty><el-empty description="暂无托盘库存数据" /></template>
|
<template #empty><el-empty description="暂无托盘库存数据" /></template>
|
||||||
<el-table-column label="托盘编号" prop="palletCode" min-width="140" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘编号" prop="palletCode" min-width="140" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="状态" prop="flowState" min-width="150">
|
<el-table-column label="状态" prop="flowState" min-width="150">
|
||||||
<template #default="scope">{{ flowStateLabel(scope.row.flowState) }}</template>
|
<template #default="scope">{{ flowStateLabel(scope.row.flowState) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -1,5 +1,22 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
|
<form class="trace-lookup" role="search" @submit="openPassport">
|
||||||
|
<span class="trace-lookup__icon" aria-hidden="true">
|
||||||
|
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M3 7V5a2 2 0 0 1 2-2h2"/><path d="M17 3h2a2 2 0 0 1 2 2v2"/><path d="M21 17v2a2 2 0 0 1-2 2h-2"/><path d="M7 21H5a2 2 0 0 1-2-2v-2"/><path d="M7 12h10"/></svg>
|
||||||
|
</span>
|
||||||
|
<span class="trace-lookup__text">
|
||||||
|
<strong>查看单个托盘档案</strong>
|
||||||
|
<span>当前状态、所在位置与本轮周转的完整流转记录</span>
|
||||||
|
</span>
|
||||||
|
<label for="trace-lookup-input" class="sf-visually-hidden">扫描托盘码或输入托盘号</label>
|
||||||
|
<input id="trace-lookup-input" v-model="passportCode" class="trace-lookup__input" type="text" autocomplete="off" spellcheck="false" maxlength="64" placeholder="扫描托盘码或输入托盘号" />
|
||||||
|
<button type="submit" class="trace-lookup__submit" :disabled="!passportReady">查看档案</button>
|
||||||
|
</form>
|
||||||
|
<div class="trace-section">
|
||||||
|
<strong>流转明细查询</strong>
|
||||||
|
<span>按隧道、内部溯源号、批次或时间查询跨托盘的逐笔记录</span>
|
||||||
|
</div>
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<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-form-item label="隧道编号" prop="tunnelCode">
|
||||||
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
@@ -64,7 +81,9 @@
|
|||||||
<el-table-column label="来源" min-width="150" :show-overflow-tooltip="true">
|
<el-table-column label="来源" min-width="150" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ sourceLabel(scope.row.sourceType, scope.row.sourceId) }}</template>
|
<template #default="scope">{{ sourceLabel(scope.row.sourceType, scope.row.sourceId) }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="生产批次快照" prop="batchNoSnapshot" min-width="140" :show-overflow-tooltip="true">
|
<el-table-column label="生产批次快照" prop="batchNoSnapshot" min-width="140" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.batchNoSnapshot || '未登记' }}</template>
|
<template #default="scope">{{ scope.row.batchNoSnapshot || '未登记' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
@@ -113,6 +132,111 @@
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.report-page { min-width: 0; }
|
.report-page { min-width: 0; }
|
||||||
|
.trace-lookup {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 12px 16px;
|
||||||
|
margin-bottom: 24px;
|
||||||
|
padding: 16px 16px 16px 18px;
|
||||||
|
border: 1px solid var(--sf-border, var(--el-border-color-lighter));
|
||||||
|
border-radius: 12px;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
.trace-lookup__icon {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex: none;
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--sf-accent-soft, var(--el-color-primary-light-9));
|
||||||
|
color: var(--sf-accent, var(--el-color-primary));
|
||||||
|
}
|
||||||
|
.trace-lookup__text {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 220px;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.45;
|
||||||
|
}
|
||||||
|
.trace-lookup__text strong {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 14.5px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.trace-lookup__text span {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
.trace-lookup__input {
|
||||||
|
flex: 1 1 280px;
|
||||||
|
min-width: 0;
|
||||||
|
max-width: 420px;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 14px;
|
||||||
|
border: 1px solid var(--sf-border-strong, var(--el-border-color));
|
||||||
|
border-radius: 10px;
|
||||||
|
outline: none;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-family: var(--sf-font-mono, monospace);
|
||||||
|
font-size: 15px;
|
||||||
|
}
|
||||||
|
.trace-lookup__input::placeholder {
|
||||||
|
color: var(--el-text-color-placeholder);
|
||||||
|
font-family: var(--sf-font-sans, inherit);
|
||||||
|
font-size: 14px;
|
||||||
|
}
|
||||||
|
.trace-lookup__input:focus {
|
||||||
|
border-color: var(--el-color-primary);
|
||||||
|
box-shadow: 0 0 0 3px var(--sf-accent-soft, var(--el-color-primary-light-9));
|
||||||
|
}
|
||||||
|
.trace-lookup__submit {
|
||||||
|
flex: none;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 20px;
|
||||||
|
border: 0;
|
||||||
|
border-radius: 10px;
|
||||||
|
background: var(--sf-accent, var(--el-color-primary));
|
||||||
|
color: #fff;
|
||||||
|
font: inherit;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
.trace-lookup__submit:hover:not(:disabled) {
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
.trace-lookup__submit:disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
.trace-section {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 4px 10px;
|
||||||
|
margin: 0 0 12px;
|
||||||
|
}
|
||||||
|
.trace-section strong {
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.trace-section span {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12.5px;
|
||||||
|
}
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.trace-lookup__input,
|
||||||
|
.trace-lookup__submit {
|
||||||
|
flex: 1 1 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
.report-filter {
|
.report-filter {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
grid-template-columns: repeat(auto-fit, minmax(260px, 1fr));
|
||||||
@@ -178,6 +302,16 @@
|
|||||||
import { listPalletTrace } from '@/api/report/palletTrace'
|
import { listPalletTrace } from '@/api/report/palletTrace'
|
||||||
|
|
||||||
const { proxy } = getCurrentInstance()
|
const { proxy } = getCurrentInstance()
|
||||||
|
// 单托盘查询直接进入托盘档案页
|
||||||
|
const passportCode = ref('')
|
||||||
|
const passportReady = computed(() => Boolean(passportCode.value.trim()))
|
||||||
|
|
||||||
|
function openPassport(event) {
|
||||||
|
event?.preventDefault()
|
||||||
|
const code = passportCode.value.trim()
|
||||||
|
if (!code) return
|
||||||
|
proxy.$router.push({ path: '/pallet/passport', query: { code } })
|
||||||
|
}
|
||||||
const reportList = ref([])
|
const reportList = ref([])
|
||||||
const traceSummary = computed(() => {
|
const traceSummary = computed(() => {
|
||||||
const used = reportList.value.reduce((sum, row) => sum + (row.usedPackages ?? 0), 0)
|
const used = reportList.value.reduce((sum, row) => sum + (row.usedPackages ?? 0), 0)
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<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-form-item label="隧道编号" prop="tunnelCode">
|
||||||
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.tunnelCode" placeholder="请输入隧道编号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
@@ -80,7 +81,9 @@
|
|||||||
<el-table-column label="目标仓库" min-width="140" :show-overflow-tooltip="true">
|
<el-table-column label="目标仓库" min-width="140" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.targetWarehouseName || `#${scope.row.targetWarehouseId}` }}</template>
|
<template #default="scope">{{ scope.row.targetWarehouseName || `#${scope.row.targetWarehouseId}` }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="退回生产批次" prop="batchNo" min-width="140" :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>
|
<template #default="scope">{{ scope.row.batchNoSnapshot || scope.row.batchNo || '未登记' }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="app-container report-page">
|
<div class="app-container report-page">
|
||||||
|
<page-header />
|
||||||
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
<el-form ref="queryRef" v-show="showSearch" class="report-filter page-filter" :model="queryParams" :inline="true" label-width="88px">
|
||||||
<el-form-item label="发货单号" prop="shipmentNo">
|
<el-form-item label="发货单号" prop="shipmentNo">
|
||||||
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
<el-input v-model="queryParams.shipmentNo" placeholder="请输入发货单号" clearable maxlength="64" @keyup.enter="handleQuery" />
|
||||||
@@ -44,7 +45,9 @@
|
|||||||
<el-table-column label="目标现场" min-width="140" :show-overflow-tooltip="true">
|
<el-table-column label="目标现场" min-width="140" :show-overflow-tooltip="true">
|
||||||
<template #default="scope">{{ scope.row.targetSiteName || `#${scope.row.targetSiteId}` }}</template>
|
<template #default="scope">{{ scope.row.targetSiteName || `#${scope.row.targetSiteId}` }}</template>
|
||||||
</el-table-column>
|
</el-table-column>
|
||||||
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true" />
|
<el-table-column label="托盘" prop="palletCode" min-width="130" :show-overflow-tooltip="true">
|
||||||
|
<template #default="scope"><pallet-code-link :code="scope.row.palletCode">{{ scope.row.palletCode }}</pallet-code-link></template>
|
||||||
|
</el-table-column>
|
||||||
<el-table-column label="发货包数" prop="packageQty" width="100" align="right" />
|
<el-table-column label="发货包数" prop="packageQty" width="100" align="right" />
|
||||||
</el-table>
|
</el-table>
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user