<template>
|
<div class="app-container production-order-detail">
|
<PageHeader content="生产详情">
|
</PageHeader>
|
|
<el-card shadow="never" class="mb12">
|
<div class="header">
|
<div class="title">基础信息</div>
|
<div class="sub">
|
<span class="mr12">生产订单号:{{ header.npsNo || "-" }}</span>
|
<span class="mr12">生产批号:{{ header.lotNo || "-" }}</span>
|
<span class="mr12">产品名称:{{ header.productCategory || "-" }}</span>
|
<span class="mr12">规格:{{ header.specificationModel || "-" }}</span>
|
</div>
|
</div>
|
</el-card>
|
|
<el-card shadow="never" class="mb12">
|
<div class="steps-head">
|
<div class="steps-title">工序执行进度</div>
|
</div>
|
<div class="steps-body">
|
<div class="steps-left">
|
<div class="steps-wrap">
|
<el-steps
|
class="process-steps"
|
:active="active"
|
finish-status="success"
|
direction="vertical"
|
>
|
<el-step
|
v-for="(p, idx) in processes"
|
:key="p.processCode || idx"
|
>
|
<template #title>
|
<div
|
class="step-title"
|
:class="{ selected: idx === selectedIndex }"
|
@click.stop="selectProcess(idx)"
|
>
|
{{ `${idx + 1}. ${p.processName || "-"}` }}
|
</div>
|
</template>
|
<template #description>
|
<div
|
class="step-panel"
|
:class="{
|
'step-panel-selected': idx === selectedIndex,
|
'step-panel-current': idx === active,
|
}"
|
@click="selectProcess(idx)"
|
>
|
<span v-if="idx === active" class="step-current-badge">生产中</span>
|
<div v-if="p.status !== 'wait'" class="current-progress">
|
<div class="current-progress-head">
|
<span class="current-progress-title">工序进度</span>
|
<!-- <span class="current-progress-value">{{ processPercentage(p) }}%</span> -->
|
</div>
|
<el-progress
|
:percentage="processPercentage(p)"
|
:color="progressColor(processPercentage(p))"
|
:status="processPercentage(p) >= 100 ? 'success' : ''"
|
:stroke-width="10"
|
/>
|
</div>
|
<div class="step-meta">
|
<span class="meta-item">
|
<span class="meta-label">工序编号</span>
|
<span class="meta-value">{{ p.processCode || "-" }}</span>
|
</span>
|
<span class="meta-item">
|
<span class="meta-label">不良率</span>
|
<span class="meta-value danger">{{ defectRateText(p) }}</span>
|
</span>
|
</div>
|
<div class="step-grid">
|
<div class="grid-item">
|
<div class="grid-label">投入数量</div>
|
<div class="grid-value">{{ p.inputQty ?? 0 }}</div>
|
</div>
|
<div class="grid-item">
|
<div class="grid-label">产出数量</div>
|
<div class="grid-value">{{ p.outputQty ?? 0 }}</div>
|
</div>
|
<div class="grid-item">
|
<div class="grid-label">合格数量</div>
|
<div class="grid-value success">{{ p.qualifiedQty ?? 0 }}</div>
|
</div>
|
<div class="grid-item">
|
<div class="grid-label">不良数量</div>
|
<div class="grid-value danger">{{ p.badQty ?? 0 }}</div>
|
</div>
|
</div>
|
</div>
|
</template>
|
</el-step>
|
</el-steps>
|
</div>
|
</div>
|
|
<div class="steps-right">
|
<div class="right-panel">
|
<div class="right-panel-head">
|
<div class="right-title">报工信息</div>
|
<div class="right-sub" v-if="selectedProcess">
|
当前工序:{{ selectedProcess.processName }}({{ selectedProcess.processCode }})
|
</div>
|
</div>
|
|
<div v-if="!selectedProcess" class="right-empty">
|
暂无工序数据。
|
</div>
|
|
<div v-else class="right-content">
|
<el-table :data="reports" border height="420" v-loading="reportLoading">
|
<el-table-column label="序号" type="index" width="60" align="center" />
|
<el-table-column label="报工单号" prop="reportNo" min-width="140" show-overflow-tooltip />
|
<el-table-column label="报工人员" prop="reportUser" min-width="120" show-overflow-tooltip />
|
<el-table-column label="报工时间" prop="reportTime" min-width="160" show-overflow-tooltip />
|
<el-table-column label="产出数量" prop="outputQty" min-width="110" />
|
<el-table-column label="合格数量" prop="qualifiedQty" min-width="110" />
|
<el-table-column label="不良数量" prop="badQty" min-width="110" />
|
<el-table-column label="不合格处理" prop="remark" min-width="160" show-overflow-tooltip />
|
<el-table-column label="操作" width="150" fixed="right">
|
<template #default="{ row }">
|
<el-button type="primary" link @click="viewReportRecord(row)">
|
生产记录
|
</el-button>
|
</template>
|
</el-table-column>
|
</el-table>
|
</div>
|
</div>
|
</div>
|
</div>
|
</el-card>
|
<el-dialog
|
v-model="reportRecordDialogVisible"
|
title="报工生产记录"
|
width="680px"
|
destroy-on-close
|
>
|
<div class="report-record-placeholder">
|
<div>报工单号:{{ currentReportRow?.reportNo || "-" }}</div>
|
<div>工序:{{ selectedProcess?.processName || "-" }}</div>
|
<div class="placeholder-tip">弹框内容待定(后续补充详细生产记录)。</div>
|
</div>
|
<template #footer>
|
<el-button @click="reportRecordDialogVisible = false">关闭</el-button>
|
</template>
|
</el-dialog>
|
</div>
|
</template>
|
|
<script setup>
|
import { computed, onMounted, ref, watch } from "vue";
|
import { useRoute } from "vue-router";
|
import { getByProductOrderId } from "@/api/productionManagement/workOrder.js";
|
import { getByProductWorkOrderId } from "@/api/productionManagement/productionProductMain.js";
|
|
const route = useRoute();
|
|
const header = computed(() => ({
|
orderId: route.query.orderId,
|
npsNo: route.query.npsNo,
|
lotNo: route.query.lotNo,
|
productCategory: route.query.productCategory,
|
specificationModel: route.query.specificationModel,
|
}));
|
|
// 工序数据(接口替换)
|
const processes = ref([]);
|
|
const normalizeStatus = (statusText, completionStatus, inputQty, outputQty) => {
|
const s = statusText === null || statusText === undefined ? "" : String(statusText).trim();
|
const lower = s.toLowerCase();
|
|
// 常见中文状态兜底:进行中/已完成/等待
|
if (s.includes("进行") || lower.includes("process") || lower.includes("doing") || lower.includes("running")) return "process";
|
if (s.includes("完成") || s.includes("已完") || lower.includes("success") || lower.includes("done") || lower.includes("completed")) return "success";
|
if (s.includes("待") || s.includes("未开始") || lower.includes("wait") || lower.includes("pending") || lower.includes("not_start")) return "wait";
|
|
// 用 completionStatus 做兜底(一般为 0~100)
|
const cs = Number(completionStatus);
|
if (Number.isFinite(cs)) {
|
if (cs >= 100) return "success";
|
if (cs > 0) return "process";
|
return "wait";
|
}
|
|
// 最后再用数量兜底
|
if (Number.isFinite(inputQty) && inputQty > 0 && Number.isFinite(outputQty) && outputQty >= inputQty) return "success";
|
if (Number.isFinite(outputQty) && outputQty > 0) return "process";
|
return "wait";
|
};
|
|
const normalizeProcess = (item) => {
|
// 字段以接口约定为准(你给的截图字段映射)
|
// 工序:completionStatus/statusText/processNo/scrapRate/planQuantity/completeQuantity/completeQty/scrapQty
|
const inputQty = Number(item?.planQuantity ?? item?.inputQty ?? 0);
|
const outputQty = Number(item?.completeQuantity ?? item?.outputQty ?? 0);
|
const qualifiedQty = Number(item?.completeQty ?? item?.qualifiedQty ?? item?.goodQty ?? 0);
|
const badQty = Number(item?.scrapQty ?? item?.badQty ?? item?.defectQty ?? 0);
|
const completionStatus = Number(item?.completionStatus ?? 0);
|
const scrapRate = Number(item?.scrapRate ?? NaN);
|
|
const status = normalizeStatus(item?.statusText ?? item?.status ?? item?.workStatus ?? item?.processStatus ?? item?.state, completionStatus, inputQty, outputQty);
|
|
return {
|
processCode: item?.processNo ?? item?.processCode ?? item?.processWorkOrderCode ?? "",
|
processName: item?.processName ?? item?.processWorkOrderName ?? item?.processNo ?? "",
|
productWorkOrderId: item?.productWorkOrderId ?? item?.workOrderId ?? item?.id ?? null,
|
inputQty: Number.isFinite(inputQty) ? inputQty : 0,
|
outputQty: Number.isFinite(outputQty) ? outputQty : 0,
|
qualifiedQty: Math.max(0, Number.isFinite(qualifiedQty) ? qualifiedQty : 0),
|
badQty: Math.max(0, Number.isFinite(badQty) ? badQty : 0),
|
completionStatus: Number.isFinite(completionStatus) ? completionStatus : 0,
|
scrapRate: Number.isFinite(scrapRate) ? scrapRate : null,
|
status,
|
};
|
};
|
|
onMounted(async () => {
|
const productOrderId = header.value?.orderId;
|
if (!productOrderId) return;
|
|
try {
|
const res = await getByProductOrderId(productOrderId);
|
const payload = res?.data;
|
const list = Array.isArray(payload) ? payload : payload?.records || payload?.data || [];
|
processes.value = list.map((it) => normalizeProcess(it));
|
} catch (e) {
|
console.error("获取工序工单列表失败:", e);
|
processes.value = [];
|
}
|
});
|
|
// 默认选中第一道序(接口数据就绪后仍可从 0 开始)
|
const selectedIndex = ref(0);
|
|
const selectProcess = (idx) => {
|
selectedIndex.value = idx;
|
};
|
|
watch(
|
() => (processes.value || []).length,
|
(len) => {
|
if (!len) return;
|
if (selectedIndex.value >= len) selectedIndex.value = len - 1;
|
if (selectedIndex.value < 0) selectedIndex.value = 0;
|
}
|
);
|
|
const selectedProcess = computed(() => {
|
const list = processes.value || [];
|
if (!list.length) return null;
|
const raw = selectedIndex.value;
|
const idx =
|
raw === null || raw === undefined
|
? 0
|
: Math.min(Math.max(0, raw), list.length - 1);
|
return list[idx] || null;
|
});
|
|
const reports = ref([]);
|
const reportLoading = ref(false);
|
|
const normalizeReport = (item) => {
|
// 报工记录:productNo/userName/createTime/quantity/qualifiedQty/scrapQty
|
const outputQty = Number(item?.quantity ?? item?.outputQty ?? 0);
|
const qualifiedQty = Number(item?.qualifiedQty ?? item?.completeQty ?? item?.goodQty ?? 0);
|
const badQty = Number(item?.scrapQty ?? item?.badQty ?? 0);
|
|
return {
|
reportNo: item?.productNo ?? item?.reportNo ?? item?.productionReportNo ?? item?.id ?? "",
|
reportUser: item?.userName ?? item?.reportUser ?? item?.createdByName ?? item?.reportUserName ?? "",
|
reportTime: item?.createTime ?? item?.reportTime ?? item?.createdAt ?? item?.reportDate ?? "",
|
outputQty: Number.isFinite(outputQty) ? outputQty : 0,
|
qualifiedQty: Math.max(0, Number.isFinite(qualifiedQty) ? qualifiedQty : 0),
|
badQty: Number.isFinite(badQty) ? Math.max(0, badQty) : 0,
|
remark: item?.remark ?? item?.remarkText ?? item?.description ?? "",
|
};
|
};
|
|
const fetchReportsForProcess = async (p) => {
|
if (!p) {
|
reports.value = [];
|
return;
|
}
|
const productWorkOrderId = p.productWorkOrderId ?? p.id ?? p.workOrderId ?? null;
|
if (!productWorkOrderId) {
|
reports.value = [];
|
return;
|
}
|
|
reportLoading.value = true;
|
try {
|
const res = await getByProductWorkOrderId(productWorkOrderId);
|
const payload = res?.data;
|
const list = Array.isArray(payload)
|
? payload
|
: payload?.records || payload?.data || payload?.list || [];
|
reports.value = list.map((it) => normalizeReport(it));
|
} catch (e) {
|
console.error("获取报工信息失败:", e);
|
reports.value = [];
|
} finally {
|
reportLoading.value = false;
|
}
|
};
|
|
watch(
|
() => selectedProcess.value,
|
(val) => {
|
fetchReportsForProcess(val);
|
},
|
{ immediate: true }
|
);
|
|
const viewReportRecord = (row) => {
|
if (!row?.reportNo) return;
|
currentReportRow.value = row;
|
reportRecordDialogVisible.value = true;
|
};
|
|
const reportRecordDialogVisible = ref(false);
|
const currentReportRow = ref(null);
|
|
const clampPercentage = (val) => {
|
const n = Number(val);
|
if (!Number.isFinite(n)) return 0;
|
if (n <= 0) return 0;
|
if (n >= 100) return 100;
|
return Math.round(n);
|
};
|
|
// el-steps: active 为当前进行中的步骤下标(模拟)
|
const active = computed(() => {
|
const list = processes.value || [];
|
const idx = list.findIndex((p) => p.status === "process");
|
return idx >= 0 ? idx : 0;
|
});
|
|
// 工序进度:用产出/投入估算(UI 先跑通,后续按真实规则替换)
|
const processPercentage = (p) => {
|
if (!p) return 0;
|
// 优先使用接口字段 completionStatus(你给的截图“工序进度”)
|
const cs = Number(p?.completionStatus ?? NaN);
|
if (Number.isFinite(cs)) return clampPercentage(cs);
|
|
// 兜底:用产出/投入估算
|
const input = Number(p.inputQty ?? 0);
|
const output = Number(p.outputQty ?? 0);
|
if (!Number.isFinite(input) || input <= 0) return 0;
|
return clampPercentage((output / input) * 100);
|
};
|
|
// 30/50/80/100 分段颜色:红/橙/蓝/绿
|
const progressColor = (percentage) => {
|
const p = clampPercentage(percentage);
|
if (p < 30) return "#f56c6c";
|
if (p < 50) return "#e6a23c";
|
if (p < 80) return "#409eff";
|
return "#67c23a";
|
};
|
|
// 不良率:不良数量 / 产出数量(先按此口径,后续对接接口可调整)
|
const defectRateText = (p) => {
|
// 优先使用接口字段 scrapRate(你给的截图“不良率”)
|
const scrapRate = Number(p?.scrapRate ?? NaN);
|
if (Number.isFinite(scrapRate)) {
|
// 有些接口 scrapRate 可能是 0~1 或 0~100,这里做一个简单判断
|
const percent = scrapRate <= 1 ? scrapRate * 100 : scrapRate;
|
return `${percent.toFixed(2)}%`;
|
}
|
|
// 兜底:不良数量 / 产出数量
|
const bad = Number(p?.badQty ?? 0);
|
const output = Number(p?.outputQty ?? 0);
|
if (!Number.isFinite(bad) || bad <= 0) return "0%";
|
if (!Number.isFinite(output) || output <= 0) return "0%";
|
const rate = (bad / output) * 100;
|
return `${rate.toFixed(2)}%`;
|
};
|
</script>
|
|
<style scoped lang="scss">
|
.production-order-detail {
|
// 左侧步骤区的可视高度:随屏幕高度自适应
|
// 这里减去页面顶部(PageHeader + 基础信息卡片 + 边距等)的大致高度
|
--steps-left-height: calc(100vh - 320px);
|
|
.header {
|
display: flex;
|
flex-direction: column;
|
gap: 8px;
|
.title {
|
font-size: 16px;
|
font-weight: 600;
|
color: #303133;
|
}
|
.sub {
|
color: #606266;
|
display: flex;
|
flex-wrap: wrap;
|
row-gap: 6px;
|
}
|
}
|
|
.steps-head {
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
margin-bottom: 12px;
|
.steps-title {
|
font-size: 14px;
|
font-weight: 600;
|
color: #303133;
|
}
|
.steps-desc {
|
font-size: 12px;
|
color: #909399;
|
}
|
}
|
|
.steps-wrap {
|
padding: 4px 0 0;
|
height: 100%;
|
overflow-y: auto;
|
}
|
|
.steps-body {
|
display: flex;
|
gap: 16px;
|
align-items: flex-start;
|
}
|
.steps-left {
|
flex: 0 0 50%;
|
max-width: 50%;
|
min-width: 0;
|
height: var(--steps-left-height);
|
overflow: hidden;
|
}
|
.steps-right {
|
flex: 1;
|
min-width: 0;
|
}
|
|
.step-title {
|
cursor: pointer;
|
display: inline-flex;
|
align-items: center;
|
padding: 2px 6px;
|
border-radius: 6px;
|
transition: background-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
|
&:hover {
|
background: #f5f7fa;
|
transform: translateX(2px);
|
}
|
&.selected {
|
background: rgba(64, 158, 255, 0.18);
|
color: #409eff;
|
font-weight: 700;
|
}
|
}
|
|
.process-steps {
|
width: 100%;
|
:deep(.el-step__title) {
|
font-weight: 600;
|
}
|
:deep(.el-step__description) {
|
font-size: 12px;
|
color: #606266;
|
line-height: 18px;
|
margin-top: 8px;
|
width: 100%;
|
}
|
:deep(.el-step__main) {
|
padding-bottom: 20px;
|
width: 100%;
|
}
|
:deep(.el-step__icon.is-text) {
|
border-color: #dcdfe6;
|
}
|
:deep(.el-step.is-vertical) {
|
align-items: flex-start;
|
}
|
:deep(.el-step__head) {
|
width: 28px;
|
flex: 0 0 28px;
|
}
|
:deep(.el-step__main) {
|
flex: 1;
|
min-width: 0;
|
padding-right: 6px;
|
}
|
}
|
|
.step-panel {
|
position: relative;
|
cursor: pointer;
|
background: #f6f8fb;
|
border: 1px solid #ebeef5;
|
border-radius: 10px;
|
padding: 12px 12px 10px;
|
width: 100%;
|
max-width: none;
|
box-sizing: border-box;
|
transition: transform 0.25s ease, box-shadow 0.25s ease, border-color 0.25s ease,
|
background 0.25s ease;
|
&:hover {
|
border-color: #c6e2ff;
|
box-shadow: 0 4px 14px rgba(64, 158, 255, 0.12);
|
transform: translateY(-1px);
|
}
|
}
|
|
/* 被选中:沿用原「高亮」语义并加呼吸动画 */
|
.step-panel-selected {
|
background: #ecf5ff;
|
border-color: #409eff;
|
animation: step-panel-selected-pulse 2.2s ease-in-out infinite;
|
&:hover {
|
transform: translateY(-1px);
|
}
|
}
|
|
@keyframes step-panel-selected-pulse {
|
0%,
|
100% {
|
box-shadow: 0 0 0 2px rgba(64, 158, 255, 0.22);
|
}
|
50% {
|
box-shadow: 0 0 0 6px rgba(64, 158, 255, 0.12);
|
}
|
}
|
|
/* 当前生产中的工序:橙色主题,与选中区分;若同时选中则以选中为主,仅保留角标 */
|
.step-panel-current:not(.step-panel-selected) {
|
background: #fdf6ec;
|
border-color: #e6a23c;
|
border-left: 4px solid #e6a23c;
|
padding-left: 9px;
|
&:hover {
|
box-shadow: 0 4px 14px rgba(230, 162, 60, 0.18);
|
}
|
}
|
|
.step-current-badge {
|
position: absolute;
|
top: 8px;
|
right: 10px;
|
z-index: 1;
|
font-size: 11px;
|
font-weight: 600;
|
color: #b88230;
|
background: rgba(230, 162, 60, 0.18);
|
border: 1px solid rgba(230, 162, 60, 0.45);
|
border-radius: 4px;
|
padding: 2px 8px;
|
line-height: 1.2;
|
pointer-events: none;
|
}
|
|
.right-panel {
|
border: 1px solid #ebeef5;
|
border-radius: 10px;
|
background: #ffffff;
|
padding: 12px;
|
min-height: 520px;
|
box-sizing: border-box;
|
}
|
.right-panel-head {
|
display: flex;
|
flex-direction: column;
|
gap: 4px;
|
margin-bottom: 12px;
|
}
|
.right-title {
|
font-size: 14px;
|
font-weight: 700;
|
color: #303133;
|
}
|
.right-sub {
|
font-size: 12px;
|
color: #909399;
|
}
|
.right-empty {
|
height: 100%;
|
min-height: 460px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
color: #909399;
|
background: #fafafa;
|
border: 1px dashed #dcdfe6;
|
border-radius: 10px;
|
}
|
|
.current-progress {
|
background: #ffffff;
|
border: 1px dashed #dcdfe6;
|
border-radius: 10px;
|
padding: 10px 12px 8px;
|
margin-bottom: 10px;
|
.current-progress-head {
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 6px;
|
}
|
.current-progress-title {
|
color: #303133;
|
font-weight: 600;
|
font-size: 12px;
|
}
|
.current-progress-value {
|
color: #606266;
|
font-size: 12px;
|
font-weight: 600;
|
}
|
}
|
|
.report-record-placeholder {
|
display: flex;
|
flex-direction: column;
|
gap: 10px;
|
min-height: 120px;
|
color: #303133;
|
.placeholder-tip {
|
color: #909399;
|
}
|
}
|
|
.step-meta {
|
display: flex;
|
gap: 16px;
|
margin-bottom: 10px;
|
.meta-item {
|
display: inline-flex;
|
gap: 8px;
|
align-items: center;
|
}
|
.meta-label {
|
color: #909399;
|
}
|
.meta-value {
|
color: #303133;
|
font-weight: 600;
|
&.danger {
|
color: #f56c6c;
|
}
|
}
|
}
|
|
.step-grid {
|
display: grid;
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
gap: 10px;
|
.grid-item {
|
background: #ffffff;
|
border: 1px solid #ebeef5;
|
border-radius: 10px;
|
padding: 10px 10px 8px;
|
}
|
.grid-label {
|
font-size: 12px;
|
color: #909399;
|
margin-bottom: 6px;
|
}
|
.grid-value {
|
font-size: 16px;
|
font-weight: 700;
|
color: #303133;
|
&.success {
|
color: #67c23a;
|
}
|
&.danger {
|
color: #f56c6c;
|
}
|
}
|
}
|
|
.mb12 {
|
margin-bottom: 12px;
|
}
|
.mr12 {
|
margin-right: 12px;
|
}
|
}
|
</style>
|