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