Files
klp-mono/apps/hand-factory/pages/eqp/eqp.vue
砂糖 f31ffa45dd feat: 新增设备巡检功能模块
1. 新增设备巡检页面路由与入口
2. 添加OSS对象存储与设备巡检相关API
3. 调整首页tab权限配置项数量
4. 在我的页面新增设备巡检跳转入口
2026-05-25 14:06:38 +08:00

887 lines
21 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="inspection-container">
<!-- 扫码区域 -->
<view class="scan-section" @click="handleScan">
<view class="scan-btn">
<text class="scan-icon">📷</text>
<text class="scan-text">{{ partInfo ? '重新扫码' : '点击扫码' }}</text>
</view>
</view>
<!-- 加载状态 -->
<view v-if="loading" class="loading-state">
<uni-load-more status="loading" />
</view>
<!-- 已扫码显示巡检信息 -->
<view v-if="partInfo && !loading" class="content-area">
<!-- 部位 + 巡检信息 -->
<view class="part-card">
<view class="part-fields">
<view class="field-row">
<text class="field-label">检验部位</text>
<text class="field-value">{{ partInfo.inspectPart || '-' }}</text>
</view>
<view class="field-row">
<text class="field-label">产线</text>
<text class="field-value">{{ partInfo.productionLine || '-' }}</text>
</view>
<view class="field-row">
<text class="field-label">线别</text>
<text class="field-value">{{ partInfo.lineSection || '-' }}</text>
</view>
<view class="field-row">
<text class="field-label">备注</text>
<text class="field-value">{{ partInfo.remark || '-' }}</text>
</view>
</view>
<view class="inspect-info">
<view class="info-item">
<text class="info-label">巡检人</text>
<text class="info-value">{{ inspector }}</text>
</view>
<view class="info-item">
<text class="info-label">巡检时间</text>
<text class="info-value">{{ inspectTime }}</text>
</view>
</view>
</view>
<!-- 班次选择 -->
<view class="shift-section">
<text class="shift-label">班次</text>
<view class="shift-radio-group">
<view class="shift-radio" :class="{ active: shift === 1 }" @click="shift = 1">
<text class="shift-radio-dot" />
<text>白班</text>
</view>
<view class="shift-radio" :class="{ active: shift === 2 }" @click="shift = 2">
<text class="shift-radio-dot" />
<text>夜班</text>
</view>
</view>
</view>
<!-- 待检项列表 -->
<view class="checklist-section">
<view class="section-title">
<text>待检项{{ checklist.length }}</text>
</view>
<view v-if="checklist.length === 0" class="empty-state">
<text class="empty-text">该部位暂无待检项</text>
</view>
<view v-for="item in checklist" :key="item.checkId" class="check-item-card">
<view class="check-item-info">
<text class="check-item-name">{{ item.checkName || item.checkContent || '检验项' }}</text>
<text v-if="item.checkContent" class="check-item-content">{{ item.checkContent }}</text>
<text v-if="item.standard" class="check-item-standard">标准{{ item.standard }}</text>
</view>
<!-- 照片 -->
<view class="photo-section">
<view v-if="uploadingMap[item.checkId]" class="photo-uploading">
<uni-load-more status="loading" />
</view>
<view v-else-if="photoMap[item.checkId]" class="photo-preview" @click="previewPhoto(item.checkId)">
<image class="photo-img" :src="photoMap[item.checkId]" mode="aspectFill" />
<view class="photo-delete" @click.stop="deletePhoto(item.checkId)"></view>
</view>
<view v-else class="photo-add" @click="choosePhoto(item)">
<text class="photo-add-icon">📷</text>
<text class="photo-add-text">拍照/选图</text>
</view>
</view>
<view class="result-section">
<!-- 已选择通过 -->
<view v-if="selectedResults[item.checkId] === 1" class="result-row">
<view class="result-tag result-pass">
<text class="result-icon"></text>
<text>正常</text>
</view>
<text class="result-time">{{ getResultTime(item.checkId) }}</text>
</view>
<!-- 已选择不通过 -->
<view v-if="selectedResults[item.checkId] === 2" class="result-row">
<view class="result-tag result-fail">
<text class="result-icon"></text>
<text>故障</text>
</view>
<text class="result-time">{{ getResultTime(item.checkId) }}</text>
</view>
<!-- 未选择显示选择按钮 -->
<view v-if="!selectedResults[item.checkId] && !pendingFail[item.checkId]" class="action-btns">
<button class="btn btn-pass" :disabled="uploadingMap[item.checkId]"
@click="handleResult(item, 1)">正常</button>
<button class="btn btn-fail" :disabled="uploadingMap[item.checkId]"
@click="handleFailSelect(item)">故障</button>
</view>
<!-- 选择故障后填写异常描述并提交 -->
<view v-if="pendingFail[item.checkId]" class="abnormal-section">
<textarea v-model="abnormalDescMap[item.checkId]" class="abnormal-input"
placeholder="请输入异常描述(必填)" :maxlength="200" />
<view class="abnormal-actions">
<button class="btn btn-cancel" @click="cancelFail(item)">取消</button>
<button class="btn btn-submit" :disabled="submitting[item.checkId]"
@click="handleResult(item, 2)">
{{ submitting[item.checkId] ? '提交中...' : '确认故障' }}
</button>
</view>
</view>
</view>
<!-- 故障结果展示 -->
<view v-if="selectedResults[item.checkId] === 2" class="abnormal-section result-abnormal">
<text class="abnormal-label">异常描述</text>
<text class="abnormal-text">{{ abnormalDescMap[item.checkId] || '未填写' }}</text>
</view>
</view>
</view>
</view>
<!-- 未扫码初始状态 -->
<view v-if="!partInfo && !loading" class="initial-state">
<text class="initial-icon">🔍</text>
<text class="initial-text">请扫描设备部位二维码开始巡检</text>
</view>
</view>
</template>
<script>
import {
listEquipmentChecklist
} from '@/api/wms/equipmentChecklist'
import {
getEquipmentPart
} from '@/api/wms/equipmentPart'
import {
addEquipmentInspectionRecord
} from '@/api/wms/equipmentInspectionRecord'
import upload from '@/utils/upload'
export default {
data() {
return {
statusBarHeight: 0,
loading: false,
partInfo: null,
checklist: [],
selectedResults: {},
resultTimes: {},
abnormalDescMap: {},
submitting: {},
pendingFail: {},
shift: 1,
inspector: '',
inspectTime: '',
photoMap: {},
uploadingMap: {}
}
},
created() {
const systemInfo = uni.getSystemInfoSync()
this.statusBarHeight = systemInfo.statusBarHeight || 0
this.inspector = this.$store.state.user.nickName || this.$store.state.user.name || '未知'
this.inspectTime = this.formatTime(new Date())
const hour = new Date().getHours()
this.shift = hour >= 6 && hour < 18 ? 1 : 2
console.log('[设备巡检] 初始化 - 巡检人:', this.inspector, '班次:', this.shift, '时间:', this.inspectTime)
},
methods: {
async handleScan() {
try {
const code = await this.scan()
console.log('[设备巡检] 扫码结果:', code)
if (!code) return
if (typeof code !== 'string' || !code.startsWith('eqpPart::')) {
uni.showToast({
title: '二维码无效,请扫描设备部位二维码',
icon: 'none'
})
return
}
const partId = code.replace('eqpPart::', '')
console.log('[设备巡检] 解析 partId:', partId)
if (!partId) {
uni.showToast({
title: '二维码格式错误',
icon: 'none'
})
return
}
this.loading = true
this.partInfo = null
this.checklist = []
this.selectedResults = {}
this.resultTimes = {}
this.abnormalDescMap = {}
this.submitting = {}
this.pendingFail = {}
this.photoMap = {}
this.uploadingMap = {}
try {
console.log('[设备巡检] 请求 getEquipmentPart, partId:', partId)
const partRes = await getEquipmentPart(partId)
console.log('[设备巡检] getEquipmentPart 响应:', partRes)
this.partInfo = partRes.data || { partId }
console.log('[设备巡检] 请求 listEquipmentChecklist, params:', { partId })
const checkRes = await listEquipmentChecklist({ partId })
console.log('[设备巡检] listEquipmentChecklist 响应:', checkRes)
this.checklist = checkRes.rows || []
} catch (err) {
console.error('[设备巡检] 获取数据失败:', err)
uni.showToast({
title: '获取数据失败',
icon: 'none'
})
}
} catch (err) {
// 用户取消扫码
} finally {
this.loading = false
}
},
scan() {
return new Promise((resolve, reject) => {
uni.scanCode({
success(res) {
resolve(res.result)
},
fail() {
reject()
}
})
})
},
formatTime(date) {
const d = new Date(date)
const bj = new Date(d.getTime() + 8 * 60 * 60 * 1000)
const y = bj.getUTCFullYear()
const M = (bj.getUTCMonth() + 1).toString().padStart(2, '0')
const day = bj.getUTCDate().toString().padStart(2, '0')
const h = bj.getUTCHours().toString().padStart(2, '0')
const m = bj.getUTCMinutes().toString().padStart(2, '0')
const s = bj.getUTCSeconds().toString().padStart(2, '0')
return y + '-' + M + '-' + day + ' ' + h + ':' + m + ':' + s
},
getResultTime(checkId) {
return this.resultTimes[checkId] || ''
},
handleFailSelect(item) {
this.pendingFail = {
...this.pendingFail,
[item.checkId]: true
}
this.abnormalDescMap = {
...this.abnormalDescMap,
[item.checkId]: ''
}
},
cancelFail(item) {
const pending = { ...this.pendingFail }
delete pending[item.checkId]
this.pendingFail = pending
},
choosePhoto(item) {
const checkId = item.checkId
uni.chooseImage({
count: 1,
sourceType: ['camera', 'album'],
success: (res) => {
const filePath = res.tempFilePaths[0]
this.uploadPhoto(checkId, filePath)
}
})
},
async uploadPhoto(checkId, filePath) {
this.uploadingMap = { ...this.uploadingMap, [checkId]: true }
try {
console.log('[设备巡检] 上传照片, checkId:', checkId, 'filePath:', filePath)
const res = await upload({
url: '/system/oss/upload',
filePath,
name: 'file'
})
console.log('[设备巡检] 上传照片响应:', res)
const url = res.data?.url || res.url || res.data || ''
if (!url) {
uni.showToast({ title: '上传失败返回URL为空', icon: 'none' })
return
}
this.photoMap = { ...this.photoMap, [checkId]: url }
uni.showToast({ title: '照片上传成功', icon: 'success' })
} catch (err) {
console.error('[设备巡检] 上传照片失败:', err)
uni.showToast({ title: '上传照片失败,请重试', icon: 'none' })
} finally {
this.uploadingMap = { ...this.uploadingMap, [checkId]: false }
}
},
deletePhoto(checkId) {
const map = { ...this.photoMap }
delete map[checkId]
this.photoMap = map
},
previewPhoto(checkId) {
const url = this.photoMap[checkId]
if (url) {
uni.previewImage({ urls: [url] })
}
},
async handleResult(item, runStatus) {
const checkId = item.checkId
if (this.submitting[checkId]) return
console.log('[设备巡检] 提交巡检结果, checkId:', checkId, 'runStatus:', runStatus, 'item:', item)
this.submitting = {
...this.submitting,
[checkId]: true
}
try {
const abnormalDesc = runStatus === 2 ? (this.abnormalDescMap[checkId] || '') : ''
const now = new Date()
const recordData = {
checkId,
runStatus,
abnormalDesc,
shift: this.shift,
inspector: this.inspector,
inspectTime: this.formatTime(now),
photo: this.photoMap[checkId] || ''
}
console.log('[设备巡检] 请求 addEquipmentInspectionRecord, data:', recordData)
const recordRes = await addEquipmentInspectionRecord(recordData)
console.log('[设备巡检] addEquipmentInspectionRecord 响应:', recordRes)
const timeStr = now.getHours().toString().padStart(2, '0') + ':' + now.getMinutes().toString()
.padStart(2, '0')
this.selectedResults = {
...this.selectedResults,
[checkId]: runStatus
}
this.resultTimes = {
...this.resultTimes,
[checkId]: timeStr
}
const pending = { ...this.pendingFail }
delete pending[checkId]
this.pendingFail = pending
uni.showToast({
title: runStatus === 1 ? '已标记正常' : '已标记故障',
icon: 'success'
})
} catch (err) {
console.error('[设备巡检] 提交巡检结果失败:', err)
uni.showToast({
title: '提交失败,请重试',
icon: 'none'
})
} finally {
this.submitting = {
...this.submitting,
[checkId]: false
}
}
}
}
}
</script>
<style scoped lang="scss">
.inspection-container {
min-height: 100vh;
background: #f5f7fa;
display: flex;
flex-direction: column;
.custom-nav-bar {
background: #ffffff;
border-bottom: 1rpx solid #f0f0f0;
.nav-content {
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
.nav-title {
font-size: 34rpx;
font-weight: 600;
color: #333333;
}
}
}
.scan-section {
padding: 40rpx 30rpx;
display: flex;
justify-content: center;
.scan-btn {
width: 100%;
height: 160rpx;
background: linear-gradient(135deg, #1a73e8, #4a90d9);
border-radius: 16rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
box-shadow: 0 4rpx 20rpx rgba(26, 115, 232, 0.3);
.scan-icon {
font-size: 48rpx;
margin-bottom: 8rpx;
}
.scan-text {
font-size: 32rpx;
color: #ffffff;
font-weight: 500;
}
&:active {
opacity: 0.85;
}
}
}
.loading-state {
padding: 100rpx 0;
}
.content-area {
flex: 1;
padding: 0 30rpx;
.part-card {
background: #ffffff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
.part-fields {
.field-row {
display: flex;
margin-bottom: 12rpx;
&:last-child {
margin-bottom: 20rpx;
}
.field-label {
font-size: 26rpx;
color: #999999;
width: 120rpx;
flex-shrink: 0;
}
.field-value {
font-size: 26rpx;
color: #333333;
flex: 1;
}
}
}
.inspect-info {
display: flex;
gap: 40rpx;
padding-top: 16rpx;
border-top: 1rpx solid #f0f0f0;
.info-item {
display: flex;
align-items: center;
.info-label {
font-size: 24rpx;
color: #999999;
margin-right: 12rpx;
}
.info-value {
font-size: 26rpx;
color: #333333;
font-weight: 500;
}
}
}
}
.shift-section {
background: #ffffff;
border-radius: 12rpx;
padding: 24rpx 30rpx;
margin-bottom: 20rpx;
display: flex;
align-items: center;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
.shift-label {
font-size: 28rpx;
color: #333333;
font-weight: 500;
margin-right: 30rpx;
}
.shift-radio-group {
display: flex;
gap: 24rpx;
.shift-radio {
display: flex;
align-items: center;
padding: 12rpx 24rpx;
border-radius: 8rpx;
background: #f5f5f5;
font-size: 26rpx;
color: #666666;
.shift-radio-dot {
width: 18rpx;
height: 18rpx;
border-radius: 50%;
border: 2rpx solid #cccccc;
margin-right: 10rpx;
}
&.active {
background: #e8f0fe;
color: #1a73e8;
.shift-radio-dot {
background: #1a73e8;
border-color: #1a73e8;
}
}
&:active {
opacity: 0.7;
}
}
}
}
.checklist-section {
.section-title {
font-size: 28rpx;
color: #666666;
margin-bottom: 20rpx;
padding-left: 10rpx;
}
.check-item-card {
background: #ffffff;
border-radius: 12rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
.check-item-info {
margin-bottom: 24rpx;
.check-item-name {
font-size: 30rpx;
color: #333333;
font-weight: 500;
display: block;
}
.check-item-content {
font-size: 26rpx;
color: #666666;
margin-top: 10rpx;
display: block;
}
.check-item-standard {
font-size: 24rpx;
color: #999999;
margin-top: 8rpx;
display: block;
}
}
.photo-section {
margin-bottom: 20rpx;
.photo-add {
width: 160rpx;
height: 160rpx;
background: #f5f5f5;
border: 2rpx dashed #cccccc;
border-radius: 12rpx;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
.photo-add-icon {
font-size: 40rpx;
margin-bottom: 6rpx;
}
.photo-add-text {
font-size: 22rpx;
color: #999999;
}
&:active {
opacity: 0.7;
}
}
.photo-uploading {
width: 160rpx;
height: 160rpx;
display: flex;
align-items: center;
justify-content: center;
background: #f8f8f8;
border-radius: 12rpx;
}
.photo-preview {
position: relative;
width: 160rpx;
height: 160rpx;
border-radius: 12rpx;
overflow: hidden;
.photo-img {
width: 100%;
height: 100%;
}
.photo-delete {
position: absolute;
top: 4rpx;
right: 4rpx;
width: 36rpx;
height: 36rpx;
line-height: 36rpx;
text-align: center;
background: rgba(0, 0, 0, 0.5);
color: #ffffff;
font-size: 22rpx;
border-radius: 50%;
}
&:active {
opacity: 0.85;
}
}
}
.result-section {
.result-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12rpx 0;
.result-tag {
display: flex;
align-items: center;
padding: 10rpx 28rpx;
border-radius: 8rpx;
font-size: 28rpx;
font-weight: 500;
.result-icon {
margin-right: 8rpx;
font-size: 30rpx;
}
&.result-pass {
background: #e8f5e9;
color: #2e7d32;
}
&.result-fail {
background: #fbe9e7;
color: #c62828;
}
}
.result-time {
font-size: 24rpx;
color: #999999;
}
}
.action-btns {
display: flex;
gap: 20rpx;
.btn {
flex: 1;
height: 76rpx;
line-height: 76rpx;
text-align: center;
border-radius: 8rpx;
font-size: 28rpx;
border: none;
&::after {
border: none;
}
&.btn-pass {
background: #e8f5e9;
color: #2e7d32;
&:disabled {
opacity: 0.4;
}
}
&.btn-fail {
background: #fbe9e7;
color: #c62828;
&:disabled {
opacity: 0.4;
}
}
&:active {
opacity: 0.7;
}
}
}
}
.abnormal-section {
margin-top: 20rpx;
padding-top: 20rpx;
border-top: 1rpx solid #f0f0f0;
.abnormal-input {
width: 100%;
min-height: 120rpx;
background: #f8f8f8;
border-radius: 8rpx;
padding: 20rpx;
font-size: 26rpx;
color: #333333;
box-sizing: border-box;
}
.abnormal-actions {
display: flex;
gap: 20rpx;
margin-top: 20rpx;
.btn {
flex: 1;
height: 68rpx;
line-height: 68rpx;
text-align: center;
border-radius: 8rpx;
font-size: 26rpx;
border: none;
&::after {
border: none;
}
&.btn-cancel {
background: #f5f5f5;
color: #666666;
}
&.btn-submit {
background: #c62828;
color: #ffffff;
&:disabled {
opacity: 0.6;
}
}
&:active {
opacity: 0.7;
}
}
}
&.result-abnormal {
border-top: none;
margin-top: 0;
padding-top: 8rpx;
.abnormal-label {
font-size: 24rpx;
color: #c62828;
}
.abnormal-text {
font-size: 24rpx;
color: #666666;
margin-left: 10rpx;
}
}
}
}
}
}
.initial-state {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 200rpx 0;
.initial-icon {
font-size: 120rpx;
margin-bottom: 30rpx;
}
.initial-text {
font-size: 30rpx;
color: #999999;
}
}
.empty-state {
padding: 60rpx 0;
.empty-text {
font-size: 28rpx;
color: #999999;
text-align: center;
display: block;
}
}
}
</style>