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

981 lines
43 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>
<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>