165 lines
16 KiB
Vue
165 lines
16 KiB
Vue
<template>
|
||
<div class="produce-work-page exception-page">
|
||
<div class="page-shell">
|
||
<section class="page-hero exception-hero">
|
||
<div>
|
||
<span class="eyebrow">现场提报</span>
|
||
<h1>异常处理</h1>
|
||
<p>先校验订单,再选择异常类型与原因,补充现场信息后提交。</p>
|
||
</div>
|
||
<div class="hero-user">
|
||
<strong>{{ currentUser.name || '未登录' }}</strong>
|
||
<span>{{ currentUser.code || '请先登录' }}</span>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="summary-grid">
|
||
<div v-for="item in summaryItems" :key="item.label" class="summary-card">
|
||
<strong :class="item.type">{{ item.value }}</strong>
|
||
<span>{{ item.label }}</span>
|
||
</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></div>
|
||
<div class="inline-form">
|
||
<el-input v-model="form.orderNo" size="large" clearable placeholder="请输入单号 / 条码" @keyup.enter="checkOrder" />
|
||
<el-button type="primary" size="large" :loading="checking" @click="checkOrder">校验单号</el-button>
|
||
</div>
|
||
<div v-if="orderMessage" class="validation" :class="orderValid ? 'success' : 'error'">{{ orderMessage }}</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-title"><span>2</span><div><h2>选择处理类型</h2><p>根据现场异常选择对应的处理分类</p></div><em>必选</em></div>
|
||
<div class="option-grid type-grid">
|
||
<button v-for="item in typeOptions" :key="item.value" type="button" :class="['choice-card', { active: form.type === item.value }]" @click="selectType(item.value)">
|
||
<strong>{{ item.label }}</strong><small>{{ item.desc || '按异常设置执行处理' }}</small>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="sub-title"><h2>3. 选择异常原因</h2><span>快速点选</span></div>
|
||
<div class="option-grid reason-grid">
|
||
<button v-for="item in activeReasons" :key="item.value" type="button" :class="['reason-card', { active: form.reason === item.value }]" @click="selectReason(item.value)">{{ item.label }}</button>
|
||
</div>
|
||
<div v-if="activeIssues.length" class="issue-box">
|
||
<div class="sub-title"><h2>具体问题</h2><span>{{ activeReasons.find(item => item.value === form.reason)?.label }}</span></div>
|
||
<el-select v-model="form.issue" filterable clearable class="full-width" placeholder="请选择具体问题">
|
||
<el-option v-for="item in activeIssues" :key="item.value" :label="item.label" :value="item.value" />
|
||
</el-select>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="panel">
|
||
<div class="panel-title"><span>4</span><div><h2>现场取证</h2><p>最多上传 4 张现场照片</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>
|
||
<el-input v-model="form.remark" class="remark-input" type="textarea" :rows="4" maxlength="200" show-word-limit placeholder="补充现场情况、处理建议、责任工位等" />
|
||
</section>
|
||
|
||
<div class="form-actions">
|
||
<el-button size="large" @click="resetForm">重置</el-button>
|
||
<el-button type="primary" size="large" :loading="submitting" @click="submit">提交异常</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>{{ nowText }}</strong></div><p v-if="userCodeError" class="field-error">{{ userCodeError }}</p></section>
|
||
<section class="panel"><div class="side-title"><h2>最近异常</h2><span>现场回看</span></div><el-empty v-if="!recentExceptions.length" description="暂无异常记录" :image-size="70" /><div v-for="item in recentExceptions" :key="item.id" class="recent-item"><div><strong>{{ item.order_no || '-' }}</strong><p>{{ item.type_name || '-' }} · {{ [item.reason_name, item.issue_name].filter(Boolean).join(' · ') }}</p></div><el-tag :type="item.status === 2 ? 'success' : item.status === 1 ? 'warning' : 'danger'">{{ item.status_name || '待处理' }}</el-tag></div></section>
|
||
</aside>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ElMessage } from 'element-plus'
|
||
import type { UploadUserFile } from 'element-plus'
|
||
import { computed, onMounted, ref } from 'vue'
|
||
import { useMemberStore } from '~/stores/member'
|
||
import { checkExceptionOrder, checkMemberUser, getExceptionSettings, getExceptionStatistics, getRecentExceptions, submitExceptionRecord, uploadProduceImage, type ProduceExceptionRecord, type ProduceExceptionSetting } from '~/apps/produce/api/exception'
|
||
|
||
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 form = ref({ orderNo: '', type: '', reason: '', issue: '', remark: '' })
|
||
const settings = ref<ProduceExceptionSetting[]>([])
|
||
const recentExceptions = ref<ProduceExceptionRecord[]>([])
|
||
const summary = ref({ pending: 0, review: 0, done: 0 })
|
||
const imageFiles = ref<UploadUserFile[]>([])
|
||
const checking = ref(false)
|
||
const submitting = ref(false)
|
||
const orderValid = ref(false)
|
||
const orderMessage = ref('')
|
||
const nowText = ref('')
|
||
const typeOptions = computed(() => settings.value.filter(item => item.setting_type === 'type').map(toOption))
|
||
const activeReasons = computed(() => settings.value.filter(item => item.setting_type === 'reason' && String(item.parent_id || '') === String(form.value.type)).map(toOption))
|
||
const activeIssues = computed(() => settings.value.filter(item => item.setting_type === 'issue' && String(item.parent_id || '') === String(form.value.reason)).map(toOption))
|
||
const summaryItems = computed(() => [{ label: '待处理', value: summary.value.pending, type: 'danger' }, { label: '待复核', value: summary.value.review, type: 'warning' }, { label: '已处理', value: summary.value.done, type: 'success' }])
|
||
const toOption = (item: ProduceExceptionSetting) => ({ value: String(item.id), label: item.name, desc: item.remark || '' })
|
||
|
||
const updateNow = () => { nowText.value = new Date().toLocaleString('zh-CN', { hour12: false }) }
|
||
const loadData = async () => {
|
||
try {
|
||
const [settingData, statisticData, recentData] = await Promise.all([getExceptionSettings(), getExceptionStatistics({ user_code: currentUser.value.code }), getRecentExceptions({ user_code: currentUser.value.code, limit: 5 })])
|
||
settings.value = Array.isArray(settingData) ? settingData : []
|
||
summary.value = statisticData || summary.value
|
||
recentExceptions.value = Array.isArray(recentData) ? recentData : []
|
||
if (!form.value.type) selectType(typeOptions.value[0]?.value || '')
|
||
} catch { /* 请求层已提示错误 */ }
|
||
}
|
||
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 selectType = (value: string) => { form.value.type = value; form.value.reason = activeReasons.value[0]?.value || ''; form.value.issue = activeIssues.value[0]?.value || '' }
|
||
const selectReason = (value: string) => { form.value.reason = value; form.value.issue = activeIssues.value[0]?.value || '' }
|
||
const checkOrder = async () => {
|
||
const orderNo = form.value.orderNo.trim()
|
||
if (!orderNo) { orderMessage.value = '请输入单号或条码'; orderValid.value = false; return }
|
||
checking.value = true
|
||
try { const result = await checkExceptionOrder(orderNo); orderValid.value = true; orderMessage.value = `已匹配${result?.reference_name || '记录'}` } catch { orderValid.value = false; orderMessage.value = '未找到对应订单或整机记录' } finally { checking.value = false }
|
||
}
|
||
const handleImageExceed = () => ElMessage.warning('最多上传 4 张图片')
|
||
const resetForm = () => { form.value = { orderNo: '', type: typeOptions.value[0]?.value || '', reason: '', issue: '', remark: '' }; imageFiles.value = []; orderValid.value = false; orderMessage.value = ''; selectType(form.value.type); updateNow() }
|
||
const submit = async () => {
|
||
if (!orderValid.value || !form.value.orderNo.trim()) { await checkOrder(); if (!orderValid.value) return }
|
||
if (!form.value.type || !form.value.reason || (activeIssues.value.length && !form.value.issue)) { ElMessage.warning('请选择处理类型、异常原因和具体问题'); return }
|
||
if (!await checkUserCode()) { ElMessage.warning(userCodeError.value); return }
|
||
submitting.value = true
|
||
try {
|
||
const images: string[] = []
|
||
for (const file of imageFiles.value) if (file.raw) { const url = await uploadProduceImage(file.raw, 'exception'); if (url) images.push(url) }
|
||
const response = await submitExceptionRecord({ order_no: form.value.orderNo.trim(), user_code: userCode.value.trim(), type_id: form.value.type, reason_id: form.value.reason, issue_id: form.value.issue || '', images, remark: form.value.remark.trim() })
|
||
if (response?.code !== 0) { ElMessage.error(response?.msg || '异常提交失败'); return }
|
||
ElMessage.success('异常已提交'); resetForm(); await loadData()
|
||
} finally { submitting.value = false }
|
||
}
|
||
onMounted(() => { updateNow(); userCode.value = currentUser.value.code; userName.value = currentUser.value.name; void loadData() })
|
||
</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; gap:24px; padding:32px 36px; border-radius:22px; color:#fff; box-shadow:0 14px 32px rgba(37,99,235,.12); }
|
||
.exception-hero { background:linear-gradient(135deg,#ff9d42,#ef625f); }
|
||
.eyebrow { font-size:13px; opacity:.85; } h1 { margin:8px 0 0; font-size:30px; } .page-hero p { margin:8px 0 0; opacity:.86; }
|
||
.hero-user { min-width:150px; padding:16px 20px; border-radius:14px; background:rgba(255,255,255,.16); text-align:right; } .hero-user strong,.hero-user span { display:block; } .hero-user span { margin-top:6px; font-size:13px; opacity:.8; }
|
||
.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 { padding:18px 22px; } .summary-card strong,.summary-card span { display:block; } .summary-card strong { font-size:28px; } .summary-card span { margin-top:6px; color:#8a94a6; font-size:13px; } .danger { color:#ef625f; } .warning { color:#e59a2f; } .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,.side-title,.sub-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:#fff0e5; color:#e97020; font-weight:700; flex:none; } 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:#e97020; background:#fff3e8; font-size:12px; font-style:normal; } .panel-title em.optional { color:#8793a6; background:#f3f5f8; }
|
||
.inline-form { display:flex; gap:12px; margin-top:20px; } .inline-form .el-input { flex:1; } .validation { margin-top:10px; font-size:13px; } .validation.success { color:#26b36c; } .validation.error { color:#ef625f; } .option-grid { display:grid; gap:12px; margin-top:18px; } .type-grid { grid-template-columns:repeat(2,1fr); } button { font:inherit; cursor:pointer; } .choice-card,.reason-card { border:1px solid #e6edf5; border-radius:14px; background:#f8fafc; text-align:left; transition:.2s; } .choice-card { padding:16px; } .choice-card strong,.choice-card small { display:block; } .choice-card strong { color:#2b3853; } .choice-card small { margin-top:8px; color:#9aa4b4; } .choice-card.active,.reason-card.active { border-color:#ff9a3c; background:#fff5ed; color:#e97020; } .sub-title { align-items:center; justify-content:space-between; margin-top:26px; } .sub-title span,.side-title span { color:#9aa4b4; font-size:12px; } .reason-grid { grid-template-columns:repeat(3,1fr); } .reason-card { min-height:46px; padding:8px; text-align:center; color:#56647a; } .issue-box { margin-top:18px; padding:16px; border-radius:14px; background:#fffaf5; } .issue-box .sub-title { margin:0 0 12px; } .full-width { width:100%; }
|
||
:deep(.el-upload--picture-card),:deep(.el-upload-list__item) { width:92px; height:92px; } .upload-plus { font-size:30px; color:#ff9a3c; } .remark-input { margin-top:18px; } .form-actions { display:flex; justify-content:flex-end; gap:12px; } .operator-panel h2 { margin-bottom: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-bottom:0; } .operator-panel span { color:#9aa4b4; } .side-title { align-items:center; justify-content:space-between; } .recent-item { display:flex; justify-content:space-between; gap:10px; padding:16px 0; border-bottom:1px solid #eef2f7; } .recent-item:last-child { border-bottom:0; } .recent-item strong { font-size:14px; } .recent-item p { margin:6px 0 0; color:#8a94a6; font-size:12px; }
|
||
@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; } .hero-user { align-self:stretch; text-align:left; } .summary-grid,.side-column { grid-template-columns:1fr; } .type-grid,.reason-grid { grid-template-columns:1fr; } .inline-form { flex-direction:column; } .form-actions { justify-content:stretch; } .form-actions .el-button { flex:1; } }
|
||
.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; }
|
||
</style>
|