Files
bj_power/bj_power_wms_client/frontend/src/pages/Inbound.vue
T
SunYF c1fc6d32b2 feat: 完成多模块功能迭代与优化
本次提交包含多模块功能更新:
1. 权限系统重构:移除硬编码admin放行逻辑,新增精细化权限控制,完善权限树形结构与用户/角色保护
2. 新增物料导入导出接口,补充台账查询条件
3. 优化列表查询排序逻辑,新增区域编码大小写不敏感搜索
4. 修复帮助抽屉重复弹出问题,补充页面权限控制
5. 新增弱密码提示逻辑,优化MES/WMS用户/角色管理权限
6. 调整路由结构,新增权限校验脚本与前端权限树重构
7. 补充数据库字段与依赖包更新
2026-09-03 18:13:59 +08:00

541 lines
24 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters
This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup>
defineOptions({ name: 'Inbound' })
import { computed, onMounted, reactive, ref } from 'vue'
import { useRoute } from 'vue-router'
import { ElMessage } from 'element-plus'
import request from '../utils/request'
import { getRealName } from '../utils/auth'
import { fmtTime } from '../utils/format'
import { exportXlsxFetch } from '../utils/export'
import { can } from '../utils/perm'
import PageHelp from '../components/PageHelp.vue'
import { helpInbound } from '../help'
const route = useRoute()
const operator = getRealName()
// ===== 多 Tab:结构件入库 / 精密件 SN 入库 在前(常操作),入库记录在后 =====
const activeTab = ref('batch') // batch=结构件入库 / sn=精密件SN入库 / records=入库记录
// 公共字典:物料 / 区域
const materials = ref([])
const zones = ref([])
async function loadDicts() {
if (materials.value.length && zones.value.length) return
const [ms, zs] = await Promise.all([request.get('/material/list'), request.get('/zone/picker')])
materials.value = Array.isArray(ms) ? ms : ms?.list || []
zones.value = Array.isArray(zs) ? zs : zs?.list || []
}
// 按当前页过滤物料:结构件页只显示批次管理(manageMode=1)SN 页只显示序列号管理(manageMode=2)
const batchMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 1))
const snMaterials = computed(() => materials.value.filter((m) => Number(m.manageMode) === 2))
/* ===================== Tab1 结构件入库(批次,支持多批到货追加) ===================== */
const batchRef = ref()
const submitting1 = ref(false)
const lastInboundNo = ref('')
const form1 = reactive({
materialCode: '', batchNo: '', quantity: 1,
zoneCode: '', productionDate: '', supplier: '', remark: ''
})
const rules1 = {
materialCode: [{ required: true, message: '请选择物料', trigger: 'change' }],
zoneCode: [{ required: true, message: '请选择入库区域', trigger: 'change' }],
quantity: [{ required: true, message: '请输入数量', trigger: 'blur' }]
}
async function submitBatch() {
await batchRef.value.validate()
if (!form1.quantity || form1.quantity <= 0) {
ElMessage.warning('数量必须大于 0')
return
}
const mat1 = materials.value.find(m => m.code === form1.materialCode)
if (mat1 && Number(mat1.manageMode) === 2) {
ElMessage.warning('该物料为精密件(按 SN 管理),请切换到「精密件 SN 入库」页扫码录入后再提交')
return
}
submitting1.value = true
try {
const data = await request.post('/inbound/create', {
inboundType: 'purchase',
materialCode: form1.materialCode,
batchNo: form1.batchNo.trim(),
quantity: form1.quantity,
zoneCode: form1.zoneCode,
productionDate: form1.productionDate,
supplier: form1.supplier.trim(),
remark: form1.remark.trim(),
operator
})
lastInboundNo.value = data?.inboundNo || ''
ElMessage.success(`入库成功:入库单号 ${data?.inboundNo || ''},数量 ${data?.quantity ?? form1.quantity}`)
form1.batchNo = ''
form1.quantity = 1
// 入库成功后刷新记录 Tab
await loadRecords()
} finally {
submitting1.value = false
}
}
/* ===================== Tab2 精密件 SN 入库(连续扫码、实时已扫数量) ===================== */
const snInput = ref('')
const submitting2 = ref(false)
const snList = ref([])
const form2 = reactive({ materialCode: '', zoneCode: '', remark: '' })
// 支持多种分隔符:中英文逗号、中英文句号、分号、顿号、空格、回车、制表符等
const SN_SEP = /[,.。;;、\s]+/
function addSnLines() {
const lines = String(snInput.value).split(SN_SEP)
for (let raw of lines) {
const sn = raw.trim()
if (!sn) continue
if (snList.value.includes(sn)) continue
snList.value.push(sn)
}
snInput.value = ''
}
function removeSn(index) {
snList.value.splice(index, 1)
}
async function submitSn() {
if (!form2.materialCode) {
ElMessage.warning('请选择物料')
return
}
if (!form2.zoneCode) {
ElMessage.warning('请选择入库区域')
return
}
if (!snList.value.length) {
ElMessage.warning('请先扫码录入 SN(输入后点击【录入到列表】)')
return
}
const mat2 = materials.value.find(m => m.code === form2.materialCode)
if (mat2 && Number(mat2.manageMode) === 1) {
ElMessage.warning('该物料为结构件(按批次管理),请切换到「结构件入库」页按数量入库')
return
}
submitting2.value = true
try {
const data = await request.post('/inbound/create', {
inboundType: 'purchase',
materialCode: form2.materialCode,
zoneCode: form2.zoneCode,
snList: [...snList.value],
remark: form2.remark.trim(),
operator
})
ElMessage.success(`入库成功:入库单号 ${data?.inboundNo || ''}SN 共 ${snList.value.length} 条`)
snList.value = []
snInput.value = ''
// 入库成功后刷新记录 Tab
await loadRecords()
} finally {
submitting2.value = false
}
}
/* ===================== 各 Tab 批量导入(结构件 batch / 精密件 sn ===================== */
const importing = ref(false)
const excelResult = ref(null)
const batchUploadRef = ref()
const snUploadRef = ref()
function pickRaw(which) {
const refEl = which === 'batch' ? batchUploadRef.value : snUploadRef.value
return refEl?.uploadFiles?.[0]?.raw
}
function onExceed(files, which) {
const refEl = which === 'batch' ? batchUploadRef.value : snUploadRef.value
refEl?.clearFiles()
const file = files[0]
if (file) refEl?.handleStart(file)
}
async function importExcel(mode, which) {
const raw = pickRaw(which)
if (!raw) { ElMessage.warning('请先选择 .xlsx 文件'); return }
const fd = new FormData()
fd.append('file', raw)
fd.append('mode', mode)
importing.value = true
excelResult.value = null
try {
const data = await request.post('/inbound/batch-excel', fd)
excelResult.value = data || {}
if (data?.failed > 0) {
ElMessage.error(`导入被拒绝:共 ${data.failed} 行存在错误,已整体回退(未发生任何写入),请修正后重传`)
} else {
ElMessage.success(`批量导入成功:共 ${data?.success ?? 0} 行`)
await loadRecords()
}
} finally {
importing.value = false
}
}
/* ===================== Tab3 入库记录(统一主表 + 抽屉明细,不分页内嵌子表) ===================== */
// 查询条件
const filters = reactive({ inboundNo: '', materialCode: '', manageMode: '', keyword: '' })
const dateRange = ref([]) // [开始日期, 结束日期] YYYY-MM-DD
const loadingRec = ref(false)
const list = ref([])
const total = ref(0)
const page = reactive({ current: 1, size: 10 })
// 列表数据:结构件(manageMode=1)与精密件(manageMode=2)同主表(inbound_order)
async function loadRecords() {
loadingRec.value = true
try {
const data = await request.get('/inbound/query', {
params: {
inboundNo: filters.inboundNo.trim(),
materialCode: filters.materialCode.trim(),
manageMode: filters.manageMode,
keyword: filters.keyword.trim(),
startDate: dateRange.value?.[0] || '',
endDate: dateRange.value?.[1] || '',
page: page.current,
pageSize: page.size
}
})
list.value = data?.list || []
total.value = data?.total || 0
} finally {
loadingRec.value = false
}
}
function search() {
page.current = 1
loadRecords()
}
function resetFilters() {
filters.inboundNo = ''
filters.materialCode = ''
filters.manageMode = ''
filters.keyword = ''
dateRange.value = []
search()
}
// 导出:按当前查询条件导出全部(后端 /inbound/export 返回 xlsx
async function exportCsv() {
try {
await exportXlsxFetch('/api/inbound/export', {
inboundNo: filters.inboundNo.trim(),
materialCode: filters.materialCode.trim(),
manageMode: filters.manageMode,
keyword: filters.keyword.trim(),
startDate: dateRange.value?.[0] || '',
endDate: dateRange.value?.[1] || ''
}, '入库管理.xlsx')
} catch (e) {
/* 错误提示由请求层统一处理 */
}
}
// 入库单明细:点击"明细"用右侧抽屉懒加载 + 分页(不在列表内嵌子表,避免表格套表格)
const detailDrawer = reactive({ visible: false, inboundNo: '', list: [], total: 0, page: 1, size: 50, loading: false, loaded: false })
async function openDetail(row) {
const no = row.inboundNo
Object.assign(detailDrawer, { visible: true, inboundNo: no, list: [], total: 0, page: 1, size: 50, loaded: false })
await fetchDetailPage()
}
async function fetchDetailPage() {
const st = detailDrawer
st.loading = true
try {
const d = await request.get('/inbound/details', {
params: { inboundNo: st.inboundNo, page: st.page, pageSize: st.size }
})
st.list = d?.list || []
st.total = d?.total || 0
st.loaded = true
} catch (e) {
/* 拦截器统一提示 */
} finally {
st.loading = false
}
}
function changeDetailPage(p) {
detailDrawer.page = p
fetchDetailPage()
}
function typeLabel(m) {
return m === 1 ? '结构件' : m === 2 ? '精密件' : '-'
}
function onTabChange(tab) {
if (tab === 'batch') { loadDicts() }
else if (tab === 'sn') { loadDicts() }
else if (tab === 'records') { loadDicts(); loadRecords() }
}
onMounted(() => {
loadDicts()
// 库存页点入库单号跳转过来 → 自动切到"入库记录"Tab 并带入单号查询(闭环)
if (route.query.inboundNo) {
activeTab.value = 'records'
filters.inboundNo = String(route.query.inboundNo)
loadRecords()
}
})
</script>
<template>
<div>
<el-tabs v-model="activeTab" @tab-change="onTabChange">
<!-- ============ Tab1 结构件入库 ============ -->
<el-tab-pane label="结构件入库" name="batch">
<el-card shadow="never">
<el-form ref="batchRef" :model="form1" :rules="rules1" label-width="110px" style="max-width:560px">
<el-form-item label="物料" prop="materialCode">
<el-select v-model="form1.materialCode" filterable placeholder="选择结构件物料" style="width:100%">
<el-option v-for="m in batchMaterials" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
</el-form-item>
<el-form-item label="批次号">
<el-input v-model="form1.batchNo" placeholder="留空则自动生成" clearable />
</el-form-item>
<el-form-item label="数量" prop="quantity">
<el-input-number v-model="form1.quantity" :min="1" :step="1" step-strictly style="width:100%" />
</el-form-item>
<el-form-item label="区域" prop="zoneCode">
<el-select v-model="form1.zoneCode" placeholder="请选择入库区域(必填)" clearable style="width:100%">
<el-option v-for="z in zones" :key="z.id" :value="z.zoneCode"
:label="`${z.zoneCode} ${z.zoneName || ''}`" />
</el-select>
</el-form-item>
<el-form-item label="生产日期">
<el-date-picker v-model="form1.productionDate" type="date" value-format="YYYY-MM-DD"
placeholder="选择生产日期" style="width:100%" />
</el-form-item>
<el-form-item label="供应商">
<el-input v-model="form1.supplier" placeholder="供应商名称" clearable />
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form1.remark" placeholder="备注(可空)" clearable />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="submitting1" @click="submitBatch">提交入库</el-button>
</el-form-item>
</el-form>
<el-alert v-if="lastInboundNo" type="success" :closable="false"
:title="`最近入库单号:${lastInboundNo}`" style="max-width:560px" />
<el-divider />
<div>
<div style="margin-bottom:8px;color:#909399;font-size:12px">
批量导入Excel):列头顺序首行忽略
<b>物料编码 | 批次号(可空自动生成) | 数量 | 生产日期 | 供应商 | 区域 | 备注</b>
整表一次性全导入任一行错误则整体回退不写入任何数据)。
</div>
<el-upload ref="batchUploadRef" :auto-upload="false" accept=".xlsx" :limit="1" drag style="max-width:560px"
:on-exceed="(f) => onExceed(f, 'batch')">
<div class="dropzone">将结构件 .xlsx 拖到此处,或点击选择</div>
</el-upload>
<el-button type="primary" :loading="importing" style="margin-top:8px"
@click="importExcel('batch', 'batch')">开始批量导入</el-button>
</div>
</el-card>
</el-tab-pane>
<!-- ============ Tab2 精密件 SN 入库 ============ -->
<el-tab-pane :label="`精密件 SN 入库${snList.length ? `(已扫 ${snList.length}` : ''}`" name="sn">
<el-card shadow="never">
<el-form label-width="110px" style="max-width:680px">
<el-form-item label="物料" required>
<el-select v-model="form2.materialCode" filterable placeholder="请选择精密件物料必填" style="width:100%">
<el-option v-for="m in snMaterials" :key="m.id" :value="m.code"
:label="`${m.code} ${m.name || ''}`" />
</el-select>
</el-form-item>
<el-form-item label="区域" required>
<el-select v-model="form2.zoneCode" placeholder="请选择入库区域必填" clearable style="width:100%">
<el-option v-for="z in zones" :key="z.id" :value="z.zoneCode"
:label="`${z.zoneCode} ${z.zoneName || ''}`" />
</el-select>
</el-form-item>
<el-form-item label="扫码录入">
<div style="width:100%">
<el-input v-model="snInput" type="textarea" :rows="4"
placeholder="扫入或粘贴 SN可用 逗号/分号/顿号/空格/回车 分隔一次可录入多条" />
<el-button type="primary" plain style="margin-top:6px" :disabled="!snInput.trim()"
@click="addSnLines">录入到列表</el-button>
<span style="margin-left:8px;color:#909399;font-size:12px">点【录入到列表】加入下方清单,回车只是换行</span>
</div>
</el-form-item>
<el-form-item label="备注">
<el-input v-model="form2.remark" type="textarea" :rows="2" placeholder="本批 SN 的统一备注可空" />
</el-form-item>
</el-form>
<div style="margin-bottom:8px">
已录入 <b>{{ snList.length }}</b> 条 SN
</div>
<el-table :data="snList.map((v, i) => ({ i, v }))" size="small" border max-height="320" empty-text="暂无 SN">
<el-table-column type="index" label="#" width="56" align="center" />
<el-table-column prop="v" label="SN 序列号" min-width="220" />
<el-table-column label="操作" width="80" align="center">
<template #default="{ row }">
<el-button link type="danger" size="small" @click="removeSn(row.i)">删除</el-button>
</template>
</el-table-column>
</el-table>
<div style="margin-top:12px">
<el-button type="primary" :loading="submitting2" @click="submitSn">提交入库</el-button>
<el-button @click="snList = []">清空列表</el-button>
</div>
<el-divider />
<div>
<div style="margin-bottom:8px;color:#909399;font-size:12px">
批量导入(Excel):列头顺序(首行忽略)
<b>物料编码 | SN | 区域 | 生产日期 | 供应商</b>。
整表一次性全导入,任一行错误则整体回退(不写入任何数据)。
</div>
<el-upload ref="snUploadRef" :auto-upload="false" accept=".xlsx" :limit="1" drag style="max-width:680px"
:on-exceed="(f) => onExceed(f, 'sn')">
<div class="dropzone">将精密件 .xlsx 拖到此处,或点击选择</div>
</el-upload>
<el-button type="primary" :loading="importing" style="margin-top:8px"
@click="importExcel('sn', 'sn')">开始批量导入</el-button>
</div>
</el-card>
</el-tab-pane>
<!-- ============ Tab3 入库记录(统一主表 + 抽屉明细) ============ -->
<el-tab-pane label="入库记录" name="records">
<el-card shadow="never" class="mb12">
<el-form inline @submit.prevent="search">
<el-form-item label="入库单号">
<el-input v-model="filters.inboundNo" placeholder="入库单号(模糊)" clearable style="width:180px" @keyup.enter="search" />
</el-form-item>
<el-form-item label="物料编码">
<el-input v-model="filters.materialCode" placeholder="物料编码(模糊)" clearable style="width:160px" @keyup.enter="search" />
</el-form-item>
<el-form-item label="类型">
<el-select v-model="filters.manageMode" placeholder="全部" clearable style="width:120px" @change="search">
<el-option label="全部" value="" />
<el-option label="结构件" value="1" />
<el-option label="精密件" value="2" />
</el-select>
</el-form-item>
<el-form-item label="关键字">
<el-input v-model="filters.keyword" placeholder="物料名称/单号" clearable style="width:180px" @keyup.enter="search" />
</el-form-item>
<el-form-item label="入库时间">
<el-date-picker v-model="dateRange" type="daterange" value-format="YYYY-MM-DD"
range-separator="" start-placeholder="开始日期" end-placeholder="结束日期" style="width:240px" />
</el-form-item>
<el-form-item>
<el-button type="primary" :loading="loadingRec" @click="search">查询</el-button>
<el-button @click="resetFilters">重置</el-button>
</el-form-item>
<el-form-item>
<el-button v-if="can('inbound:export')" :disabled="!total" @click="exportCsv">导出</el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 入库单列表(一单一行,结构件/精密件同表) -->
<el-card shadow="never" header="入库单每行一笔入库明细在右侧抽屉查看该单的行">
<el-table :data="list" border size="small" v-loading="loadingRec" empty-text="暂无入库数据">
<el-table-column prop="inboundNo" label="入库单号" min-width="160" />
<el-table-column label="类型" width="90" align="center">
<template #default="{ row }">
<el-tag size="small" :type="row.manageMode === 1 ? 'primary' : row.manageMode === 2 ? 'warning' : 'info'">
{{ typeLabel(row.manageMode) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="materialCode" label="物料编码" min-width="140" />
<el-table-column prop="materialName" label="物料名称" min-width="140">
<template #default="{ row }">{{ row.materialName || '-' }}</template>
</el-table-column>
<el-table-column label="数量" width="90" align="center">
<template #default="{ row }">{{ row.quantity ?? '' }}</template>
</el-table-column>
<el-table-column prop="batchNo" label="批次号" min-width="150">
<template #default="{ row }">{{ row.batchNo || '-' }}</template>
</el-table-column>
<el-table-column prop="zoneCode" label="区域" width="90" align="center">
<template #default="{ row }">{{ row.zoneCode || '-' }}</template>
</el-table-column>
<el-table-column prop="operator" label="操作人" width="100">
<template #default="{ row }">{{ row.operator || '-' }}</template>
</el-table-column>
<el-table-column label="入库时间" width="160" align="center">
<template #default="{ row }">{{ fmtTime(row.createdAt) }}</template>
</el-table-column>
<el-table-column label="操作" width="110" align="center" fixed="right">
<template #default="{ row }">
<el-button link type="primary" size="small" @click="openDetail(row)">
明细{{ row.detailCount ? '(' + row.detailCount + ')' : '' }}
</el-button>
</template>
</el-table-column>
</el-table>
</el-card>
<div class="pager">
<el-pagination background layout="total, sizes, prev, pager, next" :total="total"
:current-page="page.current" :page-size="page.size" :page-sizes="[10, 20, 50, 100]"
@current-change="(p) => { page.current = p; loadRecords() }"
@size-change="(s) => { page.size = s; page.current = 1; loadRecords() }" />
</div>
</el-tab-pane>
</el-tabs>
<!-- 批量导入结果(结构件/精密件共用) -->
<el-card v-if="excelResult" shadow="never" class="mb12"
:header="excelResult.failed > 0 ? '导入结果:被拒绝(整体回退,未发生写入)' : '导入结果:成功'">
<el-alert v-if="excelResult.failed > 0" type="error" :closable="false"
:title="`共 ${excelResult.failed} 行存在错误,已整体回退(未写入任何数据),请修正后重传`" />
<el-alert v-else type="success" :closable="false" :title="`批量导入成功:共 ${excelResult.success} 行`" />
<el-table v-if="excelResult.failed > 0" :data="excelResult.errors || []" size="small" border
max-height="320" style="margin-top:10px">
<el-table-column prop="row" label="行号" width="70" align="center" />
<el-table-column prop="materialCode" label="物料编码" min-width="140" />
<el-table-column prop="message" label="错误原因" min-width="260" />
</el-table>
</el-card>
<!-- 入库单明细抽屉:懒加载 + 分页,替代表格套表格 -->
<el-drawer v-model="detailDrawer.visible" :title="`入库单明细:${detailDrawer.inboundNo}`" size="540px">
<el-table :data="detailDrawer.list" border size="small" v-loading="detailDrawer.loading"
empty-text="暂无明细">
<el-table-column label="批次号" min-width="180">
<template #default="{ row }">{{ row.batchNo || '-' }}</template>
</el-table-column>
<el-table-column label="SN 序列号" min-width="190">
<template #default="{ row }">{{ row.snCode || '-' }}</template>
</el-table-column>
<el-table-column prop="quantity" label="数量" width="90" align="center" />
</el-table>
<div v-if="detailDrawer.total > detailDrawer.size" class="pager" style="margin-top:12px">
<el-pagination background layout="total, prev, pager, next"
:total="detailDrawer.total" :current-page="detailDrawer.page"
:page-size="detailDrawer.size"
@current-change="changeDetailPage" />
</div>
</el-drawer>
</div>
<PageHelp :data="helpInbound" page="Inbound" />
</template>
<style scoped>
.dropzone { padding: 30px 0; color: #606266; }
.mb12 { margin-bottom: 12px; }
.pager { display: flex; justify-content: flex-end; margin-top: 12px; }
</style>