完善生产异常维修与备件管理功能

This commit is contained in:
2026-09-01 19:17:09 +08:00
parent 02a429af66
commit 32f1486f5e
49 changed files with 1765 additions and 121 deletions
@@ -39,43 +39,57 @@
<div class="form-actions"><el-button size="large" @click="resetForm">重置</el-button><el-button type="primary" size="large" :loading="submitting" @click="completeRepair">完成维修</el-button></div>
</main>
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>姓名</span><strong>{{ currentUser.name || '-' }}</strong></div><div><span>工号</span><strong>{{ currentUser.code || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div></section><section class="panel tip-panel"><h2>处理提示</h2><p>物料不良请登记替换备件;作业不良需选择责任人员;搭配错误需选择对应物料分类。</p><el-button link type="primary" @click="goTasks">查看待维修订单</el-button></section></aside>
<aside class="side-column"><section class="panel operator-panel"><h2>当前操作人</h2><div><span>用户编码</span><el-input v-model="userCode" size="small" clearable placeholder="请输入用户编码" @blur="checkUserCode" @keyup.enter="checkUserCode" /></div><div><span>姓名</span><strong>{{ userName || '-' }}</strong></div><div><span>当前任务</span><strong>{{ task?.device_no || '未选择' }}</strong></div><p v-if="userCodeError" class="field-error">{{ userCodeError }}</p></section><section class="panel tip-panel"><h2>处理提示</h2><p>物料不良请登记替换备件;作业不良需选择责任人员;搭配错误需选择对应物料分类。</p><el-button link type="primary" @click="goTasks">查看待维修订单</el-button></section></aside>
</div>
</div>
<el-dialog v-model="partDialogVisible" title="添加维修备件" width="460px"><el-input v-model="partCode" autofocus placeholder="请输入商品编码" @keyup.enter="addPart" /><template #footer><el-button @click="partDialogVisible = false">取消</el-button><el-button type="primary" :loading="loadingPart" @click="addPart">确定</el-button></template></el-dialog>
<el-dialog v-model="partDialogVisible" title="选择维修备件" width="760px" class="material-dialog" @closed="resetMaterialSearch"><div class="material-search"><el-input v-model="materialProductCode" clearable autofocus placeholder="商品编码" @keyup.enter="searchMaterialList" /><el-input v-model="materialProductName" clearable placeholder="商品名称" @keyup.enter="searchMaterialList" /><el-select v-model="materialCategoryId" clearable filterable placeholder="物料分类" @change="searchMaterialList"><el-option v-for="item in categories" :key="item.id" :label="item.category_name" :value="String(item.id)" /></el-select><el-button type="primary" :loading="materialLoading" @click="searchMaterialList">搜索</el-button></div><div class="material-hint">可按编码、名称或物料分类筛选,已添加的备件会自动标记。</div><el-table v-loading="materialLoading" :data="materialItems" border stripe class="material-table"><el-table-column prop="product_code" label="商品编码" min-width="180" /><el-table-column prop="product_name" label="商品名称" min-width="220" show-overflow-tooltip /><el-table-column prop="category_name" label="物料分类" min-width="140"><template #default="scope">{{ scope.row.category_name || '-' }}</template></el-table-column><el-table-column label="操作" width="100" fixed="right"><template #default="scope"><el-button link type="primary" :disabled="isPartAdded(scope.row.product_code)" @click="selectMaterial(scope.row)">{{ isPartAdded(scope.row.product_code) ? '已添加' : '选择' }}</el-button></template></el-table-column></el-table><el-empty v-if="!materialLoading && !materialItems.length" description="暂无匹配的备件" :image-size="72" /><div class="material-pagination"><el-pagination v-model:current-page="materialPage" v-model:page-size="materialLimit" layout="total, prev, pager, next" :total="materialTotal" :page-sizes="[10, 20, 50]" @current-change="searchMaterialList" @size-change="handleMaterialSizeChange" /></div><template #footer><el-button @click="partDialogVisible = false">关闭</el-button></template></el-dialog>
</div>
</template>
<script setup lang="ts">
import { CircleCheckFilled, Grid, EditPen, Tools, Box } from '@element-plus/icons-vue'
import { CircleCheckFilled, Grid, EditPen, Tools, Box, Search } from '@element-plus/icons-vue'
import { ElMessage } from 'element-plus'
import type { UploadUserFile } from 'element-plus'
import { computed, onMounted, ref } from 'vue'
import { navigateTo, useRoute } from 'nuxt/app'
import { useMemberStore } from '~/stores/member'
import { getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, lookupMaterial, submitRepairRecord, uploadProduceImage, type ProduceRepairTask } from '~/apps/produce/api/exception'
import { checkMemberUser, getFaultDescriptionOptions, getMaterialCategories, getRepairStatistics, getRepairTask, getScanRecords, searchMaterials, submitRepairRecord, uploadProduceImage, type ProduceRepairTask, type ProduceScanRecord } from '~/apps/produce/api/exception'
interface Part { id: string; product_code: string; product_name?: string; category_id?: string | number; category_name?: string; source: string; quantity: number; fault_description_ids: string[] }
interface ScanRecord { id: string | number; process_name?: string; step_name?: string; operator?: string; user_code?: string }
interface FaultDescription { id: string | number; category_id: string | number; description: string }
interface MaterialOption { id: string | number; product_code: string; product_name: string; category_id: string | number; category_name: string }
const memberStore = useMemberStore()
const currentUser = computed(() => ({ name: String(memberStore.info?.nickname || memberStore.info?.real_name || memberStore.info?.username || ''), code: String(memberStore.info?.username || memberStore.info?.user_code || '') }))
const userCode = ref('')
const userName = ref('')
const userCodeError = ref('')
const summaries = ref([{ label: '待维修', value: 0, type: 'warning', action: true }, { label: '处理中', value: 0, type: 'primary' }, { label: '今日完成', value: 0, type: 'success' }])
const resultOptions = [{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: Box }, { label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: EditPen }, { label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: Grid }]
const deviceNo = ref('')
const task = ref<ProduceRepairTask | null>(null)
const categories = ref<Array<{ id: string | number; category_name: string }>>([])
const scanRecords = ref<ScanRecord[]>([])
const scanRecords = ref<ProduceScanRecord[]>([])
const faults = ref<FaultDescription[]>([])
const imageFiles = ref<UploadUserFile[]>([])
const form = ref({ repair_result: 'material_defect', category_ids: [] as string[], responsible_ids: [] as string[], parts: [] as Part[], remark: '' })
const loadingTask = ref(false)
const loadingPart = ref(false)
const submitting = ref(false)
const partDialogVisible = ref(false)
const partCode = ref('')
const materialProductCode = ref('')
const materialProductName = ref('')
const materialCategoryId = ref<string | number>('')
const materialItems = ref<MaterialOption[]>([])
const materialPage = ref(1)
const materialLimit = ref(10)
const materialTotal = ref(0)
const materialLoading = ref(false)
const reasonText = computed(() => [task.value?.reason_name, task.value?.issue_name].filter(Boolean).join(' · ') || '-')
const checkUserCode = async () => {
const value = userCode.value.trim()
if (!value) { userCodeError.value = '请输入用户编码'; userName.value = ''; return false }
try { const result = await checkMemberUser(value); userName.value = result?.nickname || result?.username || ''; userCodeError.value = ''; return true } catch (error: any) { userCodeError.value = error?.message || '用户编码不存在'; userName.value = ''; return false }
}
const loadStatistics = async () => {
const data = await getRepairStatistics({ user_code: currentUser.value.code }).catch(() => null)
@@ -88,18 +102,38 @@ const loadStatistics = async () => {
}
const loadTask = async () => { const value = deviceNo.value.trim(); if (!value || loadingTask.value) return ElMessage.warning('请输入设备编码或订单号'); loadingTask.value = true; try { task.value = await getRepairTask(value); form.value.responsible_ids = []; scanRecords.value = []; if (task.value?.device_no) { const result = await getScanRecords({ order_no: task.value.device_no, limit: 50 }); scanRecords.value = Array.isArray(result?.items) ? result.items : []; } } finally { loadingTask.value = false } }
const selectResult = (value: string) => { form.value.repair_result = value; if (value !== 'assembly_mismatch') form.value.category_ids = []; if (value !== 'operation_defect') form.value.responsible_ids = [] }
const openPartDialog = () => { partCode.value = ''; partDialogVisible.value = true }
const addPart = async () => { const code = partCode.value.trim(); if (!code) return ElMessage.warning('请输入商品编码'); if (form.value.parts.some((item: Part) => item.product_code === code)) return ElMessage.warning('该物料已添加'); loadingPart.value = true; try { const material = await lookupMaterial(code); const part: Part = { id: `${Date.now()}-${code}`, product_code: material.product_code || code, product_name: material.product_name, category_id: material.category_id, category_name: material.category_name, source: 'new', quantity: 1, fault_description_ids: [] }; form.value.parts.push(part); await loadFaults(); partDialogVisible.value = false } finally { loadingPart.value = false } }
const openPartDialog = () => { materialProductCode.value = ''; materialProductName.value = ''; materialCategoryId.value = ''; materialPage.value = 1; partDialogVisible.value = true; void searchMaterialList() }
const searchMaterialList = async () => { materialLoading.value = true; try { const result = await searchMaterials(materialProductCode.value, materialProductName.value, materialCategoryId.value, materialPage.value, materialLimit.value); materialItems.value = Array.isArray(result?.items) ? result.items : []; materialTotal.value = Number(result?.total || 0) } finally { materialLoading.value = false } }
const handleMaterialSizeChange = (size: number) => { materialLimit.value = size; materialPage.value = 1; void searchMaterialList() }
const isPartAdded = (productCode: string) => form.value.parts.some((item: Part) => item.product_code === productCode)
const appendMaterial = async (material: MaterialOption) => { if (isPartAdded(material.product_code)) return ElMessage.warning('该物料已添加'); const part: Part = { id: `${Date.now()}-${material.product_code}`, product_code: material.product_code, product_name: material.product_name, category_id: material.category_id, category_name: material.category_name, source: 'new', quantity: 1, fault_description_ids: [] }; form.value.parts.push(part); await loadFaults(); ElMessage.success('备件已添加') }
const selectMaterial = (material: MaterialOption) => { void appendMaterial(material) }
const resetMaterialSearch = () => { materialProductCode.value = ''; materialProductName.value = ''; materialCategoryId.value = ''; materialItems.value = []; materialPage.value = 1; materialTotal.value = 0 }
const loadFaults = async () => { const ids = [...new Set(form.value.parts.map((item: Part) => String(item.category_id || '')).filter(Boolean))]; faults.value = ids.length ? await getFaultDescriptionOptions(ids) : [] }
const faultsForPart = (part: Part) => faults.value.filter((item: FaultDescription) => String(item.category_id) === String(part.category_id))
const removePart = (index: number) => { form.value.parts.splice(index, 1); void loadFaults() }
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
const resetForm = () => { deviceNo.value = ''; task.value = null; scanRecords.value = []; form.value = { repair_result: 'material_defect', category_ids: [], responsible_ids: [], parts: [], remark: '' }; imageFiles.value = [] }
const completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!currentUser.value.code) return ElMessage.warning('请先登录'); if (form.value.repair_result === 'assembly_mismatch' && !form.value.category_ids.length) return ElMessage.warning('请选择搭配错误分类'); if (form.value.repair_result === 'operation_defect' && !form.value.responsible_ids.length) return ElMessage.warning('请选择责任人员'); submitting.value = true; try { const images: string[] = []; for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'repair'); if (url) images.push(url) } const response = await submitRepairRecord({ exception_id: task.value.id, user_code: currentUser.value.code, repair_result: form.value.repair_result, images, category_ids: form.value.category_ids, parts: form.value.parts.map((part: Part) => ({ product_code: part.product_code, source: part.source, quantity: part.quantity, fault_description_ids: part.fault_description_ids })), operation_fault_steps: form.value.responsible_ids, responsible_operators: form.value.responsible_ids, remark: form.value.remark.trim() }); if (response?.code !== 0) { ElMessage.error(response?.msg || '维修提交失败'); return } ElMessage.success('维修记录已完成'); resetForm(); void loadStatistics() } finally { submitting.value = false } }
const completeRepair = async () => { if (!task.value?.id) return ElMessage.warning('请先查询有效的维修任务'); if (!await checkUserCode()) return ElMessage.warning(userCodeError.value); if (form.value.repair_result === 'assembly_mismatch' && !form.value.category_ids.length) return ElMessage.warning('请选择搭配错误分类'); if (form.value.repair_result === 'operation_defect' && !form.value.responsible_ids.length) return ElMessage.warning('请选择责任人员'); submitting.value = true; try { const images: string[] = []; for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'repair'); if (url) images.push(url) } const selectedScanIds = scanRecords.value.filter(record => form.value.responsible_ids.includes(String(record.id))).map(record => record.id); const response = await submitRepairRecord({ exception_id: task.value.id, user_code: userCode.value.trim(), repair_result: form.value.repair_result, images, category_ids: form.value.category_ids, parts: form.value.parts.map((part: Part) => ({ product_code: part.product_code, source: part.source, quantity: part.quantity, fault_description_ids: part.fault_description_ids })), operation_fault_steps: selectedScanIds, responsible_operators: selectedScanIds, remark: form.value.remark.trim() }); if (response?.code !== 0) { ElMessage.error(response?.msg || '维修提交失败'); return } ElMessage.success('维修记录已完成'); resetForm(); void loadStatistics() } finally { submitting.value = false } }
const goTasks = () => navigateTo('/produce/repair/tasks')
onMounted(() => { void loadStatistics(); void getMaterialCategories().then((data: Array<{ id: string | number; category_name: string }>) => { categories.value = Array.isArray(data) ? data : [] }); const query = useRoute().query.device_no; if (query) { deviceNo.value = String(query); void loadTask() } })
onMounted(() => { userCode.value = currentUser.value.code; userName.value = currentUser.value.name; void checkUserCode(); void loadStatistics(); void getMaterialCategories().then((data: Array<{ id: string | number; category_name: string }>) => { categories.value = Array.isArray(data) ? data : [] }); const query = useRoute().query.device_no; if (query) { deviceNo.value = String(query); void loadTask() } })
</script>
<style scoped>
.produce-work-page{min-height:calc(100vh - 60px);padding:28px;background:#f5f7fb;color:#17233d}.page-shell{max-width:1440px;margin:0 auto}.page-hero{display:flex;align-items:center;justify-content:space-between;padding:32px 36px;border-radius:22px;color:#fff;box-shadow:0 14px 32px rgba(37,99,235,.12)}.repair-hero{background:linear-gradient(135deg,#2f6cf6,#35bfa3)}.eyebrow{font-size:13px;opacity:.85}h1{margin:8px 0 0;font-size:30px}.page-hero p{margin:8px 0 0;opacity:.86}.summary-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:16px;margin:20px 0}.summary-card,.panel{background:#fff;border:1px solid #e7edf5;border-radius:18px;box-shadow:0 8px 24px rgba(36,59,92,.04)}.summary-card{display:flex;align-items:center;gap:12px;padding:18px 22px}.summary-card strong{font-size:28px}.summary-card span{color:#8a94a6;font-size:13px}.summary-card .el-button{margin-left:auto}.warning{color:#e59a2f}.primary{color:#2563eb}.success{color:#26b36c}.content-grid{display:grid;grid-template-columns:minmax(0,1fr) 350px;gap:20px;align-items:start}.main-column,.side-column{display:flex;flex-direction:column;gap:20px}.panel{padding:26px}.panel-title{display:flex;align-items:flex-start;gap:14px}.panel-title>span{display:flex;align-items:center;justify-content:center;width:30px;height:30px;border-radius:50%;background:#eaf2ff;color:#2563eb;font-weight:700;flex:none}.panel-title h2{margin:0;font-size:17px}.panel-title p{margin:6px 0 0;color:#9aa4b4;font-size:13px}.panel-title em{margin-left:auto;padding:5px 10px;border-radius:999px;color:#2563eb;background:#edf4ff;font-size:12px;font-style:normal}.panel-title em.optional{color:#8793a6;background:#f3f5f8}.panel-title small{margin-left:auto;color:#9aa4b4}.inline-form{display:flex;gap:12px;margin-top:20px}.inline-form .el-input{flex:1}.task-found{display:flex;align-items:center;gap:12px;margin-top:16px;padding:14px;border-radius:12px;background:#f0fbf6;color:#26b36c}.task-found>div{display:flex;flex-direction:column;gap:4px;flex:1}.task-found span{color:#2b3853;font-weight:600}.task-found .el-tag{margin-left:auto}.info-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:22px}.info-grid div{padding:14px;border-radius:12px;background:#f8fafc}.info-grid span,.info-grid strong{display:block}.info-grid span{color:#9aa4b4;font-size:12px}.info-grid strong{margin-top:8px;font-size:14px;word-break:break-all}.result-grid{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin-top:20px}.result-card{display:flex;flex-direction:column;align-items:flex-start;gap:8px;padding:18px;border:1px solid #e6edf5;border-radius:14px;background:#f8fafc;color:#56647a;text-align:left;cursor:pointer}.result-card.active{border-color:#409eff;background:#edf4ff;color:#2563eb}.result-card small{color:#9aa4b4}.category-grid,.responsibility-grid{display:flex;flex-wrap:wrap;gap:14px;margin-top:22px}.responsibility-grid .el-checkbox{height:auto;margin:0;padding:12px 16px;border:1px solid #e6edf5;border-radius:12px;background:#f8fafc}.responsibility-grid .el-checkbox.is-checked{border-color:#409eff;background:#edf4ff}.responsibility-grid small{display:block;color:#9aa4b4}.part-toolbar{display:flex;justify-content:space-between;align-items:center;margin:22px 0 12px}.part-row{display:flex;align-items:flex-start;gap:12px;padding:16px 0;border-bottom:1px solid #eef2f7}.part-index{display:flex;align-items:center;justify-content:center;width:28px;height:28px;border-radius:50%;background:#edf4ff;color:#2563eb}.part-info{display:flex;flex-direction:column;gap:8px;flex:1}.part-info>span{color:#8a94a6;font-size:12px}.fault-select{width:100%}.remark-input{margin-top:18px}.form-actions{display:flex;justify-content:flex-end;gap:12px}.operator-panel h2,.tip-panel h2{margin:0 0 18px}.operator-panel div{display:flex;justify-content:space-between;padding:13px 0;border-bottom:1px solid #eef2f7;font-size:13px}.operator-panel div:last-child{border:0}.operator-panel span{color:#9aa4b4}.tip-panel p{color:#718096;font-size:13px;line-height:1.8}.upload-plus{font-size:30px;color:#409eff}:deep(.el-upload--picture-card),:deep(.el-upload-list__item){width:92px;height:92px}@media(max-width:900px){.produce-work-page{padding:16px}.content-grid{grid-template-columns:1fr}.side-column{display:grid;grid-template-columns:repeat(2,minmax(0,1fr))}}@media(max-width:640px){.page-hero{padding:24px;align-items:flex-start;flex-direction:column;gap:18px}.summary-grid,.side-column,.info-grid,.result-grid{grid-template-columns:1fr}.inline-form,.form-actions{flex-direction:column}.form-actions .el-button{width:100%}.part-row{flex-wrap:wrap}}
.operator-panel > div:first-of-type { align-items: center; gap: 16px; }
.operator-panel > div:first-of-type > span { flex: 0 0 72px; white-space: nowrap; }
.operator-panel > div:first-of-type :deep(.el-input) { flex: 1; min-width: 0; }
.operator-panel > div:first-of-type :deep(.el-input__wrapper) { min-height: 40px; padding: 0 13px; border-radius: 9px; box-shadow: 0 0 0 1px #d8e2ef inset; }
.operator-panel > div:first-of-type :deep(.el-input__inner) { font-size: 15px; font-weight: 600; }
.operator-panel > div:not(:first-of-type) { min-height: 44px; align-items: center; }
.responsibility-grid .el-checkbox__label { gap: 10px; line-height: 1.25; }
.responsibility-grid .el-checkbox__label > span { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.responsibility-grid small { line-height: 1.35; }
.material-search { display: flex; gap: 12px; margin-bottom: 10px; }
.material-search .el-input { flex: 1; min-width: 0; }
.material-search .el-select { width: 170px; flex: none; }
.material-hint { margin-bottom: 12px; color: #8a94a6; font-size: 13px; }
.material-table { width: 100%; }
.material-pagination { display: flex; justify-content: flex-end; margin-top: 16px; }
</style>