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