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