Files
MrleeAdmin/frontend/app/pages/repair/index.vue
T

981 lines
43 KiB
Vue
Raw Normal View History

2026-08-31 11:33:59 +08:00
<template>
<view class="repair-page" :class="`theme-${themeMode}`">
<view class="repair-hero">
<view class="hero-copy">
<text class="hero-tag">维修工作台</text>
<text class="hero-title">维修处理</text>
<text class="hero-subtitle">扫描设备,快速完成故障判断与维修记录</text>
</view>
<view class="hero-icon">
<u-icon name="setting-fill" color="#ffffff" size="46" />
</view>
</view>
<view class="summary-grid">
<view v-for="item in summaries" :key="item.label" class="summary-item" @tap="handleSummaryTap(item)">
<text class="summary-value" :class="item.type">{{ item.value }}</text>
<text class="summary-label">{{ item.label }}</text>
<view v-if="item.action === 'pending'" class="summary-arrow">
<u-icon name="arrow-right" color="#1677ff" size="16" />
</view>
</view>
</view>
<view class="section-card scan-card">
<view class="section-header">
<view>
<text class="section-title">1. 扫描或录入设备</text>
<text class="section-desc">支持订单号、整机编码或设备条码</text>
</view>
<text class="required-tag">必填</text>
</view>
<view class="scan-input-row">
<view class="scan-input">
<u-icon name="search" color="#9aa4b4" size="32" />
<u-input v-model="form.deviceNo" :input-mode="pdaMode ? 'none' : 'text'" placeholder="请输入订单号 / 整机编码" placeholder-style="color: #a0a7b5;" @focus="handleScanInputFocus" @blur="loadRepairTask" />
</view>
<view class="scan-button" @tap="scanDevice">
<u-icon name="scan" color="#ffffff" size="30" />
<text>扫码</text>
</view>
</view>
<view v-if="repairTask" class="device-found">
<view class="device-found-icon"><u-icon name="checkmark" color="#ffffff" size="28" /></view>
<view class="device-found-info">
<text class="device-found-title">已识别维修对象</text>
<text class="device-found-value">{{ repairTask.deviceNo }}</text>
</view>
<text class="device-found-status">{{ repairTask.statusName }}</text>
</view>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">2. 异常信息</text>
<text class="section-desc">来自异常提报的维修任务</text>
</view>
<text class="order-time">{{ repairTask ? repairTask.reportedDate : '待扫描设备' }}</text>
</view>
<view v-if="repairTask" class="exception-info">
<view class="info-row"><text class="info-label">异常类型</text><text class="info-value">{{ repairTask.typeName || '-' }}</text></view>
<view class="info-row"><text class="info-label">异常原因</text><text class="info-value">{{ repairTask.reasonText || '-' }}</text></view>
<view class="info-row"><text class="info-label">提报人员</text><text class="info-value">{{ repairTask.reporterText || '-' }}</text></view>
</view>
<view v-else class="exception-empty">扫描或录入设备后加载异常任务</view>
</view>
<view class="section-card">
<view class="section-header">
<view>
<text class="section-title">3. 维修判定</text>
<text class="section-desc">选择本次维修的不良类型</text>
</view>
<text class="required-tag">必选</text>
</view>
<view class="result-grid">
<view v-for="item in resultOptions" :key="item.value" class="result-item" :class="{ active: form.result === item.value }" @tap="selectRepairResult(item.value)">
<view class="result-icon" :style="{ backgroundColor: form.result === item.value ? item.color : '#f2f5fa' }">
<u-icon :name="item.icon" :color="form.result === item.value ? '#ffffff' : item.color" size="32" />
</view>
<text class="result-label">{{ item.label }}</text>
<text class="result-desc">{{ item.desc }}</text>
</view>
</view>
</view>
<view v-if="form.result === 'assembly_mismatch'" class="section-card">
<view class="section-header">
<view>
<text class="section-title">4. 搭配错误分类</text>
<text class="section-desc">选择与本次异常对应的物料分类</text>
</view>
<text class="required-tag">必选</text>
</view>
<view v-if="loadingMaterialCategories" class="repair-actions-tip">正在加载物料分类...</view>
<view v-else-if="!mismatchCategoryOptions.length" class="repair-actions-tip">暂无可用物料分类</view>
<view v-else class="repair-action-list">
<view
v-for="item in mismatchCategoryOptions"
:key="item.id"
class="repair-action-item"
:class="{ active: isMismatchCategorySelected(item.id) }"
@tap="toggleMismatchCategory(item)"
>
<u-icon :name="isMismatchCategorySelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'" :color="isMismatchCategorySelected(item.id) ? '#1677ff' : '#9aa4b4'" size="28" />
<text>{{ item.category_name }}</text>
</view>
</view>
</view>
<view v-if="form.result === 'operation_defect'" class="section-card responsibility-card">
<view class="section-header">
<view>
<text class="section-title">4. 作业不良责任</text>
<text class="section-desc">选择责任人员,工序步骤随人员记录一并保存</text>
</view>
<text class="required-tag">必选</text>
</view>
<view v-if="loadingOrderRecords" class="repair-actions-tip">正在加载历史扫码记录...</view>
<view v-else-if="!orderScanRecords.length" class="repair-actions-tip">当前订单暂无可定责的历史扫码记录</view>
<view v-else>
<text class="responsibility-title">责任人员</text>
<view class="responsibility-list">
<view v-for="item in orderScanRecords" :key="`operator-${item.id}`" class="responsibility-item" :class="{ active: isResponsibleOperatorSelected(item.id) }" @tap="toggleResponsibleOperator(item.id)">
<u-icon :name="isResponsibleOperatorSelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'" :color="isResponsibleOperatorSelected(item.id) ? '#1677ff' : '#9aa4b4'" size="28" />
<view><text class="responsibility-name">{{ item.operator || item.userCode || '未记录人员' }}</text><text class="responsibility-meta">{{ item.processName || '未设置工序' }} · {{ item.stepName || '未设置步骤' }}</text></view>
</view>
</view>
</view>
</view>
<view class="section-card">
<view class="section-header repair-record-header">
<view>
<text class="section-title">{{ form.result === 'material_defect' ? '4. 维修记录' : '5. 维修记录' }}</text>
<text class="section-desc">记录处理过程,便于后续追溯</text>
</view>
<view class="parts-actions">
<text class="parts-action scan-part-action" @tap="scanPart">扫码</text>
<text class="parts-action input-part-action" @tap="inputPart">输入</text>
</view>
</view>
<view class="parts-header">
<text class="parts-title">更换备件</text>
</view>
<view v-for="(part, index) in form.parts" :key="part.id" class="part-row">
<view class="part-index">{{ index + 1 }}</view>
<view class="part-info">
<text class="part-name">{{ part.name }}</text>
<text class="part-code">{{ part.code }}</text>
<text v-if="part.categoryName" class="part-category">{{ part.categoryName }}</text>
<view class="part-source-options">
<view
v-for="item in partSourceOptions"
:key="item.value"
class="part-source-option"
:class="{ active: part.source === item.value }"
@tap="part.source = item.value"
>
{{ item.label }}
</view>
</view>
<view class="part-fault-entry" @tap="openFaultPointPopup(part)">
<text class="part-fault-entry-label">故障点</text>
<text class="part-fault-entry-value">{{ getPartFaultSummary(part) || '请选择' }}</text>
<u-icon name="arrow-right" color="#9aa4b4" size="20" />
</view>
</view>
<view class="part-stepper">
<view class="stepper-button" @tap.stop="changePartQuantity(part, -1)">
<u-icon name="minus" color="#56647a" size="22" />
</view>
<text class="stepper-value">{{ part.quantity }}</text>
<view class="stepper-button" @tap.stop="changePartQuantity(part, 1)">
<u-icon name="plus" color="#1677ff" size="22" />
</view>
</view>
<view class="part-remove" @tap.stop="removePart(index)">
<u-icon name="trash" color="#ef625f" size="30" />
</view>
</view>
<view class="repair-actions-tip">每个备件分别选择对应分类的故障点</view>
<view v-if="form.remark" class="repair-action-summary">{{ form.remark }}</view>
</view>
<u-popup v-model="partInputVisible" mode="center" border-radius="20">
<view class="part-input-dialog">
<text class="part-input-title">输入商品编码</text>
<u-input v-model="partInputCode" :input-mode="pdaMode ? 'none' : 'text'" placeholder="请输入商品编码" border @focus="handleScanInputFocus" />
<view class="part-input-actions">
<view class="part-input-cancel" @tap="closePartInput">取消</view>
<view class="part-input-confirm" @tap="confirmPartInput">确定</view>
</view>
</view>
</u-popup>
<u-popup v-model="faultPointPopupVisible" mode="bottom" border-radius="24">
<view class="fault-popup">
<view class="fault-popup-header">
<view>
<text class="fault-popup-title">选择故障点</text>
<text class="fault-popup-subtitle">{{ currentFaultPart ? currentFaultPart.name : '' }}</text>
</view>
<text class="fault-popup-close" @tap="closeFaultPointPopup">关闭</text>
</view>
<view v-if="loadingFaultPoints" class="repair-actions-tip">正在加载故障描述...</view>
<view v-else-if="!currentFaultPartOptions.length" class="repair-actions-tip">当前备件分类暂无故障描述</view>
<scroll-view v-else scroll-y class="fault-popup-scroll">
<view class="repair-action-list fault-popup-list">
<view
v-for="item in currentFaultPartOptions"
:key="item.id"
class="repair-action-item"
:class="{ active: isFaultPointSelected(item.id) }"
@tap="toggleFaultPoint(item)"
>
<u-icon
:name="isFaultPointSelected(item.id) ? 'checkmark-circle-fill' : 'plus-circle'"
:color="isFaultPointSelected(item.id) ? '#1677ff' : '#9aa4b4'"
size="28"
/>
<text>{{ item.description }}</text>
</view>
</view>
</scroll-view>
<view class="fault-popup-footer">
<view class="fault-popup-button fault-popup-clear" @tap="clearCurrentPartFaults">清空</view>
<view class="fault-popup-button fault-popup-confirm" @tap="closeFaultPointPopup">完成</view>
</view>
</view>
</u-popup>
<view class="section-card evidence-card">
<view class="section-header">
<view>
<text class="section-title">{{ form.result === 'material_defect' ? '5. 维修凭证' : '6. 维修凭证' }}</text>
<text class="section-desc">可上传维修完成后的现场照片</text>
</view>
<text class="optional-tag">选填</text>
</view>
<view class="evidence-row">
<view v-if="form.images.length < 4" class="upload-box" @tap="chooseImages">
<u-icon name="camera-fill" color="#ff8a3d" size="38" />
<text>拍照上传</text>
</view>
<view v-for="(image, index) in form.images" :key="image" class="evidence-image">
<image :src="image" mode="aspectFill" />
<text class="upload-label">图片{{ index + 1 }}</text>
<view class="upload-delete" @tap.stop="removeImage(index)">
<u-icon name="close" color="#ffffff" size="20" />
</view>
</view>
</view>
</view>
<view class="bottom-actions">
<view class="draft-button" @tap="saveDraft">保存草稿</view>
<view class="complete-button" @tap="completeRepair">完成维修</view>
</view>
</view>
</template>
<script>
// UTS 原生扫码插件仅在 App-Plus 真机/自定义基座中解析,Web 环境不加载。
// #ifdef APP-PLUS
import { openCodeScan } from '@/uni_modules/mas-code-scan'
// #endif
import { getHardwareFaultDescriptionOptions, getOrderRecentRecords, getScanAppMaterialCategories, lookupMaterial } from '@/api/produce/data-entry.js'
import { getRepairStatistics, getRepairTask, submitRepairRecord } from '@/api/produce/exception.js'
import { uploadImage } from '@/api/upload.js'
import { getScanRegionSetting } from '@/utils/scan-settings.js'
export default {
data() {
return {
partInputVisible: false,
partInputCode: '',
scanning: false,
pdaMode: false,
loadingRepairTask: false,
loadingOrderRecords: false,
submitting: false,
repairTask: null,
orderScanRecords: [],
loadingFaultPoints: false,
loadingMaterialCategories: false,
faultPointRequestKey: '',
faultPointPopupVisible: false,
currentFaultPartId: '',
faultPointOptions: [],
summaries: [
{ label: '待维修', value: 0, type: 'warning', action: 'pending' },
{ label: '处理中', value: 0, type: 'primary' },
{ label: '今日完成', value: 0, type: 'success' }
],
resultOptions: [
{ label: '物料不良', value: 'material_defect', desc: '物料本身存在异常', icon: 'bag-fill', color: '#ef625f' },
{ label: '作业不良', value: 'operation_defect', desc: '作业过程导致异常', icon: 'edit-pen-fill', color: '#f2a93b' },
{ label: '搭配错误', value: 'assembly_mismatch', desc: '物料搭配或装配错误', icon: 'grid-fill', color: '#2f6cf6' }
],
mismatchCategoryOptions: [],
partSourceOptions: [
{ label: '新料', value: 'new' },
{ label: '售后', value: 'after_sales' },
{ label: '返修', value: 'refurbished' }
],
form: {
deviceNo: '',
result: 'material_defect',
remark: '',
parts: [],
responsibleOperatorIds: [],
mismatchCategoryIds: [],
images: [],
status: ''
}
}
},
onLoad(options) {
this.loadPdaMode()
this.loadMaterialCategories()
this.loadRepairStatistics()
const deviceNo = String(options?.device_no || '').trim()
if (deviceNo) {
this.form.deviceNo = deviceNo
this.loadRepairTask()
}
},
onShow() {
this.loadPdaMode()
},
computed: {
currentFaultPart() {
return this.form.parts.find((part) => part.id === this.currentFaultPartId) || null
},
currentFaultPartOptions() {
if (!this.currentFaultPart) return []
return this.faultPointOptions.filter((item) => item.categoryId === String(this.currentFaultPart.categoryId || ''))
}
},
methods: {
getCurrentUserCode() {
const user = uni.getStorageSync('scanapp_user') || {}
return user.username || user.nickname || 'U001'
},
loadPdaMode() {
const settings = uni.getStorageSync(`scanapp_settings_${this.getCurrentUserCode()}`)
this.pdaMode = settings?.pdaMode === true
},
handleScanInputFocus() {
// #ifdef APP-PLUS
try {
const activity = plus.android.runtimeMainActivity()
const focusedView = activity.getWindow().getDecorView().findFocus()
if (focusedView) plus.android.invoke(focusedView, 'setShowSoftInputOnFocus', !this.pdaMode)
if (this.pdaMode) uni.hideKeyboard()
} catch (error) {
if (this.pdaMode) uni.hideKeyboard()
}
// #endif
},
handleSummaryTap(item) {
if (item.action !== 'pending') return
uni.navigateTo({ url: '/pages/repair/tasks' })
},
async loadRepairStatistics() {
try {
const user = uni.getStorageSync('scanapp_user') || {}
const userCode = String(user.username || user.user_code || '').trim()
const statistics = await getRepairStatistics({ user_code: userCode })
const data = statistics?.data || statistics || {}
this.summaries = [
{ label: '待维修', value: Number(data.pending || 0), type: 'warning', action: 'pending' },
{ label: '处理中', value: Number(data.processing || 0), type: 'primary' },
{ label: '今日完成', value: Number(data.completed_today || 0), type: 'success' }
]
} catch (error) {
this.summaries = [
{ label: '待维修', value: 0, type: 'warning', action: 'pending' },
{ label: '处理中', value: 0, type: 'primary' },
{ label: '今日完成', value: 0, type: 'success' }
]
}
},
async loadMaterialCategories() {
this.loadingMaterialCategories = true
try {
const items = await getScanAppMaterialCategories()
this.mismatchCategoryOptions = (Array.isArray(items) ? items : []).map((item) => ({
id: String(item.id || ''),
category_name: String(item.category_name || item.name || '')
})).filter((item) => item.id && item.category_name)
const availableIds = new Set(this.mismatchCategoryOptions.map((item) => item.id))
this.form.mismatchCategoryIds = this.form.mismatchCategoryIds.filter((id) => availableIds.has(id))
} catch (error) {
this.mismatchCategoryOptions = []
this.form.mismatchCategoryIds = []
uni.showToast({ title: '物料分类加载失败,请稍后重试', icon: 'none' })
} finally {
this.loadingMaterialCategories = false
}
},
async loadRepairTask() {
const deviceNo = this.form.deviceNo.trim()
this.repairTask = null
this.orderScanRecords = []
this.form.responsibleOperatorIds = []
if (!deviceNo || this.loadingRepairTask) return
this.loadingRepairTask = true
try {
const task = await getRepairTask(deviceNo)
if (this.form.deviceNo.trim() !== deviceNo) return
this.form.deviceNo = String(task.device_no || deviceNo)
this.repairTask = {
id: String(task.id || ''),
deviceNo: String(task.device_no || deviceNo),
typeName: String(task.type_name || ''),
reasonText: [task.reason_name, task.issue_name].filter(Boolean).join(' · '),
reporterText: task.reporter ? `${task.reporter}${task.user_code ? `(${task.user_code})` : ''}` : String(task.user_code || ''),
reportedDate: String(task.reported_date || ''),
statusName: String(task.status_name || '待维修')
}
await this.loadOrderScanRecords(this.repairTask.deviceNo)
} catch (error) {
if (this.form.deviceNo.trim() === deviceNo) this.repairTask = null
} finally {
this.loadingRepairTask = false
}
},
async loadOrderScanRecords(orderNo) {
this.loadingOrderRecords = true
try {
const result = await getOrderRecentRecords({ order_no: String(orderNo || '').trim(), limit: 50 })
const items = Array.isArray(result?.items) ? result.items : []
this.orderScanRecords = items.map((item) => ({
id: String(item.id || ''),
processName: String(item.process_name || item.process?.process_name || ''),
stepName: String(item.step_name || item.step?.step_name || ''),
operator: String(item.operator || ''),
userCode: String(item.user_code || '')
})).filter((item) => item.id)
} catch (error) {
this.orderScanRecords = []
} finally {
this.loadingOrderRecords = false
}
},
selectRepairResult(result) {
this.form.result = result
if (result !== 'operation_defect') this.form.responsibleOperatorIds = []
if (result !== 'assembly_mismatch') this.form.mismatchCategoryIds = []
},
isMismatchCategorySelected(categoryId) {
return this.form.mismatchCategoryIds.includes(String(categoryId))
},
toggleMismatchCategory(category) {
const id = String(category.id)
const ids = this.form.mismatchCategoryIds
this.form.mismatchCategoryIds = ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]
},
isResponsibleOperatorSelected(scanId) {
return this.form.responsibleOperatorIds.includes(String(scanId))
},
toggleResponsibleOperator(scanId) {
const id = String(scanId)
const ids = this.form.responsibleOperatorIds
this.form.responsibleOperatorIds = ids.includes(id) ? ids.filter((item) => item !== id) : [...ids, id]
},
scanDevice() {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: async (result) => {
const value = String(result?.content || '').trim()
if (!value) return
this.form.deviceNo = value
await this.loadRepairTask()
},
fail: (error) => this.handleScanFail(error),
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
handleScanFail(error) {
const code = Number(error?.errCode)
if (code === 9021003) return
const messageMap = {
9021001: '当前环境不支持扫码,请在 Android 自定义调试基座或正式安装包中使用',
9021002: '相机权限被拒绝,请到系统设置中开启相机权限',
9021004: '未获取到有效扫码结果,请重试'
}
uni.showToast({ title: messageMap[code] || String(error?.errMsg || '扫码插件调用异常'), icon: 'none' })
},
chooseImages() {
if (this.submitting || this.form.images.length >= 4) return
uni.chooseImage({
count: 4 - this.form.images.length,
sizeType: ['compressed'],
sourceType: ['camera', 'album'],
success: (res) => {
this.form.images = [...this.form.images, ...res.tempFilePaths].slice(0, 4)
}
})
},
removeImage(index) {
if (this.submitting) return
this.form.images.splice(index, 1)
},
getFaultDescriptionById(id) {
return this.faultPointOptions.find((item) => item.id === id)?.description || ''
},
getPartFaultSummary(part) {
return (part.selectedFaultPointIds || [])
.map((id) => this.getFaultDescriptionById(id))
.filter(Boolean)
.join(';')
},
openFaultPointPopup(part) {
if (!part.categoryId) {
uni.showToast({ title: '当前备件缺少分类信息', icon: 'none' })
return
}
this.currentFaultPartId = part.id
this.faultPointPopupVisible = true
},
closeFaultPointPopup() {
this.faultPointPopupVisible = false
this.currentFaultPartId = ''
},
isFaultPointSelected(faultPointId) {
return !!this.currentFaultPart && (this.currentFaultPart.selectedFaultPointIds || []).includes(faultPointId)
},
toggleFaultPoint(faultPoint) {
if (!this.currentFaultPart) return
const selectedFaultPointIds = this.currentFaultPart.selectedFaultPointIds || []
const index = selectedFaultPointIds.indexOf(faultPoint.id)
if (index === -1) {
selectedFaultPointIds.push(faultPoint.id)
} else {
selectedFaultPointIds.splice(index, 1)
}
this.currentFaultPart.selectedFaultPointIds = [...selectedFaultPointIds]
this.syncFaultPointRemark()
},
clearCurrentPartFaults() {
if (!this.currentFaultPart) return
this.currentFaultPart.selectedFaultPointIds = []
this.syncFaultPointRemark()
},
syncFaultPointRemark() {
const summaries = this.form.parts
.map((part, index) => {
const summary = this.getPartFaultSummary(part)
if (!summary) return ''
return `备件${index + 1}:${summary}`
})
.filter(Boolean)
this.form.remark = summaries.join(';')
},
async reloadFaultPointOptions() {
const categoryIds = [...new Set(this.form.parts.map((part) => String(part.categoryId || '')).filter(Boolean))].sort()
const requestKey = categoryIds.join(',')
this.faultPointRequestKey = requestKey
if (!categoryIds.length) {
this.faultPointOptions = []
this.form.parts = this.form.parts.map((part) => ({
...part,
selectedFaultPointIds: []
}))
this.syncFaultPointRemark()
return
}
this.loadingFaultPoints = true
try {
const items = await getHardwareFaultDescriptionOptions(categoryIds)
if (this.faultPointRequestKey !== requestKey) return
this.faultPointOptions = (Array.isArray(items) ? items : []).map((item) => ({
id: String(item.id),
categoryId: String(item.category_id),
description: String(item.description || ''),
useCount: Number(item.use_count || 0)
}))
const availableIds = new Set(this.faultPointOptions.map((item) => item.id))
this.form.parts = this.form.parts.map((part) => ({
...part,
selectedFaultPointIds: (part.selectedFaultPointIds || []).filter((id) => availableIds.has(id))
}))
this.syncFaultPointRemark()
} catch (error) {
if (this.faultPointRequestKey === requestKey) this.faultPointOptions = []
} finally {
if (this.faultPointRequestKey === requestKey) this.loadingFaultPoints = false
}
},
async removePart(index) {
if (this.form.parts[index] && this.form.parts[index].id === this.currentFaultPartId) {
this.closeFaultPointPopup()
}
this.form.parts.splice(index, 1)
await this.reloadFaultPointOptions()
},
changePartQuantity(part, delta) {
const current = Math.max(1, Number.parseInt(part.quantity, 10) || 1)
part.quantity = String(Math.max(1, current + delta))
},
async addPartByCode(code) {
const productCode = String(code || '').trim()
if (!productCode) return
if (this.form.parts.some((part) => part.code === productCode)) {
uni.showToast({ title: '该物料已添加', icon: 'none' })
return
}
try {
const material = await lookupMaterial(productCode)
this.form.parts.push({
id: `${Date.now()}-${productCode}`,
name: material.product_name,
code: material.product_code || productCode,
categoryId: String(material.category_id || ''),
categoryName: String(material.category_name || ''),
selectedFaultPointIds: [],
source: 'new',
quantity: '1'
})
await this.reloadFaultPointOptions()
} catch (error) {
// 请求层已显示服务端错误提示,避免重复弹出。
}
},
scanPart() {
if (this.scanning || this.submitting) return
// #ifdef APP-PLUS
this.scanning = true
openCodeScan({
smartZoom: true,
vibrate: true,
scanRegion: getScanRegionSetting(),
success: (result) => {
const value = String(result?.content || '').trim()
if (value) this.addPartByCode(value)
},
fail: (error) => this.handleScanFail(error),
complete: () => {
this.scanning = false
}
})
// #endif
// #ifndef APP-PLUS
uni.showToast({ title: '请在 Android App 中使用扫码功能', icon: 'none' })
// #endif
},
inputPart() {
this.partInputCode = ''
this.partInputVisible = true
},
closePartInput() {
this.partInputVisible = false
},
async confirmPartInput() {
const productCode = this.partInputCode.trim()
if (!productCode) {
uni.showToast({ title: '请输入商品编码', icon: 'none' })
return
}
this.partInputVisible = false
await this.addPartByCode(productCode)
},
saveDraft() {
uni.showToast({ title: '维修草稿已保存', icon: 'success' })
},
completeRepair() {
if (this.submitting) return
if (!this.form.deviceNo.trim()) {
uni.showToast({ title: '请先扫描或录入设备', icon: 'none' })
return
}
if (!this.repairTask?.id) {
uni.showToast({ title: '请先加载有效的维修任务', icon: 'none' })
return
}
if (!this.form.result) {
uni.showToast({ title: '请选择维修判定', icon: 'none' })
return
}
if (this.form.result === 'operation_defect' && !this.form.responsibleOperatorIds.length) {
uni.showToast({ title: '请选择责任人员', icon: 'none' })
return
}
if (this.form.result === 'assembly_mismatch' && !this.form.mismatchCategoryIds.length) {
uni.showToast({ title: '请选择搭配错误分类', icon: 'none' })
return
}
const user = uni.getStorageSync('scanapp_user') || {}
const userCode = String(user.username || user.user_code || '').trim()
if (!userCode) {
uni.showToast({ title: '登录信息已失效,请重新登录', icon: 'none' })
return
}
uni.showModal({
title: '确认完成维修',
content: `确定提交设备 ${this.form.deviceNo} 的维修记录吗?`,
confirmText: '确认完成',
success: async (res) => {
if (!res.confirm || this.submitting) return
this.submitting = true
try {
const uploadedImages = []
for (const filePath of this.form.images) {
uploadedImages.push(await uploadImage(filePath, 'repair'))
}
const payload = {
exception_id: this.repairTask.id,
user_code: userCode,
repair_result: this.form.result,
images: uploadedImages,
parts: this.form.parts.map((part) => ({
product_code: String(part.code || '').trim(),
source: part.source || 'new',
quantity: Math.max(1, Number.parseInt(part.quantity, 10) || 1),
fault_description_ids: (part.selectedFaultPointIds || []).map((id) => String(id))
})),
operation_fault_steps: this.form.responsibleOperatorIds,
responsible_operators: this.form.responsibleOperatorIds,
remark: this.form.remark.trim()
}
if (this.form.result === 'assembly_mismatch') {
payload.category_ids = this.form.mismatchCategoryIds
}
await submitRepairRecord(payload)
uni.showToast({ title: '维修记录已完成', icon: 'success' })
this.repairTask = null
this.orderScanRecords = []
this.faultPointOptions = []
this.form.mismatchCategoryIds = []
this.form = {
deviceNo: '',
result: 'material_defect',
remark: '',
parts: [],
responsibleOperatorIds: [],
mismatchCategoryIds: [],
images: []
}
this.loadRepairStatistics()
} catch (error) {
console.error('[scanapp] 维修记录提交失败', error)
} finally {
this.submitting = false
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
.repair-page {
min-height: 100vh;
padding: 24rpx 24rpx 210rpx;
box-sizing: border-box;
background: linear-gradient(180deg, #eaf4ff 0%, #f4f6fb 24%, #f4f6fb 100%);
}
.repair-hero {
display: flex;
align-items: center;
justify-content: space-between;
padding: 20rpx 8rpx 28rpx;
}
.hero-copy text { display: block; }
.hero-tag { color: #1677ff; font-size: 20rpx; line-height: 1.35; }
.hero-title { margin-top: 6rpx; color: #17233d; font-size: 38rpx; font-weight: 800; line-height: 1.35; }
.hero-subtitle { margin-top: 8rpx; color: #7d899d; font-size: 20rpx; line-height: 1.4; }
.hero-icon { display: flex; align-items: center; justify-content: center; width: 88rpx; height: 88rpx; border-radius: 28rpx; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); box-shadow: 0 16rpx 28rpx rgba(47, 108, 246, 0.2); }
.summary-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-bottom: 18rpx; }
.summary-item { position: relative; padding: 18rpx 12rpx; border-radius: 20rpx; background: #ffffff; text-align: center; box-shadow: 0 8rpx 24rpx rgba(36, 59, 92, 0.05); }
.summary-arrow { position: absolute; top: 50%; right: 12rpx; display: flex; align-items: center; justify-content: center; width: 34rpx; height: 34rpx; border-radius: 50%; background: #edf4ff; transform: translateY(-50%); }
.summary-value { display: block; font-size: 32rpx; font-weight: 800; line-height: 1.2; }
.summary-value.warning { color: #f2a93b; }
.summary-value.primary { color: #1677ff; }
.summary-value.success { color: #26b36c; }
.summary-label { display: block; margin-top: 8rpx; color: #8a94a6; font-size: 18rpx; line-height: 1.35; }
.section-card { margin-bottom: 18rpx; padding: 24rpx; border-radius: 24rpx; background: #ffffff; box-shadow: 0 8rpx 24rpx rgba(36, 59, 92, 0.05); }
.section-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 14rpx; }
.section-title, .section-desc { display: block; }
.section-title { color: #17233d; font-size: 28rpx; font-weight: 700; line-height: 1.35; }
.section-desc { margin-top: 8rpx; color: #9aa4b4; font-size: 20rpx; line-height: 1.35; }
.required-tag, .optional-tag, .order-time { flex: none; font-size: 18rpx; line-height: 1.35; }
.required-tag { padding: 6rpx 12rpx; border-radius: 999rpx; color: #1677ff; background: #edf4ff; }
.optional-tag, .order-time { color: #9aa4b4; }
.scan-input-row { display: flex; align-items: center; margin-top: 18rpx; }
.scan-input { display: flex; align-items: center; flex: 1; min-width: 0; padding: 0 14rpx; border: 1rpx solid #e7edf5; border-radius: 18rpx; background: #f8fbff; }
.scan-button { display: flex; align-items: center; justify-content: center; gap: 8rpx; width: 116rpx; height: 78rpx; margin-left: 14rpx; border-radius: 18rpx; color: #ffffff; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); font-size: 22rpx; flex: none; }
.device-found { display: flex; align-items: center; margin-top: 16rpx; padding: 16rpx; border-radius: 18rpx; background: #f0fbf6; }
.device-found-icon { display: flex; align-items: center; justify-content: center; width: 46rpx; height: 46rpx; margin-right: 12rpx; border-radius: 50%; background: #26b36c; }
.device-found-info { flex: 1; min-width: 0; }
.device-found-title, .device-found-value { display: block; }
.device-found-title { color: #26b36c; font-size: 18rpx; line-height: 1.35; }
.device-found-value { margin-top: 4rpx; color: #17233d; font-size: 24rpx; font-weight: 700; word-break: break-all; }
.device-found-status { color: #f2a93b; font-size: 18rpx; }
.exception-info { margin-top: 18rpx; padding: 4rpx 18rpx; border-radius: 18rpx; background: #f8fafc; }
.exception-empty { margin-top: 18rpx; padding: 24rpx 18rpx; border-radius: 18rpx; color: #9aa4b4; background: #f8fafc; font-size: 20rpx; text-align: center; }
.info-row { display: flex; align-items: center; justify-content: space-between; padding: 16rpx 0; border-bottom: 1rpx solid #edf0f5; }
.info-row:last-child { border-bottom: 0; }
.info-label { color: #8a94a6; font-size: 20rpx; }
.info-value { max-width: 62%; color: #2b3853; font-size: 22rpx; font-weight: 600; text-align: right; word-break: break-all; }
.result-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 12rpx; margin-top: 18rpx; }
.result-item { padding: 16rpx 8rpx; border: 2rpx solid #edf0f5; border-radius: 18rpx; text-align: center; }
.result-item.active { border-color: #2f6cf6; background: #f4f8ff; }
.result-icon { display: flex; align-items: center; justify-content: center; width: 56rpx; height: 56rpx; margin: 0 auto 10rpx; border-radius: 18rpx; }
.result-label { display: block; color: #2b3853; font-size: 22rpx; font-weight: 700; line-height: 1.35; }
.result-desc { display: block; margin-top: 6rpx; color: #9aa4b4; font-size: 18rpx; line-height: 1.35; }
.repair-actions-tip { margin-top: 18rpx; color: #9aa4b4; font-size: 20rpx; }
.responsibility-title { display: block; margin-top: 20rpx; color: #2b3853; font-size: 22rpx; font-weight: 700; }
.responsibility-list { display: flex; flex-wrap: wrap; margin-top: 12rpx; }
.responsibility-item { display: flex; align-items: center; min-width: 44%; margin: 0 12rpx 12rpx 0; padding: 14rpx; border: 2rpx solid #e7edf5; border-radius: 14rpx; color: #56647a; background: #f8fafc; font-size: 20rpx; }
.responsibility-item.active { border-color: #1677ff; color: #1677ff; background: #edf4ff; }
.responsibility-item > view { margin-left: 8rpx; min-width: 0; }
.responsibility-name, .responsibility-meta { display: block; }
.responsibility-name { color: #2b3853; font-size: 20rpx; font-weight: 600; line-height: 1.35; }
.responsibility-meta { margin-top: 4rpx; color: #9aa4b4; font-size: 17rpx; line-height: 1.35; }
.repair-action-list { display: flex; flex-wrap: wrap; margin-top: 12rpx; }
.repair-action-item { display: flex; align-items: center; min-height: 64rpx; margin: 0 12rpx 12rpx 0; padding: 0 18rpx; border: 2rpx solid #e7edf5; border-radius: 14rpx; color: #56647a; background: #f8fafc; font-size: 22rpx; flex-shrink: 0; }
.repair-action-item text { margin-left: 8rpx; }
.repair-action-item.active { border-color: #1677ff; color: #1677ff; background: #edf4ff; }
.repair-action-summary { margin-top: 2rpx; padding: 16rpx; border-radius: 14rpx; color: #2b3853; background: #f8fbff; font-size: 20rpx; line-height: 1.5; word-break: break-all; }
.fault-popup { max-height: 78vh; padding: 28rpx 24rpx calc(24rpx + env(safe-area-inset-bottom)); box-sizing: border-box; background: #ffffff; }
.fault-popup-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16rpx; }
.fault-popup-title, .fault-popup-subtitle { display: block; }
.fault-popup-title { color: #17233d; font-size: 28rpx; font-weight: 700; line-height: 1.35; }
.fault-popup-subtitle { margin-top: 8rpx; color: #7d899d; font-size: 20rpx; line-height: 1.35; }
.fault-popup-close { color: #1677ff; font-size: 22rpx; line-height: 1.35; flex-shrink: 0; }
.fault-popup-scroll { max-height: 48vh; margin-top: 20rpx; }
.fault-popup-list { margin-top: 0; }
.fault-popup-footer { display: flex; align-items: center; margin-top: 12rpx; }
.fault-popup-button { display: flex; align-items: center; justify-content: center; height: 78rpx; border-radius: 18rpx; font-size: 24rpx; font-weight: 700; }
.fault-popup-clear { width: 30%; color: #56647a; background: #f0f3f8; }
.fault-popup-confirm { flex: 1; margin-left: 14rpx; color: #ffffff; background: #1677ff; }
.repair-record-header { align-items: center; }
.parts-header { margin-top: 24rpx; }
.parts-title { color: #2b3853; font-size: 24rpx; font-weight: 700; }
.part-source-options { display: flex; align-items: center; width: fit-content; margin-top: 10rpx; overflow: hidden; border: 1rpx solid #dce6f3; border-radius: 12rpx; }
.part-source-option { min-width: 88rpx; padding: 8rpx 14rpx; color: #7d899d; background: #ffffff; font-size: 18rpx; line-height: 1.35; text-align: center; }
.part-source-option + .part-source-option { border-left: 1rpx solid #dce6f3; }
.part-source-option.active { color: #ffffff; background: #1677ff; }
.parts-actions { display: flex; align-items: center; flex: none; }
.parts-action { display: flex; align-items: center; justify-content: center; width: 100rpx; height: 64rpx; border-radius: 16rpx; font-size: 24rpx; flex-shrink: 0; }
.scan-part-action { color: #ffffff; background: #1677ff; }
.input-part-action { margin-left: 16rpx; color: #1677ff; background: #edf4ff; }
.part-row { display: flex; align-items: center; margin-top: 12rpx; padding: 12rpx; border-radius: 16rpx; background: #f8fafc; }
.part-index { display: flex; align-items: center; justify-content: center; width: 42rpx; height: 42rpx; margin-right: 12rpx; border-radius: 50%; color: #1677ff; background: #eaf2ff; font-size: 20rpx; }
.part-info { flex: 1; min-width: 0; }
.part-name, .part-code, .part-category { display: block; }
.part-name { color: #2b3853; font-size: 22rpx; font-weight: 600; }
.part-code { margin-top: 4rpx; color: #9aa4b4; font-size: 18rpx; }
.part-category { margin-top: 4rpx; color: #1677ff; font-size: 18rpx; }
.part-fault-entry { display: flex; align-items: center; margin-top: 12rpx; padding: 14rpx 16rpx; border-radius: 14rpx; background: #ffffff; }
.part-fault-entry-label { color: #7d899d; font-size: 20rpx; line-height: 1.35; flex-shrink: 0; }
.part-fault-entry-value { margin: 0 12rpx; color: #2b3853; font-size: 20rpx; line-height: 1.4; flex: 1; min-width: 0; word-break: break-all; }
.part-stepper { display: flex; align-items: center; width: 172rpx; height: 52rpx; overflow: hidden; border: 1rpx solid #dce6f3; border-radius: 12rpx; background: #ffffff; flex: none; }
.stepper-button { display: flex; align-items: center; justify-content: center; width: 52rpx; height: 52rpx; background: #f8fafc; flex: none; }
.stepper-button + .stepper-value { border-left: 1rpx solid #dce6f3; }
.stepper-value { display: flex; align-items: center; justify-content: center; height: 52rpx; color: #2b3853; font-size: 22rpx; font-weight: 600; flex: 1; }
.stepper-value + .stepper-button { border-left: 1rpx solid #dce6f3; }
.part-remove { display: flex; align-items: center; justify-content: center; width: 52rpx; height: 52rpx; margin-left: 10rpx; flex: none; }
.part-input-dialog { width: 580rpx; padding: 32rpx; box-sizing: border-box; background: #ffffff; }
.part-input-title { display: block; margin-bottom: 24rpx; color: #17233d; font-size: 28rpx; font-weight: 700; }
.part-input-actions { display: flex; justify-content: flex-end; margin-top: 28rpx; }
.part-input-cancel, .part-input-confirm { padding: 14rpx 28rpx; border-radius: 12rpx; font-size: 24rpx; flex-shrink: 0; }
.part-input-cancel { color: #56647a; background: #f0f3f8; }
.part-input-confirm { margin-left: 16rpx; color: #ffffff; background: #1677ff; }
.evidence-row { display: flex; flex-wrap: nowrap; align-items: flex-start; overflow-x: auto; margin-top: 18rpx; padding-bottom: 4rpx; }
.evidence-row > * { margin-right: 14rpx; margin-bottom: 14rpx; }
.upload-box, .evidence-image { display: flex; flex-direction: column; align-items: center; justify-content: center; width: 144rpx; height: 144rpx; border-radius: 20rpx; font-size: 20rpx; }
.upload-box { border: 2rpx dashed #ffb47e; color: #ff8a3d; background: #fffaf6; }
.evidence-image { position: relative; overflow: hidden; color: #ffffff; background: #b7c6d8; }
.evidence-image image { width: 100%; height: 100%; }
.upload-label { position: absolute; right: 8rpx; bottom: 8rpx; padding: 4rpx 8rpx; border-radius: 8rpx; background: rgba(0, 0, 0, 0.45); font-size: 18rpx; line-height: 1.2; }
.upload-delete { position: absolute; top: 8rpx; right: 8rpx; display: flex; align-items: center; justify-content: center; width: 34rpx; height: 34rpx; border-radius: 50%; background: rgba(0, 0, 0, 0.55); }
.bottom-actions { position: fixed; right: 0; bottom: 0; left: 0; z-index: 10; display: flex; padding: 22rpx 24rpx calc(22rpx + env(safe-area-inset-bottom)); background: rgba(255, 255, 255, 0.98); box-shadow: 0 -8rpx 24rpx rgba(36, 59, 92, 0.08); }
.draft-button, .complete-button { display: flex; align-items: center; justify-content: center; height: 82rpx; border-radius: 20rpx; font-size: 24rpx; font-weight: 700; flex-shrink: 0; }
.draft-button { width: 30%; color: #56647a; background: #f0f3f8; }
.complete-button { flex: 1; margin-left: 14rpx; color: #ffffff; background: linear-gradient(135deg, #2f6cf6 0%, #35bfa3 100%); }
.theme-dark.repair-page {
background: #151923;
}
.theme-dark .summary-item,
.theme-dark .section-card,
.theme-dark .evidence-panel,
.theme-dark .remark-panel,
.theme-dark .fault-popup,
.theme-dark .part-input-dialog,
.theme-dark .bottom-actions {
border-color: #364152;
background: #222936;
box-shadow: none;
}
.theme-dark .scan-input,
.theme-dark .device-found,
.theme-dark .exception-info,
.theme-dark .exception-empty,
.theme-dark .result-item,
.theme-dark .responsibility-item,
.theme-dark .repair-action-item,
.theme-dark .repair-action-summary,
.theme-dark .part-row,
.theme-dark .part-fault-entry,
.theme-dark .part-source-option,
.theme-dark .upload-box {
border-color: #364152;
background: #1c2431;
}
.theme-dark .section-title,
.theme-dark .device-found-value,
.theme-dark .info-value,
.theme-dark .result-label,
.theme-dark .responsibility-title,
.theme-dark .responsibility-name,
.theme-dark .part-name,
.theme-dark .part-fault-entry-value,
.theme-dark .fault-popup-title,
.theme-dark .part-input-title {
color: #f2f5fa;
}
.theme-dark .section-desc,
.theme-dark .info-label,
.theme-dark .result-desc,
.theme-dark .responsibility-meta,
.theme-dark .repair-actions-tip,
.theme-dark .part-code,
.theme-dark .part-category,
.theme-dark .part-fault-entry-label,
.theme-dark .fault-popup-subtitle {
color: #aeb9ca;
}
.theme-dark .info-row,
.theme-dark .record-item,
.theme-dark .part-source-option + .part-source-option,
.theme-dark .stepper-button + .stepper-value,
.theme-dark .stepper-value + .stepper-button {
border-color: #364152;
}
.theme-dark .part-stepper,
.theme-dark .stepper-button {
border-color: #364152;
background: #1c2431;
}
.theme-dark :deep(.u-input__input),
.theme-dark :deep(textarea) {
color: #f2f5fa !important;
}
</style>