Files
MrleeAdmin/frontend/web/app/apps/produce/pages/repair/index.vue
T

140 lines
24 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<div class="produce-work-page repair-page">
<div class="page-shell">
<section class="page-hero repair-hero">
<div><span class="eyebrow">维修工作台</span><h1>维修处理</h1><p>扫描设备,快速完成故障判断、备件登记与维修记录。</p></div>
<el-icon :size="44"><Tools /></el-icon>
</section>
<section class="summary-grid">
<div v-for="item in summaries" :key="item.label" class="summary-card"><strong :class="item.type">{{ item.value }}</strong><span>{{ item.label }}</span><el-button v-if="item.action" link type="primary" @click="goTasks">查看任务</el-button></div>
</section>
<div class="content-grid">
<main class="main-column">
<section class="panel">
<div class="panel-title"><span>1</span><div><h2>扫描或录入设备</h2><p>支持订单号、整机编码或设备条码</p></div><em>必填</em></div>
<div class="inline-form"><el-input v-model="deviceNo" size="large" clearable placeholder="请输入订单号 / 整机编码" @keyup.enter="loadTask" /><el-button type="primary" size="large" :loading="loadingTask" @click="loadTask">查询任务</el-button></div>
<div v-if="task" class="task-found"><el-icon><CircleCheckFilled /></el-icon><div><strong>已识别维修对象</strong><span>{{ task.device_no }}</span></div><el-tag type="warning">{{ task.status_name || '待维修' }}</el-tag></div>
</section>
<section class="panel">
<div class="panel-title"><span>2</span><div><h2>异常信息</h2><p>来自异常提报的维修任务</p></div><small>{{ task?.reported_date || '待查询设备' }}</small></div>
<el-empty v-if="!task" description="查询设备后加载异常任务" :image-size="72" />
<div v-else class="info-grid"><div><span>异常类型</span><strong>{{ task.type_name || '-' }}</strong></div><div><span>异常原因</span><strong>{{ reasonText }}</strong></div><div><span>提报人员</span><strong>{{ task.reporter || task.user_code || '-' }}</strong></div></div>
</section>
<section class="panel">
<div class="panel-title"><span>3</span><div><h2>维修判定</h2><p>选择本次维修的不良类型</p></div><em>必选</em></div>
<div class="result-grid"><button v-for="item in resultOptions" :key="item.value" type="button" :class="['result-card', { active: form.repair_result === item.value }]" @click="selectResult(item.value)"><el-icon :size="26"><component :is="item.icon" /></el-icon><strong>{{ item.label }}</strong><small>{{ item.desc }}</small></button></div>
</section>
<section v-if="form.repair_result === 'assembly_mismatch'" class="panel"><div class="panel-title"><span>4</span><div><h2>搭配错误分类</h2><p>选择与本次异常对应的物料分类</p></div><em>必选</em></div><el-checkbox-group v-model="form.category_ids" class="category-grid"><el-checkbox v-for="item in categories" :key="item.id" :label="String(item.id)">{{ item.category_name }}</el-checkbox></el-checkbox-group><el-empty v-if="!categories.length" description="暂无可用物料分类" :image-size="60" /></section>
<section v-if="form.repair_result === 'operation_defect'" class="panel"><div class="panel-title"><span>4</span><div><h2>作业不良责任</h2><p>选择责任人员,工序步骤随人员记录保存</p></div><em>必选</em></div><el-checkbox-group v-model="form.responsible_ids" class="responsibility-grid"><el-checkbox v-for="item in scanRecords" :key="item.id" :label="String(item.id)"><span>{{ item.operator || item.user_code || '未记录人员' }}</span><small>{{ item.process_name || '-' }} · {{ item.step_name || '-' }}</small></el-checkbox></el-checkbox-group><el-empty v-if="!scanRecords.length" description="当前订单暂无可定责的历史扫码记录" :image-size="60" /></section>
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '4' : '5' }}</span><div><h2>维修记录</h2><p>登记更换备件和处理说明,便于后续追溯</p></div></div><div class="part-toolbar"><strong>更换备件</strong><div><el-button size="small" @click="openPartDialog">输入商品编码</el-button><el-button size="small" type="primary" @click="openPartDialog">添加备件</el-button></div></div><el-empty v-if="!form.parts.length" description="暂未添加备件" :image-size="60" /><div v-for="(part, index) in form.parts" :key="part.id" class="part-row"><div class="part-index">{{ index + 1 }}</div><div class="part-info"><strong>{{ part.product_name || part.product_code }}</strong><span>{{ part.product_code }}{{ part.category_name ? ` · ${part.category_name}` : '' }}</span><el-radio-group v-model="part.source" size="small"><el-radio-button label="new">新料</el-radio-button><el-radio-button label="after_sales">售后</el-radio-button><el-radio-button label="refurbished">返修</el-radio-button></el-radio-group><el-select v-model="part.fault_description_ids" multiple collapse-tags filterable clearable placeholder="选择故障点" class="fault-select"><el-option v-for="fault in faultsForPart(part)" :key="fault.id" :label="fault.description" :value="String(fault.id)" /></el-select></div><el-input-number v-model="part.quantity" :min="1" size="small" /><el-button link type="danger" @click="removePart(index)">删除</el-button></div><el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="1000" show-word-limit placeholder="记录维修过程、处理建议或补充说明" /></section>
<section class="panel"><div class="panel-title"><span>{{ form.repair_result === 'material_defect' ? '5' : '6' }}</span><div><h2>维修凭证</h2><p>可上传维修完成后的现场照片</p></div><em class="optional">选填</em></div><el-upload v-model:file-list="imageFiles" list-type="picture-card" :auto-upload="false" :limit="4" accept="image/*" :on-exceed="handleImageExceed"><span class="upload-plus">+</span></el-upload></section>
<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><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="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, 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 { 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 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<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 submitting = ref(false)
const partDialogVisible = ref(false)
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)
if (!data) return
summaries.value = [
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: true },
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' },
]
}
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 = () => { 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 (!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(() => { 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>