| | |
| | | @click="showPicker = true"></up-icon> |
| | | </template> |
| | | </u-form-item> |
| | | <u-form-item v-if="typeValue === '货车'" |
| | | prop="shippingCarNumber" |
| | | label="车牌号" |
| | | required> |
| | | <u-input v-model="form.shippingCarNumber" |
| | | placeholder="请输入车牌号" |
| | | clearable /> |
| | | </u-form-item> |
| | | <u-form-item v-if="typeValue === '快递'" |
| | | prop="expressNumber" |
| | | label="快递单号" |
| | | required> |
| | | <u-input v-model="form.expressNumber" |
| | | placeholder="请输入快递单号" |
| | | clearable /> |
| | | </u-form-item> |
| | | </u-form> |
| | | <!-- 发货图片:相册或相机、/file/upload、预览与列表样式 --> |
| | | <view class="ship-images-card"> |
| | | <view class="ship-images-header"> |
| | | <text class="ship-images-title">发货图片</text> |
| | | <text class="ship-images-hint">最多 {{ uploadConfig.limit }} 张</text> |
| | | </view> |
| | | <view class="simple-upload-area"> |
| | | <view class="upload-buttons"> |
| | | <u-button type="primary" |
| | | @click="chooseShipImage" |
| | | :loading="shipUploading" |
| | | :disabled="shipFiles.length >= uploadConfig.limit" |
| | | :customStyle="{ width: '100%' }"> |
| | | <u-icon name="camera" |
| | | size="18" |
| | | color="#fff" |
| | | style="margin-right: 5px;"></u-icon> |
| | | {{ shipUploading ? '上传中...' : '添加图片' }} |
| | | </u-button> |
| | | </view> |
| | | <view v-if="shipUploading" |
| | | class="upload-progress"> |
| | | <u-line-progress :percentage="shipUploadProgress" |
| | | :showText="true" |
| | | activeColor="#409eff"></u-line-progress> |
| | | </view> |
| | | <view v-if="shipFiles.length > 0" |
| | | class="file-list"> |
| | | <view v-for="(file, index) in shipFiles" |
| | | :key="file.uid || index" |
| | | class="file-item"> |
| | | <view class="file-preview-container" |
| | | @click="previewShipImage(file)"> |
| | | <image :src="getFileAccessUrl(file)" |
| | | class="file-preview" |
| | | mode="aspectFill" /> |
| | | <view class="delete-btn" |
| | | @click.stop="removeShipFile(index)"> |
| | | <u-icon name="close" |
| | | size="12" |
| | | color="#fff"></u-icon> |
| | | </view> |
| | | </view> |
| | | <view class="file-info"> |
| | | <text class="file-name">{{ file.bucketFilename || file.name || '图片' }}</text> |
| | | <text class="file-size">{{ formatFileSize(file.size) }}</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <view v-else |
| | | class="empty-state"> |
| | | <text>请从相册选择或拍照上传发货图片</text> |
| | | </view> |
| | | </view> |
| | | </view> |
| | | <!-- 选择器弹窗 --> |
| | | <up-action-sheet :show="showPicker" |
| | | :actions="productOptions" |
| | |
| | | </template> |
| | | |
| | | <script setup> |
| | | import { ref, onMounted, onUnmounted, reactive, toRefs } from "vue"; |
| | | import { ref, computed, onMounted, onUnmounted, reactive, toRefs } from "vue"; |
| | | import PageHeader from "@/components/PageHeader.vue"; |
| | | import { addShippingInfo } from "@/api/salesManagement/salesLedger"; |
| | | import config from "@/config"; |
| | | import { getToken } from "@/utils/auth"; |
| | | const showToast = message => { |
| | | uni.showToast({ |
| | | title: message, |
| | |
| | | }); |
| | | }; |
| | | import { userListNoPageByTenantId } from "@/api/system/user"; |
| | | |
| | | const typeValue = ref("货车"); |
| | | |
| | | const data = reactive({ |
| | | form: { |
| | |
| | | endDate: "", |
| | | location: "", |
| | | price: "", |
| | | shippingCarNumber: "", |
| | | expressNumber: "", |
| | | }, |
| | | rules: { |
| | | typeValue: [{ required: false, message: "请选择", trigger: "change" }], |
| | | shippingCarNumber: [ |
| | | { |
| | | validator: (rule, value, callback) => { |
| | | if (typeValue.value !== "货车") { |
| | | callback(); |
| | | return; |
| | | } |
| | | if (!value || !String(value).trim()) { |
| | | callback(new Error("请输入车牌号")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }, |
| | | trigger: ["blur", "change"], |
| | | }, |
| | | ], |
| | | expressNumber: [ |
| | | { |
| | | validator: (rule, value, callback) => { |
| | | if (typeValue.value !== "快递") { |
| | | callback(); |
| | | return; |
| | | } |
| | | if (!value || !String(value).trim()) { |
| | | callback(new Error("请输入快递单号")); |
| | | return; |
| | | } |
| | | callback(); |
| | | }, |
| | | trigger: ["blur", "change"], |
| | | }, |
| | | ], |
| | | }, |
| | | }); |
| | | const { form, rules } = toRefs(data); |
| | |
| | | const formRef = ref(null); |
| | | const approveType = ref(0); |
| | | const goOutData = ref({}); |
| | | |
| | | // 与设备巡检 inspectionUpload/index.vue 中 uploadConfig 一致 |
| | | const uploadConfig = { |
| | | action: "/file/upload", |
| | | limit: 10, |
| | | fileSize: 50, |
| | | fileType: ["jpg", "jpeg", "png", "gif", "webp"], |
| | | }; |
| | | |
| | | /** 与设备巡检一致:生产前 type=10 传给 /file/upload 的 formData.type */ |
| | | const shipUploadFormType = 10; |
| | | |
| | | const uploadFileUrl = computed(() => (config.baseUrl || "").replace(/\/$/, "") + uploadConfig.action); |
| | | |
| | | const shipFiles = ref([]); |
| | | const shipUploading = ref(false); |
| | | const shipUploadProgress = ref(0); |
| | | |
| | | const isImageFile = file => { |
| | | if (file?.contentType && String(file.contentType).startsWith("image/")) return true; |
| | | if (file?.type === "image" || file?.mediaType === "image") return true; |
| | | const name = file?.bucketFilename || file?.originalFilename || file?.name || ""; |
| | | const ext = name.split(".").pop()?.toLowerCase(); |
| | | return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext); |
| | | }; |
| | | |
| | | const filePreviewBase = config.fileUrl; |
| | | |
| | | const normalizeFileUrl = (rawUrl = "") => { |
| | | let fileUrl = rawUrl || ""; |
| | | if (typeof fileUrl === "string") { |
| | | fileUrl = fileUrl.trim().replace(/^['"]|['"]$/g, ""); |
| | | } |
| | | const javaApi = filePreviewBase; |
| | | const localPrefixes = ["wxfile://", "file://", "content://", "blob:", "data:"]; |
| | | |
| | | if (localPrefixes.some(prefix => fileUrl.startsWith(prefix))) { |
| | | return fileUrl; |
| | | } |
| | | |
| | | if (fileUrl && fileUrl.indexOf("\\") > -1) { |
| | | const lowerPath = fileUrl.toLowerCase(); |
| | | const uploadPathIndex = lowerPath.indexOf("uploadpath"); |
| | | const prodIndex = lowerPath.indexOf("\\prod\\"); |
| | | |
| | | if (uploadPathIndex > -1) { |
| | | fileUrl = fileUrl.substring(uploadPathIndex).replace(/\\/g, "/"); |
| | | } else if (prodIndex > -1) { |
| | | fileUrl = fileUrl |
| | | .substring(prodIndex + "\\prod\\".length) |
| | | .replace(/\\/g, "/"); |
| | | } else { |
| | | fileUrl = fileUrl.replace(/\\/g, "/"); |
| | | } |
| | | } |
| | | const normalizedLower = String(fileUrl).toLowerCase(); |
| | | const fileProdIdx = normalizedLower.indexOf("/file/prod/"); |
| | | if (fileProdIdx > -1) { |
| | | fileUrl = `/profile/prod/${fileUrl.substring(fileProdIdx + "/file/prod/".length)}`; |
| | | } |
| | | const fileTempIdx = normalizedLower.indexOf("/file/temp/"); |
| | | if (fileTempIdx > -1) { |
| | | fileUrl = `/profile/temp/${fileUrl.substring(fileTempIdx + "/file/temp/".length)}`; |
| | | } |
| | | |
| | | if (/^\/?uploadPath/i.test(fileUrl)) { |
| | | fileUrl = fileUrl.replace(/^\/?uploadPath/i, "/profile"); |
| | | } |
| | | |
| | | if (fileUrl && !fileUrl.startsWith("http")) { |
| | | if (!fileUrl.startsWith("/")) fileUrl = "/" + fileUrl; |
| | | fileUrl = javaApi + fileUrl; |
| | | } |
| | | |
| | | return fileUrl; |
| | | }; |
| | | |
| | | const getFileAccessUrl = (file = {}) => { |
| | | if (file?.link) { |
| | | if (String(file.link).startsWith("http")) return file.link; |
| | | return normalizeFileUrl(file.link); |
| | | } |
| | | const remoteUrl = normalizeFileUrl( |
| | | file?.url || file?.downloadUrl || file?.tempPath || "" |
| | | ); |
| | | if (remoteUrl) return remoteUrl; |
| | | if (file?._localPreviewUrl) return file._localPreviewUrl; |
| | | return normalizeFileUrl(file?.tempFilePath || file?.path || ""); |
| | | }; |
| | | |
| | | const formatFileSize = size => { |
| | | if (!size) return ""; |
| | | if (size < 1024) return size + "B"; |
| | | if (size < 1024 * 1024) return (size / 1024).toFixed(1) + "KB"; |
| | | return (size / (1024 * 1024)).toFixed(1) + "MB"; |
| | | }; |
| | | |
| | | const handleShipUploadError = (message = "上传文件失败") => { |
| | | shipUploading.value = false; |
| | | shipUploadProgress.value = 0; |
| | | uni.showToast({ title: message, icon: "error" }); |
| | | }; |
| | | |
| | | const handleShipUploadSuccess = (res, file) => { |
| | | const uploadedFile = res.data; |
| | | if (!uploadedFile) { |
| | | handleShipUploadError("上传响应数据格式错误"); |
| | | return; |
| | | } |
| | | const fileData = { |
| | | ...file, |
| | | id: uploadedFile.id, |
| | | tempId: uploadedFile.tempId ?? uploadedFile.tempFileId ?? uploadedFile.id, |
| | | url: |
| | | uploadedFile.url || |
| | | uploadedFile.downloadUrl || |
| | | uploadedFile.tempPath || |
| | | file.tempFilePath || |
| | | file.path, |
| | | tempPath: uploadedFile.tempPath || file.tempPath || "", |
| | | _localPreviewUrl: file.tempFilePath || file.path || "", |
| | | bucketFilename: |
| | | uploadedFile.bucketFilename || |
| | | uploadedFile.originalFilename || |
| | | uploadedFile.originalName || |
| | | file.name, |
| | | downloadUrl: uploadedFile.downloadUrl || uploadedFile.url, |
| | | size: uploadedFile.size || uploadedFile.byteSize || file.size, |
| | | createTime: uploadedFile.createTime || new Date().getTime(), |
| | | mediaType: file.type || uploadedFile.mediaType, |
| | | }; |
| | | shipFiles.value.push(fileData); |
| | | uni.showToast({ title: "上传成功", icon: "success" }); |
| | | }; |
| | | |
| | | const uploadShipWithUniUploadFile = (file, filePath, token) => { |
| | | if (!filePath) { |
| | | handleShipUploadError("文件路径不存在"); |
| | | return; |
| | | } |
| | | shipUploading.value = true; |
| | | shipUploadProgress.value = 0; |
| | | |
| | | const uploadTask = uni.uploadFile({ |
| | | url: uploadFileUrl.value, |
| | | filePath, |
| | | name: "file", |
| | | formData: { |
| | | type: shipUploadFormType, |
| | | }, |
| | | header: { |
| | | Authorization: `Bearer ${token}`, |
| | | }, |
| | | success: res => { |
| | | try { |
| | | if (res.statusCode === 200) { |
| | | const response = JSON.parse(res.data || "{}"); |
| | | if (response.code === 200) { |
| | | handleShipUploadSuccess(response, file); |
| | | } else { |
| | | handleShipUploadError(response.msg || "服务器返回错误"); |
| | | } |
| | | } else { |
| | | handleShipUploadError(`服务器错误,状态码: ${res.statusCode}`); |
| | | } |
| | | } catch (e) { |
| | | console.error("解析响应失败:", e); |
| | | handleShipUploadError("响应数据解析失败"); |
| | | } |
| | | }, |
| | | fail: err => { |
| | | let errorMessage = "上传失败"; |
| | | if (err.errMsg) { |
| | | if (err.errMsg.includes("statusCode: null")) { |
| | | errorMessage = "网络连接失败,请检查网络设置"; |
| | | } else if (err.errMsg.includes("timeout")) { |
| | | errorMessage = "上传超时,请重试"; |
| | | } else if (err.errMsg.includes("fail")) { |
| | | errorMessage = "上传失败,请检查网络连接"; |
| | | } else { |
| | | errorMessage = err.errMsg; |
| | | } |
| | | } |
| | | handleShipUploadError(errorMessage); |
| | | }, |
| | | complete: () => { |
| | | shipUploading.value = false; |
| | | shipUploadProgress.value = 0; |
| | | }, |
| | | }); |
| | | |
| | | if (uploadTask && uploadTask.onProgressUpdate) { |
| | | uploadTask.onProgressUpdate(r => { |
| | | shipUploadProgress.value = r.progress; |
| | | }); |
| | | } |
| | | }; |
| | | |
| | | const uploadShipFile = file => { |
| | | const token = getToken(); |
| | | if (!token) { |
| | | handleShipUploadError("用户未登录"); |
| | | return; |
| | | } |
| | | uploadShipWithUniUploadFile(file, file.tempFilePath || file.path || "", token); |
| | | }; |
| | | |
| | | const handleBeforeShipUpload = async file => { |
| | | if (uploadConfig.fileType?.length) { |
| | | const fileName = file.name || ""; |
| | | const fileExtension = fileName ? fileName.split(".").pop().toLowerCase() : ""; |
| | | const expectedTypes = ["jpg", "jpeg", "png", "gif", "webp"]; |
| | | if (fileExtension && expectedTypes.length > 0) { |
| | | const isAllowed = expectedTypes.some( |
| | | t => uploadConfig.fileType.includes(t) && t === fileExtension |
| | | ); |
| | | if (!isAllowed) { |
| | | uni.showToast({ |
| | | title: `文件格式不支持,请选择 ${expectedTypes.join("/")} 格式的图片`, |
| | | icon: "none", |
| | | }); |
| | | return false; |
| | | } |
| | | } |
| | | } |
| | | uploadShipFile(file); |
| | | return true; |
| | | }; |
| | | |
| | | const chooseShipImage = () => { |
| | | if (shipFiles.value.length >= uploadConfig.limit) { |
| | | uni.showToast({ |
| | | title: `最多只能上传${uploadConfig.limit}张图片`, |
| | | icon: "none", |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | const remaining = uploadConfig.limit - shipFiles.value.length; |
| | | |
| | | if (typeof uni.chooseMedia === "function") { |
| | | uni.chooseMedia({ |
| | | count: Math.min(remaining, 1), |
| | | mediaType: ["image"], |
| | | sizeType: ["compressed", "original"], |
| | | sourceType: ["album", "camera"], |
| | | success: res => { |
| | | try { |
| | | const files = res?.tempFiles || []; |
| | | if (!files.length) throw new Error("未获取到文件"); |
| | | files.forEach((tf, idx) => { |
| | | const filePath = tf.tempFilePath || tf.path || ""; |
| | | const file = { |
| | | tempFilePath: filePath, |
| | | path: filePath, |
| | | type: "image", |
| | | name: `image_${Date.now()}_${idx}.jpg`, |
| | | size: tf.size || 0, |
| | | createTime: Date.now(), |
| | | uid: Date.now() + Math.random() + idx, |
| | | }; |
| | | handleBeforeShipUpload(file); |
| | | }); |
| | | } catch (e) { |
| | | console.error("处理图片选择结果失败:", e); |
| | | uni.showToast({ title: "处理文件失败", icon: "error" }); |
| | | } |
| | | }, |
| | | fail: () => uni.showToast({ title: "选择图片失败", icon: "error" }), |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | uni.chooseImage({ |
| | | count: 1, |
| | | sizeType: ["compressed", "original"], |
| | | sourceType: ["album", "camera"], |
| | | success: res => { |
| | | const tempFilePath = res?.tempFilePaths?.[0]; |
| | | const tempFile = res?.tempFiles?.[0] || {}; |
| | | if (!tempFilePath) return; |
| | | handleBeforeShipUpload({ |
| | | tempFilePath, |
| | | path: tempFilePath, |
| | | type: "image", |
| | | name: `photo_${Date.now()}.jpg`, |
| | | size: tempFile.size || 0, |
| | | createTime: Date.now(), |
| | | uid: Date.now() + Math.random(), |
| | | }); |
| | | }, |
| | | fail: () => uni.showToast({ title: "选择图片失败", icon: "none" }), |
| | | }); |
| | | }; |
| | | |
| | | const previewShipImage = file => { |
| | | if (!file || !isImageFile(file)) return; |
| | | const imageUrls = shipFiles.value |
| | | .filter(f => isImageFile(f)) |
| | | .map(f => getFileAccessUrl(f)) |
| | | .filter(Boolean); |
| | | const current = getFileAccessUrl(file); |
| | | if (!imageUrls.length || !current) return; |
| | | uni.previewImage({ urls: imageUrls, current }); |
| | | }; |
| | | |
| | | const removeShipFile = index => { |
| | | uni.showModal({ |
| | | title: "确认删除", |
| | | content: "确定要删除这个文件吗?", |
| | | success: res => { |
| | | if (res.confirm) { |
| | | shipFiles.value.splice(index, 1); |
| | | uni.showToast({ title: "删除成功", icon: "success" }); |
| | | } |
| | | }, |
| | | }); |
| | | }; |
| | | |
| | | const getShipTempFileIds = () => |
| | | shipFiles.value |
| | | .map(it => it.tempId ?? it.tempFileId ?? it.id) |
| | | .filter(v => v !== undefined && v !== null && v !== ""); |
| | | onMounted(async () => { |
| | | try { |
| | | userListNoPageByTenantId().then(res => { |
| | |
| | | // 移除事件监听 |
| | | uni.$off("selectContact", handleSelectContact); |
| | | }); |
| | | const typeValue = ref("货车"); |
| | | const onConfirm = item => { |
| | | // 设置选中的部门 |
| | | typeValue.value = item.name; |
| | | showPicker.value = false; |
| | | if (item.name === "货车") { |
| | | form.value.expressNumber = ""; |
| | | } else { |
| | | form.value.shippingCarNumber = ""; |
| | | } |
| | | }; |
| | | |
| | | const goBack = () => { |
| | |
| | | showToast("请为每个审批步骤选择审批人"); |
| | | return; |
| | | } |
| | | if (shipUploading.value) { |
| | | showToast("图片正在上传,请稍候"); |
| | | return; |
| | | } |
| | | formRef.value |
| | | .validate() |
| | | .then(valid => { |
| | |
| | | salesLedgerProductId: goOutData.value.id, |
| | | type: typeValue.value, |
| | | approveUserIds, |
| | | shippingCarNumber: |
| | | typeValue.value === "货车" ? String(form.value.shippingCarNumber || "").trim() : "", |
| | | expressNumber: |
| | | typeValue.value === "快递" ? String(form.value.expressNumber || "").trim() : "", |
| | | tempFileIds: getShipTempFileIds(), |
| | | }; |
| | | console.log(params, "params"); |
| | | |
| | |
| | | <style scoped lang="scss"> |
| | | @import "@/static/scss/form-common.scss"; |
| | | |
| | | .ship-images-card { |
| | | background: #fff; |
| | | margin: 16px; |
| | | border-radius: 16px; |
| | | padding: 16px; |
| | | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); |
| | | } |
| | | |
| | | .ship-images-header { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .ship-images-title { |
| | | font-size: 16px; |
| | | font-weight: 600; |
| | | color: #333; |
| | | display: block; |
| | | margin-bottom: 4px; |
| | | } |
| | | |
| | | .ship-images-hint { |
| | | font-size: 12px; |
| | | color: #999; |
| | | } |
| | | |
| | | /* 以下与 inspectionUpload/index.vue 简化上传区、文件列表、视频弹窗一致 */ |
| | | .simple-upload-area { |
| | | padding: 0; |
| | | } |
| | | |
| | | .upload-buttons { |
| | | display: flex; |
| | | gap: 10px; |
| | | margin-bottom: 15px; |
| | | } |
| | | |
| | | .upload-progress { |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .file-list { |
| | | margin-top: 15px; |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 12px; |
| | | } |
| | | |
| | | .file-item { |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | background: #fff; |
| | | border-radius: 12px; |
| | | padding: 8px; |
| | | border: 1px solid #e9ecef; |
| | | box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
| | | width: calc(50% - 6px); |
| | | min-width: 120px; |
| | | } |
| | | |
| | | .file-preview-container { |
| | | position: relative; |
| | | margin-bottom: 8px; |
| | | } |
| | | |
| | | .file-preview { |
| | | width: 80px; |
| | | height: 80px; |
| | | border-radius: 8px; |
| | | object-fit: cover; |
| | | border: 2px solid #f0f0f0; |
| | | } |
| | | |
| | | .delete-btn { |
| | | position: absolute; |
| | | top: -6px; |
| | | right: -6px; |
| | | width: 20px; |
| | | height: 20px; |
| | | background: #ff4757; |
| | | border-radius: 50%; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-shadow: 0 2px 4px rgba(255, 71, 87, 0.3); |
| | | } |
| | | |
| | | .file-info { |
| | | text-align: center; |
| | | width: 100%; |
| | | } |
| | | |
| | | .file-name { |
| | | font-size: 12px; |
| | | color: #333; |
| | | font-weight: 500; |
| | | display: block; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | max-width: 100px; |
| | | } |
| | | |
| | | .file-size { |
| | | font-size: 10px; |
| | | color: #999; |
| | | margin-top: 2px; |
| | | display: block; |
| | | } |
| | | |
| | | .empty-state { |
| | | text-align: center; |
| | | padding: 24px 16px; |
| | | color: #999; |
| | | font-size: 14px; |
| | | background: #f8f9fa; |
| | | border-radius: 8px; |
| | | border: 2px dashed #ddd; |
| | | } |
| | | |
| | | .approval-process { |
| | | background: #fff; |
| | | margin: 16px; |