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.

683 lines
22 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('sparepartInbound.createTitle')" />
<!-- 操作按钮区 -->
<view class="action-row">
<view class="scan-input-row">
<input
id="sparepart-inbound-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">{{ t('sparepartInbound.selectSparepart') }}</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="inboundDate" @change="handleDateChange">
<view class="form-row-card">
<text :class="{ placeholder: !inboundDate }">{{ inboundDate || '请选择入库时间' }}</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 { createSparepartInbound } from '@/api/mes/sparepartInbound'
import { getSparepartDetail } from '@/api/mes/sparepart'
import { getBaseUrl } from '@/utils/request'
import { getToken } from '@/utils/auth'
const { t } = useI18n()
const itemList = ref([])
const scanCodeInput = ref('') // 扫码/输入备件码
// 聚焦阻止键盘弹出
const focusNoKeyboardRef = ref(null)
const keywordInputSelector = '#sparepart-inbound-scan-input input, input#sparepart-inbound-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/sparepartInbound/sparepartConfirm'
})
} else {
uni.showToast({ title: '未找到备件: ' + sparepartId, icon: 'none' })
}
} catch (e) {
console.error('[备件入库] 扫码查询备件失败:', e)
uni.showToast({ title: '扫码失败', icon: 'none' })
}
}
// 入库时间
const inboundDate = ref(formatDate(new Date()))
// 经办人(页面选择模式)
const selectedOperatorId = ref(null)
const selectedOperatorName = ref('')
// 备注
const remark = ref('')
// 附件单文件web端 limit=1
const fileUrl = ref('') // JSON: {"fileName":"xxx.pdf","fileUrl":"https://..."}
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) {
inboundDate.value = e.detail.value
}
function getItemImage(sparepart) {
const images = sparepart.images
if (!images) return ''
if (Array.isArray(images)) return String(images[0] || '')
return String(images).split(',')[0]?.trim() || ''
}
function handleCancel() {
getApp().globalData._sparepartInboundItems = []
uni.navigateBack()
}
function handleSelectSparepart() {
getApp().globalData._sparepartSelectFrom = 'inbound'
uni.navigateTo({
url: '/pages_function/pages/sparepartInbound/sparepartSelect'
})
}
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._sparepartInboundItems = [...itemList.value]
}
// 经办人
function goSelectOperator() {
getApp().globalData._sparepartInboundUserFrom = 'inbound'
uni.navigateTo({
url: '/pages_function/pages/moldRepair/userSelect?field=operator&from=sparepartInbound'
})
}
// 附件
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 < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
const ALLOWED_EXTS = ['png', 'jpg', 'jpeg', 'doc', 'xls', 'ppt', 'txt', 'pdf']
const MAX_FILE_SIZE = 100 * 1024 * 1024 // 100MB
function handleAddAttachment() {
// #ifdef APP-PLUS
// APP端用 plus.gallery 选择文件
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
// 非APP端用 uni.chooseFile 选择文件
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: () => {
// 兜底:不支持 chooseFile 的环境用 chooseImage
uni.chooseImage({
count: 1,
sizeType: ['compressed'],
sourceType: ['album', 'camera'],
success: (imgRes) => {
const path = imgRes.tempFilePaths?.[0]
if (!path) return
const parts = path.split('/')
const name = parts[parts.length - 1] || 'image.jpg'
uploadFile({ path, name, size: 0 })
}
})
}
})
// #endif
}
async function uploadFile(file) {
uploadLoading.value = true
attachmentFileName.value = file.name || 'unknown'
attachmentFileSize.value = file.size || 0
// 构建 form-data
const formData = {
file: file.path // uni.uploadFile 的 files 参数需要的是路径
}
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 {
const data = JSON.parse(uploadRes.data)
resolve(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 = ''
const msg = e?.message || '上传失败'
uni.showToast({ title: String(msg).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 (!inboundDate.value) {
uni.showToast({ title: '请选择入库时间', icon: 'none' })
return
}
if (!selectedOperatorId.value) {
uni.showToast({ title: '请选择经办人', icon: 'none' })
return
}
let totalCount = 0
const items = itemList.value.map(item => {
totalCount += item.count || 0
return {
warehouseId: item.warehouseId,
areaId: item.areaId,
productId: item.productId,
productName: item.productName,
productBarCode: item.productBarCode,
productUnitName: item.productUnitName,
purchaseUnitName: item.purchaseUnitName,
purchaseUnitConvertQuantity: item.purchaseUnitConvertQuantity,
inputCount: item.inputCount,
count: item.count
}
})
const now = new Date()
const [y, m, d] = inboundDate.value.split('-').map(Number)
const inTime = new Date(y, m - 1, d, now.getHours(), now.getMinutes(), now.getSeconds()).getTime()
const submitData = {
isCode: true,
inTime: inTime,
stockUserId: String(selectedOperatorId.value),
supplierId: itemList.value[0]?.supplierId,
status: 0,
totalCount: totalCount,
totalPrice: 0,
remark: remark.value,
items: items
}
// 附件(单文件,与 web 端一致存 JSON 字符串)
if (fileUrl.value) {
submitData.fileUrl = fileUrl.value
}
console.log('提交数据:', JSON.stringify(submitData))
uni.showLoading({ title: '提交中...', mask: true })
try {
await createSparepartInbound(submitData)
uni.hideLoading()
getApp().globalData._sparepartInboundItems = []
uni.showToast({ title: '入库成功', 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)
uni.showToast({ title: String(msg).substring(0, 50), icon: 'none' })
}
}
onReady(() => {
focusKeywordNoKeyboard()
})
onShow(() => {
const items = getApp().globalData?._sparepartInboundItems
if (Array.isArray(items)) {
itemList.value = [...items]
}
// 读取用户选择页返回的经办人
const userResult = getApp().globalData?._sparepartInboundUserSelectResult
if (userResult) {
selectedOperatorId.value = userResult.user.id
selectedOperatorName.value = userResult.user.nickname || userResult.user.userName || userResult.user.name || ''
getApp().globalData._sparepartInboundUserSelectResult = 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;
height: 72rpx;
border-radius: 10rpx;
overflow: hidden;
}
.scan-input {
flex: 1;
height: 72rpx;
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; }
}
/* 备件列表 */
.sparepart-section { padding: 0; }
.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; }
.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; }
/* 入库信息区 */
.form-section { padding-bottom: 16rpx; }
.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; }
/* 备注 */
.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; gap: 10rpx;
height: 88rpx; background: #fff; border: 2rpx dashed #cbd5e1;
border-radius: 12rpx;
}
.add-icon { font-size: 32rpx; }
.add-text { font-size: 26rpx; color: #6b7280; }
/* 底部操作栏 */
.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: #ffffff; 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>