You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

441 lines
14 KiB

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