You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

439 lines
21 KiB
Vue

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<template>
<view class="page-container">
<NavBar :title="t('sparepartOutbound.createTitle')" />
<!-- 操作按钮区 -->
<view class="action-row">
<view class="scan-input-row">
<input
id="sparepart-outbound-scan-input"
class="scan-input"
v-model="scanCodeInput"
placeholder="红外扫码或输入备件码"
confirm-type="done"
@confirm="onScanInputConfirm"
/>
</view>
<view class="action-btn select-btn" @click="handleSelectSparepart">
<text class="btn-text">选择备件</text>
</view>
</view>
<!-- 出库信息 -->
<view class="form-section">
<!-- 出库时间 -->
<view class="section-title-bar">
<view class="section-bar-line"></view>
<text class="section-title"><text class="required">*</text>出库时间</text>
</view>
<picker mode="date" :value="outboundDate" @change="handleDateChange">
<view class="form-row-card">
<text :class="{ placeholder: !outboundDate }">{{ outboundDate || '请选择出库时间' }}</text>
<text class="form-row-arrow">▼</text>
</view>
</picker>
<!-- 经办人 -->
<view class="section-title-bar" style="padding-top: 24rpx;">
<view class="section-bar-line"></view>
<text class="section-title"><text class="required">*</text>经办人</text>
</view>
<view class="form-row-card" @click="goSelectOperator">
<text :class="{ placeholder: !selectedOperatorName }">{{ selectedOperatorName || '请选择经办人' }}</text>
<text class="form-row-arrow">▶</text>
</view>
<!-- 备注 -->
<view class="section-title-bar" style="padding-top: 24rpx;">
<view class="section-bar-line"></view>
<text class="section-title">备注</text>
</view>
<view class="form-row-card remark-card">
<textarea v-model="remark" class="remark-textarea" placeholder="请输入备注信息" placeholder-class="remark-placeholder" :maxlength="500" auto-height />
</view>
<!-- 附件 -->
<view class="section-title-bar" style="padding-top: 24rpx;">
<view class="section-bar-line"></view>
<text class="section-title">附件</text>
</view>
<view class="attachment-area">
<view v-if="attachmentFileName" class="attachment-file-item">
<view class="file-icon">
<text class="file-icon-text">{{ getFileIcon(attachmentFileName) }}</text>
</view>
<view class="file-info">
<text class="file-name" :title="attachmentFileName">{{ attachmentFileName }}</text>
<text v-if="attachmentFileSize" class="file-size">{{ formatFileSize(attachmentFileSize) }}</text>
</view>
<view v-if="uploadLoading" class="file-uploading">
<text class="uploading-text">上传中...</text>
</view>
<view v-else class="file-delete" @click="removeAttachment">
<text class="delete-icon-sm">✕</text>
</view>
</view>
<view class="attachment-add-file" @click="handleAddAttachment" v-if="!attachmentFileName">
<text class="add-text">选取文件</text>
</view>
</view>
</view>
<!-- 出库清单 -->
<view class="sparepart-section">
<view class="section-title-bar">
<view class="section-bar-line"></view>
<text class="section-title">出库清单({{ itemList.length }}</text>
</view>
<view v-if="itemList.length" class="item-list">
<view v-for="(item, idx) in itemList" :key="idx" class="item-card">
<view class="item-delete-top" @click="removeItem(idx)"><text class="delete-icon">✕</text></view>
<view class="item-left">
<view class="item-image-wrap">
<image v-if="item._sparepart && getItemImage(item._sparepart)" :src="getItemImage(item._sparepart)" class="item-image" mode="aspectFill" />
<text v-else class="item-image-empty">📦</text>
</view>
<view class="item-info">
<text class="item-name">{{ item.productName }}</text>
<text class="item-spec">{{ item.productBarCode || '-' }}</text>
</view>
</view>
<view class="item-right">
<view class="item-qty-row">
<text class="item-qty-label">出库数量</text>
<text class="item-qty-value">{{ item.inputCount }}{{ item.purchaseUnitName }}</text>
</view>
</view>
</view>
</view>
<view v-else class="empty-wrap"><text class="empty-text">请扫码或选择备件</text></view>
</view>
<!-- 底部操作栏 -->
<view class="bottom-actions">
<view class="bottom-btn cancel-btn" @click="handleCancel">取消</view>
<view class="bottom-btn confirm-btn" @click="handleSubmit">确认出库</view>
</view>
<sv-focus-no-keyboard ref="focusNoKeyboardRef"></sv-focus-no-keyboard>
</view>
</template>
<script setup>
import { ref, nextTick } from 'vue'
import { onReady, onShow, onHide } from '@dcloudio/uni-app'
import { useI18n } from 'vue-i18n'
import NavBar from '@/components/common/NavBar.vue'
import { createSparepartOutbound } from '@/api/mes/sparepartOutbound'
import { getSparepartDetail } from '@/api/mes/sparepart'
import { getBaseUrl } from '@/utils/request'
import { getToken } from '@/utils/auth'
const { t } = useI18n()
const itemList = ref([])
const outboundDate = ref(formatDate(new Date()))
const selectedOperatorId = ref(null)
const selectedOperatorName = ref('')
const remark = ref('')
// 附件(单文件)
const fileUrl = ref('')
const attachmentFileName = ref('')
const attachmentFileSize = ref(0)
const uploadLoading = ref(false)
function formatDate(date) { const y = date.getFullYear(); const m = String(date.getMonth() + 1).padStart(2, '0'); const d = String(date.getDate()).padStart(2, '0'); return `${y}-${m}-${d}` }
function handleDateChange(e) { outboundDate.value = e.detail.value }
function getItemImage(sparepart) { if (!sparepart.images) return ''; if (Array.isArray(sparepart.images)) return String(sparepart.images[0] || ''); return String(sparepart.images).split(',')[0]?.trim() || '' }
function handleCancel() { getApp().globalData._sparepartOutboundItems = []; uni.navigateBack() }
function handleSelectSparepart() {
getApp().globalData._sparepartSelectFrom = 'outbound'
uni.navigateTo({ url: '/pages_function/pages/sparepartInbound/sparepartSelect' })
}
const scanCodeInput = ref('')
// 聚焦阻止键盘弹出
const focusNoKeyboardRef = ref(null)
const keywordInputSelector = '#sparepart-outbound-scan-input input, input#sparepart-outbound-scan-input'
function focusKeywordNoKeyboard() {
nextTick(() => {
setTimeout(() => {
focusNoKeyboardRef.value?.focus(keywordInputSelector)
}, 80)
})
}
function onScanInputConfirm() {
const code = scanCodeInput.value.trim()
if (!code) return
handleScanCode(code)
}
async function handleScanCode(code) {
let sparepartId = null
if (code.toUpperCase().startsWith('SPARE-')) sparepartId = code.replace(/SPARE-/i, '')
else { const idMatch = code.match(/(\d+)$/); if (idMatch) sparepartId = idMatch[1] }
if (!sparepartId) { uni.showToast({ title: '无法识别备件码', icon: 'none' }); return }
try {
const apiRes = await getSparepartDetail(sparepartId)
const detail = apiRes?.data || apiRes
if (detail && detail.id) {
getApp().globalData._sparepartFromScan = true
getApp().globalData._sparepartBeforeConfirm = detail
uni.navigateTo({ url: '/pages_function/pages/sparepartOutbound/sparepartConfirm' })
} else { uni.showToast({ title: '未找到备件: ' + sparepartId, icon: 'none' }) }
} catch (e) { console.error(e); uni.showToast({ title: '扫码失败', icon: 'none' }) }
}
function handleScan() {
uni.scanCode({
onlyFromCamera: false, scanType: ['barCode', 'qrCode'],
success: (res) => {
const code = (res.result || '').trim()
if (!code) return
scanCodeInput.value = code
handleScanCode(code)
},
fail: () => { uni.showToast({ title: '扫码失败', icon: 'none' }) }
})
}
function removeItem(idx) { itemList.value.splice(idx, 1); getApp().globalData._sparepartOutboundItems = [...itemList.value] }
// 经办人
function goSelectOperator() {
getApp().globalData._sparepartOutboundUserFrom = 'outbound'
uni.navigateTo({
url: '/pages_function/pages/moldRepair/userSelect?field=operator&from=sparepartOutbound'
})
}
// 附件
function getFileIcon(fileName) {
const ext = (fileName || '').split('.').pop()?.toLowerCase()
const iconMap = { pdf: '📄', doc: '📝', docx: '📝', xls: '📊', xlsx: '📊', ppt: '📽', pptx: '📽', txt: '📃', zip: '📦', rar: '📦', jpg: '🖼', jpeg: '🖼', png: '🖼', gif: '🖼', webp: '🖼' }
return iconMap[ext] || '📎'
}
function formatFileSize(bytes) {
if (!bytes) return '0 B'
if (bytes < 1024) return bytes + ' B'
if (bytes < 1048576) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / 1048576).toFixed(1) + ' MB'
}
const ALLOWED_EXTS = ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf']
const MAX_FILE_SIZE = 100 * 1024 * 1024
function handleAddAttachment() {
// #ifdef APP-PLUS
plus.gallery.pick(
(res) => {
const file = res?.files?.[0]
if (!file) return
const ext = (file.name || '').split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) { uni.showToast({ title: '不支持的文件类型:.' + (ext || '未知'), icon: 'none' }); return }
if (file.size > MAX_FILE_SIZE) { uni.showToast({ title: '文件大小不能超过100MB', icon: 'none' }); return }
uploadFile(file)
},
(e) => { console.log('选择文件失败:', e) },
{ filter: 'all', multiple: false }
)
// #endif
// #ifndef APP-PLUS
uni.chooseFile({
count: 1, type: 'all',
success: (res) => {
const tempFile = res.tempFiles?.[0]
if (!tempFile) return
const ext = (tempFile.name || '').split('.').pop()?.toLowerCase()
if (!ALLOWED_EXTS.includes(ext)) { uni.showToast({ title: '不支持的文件类型:.' + (ext || '未知'), icon: 'none' }); return }
if (tempFile.size > MAX_FILE_SIZE) { uni.showToast({ title: '文件大小不能超过100MB', icon: 'none' }); return }
uploadFile(tempFile)
},
fail: () => {
uni.chooseImage({
count: 1, sizeType: ['compressed'], sourceType: ['album', 'camera'],
success: (imgRes) => {
const path = imgRes.tempFilePaths?.[0]
if (!path) return
const parts = path.split('/')
uploadFile({ path, name: parts[parts.length - 1] || 'image.jpg', size: 0 })
}
})
}
})
// #endif
}
async function uploadFile(file) {
uploadLoading.value = true
attachmentFileName.value = file.name || 'unknown'
attachmentFileSize.value = file.size || 0
try {
const res = await new Promise((resolve, reject) => {
uni.uploadFile({
url: getBaseUrl() + '/admin-api/infra/file/upload',
filePath: file.path,
name: 'file',
header: { 'Authorization': 'Bearer ' + (getToken() || ''), 'tenantId': '1' },
success: (uploadRes) => {
if (uploadRes.statusCode === 200) {
try { resolve(JSON.parse(uploadRes.data)) }
catch (e) { reject(new Error('解析上传结果失败')) }
} else { reject(new Error('上传失败,状态码:' + uploadRes.statusCode)) }
},
fail: (err) => { reject(err) }
})
})
if (res.code === 0 && res.data) {
const { fileName, fileUrl: url } = res.data
fileUrl.value = JSON.stringify({ fileName: fileName || file.name, fileUrl: url })
attachmentFileName.value = fileName || file.name
uni.showToast({ title: '上传成功', icon: 'success' })
} else {
throw new Error(res.msg || '上传失败')
}
} catch (e) {
attachmentFileName.value = ''; attachmentFileSize.value = 0; fileUrl.value = ''
uni.showToast({ title: String(e?.message || '上传失败').substring(0, 50), icon: 'none' })
} finally {
uploadLoading.value = false
}
}
function removeAttachment() {
attachmentFileName.value = ''; attachmentFileSize.value = 0; fileUrl.value = ''
}
async function handleSubmit() {
if (!itemList.value.length) { uni.showToast({ title: '请先添加备件', icon: 'none' }); return }
if (!outboundDate.value) { uni.showToast({ title: '请选择出库时间', icon: 'none' }); return }
if (!selectedOperatorId.value) { uni.showToast({ title: '请选择经办人', icon: 'none' }); return }
const now = new Date()
const [y, m, d] = outboundDate.value.split('-').map(Number)
const outTime = new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds()).getTime()
let totalCount = 0
const items = itemList.value.map(item => {
totalCount += Number(item.count) || 0
const it = {
warehouseId: item.warehouseId || null,
areaId: item.areaId || null,
productId: item.productId,
count: Number(item.count) || 0,
outUsageType: String(item.outUsageType || 3)
}
if (item.repairId) it.repairId = Number(item.repairId)
if (item.repairDeviceId) it.repairDeviceId = Number(item.repairDeviceId)
if (item.maintenanceId) it.maintenanceId = Number(item.maintenanceId)
return it
})
const submitData = {
isCode: true,
outTime: outTime,
stockUserId: String(selectedOperatorId.value),
stockUserName: selectedOperatorName.value || '',
responserId: Number(selectedOperatorId.value),
status: 0,
totalCount: totalCount,
totalPrice: 0,
remark: remark.value || '',
items: items
}
if (fileUrl.value) { submitData.fileUrl = fileUrl.value }
console.log('=== 出库提交 ===')
console.log(JSON.stringify(submitData))
uni.showLoading({ title: '提交中...', mask: true })
try {
await createSparepartOutbound(submitData)
uni.hideLoading(); getApp().globalData._sparepartOutboundItems = []
uni.showToast({ title: t('functionCommon.createSuccess'), icon: 'success' })
setTimeout(() => uni.navigateBack(), 1500)
} catch (e) {
uni.hideLoading(); const msg = e?.message || e?.data?.msg || e?.response?.data?.msg || t('functionCommon.saveFailed')
console.error('出库提交失败:', e, JSON.stringify(e))
uni.showToast({ title: String(msg).substring(0, 50), icon: 'none' })
}
}
onReady(() => { focusKeywordNoKeyboard() })
onShow(() => { const items = getApp().globalData?._sparepartOutboundItems; if (Array.isArray(items)) itemList.value = [...items]; const userResult = getApp().globalData?._sparepartOutboundUserSelectResult; if (userResult) { selectedOperatorId.value = userResult.user.id; selectedOperatorName.value = userResult.user.nickname || userResult.user.userName || userResult.user.name || ''; getApp().globalData._sparepartOutboundUserSelectResult = null } })
onHide(() => {})
</script>
<style lang="scss" scoped>
.page-container { min-height: 100vh; background: #f5f6f8; padding-bottom: calc(120rpx + env(safe-area-inset-bottom)); }
.action-row { display: flex; align-items: center; gap: 16rpx; padding: 20rpx 24rpx; }
.scan-input-row { flex: 1; display: flex; align-items: center; min-height: 72rpx !important; height: 72rpx !important; border-radius: 10rpx; overflow: hidden; }
.scan-input { flex: 1; min-height: 72rpx !important; height: 72rpx !important; padding: 0 20rpx; font-size: 26rpx; color: #333; background: #fff; border: 1rpx solid #d0d5dd; border-radius: 10rpx; }
.action-btn { display: flex; align-items: center; justify-content: center; height: 72rpx; padding: 0 24rpx; border-radius: 10rpx; background: #1f4b79; color: #fff; font-size: 26rpx; font-weight: 600; white-space: nowrap; .btn-icon-wrap { width: 44rpx; height: 44rpx; display: flex; align-items: center; justify-content: center; } .btn-icon { font-size: 36rpx; color: #fff; } .btn-text { font-size: 26rpx; font-weight: 600; color: #fff; } }
.form-section { padding-bottom: 16rpx; }
.section-title-bar { display: flex; align-items: center; gap: 10rpx; padding: 24rpx 24rpx 18rpx; }
.section-bar-line { width: 6rpx; height: 32rpx; border-radius: 3rpx; background: #2563eb; flex-shrink: 0; }
.section-title { font-size: 30rpx; font-weight: 700; color: #1a1a1a; }
.required { color: #ef4444; margin-right: 2rpx; }
.form-row-card { position: relative; display: flex; align-items: center; justify-content: space-between; background: #fff; border-radius: 16rpx; padding: 26rpx 24rpx; margin: 0 24rpx 16rpx; box-shadow: 0 4rpx 16rpx rgba(15,23,42,0.04); font-size: 28rpx; color: #374151; .placeholder { color: #9ca3af; } }
.form-row-arrow { font-size: 20rpx; color: #999; flex-shrink: 0; }
.purpose-card { display: flex; gap: 20rpx; margin: 0 24rpx 20rpx; }
.purpose-item { flex: 1; display: flex; align-items: center; justify-content: center; height: 92rpx; background: #fff; border: 2rpx solid #e5e7eb; border-radius: 14rpx; font-size: 28rpx; color: #6b7280; &.active { border-color: #2563eb; background: #eff6ff; color: #2563eb; font-weight: 600; } }
.dropdown-panel { position: absolute; top: 100%; left: 0; right: 0; z-index: 200; margin-top: 4rpx; background: #fff; border: 1rpx solid #e0e0e0; border-radius: 12rpx; box-shadow: 0 8rpx 30rpx rgba(0,0,0,0.1); overflow: hidden; }
.dropdown-scroll { max-height: 360rpx; overflow-y: auto; }
.dropdown-item { display: flex; align-items: center; justify-content: space-between; padding: 20rpx 24rpx; border-bottom: 1rpx solid #f0f0f0; &:last-child { border-bottom: 0; } &.active { background: #f0f5ff; } }
.dropdown-item-text { font-size: 27rpx; color: #333; }
.dropdown-check { font-size: 28rpx; color: #2563eb; font-weight: 700; }
.dropdown-empty { padding: 32rpx; text-align: center; color: #999; font-size: 26rpx; }
.remark-card { padding: 20rpx 24rpx; }
.remark-textarea { width: 100%; min-height: 120rpx; font-size: 27rpx; color: #374151; line-height: 1.6; box-sizing: border-box; }
.remark-placeholder { color: #bbb; }
.attachment-area { padding: 0 24rpx 16rpx; }
.attachment-list { display: flex; flex-direction: column; gap: 12rpx; }
.attachment-file-item { display: flex; align-items: center; gap: 16rpx; background: #fff; border-radius: 12rpx; padding: 18rpx 20rpx; box-shadow: 0 2rpx 8rpx rgba(15,23,42,0.03); }
.file-icon { width: 72rpx; height: 72rpx; border-radius: 10rpx; background: #f1f5f9; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.file-icon-text { font-size: 36rpx; }
.file-info { flex: 1; min-width: 0; }
.file-name { font-size: 26rpx; color: #1f2937; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.file-size { font-size: 22rpx; color: #9ca3af; margin-top: 4rpx; display: block; }
.file-delete { width: 48rpx; height: 48rpx; border-radius: 24rpx; background: #fee2e2; display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
.delete-icon-sm { font-size: 22rpx; color: #dc2626; }
.file-uploading { flex-shrink: 0; .uploading-text { font-size: 22rpx; color: #2563eb; } }
.attachment-add-file { display: flex; align-items: center; justify-content: center; height: 88rpx; background: #fff; border: 2rpx dashed #cbd5e1; border-radius: 12rpx; }
.add-text { font-size: 26rpx; color: #6b7280; }
.sparepart-section { padding: 0; }
.item-list { padding: 0 24rpx; }
.item-card { position: relative; display: flex; align-items: center; justify-content: space-between; background: #fff; border-radius: 14rpx; padding: 20rpx; margin-bottom: 16rpx; box-shadow: 0 2rpx 12rpx rgba(15,23,42,0.04); }
.item-delete-top { position: absolute; top: 10rpx; right: 10rpx; width: 40rpx; height: 40rpx; border-radius: 20rpx; background: #fee2e2; display: flex; align-items: center; justify-content: center; z-index: 1; .delete-icon { font-size: 20rpx; color: #dc2626; } }
.item-left { display: flex; align-items: center; flex: 1; min-width: 0; padding-right: 36rpx; }
.item-image-wrap { width: 80rpx; height: 80rpx; border-radius: 10rpx; overflow: hidden; background: #f8fafc; border: 1rpx solid #f0f0f0; flex-shrink: 0; display: flex; align-items: center; justify-content: center; }
.item-image { width: 100%; height: 100%; }
.item-image-empty { font-size: 36rpx; opacity: 0.3; }
.item-info { margin-left: 16rpx; flex: 1; min-width: 0; }
.item-name { font-size: 28rpx; font-weight: 600; color: #1a1a1a; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-spec { font-size: 24rpx; color: #9ca3af; margin-top: 6rpx; display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.item-right { display: flex; flex-direction: column; align-items: flex-end; margin-left: 16rpx; flex-shrink: 0; padding-right: 50rpx; }
.item-qty-row { display: flex; flex-direction: column; align-items: flex-end; }
.item-qty-label { font-size: 22rpx; color: #9ca3af; }
.item-qty-value { font-size: 28rpx; font-weight: 700; color: #2563eb; margin-top: 4rpx; }
.empty-wrap { padding: 160rpx 0; text-align: center; }
.empty-text { font-size: 28rpx; color: #94a3b8; }
.bottom-actions { position: fixed; left: 0; right: 0; bottom: 0; display: flex; gap: 18rpx; padding: 18rpx 24rpx calc(18rpx + env(safe-area-inset-bottom)); background: #fff; box-shadow: 0 -8rpx 24rpx rgba(15,23,42,0.06); z-index: 99; }
.bottom-btn { flex: 1; height: 84rpx; line-height: 84rpx; text-align: center; border-radius: 16rpx; font-size: 30rpx; font-weight: 600; &:active { opacity: 0.85; } }
.cancel-btn { background: #eef2f7; color: #475569; }
.confirm-btn { background: #1f4b79; color: #ffffff; }
</style>