From 4d34d11062610404b1df324b109c5b5b3286cab0 Mon Sep 17 00:00:00 2001
From: spring <2396852758@qq.com>
Date: 星期四, 07 五月 2026 16:22:11 +0800
Subject: [PATCH] Merge branch 'dev_NEW_pro' of http://114.132.189.42:9002/r/product-inventory-management into dev_NEW_pro

---
 src/components/AIChatSidebar/index.vue |  483 ++++++++++++++++++++++++++++++++++++++++++++++++++++-
 1 files changed, 470 insertions(+), 13 deletions(-)

diff --git a/src/components/AIChatSidebar/index.vue b/src/components/AIChatSidebar/index.vue
index 369d75d..257e4fe 100644
--- a/src/components/AIChatSidebar/index.vue
+++ b/src/components/AIChatSidebar/index.vue
@@ -187,6 +187,30 @@
                 <!-- 鏂囨湰鍐呭 -->
                 <div class="text-box" v-html="message.htmlContent"></div>
 
+                <div v-if="message.localUploadFiles?.length" class="message-local-file-list">
+                  <div
+                      v-for="(file, fileIndex) in message.localUploadFiles"
+                      :key="`${file.previewId || file.name}-${fileIndex}`"
+                      class="message-local-file-item"
+                  >
+                    <el-image
+                        v-if="file.isImage && file.previewUrl"
+                        :src="file.previewUrl"
+                        :preview-src-list="getImagePreviewList(message.localUploadFiles)"
+                        :initial-index="getImagePreviewInitialIndex(message.localUploadFiles, file.previewUrl)"
+                        :z-index="4000"
+                        preview-teleported
+                        fit="cover"
+                        class="message-local-file-thumb"
+                    />
+                    <el-icon v-else class="message-local-file-icon"><Document /></el-icon>
+                    <div class="message-local-file-meta">
+                      <span class="message-local-file-name">{{ file.name }}</span>
+                      <small class="message-local-file-size">{{ formatFileSize(file.size) }}</small>
+                    </div>
+                  </div>
+                </div>
+
                 <!-- 鍥捐〃鍐呭 -->
                 <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
                   <div
@@ -443,9 +467,22 @@
             </div>
             <div class="input-box">
               <div v-if="selectedFiles.length" class="selected-file-list">
-                <div v-for="(file, fileIndex) in selectedFiles" :key="`${file.name}-${fileIndex}`" class="selected-file-tag">
-                  <el-icon><Document /></el-icon>
-                  <span class="file-name">{{ file.name }}</span>
+                <div v-for="(file, fileIndex) in selectedFileSnapshots" :key="`${file.previewId || file.name}-${fileIndex}`" class="selected-file-tag">
+                  <el-image
+                      v-if="file.isImage && file.previewUrl"
+                      :src="file.previewUrl"
+                      :preview-src-list="getImagePreviewList(selectedFileSnapshots)"
+                      :initial-index="getImagePreviewInitialIndex(selectedFileSnapshots, file.previewUrl)"
+                      :z-index="4000"
+                      preview-teleported
+                      fit="cover"
+                      class="selected-file-thumb"
+                  />
+                  <el-icon v-else><Document /></el-icon>
+                  <div class="selected-file-meta">
+                    <span class="file-name">{{ file.name }}</span>
+                    <small class="file-size">{{ formatFileSize(file.size) }}</small>
+                  </div>
                   <el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon>
                 </div>
               </div>
@@ -589,6 +626,7 @@
 const inputMessage = ref('')
 const selectedFiles = ref([])
 const uploadFileList = ref([])
+const selectedFileSnapshots = ref([])
 const messages = ref([])
 const uuid = ref('')
 const chartInstances = ref({})
@@ -792,6 +830,75 @@
 const sessions = ref([])
 const loadingSessions = ref(false)
 
