Browse Source

init

master
han\hanst 2 months ago
parent
commit
78f3f56f3c
  1. 83
      src/api/rack/closedLoop.js
  2. 6
      src/element-ui/index.js
  3. 1
      src/router/index.js
  4. 327
      src/views/modules/rack/batch-inbound-management.vue
  5. 914
      src/views/modules/rack/execution-control.vue
  6. 401
      src/views/modules/rack/job-order-management.vue
  7. 699
      src/views/modules/rack/manual-record-ledger.vue
  8. 280
      src/views/modules/rack/part-archive.vue
  9. 718
      src/views/modules/rack/program-management.vue
  10. 353
      src/views/modules/rack/rack-archive.vue
  11. 979
      src/views/modules/rack/screen-rack-plating-progress.vue
  12. 441
      src/views/modules/rack/trace-report.vue

83
src/api/rack/closedLoop.js

@ -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', {})

6
src/element-ui/index.js

@ -153,7 +153,7 @@ Vue.prototype.$ELEMENT = {size: 'medium'}
Vue.prototype.$message = function (msg) {
let msgObj = {
message: msg.message ? msg.message : msg,
duration: msg.duration || 2000
duration: msg.duration || 3000
}
let msgType = msg.type || ""
switch (msgType) {
@ -184,14 +184,14 @@ Vue.prototype.$message.success = function (msg) {
Vue.prototype.$message.warning = function (msg) {
return Message.warning({
message: msg,
duration: 1000
duration: 3000
})
}
Vue.prototype.$message.error = function (msg) {
return Message.error({
message: msg,
duration: 1500
duration: 3000
})
}

1
src/router/index.js

@ -26,6 +26,7 @@ const globalRoutes = [
{ path: '/screen-cable-cop-progress', component: _import('modules/longchuang/screen-cable-cop-progress'), name: 'screen-cable-cop-progress', meta: { title: '线缆/COP生产进度看板' } },
{ path: '/screen-renovation-progress', component: _import('modules/longchuang/screen-renovation-progress'), name: 'screen-renovation-progress', meta: { title: '改造项目生产进度看板' } },
{ path: '/screen-factory-overview', component: _import('modules/longchuang/screen-factory-overview'), name: 'screen-factory-overview', meta: { title: '工厂综合运营看板' } },
{ path: '/screen-rack-plating-progress', component: _import('modules/rack/screen-rack-plating-progress'), name: 'screen-rack-plating-progress', meta: { title: '挂具过站采集大屏' } },
]
// 主入口路由(需嵌套上左右整体布局)

327
src/views/modules/rack/batch-inbound-management.vue

@ -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>

914
src/views/modules/rack/execution-control.vue

@ -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>

401
src/views/modules/rack/job-order-management.vue

@ -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>

699
src/views/modules/rack/manual-record-ledger.vue

@ -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>

280
src/views/modules/rack/part-archive.vue

@ -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>

718
src/views/modules/rack/program-management.vue

@ -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>

353
src/views/modules/rack/rack-archive.vue

@ -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>

979
src/views/modules/rack/screen-rack-plating-progress.vue

@ -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>

441
src/views/modules/rack/trace-report.vue

@ -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>
Loading…
Cancel
Save