480 lines
12 KiB
Vue
480 lines
12 KiB
Vue
<template>
|
||
<div class="process-card-wrap">
|
||
<div class="process-card" :class="themeClass">
|
||
<div class="card-head">
|
||
<div class="card-title-block">
|
||
<span class="card-title">
|
||
<em v-if="process.operNumber != null" class="card-oper">{{ process.operNumber }}</em>
|
||
{{ process.processName || '-' }}
|
||
</span>
|
||
<span v-if="materialCode" class="card-material">{{ materialCode }}</span>
|
||
</div>
|
||
<n-tag size="small" :bordered="false" :type="statusTag.type">{{ statusTag.label }}</n-tag>
|
||
</div>
|
||
|
||
<div class="card-progress">
|
||
<n-progress
|
||
type="circle"
|
||
:percentage="progressPct"
|
||
:stroke-width="10"
|
||
:color="progressColor"
|
||
:rail-color="railColor"
|
||
style="width: 88px"
|
||
>
|
||
<span class="progress-text">{{ progressPct }}%</span>
|
||
</n-progress>
|
||
</div>
|
||
|
||
<div class="card-stats">
|
||
<div class="stat-row">
|
||
<span class="stat-label">完成</span>
|
||
<span class="stat-value">{{ reportedQuantity }} / {{ totalQty }}</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">合格 / 不合格</span>
|
||
<span class="stat-value">
|
||
<em class="ok">{{ completedQty }}</em>
|
||
<span class="sep">/</span>
|
||
<em class="bad">{{ scrapQty }}</em>
|
||
</span>
|
||
</div>
|
||
<div class="stat-row">
|
||
<span class="stat-label">合格率</span>
|
||
<span class="stat-value highlight">{{ passRate }}%</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-flow">
|
||
<div class="flow-item">
|
||
<span>转入</span>
|
||
<strong>{{ process.transferInQty ?? 0 }}</strong>
|
||
</div>
|
||
<div class="flow-item">
|
||
<span>剩余物料</span>
|
||
<strong>{{ leftoverMaterials }}</strong>
|
||
</div>
|
||
|
||
<div class="flow-item">
|
||
<span>转出</span>
|
||
<strong>{{ process.transferOutQty ?? 0 }}</strong>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card-meta">
|
||
<div class="meta-line"><span>生产订单</span><em>{{ orderCode || '-' }}</em></div>
|
||
<div class="meta-line"><span>工单号</span><em>{{ process.workOrderCode || '-' }}</em></div>
|
||
<div class="meta-line"><span>人员</span><em>{{ personnel }}</em></div>
|
||
<div class="meta-line"><span>车间</span><em>{{ workshopText }}</em></div>
|
||
<div class="meta-line"><span>工位</span><em>{{ process.sectionName || '-' }}</em></div>
|
||
<div class="meta-line"><span>设备</span><em>{{ deviceText }}</em></div>
|
||
<div class="meta-line"><span>计划时间</span><em>{{ planTimeText }}</em></div>
|
||
<div class="meta-line"><span>实际开始</span><em>{{ process.actualStartTime || '-' }}</em></div>
|
||
</div>
|
||
|
||
<div class="card-actions">
|
||
<n-button
|
||
v-if="canAssign"
|
||
size="tiny"
|
||
type="warning"
|
||
ghost
|
||
@click="emit('assign', process)"
|
||
>派工</n-button>
|
||
<n-button
|
||
v-if="canSplit"
|
||
size="tiny"
|
||
type="primary"
|
||
ghost
|
||
@click="emit('split', process)"
|
||
>拆单</n-button>
|
||
<n-button
|
||
v-if="canEdit"
|
||
size="tiny"
|
||
ghost
|
||
@click="emit('edit', process)"
|
||
>编辑</n-button>
|
||
<n-button size="tiny" type="info" ghost @click="emit('viewModel', process)">模型</n-button>
|
||
<n-button
|
||
v-if="canOutsource"
|
||
size="tiny"
|
||
type="warning"
|
||
ghost
|
||
@click="emit('outsource', process)"
|
||
>委外</n-button>
|
||
<n-button
|
||
size="tiny"
|
||
type="info"
|
||
ghost
|
||
@click="emit('submitDetail', process)"
|
||
>汇报</n-button>
|
||
<n-button
|
||
:disabled="refillDisAbled"
|
||
size="tiny"
|
||
type="info"
|
||
ghost
|
||
@click="emit('handleLeftoverMaterial', process)"
|
||
>补料</n-button>
|
||
<n-button
|
||
size="tiny"
|
||
:type="process.processStatus === 4 ? 'success' : 'default'"
|
||
:ghost="process.processStatus !== 4"
|
||
disabled
|
||
>{{ process.processStatus === 4 ? '已完成' : '进行中' }}</n-button>
|
||
</div>
|
||
</div>
|
||
<div v-if="!isLast" class="card-chevron">
|
||
<n-icon size="28" color="#c0c4cc"><ChevronForwardOutline /></n-icon>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import {computed, onMounted, ref} from 'vue'
|
||
import { NButton, NIcon, NProgress, NTag } from 'naive-ui'
|
||
import { ChevronForwardOutline } from '@vicons/ionicons5'
|
||
import { PROCESS_STATUS_MAP, type ProcessPlanItemVO } from '@/api/orderProcessPlan'
|
||
import { refillRecordApi } from '@/api/refillRecord.ts'
|
||
|
||
|
||
const props = defineProps<{
|
||
process: ProcessPlanItemVO
|
||
/** 物料编码(订单头) */
|
||
materialCode?: string
|
||
/** 生产订单编号(订单头 orderCode) */
|
||
orderCode?: string
|
||
/** 订单计划开始(订单头 beginTime,工序无计划时间时回退) */
|
||
orderBeginTime?: string
|
||
/** 订单计划结束(订单头 endTime) */
|
||
orderEndTime?: string
|
||
/** 生产车间名称(订单头 workshopName) */
|
||
workshopName?: string
|
||
isLast?: boolean
|
||
canEdit?: boolean
|
||
canAssign?: boolean
|
||
canSplit?: boolean
|
||
canOutsource?: boolean
|
||
canSubmitDetail?: boolean
|
||
}>()
|
||
|
||
const emit = defineEmits<{
|
||
edit: [process: ProcessPlanItemVO]
|
||
assign: [process: ProcessPlanItemVO]
|
||
split: [process: ProcessPlanItemVO]
|
||
viewModel: [process: ProcessPlanItemVO]
|
||
outsource: [process: ProcessPlanItemVO]
|
||
submitDetail: [process: ProcessPlanItemVO]
|
||
handleLeftoverMaterial: [process: ProcessPlanItemVO]
|
||
}>()
|
||
|
||
const reportedQuantity = computed(() => props.process.reportedQuantity ?? 0)
|
||
const totalQty = computed(() => props.process.quantity ?? 0)
|
||
const completedQty = computed(() => props.process.completedQty ?? 0)
|
||
const scrapQty = computed(() => props.process.scrapQty ?? 0)
|
||
const qualifiedQty = computed(() => Math.max(0, completedQty.value - scrapQty.value))
|
||
const leftoverMaterials = computed(() => props.process.leftoverMaterials ?? 0) //剩余物料
|
||
|
||
|
||
const progressPct = computed(() => {
|
||
if (!totalQty.value) return 0
|
||
return Math.min(100, Math.round((completedQty.value / totalQty.value) * 100))
|
||
})
|
||
|
||
const passRate = computed(() => {
|
||
if (!completedQty.value) return progressPct.value
|
||
return Math.round((qualifiedQty.value / completedQty.value) * 100)
|
||
})
|
||
|
||
// const remainQty = computed(() => Math.max(0, totalQty.value - completedQty.value))
|
||
|
||
const statusTag = computed(() => {
|
||
const s = props.process.processStatus
|
||
if (s == null) return { label: '-', type: 'default' as const }
|
||
const m = PROCESS_STATUS_MAP[s]
|
||
return m ? { label: m.label, type: m.type } : { label: String(s), type: 'default' as const }
|
||
})
|
||
|
||
const themeClass = computed(() => {
|
||
const s = props.process.processStatus
|
||
if (s === 4) return 'theme-done'
|
||
if (s != null && s >= 2) return 'theme-active'
|
||
return 'theme-pending'
|
||
})
|
||
|
||
const progressColor = computed(() => {
|
||
if (themeClass.value === 'theme-done') return '#18a058'
|
||
if (themeClass.value === 'theme-active') return '#2080f0'
|
||
return '#d9d9d9'
|
||
})
|
||
|
||
const railColor = computed(() => {
|
||
if (themeClass.value === 'theme-done') return 'rgba(24,160,88,0.15)'
|
||
if (themeClass.value === 'theme-active') return 'rgba(32,128,240,0.12)'
|
||
return '#f0f0f0'
|
||
})
|
||
|
||
const personnel = computed(() => {
|
||
if (props.process.assignByName) return props.process.assignByName
|
||
const list = props.process.assignWorkList
|
||
if (list?.length) {
|
||
const names = list
|
||
.map((a: any) => a.userName || a.userId)
|
||
.filter(Boolean)
|
||
if (names.length) return names.join('、')
|
||
return `${list.length} 人派工`
|
||
}
|
||
return '-'
|
||
})
|
||
|
||
const workshopText = computed(() => {
|
||
return props.process.workshopName || props.workshopName || '-'
|
||
})
|
||
|
||
const deviceText = computed(() => {
|
||
const list = props.process.assignWorkList as any[] | undefined
|
||
if (!list?.length) return '-'
|
||
const devices = list
|
||
.map((a) => a.deviceName || a.deviceId)
|
||
.filter(Boolean)
|
||
return devices.length ? [...new Set(devices)].join('、') : '-'
|
||
})
|
||
|
||
const planTimeText = computed(() => {
|
||
// 工序计划时间优先;没有则回退订单头 beginTime/endTime
|
||
const start = props.process.planStartTime || props.process.beginTime || props.orderBeginTime
|
||
const end = props.process.planFinishTime || props.process.endTime || props.orderEndTime
|
||
if (!start && !end) return '-'
|
||
if (start && end) {
|
||
const startText = start.length >= 16 ? start.slice(0, 16) : start
|
||
const endText = end.length >= 16 ? end.slice(11, 16) : end
|
||
return `${startText} ~ ${endText}`
|
||
}
|
||
return start || end || '-'
|
||
})
|
||
|
||
const refillDisAbled = ref<Boolean>(false);
|
||
|
||
async function geRefillRecord() {
|
||
//查询补料记录
|
||
const res = await refillRecordApi.getRefillNumByProcessId(props.process.planId);
|
||
if (res >= props.process.scrapQty){
|
||
refillDisAbled.value = true
|
||
}
|
||
refillDisAbled.value = false
|
||
}
|
||
|
||
onMounted(()=>{
|
||
geRefillRecord()
|
||
})
|
||
|
||
</script>
|
||
|
||
<style scoped>
|
||
.process-card-wrap {
|
||
display: flex;
|
||
align-items: center;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.process-card {
|
||
width: 220px;
|
||
border-radius: 8px;
|
||
border: 1px solid #e8e8e8;
|
||
background: #fff;
|
||
overflow: hidden;
|
||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
|
||
transition: box-shadow 0.2s, transform 0.2s;
|
||
}
|
||
|
||
.process-card:hover {
|
||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08);
|
||
transform: translateY(-1px);
|
||
}
|
||
|
||
.theme-done {
|
||
border-color: rgba(24, 160, 88, 0.35);
|
||
}
|
||
|
||
.theme-done .card-head {
|
||
background: linear-gradient(135deg, #18a058 0%, #36ad6a 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.theme-done .card-head :deep(.n-tag) {
|
||
background: rgba(255, 255, 255, 0.25) !important;
|
||
color: #fff !important;
|
||
}
|
||
|
||
.theme-active .card-head {
|
||
background: linear-gradient(135deg, #2080f0 0%, #4098fc 100%);
|
||
color: #fff;
|
||
}
|
||
|
||
.theme-active .card-head :deep(.n-tag) {
|
||
background: rgba(255, 255, 255, 0.25) !important;
|
||
color: #fff !important;
|
||
}
|
||
|
||
.theme-pending .card-head {
|
||
background: #f5f5f5;
|
||
color: #595959;
|
||
}
|
||
|
||
.card-head {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
gap: 8px;
|
||
padding: 8px 10px;
|
||
min-height: 36px;
|
||
}
|
||
|
||
.card-title {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-oper {
|
||
font-style: normal;
|
||
font-weight: 700;
|
||
margin-right: 4px;
|
||
opacity: 0.95;
|
||
}
|
||
|
||
.card-title-block {
|
||
min-width: 0;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
}
|
||
|
||
.card-material {
|
||
font-size: 11px;
|
||
font-weight: 500;
|
||
opacity: 0.85;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-progress {
|
||
display: flex;
|
||
justify-content: center;
|
||
padding: 12px 0 8px;
|
||
}
|
||
|
||
.progress-text {
|
||
font-size: 14px;
|
||
font-weight: 700;
|
||
color: #333;
|
||
}
|
||
|
||
.card-stats {
|
||
padding: 0 12px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 4px;
|
||
}
|
||
|
||
.stat-row {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.stat-label {
|
||
color: #8c8c8c;
|
||
}
|
||
|
||
.stat-value {
|
||
color: #333;
|
||
font-weight: 500;
|
||
}
|
||
|
||
.stat-value .ok {
|
||
color: #18a058;
|
||
font-style: normal;
|
||
}
|
||
|
||
.stat-value .bad {
|
||
color: #d03050;
|
||
font-style: normal;
|
||
}
|
||
|
||
.stat-value .sep {
|
||
margin: 0 2px;
|
||
color: #bfbfbf;
|
||
}
|
||
|
||
.stat-value.highlight {
|
||
color: #18a058;
|
||
}
|
||
|
||
.card-flow {
|
||
display: flex;
|
||
margin: 0 12px 8px;
|
||
padding: 8px;
|
||
background: #fafafa;
|
||
border-radius: 6px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.flow-item {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
font-size: 11px;
|
||
color: #8c8c8c;
|
||
}
|
||
|
||
.flow-item strong {
|
||
font-size: 14px;
|
||
color: #333;
|
||
}
|
||
|
||
.card-meta {
|
||
padding: 0 12px 8px;
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 3px;
|
||
}
|
||
|
||
.meta-line {
|
||
display: flex;
|
||
justify-content: space-between;
|
||
font-size: 11px;
|
||
gap: 8px;
|
||
}
|
||
|
||
.meta-line span {
|
||
color: #8c8c8c;
|
||
flex-shrink: 0;
|
||
}
|
||
|
||
.meta-line em {
|
||
font-style: normal;
|
||
color: #595959;
|
||
text-align: right;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
|
||
.card-actions {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 4px;
|
||
padding: 8px 10px 10px;
|
||
border-top: 1px solid #f0f0f0;
|
||
}
|
||
|
||
.card-chevron {
|
||
padding: 0 4px;
|
||
display: flex;
|
||
align-items: center;
|
||
opacity: 0.7;
|
||
}
|
||
</style>
|