| | |
| | | <!-- 文本内容 --> |
| | | <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 |
| | |
| | | </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> |
| | |
| | | const inputMessage = ref('') |
| | | const selectedFiles = ref([]) |
| | | const uploadFileList = ref([]) |
| | | const selectedFileSnapshots = ref([]) |
| | | const messages = ref([]) |
| | | const uuid = ref('') |
| | | const chartInstances = ref({}) |
| | |
| | | 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 |
| | | |
| | |
| | | |
| | | const selectSession = async (session) => { |
| | | showHistory.value = false |
| | | clearSelectedFiles() |
| | | uuid.value = session.memoryId |
| | | localStorage.setItem(currentAssistant.value.storageKey, uuid.value) |
| | | |
| | |
| | | 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 || [] |
| | |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | revokeMessageLocalFileSnapshots(messages.value) |
| | | clearSelectedFiles() |
| | | disposeCharts() |
| | | window.removeEventListener('resize', handleWindowResize) |
| | | }) |
| | |
| | | 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() |
| | |
| | | } |
| | | |
| | | 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() |
| | |
| | | '审批用户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) |
| | |
| | | }, {}) |
| | | } |
| | | |
| | | 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) |
| | | } |
| | |
| | | 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 |
| | |
| | | isUser: true, |
| | | content: userMsg, |
| | | htmlContent: convertTextToHtml(userMsg), |
| | | isTyping: false |
| | | isTyping: false, |
| | | localUploadFiles: Array.isArray(localFileSnapshots) ? localFileSnapshots : [] |
| | | }) |
| | | |
| | | const botMsgIndex = messages.value.length |
| | |
| | | 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) |
| | | } |
| | |
| | | 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 { |
| | |
| | | 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; |
| | |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .file-size { |
| | | font-size: 11px; |
| | | color: #5f86b4; |
| | | line-height: 1.1; |
| | | } |
| | | |
| | | .remove-file { |
| | | cursor: pointer; |
| | | color: $secondary-blue; |