12 changed files with 5199 additions and 3 deletions
-
83src/api/rack/closedLoop.js
-
6src/element-ui/index.js
-
1src/router/index.js
-
327src/views/modules/rack/batch-inbound-management.vue
-
914src/views/modules/rack/execution-control.vue
-
401src/views/modules/rack/job-order-management.vue
-
699src/views/modules/rack/manual-record-ledger.vue
-
280src/views/modules/rack/part-archive.vue
-
718src/views/modules/rack/program-management.vue
-
353src/views/modules/rack/rack-archive.vue
-
979src/views/modules/rack/screen-rack-plating-progress.vue
-
441src/views/modules/rack/trace-report.vue
@ -0,0 +1,83 @@ |
|||
import { createAPI } from '@/utils/httpRequest' |
|||
|
|||
export const listPart = (data) => createAPI('/rack/closedLoop/part/list', 'post', data || {}) |
|||
export const savePart = (data) => createAPI('/rack/closedLoop/part/save', 'post', data) |
|||
export const updatePart = (data) => createAPI('/rack/closedLoop/part/update', 'post', data) |
|||
export const updatePartByNo = (partNo, data) => createAPI('/rack/closedLoop/part/update-by-no', 'post', { |
|||
partNo, |
|||
newPartNo: (data && data.partNo) || '', |
|||
partName: (data && data.partName) || '', |
|||
category: (data && data.category) || '', |
|||
routeCode: (data && data.routeCode) || '', |
|||
rackType: (data && data.rackType) || '', |
|||
spec: (data && data.spec) || '', |
|||
unit: (data && data.unit) || '', |
|||
status: (data && data.status) || '', |
|||
remark: (data && data.remark) || '' |
|||
}) |
|||
export const deletePart = (partId) => createAPI(`/rack/closedLoop/part/delete/${partId}`, 'post', {}) |
|||
export const deletePartByNo = (partNo) => createAPI('/rack/closedLoop/part/delete-by-no', 'post', { partNo }) |
|||
|
|||
export const listRack = (data) => createAPI('/rack/closedLoop/rack/list', 'post', data || {}) |
|||
export const saveRack = (data) => createAPI('/rack/closedLoop/rack/save', 'post', data) |
|||
export const updateRack = (data) => createAPI('/rack/closedLoop/rack/update', 'post', data) |
|||
export const updateRackByCode = (rackCode, data) => createAPI('/rack/closedLoop/rack/update-by-code', 'post', { |
|||
rackCode, |
|||
newRackCode: (data && data.rackCode) || '', |
|||
rackName: (data && data.rackName) || '', |
|||
rackType: (data && data.rackType) || '', |
|||
lineId: (data && data.lineId) || '', |
|||
status: (data && data.status) || '', |
|||
remark: (data && data.remark) || '', |
|||
usageCount: (data && data.usageCount) || 0 |
|||
}) |
|||
export const deleteRack = (rackId) => createAPI(`/rack/closedLoop/rack/delete/${rackId}`, 'post', {}) |
|||
export const deleteRackByCode = (rackCode) => createAPI('/rack/closedLoop/rack/delete-by-code', 'post', { rackCode }) |
|||
export const listRackType = (data) => createAPI('/rack/closedLoop/rack/type/list', 'post', data || {}) |
|||
export const saveRackType = (data) => createAPI('/rack/closedLoop/rack/type/save', 'post', data) |
|||
export const updateRackType = (data) => createAPI('/rack/closedLoop/rack/type/update', 'post', data) |
|||
export const deleteRackTypeByName = (typeName) => createAPI('/rack/closedLoop/rack/type/delete-by-name', 'post', { typeName }) |
|||
|
|||
export const listRoute = (data) => createAPI('/rack/closedLoop/route/list', 'post', data || {}) |
|||
export const saveRoute = (data) => createAPI('/rack/closedLoop/route/save', 'post', data) |
|||
export const updateRoute = (data) => createAPI('/rack/closedLoop/route/update', 'post', data) |
|||
export const updateRouteByCode = (data) => createAPI('/rack/closedLoop/route/update-by-code', 'post', data) |
|||
export const deleteRoute = (routeId) => createAPI(`/rack/closedLoop/route/delete/${routeId}`, 'post', {}) |
|||
export const deleteRouteByCode = (routeCode) => createAPI('/rack/closedLoop/route/delete-by-code', 'post', { routeCode }) |
|||
export const listRouteStep = (routeId) => createAPI(`/rack/closedLoop/route/step/list/${routeId}`, 'get', {}) |
|||
export const listRouteStepByCode = (routeCode) => createAPI('/rack/closedLoop/route/step/list-by-code', 'post', { routeCode }) |
|||
export const listRoutePoolParamByCode = (routeCode) => createAPI('/rack/closedLoop/route/pool-param/list-by-code', 'post', { routeCode }) |
|||
export const saveRouteStep = (data) => createAPI('/rack/closedLoop/route/step/save', 'post', data) |
|||
export const deleteRouteStep = (stepId) => createAPI(`/rack/closedLoop/route/step/delete/${stepId}`, 'post', {}) |
|||
export const replaceRoutePoolsByCode = (data) => createAPI('/rack/closedLoop/route/step/replace-by-code', 'post', data || {}) |
|||
|
|||
export const listBatch = (data) => createAPI('/rack/closedLoop/batch/list', 'post', data || {}) |
|||
export const saveBatch = (data) => createAPI('/rack/closedLoop/batch/save', 'post', data) |
|||
export const updateBatch = (data) => createAPI('/rack/closedLoop/batch/update', 'post', data) |
|||
export const deleteBatch = (batchId) => createAPI(`/rack/closedLoop/batch/delete/${batchId}`, 'post', {}) |
|||
export const deleteBatchByNo = (inboundNo) => createAPI('/rack/closedLoop/batch/delete-by-no', 'post', { inboundNo }) |
|||
|
|||
export const listJob = (data) => createAPI('/rack/closedLoop/job/list', 'post', data || {}) |
|||
export const saveJob = (data) => createAPI('/rack/closedLoop/job/save', 'post', data) |
|||
export const listJobAvailableMaterials = (data) => createAPI('/rack/closedLoop/job/available-materials', 'post', data || {}) |
|||
export const listJobDetailsByCode = (jobCode) => createAPI('/rack/closedLoop/job/details-by-code', 'post', { jobCode }) |
|||
export const dispatchJobByCode = (jobCode) => createAPI('/rack/closedLoop/job/dispatch-by-code', 'post', { jobCode }) |
|||
export const completeJobByCode = (jobCode, params) => createAPI('/rack/closedLoop/job/complete-by-code', 'post', { ...(params || {}), jobCode }) |
|||
export const deleteJobByCode = (jobCode) => createAPI('/rack/closedLoop/job/delete-by-code', 'post', { jobCode }) |
|||
|
|||
export const bindUpHang = (data) => createAPI('/rack/closedLoop/bind/uphang', 'post', data) |
|||
export const bindDownHang = (data) => createAPI('/rack/closedLoop/bind/downhang', 'post', data) |
|||
export const stationPass = (data) => createAPI('/rack/closedLoop/station/pass', 'post', data) |
|||
export const productionView = (jobCode) => createAPI('/rack/closedLoop/production/view', 'post', { jobCode }) |
|||
export const productionStart = (jobCode) => createAPI('/rack/closedLoop/production/start', 'post', { jobCode }) |
|||
export const productionUpHang = (data) => createAPI('/rack/closedLoop/production/uphang', 'post', data || {}) |
|||
export const productionDownHang = (data) => createAPI('/rack/closedLoop/production/downhang', 'post', data || {}) |
|||
export const productionStationPass = (data) => createAPI('/rack/closedLoop/production/station-pass', 'post', data || {}) |
|||
export const productionStationPassAll = (data) => createAPI('/rack/closedLoop/production/station-pass-all', 'post', data || {}) |
|||
export const productionComplete = (jobCode, data) => createAPI('/rack/closedLoop/production/complete', 'post', { ...(data || {}), jobCode }) |
|||
|
|||
export const listManualRecord = (data) => createAPI('/rack/closedLoop/manual/list', 'post', data || {}) |
|||
export const saveManualRecord = (data) => createAPI('/rack/closedLoop/manual/save', 'post', data) |
|||
|
|||
export const traceQuery = (data) => createAPI('/rack/closedLoop/trace/query', 'post', data || {}) |
|||
export const reportOverview = () => createAPI('/rack/closedLoop/report/overview', 'get', {}) |
|||
@ -0,0 +1,327 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="批次编码"> |
|||
<el-input v-model="searchData.batchCode" clearable style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="入库单号"> |
|||
<el-input v-model="searchData.inboundNo" clearable style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
<el-button plain class="add-btn" @click="openDialog">新建批次</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table |
|||
ref="inboundTable" |
|||
class="data-table" |
|||
:data="dataList" |
|||
border |
|||
style="width:100%" |
|||
highlight-current-row |
|||
v-loading="loading" :height="tableHeight" |
|||
@current-change="handleCurrentInboundChange"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="inboundNo" label="入库单号" min-width="180" /> |
|||
<el-table-column prop="batchCode" label="批次编码" min-width="160" /> |
|||
<el-table-column prop="status" label="状态" width="100" /> |
|||
<el-table-column prop="customerOrderNo" label="客户订单号" min-width="140" /> |
|||
<el-table-column prop="customerBatchNo" label="客户批次号" min-width="140" /> |
|||
<el-table-column prop="traceBatchNo" label="追溯批次号" width="140" /> |
|||
<el-table-column label="明细数量" width="100"> |
|||
<template slot-scope="scope">{{ (scope.row.partItems || []).length }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="操作" width="100"> |
|||
<template slot-scope="scope"> |
|||
<a style="color:#F56C6C;" v-if="scope.row.status==='待执行'" @click="handleDeleteInbound(scope.row)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-tabs v-model="detailTab" type="border-card" style="margin-top: 12px;"> |
|||
<el-tab-pane label="物料明细列表" name="partItems"> |
|||
<el-table class="data-table" :data="selectedPartItems" border style="width:100%" height="280"> |
|||
<el-table-column type="index" width="50" /> |
|||
<!-- <el-table-column prop="partId" label="part_id" width="160" />--> |
|||
<el-table-column prop="partNo" label="物料分类" width="180" /> |
|||
<el-table-column prop="partName" label="物料名称" min-width="180"> |
|||
<template slot-scope="scope">{{ scope.row.partName || '-' }}</template> |
|||
</el-table-column> |
|||
<el-table-column prop="qty" label="数量" width="100" /> |
|||
<el-table-column prop="remark" label="备注" min-width="180" /> |
|||
</el-table> |
|||
</el-tab-pane> |
|||
</el-tabs> |
|||
|
|||
<el-dialog |
|||
title="新建批次" |
|||
:visible.sync="dialogVisible" |
|||
width="920px" |
|||
top="6vh" |
|||
class="batch-dialog" |
|||
:close-on-click-modal="false" |
|||
v-drag> |
|||
<el-form :model="form" label-position="top" class="batch-form"> |
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"><el-form-item label="批次编码" required><el-input v-model="form.batchCode" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="客户订单号"><el-input v-model="form.customerOrderNo" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="客户批次号"><el-input v-model="form.customerBatchNo" /></el-form-item></el-col> |
|||
</el-row> |
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"><el-form-item label="追溯批次号"><el-input v-model="form.traceBatchNo" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="生产日期"><el-date-picker v-model="form.productionDate" type="date" value-format="yyyy-MM-dd" style="width:100%" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="状态"><el-select v-model="form.status" style="width:100%"><el-option label="待执行" value="待执行" /><el-option label="执行中" value="执行中" /><el-option label="已完成" value="已完成" /><el-option label="已关闭" value="已关闭" /></el-select></el-form-item></el-col> |
|||
</el-row> |
|||
<el-form-item label="备注"><el-input v-model="form.remark" type="textarea" :rows="2" /></el-form-item> |
|||
|
|||
<div class="part-block-title" style="margin-top: 30px">批次明细:物料与数量</div> |
|||
<el-table class="part-edit-table" :data="form.partItems" border height="240"> |
|||
<el-table-column label="物料分类" width="160"> |
|||
<template slot-scope="scope"> |
|||
<el-select |
|||
v-model="scope.row.partNo" |
|||
clearable |
|||
filterable |
|||
remote |
|||
default-first-option |
|||
reserve-keyword |
|||
placeholder="输入物料分类模糊搜索" |
|||
:remote-method="remoteSearchPartNo" |
|||
:loading="partLoading" |
|||
@focus="ensurePartOptionsLoaded" |
|||
@change="onSelectPartNo(scope.row)" |
|||
style="width: 100%"> |
|||
<el-option |
|||
v-for="item in partOptions" |
|||
:key="item.partNo" |
|||
:label="item.partNo" |
|||
:value="item.partNo"> |
|||
<span class="part-option-left">{{ item.partNo }}</span> |
|||
<span class="part-option-right">{{ item.partName }}</span> |
|||
</el-option> |
|||
</el-select> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="物料名称" width="160"> |
|||
<template slot-scope="scope"><el-input v-model="scope.row.partName" disabled /></template> |
|||
</el-table-column> |
|||
<el-table-column label="数量" width="150"> |
|||
<template slot-scope="scope"><el-input v-model="scope.row.qty" :controls-position="'right'" style="width:100%" /></template> |
|||
</el-table-column> |
|||
<el-table-column label="备注" min-width="180"> |
|||
<template slot-scope="scope"><el-input v-model="scope.row.remark" /></template> |
|||
</el-table-column> |
|||
<el-table-column label="操作" width="80"> |
|||
<template slot-scope="scope"><a style="color:#F56C6C" @click="removePart(scope.$index)">删除</a></template> |
|||
</el-table-column> |
|||
</el-table> |
|||
<el-button plain class="add-btn" style="margin-top:8px;" @click="addPartRow">新增物料行</el-button> |
|||
</el-form> |
|||
<el-footer style="height:40px;text-align:center;margin-top:12px;"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible=false">取消</el-button> |
|||
<el-button plain class="add-btn" @click="saveBatchOrder">保存</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listBatch, saveBatch, listPart, deleteBatchByNo } from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
// 表格高度 |
|||
tableHeight: (window.innerHeight - 260)/2, |
|||
loading: false, |
|||
partLoading: false, |
|||
dialogVisible: false, |
|||
detailTab: 'partItems', |
|||
dataList: [], |
|||
selectedInbound: null, |
|||
selectedPartItems: [], |
|||
partOptions: [], |
|||
partOptionMap: {}, |
|||
searchData: { batchCode: '', inboundNo: '' }, |
|||
form: this.emptyForm() |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
emptyForm () { |
|||
return { |
|||
batchCode: '', |
|||
customerOrderNo: '', |
|||
customerBatchNo: '', |
|||
traceBatchNo: '', |
|||
productionDate: '', |
|||
status: '待执行', |
|||
remark: '', |
|||
partItems: [{ partId: '', partNo: '', partName: '', qty: '', remark: '' }] |
|||
} |
|||
}, |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const { data } = await listBatch(this.searchData) |
|||
this.dataList = data.rows || [] |
|||
if (this.dataList.length) { |
|||
this.$nextTick(() => { |
|||
this.$refs.inboundTable && this.$refs.inboundTable.setCurrentRow(this.dataList[0]) |
|||
}) |
|||
} else { |
|||
this.selectedInbound = null |
|||
this.selectedPartItems = [] |
|||
} |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.searchData = { batchCode: '', inboundNo: '' } |
|||
this.loadList() |
|||
}, |
|||
async openDialog () { |
|||
this.form = this.emptyForm() |
|||
this.dialogVisible = true |
|||
await this.ensurePartOptionsLoaded() |
|||
}, |
|||
addPartRow () { |
|||
this.form.partItems.push({ partId: '', partNo: '', partName: '', qty: '', remark: '' }) |
|||
}, |
|||
removePart (index) { |
|||
this.form.partItems.splice(index, 1) |
|||
if (!this.form.partItems.length) this.addPartRow() |
|||
}, |
|||
async saveBatchOrder () { |
|||
if (!this.form.batchCode) { |
|||
this.$message.warning('批次编码必填') |
|||
return |
|||
} |
|||
const validItems = (this.form.partItems || []).filter(i => i.partNo && i.qty !== null && i.qty !== undefined && Number(i.qty) > 0) |
|||
if (!validItems.length) { |
|||
this.$message.warning('至少填写1条物料和数量') |
|||
return |
|||
} |
|||
await saveBatch({ ...this.form, partItems: validItems }) |
|||
this.$message.success('新建成功,已自动生成入库单号') |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
}, |
|||
handleCurrentInboundChange (row) { |
|||
this.selectedInbound = row |
|||
this.selectedPartItems = (row && row.partItems) || [] |
|||
}, |
|||
async ensurePartOptionsLoaded () { |
|||
if (!this.partOptions.length) { |
|||
await this.remoteSearchPartNo('') |
|||
} |
|||
}, |
|||
async remoteSearchPartNo (keyword) { |
|||
this.partLoading = true |
|||
try { |
|||
const kw = (keyword || '').trim() |
|||
const { data } = await listPart(kw ? { partNo: kw } : {}) |
|||
this.partOptions = data.rows || [] |
|||
this.partOptionMap = {} |
|||
this.partOptions.forEach(item => { |
|||
this.partOptionMap[item.partNo] = item |
|||
}) |
|||
} finally { |
|||
this.partLoading = false |
|||
} |
|||
}, |
|||
onSelectPartNo (row) { |
|||
const part = this.partOptionMap[row.partNo] |
|||
if (part) { |
|||
row.partId = part.partId |
|||
row.partName = part.partName |
|||
} else { |
|||
row.partId = '' |
|||
row.partName = '' |
|||
} |
|||
}, |
|||
async handleDeleteInbound (row) { |
|||
if (!row || !row.inboundNo) { |
|||
return |
|||
} |
|||
try { |
|||
await this.$confirm(`确认物理删除入库单:${row.inboundNo} 吗?`, '提示', { type: 'warning' }) |
|||
await deleteBatchByNo(row.inboundNo) |
|||
this.$message.success('删除成功') |
|||
this.loadList() |
|||
} catch (e) {} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.part-block-title { margin: 8px 0; font-weight: 600; color: #303133; } |
|||
.batch-dialog >>> .el-dialog__body { padding: 12px 20px 10px; max-height: 70vh; overflow-y: auto; } |
|||
.batch-form >>> .el-form-item { margin-bottom: 10px; } |
|||
.part-edit-table { margin-top: 4px; } |
|||
.part-edit-table >>> .el-table__header-wrapper th, |
|||
.part-edit-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
.part-edit-table >>> .el-table__header-wrapper .cell, |
|||
.part-edit-table >>> .el-table__fixed-header-wrapper .cell { |
|||
font-size: 13px !important; |
|||
padding: 0 10px; |
|||
} |
|||
.part-edit-table >>> .el-table__row { |
|||
height: 36px; |
|||
} |
|||
.part-edit-table >>> .el-table__body-wrapper td { padding: 4px 0; } |
|||
.part-edit-table >>> .el-table__body-wrapper .cell { |
|||
height: auto !important; |
|||
min-height: 24px; |
|||
display: flex; |
|||
align-items: center; |
|||
overflow: visible !important; |
|||
line-height: normal !important; |
|||
white-space: nowrap !important; |
|||
padding: 0 10px !important; |
|||
} |
|||
.part-edit-table >>> .el-input, |
|||
.part-edit-table >>> .el-select, |
|||
.part-edit-table >>> .el-input-number { |
|||
width: 100%; |
|||
} |
|||
.part-edit-table >>> .el-input__inner, |
|||
.part-edit-table >>> .el-input-number .el-input__inner { |
|||
height: 24px !important; |
|||
line-height: 24px !important; |
|||
} |
|||
.part-edit-table >>> .el-input-number.is-controls-right .el-input__inner { |
|||
padding-left: 10px; |
|||
padding-right: 38px; |
|||
} |
|||
.part-edit-table >>> .el-input-number .el-input__inner { text-align: left; } |
|||
.part-option-left { float: left; max-width: 120px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|||
.part-option-right { float: right; color: #8492a6; font-size: 12px; max-width: 160px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } |
|||
.data-table { background-color:#fff; border-radius:4px; } |
|||
.data-table >>> .el-table__header-wrapper th,.data-table >>> .el-table__fixed-header-wrapper th { background-color:#f5f7fa !important;color:#333;font-weight:600;border-color:#ebeef5;padding:8px 0; } |
|||
.data-table >>> .el-table__header-wrapper .cell,.data-table >>> .el-table__fixed-header-wrapper .cell,.data-table >>> .el-table__body-wrapper .cell,.data-table >>> .el-table__fixed-body-wrapper .cell { padding:0 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px !important; } |
|||
.data-table >>> .el-table__body tr:hover > td { background-color:#f5f7fa !important; } |
|||
.data-table >>> .el-table__body tr.current-row > td { background-color:#ecf5ff !important; } |
|||
.query-form { background-color:#fff;padding:15px 15px 5px 15px;border-radius:4px;margin-bottom:12px; } |
|||
.query-form >>> .el-form-item__label { color:#333;font-size:13px;padding-bottom:5px; } |
|||
.query-form >>> .el-input__inner { height:32px;line-height:32px;border-radius:4px;font-size:13px; } |
|||
.query-form >>> .el-button { height:32px;padding:0 15px;font-size:13px;border-radius:4px; } |
|||
.search-btn { background-color:#ecf5ff;border-color:#b3d8ff;color:#409eff; } |
|||
.search-btn:hover { background-color:#409eff;border-color:#409eff;color:#fff; } |
|||
.reset-btn { background-color:#f5f7fa;border-color:#d3d4d6;color:#606266; } |
|||
.reset-btn:hover { background-color:#909399;border-color:#909399;color:#fff; } |
|||
.add-btn { background-color:#f0f9eb;border-color:#c2e7b0;color:#67c23a; } |
|||
.add-btn:hover { background-color:#67c23a;border-color:#67c23a;color:#fff; } |
|||
</style> |
|||
@ -0,0 +1,914 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-card> |
|||
<div slot="header" class="card-title-row"> |
|||
<span>扫码生产执行</span> |
|||
<div> |
|||
<el-switch |
|||
v-model="autoRefresh" |
|||
active-text="自动刷新池子进度" |
|||
@change="onAutoRefreshChange" |
|||
style="margin-right: 12px;" /> |
|||
<el-button plain class="search-btn" @click="loadProductionView">手动刷新</el-button> |
|||
</div> |
|||
</div> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="任务单扫码"> |
|||
<el-input |
|||
v-model.trim="jobScanCode" |
|||
clearable |
|||
placeholder="请扫码任务单号,回车加载" |
|||
style="width: 240px" |
|||
@keyup.enter.native="onJobScanned" /> |
|||
</el-form-item> |
|||
<el-form-item label="物料扫码"> |
|||
<el-input |
|||
v-model.trim="materialScanCode" |
|||
clearable |
|||
placeholder="请扫码物料" |
|||
style="width: 240px" |
|||
@keyup.enter.native="onMaterialScanned" /> |
|||
</el-form-item> |
|||
<el-form-item label="挂具扫码"> |
|||
<el-input |
|||
v-model.trim="rackScanCode" |
|||
clearable |
|||
placeholder="请扫码挂具码,回车确认" |
|||
style="width: 240px" |
|||
@keyup.enter.native="onRackScanned" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-card shadow="never" style="margin-top: 8px;"> |
|||
<el-table class="data-table" :data="production.detailRows || []" border style="width:100%" height="180"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="inboundNo" label="入库单号" width="170" /> |
|||
<el-table-column prop="batchCode" label="批次编码" width="140" /> |
|||
<el-table-column prop="partNo" label="物料分类" width="150" /> |
|||
<el-table-column prop="partName" label="物料名称" min-width="160" /> |
|||
<el-table-column prop="plannedQty" label="计划数量" width="100" /> |
|||
<el-table-column prop="executedQty" label="已执行" width="100" /> |
|||
<el-table-column label="剩余可执行" width="120"> |
|||
<template slot-scope="scope"> |
|||
<a size="mini" :type="(scope.row.remainingQty || 0) > 0 ? 'warning' : 'success'"> |
|||
{{ scope.row.remainingQty || 0 }} |
|||
</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</el-card> |
|||
|
|||
<div style="margin-top: 12px;"> |
|||
<el-button plain class="add-btn" :loading="startSubmitting" :disabled="!canStartProduction" @click="startProductionAction">开始生产</el-button> |
|||
<el-button plain class="search-btn" :loading="upHangSubmitting" :disabled="!canUpHang" @click="upHangAction">上挂</el-button> |
|||
<el-button plain class="search-btn" :loading="downHangSubmitting" :disabled="!canDownHang" @click="downHangAction">下挂</el-button> |
|||
<el-button plain class="search-btn" :loading="simulateSubmitting" :disabled="!canSimulatePass" @click="simulateStationPassOnce">模拟过一池</el-button> |
|||
<el-button plain class="search-btn" :loading="simulateSubmitting" :disabled="!canSimulatePass" @click="simulateStationPassAll">模拟全部剩余池</el-button> |
|||
<el-button plain class="reset-btn" :loading="completeSubmitting" :disabled="!canComplete" @click="completeAction">任务完工</el-button> |
|||
</div> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 12px;"> |
|||
<el-table |
|||
ref="bindingTable" |
|||
class="data-table" |
|||
:data="visibleBindingRows" |
|||
border |
|||
highlight-current-row |
|||
style="width:100%" |
|||
height="180" |
|||
@current-change="onCurrentBindingChange"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="rackCode" label="挂具编码" width="150" /> |
|||
<el-table-column label="状态" width="110"> |
|||
<template slot-scope="scope"> |
|||
<a size="mini" :type="scope.row.bindStatus === '生效中' ? 'danger' : 'success'"> |
|||
{{ scope.row.bindStatus || '-' }} |
|||
</a> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="上挂时间" min-width="170"> |
|||
<template slot-scope="scope">{{ formatDateTime(scope.row.bindTime) }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="下挂时间" min-width="170"> |
|||
<template slot-scope="scope">{{ formatDateTime(scope.row.unbindTime) }}</template> |
|||
</el-table-column> |
|||
<el-table-column prop="operatorName" label="操作人" width="120" /> |
|||
</el-table> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 12px;"> |
|||
<div slot="header">池子过站进度(PLC采集){{ currentProgressRackCode ? ` - 挂具 ${currentProgressRackCode}` : '' }}</div> |
|||
<div class="pool-flow-wrap" v-if="displayedStepProgress.length"> |
|||
<div |
|||
v-for="(step, index) in displayedStepProgress" |
|||
:key="`${step.stepCode}_${index}`" |
|||
class="pool-flow-item" |
|||
:class="{ passed: step.passed }"> |
|||
<div class="pool-flow-item-head"> |
|||
<div class="pool-flow-node" :class="{ passed: step.passed }">{{ step.stepNo }}</div> |
|||
<div class="pool-flow-line" :class="{ passed: step.passed }" /> |
|||
<div class="pool-flow-inline-text" :class="{ passed: step.passed }"> |
|||
{{ step.passed ? `挂具:${step.passRackCode || '-'}` : '待过池' }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</el-card> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { |
|||
listJob, |
|||
listRack, |
|||
productionView, |
|||
productionStart, |
|||
productionUpHang, |
|||
productionDownHang, |
|||
productionStationPass, |
|||
productionStationPassAll, |
|||
productionComplete |
|||
} from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
jobOptions: [], |
|||
rackOptions: [], |
|||
jobScanCode: '', |
|||
materialScanCode: '', |
|||
rackScanCode: '', |
|||
selectedJobCode: '', |
|||
selectedRackCode: '', |
|||
selectedBindingRackCode: '', |
|||
operatorName: 'PC操作员', |
|||
production: {}, |
|||
startSubmitting: false, |
|||
upHangSubmitting: false, |
|||
downHangSubmitting: false, |
|||
simulateSubmitting: false, |
|||
completeSubmitting: false, |
|||
autoRefresh: true, |
|||
timer: null |
|||
} |
|||
}, |
|||
computed: { |
|||
canRunBindAction () { |
|||
return !!this.selectedJobCode && !!(this.selectedRackCode || this.rackScanCode) |
|||
}, |
|||
canStartProduction () { |
|||
return !!this.selectedJobCode && this.production.status === '待下达' && !this.startSubmitting |
|||
}, |
|||
canUpHang () { |
|||
return this.canRunBindAction && |
|||
!!this.materialScanCode && |
|||
!this.upHangSubmitting && |
|||
['待下达', '已下达', '生产中'].includes(this.production.status || '待下达') |
|||
}, |
|||
canDownHang () { |
|||
const hasAnyActiveRack = this.getActiveRackCodes().length > 0 |
|||
return !!this.selectedJobCode && hasAnyActiveRack && !!(this.selectedRackCode || this.rackScanCode) && !this.downHangSubmitting |
|||
}, |
|||
canComplete () { |
|||
const hasAnyActiveRack = this.getActiveRackCodes().length > 0 |
|||
return !!this.selectedJobCode && !hasAnyActiveRack && ['已下达', '生产中'].includes(this.production.status) && !this.completeSubmitting |
|||
}, |
|||
canSimulatePass () { |
|||
return !!this.selectedJobCode && this.getActiveRackCodes().length > 0 && !this.simulateSubmitting |
|||
}, |
|||
currentProgressRackCode () { |
|||
const rows = this.visibleBindingRows || [] |
|||
if (!rows.length) { |
|||
return '' |
|||
} |
|||
if (!this.selectedBindingRackCode) { |
|||
return '' |
|||
} |
|||
const exists = rows.some(row => row && row.rackCode === this.selectedBindingRackCode) |
|||
return exists ? this.selectedBindingRackCode : '' |
|||
}, |
|||
displayedStepProgress () { |
|||
const map = this.production.rackStepProgressMap || {} |
|||
const rackCode = this.currentProgressRackCode |
|||
if (!rackCode) { |
|||
return [] |
|||
} |
|||
if (rackCode && Array.isArray(map[rackCode]) && map[rackCode].length) { |
|||
return map[rackCode] |
|||
} |
|||
if (rackCode && Array.isArray(this.production.stepProgress) && this.production.stepProgress.length) { |
|||
return this.production.stepProgress.map(step => ({ |
|||
stepNo: step.stepNo, |
|||
stepCode: step.stepCode, |
|||
stepName: step.stepName, |
|||
passed: false, |
|||
passTime: null, |
|||
passRackCode: '' |
|||
})) |
|||
} |
|||
return this.production.stepProgress || [] |
|||
}, |
|||
visibleBindingRows () { |
|||
return (this.production.bindingRows || []).filter(row => row && row.bindStatus !== '已结束') |
|||
}, |
|||
operationGuide () { |
|||
if (!this.production.jobCode) { |
|||
return '请先扫码任务单。' |
|||
} |
|||
if (this.getActiveRackCodes().length > 0) { |
|||
return `当前有 ${this.getActiveRackCodes().length} 个挂具在制中,可继续扫码新挂具“上挂”;需要下挂时请扫对应挂具后点“下挂”。` |
|||
} |
|||
if (this.production.status === '待下达') { |
|||
return '先点“开始生产”下达任务,再扫码来料和挂具,校验通过后自动上挂。' |
|||
} |
|||
return '请先扫码来料,再扫码挂具;校验通过后会自动上挂。全部来料完成后再点“任务完工”。' |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.refreshBaseOptions() |
|||
this.startTimer() |
|||
}, |
|||
beforeDestroy () { |
|||
this.stopTimer() |
|||
}, |
|||
methods: { |
|||
async refreshBaseOptions () { |
|||
const [{ data: jobData }, { data: rackData }] = await Promise.all([ |
|||
listJob({}), |
|||
listRack({}) |
|||
]) |
|||
this.jobOptions = (jobData.rows || []).filter(item => ['待下达', '已下达', '生产中'].includes(item.status)) |
|||
this.rackOptions = rackData.rows || [] |
|||
if (!this.selectedJobCode && this.jobOptions.length) { |
|||
this.selectedJobCode = this.jobOptions[0].jobCode |
|||
this.jobScanCode = this.selectedJobCode |
|||
await this.onJobChange() |
|||
} |
|||
}, |
|||
async onJobScanned () { |
|||
if (!this.jobScanCode) { |
|||
this.$message.warning('请先扫码任务单') |
|||
return |
|||
} |
|||
this.selectedJobCode = this.jobScanCode |
|||
this.materialScanCode = '' |
|||
this.rackScanCode = '' |
|||
this.selectedRackCode = '' |
|||
await this.onJobChange() |
|||
}, |
|||
onMaterialScanned () { |
|||
if (!this.materialScanCode) { |
|||
return |
|||
} |
|||
this.tryAutoUpHang() |
|||
}, |
|||
onRackScanned () { |
|||
if (!this.rackScanCode) { |
|||
return |
|||
} |
|||
this.selectedRackCode = this.rackScanCode |
|||
this.tryAutoUpHang() |
|||
}, |
|||
async onJobChange () { |
|||
if (this.production.jobCode && this.production.jobCode !== this.selectedJobCode) { |
|||
// 切换任务单时先清空看板,避免短暂显示上一个任务的数据 |
|||
this.production = {} |
|||
this.selectedBindingRackCode = '' |
|||
} |
|||
await this.loadProductionView() |
|||
const activeRackCode = this.production.activeRackCode |
|||
const recommendRack = (this.production.recommendedRackCodes || [])[0] |
|||
const defaultRackCode = activeRackCode || recommendRack |
|||
if (defaultRackCode && !this.selectedRackCode && !this.rackScanCode) { |
|||
this.selectedRackCode = defaultRackCode |
|||
this.rackScanCode = defaultRackCode |
|||
} |
|||
await this.loadRackOptionsByStatus() |
|||
}, |
|||
async loadRackOptionsByStatus () { |
|||
const { data } = await listRack({}) |
|||
this.rackOptions = data.rows || [] |
|||
}, |
|||
async loadProductionView () { |
|||
if (!this.selectedJobCode) { |
|||
this.production = {} |
|||
return |
|||
} |
|||
const { data } = await productionView(this.selectedJobCode) |
|||
const result = data.result || {} |
|||
const bindingRows = Array.isArray(result.bindingRows) ? result.bindingRows : [] |
|||
const activeRackCodesFromBindingRows = bindingRows |
|||
.filter(item => item && item.bindStatus === '生效中' && item.rackCode) |
|||
.map(item => item.rackCode) |
|||
const activeRackCodes = (Array.isArray(result.activeRackCodes) && result.activeRackCodes.length) |
|||
? result.activeRackCodes |
|||
: (result.activeRackCode ? [result.activeRackCode] : activeRackCodesFromBindingRows) |
|||
const finishedRackCodes = Array.isArray(result.finishedRackCodes) ? result.finishedRackCodes : [] |
|||
this.production = { |
|||
...result, |
|||
bindingRows, |
|||
activeRackCodes, |
|||
finishedRackCodes |
|||
} |
|||
this.ensureBindingSelection() |
|||
this.production.activeRackCodes = this.getActiveRackCodes() |
|||
if (!this.production.activeRackCode && (this.production.activeRackCodes || []).length > 0) { |
|||
this.production.activeRackCode = this.production.activeRackCodes[0] |
|||
} |
|||
if (this.production.activeRackCode && !this.rackScanCode && !this.selectedRackCode) { |
|||
this.selectedRackCode = this.production.activeRackCode |
|||
this.rackScanCode = this.production.activeRackCode |
|||
} |
|||
}, |
|||
async startProductionAction () { |
|||
if (this.startSubmitting) { |
|||
return |
|||
} |
|||
if (!this.canStartProduction) { |
|||
this.$message.warning('当前状态不需要重复开始生产') |
|||
return |
|||
} |
|||
this.startSubmitting = true |
|||
try { |
|||
const resp = await productionStart(this.selectedJobCode) |
|||
this.assertApiSuccess(resp, '开始生产失败') |
|||
this.$message.success('已开始生产') |
|||
await this.loadProductionView() |
|||
} catch (e) { |
|||
this.$message.error(this.resolveErrorMessage(e)) |
|||
} finally { |
|||
this.startSubmitting = false |
|||
} |
|||
}, |
|||
async upHangAction () { |
|||
this.syncScanCodes() |
|||
const materialError = this.validateMaterialForUpHang() |
|||
if (materialError) { |
|||
this.$message.warning(materialError) |
|||
return |
|||
} |
|||
if (this.upHangSubmitting) { |
|||
return |
|||
} |
|||
this.upHangSubmitting = true |
|||
await this.ensureProductionStarted() |
|||
try { |
|||
const currentRackCode = this.selectedRackCode |
|||
const resp = await productionUpHang({ |
|||
jobCode: this.selectedJobCode, |
|||
rackCode: currentRackCode, |
|||
operatorName: this.operatorName, |
|||
payloadJson: JSON.stringify({ |
|||
materialScanCode: this.materialScanCode |
|||
}) |
|||
}) |
|||
this.assertApiSuccess(resp, '上挂失败') |
|||
await this.loadProductionView() |
|||
this.$message.success('上挂成功') |
|||
this.materialScanCode = '' |
|||
this.rackScanCode = '' |
|||
this.selectedRackCode = '' |
|||
} catch (e) { |
|||
this.$message.error(this.resolveErrorMessage(e)) |
|||
} finally { |
|||
this.upHangSubmitting = false |
|||
} |
|||
}, |
|||
async downHangAction () { |
|||
if (this.downHangSubmitting) { |
|||
return |
|||
} |
|||
this.syncScanCodes() |
|||
if (!this.selectedRackCode && this.getActiveRackCodes().length === 1) { |
|||
this.selectedRackCode = this.getActiveRackCodes()[0] |
|||
} |
|||
if (!this.selectedRackCode) { |
|||
this.$message.warning('请先扫码要下挂的挂具') |
|||
return |
|||
} |
|||
const downRackCode = this.selectedRackCode |
|||
this.downHangSubmitting = true |
|||
try { |
|||
const resp = await productionDownHang({ |
|||
jobCode: this.selectedJobCode, |
|||
rackCode: downRackCode, |
|||
operatorName: this.operatorName |
|||
}) |
|||
this.assertApiSuccess(resp, '下挂失败') |
|||
await this.loadProductionView() |
|||
this.$message.success('下挂成功') |
|||
this.selectedRackCode = '' |
|||
this.rackScanCode = '' |
|||
} catch (e) { |
|||
this.$message.error(this.resolveErrorMessage(e)) |
|||
} finally { |
|||
this.downHangSubmitting = false |
|||
} |
|||
}, |
|||
async completeAction () { |
|||
if (this.completeSubmitting) { |
|||
return |
|||
} |
|||
const detailRows = this.production.detailRows || [] |
|||
const totalRemainingQty = detailRows.reduce((sum, item) => sum + (Number(item.remainingQty) || 0), 0) |
|||
if (totalRemainingQty > 0) { |
|||
try { |
|||
await this.$confirm(`还有 ${totalRemainingQty} 未执行,确认仍要完工吗?`, '提示', { type: 'warning' }) |
|||
} catch (e) { |
|||
return |
|||
} |
|||
} |
|||
this.completeSubmitting = true |
|||
try { |
|||
const resp = await productionComplete(this.selectedJobCode, { operatorName: this.operatorName }) |
|||
this.assertApiSuccess(resp, '任务完工失败') |
|||
this.$message.success('任务完工') |
|||
await this.loadProductionView() |
|||
await this.refreshBaseOptions() |
|||
} catch (e) { |
|||
this.$message.error(this.resolveErrorMessage(e)) |
|||
} finally { |
|||
this.completeSubmitting = false |
|||
} |
|||
}, |
|||
getCurrentRackForPass () { |
|||
const activeRackCodes = this.getActiveRackCodes() |
|||
if (this.selectedBindingRackCode && activeRackCodes.includes(this.selectedBindingRackCode)) { |
|||
return this.selectedBindingRackCode |
|||
} |
|||
if (this.selectedRackCode && activeRackCodes.includes(this.selectedRackCode)) { |
|||
return this.selectedRackCode |
|||
} |
|||
if (this.rackScanCode && activeRackCodes.includes(this.rackScanCode)) { |
|||
return this.rackScanCode |
|||
} |
|||
return activeRackCodes[0] || '' |
|||
}, |
|||
getActiveRackCodes () { |
|||
const fromBinding = (this.production.bindingRows || []) |
|||
.filter(item => item && item.bindStatus === '生效中' && item.rackCode) |
|||
.map(item => item.rackCode) |
|||
if (fromBinding.length) { |
|||
return Array.from(new Set(fromBinding)) |
|||
} |
|||
return Array.from(new Set((this.production.activeRackCodes || []).filter(Boolean))) |
|||
}, |
|||
isRackActiveForPass (rackCode) { |
|||
if (!rackCode) { |
|||
return false |
|||
} |
|||
const activeRackCodes = this.getActiveRackCodes() |
|||
return activeRackCodes.includes(rackCode) || this.production.activeRackCode === rackCode |
|||
}, |
|||
assertApiSuccess (resp, fallbackMsg) { |
|||
const data = (resp && resp.data) || {} |
|||
if (data.code !== 0) { |
|||
throw new Error(data.msg || fallbackMsg || '操作失败') |
|||
} |
|||
return data |
|||
}, |
|||
resolveErrorMessage (e) { |
|||
if (e && e.response && e.response.data) { |
|||
return e.response.data.msg || e.message || '操作失败' |
|||
} |
|||
return (e && e.message) || '操作失败' |
|||
}, |
|||
validateMaterialForUpHang () { |
|||
if (!this.materialScanCode) { |
|||
return '请先扫码来料' |
|||
} |
|||
const key = String(this.materialScanCode).trim() |
|||
const rows = this.production.detailRows || [] |
|||
const matched = rows.filter(row => |
|||
String(row.partNo || '').trim() === key || |
|||
String(row.inboundNo || '').trim() === key || |
|||
String(row.batchCode || '').trim() === key |
|||
) |
|||
if (!matched.length) { |
|||
return `来料码 ${key} 不属于当前任务` |
|||
} |
|||
const hasRemaining = matched.some(row => Number(row.remainingQty || 0) > 0) |
|||
if (!hasRemaining) { |
|||
return `来料码 ${key} 对应明细剩余数量为 0` |
|||
} |
|||
return '' |
|||
}, |
|||
async tryAutoUpHang () { |
|||
if (!this.selectedJobCode || !this.materialScanCode || !this.selectedRackCode) { |
|||
return |
|||
} |
|||
if (!['待下达', '已下达', '生产中'].includes(this.production.status || '待下达')) { |
|||
return |
|||
} |
|||
if (this.upHangSubmitting) { |
|||
return |
|||
} |
|||
await this.upHangAction() |
|||
}, |
|||
getNextPendingStep () { |
|||
const steps = this.displayedStepProgress || [] |
|||
return steps.find(step => !step.passed) || null |
|||
}, |
|||
async ensureStepProgressLoaded () { |
|||
let steps = this.displayedStepProgress || [] |
|||
if (steps.length > 0) { |
|||
return steps |
|||
} |
|||
await this.loadProductionView() |
|||
steps = this.displayedStepProgress || [] |
|||
return steps |
|||
}, |
|||
onCurrentBindingChange (row) { |
|||
if (row && row.rackCode) { |
|||
this.selectedBindingRackCode = row.rackCode |
|||
return |
|||
} |
|||
this.selectedBindingRackCode = '' |
|||
}, |
|||
ensureBindingSelection () { |
|||
const rows = this.visibleBindingRows |
|||
if (!rows.length) { |
|||
this.selectedBindingRackCode = '' |
|||
this.$nextTick(() => { |
|||
if (this.$refs.bindingTable) { |
|||
this.$refs.bindingTable.setCurrentRow(null) |
|||
} |
|||
}) |
|||
return |
|||
} |
|||
const matchedRow = rows.find(row => row && row.rackCode === this.selectedBindingRackCode) |
|||
const exists = !!matchedRow |
|||
if (exists) { |
|||
this.$nextTick(() => { |
|||
if (this.$refs.bindingTable && matchedRow) { |
|||
this.$refs.bindingTable.setCurrentRow(matchedRow) |
|||
} |
|||
}) |
|||
return |
|||
} |
|||
this.selectedBindingRackCode = '' |
|||
this.$nextTick(() => { |
|||
if (this.$refs.bindingTable) { |
|||
this.$refs.bindingTable.setCurrentRow(null) |
|||
} |
|||
}) |
|||
}, |
|||
async simulateStationPassOnce () { |
|||
if (this.simulateSubmitting) { |
|||
return |
|||
} |
|||
this.simulateSubmitting = true |
|||
this.syncScanCodes() |
|||
const rackCode = this.getCurrentRackForPass() |
|||
if (!rackCode) { |
|||
this.$message.warning('请先扫码挂具后再模拟过站') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
if (!this.isRackActiveForPass(rackCode)) { |
|||
this.$message.warning(`挂具 ${rackCode} 未上挂,请先执行上挂后再模拟过站`) |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
await this.loadProductionView() |
|||
const steps = await this.ensureStepProgressLoaded() |
|||
if (!steps.length) { |
|||
this.$message.warning('未加载到工序步骤,请先检查程序池子配置') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
const nextStep = this.getNextPendingStep() |
|||
if (!nextStep) { |
|||
this.$message.success('当前任务所有池子都已通过,无需模拟') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
try { |
|||
const resp = await productionStationPass({ |
|||
jobCode: this.selectedJobCode, |
|||
rackCode, |
|||
stepCode: nextStep.stepCode, |
|||
stationId: `PLC-${nextStep.stepCode}`, |
|||
sourceType: 'PLC', |
|||
operatorName: this.operatorName, |
|||
payloadJson: JSON.stringify({ |
|||
source: 'PLC', |
|||
mode: 'single', |
|||
signal: 'PASS', |
|||
stepCode: nextStep.stepCode, |
|||
rackCode |
|||
}) |
|||
}) |
|||
this.assertApiSuccess(resp, '过站失败') |
|||
this.$message.success(`已模拟过站:${nextStep.stepCode}`) |
|||
await this.loadProductionView() |
|||
} catch (e) { |
|||
const msg = this.resolveErrorMessage(e) |
|||
if (msg.indexOf('工序顺序校验失败') >= 0) { |
|||
this.$message.warning('过站过快,已自动刷新最新工序,请再点一次') |
|||
await this.loadProductionView() |
|||
} else { |
|||
this.$message.error(msg) |
|||
} |
|||
} finally { |
|||
this.simulateSubmitting = false |
|||
} |
|||
}, |
|||
async simulateStationPassAll () { |
|||
if (this.simulateSubmitting) { |
|||
return |
|||
} |
|||
this.simulateSubmitting = true |
|||
this.syncScanCodes() |
|||
const rackCode = this.getCurrentRackForPass() |
|||
if (!rackCode) { |
|||
this.$message.warning('请先扫码挂具后再模拟过站') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
if (!this.isRackActiveForPass(rackCode)) { |
|||
this.$message.warning(`挂具 ${rackCode} 未上挂,请先执行上挂后再模拟过站`) |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
await this.loadProductionView() |
|||
const steps = await this.ensureStepProgressLoaded() |
|||
if (!steps.length) { |
|||
this.$message.warning('未加载到工序步骤,请先检查程序池子配置') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
const pendingSteps = steps.filter(step => !step.passed) |
|||
if (!pendingSteps.length) { |
|||
this.$message.success('当前任务所有池子都已通过,无需模拟') |
|||
this.simulateSubmitting = false |
|||
return |
|||
} |
|||
try { |
|||
const resp = await productionStationPassAll({ |
|||
jobCode: this.selectedJobCode, |
|||
rackCode, |
|||
sourceType: 'PLC', |
|||
operatorName: this.operatorName, |
|||
payloadJson: JSON.stringify({ |
|||
source: 'PLC', |
|||
mode: 'all', |
|||
signal: 'PASS', |
|||
rackCode |
|||
}) |
|||
}) |
|||
const data = this.assertApiSuccess(resp, '批量过站失败') |
|||
const result = data.result || {} |
|||
const passCount = Number(result.passCount || 0) |
|||
if (passCount > 0) { |
|||
this.$message.success(`已模拟完成 ${passCount} 道工序`) |
|||
} else { |
|||
this.$message.success(result.msg || '当前任务所有池子都已通过,无需模拟') |
|||
} |
|||
await this.loadProductionView() |
|||
} catch (e) { |
|||
this.$message.error(this.resolveErrorMessage(e)) |
|||
} finally { |
|||
this.simulateSubmitting = false |
|||
} |
|||
}, |
|||
onAutoRefreshChange (val) { |
|||
if (val) { |
|||
this.startTimer() |
|||
} else { |
|||
this.stopTimer() |
|||
} |
|||
}, |
|||
startTimer () { |
|||
this.stopTimer() |
|||
this.timer = setInterval(() => { |
|||
if (this.autoRefresh && this.selectedJobCode) { |
|||
this.loadProductionView() |
|||
} |
|||
}, 5000) |
|||
}, |
|||
stopTimer () { |
|||
if (this.timer) { |
|||
clearInterval(this.timer) |
|||
this.timer = null |
|||
} |
|||
}, |
|||
formatDateTime (v) { |
|||
if (!v) return '-' |
|||
let date = null |
|||
if (typeof v === 'number') { |
|||
date = new Date(v) |
|||
} else if (typeof v === 'string') { |
|||
const trimmed = v.trim() |
|||
if (/^\d+$/.test(trimmed)) { |
|||
date = new Date(Number(trimmed)) |
|||
} else { |
|||
date = new Date(trimmed) |
|||
} |
|||
} else if (v instanceof Date) { |
|||
date = v |
|||
} else { |
|||
date = new Date(v) |
|||
} |
|||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) { |
|||
return String(v).replace('T', ' ') |
|||
} |
|||
const y = date.getFullYear() |
|||
const m = String(date.getMonth() + 1).padStart(2, '0') |
|||
const d = String(date.getDate()).padStart(2, '0') |
|||
const hh = String(date.getHours()).padStart(2, '0') |
|||
const mm = String(date.getMinutes()).padStart(2, '0') |
|||
const ss = String(date.getSeconds()).padStart(2, '0') |
|||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}` |
|||
}, |
|||
async ensureProductionStarted () { |
|||
if (this.production.status === '待下达') { |
|||
const resp = await productionStart(this.selectedJobCode) |
|||
this.assertApiSuccess(resp, '自动下达失败') |
|||
await this.loadProductionView() |
|||
} |
|||
}, |
|||
syncScanCodes () { |
|||
if (!this.selectedJobCode && this.jobScanCode) { |
|||
this.selectedJobCode = this.jobScanCode |
|||
} |
|||
if (!this.selectedRackCode && this.rackScanCode) { |
|||
this.selectedRackCode = this.rackScanCode |
|||
} |
|||
if (!this.selectedRackCode && this.production.activeRackCode) { |
|||
this.selectedRackCode = this.production.activeRackCode |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 5px 15px 2px 15px; |
|||
border-radius: 4px; |
|||
margin-bottom: 6px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
|
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.pool-flow-wrap { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
align-items: stretch; |
|||
padding: 2px 0 4px; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.pool-flow-item { |
|||
width: 118px; |
|||
border: 1px solid #ebeef5; |
|||
border-radius: 6px; |
|||
background: #fff; |
|||
padding: 4px 6px; |
|||
position: relative; |
|||
} |
|||
|
|||
.pool-flow-item.passed { |
|||
border-color: #c2e7b0; |
|||
background: #f0f9eb; |
|||
} |
|||
|
|||
.pool-flow-item-head { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 3px; |
|||
} |
|||
|
|||
.pool-flow-node { |
|||
width: 16px; |
|||
height: 16px; |
|||
border-radius: 50%; |
|||
border: 1px solid #c0c4cc; |
|||
color: #909399; |
|||
font-size: 11px; |
|||
line-height: 16px; |
|||
text-align: center; |
|||
background: #fff; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.pool-flow-node.passed { |
|||
border-color: #67c23a; |
|||
background: #67c23a; |
|||
color: #fff; |
|||
} |
|||
|
|||
.pool-flow-line { |
|||
width: 9px; |
|||
height: 2px; |
|||
background: #dcdfe6; |
|||
margin: 0 1px; |
|||
flex-shrink: 0; |
|||
} |
|||
|
|||
.pool-flow-line.passed { |
|||
background: #67c23a; |
|||
} |
|||
|
|||
.pool-flow-inline-text { |
|||
flex: 1; |
|||
font-size: 11px; |
|||
color: #606266; |
|||
white-space: nowrap; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
} |
|||
|
|||
.pool-flow-inline-text.passed { |
|||
color: #67c23a; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.card-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,401 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="任务单号"> |
|||
<el-input v-model="searchData.jobCode" clearable style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="searchData.status" clearable placeholder="全部" style="width: 120px"> |
|||
<el-option label="待下达" value="待下达" /> |
|||
<el-option label="已下达" value="已下达" /> |
|||
<el-option label="生产中" value="生产中" /> |
|||
<el-option label="已完工" value="已完工" /> |
|||
<el-option label="已关闭" value="已关闭" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
<el-button plain class="add-btn" @click="openDialog">新建任务单</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table |
|||
ref="jobTable" |
|||
class="data-table" |
|||
:data="dataList" |
|||
border :height="tableHeight" |
|||
highlight-current-row |
|||
style="width: 100%" |
|||
v-loading="loading" |
|||
@current-change="onCurrentJobChange"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="jobCode" label="任务单号" width="180" /> |
|||
<el-table-column prop="inboundNos" label="关联入库单号" min-width="260" /> |
|||
<el-table-column prop="plannedQty" label="任务数量" width="100" /> |
|||
<el-table-column prop="status" label="状态" width="100" /> |
|||
<el-table-column label="操作" width="190"> |
|||
<template slot-scope="scope"> |
|||
<a @click="dispatch(scope.row.jobCode)">下达</a> |
|||
<a style="margin-left: 10px" @click="complete(scope.row.jobCode)">完工</a> |
|||
<a style="color:#F56C6C;margin-left:10px" v-if="scope.row.status==='待下达'" @click="handleDeleteJob(scope.row)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-tabs v-model="detailTab" type="border-card" style="margin-top: 12px;"> |
|||
<el-tab-pane label="任务明细" name="details"> |
|||
<el-table |
|||
class="data-table" |
|||
:data="selectedJobDetails" |
|||
border height="280" |
|||
v-loading="detailLoading" |
|||
style="width:100%"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="inboundNo" label="入库单号" width="170" /> |
|||
<el-table-column prop="batchCode" label="批次编码" width="140" /> |
|||
<el-table-column prop="partNo" label="物料分类" width="150" /> |
|||
<el-table-column prop="partName" label="物料名称" width="160" /> |
|||
<el-table-column prop="plannedQty" label="计划数量" width="100" /> |
|||
<el-table-column prop="executedQty" label="已执行" width="100" /> |
|||
<el-table-column prop="remainingQty" label="未执行" width="100" /> |
|||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip /> |
|||
</el-table> |
|||
</el-tab-pane> |
|||
</el-tabs> |
|||
|
|||
<el-dialog |
|||
title="新建任务单(选择批次物料并按剩余量分配)" |
|||
:visible.sync="dialogVisible" |
|||
width="1100px" |
|||
top="5vh" |
|||
:close-on-click-modal="false" |
|||
class="job-dialog" |
|||
v-drag> |
|||
<el-form :model="form" label-position="top" class="job-form"> |
|||
<el-card shadow="never" class="pick-card"> |
|||
<div slot="header" class="card-title-row"> |
|||
<span>第一步:选择入库单(支持多选)</span> |
|||
<el-button plain class="search-btn" @click="loadInbounds">刷新入库单</el-button> |
|||
</div> |
|||
<el-table |
|||
ref="inboundSelectTable" |
|||
class="data-table" |
|||
:data="inboundOptions" |
|||
border |
|||
height="130" |
|||
@selection-change="onInboundSelectionChange" |
|||
@row-click="onInboundRowClick"> |
|||
<el-table-column type="selection" width="50" /> |
|||
<el-table-column prop="inboundNo" label="入库单号" width="170" /> |
|||
<el-table-column prop="batchCode" label="批次编码" width="160" /> |
|||
<el-table-column label="物料概览" min-width="260"> |
|||
<template slot-scope="scope"> |
|||
<span v-if="!(scope.row.partItems || []).length">-</span> |
|||
<a |
|||
v-for="item in (scope.row.partItems || [])" |
|||
:key="item.itemId" |
|||
size="mini" |
|||
style="margin-right:6px;margin-bottom:4px;"> |
|||
{{ item.partNo }} x {{ item.qty }} |
|||
</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</el-card> |
|||
|
|||
<el-card shadow="never" class="pick-card" style="margin-top: 10px;"> |
|||
<div slot="header" class="card-title-row"> |
|||
<span>第二步:填写任务明细(只能填写剩余量)</span> |
|||
</div> |
|||
<el-table class="data-table detail-table" :data="detailRows" border height="240"> |
|||
<el-table-column prop="inboundNo" label="入库单号" width="170" /> |
|||
<el-table-column prop="batchCode" label="批次编码" width="140" /> |
|||
<el-table-column prop="partNo" label="物料分类" width="150" /> |
|||
<el-table-column prop="partName" label="物料名称" width="160" /> |
|||
<el-table-column prop="totalQty" label="入库量" width="80" /> |
|||
<el-table-column prop="usedQty" label="已分配" width="80" /> |
|||
<el-table-column prop="remainingQty" label="剩余可分配" width="110" /> |
|||
<el-table-column label="本次执行数量" width="140"> |
|||
<template slot-scope="scope"> |
|||
<el-input |
|||
v-model="scope.row.executeQty" |
|||
:min="0" |
|||
:max="scope.row.remainingQty" |
|||
:disabled="scope.row.remainingQty <= 0" |
|||
controls-position="right" |
|||
style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</el-card> |
|||
</el-form> |
|||
<el-footer style="height: 40px; text-align: center; margin-top: 12px;"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible=false">取消</el-button> |
|||
<el-button plain class="add-btn" @click="saveJobOrder">保存任务单</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listBatch, listJob, saveJob, dispatchJobByCode, completeJobByCode, listJobAvailableMaterials, listJobDetailsByCode, deleteJobByCode, listPart } from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
// 表格高度 |
|||
tableHeight: (window.innerHeight - 260)/2, |
|||
loading: false, |
|||
detailLoading: false, |
|||
dialogVisible: false, |
|||
dataList: [], |
|||
detailTab: 'details', |
|||
selectedJob: null, |
|||
selectedJobDetails: [], |
|||
inboundOptions: [], |
|||
selectedInbounds: [], |
|||
detailRows: [], |
|||
searchData: { jobCode: '', status: '' }, |
|||
form: { remark: '' } |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const { data } = await listJob(this.searchData) |
|||
this.dataList = data.rows || [] |
|||
this.selectedJob = null |
|||
this.selectedJobDetails = [] |
|||
if (this.dataList.length) { |
|||
this.$nextTick(() => { |
|||
this.$refs.jobTable && this.$refs.jobTable.setCurrentRow(this.dataList[0]) |
|||
}) |
|||
} |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.searchData = { jobCode: '', status: '' } |
|||
this.loadList() |
|||
}, |
|||
async onCurrentJobChange (row) { |
|||
this.selectedJob = row || null |
|||
this.selectedJobDetails = [] |
|||
if (!row || !row.jobCode) { |
|||
return |
|||
} |
|||
this.detailLoading = true |
|||
try { |
|||
const { data } = await listJobDetailsByCode(row.jobCode) |
|||
this.selectedJobDetails = data.rows || [] |
|||
} finally { |
|||
this.detailLoading = false |
|||
} |
|||
}, |
|||
async openDialog () { |
|||
this.form = { remark: '' } |
|||
this.selectedInbounds = [] |
|||
this.detailRows = [] |
|||
this.dialogVisible = true |
|||
await this.loadInbounds() |
|||
}, |
|||
async loadInbounds () { |
|||
const { data } = await listBatch({}) |
|||
const rows = data.rows || [] |
|||
const inboundNoList = rows.map(item => item.inboundNo).filter(Boolean) |
|||
if (!inboundNoList.length) { |
|||
this.inboundOptions = [] |
|||
return |
|||
} |
|||
const { data: availableData } = await listJobAvailableMaterials({ inboundNoList }) |
|||
const remainMap = {} |
|||
;(availableData.rows || []).forEach(item => { |
|||
const inboundNo = item.inboundNo |
|||
if (!inboundNo) { |
|||
return |
|||
} |
|||
const remainingQty = Number(item.remainingQty) || 0 |
|||
remainMap[inboundNo] = (remainMap[inboundNo] || 0) + remainingQty |
|||
}) |
|||
this.inboundOptions = rows.filter(item => (remainMap[item.inboundNo] || 0) > 0) |
|||
}, |
|||
async onInboundSelectionChange (rows) { |
|||
this.selectedInbounds = rows || [] |
|||
await this.loadAvailableMaterials(false) |
|||
}, |
|||
onInboundRowClick (row) { |
|||
if (!row) { |
|||
return |
|||
} |
|||
this.$refs.inboundSelectTable && this.$refs.inboundSelectTable.toggleRowSelection(row) |
|||
}, |
|||
async loadAvailableMaterials (showEmptyWarning = true) { |
|||
const inboundNoList = this.selectedInbounds.map(i => i.inboundNo).filter(Boolean) |
|||
if (!inboundNoList.length) { |
|||
this.detailRows = [] |
|||
if (showEmptyWarning) { |
|||
this.$message.warning('请先选择至少一个入库单') |
|||
} |
|||
return |
|||
} |
|||
const previousValueMap = this.detailRows.reduce((acc, item) => { |
|||
const key = `${item.inboundNo || ''}_${item.partNo || ''}` |
|||
acc[key] = { |
|||
executeQty: Number(item.executeQty) || 0, |
|||
remark: item.remark || '' |
|||
} |
|||
return acc |
|||
}, {}) |
|||
const { data } = await listJobAvailableMaterials({ inboundNoList }) |
|||
this.detailRows = (data.rows || []).map(i => { |
|||
const key = `${i.inboundNo || ''}_${i.partNo || ''}` |
|||
const hasPrevious = Object.prototype.hasOwnProperty.call(previousValueMap, key) |
|||
const previous = previousValueMap[key] || {} |
|||
const remainingQty = Number(i.remainingQty) || 0 |
|||
return { |
|||
...i, |
|||
executeQty: hasPrevious |
|||
? Math.min(Number(previous.executeQty) || 0, remainingQty) |
|||
: remainingQty, |
|||
remark: previous.remark || '' |
|||
} |
|||
}) |
|||
}, |
|||
async saveJobOrder () { |
|||
const detailItems = this.detailRows |
|||
.filter(i => Number(i.executeQty) > 0) |
|||
.map(i => ({ |
|||
inboundNo: i.inboundNo, |
|||
partNo: i.partNo, |
|||
plannedQty: Number(i.executeQty), |
|||
remark: i.remark |
|||
})) |
|||
if (!detailItems.length) { |
|||
this.$message.warning('请至少填写一条任务明细执行数量') |
|||
return |
|||
} |
|||
const routeCheck = await this.validateDetailRouteConsistency(detailItems) |
|||
if (!routeCheck.ok) { |
|||
this.$message({ |
|||
type: 'warning', |
|||
message: routeCheck.message, |
|||
duration: 3000 |
|||
}) |
|||
return |
|||
} |
|||
await saveJob({ |
|||
...this.form, |
|||
detailItems |
|||
}) |
|||
this.$message.success('任务保存成功') |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
}, |
|||
async validateDetailRouteConsistency (detailItems) { |
|||
const partNoList = Array.from(new Set((detailItems || []).map(item => item && item.partNo).filter(Boolean))) |
|||
if (!partNoList.length) { |
|||
return { ok: true, message: '' } |
|||
} |
|||
const { data } = await listPart({}) |
|||
const partRows = data.rows || [] |
|||
const partMap = partRows.reduce((acc, part) => { |
|||
const key = String(part.partNo || '').trim() |
|||
if (key && !acc[key]) { |
|||
acc[key] = part |
|||
} |
|||
return acc |
|||
}, {}) |
|||
|
|||
const missingParts = [] |
|||
const noRouteParts = [] |
|||
const routeCodeSet = new Set() |
|||
partNoList.forEach(partNo => { |
|||
const key = String(partNo).trim() |
|||
const part = partMap[key] |
|||
if (!part) { |
|||
missingParts.push(key) |
|||
return |
|||
} |
|||
const routeCode = String(part.routeCode || '').trim() |
|||
if (!routeCode) { |
|||
noRouteParts.push(key) |
|||
return |
|||
} |
|||
routeCodeSet.add(routeCode) |
|||
}) |
|||
|
|||
if (missingParts.length) { |
|||
return { |
|||
ok: false, |
|||
message: `以下物料在档案中不存在:${missingParts.join('、')}` |
|||
} |
|||
} |
|||
if (noRouteParts.length) { |
|||
return { |
|||
ok: false, |
|||
message: `以下物料未绑定程序,不允许创建任务单:${noRouteParts.join('、')}` |
|||
} |
|||
} |
|||
if (routeCodeSet.size > 1) { |
|||
return { |
|||
ok: false, |
|||
message: `任务单仅允许单一程序,当前明细存在多个程序:${Array.from(routeCodeSet).join(' / ')}` |
|||
} |
|||
} |
|||
return { ok: true, message: '' } |
|||
}, |
|||
async dispatch (jobCode) { |
|||
await dispatchJobByCode(jobCode) |
|||
this.$message.success('任务下达成功') |
|||
this.loadList() |
|||
}, |
|||
async complete (jobCode) { |
|||
await completeJobByCode(jobCode, { operatorName: 'PC操作员' }) |
|||
this.$message.success('任务完工') |
|||
this.loadList() |
|||
}, |
|||
async handleDeleteJob (row) { |
|||
if (!row || !row.jobCode) { |
|||
return |
|||
} |
|||
try { |
|||
await this.$confirm(`确认物理删除任务单:${row.jobCode} 吗?`, '提示', { type: 'warning' }) |
|||
await deleteJobByCode(row.jobCode) |
|||
this.$message.success('删除成功') |
|||
this.loadList() |
|||
} catch (e) {} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.job-dialog >>> .el-dialog__body { padding: 12px 20px 10px; max-height: 72vh; overflow-y: auto; } |
|||
.job-form >>> .el-form-item { margin-bottom: 10px; } |
|||
.pick-card { border: 1px solid #ebeef5; } |
|||
.card-title-row { display: flex; align-items: center; justify-content: space-between; } |
|||
.detail-table >>> .el-table__body-wrapper .cell { overflow: visible !important; } |
|||
.data-table { background-color:#fff; border-radius:4px; } |
|||
.data-table >>> .el-table__header-wrapper th,.data-table >>> .el-table__fixed-header-wrapper th { background-color:#f5f7fa !important;color:#333;font-weight:600;border-color:#ebeef5;padding:8px 0; } |
|||
.data-table >>> .el-table__header-wrapper .cell,.data-table >>> .el-table__fixed-header-wrapper .cell,.data-table >>> .el-table__body-wrapper .cell,.data-table >>> .el-table__fixed-body-wrapper .cell { padding:0 10px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-size:13px !important; } |
|||
.data-table >>> .el-table__body tr:hover > td { background-color:#f5f7fa !important; } |
|||
.data-table >>> .el-table__body tr.current-row > td { background-color:#ecf5ff !important; } |
|||
.query-form { background-color:#fff;padding:15px 15px 5px 15px;border-radius:4px;margin-bottom:12px; } |
|||
.query-form >>> .el-form-item__label { color:#333;font-size:13px;padding-bottom:5px; } |
|||
.query-form >>> .el-input__inner { height:32px;line-height:32px;border-radius:4px;font-size:13px; } |
|||
.query-form >>> .el-button { height:32px;padding:0 15px;font-size:13px;border-radius:4px; } |
|||
.search-btn { background-color:#ecf5ff;border-color:#b3d8ff;color:#409eff; } |
|||
.search-btn:hover { background-color:#409eff;border-color:#409eff;color:#fff; } |
|||
.reset-btn { background-color:#f5f7fa;border-color:#d3d4d6;color:#606266; } |
|||
.reset-btn:hover { background-color:#909399;border-color:#909399;color:#fff; } |
|||
.add-btn { background-color:#f0f9eb;border-color:#c2e7b0;color:#67c23a; } |
|||
.add-btn:hover { background-color:#67c23a;border-color:#67c23a;color:#fff; } |
|||
.detail-tabs { margin-top: 12px; background:#fff; border-radius:4px; padding: 8px 12px 12px; } |
|||
.detail-panel-title { margin-bottom: 8px; font-size: 13px; color: #606266; } |
|||
</style> |
|||
@ -0,0 +1,699 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-card class="overview-card" shadow="never"> |
|||
<div slot="header" class="card-title-row"> |
|||
<div> |
|||
<div class="page-title">任务执行手工补录台账</div> |
|||
<div class="page-desc">用于 PLC 信息缺失场景的人工补录,确保任务执行链路完整可追溯</div> |
|||
</div> |
|||
<el-button plain class="add-btn" @click="openDialog">新增补录</el-button> |
|||
</div> |
|||
<div class="overview-grid"> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">补录总数</div> |
|||
<div class="overview-value">{{ summary.totalCount }}</div> |
|||
</div> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">补录总数量</div> |
|||
<div class="overview-value">{{ summary.totalQty }}</div> |
|||
</div> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">过站补录</div> |
|||
<div class="overview-value">{{ summary.typeCount['过站补录'] || 0 }}</div> |
|||
</div> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">上挂补录</div> |
|||
<div class="overview-value">{{ summary.typeCount['上挂补录'] || 0 }}</div> |
|||
</div> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">下挂补录</div> |
|||
<div class="overview-value">{{ summary.typeCount['下挂补录'] || 0 }}</div> |
|||
</div> |
|||
<div class="overview-item"> |
|||
<div class="overview-label">完工补录</div> |
|||
<div class="overview-value">{{ summary.typeCount['完工补录'] || 0 }}</div> |
|||
</div> |
|||
</div> |
|||
</el-card> |
|||
|
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="任务单号"> |
|||
<el-input v-model.trim="query.jobCode" clearable placeholder="请输入任务单号" style="width: 200px" @keyup.enter.native="loadList" /> |
|||
</el-form-item> |
|||
<el-form-item label="补录类型"> |
|||
<el-select v-model="query.recordType" clearable placeholder="全部类型" style="width: 180px"> |
|||
<el-option v-for="item in recordTypeOptions" :key="item" :label="item" :value="item" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="关联单号"> |
|||
<el-input v-model.trim="query.docNo" clearable placeholder="模糊查询" style="width: 180px" @keyup.enter.native="loadList" /> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" :loading="loading" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table |
|||
class="data-table" |
|||
:data="dataList" |
|||
border |
|||
stripe |
|||
style="width: 100%" |
|||
v-loading="loading" |
|||
:height="tableHeight"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="recordId" label="record_id" width="170" /> |
|||
<el-table-column label="类型" width="110"> |
|||
<template slot-scope="scope"> |
|||
<el-tag size="mini" :type="recordTypeTag(scope.row.recordType)">{{ scope.row.recordType || '-' }}</el-tag> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column prop="jobId" label="job_id" width="130" /> |
|||
<el-table-column prop="docNo" label="关联单号" min-width="150" show-overflow-tooltip /> |
|||
<el-table-column label="挂具" width="100"> |
|||
<template slot-scope="scope">{{ payloadValue(scope.row, 'rackCode') || '-' }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="工序" width="120"> |
|||
<template slot-scope="scope">{{ payloadValue(scope.row, 'stepCode') || '-' }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="站点" width="120"> |
|||
<template slot-scope="scope">{{ payloadValue(scope.row, 'stationId') || '-' }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="缺失原因" min-width="150" show-overflow-tooltip> |
|||
<template slot-scope="scope">{{ payloadValue(scope.row, 'missingReason') || '-' }}</template> |
|||
</el-table-column> |
|||
<el-table-column prop="qty" label="数量" width="80" align="right" /> |
|||
<el-table-column prop="operatorName" label="补录人" width="110" /> |
|||
<el-table-column prop="reviewerName" label="复核人" width="110" /> |
|||
<el-table-column label="补录时间" min-width="170"> |
|||
<template slot-scope="scope">{{ formatDateTime(scope.row.recordTime) }}</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-dialog |
|||
title="PLC缺失信息手工补录" |
|||
:visible.sync="dialogVisible" |
|||
width="900px" |
|||
top="5vh" |
|||
class="manual-dialog" |
|||
:close-on-click-modal="false" |
|||
v-drag> |
|||
<el-form ref="manualForm" :model="form" :rules="rules" label-position="top" class="manual-form"> |
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"> |
|||
<el-form-item label="补录类型" prop="recordType"> |
|||
<el-select v-model="form.recordType" style="width: 100%"> |
|||
<el-option v-for="item in recordTypeOptions" :key="item" :label="item" :value="item" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</el-col> |
|||
<el-col :span="10"> |
|||
<el-form-item label="任务单号" prop="selectedJobCode"> |
|||
<el-select |
|||
v-model="form.selectedJobCode" |
|||
filterable |
|||
clearable |
|||
:loading="jobLoading" |
|||
placeholder="请选择任务单号" |
|||
style="width: 100%" |
|||
@change="onDialogJobChange"> |
|||
<el-option |
|||
v-for="item in jobOptions" |
|||
:key="item.jobCode" |
|||
:label="`${item.jobCode} / ${item.status || '-'} / ${item.inboundNos || '-'}`" |
|||
:value="item.jobCode" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</el-col> |
|||
<el-col :span="6"> |
|||
<el-form-item label="补录数量" prop="qty"> |
|||
<el-input-number v-model="form.qty" :min="0" :precision="0" style="width: 100%" /> |
|||
</el-form-item> |
|||
</el-col> |
|||
</el-row> |
|||
|
|||
<el-row :gutter="12"> |
|||
<el-col :span="12"> |
|||
<el-form-item label="任务明细(可选)"> |
|||
<el-select |
|||
v-model="form.selectedDetailKey" |
|||
clearable |
|||
filterable |
|||
:loading="detailLoading" |
|||
placeholder="选择后自动带入 batch_id / part_id" |
|||
style="width: 100%" |
|||
@change="onDetailChange"> |
|||
<el-option |
|||
v-for="item in jobDetailOptions" |
|||
:key="item._key" |
|||
:label="`${item.inboundNo || '-'} / ${item.partNo || '-'} / 剩余${item.remainingQty || 0}`" |
|||
:value="item._key" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</el-col> |
|||
<el-col :span="12"> |
|||
<el-form-item label="关联单号" prop="docNo"> |
|||
<el-input v-model.trim="form.docNo" placeholder="建议:任务单+工序+挂具组合号"> |
|||
<el-button slot="append" @click="fillDocNoByContext">自动生成</el-button> |
|||
</el-input> |
|||
</el-form-item> |
|||
</el-col> |
|||
</el-row> |
|||
|
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"><el-form-item label="batch_id"><el-input v-model="form.batchId" disabled /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="part_id"><el-input v-model="form.partId" disabled /></el-form-item></el-col> |
|||
<el-col :span="8"> |
|||
<el-form-item label="补录时间"> |
|||
<el-date-picker v-model="form.recordTime" type="datetime" style="width: 100%" /> |
|||
</el-form-item> |
|||
</el-col> |
|||
</el-row> |
|||
|
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"><el-form-item label="挂具码"><el-input v-model.trim="form.rackCode" placeholder="例如 RACK-001" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="工序编码"><el-input v-model.trim="form.stepCode" placeholder="例如 POOL-001" /></el-form-item></el-col> |
|||
<el-col :span="8"><el-form-item label="站点ID"><el-input v-model.trim="form.stationId" placeholder="例如 PLC-POOL-001" /></el-form-item></el-col> |
|||
</el-row> |
|||
|
|||
<el-row :gutter="12"> |
|||
<el-col :span="8"> |
|||
<el-form-item label="缺失原因"> |
|||
<el-select v-model="form.missingReason" style="width: 100%"> |
|||
<el-option v-for="item in missingReasonOptions" :key="item" :label="item" :value="item" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</el-col> |
|||
<el-col :span="16"> |
|||
<el-form-item label="原因说明"> |
|||
<el-input v-model.trim="form.reasonRemark" placeholder="可填写故障说明、补录依据等" /> |
|||
</el-form-item> |
|||
</el-col> |
|||
</el-row> |
|||
|
|||
<el-row :gutter="12"> |
|||
<el-col :span="12"><el-form-item label="补录人"><el-input v-model.trim="form.operatorName" placeholder="例如 班组长A" /></el-form-item></el-col> |
|||
<el-col :span="12"><el-form-item label="复核人"><el-input v-model.trim="form.reviewerName" placeholder="例如 工艺工程师B" /></el-form-item></el-col> |
|||
</el-row> |
|||
|
|||
<el-form-item label="扩展JSON(可选)" prop="payloadJson"> |
|||
<el-input |
|||
v-model.trim="form.payloadJson" |
|||
type="textarea" |
|||
:rows="4" |
|||
placeholder='如 {"manualSource":"paper-log"},留空则自动按上方字段生成' /> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-footer style="height: 40px; text-align: center; margin-top: 10px;"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible = false">取消</el-button> |
|||
<el-button plain class="add-btn" :loading="submitLoading" @click="submit">保存补录</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listJob, listJobDetailsByCode, listManualRecord, saveManualRecord } from '@/api/rack/closedLoop' |
|||
|
|||
const RECORD_TYPE_OPTIONS = ['过站补录', '上挂补录', '下挂补录', '完工补录', '其他补录'] |
|||
const MISSING_REASON_OPTIONS = ['PLC数据缺失', 'PLC离线', 'PLC通信中断', 'PLC数据丢包', '人工确认补录', '其他'] |
|||
|
|||
const emptyForm = () => ({ |
|||
recordType: '过站补录', |
|||
selectedJobCode: '', |
|||
jobId: null, |
|||
selectedDetailKey: '', |
|||
batchId: '', |
|||
partId: '', |
|||
docNo: '', |
|||
qty: 0, |
|||
rackCode: '', |
|||
stepCode: '', |
|||
stationId: '', |
|||
missingReason: 'PLC数据缺失', |
|||
reasonRemark: '', |
|||
operatorName: '', |
|||
reviewerName: '', |
|||
recordTime: new Date(), |
|||
payloadJson: '' |
|||
}) |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
tableHeight: window.innerHeight - 320, |
|||
loading: false, |
|||
jobLoading: false, |
|||
detailLoading: false, |
|||
submitLoading: false, |
|||
dialogVisible: false, |
|||
dataList: [], |
|||
jobOptions: [], |
|||
jobDetailOptions: [], |
|||
query: { |
|||
jobCode: '', |
|||
recordType: '', |
|||
docNo: '' |
|||
}, |
|||
form: emptyForm(), |
|||
recordTypeOptions: RECORD_TYPE_OPTIONS, |
|||
missingReasonOptions: MISSING_REASON_OPTIONS, |
|||
rules: { |
|||
recordType: [{ required: true, message: '请选择补录类型', trigger: 'change' }], |
|||
selectedJobCode: [{ required: true, message: '请选择任务单号', trigger: 'change' }], |
|||
docNo: [{ required: true, message: '请输入关联单号', trigger: 'blur' }], |
|||
qty: [{ type: 'number', required: true, message: '请输入补录数量', trigger: 'change' }], |
|||
payloadJson: [{ validator: (rule, value, callback) => this.validatePayloadJson(value, callback), trigger: 'blur' }] |
|||
} |
|||
} |
|||
}, |
|||
computed: { |
|||
summary () { |
|||
const result = { |
|||
totalCount: this.dataList.length, |
|||
totalQty: 0, |
|||
typeCount: {} |
|||
} |
|||
this.dataList.forEach(item => { |
|||
const type = item.recordType || '未知' |
|||
result.typeCount[type] = (result.typeCount[type] || 0) + 1 |
|||
result.totalQty += Number(item.qty) || 0 |
|||
}) |
|||
return result |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadJobOptions() |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
async loadJobOptions () { |
|||
this.jobLoading = true |
|||
try { |
|||
const { data } = await listJob({}) |
|||
this.jobOptions = data.rows || [] |
|||
} finally { |
|||
this.jobLoading = false |
|||
} |
|||
}, |
|||
resolveJobByCode (jobCode) { |
|||
const key = String(jobCode || '').trim() |
|||
if (!key) { |
|||
return null |
|||
} |
|||
return (this.jobOptions || []).find(item => item && String(item.jobCode || '').trim() === key) || null |
|||
}, |
|||
async resolveJobIdForQuery () { |
|||
const key = String(this.query.jobCode || '').trim() |
|||
if (!key) { |
|||
return null |
|||
} |
|||
let matched = this.resolveJobByCode(key) |
|||
if (!matched) { |
|||
const { data } = await listJob({ jobCode: key }) |
|||
const rows = data.rows || [] |
|||
matched = rows.find(item => item && String(item.jobCode || '').trim() === key) || rows[0] || null |
|||
} |
|||
return matched ? matched.jobId : -1 |
|||
}, |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const jobId = await this.resolveJobIdForQuery() |
|||
if (jobId === -1) { |
|||
this.dataList = [] |
|||
this.$message.warning(`任务单不存在:${this.query.jobCode}`) |
|||
return |
|||
} |
|||
const params = { |
|||
recordType: this.query.recordType, |
|||
docNo: this.query.docNo |
|||
} |
|||
if (jobId) { |
|||
params.jobId = jobId |
|||
} |
|||
const { data } = await listManualRecord(params) |
|||
this.dataList = (data.rows || []).map(row => ({ |
|||
...row, |
|||
_payload: this.safeParseJson(row.payloadJson) |
|||
})) |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.query = { jobCode: '', recordType: '', docNo: '' } |
|||
this.loadList() |
|||
}, |
|||
openDialog () { |
|||
this.form = emptyForm() |
|||
this.jobDetailOptions = [] |
|||
if (!this.jobOptions.length) { |
|||
this.loadJobOptions() |
|||
} |
|||
if (this.query.jobCode) { |
|||
this.form.selectedJobCode = this.query.jobCode |
|||
this.onDialogJobChange(this.query.jobCode) |
|||
} |
|||
this.dialogVisible = true |
|||
this.$nextTick(() => { |
|||
this.$refs.manualForm && this.$refs.manualForm.clearValidate() |
|||
}) |
|||
}, |
|||
async onDialogJobChange (jobCode) { |
|||
this.form.jobId = null |
|||
this.form.selectedDetailKey = '' |
|||
this.form.batchId = '' |
|||
this.form.partId = '' |
|||
this.jobDetailOptions = [] |
|||
|
|||
if (!jobCode) { |
|||
return |
|||
} |
|||
let job = this.resolveJobByCode(jobCode) |
|||
if (!job) { |
|||
const { data } = await listJob({ jobCode }) |
|||
const rows = data.rows || [] |
|||
job = rows.find(item => item && item.jobCode === jobCode) || rows[0] || null |
|||
} |
|||
this.form.jobId = job ? job.jobId : null |
|||
await this.loadJobDetails(jobCode) |
|||
}, |
|||
async loadJobDetails (jobCode) { |
|||
if (!jobCode) { |
|||
this.jobDetailOptions = [] |
|||
return |
|||
} |
|||
this.detailLoading = true |
|||
try { |
|||
const { data } = await listJobDetailsByCode(jobCode) |
|||
const rows = data.rows || [] |
|||
this.jobDetailOptions = rows.map(item => ({ |
|||
...item, |
|||
_key: `${item.inboundId || ''}_${item.partNo || ''}` |
|||
})) |
|||
} finally { |
|||
this.detailLoading = false |
|||
} |
|||
}, |
|||
onDetailChange (key) { |
|||
if (!key) { |
|||
this.form.batchId = '' |
|||
this.form.partId = '' |
|||
return |
|||
} |
|||
const detail = (this.jobDetailOptions || []).find(item => item._key === key) |
|||
if (!detail) { |
|||
return |
|||
} |
|||
this.form.batchId = detail.inboundId || '' |
|||
this.form.partId = detail.partId || '' |
|||
if (!this.form.qty || Number(this.form.qty) <= 0) { |
|||
this.form.qty = Number(detail.remainingQty) > 0 ? Number(detail.remainingQty) : (Number(detail.plannedQty) || 0) |
|||
} |
|||
if (!this.form.docNo) { |
|||
this.fillDocNoByContext() |
|||
} |
|||
}, |
|||
fillDocNoByContext () { |
|||
const code = this.form.selectedJobCode || 'NOJOB' |
|||
const step = this.form.stepCode || 'NOSTEP' |
|||
const rack = this.form.rackCode || 'NORACK' |
|||
this.form.docNo = `${code}-${step}-${rack}-${Date.now()}` |
|||
}, |
|||
safeParseJson (jsonText) { |
|||
if (!jsonText) { |
|||
return {} |
|||
} |
|||
try { |
|||
return JSON.parse(jsonText) |
|||
} catch (e) { |
|||
return {} |
|||
} |
|||
}, |
|||
payloadValue (row, field) { |
|||
const payload = row && row._payload ? row._payload : {} |
|||
return payload[field] |
|||
}, |
|||
validatePayloadJson (value, callback) { |
|||
if (!value) { |
|||
callback() |
|||
return |
|||
} |
|||
try { |
|||
JSON.parse(value) |
|||
callback() |
|||
} catch (e) { |
|||
callback(new Error('扩展JSON格式不合法')) |
|||
} |
|||
}, |
|||
buildPayloadFromForm () { |
|||
let payload = {} |
|||
if (this.form.payloadJson) { |
|||
payload = JSON.parse(this.form.payloadJson) |
|||
} |
|||
const selectedDetail = (this.jobDetailOptions || []).find(item => item._key === this.form.selectedDetailKey) |
|||
return { |
|||
...payload, |
|||
fillScene: 'PLC_MISSING', |
|||
manualFill: true, |
|||
recordType: this.form.recordType, |
|||
jobCode: this.form.selectedJobCode || '', |
|||
rackCode: this.form.rackCode || '', |
|||
stepCode: this.form.stepCode || '', |
|||
stationId: this.form.stationId || '', |
|||
missingReason: this.form.missingReason || '', |
|||
reasonRemark: this.form.reasonRemark || '', |
|||
inboundNo: selectedDetail ? (selectedDetail.inboundNo || '') : '', |
|||
partNo: selectedDetail ? (selectedDetail.partNo || '') : '' |
|||
} |
|||
}, |
|||
async submit () { |
|||
if (this.submitLoading) { |
|||
return |
|||
} |
|||
this.$refs.manualForm.validate(async valid => { |
|||
if (!valid) { |
|||
return |
|||
} |
|||
let payloadObj = null |
|||
try { |
|||
payloadObj = this.buildPayloadFromForm() |
|||
} catch (e) { |
|||
this.$message.warning((e && e.message) || '扩展JSON格式错误') |
|||
return |
|||
} |
|||
this.submitLoading = true |
|||
try { |
|||
await saveManualRecord({ |
|||
recordType: this.form.recordType, |
|||
docNo: this.form.docNo, |
|||
batchId: this.form.batchId || null, |
|||
jobId: this.form.jobId || null, |
|||
partId: this.form.partId || null, |
|||
qty: Number(this.form.qty) || 0, |
|||
operatorName: this.form.operatorName, |
|||
reviewerName: this.form.reviewerName, |
|||
recordTime: this.form.recordTime || new Date(), |
|||
payloadJson: JSON.stringify(payloadObj) |
|||
}) |
|||
this.$message.success('补录保存成功') |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
} catch (e) { |
|||
this.$message.error((e && e.message) || '补录保存失败') |
|||
} finally { |
|||
this.submitLoading = false |
|||
} |
|||
}) |
|||
}, |
|||
recordTypeTag (recordType) { |
|||
const map = { |
|||
过站补录: 'warning', |
|||
上挂补录: 'success', |
|||
下挂补录: '', |
|||
完工补录: 'danger', |
|||
其他补录: 'info' |
|||
} |
|||
return map[recordType] || 'info' |
|||
}, |
|||
formatDateTime (v) { |
|||
if (!v) return '-' |
|||
let date = null |
|||
if (typeof v === 'number') { |
|||
date = new Date(v) |
|||
} else if (typeof v === 'string') { |
|||
const trimmed = v.trim() |
|||
if (/^\d+$/.test(trimmed)) { |
|||
date = new Date(Number(trimmed)) |
|||
} else { |
|||
date = new Date(trimmed) |
|||
} |
|||
} else if (v instanceof Date) { |
|||
date = v |
|||
} else { |
|||
date = new Date(v) |
|||
} |
|||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) { |
|||
return String(v).replace('T', ' ') |
|||
} |
|||
const y = date.getFullYear() |
|||
const m = String(date.getMonth() + 1).padStart(2, '0') |
|||
const d = String(date.getDate()).padStart(2, '0') |
|||
const hh = String(date.getHours()).padStart(2, '0') |
|||
const mm = String(date.getMinutes()).padStart(2, '0') |
|||
const ss = String(date.getSeconds()).padStart(2, '0') |
|||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}` |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.overview-card { |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.card-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: #303133; |
|||
} |
|||
|
|||
.page-desc { |
|||
margin-top: 4px; |
|||
color: #909399; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.overview-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(6, minmax(120px, 1fr)); |
|||
gap: 10px; |
|||
} |
|||
|
|||
.overview-item { |
|||
border: 1px solid #ebeef5; |
|||
border-radius: 6px; |
|||
padding: 10px; |
|||
background: #fff; |
|||
} |
|||
|
|||
.overview-label { |
|||
color: #909399; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.overview-value { |
|||
margin-top: 6px; |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
color: #303133; |
|||
} |
|||
|
|||
.manual-dialog >>> .el-dialog__body { |
|||
padding: 12px 20px 10px; |
|||
max-height: 72vh; |
|||
overflow-y: auto; |
|||
} |
|||
|
|||
.manual-form >>> .el-form-item { |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 15px 15px 5px 15px; |
|||
border-radius: 4px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,280 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="物料分类"> |
|||
<el-input v-model="searchData.partNo" clearable placeholder="请输入物料分类" style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="物料名称"> |
|||
<el-input v-model="searchData.partName" clearable placeholder="请输入物料名称" style="width: 200px" /> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="searchData.status" clearable placeholder="全部" style="width: 120px"> |
|||
<el-option label="启用" value="启用" /> |
|||
<el-option label="停用" value="停用" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
<el-button plain class="add-btn" @click="openDialog()">新增Part</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table class="data-table" :data="dataList" border v-loading="loading" style="width: 100%"> |
|||
<el-table-column type="index" width="50" align="center" label="#" /> |
|||
<!-- <el-table-column prop="partId" label="part_id" width="180" />--> |
|||
<!-- <el-table-column prop="partNo" label="part_no(分类)" width="180" />--> |
|||
<el-table-column prop="partName" label="物料名称" min-width="160" /> |
|||
<el-table-column prop="category" label="分类" width="120" /> |
|||
<el-table-column prop="routeName" label="程序" min-width="150" /> |
|||
<el-table-column prop="rackType" label="挂具类型" min-width="140" /> |
|||
<el-table-column prop="spec" label="规格" width="140" /> |
|||
<el-table-column prop="unit" label="单位" width="100" /> |
|||
<el-table-column prop="status" label="状态" width="100" /> |
|||
<el-table-column label="操作" width="140" align="center"> |
|||
<template slot-scope="scope"> |
|||
<a @click="openDialog(scope.row)">修改</a> |
|||
<a style="color:#F56C6C;margin-left:10px" @click.stop.prevent="handleDelete(scope.row)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-dialog :title="form.partId ? '修改物料' : '新增物料'" :visible.sync="dialogVisible" width="520px" :close-on-click-modal="false" v-drag> |
|||
<el-form :model="form" label-position="top"> |
|||
<el-form-item label="分类" required> |
|||
<el-input v-model="form.category" placeholder="" /> |
|||
</el-form-item> |
|||
<el-form-item label="物料名称" required> |
|||
<el-input v-model="form.partName" /> |
|||
</el-form-item> |
|||
<el-form-item label="程序" required> |
|||
<el-select v-model="form.routeCode" filterable clearable placeholder="请选择程序" style="width: 100%"> |
|||
<el-option |
|||
v-for="item in routeOptions" |
|||
:key="item.routeCode" |
|||
:label="`${item.routeCode} / ${item.routeName}`" |
|||
:value="item.routeCode" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="挂具类型" required> |
|||
<el-select v-model="form.rackType" filterable clearable placeholder="请选择挂具类型" style="width: 100%"> |
|||
<el-option |
|||
v-for="item in rackTypeOptions" |
|||
:key="item" |
|||
:label="item" |
|||
:value="item" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="规格"> |
|||
<el-input v-model="form.spec" /> |
|||
</el-form-item> |
|||
<el-form-item label="单位"> |
|||
<el-input v-model="form.unit" /> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="form.status" style="width: 100%"> |
|||
<el-option label="启用" value="启用" /> |
|||
<el-option label="停用" value="停用" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="备注"> |
|||
<el-input v-model="form.remark" type="textarea" :rows="3" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<el-footer style="height: 40px; text-align: center;margin-top: 50px"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible = false">取消</el-button> |
|||
<el-button plain class="add-btn" @click="submitForm">保存</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listPart, savePart, updatePartByNo, deletePartByNo, listRoute, listRackType } from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
loading: false, |
|||
dialogVisible: false, |
|||
dataList: [], |
|||
routeOptions: [], |
|||
rackTypeOptions: [], |
|||
searchData: { partNo: '', partName: '', status: '' }, |
|||
form: { partId: '', originPartNo: '', partNo: '', partName: '', category: '', routeCode: '', rackType: '', spec: '', unit: '', status: '启用', remark: '' } |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadBaseOptions() |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
async loadBaseOptions () { |
|||
const [{ data: routeData }, { data: rackTypeData }] = await Promise.all([listRoute({}), listRackType({ status: '启用' })]) |
|||
this.routeOptions = routeData.rows || [] |
|||
this.rackTypeOptions = (rackTypeData.rows || []) |
|||
.map(item => (item && (item.typeName || item.typeCode)) || '') |
|||
.filter(Boolean) |
|||
}, |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const { data } = await listPart(this.searchData) |
|||
this.dataList = data.rows || [] |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.searchData = { partNo: '', partName: '', status: '' } |
|||
this.loadList() |
|||
}, |
|||
async openDialog (row) { |
|||
await this.loadBaseOptions() |
|||
this.form = row |
|||
? { |
|||
...row, |
|||
originPartNo: row.partNo || '', |
|||
routeCode: row.routeCode || '', |
|||
rackType: row.rackType || '' |
|||
} |
|||
: { partId: '', originPartNo: '', partNo: '', partName: '', category: '', routeCode: '', rackType: '', spec: '', unit: '', status: '启用', remark: '' } |
|||
this.dialogVisible = true |
|||
}, |
|||
async submitForm () { |
|||
if (!this.form.category || !this.form.partName || !this.form.routeCode || !this.form.rackType) { |
|||
this.$message.warning('请填写分类、物料名称并选择程序和挂具类型') |
|||
return |
|||
} |
|||
this.form.partNo = this.form.category |
|||
if (this.form.partId) { |
|||
const originPartNo = this.form.originPartNo || this.form.partNo |
|||
await updatePartByNo(originPartNo, this.form) |
|||
this.$message.success('修改成功') |
|||
} else { |
|||
await savePart(this.form) |
|||
this.$message.success('新增成功') |
|||
} |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
}, |
|||
async handleDelete (row) { |
|||
const partNo = row && (row.partNo || row.category) |
|||
if (!partNo) { |
|||
this.$message.warning('当前数据缺少物料分类,无法删除') |
|||
return |
|||
} |
|||
try { |
|||
await this.$confirm(`确认物理删除 物料:${partNo} 吗?`, '提示', { type: 'warning' }) |
|||
await deletePartByNo(partNo) |
|||
this.$message.success('删除成功') |
|||
await this.loadList() |
|||
} catch (e) { |
|||
if (e === 'cancel' || e === 'close') { |
|||
return |
|||
} |
|||
const msg = (e && e.message) || '删除失败,请重试' |
|||
this.$message.error(msg) |
|||
} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr:hover > td { |
|||
background-color: #f5f7fa !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr.current-row > td { |
|||
background-color: #ecf5ff !important; |
|||
} |
|||
|
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 15px 15px 5px 15px; |
|||
border-radius: 4px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,718 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="程序编码"> |
|||
<el-input v-model="searchData.routeCode" clearable placeholder="请输入程序编码" style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="程序名称"> |
|||
<el-input v-model="searchData.routeName" clearable placeholder="请输入程序名称" style="width: 200px" /> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
<el-button plain class="add-btn" @click="openDialog()">新增程序</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table |
|||
ref="programTable" |
|||
class="data-table" |
|||
:data="dataList" |
|||
border |
|||
highlight-current-row |
|||
:height="tableHeight" |
|||
style="width: 100%" |
|||
v-loading="loading" |
|||
@current-change="onCurrentProgramChange"> |
|||
<el-table-column type="index" width="50" align="center" label="#" /> |
|||
<el-table-column prop="routeCode" label="程序编码" min-width="180" /> |
|||
<el-table-column prop="routeName" label="程序名称" min-width="200" /> |
|||
<el-table-column prop="status" label="状态" width="100" /> |
|||
<el-table-column prop="remark" label="备注" min-width="180" show-overflow-tooltip /> |
|||
<el-table-column label="操作" width="140" align="center"> |
|||
<template slot-scope="scope"> |
|||
<a @click="openDialog(scope.row)">修改</a> |
|||
<a style="color:#F56C6C;margin-left:10px" @click="handleDelete(scope.row.routeCode)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-tabs v-model="detailTab" type="border-card" style="margin-top: 12px;"> |
|||
<el-tab-pane label="程序池子配置(可不设置)" name="poolConfig"> |
|||
<div class="toolbar"> |
|||
<span class="pool-count-label">池子总数</span> |
|||
<el-input |
|||
v-model="poolCount" |
|||
:min="1" |
|||
:max="500" |
|||
:disabled="!currentProgram" |
|||
controls-position="right" |
|||
style="width: 130px" /> |
|||
<el-button plain class="search-btn" :disabled="!currentProgram" @click="selectAllPools">全选当前池子</el-button> |
|||
<el-button plain class="reset-btn" :disabled="!currentProgram" @click="clearPools">清空</el-button> |
|||
<el-button plain class="add-btn" :disabled="!currentProgram" @click="invertPools">反选</el-button> |
|||
<el-button plain class="add-btn" :disabled="!currentProgram" @click="savePools">保存池子配置</el-button> |
|||
<span class="pool-summary">已选 {{ selectedPools.length }} / {{ poolCount }}</span> |
|||
</div> |
|||
<el-checkbox-group v-model="selectedPools" class="pool-grid"> |
|||
<el-checkbox |
|||
v-for="pool in poolOptions" |
|||
:key="pool" |
|||
:label="pool" |
|||
:disabled="!currentProgram"> |
|||
{{ `池${String(pool).padStart(2, '0')}` }} |
|||
</el-checkbox> |
|||
</el-checkbox-group> |
|||
|
|||
<el-card shadow="never" class="pool-param-card"> |
|||
<div slot="header" class="card-title-row"> |
|||
<span>池子参数设置</span> |
|||
<div> |
|||
<el-button plain class="search-btn" :disabled="!currentProgram || !selectedPools.length" @click="applyDefaultParamToAllPools">默认值覆盖已选池子</el-button> |
|||
<el-button plain class="reset-btn" :disabled="!currentProgram || !selectedPools.length" @click="fillEmptyParamByDefault">默认值填充空白</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<el-form :inline="true" label-position="top" class="param-default-form"> |
|||
<el-form-item label="默认电流(下/目/上)"> |
|||
<el-input v-model="defaultPoolParam.currentMin" :step="0.1" :precision="2" controls-position="right" style="width: 112px" /> |
|||
<el-input v-model="defaultPoolParam.currentTarget" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
<el-input v-model="defaultPoolParam.currentMax" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
</el-form-item> |
|||
<el-form-item label="默认电压(下/目/上)"> |
|||
<el-input v-model="defaultPoolParam.voltageMin" :step="0.1" :precision="2" controls-position="right" style="width: 112px" /> |
|||
<el-input v-model="defaultPoolParam.voltageTarget" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
<el-input v-model="defaultPoolParam.voltageMax" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
</el-form-item> |
|||
<el-form-item label="默认温度(下/目/上)"> |
|||
<el-input v-model="defaultPoolParam.tempMin" :step="0.1" :precision="2" controls-position="right" style="width: 112px" /> |
|||
<el-input v-model="defaultPoolParam.tempTarget" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
<el-input v-model="defaultPoolParam.tempMax" :step="0.1" :precision="2" controls-position="right" style="width: 112px; margin-left: 6px;" /> |
|||
</el-form-item> |
|||
<!-- <el-form-item label="启用判定"> |
|||
<el-switch v-model="defaultPoolParam.enabled" :active-value="1" :inactive-value="0" /> |
|||
</el-form-item> |
|||
<el-form-item label="缺失数据拦截"> |
|||
<el-switch v-model="defaultPoolParam.missingAsAlarm" :active-value="1" :inactive-value="0" /> |
|||
</el-form-item>--> |
|||
</el-form> |
|||
|
|||
<el-table class="data-table param-table" :data="poolParamRows" border height="280" style="width: 100%"> |
|||
<el-table-column label="池子" width="80" align="center"> |
|||
<template slot-scope="scope">{{ `池${String(scope.row.poolNo).padStart(2, '0')}` }}</template> |
|||
</el-table-column> |
|||
<!-- <el-table-column label="启用判定" width="110" align="center"> |
|||
<template slot-scope="scope"> |
|||
<el-switch v-model="scope.row.enabled" :active-value="1" :inactive-value="0" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="缺失拦截" width="110" align="center"> |
|||
<template slot-scope="scope"> |
|||
<el-switch v-model="scope.row.missingAsAlarm" :active-value="1" :inactive-value="0" /> |
|||
</template> |
|||
</el-table-column>--> |
|||
<el-table-column label="电流下限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.currentMin" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="电流目标" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.currentTarget" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="电流上限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.currentMax" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="电压下限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.voltageMin" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="电压目标" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.voltageTarget" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="电压上限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.voltageMax" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="温度下限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.tempMin" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="温度目标" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.tempTarget" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="温度上限" width="120"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model="scope.row.tempMax" :step="0.1" :precision="2" controls-position="right" style="width: 100%" /> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column label="备注" min-width="150"> |
|||
<template slot-scope="scope"> |
|||
<el-input v-model.trim="scope.row.remark" /> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</el-card> |
|||
</el-tab-pane> |
|||
</el-tabs> |
|||
|
|||
<el-dialog :title="form.routeCode ? '修改程序' : '新增程序'" :visible.sync="dialogVisible" width="520px" :close-on-click-modal="false" v-drag> |
|||
<el-form :model="form" label-position="top"> |
|||
<el-form-item label="程序编码" required> |
|||
<el-input v-model="form.routeCode" :disabled="!!form.originRouteCode" placeholder="例如 PRG-ZN-001" /> |
|||
</el-form-item> |
|||
<el-form-item label="程序名称" required> |
|||
<el-input v-model="form.routeName" placeholder="例如 镀锌主程序" /> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="form.status" style="width: 100%"> |
|||
<el-option label="启用" value="启用" /> |
|||
<el-option label="停用" value="停用" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="备注"> |
|||
<el-input v-model="form.remark" type="textarea" :rows="3" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<el-footer style="height: 40px; text-align: center;margin-top: 50px"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible = false">取消</el-button> |
|||
<el-button plain class="add-btn" @click="submitForm">保存</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { |
|||
listRoute, |
|||
saveRoute, |
|||
updateRouteByCode, |
|||
deleteRouteByCode, |
|||
listRouteStepByCode, |
|||
listRoutePoolParamByCode, |
|||
replaceRoutePoolsByCode |
|||
} from '@/api/rack/closedLoop' |
|||
|
|||
const POOL_PARAM_VALUE_FIELDS = [ |
|||
'currentMin', 'currentTarget', 'currentMax', |
|||
'voltageMin', 'voltageTarget', 'voltageMax', |
|||
'tempMin', 'tempTarget', 'tempMax' |
|||
] |
|||
|
|||
const buildDefaultPoolParam = () => ({ |
|||
currentMin: 90, |
|||
currentTarget: 100, |
|||
currentMax: 120, |
|||
voltageMin: 4, |
|||
voltageTarget: 5, |
|||
voltageMax: 6, |
|||
tempMin: 35, |
|||
tempTarget: 40, |
|||
tempMax: 45, |
|||
enabled: 1, |
|||
missingAsAlarm: 1, |
|||
remark: '默认参数' |
|||
}) |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
tableHeight: (window.innerHeight - 400) / 2, |
|||
loading: false, |
|||
dialogVisible: false, |
|||
dataList: [], |
|||
currentProgram: null, |
|||
detailTab: 'poolConfig', |
|||
selectedPools: [], |
|||
poolCount: 80, |
|||
searchData: { routeCode: '', routeName: '' }, |
|||
form: { originRouteCode: '', routeCode: '', routeName: '', status: '启用', remark: '' }, |
|||
poolOptions: [], |
|||
defaultPoolParam: buildDefaultPoolParam(), |
|||
poolParamRows: [] |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.rebuildPoolOptions() |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
rebuildPoolOptions () { |
|||
const count = Math.max(Number(this.poolCount) || 1, 1) |
|||
this.poolOptions = Array.from({ length: count }, (_, i) => i + 1) |
|||
const nextSelected = (this.selectedPools || []) |
|||
.map(pool => Number(pool)) |
|||
.filter(pool => pool > 0 && pool <= count) |
|||
this.selectedPools = Array.from(new Set(nextSelected)) |
|||
}, |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const { data } = await listRoute(this.searchData) |
|||
this.dataList = data.rows || [] |
|||
if (this.dataList.length) { |
|||
this.$nextTick(() => { |
|||
this.$refs.programTable && this.$refs.programTable.setCurrentRow(this.dataList[0]) |
|||
}) |
|||
} else { |
|||
this.currentProgram = null |
|||
this.selectedPools = [] |
|||
this.poolParamRows = [] |
|||
} |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.searchData = { routeCode: '', routeName: '' } |
|||
this.loadList() |
|||
}, |
|||
openDialog (row) { |
|||
this.form = row |
|||
? { |
|||
originRouteCode: row.routeCode, |
|||
routeCode: row.routeCode, |
|||
routeName: row.routeName, |
|||
status: row.status || '启用', |
|||
remark: row.remark || '' |
|||
} |
|||
: { originRouteCode: '', routeCode: '', routeName: '', status: '启用', remark: '' } |
|||
this.dialogVisible = true |
|||
}, |
|||
async submitForm () { |
|||
if (!this.form.routeCode || !this.form.routeName) { |
|||
this.$message.warning('请填写程序编码和程序名称') |
|||
return |
|||
} |
|||
if (this.form.originRouteCode) { |
|||
await updateRouteByCode({ |
|||
routeCode: this.form.originRouteCode, |
|||
routeName: this.form.routeName, |
|||
status: this.form.status, |
|||
remark: this.form.remark |
|||
}) |
|||
this.$message.success('修改成功') |
|||
} else { |
|||
await saveRoute({ |
|||
routeCode: this.form.routeCode, |
|||
routeName: this.form.routeName, |
|||
status: this.form.status, |
|||
remark: this.form.remark |
|||
}) |
|||
this.$message.success('新增成功') |
|||
} |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
}, |
|||
async handleDelete (routeCode) { |
|||
await deleteRouteByCode(routeCode) |
|||
this.$message.success('删除成功') |
|||
this.loadList() |
|||
}, |
|||
async onCurrentProgramChange (row) { |
|||
this.currentProgram = row || null |
|||
this.selectedPools = [] |
|||
this.poolParamRows = [] |
|||
if (!row || !row.routeCode) { |
|||
return |
|||
} |
|||
const { data } = await listRouteStepByCode(row.routeCode) |
|||
const steps = data.rows || [] |
|||
this.selectedPools = steps |
|||
.map(step => String(step.stepCode || '').match(/POOL-(\d{1,3})/)) |
|||
.filter(Boolean) |
|||
.map(m => Number(m[1])) |
|||
.filter(n => n > 0) |
|||
const maxPool = this.selectedPools.length ? Math.max(...this.selectedPools) : 80 |
|||
this.poolCount = Math.max(maxPool, 1) |
|||
this.rebuildPoolOptions() |
|||
await this.loadPoolParamRows(row.routeCode) |
|||
}, |
|||
async loadPoolParamRows (routeCode) { |
|||
if (!routeCode) { |
|||
this.poolParamRows = [] |
|||
return |
|||
} |
|||
const { data } = await listRoutePoolParamByCode(routeCode) |
|||
const rows = data.rows || [] |
|||
const serverMap = rows.reduce((acc, item) => { |
|||
const poolNo = Number(item.poolNo) || this.extractPoolNoFromStepCode(item.stepCode) |
|||
if (poolNo > 0) { |
|||
acc[poolNo] = this.normalizePoolParam(item) |
|||
} |
|||
return acc |
|||
}, {}) |
|||
this.syncPoolParamRowsBySelectedPools(serverMap) |
|||
}, |
|||
extractPoolNoFromStepCode (stepCode) { |
|||
const matched = String(stepCode || '').match(/POOL-(\d{1,3})/) |
|||
if (!matched) { |
|||
return 0 |
|||
} |
|||
return Number(matched[1]) || 0 |
|||
}, |
|||
normalizePoolParam (source = {}) { |
|||
const defaults = buildDefaultPoolParam() |
|||
const pickNumber = (val, fallback) => { |
|||
const n = Number(val) |
|||
return Number.isFinite(n) ? n : fallback |
|||
} |
|||
return { |
|||
currentMin: pickNumber(source.currentMin, defaults.currentMin), |
|||
currentTarget: pickNumber(source.currentTarget, defaults.currentTarget), |
|||
currentMax: pickNumber(source.currentMax, defaults.currentMax), |
|||
voltageMin: pickNumber(source.voltageMin, defaults.voltageMin), |
|||
voltageTarget: pickNumber(source.voltageTarget, defaults.voltageTarget), |
|||
voltageMax: pickNumber(source.voltageMax, defaults.voltageMax), |
|||
tempMin: pickNumber(source.tempMin, defaults.tempMin), |
|||
tempTarget: pickNumber(source.tempTarget, defaults.tempTarget), |
|||
tempMax: pickNumber(source.tempMax, defaults.tempMax), |
|||
enabled: Number(source.enabled) === 0 ? 0 : 1, |
|||
missingAsAlarm: Number(source.missingAsAlarm) === 0 ? 0 : 1, |
|||
remark: source.remark || defaults.remark |
|||
} |
|||
}, |
|||
createPoolParamRow (poolNo, source = {}) { |
|||
return { |
|||
poolNo, |
|||
...this.normalizePoolParam(source) |
|||
} |
|||
}, |
|||
syncPoolParamRowsBySelectedPools (sourceMap = null) { |
|||
const selectedPoolList = Array.from(new Set((this.selectedPools || []) |
|||
.map(pool => Number(pool)) |
|||
.filter(pool => pool > 0))) |
|||
.sort((a, b) => a - b) |
|||
const currentMap = sourceMap || this.poolParamRows.reduce((acc, row) => { |
|||
if (row && row.poolNo) { |
|||
acc[row.poolNo] = row |
|||
} |
|||
return acc |
|||
}, {}) |
|||
this.poolParamRows = selectedPoolList.map(poolNo => this.createPoolParamRow(poolNo, currentMap[poolNo] || {})) |
|||
}, |
|||
applyDefaultParamToAllPools () { |
|||
const defaults = this.normalizePoolParam(this.defaultPoolParam) |
|||
let changedRows = 0 |
|||
this.poolParamRows = this.poolParamRows.map(row => { |
|||
const next = { |
|||
...row, |
|||
...defaults |
|||
} |
|||
const valueChanged = POOL_PARAM_VALUE_FIELDS.some(field => Number(row[field]) !== Number(next[field])) |
|||
const switchChanged = Number(row.enabled) !== Number(next.enabled) || |
|||
Number(row.missingAsAlarm) !== Number(next.missingAsAlarm) |
|||
const remarkChanged = String(row.remark || '') !== String(next.remark || '') |
|||
if (valueChanged || switchChanged || remarkChanged) { |
|||
changedRows += 1 |
|||
} |
|||
return next |
|||
}) |
|||
this.$message({ |
|||
type: changedRows > 0 ? 'success' : 'warning', |
|||
message: changedRows > 0 |
|||
? `已用默认值覆盖 ${changedRows} 个池子的参数` |
|||
: '已选池子参数与默认值一致,无需覆盖' |
|||
}) |
|||
}, |
|||
fillEmptyParamByDefault () { |
|||
const defaults = this.normalizePoolParam(this.defaultPoolParam) |
|||
let changedRows = 0 |
|||
let changedFields = 0 |
|||
this.poolParamRows = this.poolParamRows.map(row => { |
|||
const next = { ...row } |
|||
let rowChanged = false |
|||
POOL_PARAM_VALUE_FIELDS.forEach(field => { |
|||
if (next[field] === null || next[field] === undefined || next[field] === '') { |
|||
next[field] = defaults[field] |
|||
rowChanged = true |
|||
changedFields += 1 |
|||
} |
|||
}) |
|||
if (next.enabled !== 0 && next.enabled !== 1) { |
|||
next.enabled = defaults.enabled |
|||
rowChanged = true |
|||
changedFields += 1 |
|||
} |
|||
if (next.missingAsAlarm !== 0 && next.missingAsAlarm !== 1) { |
|||
next.missingAsAlarm = defaults.missingAsAlarm |
|||
rowChanged = true |
|||
changedFields += 1 |
|||
} |
|||
if (!next.remark) { |
|||
next.remark = defaults.remark |
|||
rowChanged = true |
|||
changedFields += 1 |
|||
} |
|||
if (rowChanged) { |
|||
changedRows += 1 |
|||
} |
|||
return next |
|||
}) |
|||
this.$message({ |
|||
type: changedRows > 0 ? 'success' : 'warning', |
|||
message: changedRows > 0 |
|||
? `已为 ${changedRows} 个池子填充 ${changedFields} 项空白参数` |
|||
: '当前没有空白参数可填充' |
|||
}) |
|||
}, |
|||
selectAllPools () { |
|||
this.selectedPools = this.poolOptions.slice() |
|||
}, |
|||
clearPools () { |
|||
this.selectedPools = [] |
|||
}, |
|||
invertPools () { |
|||
const selectedSet = new Set(this.selectedPools) |
|||
this.selectedPools = this.poolOptions.filter(pool => !selectedSet.has(pool)) |
|||
}, |
|||
validateRange (poolNo, typeName, min, target, max) { |
|||
const poolLabel = `池${String(poolNo).padStart(2, '0')}` |
|||
if ([min, target, max].some(val => val === null || val === undefined || val === '')) { |
|||
return `${poolLabel} ${typeName}参数不能为空` |
|||
} |
|||
if (Number(min) > Number(max)) { |
|||
return `${poolLabel} ${typeName}下限不能大于上限` |
|||
} |
|||
if (Number(target) < Number(min) || Number(target) > Number(max)) { |
|||
return `${poolLabel} ${typeName}目标值必须在上下限区间内` |
|||
} |
|||
return '' |
|||
}, |
|||
validatePoolParamRows () { |
|||
for (let i = 0; i < this.poolParamRows.length; i++) { |
|||
const row = this.poolParamRows[i] |
|||
const currentMsg = this.validateRange(row.poolNo, '电流', row.currentMin, row.currentTarget, row.currentMax) |
|||
if (currentMsg) { |
|||
return currentMsg |
|||
} |
|||
const voltageMsg = this.validateRange(row.poolNo, '电压', row.voltageMin, row.voltageTarget, row.voltageMax) |
|||
if (voltageMsg) { |
|||
return voltageMsg |
|||
} |
|||
const tempMsg = this.validateRange(row.poolNo, '温度', row.tempMin, row.tempTarget, row.tempMax) |
|||
if (tempMsg) { |
|||
return tempMsg |
|||
} |
|||
} |
|||
return '' |
|||
}, |
|||
buildPoolParamPayload (row, withPoolNo = false) { |
|||
const payload = { |
|||
currentMin: Number(row.currentMin), |
|||
currentTarget: Number(row.currentTarget), |
|||
currentMax: Number(row.currentMax), |
|||
voltageMin: Number(row.voltageMin), |
|||
voltageTarget: Number(row.voltageTarget), |
|||
voltageMax: Number(row.voltageMax), |
|||
tempMin: Number(row.tempMin), |
|||
tempTarget: Number(row.tempTarget), |
|||
tempMax: Number(row.tempMax), |
|||
enabled: Number(row.enabled) === 0 ? 0 : 1, |
|||
missingAsAlarm: Number(row.missingAsAlarm) === 0 ? 0 : 1, |
|||
remark: row.remark || '' |
|||
} |
|||
if (withPoolNo) { |
|||
payload.poolNo = Number(row.poolNo) |
|||
} |
|||
return payload |
|||
}, |
|||
async savePools () { |
|||
if (!this.currentProgram || !this.currentProgram.routeCode) { |
|||
this.$message.warning('请先选择程序') |
|||
return |
|||
} |
|||
const poolNos = Array.from(new Set((this.selectedPools || []) |
|||
.map(pool => Number(pool)) |
|||
.filter(pool => pool > 0))) |
|||
.sort((a, b) => a - b) |
|||
const validateMessage = this.validatePoolParamRows() |
|||
if (validateMessage) { |
|||
this.$message.warning(validateMessage) |
|||
return |
|||
} |
|||
await replaceRoutePoolsByCode({ |
|||
routeCode: this.currentProgram.routeCode, |
|||
poolNos, |
|||
defaultParam: this.buildPoolParamPayload(this.defaultPoolParam), |
|||
poolParams: this.poolParamRows.map(row => this.buildPoolParamPayload(row, true)) |
|||
}) |
|||
await this.loadPoolParamRows(this.currentProgram.routeCode) |
|||
this.$message.success('池子配置及参数保存成功') |
|||
} |
|||
}, |
|||
watch: { |
|||
poolCount () { |
|||
this.rebuildPoolOptions() |
|||
}, |
|||
selectedPools () { |
|||
this.syncPoolParamRowsBySelectedPools() |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr:hover > td { |
|||
background-color: #f5f7fa !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr.current-row > td { |
|||
background-color: #ecf5ff !important; |
|||
} |
|||
|
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 15px 15px 5px 15px; |
|||
border-radius: 4px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
|
|||
.card-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.toolbar { |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 8px; |
|||
margin-bottom: 12px; |
|||
flex-wrap: wrap; |
|||
} |
|||
|
|||
.pool-count-label { |
|||
color: #606266; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.pool-summary { |
|||
margin-left: 8px; |
|||
color: #606266; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.pool-param-card { |
|||
margin-top: 12px; |
|||
border: 1px solid #ebeef5; |
|||
} |
|||
|
|||
.param-default-form { |
|||
margin-bottom: 10px; |
|||
} |
|||
|
|||
.param-default-form >>> .el-form-item { |
|||
margin-right: 12px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.param-table >>> .el-table__body-wrapper .cell { |
|||
overflow: visible !important; |
|||
} |
|||
|
|||
.param-table >>> .el-input { |
|||
width: 100%; |
|||
} |
|||
|
|||
.pool-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(10, minmax(84px, 1fr)); |
|||
grid-row-gap: 8px; |
|||
} |
|||
|
|||
.pool-grid >>> .el-checkbox { |
|||
margin-left: 0 !important; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,353 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="挂具编码"> |
|||
<el-input v-model="searchData.rackCode" clearable placeholder="请输入挂具编码" style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="挂具名称"> |
|||
<el-input v-model="searchData.rackName" clearable placeholder="请输入挂具名称" style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="searchData.status" clearable placeholder="全部" style="width: 120px"> |
|||
<el-option label="空闲" value="空闲" /> |
|||
<el-option label="使用中" value="使用中" /> |
|||
<el-option label="维修中" value="维修中" /> |
|||
<el-option label="报废" value="报废" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetQuery">重置</el-button> |
|||
<el-button plain class="search-btn" @click="openTypeDialog">挂具类型</el-button> |
|||
<el-button plain class="add-btn" @click="openDialog()">新增挂具</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table class="data-table" :data="dataList" border v-loading="loading" style="width: 100%"> |
|||
<el-table-column type="index" width="50" align="center" label="#" /> |
|||
<!-- <el-table-column prop="rackId" label="rack_id" width="180" />--> |
|||
<el-table-column prop="rackCode" min-width="160" label="挂具编码" /> |
|||
<el-table-column prop="rackName" min-width="160" label="挂具名称" /> |
|||
<el-table-column prop="rackType" min-width="160" label="挂具类型" /> |
|||
<el-table-column prop="usageCount" label="使用次数" width="100" /> |
|||
<el-table-column prop="status" min-width="160" label="状态" /> |
|||
<el-table-column label="操作" width="140" align="center"> |
|||
<template slot-scope="scope"> |
|||
<a @click="openDialog(scope.row)">修改</a> |
|||
<a style="color:#F56C6C;margin-left:10px" @click="handleDelete(scope.row.rackCode)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-dialog :title="form.rackId ? '修改挂具' : '新增挂具'" :visible.sync="dialogVisible" width="520px" :close-on-click-modal="false" v-drag> |
|||
<el-form :model="form" label-position="top"> |
|||
<el-form-item label="挂具编码" required> |
|||
<el-input v-model="form.rackCode" /> |
|||
</el-form-item> |
|||
<el-form-item label="挂具名称" required> |
|||
<el-input v-model="form.rackName" /> |
|||
</el-form-item> |
|||
<el-form-item label="挂具类型" required> |
|||
<el-select v-model="form.rackType" filterable clearable placeholder="请选择挂具类型" style="width: 100%"> |
|||
<el-option |
|||
v-for="item in rackTypeOptions" |
|||
:key="item.typeName" |
|||
:label="item.typeName" |
|||
:value="item.typeName" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="form.status" style="width: 100%"> |
|||
<el-option label="空闲" value="空闲" /> |
|||
<el-option label="使用中" value="使用中" /> |
|||
<el-option label="维修中" value="维修中" /> |
|||
<el-option label="报废" value="报废" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
<el-form-item label="备注"> |
|||
<el-input v-model="form.remark" type="textarea" :rows="3" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<el-footer style="height: 40px; text-align: center;margin-top: 50px"> |
|||
<el-button plain class="reset-btn" @click="dialogVisible = false">取消</el-button> |
|||
<el-button plain class="add-btn" @click="submitForm">保存</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
|
|||
<el-dialog title="挂具类型管理" :visible.sync="typeDialogVisible" width="760px" :close-on-click-modal="false" v-drag> |
|||
<el-form :inline="true" label-position="top" class="query-form" style="margin-bottom: 8px;"> |
|||
<el-form-item label="类型名称"> |
|||
<el-input v-model="typeQuery.typeName" clearable placeholder="请输入类型名称" style="width: 180px" /> |
|||
</el-form-item> |
|||
<el-form-item label=" "> |
|||
<el-button plain class="search-btn" @click="loadTypeList">查询</el-button> |
|||
<el-button plain class="reset-btn" @click="resetTypeQuery">重置</el-button> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-table class="data-table" :data="typeList" border style="width:100%" height="220"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="typeName" label="类型名称" min-width="140" /> |
|||
<el-table-column prop="status" label="状态" width="90" /> |
|||
<el-table-column label="操作" width="140"> |
|||
<template slot-scope="scope"> |
|||
<a @click="editType(scope.row)">修改</a> |
|||
<a style="color:#F56C6C;margin-left:10px" @click="deleteType(scope.row)">删除</a> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
|
|||
<el-form :model="typeForm" label-position="top" style="margin-top: 10px;"> |
|||
<el-row :gutter="10"> |
|||
<el-col :span="12"> |
|||
<el-form-item label="类型名称" required> |
|||
<el-input v-model="typeForm.typeName" /> |
|||
</el-form-item> |
|||
</el-col> |
|||
<el-col :span="12"> |
|||
<el-form-item label="状态"> |
|||
<el-select v-model="typeForm.status" style="width:100%"> |
|||
<el-option label="启用" value="启用" /> |
|||
<el-option label="停用" value="停用" /> |
|||
</el-select> |
|||
</el-form-item> |
|||
</el-col> |
|||
</el-row> |
|||
<el-form-item label="备注"> |
|||
<el-input v-model="typeForm.remark" type="textarea" :rows="2" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
<el-footer style="height: 40px; text-align: center;margin-top: 30px"> |
|||
<el-button plain class="reset-btn" @click="resetTypeForm">清空</el-button> |
|||
<el-button plain class="add-btn" @click="saveType">保存类型</el-button> |
|||
</el-footer> |
|||
</el-dialog> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { |
|||
listRack, |
|||
saveRack, |
|||
updateRackByCode, |
|||
deleteRackByCode, |
|||
listRackType, |
|||
saveRackType, |
|||
updateRackType, |
|||
deleteRackTypeByName |
|||
} from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
loading: false, |
|||
dialogVisible: false, |
|||
typeDialogVisible: false, |
|||
dataList: [], |
|||
rackTypeOptions: [], |
|||
typeList: [], |
|||
typeQuery: { typeName: '' }, |
|||
typeForm: { typeName: '', status: '启用', remark: '' }, |
|||
searchData: { rackCode: '', rackName: '', lineId: '', status: '' }, |
|||
form: { rackId: '', originRackCode: '', rackCode: '', rackName: '', rackType: '', lineId: '', status: '空闲', remark: '' } |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadRackTypeOptions() |
|||
this.loadList() |
|||
}, |
|||
methods: { |
|||
async loadRackTypeOptions () { |
|||
const { data } = await listRackType({ status: '启用' }) |
|||
this.rackTypeOptions = data.rows || [] |
|||
}, |
|||
async loadList () { |
|||
this.loading = true |
|||
try { |
|||
const { data } = await listRack(this.searchData) |
|||
this.dataList = data.rows || [] |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
resetQuery () { |
|||
this.searchData = { rackCode: '', rackName: '', lineId: '', status: '' } |
|||
this.loadList() |
|||
}, |
|||
openDialog (row) { |
|||
this.form = row |
|||
? { ...row, originRackCode: row.rackCode || '' } |
|||
: { rackId: '', originRackCode: '', rackCode: '', rackName: '', rackType: '', lineId: '', status: '空闲', remark: '' } |
|||
this.loadRackTypeOptions() |
|||
this.dialogVisible = true |
|||
}, |
|||
async submitForm () { |
|||
if (!this.form.rackCode || !this.form.rackName || !this.form.rackType) { |
|||
this.$message.warning('请填写挂具编码、挂具名称并选择挂具类型') |
|||
return |
|||
} |
|||
if (this.form.rackId) { |
|||
await updateRackByCode(this.form.originRackCode || this.form.rackCode, this.form) |
|||
this.$message.success('修改成功') |
|||
} else { |
|||
await saveRack(this.form) |
|||
this.$message.success('新增成功') |
|||
} |
|||
this.dialogVisible = false |
|||
this.loadList() |
|||
}, |
|||
async handleDelete (rackCode) { |
|||
try { |
|||
await this.$confirm(`确认物理删除挂具:${rackCode} 吗?`, '提示', { type: 'warning' }) |
|||
await deleteRackByCode(rackCode) |
|||
this.$message.success('删除成功') |
|||
this.loadList() |
|||
} catch (e) {} |
|||
}, |
|||
async openTypeDialog () { |
|||
this.typeDialogVisible = true |
|||
this.resetTypeForm() |
|||
await this.loadTypeList() |
|||
}, |
|||
async loadTypeList () { |
|||
const { data } = await listRackType(this.typeQuery) |
|||
this.typeList = data.rows || [] |
|||
await this.loadRackTypeOptions() |
|||
}, |
|||
resetTypeQuery () { |
|||
this.typeQuery = { typeName: '' } |
|||
this.loadTypeList() |
|||
}, |
|||
resetTypeForm () { |
|||
this.typeForm = { typeName: '', status: '启用', remark: '' } |
|||
}, |
|||
editType (row) { |
|||
this.typeForm = { ...row } |
|||
}, |
|||
async saveType () { |
|||
if (!this.typeForm.typeName) { |
|||
this.$message.warning('请填写类型名称') |
|||
return |
|||
} |
|||
if (this.typeList.some(item => item.typeName === this.typeForm.typeName)) { |
|||
await updateRackType(this.typeForm) |
|||
this.$message.success('类型修改成功') |
|||
} else { |
|||
await saveRackType(this.typeForm) |
|||
this.$message.success('类型新增成功') |
|||
} |
|||
this.resetTypeForm() |
|||
await this.loadTypeList() |
|||
}, |
|||
async deleteType (row) { |
|||
if (!row || !row.typeName) { |
|||
return |
|||
} |
|||
try { |
|||
await this.$confirm(`确认删除挂具类型:${row.typeName} 吗?`, '提示', { type: 'warning' }) |
|||
await deleteRackTypeByName(row.typeName) |
|||
this.$message.success('删除成功') |
|||
await this.loadTypeList() |
|||
} catch (e) {} |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr:hover > td { |
|||
background-color: #f5f7fa !important; |
|||
} |
|||
|
|||
.data-table >>> .el-table__body tr.current-row > td { |
|||
background-color: #ecf5ff !important; |
|||
} |
|||
|
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 15px 15px 5px 15px; |
|||
border-radius: 4px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,979 @@ |
|||
<template> |
|||
<div class="screen-wrap"> |
|||
<div class="top-bar"> |
|||
<div class="header-left"><div class="logo-box">冠华LOGO</div></div> |
|||
<div class="header-center"> |
|||
<div class="title">挂具电镀过站采集大屏</div> |
|||
<div class="subtitle">物料上挂 -> 滑道流转 -> 过站电镀池</div> |
|||
</div> |
|||
<div class="tools"> |
|||
<span class="time">{{ currentTime }}</span> |
|||
<el-button plain class="refresh-btn" @click="loadBoardData(false)">刷新</el-button> |
|||
</div> |
|||
</div> |
|||
|
|||
<div class="kpi-row"> |
|||
<div class="kpi-card"><div class="kpi-label">任务总数</div><div class="kpi-value">{{ kpi.totalJobs }}</div></div> |
|||
<div class="kpi-card"><div class="kpi-label">生产中任务</div><div class="kpi-value warning">{{ kpi.runningJobs }}</div></div> |
|||
<div class="kpi-card"><div class="kpi-label">已完工任务</div><div class="kpi-value success">{{ kpi.completedJobs }}</div></div> |
|||
<div class="kpi-card"><div class="kpi-label">在制挂具</div><div class="kpi-value">{{ kpi.activeRacks }}</div></div> |
|||
<div class="kpi-card"><div class="kpi-label">累计过站</div><div class="kpi-value highlight">{{ kpi.stationPassCount }}</div></div> |
|||
<div class="kpi-card"><div class="kpi-label">累计拦截</div><div class="kpi-value danger">{{ kpi.stationInterceptCount }}</div></div> |
|||
</div> |
|||
|
|||
<div class="content-grid"> |
|||
<div class="panel top-left-panel"> |
|||
<div class="panel-title-row"> |
|||
<span class="panel-title">任务执行总览</span> |
|||
<span class="panel-meta">最近刷新:{{ lastRefreshTime || '-' }}</span> |
|||
</div> |
|||
<el-table |
|||
ref="jobBoardTable" |
|||
class="board-table" |
|||
:data="jobBoardRows" |
|||
:height="tableHeight" |
|||
v-loading="loading" |
|||
border |
|||
stripe |
|||
highlight-current-row |
|||
@row-click="onJobRowClick"> |
|||
<el-table-column type="index" label="#" width="50" align="center" /> |
|||
<el-table-column prop="jobCode" label="任务单号" width="120" show-overflow-tooltip /> |
|||
<el-table-column label="状态" width="90" align="center"> |
|||
<template slot-scope="scope"> |
|||
<el-tag class="board-tag" :class="getStatusClass(scope.row.status)" size="small">{{ scope.row.status || '-' }}</el-tag> |
|||
</template> |
|||
</el-table-column> |
|||
<el-table-column prop="plannedQty" label="计划量" width="60" align="right" /> |
|||
<el-table-column prop="executedQty" label="已执行" width="60" align="right" /> |
|||
<el-table-column prop="remainingQty" label="未执行" width="60" align="right" /> |
|||
<el-table-column prop="activeRackCount" label="在制挂具" width="80" align="center" /> |
|||
<el-table-column label="过池进度" width="80" align="center"> |
|||
<template slot-scope="scope">{{ scope.row.passedSteps }}/{{ scope.row.totalSteps }}</template> |
|||
</el-table-column> |
|||
<el-table-column label="完成率" width="70" align="center"> |
|||
<template slot-scope="scope">{{ scope.row.stepRate }}%</template> |
|||
</el-table-column> |
|||
<el-table-column prop="nextStepCode" label="下一池" width="100" align="center" /> |
|||
<el-table-column prop="inboundNos" label="关联入库单" min-width="120" show-overflow-tooltip /> |
|||
</el-table> |
|||
</div> |
|||
|
|||
<div class="panel top-right-panel"> |
|||
<div class="panel-title-row"> |
|||
<span class="panel-title">在制挂具队列</span> |
|||
</div> |
|||
<el-table class="board-table rack-table" :data="rackBoardRows" border stripe :height="tableHeight"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="rackCode" label="挂具码" width="110" /> |
|||
<el-table-column prop="jobCode" label="任务单号" min-width="150" show-overflow-tooltip /> |
|||
<el-table-column prop="rackProgress" label="过池进度" width="100" align="center" /> |
|||
<el-table-column prop="nextStepCode" label="下一池" width="110" /> |
|||
<el-table-column prop="status" label="状态" width="90" align="center"> |
|||
<template slot-scope="scope"> |
|||
<el-tag class="board-tag" :class="getStatusClass(scope.row.status)" size="small">{{ scope.row.status || '-' }}</el-tag> |
|||
</template> |
|||
</el-table-column> |
|||
</el-table> |
|||
</div> |
|||
|
|||
<div class="panel bottom-flow-panel"> |
|||
<div class="panel-title-row"> |
|||
<span class="panel-title">挂具位置实时展示</span> |
|||
<span class="panel-meta">上方是“挂具定位层”,下方是连续过池动态演示</span> |
|||
</div> |
|||
<div class="flow-board" v-if="flowPoolNodes.length"> |
|||
<div class="flow-scroll-wrap"> |
|||
<div class="flow-pool-row"> |
|||
<div |
|||
v-for="pool in poolPositionColumns" |
|||
:key="`pool_${pool.stepNo}`" |
|||
class="flow-pool-node" |
|||
:class="{ 'pool-node-active': pool.racks.length }"> |
|||
<span class="pool-name">池{{ pool.poolNo }}</span> |
|||
<div v-if="pool.racks.length" class="pool-rack-list"> |
|||
<span |
|||
v-for="(rack, rIdx) in pool.racks" |
|||
:key="`rack_${pool.stepNo}_${rack.rackCode}_${rIdx}`" |
|||
class="pool-rack-chip"> |
|||
{{ rack.rackCode }} |
|||
</span> |
|||
<span v-if="pool.extraCount > 0" class="pool-rack-chip pool-rack-chip-more">+{{ pool.extraCount }}</span> |
|||
</div> |
|||
<div v-else class="pool-rack-empty">-</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
<div class="flow-track-wrap"> |
|||
<div class="flow-track-line"></div> |
|||
<div |
|||
v-for="(runner, idx) in flowRunners" |
|||
:key="`progress_${runner.rackCode}_${idx}`" |
|||
class="flow-progress-marker" |
|||
:style="getFlowProgressStyle(runner)"> |
|||
</div> |
|||
<div |
|||
v-for="(runner, idx) in simFlowRunners" |
|||
:key="`sim_${runner.rackCode}_${idx}`" |
|||
class="flow-runner" |
|||
:style="getFlowRunnerStyle(runner, idx)"> |
|||
{{ runner.rackCode }} |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { listJob, productionView, reportOverview } from '@/api/rack/closedLoop' |
|||
|
|||
const STATUS_PRIORITY = { |
|||
生产中: 1, |
|||
已下达: 2, |
|||
待下达: 3, |
|||
已完工: 4 |
|||
} |
|||
|
|||
export default { |
|||
name: 'ScreenRackPlatingProgress', |
|||
data () { |
|||
return { |
|||
loading: false, |
|||
currentTime: '', |
|||
lastRefreshTime: '', |
|||
tableHeight: 360, |
|||
clockTimer: null, |
|||
refreshTimer: null, |
|||
report: {}, |
|||
jobRows: [], |
|||
jobBoardRows: [], |
|||
rackBoardRows: [], |
|||
selectedJobCode: '' |
|||
} |
|||
}, |
|||
computed: { |
|||
kpi () { |
|||
const totalJobs = this.jobRows.length |
|||
const runningJobs = this.jobRows.filter(item => item.status === '生产中').length |
|||
const completedJobs = this.jobRows.filter(item => item.status === '已完工').length |
|||
const activeRacks = this.rackBoardRows.length |
|||
return { |
|||
totalJobs, |
|||
runningJobs, |
|||
completedJobs, |
|||
activeRacks, |
|||
stationPassCount: Number(this.report.stationPassCount) || 0, |
|||
stationInterceptCount: Number(this.report.stationInterceptCount) || 0 |
|||
} |
|||
}, |
|||
selectedJobSummary () { |
|||
if (!this.selectedJobCode) { |
|||
return null |
|||
} |
|||
return this.jobBoardRows.find(item => item.jobCode === this.selectedJobCode) || null |
|||
}, |
|||
flowPoolNodes () { |
|||
const summary = this.selectedJobSummary |
|||
const fromSummary = (summary && Array.isArray(summary.stepProgress)) ? summary.stepProgress : [] |
|||
if (fromSummary.length) { |
|||
return fromSummary.map((step, idx) => ({ |
|||
stepNo: step.stepNo || (idx + 1), |
|||
stepCode: step.stepCode || `POOL-${String(idx + 1).padStart(3, '0')}`, |
|||
poolNo: this.extractPoolNoFromStepCode(step.stepCode) || (idx + 1) |
|||
})) |
|||
} |
|||
return [] |
|||
}, |
|||
poolPositionColumns () { |
|||
const nodes = this.flowPoolNodes || [] |
|||
const map = {} |
|||
nodes.forEach(node => { |
|||
map[node.stepCode] = [] |
|||
}) |
|||
;(this.flowRunners || []).forEach(runner => { |
|||
const stepCode = this.resolveRunnerStepCode(runner) |
|||
if (!stepCode || !Object.prototype.hasOwnProperty.call(map, stepCode)) { |
|||
return |
|||
} |
|||
map[stepCode].push(runner) |
|||
}) |
|||
return nodes.map(node => { |
|||
const rackList = map[node.stepCode] || [] |
|||
return { |
|||
...node, |
|||
poolNo: node.poolNo || this.extractPoolNoFromStepCode(node.stepCode) || node.stepNo, |
|||
racks: rackList.slice(0, 2), |
|||
extraCount: Math.max(0, rackList.length - 2) |
|||
} |
|||
}) |
|||
}, |
|||
flowRunners () { |
|||
const source = this.selectedJobCode |
|||
? (this.rackBoardRows || []).filter(item => item.jobCode === this.selectedJobCode) |
|||
: (this.rackBoardRows || []) |
|||
return source.slice(0, 14).map(item => { |
|||
const progress = this.parseRackProgress(item.rackProgress) |
|||
return { |
|||
...item, |
|||
_passed: progress.passed, |
|||
_total: progress.total |
|||
} |
|||
}) |
|||
}, |
|||
simFlowRunners () { |
|||
if (this.flowRunners.length) { |
|||
return this.flowRunners.slice(0, 10) |
|||
} |
|||
return Array.from({ length: 6 }, (_, i) => ({ |
|||
rackCode: `RACK-${String(i + 1).padStart(2, '0')}` |
|||
})) |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.setTableHeight() |
|||
this.updateTime() |
|||
this.clockTimer = setInterval(this.updateTime, 1000) |
|||
this.refreshTimer = setInterval(() => this.loadBoardData(true), 5000) |
|||
this.loadBoardData(false) |
|||
window.addEventListener('resize', this.setTableHeight) |
|||
}, |
|||
beforeDestroy () { |
|||
if (this.clockTimer) clearInterval(this.clockTimer) |
|||
if (this.refreshTimer) clearInterval(this.refreshTimer) |
|||
window.removeEventListener('resize', this.setTableHeight) |
|||
}, |
|||
methods: { |
|||
setTableHeight () { |
|||
this.tableHeight = Math.max(220, Math.floor((window.innerHeight - 360) * 0.5)) |
|||
}, |
|||
updateTime () { |
|||
const now = new Date() |
|||
const weekList = ['星期日', '星期一', '星期二', '星期三', '星期四', '星期五', '星期六'] |
|||
const d = this.dayjs(now) |
|||
this.currentTime = `${d.format('YYYY/MM/DD')} ${weekList[now.getDay()]} ${d.format('HH:mm:ss')}` |
|||
}, |
|||
async loadBoardData (silent = true) { |
|||
if (!silent) { |
|||
this.loading = true |
|||
} |
|||
try { |
|||
const [{ data: jobData }, { data: reportData }] = await Promise.all([ |
|||
listJob({}), |
|||
reportOverview() |
|||
]) |
|||
this.jobRows = (jobData.rows || []).filter(item => item && item.jobCode) |
|||
this.report = reportData.result || {} |
|||
|
|||
const targetJobs = this.jobRows |
|||
.filter(item => ['待下达', '已下达', '生产中', '已完工'].includes(item.status)) |
|||
.slice(0, 20) |
|||
|
|||
const viewResults = await Promise.all(targetJobs.map(async job => { |
|||
try { |
|||
const { data } = await productionView(job.jobCode) |
|||
return { |
|||
job, |
|||
view: data.result || {} |
|||
} |
|||
} catch (e) { |
|||
return { |
|||
job, |
|||
view: {} |
|||
} |
|||
} |
|||
})) |
|||
|
|||
this.buildBoardRows(viewResults) |
|||
this.ensureSelectedJob() |
|||
this.lastRefreshTime = this.dayjs(new Date()).format('YYYY-MM-DD HH:mm:ss') |
|||
} finally { |
|||
this.loading = false |
|||
} |
|||
}, |
|||
buildBoardRows (viewResults) { |
|||
const jobRows = [] |
|||
const rackRows = [] |
|||
viewResults.forEach(({ job, view }) => { |
|||
const detailRows = Array.isArray(view.detailRows) ? view.detailRows : [] |
|||
const bindingRows = Array.isArray(view.bindingRows) ? view.bindingRows : [] |
|||
const stepProgress = Array.isArray(view.stepProgress) ? view.stepProgress : [] |
|||
const activeRackCodes = this.getActiveRackCodes(view, bindingRows) |
|||
const activeBindingOrderMap = this.buildActiveBindingOrderMap(bindingRows) |
|||
|
|||
const plannedQty = Number(job.plannedQty) || detailRows.reduce((sum, item) => sum + (Number(item.plannedQty) || 0), 0) |
|||
const executedQty = detailRows.reduce((sum, item) => sum + (Number(item.executedQty) || 0), 0) |
|||
const remainingQty = detailRows.reduce((sum, item) => sum + (Number(item.remainingQty) || 0), 0) |
|||
const passedSteps = stepProgress.filter(item => item && item.passed).length |
|||
const totalSteps = stepProgress.length |
|||
const nextStep = stepProgress.find(item => item && !item.passed) |
|||
const nextStepCode = nextStep ? (nextStep.stepCode || '-') : '已全部通过' |
|||
|
|||
jobRows.push({ |
|||
jobCode: job.jobCode, |
|||
status: job.status || '', |
|||
inboundNos: job.inboundNos || '', |
|||
plannedQty, |
|||
executedQty, |
|||
remainingQty, |
|||
activeRackCount: activeRackCodes.length, |
|||
passedSteps, |
|||
totalSteps, |
|||
stepRate: totalSteps > 0 ? Math.round((passedSteps / totalSteps) * 100) : 0, |
|||
nextStepCode, |
|||
stepProgress |
|||
}) |
|||
|
|||
const rackStepProgressMap = view.rackStepProgressMap || {} |
|||
activeRackCodes.forEach((rackCode, rackIdx) => { |
|||
let rackSteps = [] |
|||
if (Array.isArray(rackStepProgressMap[rackCode]) && rackStepProgressMap[rackCode].length) { |
|||
rackSteps = rackStepProgressMap[rackCode] |
|||
} else { |
|||
rackSteps = stepProgress.map(step => ({ |
|||
stepNo: step.stepNo, |
|||
stepCode: step.stepCode, |
|||
passed: false |
|||
})) |
|||
} |
|||
const rackPassed = rackSteps.filter(item => item && item.passed).length |
|||
const rackTotal = rackSteps.length |
|||
const rackNextStep = rackSteps.find(item => item && !item.passed) |
|||
const upHangOrder = Object.prototype.hasOwnProperty.call(activeBindingOrderMap, rackCode) |
|||
? activeBindingOrderMap[rackCode] |
|||
: rackIdx |
|||
rackRows.push({ |
|||
rackCode, |
|||
jobCode: job.jobCode, |
|||
status: job.status || '', |
|||
rackProgress: `${rackPassed}/${rackTotal}`, |
|||
nextStepCode: rackNextStep ? (rackNextStep.stepCode || '-') : '已全部通过', |
|||
passRate: rackTotal > 0 ? Math.round((rackPassed / rackTotal) * 100) : 0, |
|||
upHangOrder |
|||
}) |
|||
}) |
|||
}) |
|||
|
|||
this.jobBoardRows = jobRows.sort((a, b) => { |
|||
const pA = STATUS_PRIORITY[a.status] || 99 |
|||
const pB = STATUS_PRIORITY[b.status] || 99 |
|||
if (pA !== pB) { |
|||
return pA - pB |
|||
} |
|||
return (b.stepRate || 0) - (a.stepRate || 0) |
|||
}) |
|||
this.rackBoardRows = rackRows.sort((a, b) => { |
|||
const aOrder = Number(a.upHangOrder) |
|||
const bOrder = Number(b.upHangOrder) |
|||
if (Number.isFinite(aOrder) && Number.isFinite(bOrder) && aOrder !== bOrder) { |
|||
return aOrder - bOrder |
|||
} |
|||
const pA = STATUS_PRIORITY[a.status] || 99 |
|||
const pB = STATUS_PRIORITY[b.status] || 99 |
|||
if (pA !== pB) { |
|||
return pA - pB |
|||
} |
|||
return (a.rackCode || '').localeCompare(b.rackCode || '') |
|||
}) |
|||
}, |
|||
buildActiveBindingOrderMap (bindingRows) { |
|||
const activeRows = (bindingRows || []) |
|||
.filter(item => item && item.bindStatus === '生效中' && item.rackCode) |
|||
.map((item, idx) => ({ |
|||
rackCode: String(item.rackCode || ''), |
|||
bindTimeMs: this.parseTimeToMs(item.bindTime), |
|||
rawIndex: idx |
|||
})) |
|||
activeRows.sort((a, b) => { |
|||
const aValid = Number.isFinite(a.bindTimeMs) |
|||
const bValid = Number.isFinite(b.bindTimeMs) |
|||
if (aValid && bValid && a.bindTimeMs !== b.bindTimeMs) { |
|||
return a.bindTimeMs - b.bindTimeMs |
|||
} |
|||
if (aValid !== bValid) { |
|||
return aValid ? -1 : 1 |
|||
} |
|||
return a.rawIndex - b.rawIndex |
|||
}) |
|||
const orderMap = {} |
|||
activeRows.forEach((item, idx) => { |
|||
if (!Object.prototype.hasOwnProperty.call(orderMap, item.rackCode)) { |
|||
orderMap[item.rackCode] = idx |
|||
} |
|||
}) |
|||
return orderMap |
|||
}, |
|||
parseTimeToMs (timeValue) { |
|||
if (timeValue === null || timeValue === undefined || timeValue === '') { |
|||
return NaN |
|||
} |
|||
if (typeof timeValue === 'number') { |
|||
return timeValue |
|||
} |
|||
const parsed = Date.parse(String(timeValue)) |
|||
return Number.isFinite(parsed) ? parsed : NaN |
|||
}, |
|||
extractPoolNoFromStepCode (stepCode) { |
|||
const code = String(stepCode || '').trim() |
|||
const matched = code.match(/POOL-(\d+)/i) |
|||
if (!matched) { |
|||
return null |
|||
} |
|||
return Number(matched[1]) || null |
|||
}, |
|||
getActiveRackCodes (view, bindingRows) { |
|||
const fromView = Array.isArray(view.activeRackCodes) ? view.activeRackCodes : [] |
|||
const fromBinding = bindingRows |
|||
.filter(item => item && item.bindStatus === '生效中' && item.rackCode) |
|||
.map(item => item.rackCode) |
|||
const list = fromView.length ? fromView : fromBinding |
|||
return Array.from(new Set((list || []).filter(Boolean))) |
|||
}, |
|||
ensureSelectedJob () { |
|||
if (!this.jobBoardRows.length) { |
|||
this.selectedJobCode = '' |
|||
return |
|||
} |
|||
const exists = this.jobBoardRows.some(item => item.jobCode === this.selectedJobCode) |
|||
if (!exists) { |
|||
const running = this.jobBoardRows.find(item => item.status === '生产中') |
|||
this.selectedJobCode = (running || this.jobBoardRows[0]).jobCode |
|||
} |
|||
this.$nextTick(() => { |
|||
const current = this.jobBoardRows.find(item => item.jobCode === this.selectedJobCode) |
|||
if (this.$refs.jobBoardTable && current) { |
|||
this.$refs.jobBoardTable.setCurrentRow(current) |
|||
} |
|||
}) |
|||
}, |
|||
onJobRowClick (row) { |
|||
if (!row || !row.jobCode) { |
|||
return |
|||
} |
|||
this.selectedJobCode = row.jobCode |
|||
}, |
|||
parseRackProgress (rackProgressText) { |
|||
const text = String(rackProgressText || '') |
|||
const matched = text.match(/^(\d+)\/(\d+)$/) |
|||
if (!matched) { |
|||
return { passed: 0, total: 0 } |
|||
} |
|||
return { |
|||
passed: Number(matched[1]) || 0, |
|||
total: Number(matched[2]) || 0 |
|||
} |
|||
}, |
|||
resolveRunnerStepCode (runner) { |
|||
const nodes = this.flowPoolNodes || [] |
|||
if (!nodes.length || !runner) { |
|||
return '' |
|||
} |
|||
const nextStepCode = String(runner.nextStepCode || '').trim() |
|||
if (nextStepCode && nextStepCode !== '已全部通过' && nodes.some(item => item.stepCode === nextStepCode)) { |
|||
return nextStepCode |
|||
} |
|||
const passed = Number(runner._passed) || 0 |
|||
const total = Number(runner._total) || 0 |
|||
if (total > 0 && passed >= total) { |
|||
return nodes[nodes.length - 1].stepCode |
|||
} |
|||
const idx = Math.min(Math.max(passed, 0), nodes.length - 1) |
|||
return nodes[idx].stepCode |
|||
}, |
|||
resolveRunnerStepIndex (runner) { |
|||
const stepCode = this.resolveRunnerStepCode(runner) |
|||
if (!stepCode) { |
|||
return -1 |
|||
} |
|||
return this.flowPoolNodes.findIndex(item => item.stepCode === stepCode) |
|||
}, |
|||
getFlowProgressStyle (runner) { |
|||
const count = this.flowPoolNodes.length || 1 |
|||
const stepIndex = this.resolveRunnerStepIndex(runner) |
|||
const basePercent = stepIndex >= 0 ? (((stepIndex + 0.5) / count) * 100) : 0 |
|||
return { |
|||
left: `${basePercent}%` |
|||
} |
|||
}, |
|||
getFlowRunnerStyle (runner, idx) { |
|||
const delay = idx * 2.2 |
|||
const duration = 24 |
|||
return { |
|||
animationDelay: `${delay}s`, |
|||
animationDuration: `${duration}s` |
|||
} |
|||
}, |
|||
getStatusClass (status) { |
|||
if (status === '已完工') return 'status-done' |
|||
if (status === '生产中') return 'status-doing' |
|||
if (status === '已下达') return 'status-dispatched' |
|||
return 'status-planned' |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style> |
|||
.screen-wrap { |
|||
height: 100vh; |
|||
width: 100%; |
|||
max-width: 100%; |
|||
padding: 16px; |
|||
box-sizing: border-box; |
|||
overflow: hidden; |
|||
display: flex; |
|||
flex-direction: column; |
|||
background: linear-gradient(165deg, #071b30 0%, #0c2943 52%, #081c31 100%); |
|||
} |
|||
|
|||
.top-bar { |
|||
display: flex; |
|||
justify-content: space-between; |
|||
align-items: center; |
|||
min-width: 0; |
|||
padding: 14px 18px; |
|||
border-radius: 12px; |
|||
border: 1px solid rgba(109, 167, 219, 0.24); |
|||
background: rgba(9, 29, 49, 0.82); |
|||
position: relative; |
|||
} |
|||
|
|||
.header-left { |
|||
display: flex; |
|||
align-items: center; |
|||
min-width: 110px; |
|||
z-index: 2; |
|||
} |
|||
|
|||
.logo-box { |
|||
width: 86px; |
|||
height: 30px; |
|||
border-radius: 6px; |
|||
border: 1px solid rgba(128, 198, 255, 0.45); |
|||
color: #d8edff; |
|||
font-size: 14px; |
|||
font-weight: 700; |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
background: rgba(36, 82, 122, 0.35); |
|||
} |
|||
|
|||
.header-center { |
|||
position: absolute; |
|||
left: 50%; |
|||
top: 50%; |
|||
transform: translate(-50%, -50%); |
|||
text-align: center; |
|||
pointer-events: none; |
|||
max-width: 70%; |
|||
width: auto; |
|||
padding: 6px 24px 8px; |
|||
border-radius: 8px; |
|||
border: 1px solid rgba(96, 170, 232, 0.34); |
|||
background: linear-gradient(180deg, rgba(33, 73, 116, 0.52), rgba(16, 44, 77, 0.42)); |
|||
} |
|||
|
|||
.header-center::before { |
|||
content: ''; |
|||
position: absolute; |
|||
left: 50%; |
|||
transform: translateX(-50%); |
|||
top: -8px; |
|||
width: 68%; |
|||
height: 1px; |
|||
background: linear-gradient(90deg, rgba(87, 164, 230, 0), rgba(87, 164, 230, 0.9), rgba(87, 164, 230, 0)); |
|||
} |
|||
|
|||
.title { |
|||
color: #8fe7ff; |
|||
font-size: 30px; |
|||
font-weight: 800; |
|||
letter-spacing: 3px; |
|||
line-height: 1.05; |
|||
text-shadow: 0 0 14px rgba(79, 179, 255, 0.36); |
|||
} |
|||
|
|||
.subtitle { |
|||
margin-top: 4px; |
|||
color: #c7e8ff; |
|||
font-size: 12px; |
|||
letter-spacing: 1px; |
|||
} |
|||
|
|||
.tools { |
|||
margin-left: auto; |
|||
z-index: 2; |
|||
display: flex; |
|||
align-items: center; |
|||
gap: 10px; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.time { |
|||
color: #89d7ff; |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
} |
|||
|
|||
.refresh-btn { |
|||
background: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.refresh-btn:hover { |
|||
background: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.kpi-row { |
|||
display: grid; |
|||
grid-template-columns: repeat(6, minmax(0, 1fr)); |
|||
gap: 10px; |
|||
margin: 12px 0 10px; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.kpi-card { |
|||
border-radius: 10px; |
|||
padding: 12px 14px; |
|||
background: rgba(14, 43, 70, 0.82); |
|||
border: 1px solid rgba(101, 157, 209, 0.22); |
|||
min-width: 0; |
|||
} |
|||
|
|||
.kpi-label { |
|||
color: #b7d3ee; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.kpi-value { |
|||
margin-top: 6px; |
|||
font-size: 30px; |
|||
font-weight: 700; |
|||
color: #edf6ff; |
|||
} |
|||
|
|||
.kpi-value.warning { color: #ffd166; } |
|||
.kpi-value.success { color: #74dfa3; } |
|||
.kpi-value.highlight { color: #79d5ff; } |
|||
.kpi-value.danger { color: #ff9da1; } |
|||
|
|||
.content-grid { |
|||
flex: 1; |
|||
min-height: 0; |
|||
display: grid; |
|||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); |
|||
grid-template-rows: 52% 48%; |
|||
gap: 12px; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.panel { |
|||
border-radius: 10px; |
|||
background: rgba(13, 34, 54, 0.86); |
|||
border: 1px solid rgba(95, 180, 255, 0.24); |
|||
padding: 10px 12px; |
|||
display: flex; |
|||
flex-direction: column; |
|||
min-height: 0; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.top-left-panel { |
|||
grid-column: 1; |
|||
grid-row: 1; |
|||
} |
|||
|
|||
.top-right-panel { |
|||
grid-column: 2; |
|||
grid-row: 1; |
|||
} |
|||
|
|||
.bottom-flow-panel { |
|||
grid-column: 1 / span 2; |
|||
grid-row: 2; |
|||
} |
|||
|
|||
.panel-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
min-width: 0; |
|||
gap: 8px; |
|||
margin-bottom: 8px; |
|||
} |
|||
|
|||
.panel-title { |
|||
font-size: 14px; |
|||
font-weight: 600; |
|||
color: #d9ebff; |
|||
} |
|||
|
|||
.panel-meta { |
|||
font-size: 12px; |
|||
color: #9fc7ea; |
|||
min-width: 0; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.flow-board { |
|||
border: 1px solid rgba(106, 171, 224, 0.28); |
|||
border-radius: 8px; |
|||
background: rgba(17, 43, 71, 0.5); |
|||
padding: 4px 8px; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.flow-scroll-wrap { |
|||
overflow: hidden; |
|||
min-width: 0; |
|||
} |
|||
|
|||
.flow-pool-row { |
|||
display: grid; |
|||
grid-template-columns: repeat(auto-fit, minmax(74px, 1fr)); |
|||
gap: 6px; |
|||
} |
|||
|
|||
.flow-pool-node { |
|||
border: 1px solid rgba(130, 189, 235, 0.26); |
|||
border-radius: 6px; |
|||
background: rgba(26, 59, 89, 0.55); |
|||
min-height: 58px; |
|||
padding: 4px 4px; |
|||
text-align: center; |
|||
display: flex; |
|||
flex-direction: column; |
|||
justify-content: flex-start; |
|||
gap: 4px; |
|||
} |
|||
|
|||
.flow-pool-node.pool-node-active { |
|||
border-color: rgba(135, 229, 255, 0.65); |
|||
box-shadow: inset 0 0 0 1px rgba(135, 229, 255, 0.24); |
|||
} |
|||
|
|||
.pool-name { |
|||
display: block; |
|||
font-size: 12px; |
|||
font-weight: 600; |
|||
color: #d4e9fb; |
|||
line-height: 16px; |
|||
} |
|||
|
|||
.pool-rack-list { |
|||
display: flex; |
|||
flex-wrap: wrap; |
|||
justify-content: center; |
|||
gap: 3px; |
|||
} |
|||
|
|||
.pool-rack-chip { |
|||
display: inline-flex; |
|||
align-items: center; |
|||
justify-content: center; |
|||
min-width: 48px; |
|||
height: 18px; |
|||
border-radius: 9px; |
|||
border: 1px solid rgba(129, 219, 255, 0.55); |
|||
background: rgba(37, 110, 157, 0.68); |
|||
color: #ebf9ff; |
|||
font-size: 11px; |
|||
font-weight: 700; |
|||
line-height: 16px; |
|||
padding: 0 6px; |
|||
max-width: 100%; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
} |
|||
|
|||
.pool-rack-chip.pool-rack-chip-more { |
|||
border-color: rgba(255, 218, 129, 0.55); |
|||
background: rgba(127, 96, 31, 0.58); |
|||
color: #ffe1a0; |
|||
} |
|||
|
|||
.pool-rack-empty { |
|||
height: 18px; |
|||
line-height: 18px; |
|||
color: #7ea8cb; |
|||
font-size: 11px; |
|||
text-align: center; |
|||
} |
|||
|
|||
.flow-track-wrap { |
|||
position: relative; |
|||
height: 58px; |
|||
overflow: hidden; |
|||
margin-top: 6px; |
|||
} |
|||
|
|||
.flow-track-line { |
|||
position: absolute; |
|||
left: 0; |
|||
right: 0; |
|||
top: 50%; |
|||
transform: translateY(-50%); |
|||
height: 2px; |
|||
background: linear-gradient(90deg, rgba(105, 174, 230, 0), rgba(105, 174, 230, 0.85), rgba(105, 174, 230, 0)); |
|||
} |
|||
|
|||
.flow-progress-marker { |
|||
position: absolute; |
|||
top: 50%; |
|||
width: 10px; |
|||
height: 10px; |
|||
border-radius: 50%; |
|||
transform: translate(-50%, -50%); |
|||
background: #7dd7ff; |
|||
box-shadow: 0 0 10px rgba(125, 215, 255, 0.9); |
|||
} |
|||
|
|||
.flow-runner { |
|||
position: absolute; |
|||
top: 50%; |
|||
left: -10%; |
|||
transform: translateY(-50%); |
|||
min-width: 82px; |
|||
height: 28px; |
|||
border-radius: 14px; |
|||
border: 1px solid rgba(137, 227, 255, 0.58); |
|||
background: rgba(37, 104, 150, 0.7); |
|||
color: #e9f7ff; |
|||
font-size: 12px; |
|||
font-weight: 700; |
|||
line-height: 26px; |
|||
text-align: center; |
|||
white-space: nowrap; |
|||
animation-name: runnerTravel, runnerPulse; |
|||
animation-timing-function: linear, ease-in-out; |
|||
animation-iteration-count: infinite; |
|||
pointer-events: none; |
|||
} |
|||
|
|||
@keyframes runnerPulse { |
|||
0%, 100% { |
|||
box-shadow: 0 0 0 rgba(94, 196, 255, 0.15); |
|||
} |
|||
50% { |
|||
box-shadow: 0 0 14px rgba(94, 196, 255, 0.55); |
|||
} |
|||
} |
|||
|
|||
@keyframes runnerTravel { |
|||
0% { |
|||
left: -10%; |
|||
} |
|||
100% { |
|||
left: 102%; |
|||
} |
|||
} |
|||
|
|||
.board-table { |
|||
border-radius: 10px; |
|||
overflow: hidden; |
|||
border: 1px solid rgba(86, 140, 190, 0.35); |
|||
flex: 1; |
|||
min-height: 0; |
|||
min-width: 0; |
|||
width: 100%; |
|||
} |
|||
.board-table .cell { |
|||
line-height: 24px; |
|||
height: 24px; |
|||
} |
|||
.screen-wrap .board-table .el-table, |
|||
.screen-wrap .board-table .el-table__expanded-cell, |
|||
.screen-wrap .board-table .el-table__body-wrapper, |
|||
.screen-wrap .board-table .el-table__empty-block, |
|||
.screen-wrap .board-table .el-table__fixed-body-wrapper { |
|||
background: rgba(12, 39, 64, 0.96) !important; |
|||
color: #fff !important; |
|||
width: 100% !important; |
|||
min-width: 0 !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table__header-wrapper th, |
|||
.screen-wrap .board-table .el-table__fixed-header-wrapper th { |
|||
background: #123a5e !important; |
|||
color: #d9e9f8 !important; |
|||
border-color: rgba(80, 133, 181, 0.6) !important; |
|||
font-size: 14px !important; |
|||
padding: 8px 0 !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table__body tr > td, |
|||
.screen-wrap .board-table .el-table__fixed-body-wrapper tr > td, |
|||
.screen-wrap .board-table .el-table__fixed-right .el-table__fixed-body-wrapper tr > td { |
|||
background: rgba(20, 52, 83, 0.96) !important; |
|||
border-color: rgba(88, 139, 187, 0.4) !important; |
|||
color: #fff !important; |
|||
height: 40px !important; |
|||
vertical-align: middle !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table--striped .el-table__body tr.el-table__row--striped > td, |
|||
.screen-wrap .board-table .el-table__fixed-body-wrapper tr.el-table__row--striped > td { |
|||
background: rgba(29, 66, 102, 0.96) !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table--enable-row-hover .el-table__body tr:hover > td, |
|||
.screen-wrap .board-table .el-table__fixed-body-wrapper tr:hover > td { |
|||
background: rgba(39, 81, 123, 0.96) !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table .cell { |
|||
color: #fff !important; |
|||
line-height: 20px !important; |
|||
} |
|||
|
|||
.screen-wrap .board-table .el-table__empty-text { |
|||
color: #9ac2e7 !important; |
|||
} |
|||
|
|||
.board-tag { |
|||
display: inline-flex !important; |
|||
align-items: center !important; |
|||
justify-content: center !important; |
|||
min-width: 66px !important; |
|||
height: 24px !important; |
|||
padding: 0 10px !important; |
|||
border-radius: 12px !important; |
|||
border: 1px solid transparent !important; |
|||
font-size: 12px !important; |
|||
font-weight: 600 !important; |
|||
line-height: 22px !important; |
|||
box-sizing: border-box !important; |
|||
} |
|||
|
|||
.board-tag.status-planned { |
|||
color: #b9c7d8 !important; |
|||
background: rgba(96, 118, 141, 0.28) !important; |
|||
border-color: rgba(185, 199, 216, 0.34) !important; |
|||
} |
|||
|
|||
.board-tag.status-dispatched { |
|||
color: #8fd4ff !important; |
|||
background: rgba(29, 85, 129, 0.32) !important; |
|||
border-color: rgba(143, 212, 255, 0.45) !important; |
|||
} |
|||
|
|||
.board-tag.status-doing { |
|||
color: #ffd774 !important; |
|||
background: rgba(120, 92, 27, 0.32) !important; |
|||
border-color: rgba(255, 215, 116, 0.45) !important; |
|||
} |
|||
|
|||
.board-tag.status-done { |
|||
color: #83f3be !important; |
|||
background: rgba(31, 110, 84, 0.32) !important; |
|||
border-color: rgba(131, 243, 190, 0.45) !important; |
|||
} |
|||
</style> |
|||
@ -0,0 +1,441 @@ |
|||
<template> |
|||
<div class="mod-config"> |
|||
<el-card class="overview-card" shadow="never"> |
|||
<div slot="header" class="card-title-row"> |
|||
<div> |
|||
<div class="page-title">生产追溯与运营看板</div> |
|||
<div class="page-desc">面向批次、任务单、挂具、物料的全链路追溯与关键指标监控</div> |
|||
</div> |
|||
<el-button plain class="search-btn" :loading="reportLoading" @click="loadReport">刷新报表</el-button> |
|||
</div> |
|||
<div class="metric-grid"> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">任务总数</div> |
|||
<div class="metric-value">{{ metricData.totalJobs }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">完工任务</div> |
|||
<div class="metric-value">{{ metricData.completedJobs }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">在制任务</div> |
|||
<div class="metric-value">{{ metricData.processingJobs }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">任务完工率</div> |
|||
<div class="metric-value">{{ formatRate(report.jobCompletionRate) }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">过站通过数</div> |
|||
<div class="metric-value">{{ metricData.stationPassCount }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">过站拦截数</div> |
|||
<div class="metric-value">{{ metricData.stationInterceptCount }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">过站拦截率</div> |
|||
<div class="metric-value">{{ formatRate(report.interceptRate) }}</div> |
|||
</div> |
|||
<div class="metric-item"> |
|||
<div class="metric-label">手工录入数</div> |
|||
<div class="metric-value">{{ metricData.manualRecordCount }}</div> |
|||
</div> |
|||
</div> |
|||
<div class="report-time">报表更新时间:{{ reportRefreshTime || '-' }}</div> |
|||
</el-card> |
|||
|
|||
<el-card style="margin-top: 12px;"> |
|||
<div slot="header" class="card-title-row"> |
|||
<span>追溯查询工作台</span> |
|||
<div> |
|||
<el-button plain class="reset-btn" @click="resetTraceQuery">重置条件</el-button> |
|||
<el-button plain class="search-btn" :loading="traceLoading" @click="loadTrace">查询</el-button> |
|||
</div> |
|||
</div> |
|||
<el-form :inline="true" label-position="top" class="query-form"> |
|||
<el-form-item label="批次编码"> |
|||
<el-input v-model.trim="traceQueryForm.batchCode" clearable placeholder="例如 BATCH-20260428-01" style="width: 220px" @keyup.enter.native="loadTrace" /> |
|||
</el-form-item> |
|||
<el-form-item label="任务单号"> |
|||
<el-input v-model.trim="traceQueryForm.jobCode" clearable placeholder="例如 JOB-20260428-01" style="width: 220px" @keyup.enter.native="loadTrace" /> |
|||
</el-form-item> |
|||
<el-form-item label="挂具编码"> |
|||
<el-input v-model.trim="traceQueryForm.rackCode" clearable placeholder="例如 RACK-01" style="width: 180px" @keyup.enter.native="loadTrace" /> |
|||
</el-form-item> |
|||
<el-form-item label="物料编码"> |
|||
<el-input v-model.trim="traceQueryForm.partNo" clearable placeholder="例如 PART-001" style="width: 180px" @keyup.enter.native="loadTrace" /> |
|||
</el-form-item> |
|||
</el-form> |
|||
|
|||
<el-tabs v-model="traceTab" type="border-card"> |
|||
<el-tab-pane label="追溯摘要" name="summary"> |
|||
<el-descriptions :column="4" border> |
|||
<el-descriptions-item label="批次">{{ traceSummary.batchCode || '-' }}</el-descriptions-item> |
|||
<el-descriptions-item label="任务单">{{ traceSummary.jobCode || '-' }}</el-descriptions-item> |
|||
<el-descriptions-item label="挂具">{{ traceSummary.rackCode || '-' }}</el-descriptions-item> |
|||
<el-descriptions-item label="物料">{{ traceSummary.partNo || '-' }}</el-descriptions-item> |
|||
<el-descriptions-item label="当前状态">{{ traceSummary.status || '-' }}</el-descriptions-item> |
|||
<el-descriptions-item label="通过工序数">{{ traceSummary.passCount }}</el-descriptions-item> |
|||
<el-descriptions-item label="拦截次数">{{ traceSummary.interceptCount }}</el-descriptions-item> |
|||
<el-descriptions-item label="手工补录次数">{{ traceSummary.manualCount }}</el-descriptions-item> |
|||
</el-descriptions> |
|||
</el-tab-pane> |
|||
|
|||
<el-tab-pane label="过站记录" name="station"> |
|||
<el-table class="data-table" :data="stationRows" border height="260" style="width: 100%"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="time" label="时间" min-width="170" /> |
|||
<el-table-column prop="stepCode" label="工序编码" width="120" /> |
|||
<el-table-column prop="stationId" label="站点/池子" width="140" /> |
|||
<el-table-column prop="rackCode" label="挂具" width="120" /> |
|||
<el-table-column prop="result" label="结果" width="100" /> |
|||
<el-table-column prop="message" label="备注" min-width="180" show-overflow-tooltip /> |
|||
</el-table> |
|||
</el-tab-pane> |
|||
|
|||
<el-tab-pane label="手工录入记录" name="manual"> |
|||
<el-table class="data-table" :data="manualRows" border height="260" style="width: 100%"> |
|||
<el-table-column type="index" width="50" /> |
|||
<el-table-column prop="recordType" label="类型" width="110" /> |
|||
<el-table-column prop="docNo" label="单号" min-width="150" /> |
|||
<el-table-column prop="qty" label="数量" width="100" /> |
|||
<el-table-column prop="operatorName" label="录入人" width="120" /> |
|||
<el-table-column prop="reviewerName" label="复核人" width="120" /> |
|||
<el-table-column prop="recordTime" label="录入时间" min-width="170" /> |
|||
</el-table> |
|||
</el-tab-pane> |
|||
|
|||
<el-tab-pane label="原始JSON" name="raw"> |
|||
<pre class="trace-box">{{ prettyTraceJson }}</pre> |
|||
</el-tab-pane> |
|||
</el-tabs> |
|||
</el-card> |
|||
</div> |
|||
</template> |
|||
|
|||
<script> |
|||
import { traceQuery, reportOverview } from '@/api/rack/closedLoop' |
|||
|
|||
export default { |
|||
data () { |
|||
return { |
|||
traceQueryForm: { batchCode: '', jobCode: '', rackCode: '', partNo: '' }, |
|||
traceLoading: false, |
|||
reportLoading: false, |
|||
traceTab: 'summary', |
|||
reportRefreshTime: '', |
|||
traceResult: {}, |
|||
report: {} |
|||
} |
|||
}, |
|||
computed: { |
|||
metricData () { |
|||
const totalJobs = Number(this.report.totalJobs) || 0 |
|||
const completedJobs = Number(this.report.completedJobs) || 0 |
|||
return { |
|||
totalJobs, |
|||
completedJobs, |
|||
processingJobs: Math.max(totalJobs - completedJobs, 0), |
|||
stationPassCount: Number(this.report.stationPassCount) || 0, |
|||
stationInterceptCount: Number(this.report.stationInterceptCount) || 0, |
|||
manualRecordCount: Number(this.report.manualRecordCount) || 0 |
|||
} |
|||
}, |
|||
traceSummary () { |
|||
return { |
|||
batchCode: this.pickFirstValue(this.traceResult, ['batchCode', 'batchNo', 'inboundBatchCode']), |
|||
jobCode: this.pickFirstValue(this.traceResult, ['jobCode', 'taskCode']), |
|||
rackCode: this.pickFirstValue(this.traceResult, ['rackCode', 'currentRackCode']), |
|||
partNo: this.pickFirstValue(this.traceResult, ['partNo', 'materialCode']), |
|||
status: this.pickFirstValue(this.traceResult, ['status', 'jobStatus', 'traceStatus']), |
|||
passCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['passCount', 'stationPassCount'])), |
|||
interceptCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['interceptCount', 'stationInterceptCount'])), |
|||
manualCount: this.safeNumber(this.pickFirstValue(this.traceResult, ['manualCount', 'manualRecordCount'])) |
|||
} |
|||
}, |
|||
stationRows () { |
|||
const source = this.pickFirstArray(this.traceResult, [ |
|||
'stationRecords', |
|||
'stationPassLogs', |
|||
'passLogs', |
|||
'traceRecords', |
|||
'events' |
|||
]) |
|||
return source.map(item => ({ |
|||
time: this.formatDateTime(this.pickFirstValue(item, ['eventTime', 'passTime', 'recordTime', 'createTime', 'time'])), |
|||
stepCode: this.pickFirstValue(item, ['stepCode', 'processCode', 'stepNo']), |
|||
stationId: this.pickFirstValue(item, ['stationId', 'poolCode', 'stationCode']), |
|||
rackCode: this.pickFirstValue(item, ['rackCode']), |
|||
result: this.pickFirstValue(item, ['result', 'eventType', 'status']) || '-', |
|||
message: this.pickFirstValue(item, ['msg', 'message', 'remark']) || '-' |
|||
})) |
|||
}, |
|||
manualRows () { |
|||
const source = this.pickFirstArray(this.traceResult, [ |
|||
'manualRecords', |
|||
'manualRecordList', |
|||
'manualRows', |
|||
'manualList' |
|||
]) |
|||
return source.map(item => ({ |
|||
recordType: item.recordType || '-', |
|||
docNo: item.docNo || '-', |
|||
qty: this.safeNumber(item.qty), |
|||
operatorName: item.operatorName || '-', |
|||
reviewerName: item.reviewerName || '-', |
|||
recordTime: this.formatDateTime(item.recordTime) |
|||
})) |
|||
}, |
|||
prettyTraceJson () { |
|||
return JSON.stringify(this.traceResult || {}, null, 2) |
|||
} |
|||
}, |
|||
mounted () { |
|||
this.loadReport() |
|||
}, |
|||
methods: { |
|||
resetTraceQuery () { |
|||
this.traceQueryForm = { batchCode: '', jobCode: '', rackCode: '', partNo: '' } |
|||
this.traceResult = {} |
|||
this.traceTab = 'summary' |
|||
}, |
|||
async loadTrace () { |
|||
const hasCondition = Object.keys(this.traceQueryForm).some(key => String(this.traceQueryForm[key] || '').trim()) |
|||
if (!hasCondition) { |
|||
this.$message.warning('请至少输入一个追溯条件') |
|||
return |
|||
} |
|||
this.traceLoading = true |
|||
try { |
|||
const { data } = await traceQuery(this.traceQueryForm) |
|||
this.traceResult = data.result || {} |
|||
if (!Object.keys(this.traceResult).length) { |
|||
this.$message.warning('未查询到追溯结果') |
|||
} |
|||
} finally { |
|||
this.traceLoading = false |
|||
} |
|||
}, |
|||
async loadReport () { |
|||
this.reportLoading = true |
|||
try { |
|||
const { data } = await reportOverview() |
|||
this.report = data.result || {} |
|||
this.reportRefreshTime = this.formatDateTime(Date.now()) |
|||
} finally { |
|||
this.reportLoading = false |
|||
} |
|||
}, |
|||
formatRate (value) { |
|||
if (value === null || value === undefined) return '0%' |
|||
return `${(Number(value) * 100).toFixed(2)}%` |
|||
}, |
|||
safeNumber (val) { |
|||
return Number(val) || 0 |
|||
}, |
|||
pickFirstValue (obj, fields) { |
|||
if (!obj || typeof obj !== 'object') { |
|||
return '' |
|||
} |
|||
for (let i = 0; i < fields.length; i++) { |
|||
const key = fields[i] |
|||
if (obj[key] !== undefined && obj[key] !== null && obj[key] !== '') { |
|||
return obj[key] |
|||
} |
|||
} |
|||
return '' |
|||
}, |
|||
pickFirstArray (obj, fields) { |
|||
for (let i = 0; i < fields.length; i++) { |
|||
const value = this.pickFirstValue(obj, [fields[i]]) |
|||
if (Array.isArray(value)) { |
|||
return value |
|||
} |
|||
} |
|||
return [] |
|||
}, |
|||
formatDateTime (v) { |
|||
if (!v) return '-' |
|||
let date = null |
|||
if (typeof v === 'number') { |
|||
date = new Date(v) |
|||
} else if (typeof v === 'string') { |
|||
const trimmed = v.trim() |
|||
if (/^\d+$/.test(trimmed)) { |
|||
date = new Date(Number(trimmed)) |
|||
} else { |
|||
date = new Date(trimmed) |
|||
} |
|||
} else if (v instanceof Date) { |
|||
date = v |
|||
} else { |
|||
date = new Date(v) |
|||
} |
|||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) { |
|||
return String(v).replace('T', ' ') |
|||
} |
|||
const y = date.getFullYear() |
|||
const m = String(date.getMonth() + 1).padStart(2, '0') |
|||
const d = String(date.getDate()).padStart(2, '0') |
|||
const hh = String(date.getHours()).padStart(2, '0') |
|||
const mm = String(date.getMinutes()).padStart(2, '0') |
|||
const ss = String(date.getSeconds()).padStart(2, '0') |
|||
return `${y}-${m}-${d} ${hh}:${mm}:${ss}` |
|||
} |
|||
} |
|||
} |
|||
</script> |
|||
|
|||
<style scoped> |
|||
.overview-card { |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.card-title-row { |
|||
display: flex; |
|||
align-items: center; |
|||
justify-content: space-between; |
|||
} |
|||
|
|||
.page-title { |
|||
font-size: 16px; |
|||
font-weight: 600; |
|||
color: #303133; |
|||
} |
|||
|
|||
.page-desc { |
|||
margin-top: 4px; |
|||
font-size: 12px; |
|||
color: #909399; |
|||
} |
|||
|
|||
.metric-grid { |
|||
display: grid; |
|||
grid-template-columns: repeat(4, minmax(130px, 1fr)); |
|||
gap: 10px; |
|||
} |
|||
|
|||
.metric-item { |
|||
border: 1px solid #ebeef5; |
|||
border-radius: 6px; |
|||
background: #fff; |
|||
padding: 10px; |
|||
} |
|||
|
|||
.metric-label { |
|||
font-size: 12px; |
|||
color: #909399; |
|||
} |
|||
|
|||
.metric-value { |
|||
margin-top: 6px; |
|||
font-size: 20px; |
|||
font-weight: 600; |
|||
color: #303133; |
|||
} |
|||
|
|||
.report-time { |
|||
margin-top: 10px; |
|||
color: #909399; |
|||
font-size: 12px; |
|||
} |
|||
|
|||
.data-table { |
|||
background-color: #fff; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper th, |
|||
.data-table >>> .el-table__fixed-header-wrapper th { |
|||
background-color: #f5f7fa !important; |
|||
color: #333; |
|||
font-weight: 600; |
|||
border-color: #ebeef5; |
|||
padding: 8px 0; |
|||
} |
|||
|
|||
.data-table >>> .el-table__header-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-header-wrapper .cell, |
|||
.data-table >>> .el-table__body-wrapper .cell, |
|||
.data-table >>> .el-table__fixed-body-wrapper .cell { |
|||
padding: 0 10px; |
|||
overflow: hidden; |
|||
text-overflow: ellipsis; |
|||
white-space: nowrap; |
|||
font-size: 13px !important; |
|||
} |
|||
|
|||
.query-form { |
|||
background-color: #fff; |
|||
padding: 5px 0 0; |
|||
border-radius: 4px; |
|||
margin-bottom: 12px; |
|||
} |
|||
|
|||
.query-form >>> .el-form-item__label { |
|||
color: #333; |
|||
font-size: 13px; |
|||
padding-bottom: 5px; |
|||
} |
|||
|
|||
.query-form >>> .el-input__inner { |
|||
height: 32px; |
|||
line-height: 32px; |
|||
border-radius: 4px; |
|||
font-size: 13px; |
|||
} |
|||
|
|||
.query-form >>> .el-button { |
|||
height: 32px; |
|||
padding: 0 15px; |
|||
font-size: 13px; |
|||
border-radius: 4px; |
|||
} |
|||
|
|||
.search-btn { |
|||
background-color: #ecf5ff; |
|||
border-color: #b3d8ff; |
|||
color: #409eff; |
|||
} |
|||
|
|||
.search-btn:hover { |
|||
background-color: #409eff; |
|||
border-color: #409eff; |
|||
color: #fff; |
|||
} |
|||
|
|||
.reset-btn { |
|||
background-color: #f5f7fa; |
|||
border-color: #d3d4d6; |
|||
color: #606266; |
|||
} |
|||
|
|||
.reset-btn:hover { |
|||
background-color: #909399; |
|||
border-color: #909399; |
|||
color: #fff; |
|||
} |
|||
|
|||
.add-btn { |
|||
background-color: #f0f9eb; |
|||
border-color: #c2e7b0; |
|||
color: #67c23a; |
|||
} |
|||
|
|||
.add-btn:hover { |
|||
background-color: #67c23a; |
|||
border-color: #67c23a; |
|||
color: #fff; |
|||
} |
|||
|
|||
.trace-box { |
|||
margin: 0; |
|||
max-height: 300px; |
|||
overflow: auto; |
|||
background: #f7f7f7; |
|||
border: 1px solid #ebeef5; |
|||
border-radius: 4px; |
|||
padding: 10px; |
|||
} |
|||
</style> |
|||
Write
Preview
Loading…
Cancel
Save
Reference in new issue