+const isImageFileType = (fileType = '') => String(fileType || '').toLowerCase().startsWith('image/')
+
+const getImagePreviewList = (files = []) => {
+  if (!Array.isArray(files)) return []
+  return files
+    .filter(item => item?.isImage && item?.previewUrl)
+    .map(item => item.previewUrl)
+}
+
+const getImagePreviewInitialIndex = (files = [], previewUrl = '') => {
+  const list = getImagePreviewList(files)
+  const index = list.indexOf(previewUrl)
+  return index >= 0 ? index : 0
+}
+
+const formatFileSize = (size) => {
+  const bytes = Number(size)
+  if (!Number.isFinite(bytes) || bytes <= 0) return '0 B'
+  if (bytes < 1024) return `${bytes} B`
+  if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(/\.0$/, '')} KB`
+  return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB`
+}
+
+const createLocalFileSnapshot = (file, index = 0) => {
+  const rawFile = typeof File !== 'undefined' && file instanceof File ? file : null
+  const fileType = rawFile?.type || ''
+  const isImage = isImageFileType(fileType)
+  const canCreateObjectURL = typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function'
+  return {
+    previewId: `${rawFile?.name || 'file'}-${rawFile?.size || 0}-${rawFile?.lastModified || Date.now()}-${index}`,
+    name: rawFile?.name || `file-${index + 1}`,
+    size: rawFile?.size || 0,
+    type: fileType,
+    isImage,
+    previewUrl: isImage && rawFile && canCreateObjectURL ? URL.createObjectURL(rawFile) : '',
+    rawFile
+  }
+}
+
+const revokeLocalFileSnapshots = (snapshots = []) => {
+  const canRevokeObjectURL = typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function'
+  if (!canRevokeObjectURL) return
+  if (!Array.isArray(snapshots)) return
+  snapshots.forEach((snapshot) => {
+    if (snapshot?.previewUrl) {
+      URL.revokeObjectURL(snapshot.previewUrl)
+    }
+  })
+}
+
+const revokeMessageLocalFileSnapshots = (messageList = []) => {
+  if (!Array.isArray(messageList)) return
+  messageList.forEach((msg) => {
+    if (Array.isArray(msg?.localUploadFiles)) {
+      revokeLocalFileSnapshots(msg.localUploadFiles)
+      msg.localUploadFiles = []
+    }
+  })
+}
+
+const clearSelectedFiles = ({ releaseSnapshots = true } = {}) => {
+  if (releaseSnapshots) {
+    revokeLocalFileSnapshots(selectedFileSnapshots.value)
+  }
+  selectedFiles.value = []
+  uploadFileList.value = []
+  selectedFileSnapshots.value = []
+}
+
 const abortCurrentRequest = () => {
   if (!currentAbortController.value) return
 
@@ -835,6 +942,7 @@
 
 const selectSession = async (session) => {
   showHistory.value = false
+  clearSelectedFiles()
   uuid.value = session.memoryId
   localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
 
@@ -842,6 +950,7 @@
   try {
     const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`)
     if (res.code === 200) {
+      revokeMessageLocalFileSnapshots(messages.value)
       disposeCharts()
       messages.value = []
       const historyMsgs = res.data || []
@@ -928,6 +1037,8 @@
 })
 
 onUnmounted(() => {
+  revokeMessageLocalFileSnapshots(messages.value)
+  clearSelectedFiles()
   disposeCharts()
   window.removeEventListener('resize', handleWindowResize)
 })
@@ -936,13 +1047,13 @@
   if (!prevKey || nextKey === prevKey) return
 
   abortCurrentRequest()
+  revokeMessageLocalFileSnapshots(messages.value)
   disposeCharts()
   messages.value = []
   outputState.value = {}
   sessions.value = []
   showHistory.value = false
-  selectedFiles.value = []
-  uploadFileList.value = []
+  clearSelectedFiles()
   inputMessage.value = ''
   quickPromptStart.value = 0
   initUUID()
@@ -985,13 +1096,13 @@
 }
 
 const newChat = () => {
+  revokeMessageLocalFileSnapshots(messages.value)
   disposeCharts()
   messages.value = []
   outputState.value = {}
   sessions.value = []
   showHistory.value = false
-  selectedFiles.value = []
-  uploadFileList.value = []
+  clearSelectedFiles()
   quickPromptStart.value = 0
   localStorage.removeItem(currentAssistant.value.storageKey)
   initUUID()
@@ -1667,6 +1778,76 @@
   '瀹℃壒鐢ㄦ埛ID鍒楄〃'
 ])
 
+const purchaseIntegerFieldKeys = new Set([
+  'id',
+  'supplierId',
+  'recorderId',
+  'salesContractNoId',
+  'salesLedgerId',
+  'Type',
+  'businessPersonId',
+  'productId',
+  'productModelId',
+  'ticketRegistrationId',
+  'type',
+  'approvalStatus',
+  'inventoryWarningQuantity'
+])
+
+const purchaseDecimalFieldKeys = new Set([
+  'invoiceAmount',
+  'contractAmount',
+  'receiptPaymentAmount',
+  'unReceiptPaymentAmount',
+  'quantity',
+  'taxRate',
+  'taxInclusiveUnitPrice',
+  'taxInclusiveTotalPrice',
+  'taxExclusiveTotalPrice',
+  'priceWithTax',
+  'totalPriceWithTax'
+])
+
+const purchaseBooleanFieldKeys = new Set([
+  'hasChildren',
+  'isWhite',
+  'isInspected',
+  'isChecked'
+])
+
+const purchaseStringFieldKeys = new Set([
+  'entryDateStart',
+  'entryDateEnd',
+  'purchaseContractNumber',
+  'supplierName',
+  'recorderName',
+  'salesContractNo',
+  'projectName',
+  'entryDate',
+  'executionDate',
+  'remarks',
+  'attachmentMaterials',
+  'createdAt',
+  'updatedAt',
+  'phoneNumber',
+  'invoiceNumber',
+  'paymentMethod',
+  'templateName',
+  'productCategory',
+  'specificationModel',
+  'unit',
+  'invoiceType'
+])
+
+const purchaseGenericArrayFieldKeys = new Set([
+  'purchaseLedgers',
+  'productData'
+])
+
+const purchaseStringArrayFieldKeys = new Set(['tempFileIds'])
+
+const purchaseObjectArrayFieldKeys = new Set(['SalesLedgerFiles'])
+
 const normalizePurchaseProductRecord = (record) => {
   if (!record || typeof record !== 'object' || Array.isArray(record)) return record
   return mapPayloadKeys(record, purchasePayloadFieldKeyMap)
@@ -1746,10 +1927,191 @@
   }, {})
 }
 
+const normalizeAttachmentMaterialsValue = (value) => {
+  if (value === null || value === undefined) return value
+  if (typeof value === 'string') return value
+  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+  try {
+    return JSON.stringify(value)
+  } catch (err) {
+    return String(value)
+  }
+}
+
+const normalizePurchaseAttachmentMaterialsField = (value) => {
+  if (Array.isArray(value)) {
+    return value.map(item => normalizePurchaseAttachmentMaterialsField(item))
+  }
+  if (value && typeof value === 'object') {
+    return Object.entries(value).reduce((result, [key, item]) => {
+      if (key === 'attachmentMaterials') {
+        result[key] = normalizeAttachmentMaterialsValue(item)
+      } else {
+        result[key] = normalizePurchaseAttachmentMaterialsField(item)
+      }
+      return result
+    }, {})
+  }
+  return value
+}
+
+const normalizePurchaseNumericText = (text = '') => {
+  return String(text)
+    .replace(/[,\s锛宂/g, '')
+    .replace(/[楼锟ュ厓%]/g, '')
+}
+
+const parsePurchaseNumberValue = (value) => {
+  if (typeof value === 'number') {
+    return Number.isFinite(value) ? value : null
+  }
+  if (typeof value === 'boolean') {
+    return value ? 1 : 0
+  }
+  if (typeof value !== 'string') {
+    return null
+  }
+  const text = normalizePurchaseNumericText(value.trim())
+  if (!text) return null
+  if (!/^[-+]?\d+(\.\d+)?$/.test(text)) return null
+  const numberValue = Number(text)
+  return Number.isFinite(numberValue) ? numberValue : null
+}
+
+const normalizePurchaseIntegerFieldValue = (value) => {
+  if (value === null || value === undefined) return value
+  if (value === '') return null
+  const numberValue = parsePurchaseNumberValue(value)
+  if (numberValue === null) return null
+  return Math.trunc(numberValue)
+}
+
+const normalizePurchaseDecimalFieldValue = (value) => {
+  if (value === null || value === undefined) return value
+  if (value === '') return null
+  const numberValue = parsePurchaseNumberValue(value)
+  return numberValue === null ? null : numberValue
+}
+
+const normalizePurchaseBooleanFieldValue = (value) => {
+  if (value === null || value === undefined) return value
+  if (value === '') return null
+  if (typeof value === 'boolean') return value
+  if (typeof value === 'number') return value !== 0
+  if (typeof value !== 'string') return null
+
+  const text = value.trim().toLowerCase()
+  if (!text) return null
+  if (['true', '1', 'yes', 'y', '鏄�', '宸�', 'checked'].includes(text)) return true
+  if (['false', '0', 'no', 'n', '鍚�', '鏈�', 'unchecked'].includes(text)) return false
+  return null
+}
+
+const normalizePurchaseStringFieldValue = (value) => {
+  if (value === null || value === undefined) return value
+  if (typeof value === 'string') return value
+  if (typeof value === 'number' || typeof value === 'boolean') return String(value)
+  try {
+    return JSON.stringify(value)
+  } catch (err) {
+    return String(value)
+  }
+}
+
+const normalizePurchaseStringArrayFieldValue = (value) => {
+  if (Array.isArray(value)) {
+    return value
+      .map(item => normalizePurchaseStringFieldValue(item))
+      .filter(item => item !== null && item !== undefined && item !== '')
+  }
+  if (value === null || value === undefined || value === '') return []
+  if (typeof value === 'string') {
+    const text = value.trim()
+    if (!text) return []
+    if (/^\[.*\]$/.test(text)) {
+      try {
+        const parsedValue = JSON.parse(text)
+        if (Array.isArray(parsedValue)) {
+          return parsedValue
+            .map(item => normalizePurchaseStringFieldValue(item))
+            .filter(item => item !== null && item !== undefined && item !== '')
+        }
+      } catch (err) {
+        // Keep as plain text when not valid JSON array.
+      }
+    }
+    const splitValues = text
+      .split(/[,\n锛宂/)
+      .map(item => item.trim())
+      .filter(Boolean)
+    return splitValues.length > 1 ? splitValues : [text]
+  }
+  const normalizedValue = normalizePurchaseStringFieldValue(value)
+  return normalizedValue === null || normalizedValue === undefined || normalizedValue === ''
+    ? []
+    : [normalizedValue]
+}
+
+const normalizePurchaseObjectArrayFieldValue = (value) => {
+  if (Array.isArray(value)) {
+    return value.filter(item => item && typeof item === 'object')
+  }
+  if (value && typeof value === 'object') {
+    return [value]
+  }
+  return []
+}
+
+const normalizePurchaseValueByFieldKey = (fieldKey, value) => {
+  if (fieldKey === 'attachmentMaterials') return normalizeAttachmentMaterialsValue(value)
+  if (purchaseIntegerFieldKeys.has(fieldKey)) return normalizePurchaseIntegerFieldValue(value)
+  if (purchaseDecimalFieldKeys.has(fieldKey)) return normalizePurchaseDecimalFieldValue(value)
+  if (purchaseBooleanFieldKeys.has(fieldKey)) return normalizePurchaseBooleanFieldValue(value)
+  if (purchaseStringArrayFieldKeys.has(fieldKey)) return normalizePurchaseStringArrayFieldValue(value)
+  if (purchaseObjectArrayFieldKeys.has(fieldKey)) return normalizePurchaseObjectArrayFieldValue(value)
+  if (purchaseStringFieldKeys.has(fieldKey)) return normalizePurchaseStringFieldValue(value)
+  return value
+}
+
+const normalizePurchasePayloadFieldTypes = (value, fieldKey = '') => {
+  if (purchaseGenericArrayFieldKeys.has(fieldKey)) {
+    if (Array.isArray(value)) {
+      return value.map(item => normalizePurchasePayloadFieldTypes(item))
+    }
+    if (value && typeof value === 'object') {
+      return [normalizePurchasePayloadFieldTypes(value)]
+    }
+    return []
+  }
+
+  if (purchaseStringArrayFieldKeys.has(fieldKey)) {
+    return normalizePurchaseStringArrayFieldValue(value)
+  }
+
+  if (purchaseObjectArrayFieldKeys.has(fieldKey)) {
+    return normalizePurchaseObjectArrayFieldValue(value)
+  }
+
+  if (Array.isArray(value)) {
+    return value.map(item => normalizePurchasePayloadFieldTypes(item))
+  }
+
+  if (value && typeof value === 'object') {
+    return Object.entries(value).reduce((result, [key, item]) => {
+      result[key] = normalizePurchasePayloadFieldTypes(item, key)
+      return result
+    }, {})
+  }
+
+  return normalizePurchaseValueByFieldKey(fieldKey, value)
+}
+
 const sanitizePurchasePayloadForSubmit = (payload, businessType) => {
   if (businessType !== 'purchase_ledger' || !payload || typeof payload !== 'object') return payload
 
-  const sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload })
+  let sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload })
+  sanitized = normalizePurchaseAttachmentMaterialsField(sanitized)
+  sanitized = normalizePurchasePayloadFieldTypes(sanitized)
   if (Array.isArray(sanitized.purchaseLedgers)) {
     sanitized.purchaseLedgers = sanitized.purchaseLedgers.map(filterPurchaseLedgerRecord)
   }
@@ -1954,16 +2316,20 @@
     return isLt10M
   })
 
+  clearSelectedFiles()
   selectedFiles.value = validFiles
   uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw))
+  selectedFileSnapshots.value = validFiles.map((rawFile, index) => createLocalFileSnapshot(rawFile, index))
 }
 
 const removeSelectedFile = (index) => {
+  const [removedSnapshot] = selectedFileSnapshots.value.splice(index, 1)
+  revokeLocalFileSnapshots(removedSnapshot ? [removedSnapshot] : [])
   selectedFiles.value.splice(index, 1)
   uploadFileList.value.splice(index, 1)
 }
 
-const analyzeFiles = async (files, message = '') => {
+const analyzeFiles = async (files, message = '', localFileSnapshots = []) => {
   const uploadFiles = Array.isArray(files) ? files : [files].filter(Boolean)
   if (!uploadFiles.length) return
   if (isSending.value) return
@@ -1976,7 +2342,8 @@
     isUser: true,
     content: userMsg,
     htmlContent: convertTextToHtml(userMsg),
-    isTyping: false
+    isTyping: false,
+    localUploadFiles: Array.isArray(localFileSnapshots) ? localFileSnapshots : []
   })
 
   const botMsgIndex = messages.value.length
@@ -2075,9 +2442,9 @@
   const msg = inputMessage.value?.trim() || ''
   if ((msg || selectedFiles.value.length) && !isSending.value) {
     if (selectedFiles.value.length) {
-      analyzeFiles([...selectedFiles.value], msg)
-      selectedFiles.value = []
-      uploadFileList.value = []
+      const localFileSnapshots = selectedFileSnapshots.value
+      analyzeFiles([...selectedFiles.value], msg, localFileSnapshots)
+      clearSelectedFiles({ releaseSnapshots: false })
     } else {
       sendRequest(msg)
     }
@@ -3125,6 +3492,68 @@
         border-radius: 2px;
       }
     }
+
+    .message-local-file-list {
+      margin-top: 8px;
+      display: grid;
+      gap: 8px;
+      max-width: 100%;
+    }
+
+    .message-local-file-item {
+      display: flex;
+      align-items: center;
+      gap: 10px;
+      padding: 8px 10px;
+      border-radius: 10px;
+      border: 1px solid rgba(88, 117, 255, 0.2);
+      background: rgba(255, 255, 255, 0.9);
+      max-width: 100%;
+    }
+
+    .message-local-file-thumb {
+      width: 40px;
+      height: 40px;
+      border-radius: 6px;
+      overflow: hidden;
+      flex-shrink: 0;
+      border: 1px solid rgba(124, 148, 255, 0.26);
+      background: #f4f7ff;
+      cursor: zoom-in;
+
+      :deep(.el-image__inner) {
+        width: 100%;
+        height: 100%;
+      }
+    }
+
+    .message-local-file-icon {
+      font-size: 20px;
+      color: $primary-blue;
+      flex-shrink: 0;
+    }
+
+    .message-local-file-meta {
+      min-width: 0;
+      display: flex;
+      flex-direction: column;
+      gap: 2px;
+    }
+
+    .message-local-file-name {
+      font-size: 12px;
+      color: #1f2a44;
+      font-weight: 600;
+      white-space: nowrap;
+      overflow: hidden;
+      text-overflow: ellipsis;
+    }
+
+    .message-local-file-size {
+      font-size: 11px;
+      color: #7f8ba1;
+      line-height: 1.2;
+    }
   }
 
   &.bot-message {
@@ -3537,6 +3966,28 @@
         font-size: 18px;
       }
 
+      .selected-file-thumb {
+        width: 30px;
+        height: 30px;
+        border-radius: 6px;
+        overflow: hidden;
+        border: 1px solid rgba(0, 85, 212, 0.2);
+        flex-shrink: 0;
+        cursor: zoom-in;
+
+        :deep(.el-image__inner) {
+          width: 100%;
+          height: 100%;
+        }
+      }
+
+      .selected-file-meta {
+        min-width: 0;
+        display: flex;
+        flex-direction: column;
+        gap: 2px;
+      }
+
       .file-name {
         font-size: 13px;
         color: $deep-blue;
@@ -3546,6 +3997,12 @@
         font-weight: 600;
       }
 
+      .file-size {
+        font-size: 11px;
+        color: #5f86b4;
+        line-height: 1.1;
+      }
+
       .remove-file {
         cursor: pointer;
         color: $secondary-blue;

--
Gitblit v1.9.3