| | |
| | | </div> |
| | | |
| | | <!-- 打字中动画 --> |
| | | <div v-if="message.purchaseAnalysisData" class="purchase-confirm-card"> |
| | | <div class="purchase-confirm-header"> |
| | | <span>{{ businessTypeLabelMap[message.purchaseAnalysisData.businessType] || message.purchaseAnalysisData.businessType || '采购业务' }}</span> |
| | | <el-tag size="small" type="success" v-if="message.purchaseAnalysisData.confidence !== undefined"> |
| | | 置信度 {{ formatPercent(message.purchaseAnalysisData.confidence) }} |
| | | </el-tag> |
| | | </div> |
| | | <div class="purchase-confirm-desc"> |
| | | {{ getPurchaseConfirmDescription(message.purchaseAnalysisData) }} |
| | | </div> |
| | | <div v-if="isPurchasePayloadEmpty(message.purchaseAnalysisData.payload)" class="purchase-empty-state"> |
| | | <div class="empty-title">没有识别到可直接提交的采购台账信息</div> |
| | | <div class="empty-desc">当前文件里缺少采购合同号、供应商、项目、日期、物料明细等关键内容。请上传更完整的合同、订单或明细表,或在下方补充数据后再确认。</div> |
| | | </div> |
| | | <div v-if="message.purchaseAnalysisData.warnings?.length" class="purchase-alert warning"> |
| | | <strong>风险提示</strong> |
| | | <ul> |
| | | <li v-for="(warning, warningIndex) in message.purchaseAnalysisData.warnings" :key="warningIndex"> |
| | | {{ formatPreviewItem(warning) }} |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | <div v-if="getVisiblePurchaseMissingFields(message.purchaseAnalysisData).length" class="purchase-alert missing"> |
| | | <strong>需要补充 {{ getVisiblePurchaseMissingFields(message.purchaseAnalysisData).length }} 项</strong> |
| | | <el-tag |
| | | v-for="field in getVisiblePurchaseMissingFields(message.purchaseAnalysisData)" |
| | | :key="field" |
| | | size="small" |
| | | type="danger" |
| | | > |
| | | {{ field }} |
| | | </el-tag> |
| | | </div> |
| | | <div v-if="message.purchaseAnalysisData.preview?.length" class="purchase-preview"> |
| | | <div class="purchase-section-title">确认摘要</div> |
| | | <ul> |
| | | <li v-for="(item, previewIndex) in message.purchaseAnalysisData.preview" :key="previewIndex"> |
| | | {{ formatPreviewItem(item) }} |
| | | </li> |
| | | </ul> |
| | | </div> |
| | | <div class="purchase-section-title">补充或确认数据</div> |
| | | <el-input |
| | | v-model="message.payloadText" |
| | | type="textarea" |
| | | :rows="8" |
| | | resize="vertical" |
| | | spellcheck="false" |
| | | class="payload-editor" |
| | | /> |
| | | <div class="payload-editor-tip"> |
| | | 日期请填写 yyyy-MM-dd,例如 2026-04-30。产品明细建议放在每条采购台账的 productData 中,确认时会自动兼容旧格式并清理审批字段。 |
| | | </div> |
| | | <div class="purchase-confirm-actions"> |
| | | <span v-if="message.confirmResult" :class="['confirm-result', message.confirmed ? 'success' : 'error']"> |
| | | {{ message.confirmResult }} |
| | | </span> |
| | | <el-button |
| | | type="primary" |
| | | size="small" |
| | | :loading="message.confirming" |
| | | :disabled="message.confirmed || isSending" |
| | | @click="confirmPurchaseAnalysis(message)" |
| | | > |
| | | 确认并执行 |
| | | </el-button> |
| | | </div> |
| | | </div> |
| | | |
| | | <div v-if="message.isTyping" class="typing-indicator"> |
| | | <span class="dot"></span> |
| | | <span class="dot"></span> |
| | |
| | | action="#" |
| | | :auto-upload="false" |
| | | :show-file-list="false" |
| | | v-model:file-list="uploadFileList" |
| | | :multiple="currentAssistant.allowMultipleFileUpload" |
| | | :on-change="handleFileChange" |
| | | :disabled="isSending" |
| | | > |
| | |
| | | </el-upload> |
| | | </div> |
| | | <div class="input-box"> |
| | | <div v-if="selectedFile" class="selected-file-tag"> |
| | | <el-icon><Document /></el-icon> |
| | | <span class="file-name">{{ selectedFile.name }}</span> |
| | | <el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon> |
| | | <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> |
| | | <el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon> |
| | | </div> |
| | | </div> |
| | | <el-input |
| | | v-model="inputMessage" |
| | | type="textarea" |
| | | :rows="selectedFile ? 2 : 3" |
| | | :rows="selectedFiles.length ? 2 : 3" |
| | | :placeholder="currentAssistant.placeholder" |
| | | resize="none" |
| | | @keydown.enter.exact.prevent="sendMessage" |
| | |
| | | <el-button |
| | | type="primary" |
| | | class="send-btn" |
| | | :disabled="isSending || (!inputMessage.trim() && !selectedFile)" |
| | | :disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)" |
| | | @click="sendMessage" |
| | | aria-label="发送" |
| | | > |
| | |
| | | placeholder: '请输入采购问题... (Enter 发送, Shift+Enter 换行)', |
| | | welcomeMessage: '你好', |
| | | description: '我可以协助你分析采购订单、到货进度、供应商表现和付款情况,帮助你快速定位采购异常。', |
| | | allowFileUpload: false, |
| | | allowFileUpload: true, |
| | | allowMultipleFileUpload: true, |
| | | fileAnalyzeUrl: '/purchase-ai/analyze-files', |
| | | emptySessionText: '暂无采购会话' |
| | | } |
| | | ] |
| | |
| | | const isSending = ref(false) |
| | | const currentAbortController = ref(null) |
| | | const inputMessage = ref('') |
| | | const selectedFile = ref(null) |
| | | const selectedFiles = ref([]) |
| | | const uploadFileList = ref([]) |
| | | const messages = ref([]) |
| | | const uuid = ref('') |
| | | const chartInstances = ref({}) |
| | | const resizeHandlers = ref([]) |
| | | const outputState = ref({}) |
| | | const businessTypeLabelMap = { |
| | | purchase_ledger: '采购台账', |
| | | payment_registration: '付款登记', |
| | | purchase_return_order: '采购退货单', |
| | | unknown: '未知采购业务' |
| | | } |
| | | const purchasePayloadFieldLabelMap = { |
| | | purchaseLedgers: '采购台账', |
| | | productData: '产品明细', |
| | | purchaseContractNumber: '采购合同号', |
| | | purchaseContractNo: '采购合同号', |
| | | purchaseOrderNumber: '采购合同号', |
| | | salesContractNo: '销售合同号', |
| | | salesContractNumber: '销售合同号', |
| | | salesOrderNumber: '销售合同号', |
| | | salesContractNoId: '销售合同ID', |
| | | approveUserIds: '审批用户ID列表', |
| | | entryDateStart: '录入开始日期', |
| | | entryDateEnd: '录入结束日期', |
| | | id: 'ID', |
| | | supplierId: '供应商ID', |
| | | projectName: '项目名称', |
| | | supplierName: '供应商名称', |
| | | isWhite: '是否白名单', |
| | | recorderId: '录入人ID', |
| | | recorderName: '录入人', |
| | | contractDate: '执行日期', |
| | | executionDate: '执行日期', |
| | | inputPerson: '录入人', |
| | | inputDate: '录入日期', |
| | | entryDate: '录入日期', |
| | | paymentMethod: '付款方式', |
| | | auditors: '审批人', |
| | | approverId: '审批人ID', |
| | | approvalStatus: '审批状态', |
| | | remark: '备注', |
| | | remarks: '备注', |
| | | attachmentMaterials: '附件材料', |
| | | createdAt: '创建时间', |
| | | updatedAt: '更新时间', |
| | | salesLedgerId: '销售台账ID', |
| | | hasChildren: '是否有子项', |
| | | Type: '类型', |
| | | type: '类型', |
| | | tempFileIds: '临时文件ID', |
| | | SalesLedgerFiles: '销售台账附件', |
| | | phoneNumber: '联系电话', |
| | | businessPersonId: '业务员ID', |
| | | productId: '产品ID', |
| | | productModelId: '产品型号ID', |
| | | invoiceNumber: '发票号码', |
| | | invoiceAmount: '发票金额', |
| | | ticketRegistrationId: '开票登记ID', |
| | | contractAmount: '合同金额', |
| | | receiptPaymentAmount: '已收付款金额', |
| | | unReceiptPaymentAmount: '未收付款金额', |
| | | templateName: '模板名称', |
| | | productCategory: '产品类别', |
| | | specificationModel: '规格型号', |
| | | unit: '单位', |
| | | taxRate: '税率', |
| | | taxInclusiveUnitPrice: '含税单价', |
| | | priceWithTax: '含税单价', |
| | | quantity: '数量', |
| | | taxInclusiveTotalPrice: '含税总价', |
| | | totalPriceWithTax: '含税总价', |
| | | invoiceType: '发票类型', |
| | | inventoryWarningQuantity: '库存预警数量', |
| | | isInspected: '是否质检' |
| | | } |
| | | const purchasePayloadFieldKeyMap = { |
| | | 采购台账: 'purchaseLedgers', |
| | | 产品明细: 'productData', |
| | | 采购合同号: 'purchaseContractNumber', |
| | | 采购单号: 'purchaseContractNumber', |
| | | 采购订单号: 'purchaseContractNumber', |
| | | 销售合同号: 'salesContractNo', |
| | | 销售单号: 'salesContractNo', |
| | | 销售订单号: 'salesContractNo', |
| | | 销售合同ID: 'salesContractNoId', |
| | | 审批用户ID列表: 'approveUserIds', |
| | | 录入开始日期: 'entryDateStart', |
| | | 录入结束日期: 'entryDateEnd', |
| | | ID: 'id', |
| | | 项目名称: 'projectName', |
| | | 供应商ID: 'supplierId', |
| | | 供应商名称: 'supplierName', |
| | | 是否白名单: 'isWhite', |
| | | 录入人ID: 'recorderId', |
| | | 录入人: 'recorderName', |
| | | 签订日期: 'executionDate', |
| | | 执行日期: 'executionDate', |
| | | 录入日期: 'entryDate', |
| | | 付款方式: 'paymentMethod', |
| | | 审核人: 'approverId', |
| | | 审批人: 'approverId', |
| | | 审批人ID: 'approverId', |
| | | 审批状态: 'approvalStatus', |
| | | 备注: 'remarks', |
| | | 附件材料: 'attachmentMaterials', |
| | | 创建时间: 'createdAt', |
| | | 更新时间: 'updatedAt', |
| | | 销售台账ID: 'salesLedgerId', |
| | | 是否有子项: 'hasChildren', |
| | | 类型: 'type', |
| | | 临时文件ID: 'tempFileIds', |
| | | 销售台账附件: 'SalesLedgerFiles', |
| | | 联系电话: 'phoneNumber', |
| | | 业务员ID: 'businessPersonId', |
| | | 产品ID: 'productId', |
| | | 产品型号ID: 'productModelId', |
| | | 发票号码: 'invoiceNumber', |
| | | 发票金额: 'invoiceAmount', |
| | | 开票登记ID: 'ticketRegistrationId', |
| | | 合同金额: 'contractAmount', |
| | | 已收付款金额: 'receiptPaymentAmount', |
| | | 未收付款金额: 'unReceiptPaymentAmount', |
| | | 模板名称: 'templateName', |
| | | 产品类别: 'productCategory', |
| | | 产品名称: 'productCategory', |
| | | 规格型号: 'specificationModel', |
| | | 单位: 'unit', |
| | | 税率: 'taxRate', |
| | | 含税单价: 'taxInclusiveUnitPrice', |
| | | 数量: 'quantity', |
| | | 含税总价: 'taxInclusiveTotalPrice', |
| | | 发票类型: 'invoiceType', |
| | | 库存预警数量: 'inventoryWarningQuantity', |
| | | 是否质检: 'isInspected', |
| | | purchaseLedgers: 'purchaseLedgers', |
| | | productData: 'productData', |
| | | purchaseContractNumber: 'purchaseContractNumber', |
| | | purchaseContractNo: 'purchaseContractNumber', |
| | | purchaseOrderNumber: 'purchaseContractNumber', |
| | | salesContractNo: 'salesContractNo', |
| | | salesContractNumber: 'salesContractNo', |
| | | salesOrderNumber: 'salesContractNo', |
| | | contractDate: 'executionDate', |
| | | inputPerson: 'recorderName', |
| | | inputDate: 'entryDate', |
| | | auditors: 'approverId', |
| | | remark: 'remarks', |
| | | productCategory: 'productCategory', |
| | | productName: 'productCategory', |
| | | specificationModel: 'specificationModel', |
| | | unit: 'unit', |
| | | taxRate: 'taxRate', |
| | | priceWithTax: 'taxInclusiveUnitPrice', |
| | | taxInclusiveUnitPrice: 'taxInclusiveUnitPrice', |
| | | quantity: 'quantity', |
| | | totalPriceWithTax: 'taxInclusiveTotalPrice', |
| | | taxInclusiveTotalPrice: 'taxInclusiveTotalPrice', |
| | | invoiceType: 'invoiceType', |
| | | inventoryWarningQuantity: 'inventoryWarningQuantity', |
| | | isInspected: 'isInspected' |
| | | } |
| | | |
| | | // 历史会话相关 |
| | | const showHistory = ref(false) |
| | |
| | | outputState.value = {} |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | selectedFiles.value = [] |
| | | uploadFileList.value = [] |
| | | inputMessage.value = '' |
| | | quickPromptStart.value = 0 |
| | | initUUID() |
| | |
| | | outputState.value = {} |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | selectedFiles.value = [] |
| | | uploadFileList.value = [] |
| | | quickPromptStart.value = 0 |
| | | localStorage.removeItem(currentAssistant.value.storageKey) |
| | | initUUID() |
| | |
| | | const extractEmbeddedSuccessJson = (text) => { |
| | | if (!text || typeof text !== 'string') return null |
| | | |
| | | const startIdx = text.indexOf('{"success"') |
| | | if (startIdx === -1) return null |
| | | const startMatch = text.match(/\{\s*"success"\s*:/) |
| | | if (!startMatch) return null |
| | | const startIdx = startMatch.index ?? -1 |
| | | if (startIdx < 0) return null |
| | | |
| | | for (let i = startIdx; i < text.length; i++) { |
| | | if (text[i] !== '{') continue |
| | |
| | | messageObj.tableData = parsedData.data |
| | | } |
| | | |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | messageObj.chartOptions = parsedData.charts |
| | | if (parsedData.action === 'confirm_required' && parsedData.businessType) { |
| | | messageObj.type = 'purchase_analysis_confirm' |
| | | messageObj.purchaseAnalysisData = parsedData |
| | | if (!messageObj.payloadText) { |
| | | messageObj.payloadText = JSON.stringify(localizePurchasePayload(parsedData.payload || {}), null, 2) |
| | | } |
| | | messageObj.confirmResult = '' |
| | | messageObj.confirmed = false |
| | | messageObj.confirming = false |
| | | } |
| | | |
| | | const chartOptions = getStructuredChartOptions(parsedData) |
| | | if (chartOptions && Object.keys(chartOptions).length > 0) { |
| | | messageObj.chartOptions = chartOptions |
| | | messageObj.chartRenderReady = true |
| | | |
| | | if (shouldRenderCharts) { |
| | |
| | | } |
| | | } |
| | | |
| | | const getStructuredChartOptions = (parsedData) => { |
| | | if (!parsedData?.success) return null |
| | | |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | return parsedData.charts |
| | | } |
| | | |
| | | if (parsedData.type === 'purchase_material_rank') { |
| | | return buildPurchaseMaterialRankCharts(parsedData) |
| | | } |
| | | |
| | | return null |
| | | } |
| | | |
| | | const buildPurchaseMaterialRankCharts = (parsedData) => { |
| | | const items = Array.isArray(parsedData?.data?.items) ? parsedData.data.items : [] |
| | | if (!items.length) return null |
| | | |
| | | const names = items.map(item => item.productCategory || '-') |
| | | const amounts = items.map(item => Number(item.amount) || 0) |
| | | |
| | | return { |
| | | purchaseMaterialAmountRank: { |
| | | title: { |
| | | text: '\u91c7\u8d2d\u7269\u6599\u91d1\u989d\u6392\u884c', |
| | | left: 'center', |
| | | textStyle: { |
| | | fontSize: 14, |
| | | fontWeight: 600, |
| | | color: '#1a1a2e' |
| | | } |
| | | }, |
| | | tooltip: { |
| | | trigger: 'axis', |
| | | axisPointer: { |
| | | type: 'shadow' |
| | | }, |
| | | formatter(params) { |
| | | const dataIndex = params?.[0]?.dataIndex ?? 0 |
| | | const item = items[dataIndex] || {} |
| | | const amount = Number(item.amount) || 0 |
| | | const quantity = Number(item.quantity) || 0 |
| | | return [ |
| | | `${item.productCategory || '-'}`, |
| | | `${params?.[0]?.marker || ''} \u91d1\u989d\uff1a${formatCurrency(amount)}`, |
| | | `\u89c4\u683c\u578b\u53f7\uff1a${item.specificationModel || '-'}`, |
| | | `\u6570\u91cf\uff1a${quantity}${item.unit || ''}` |
| | | ].join('<br/>') |
| | | } |
| | | }, |
| | | grid: { |
| | | left: '3%', |
| | | right: '4%', |
| | | bottom: names.some(name => String(name).length > 6) ? 72 : 48, |
| | | top: 48, |
| | | containLabel: true |
| | | }, |
| | | xAxis: { |
| | | type: 'category', |
| | | data: names, |
| | | axisLabel: { |
| | | interval: 0, |
| | | rotate: names.some(name => String(name).length > 6) ? 28 : 0, |
| | | color: '#4b5563' |
| | | } |
| | | }, |
| | | yAxis: { |
| | | type: 'value', |
| | | name: '\u91d1\u989d(\u5143)', |
| | | axisLabel: { |
| | | color: '#4b5563', |
| | | formatter: value => formatCompactNumber(value) |
| | | } |
| | | }, |
| | | series: [{ |
| | | name: '\u91c7\u8d2d\u91d1\u989d', |
| | | type: 'bar', |
| | | barMaxWidth: 36, |
| | | data: amounts, |
| | | itemStyle: { |
| | | borderRadius: [6, 6, 0, 0], |
| | | color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [ |
| | | { offset: 0, color: '#2f7cf6' }, |
| | | { offset: 1, color: '#55d7ff' } |
| | | ]) |
| | | }, |
| | | label: { |
| | | show: true, |
| | | position: 'top', |
| | | color: '#1f2937', |
| | | formatter: params => formatCompactNumber(params.value) |
| | | } |
| | | }] |
| | | } |
| | | } |
| | | } |
| | | |
| | | const formatCurrency = (value) => { |
| | | const amount = Number(value) || 0 |
| | | return `\u00a5${amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 })}` |
| | | } |
| | | |
| | | const formatCompactNumber = (value) => { |
| | | const amount = Number(value) || 0 |
| | | if (Math.abs(amount) >= 10000) { |
| | | return `${(amount / 10000).toFixed(2).replace(/\.?0+$/, '')}\u4e07` |
| | | } |
| | | return amount.toLocaleString('zh-CN', { maximumFractionDigits: 2 }) |
| | | } |
| | | |
| | | const formatPercent = (value) => { |
| | | const number = Number(value) |
| | | if (Number.isNaN(number)) return '-' |
| | | return `${Math.round(number * 100)}%` |
| | | } |
| | | |
| | | const formatPreviewItem = (item) => { |
| | | if (item === null || item === undefined) return '-' |
| | | if (typeof item === 'string') return item |
| | | try { |
| | | return JSON.stringify(item) |
| | | } catch (err) { |
| | | return String(item) |
| | | } |
| | | } |
| | | |
| | | const mapPayloadKeys = (value, keyMap) => { |
| | | if (Array.isArray(value)) { |
| | | return value.map(item => mapPayloadKeys(item, keyMap)) |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | return Object.entries(value).reduce((result, [key, item]) => { |
| | | result[keyMap[key] || key] = mapPayloadKeys(item, keyMap) |
| | | return result |
| | | }, {}) |
| | | } |
| | | return value |
| | | } |
| | | |
| | | const localizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldLabelMap) |
| | | |
| | | const normalizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldKeyMap) |
| | | |
| | | const purchaseDateFieldKeys = new Set([ |
| | | 'entryDateStart', |
| | | 'entryDateEnd', |
| | | 'entryDate', |
| | | 'executionDate', |
| | | 'contractDate', |
| | | 'inputDate', |
| | | 'createdAt', |
| | | 'updatedAt' |
| | | ]) |
| | | |
| | | const purchaseLedgerAllowedFieldKeys = new Set([ |
| | | 'entryDateStart', |
| | | 'entryDateEnd', |
| | | 'id', |
| | | 'purchaseContractNumber', |
| | | 'supplierId', |
| | | 'supplierName', |
| | | 'isWhite', |
| | | 'recorderId', |
| | | 'recorderName', |
| | | 'salesContractNo', |
| | | 'salesContractNoId', |
| | | 'projectName', |
| | | 'entryDate', |
| | | 'executionDate', |
| | | 'remarks', |
| | | 'attachmentMaterials', |
| | | 'createdAt', |
| | | 'updatedAt', |
| | | 'salesLedgerId', |
| | | 'hasChildren', |
| | | 'Type', |
| | | 'productData', |
| | | 'tempFileIds', |
| | | 'SalesLedgerFiles', |
| | | 'phoneNumber', |
| | | 'businessPersonId', |
| | | 'productId', |
| | | 'productModelId', |
| | | 'invoiceNumber', |
| | | 'invoiceAmount', |
| | | 'ticketRegistrationId', |
| | | 'contractAmount', |
| | | 'receiptPaymentAmount', |
| | | 'unReceiptPaymentAmount', |
| | | 'type', |
| | | 'paymentMethod', |
| | | 'approvalStatus', |
| | | 'templateName' |
| | | ]) |
| | | |
| | | const purchaseApprovalFieldKeys = new Set([ |
| | | 'approveUserIds', |
| | | 'approverId', |
| | | 'auditors', |
| | | '审核人', |
| | | '审批人', |
| | | '审批人ID', |
| | | '审批用户ID列表' |
| | | ]) |
| | | |
| | | const normalizePurchaseProductRecord = (record) => { |
| | | if (!record || typeof record !== 'object' || Array.isArray(record)) return record |
| | | return mapPayloadKeys(record, purchasePayloadFieldKeyMap) |
| | | } |
| | | |
| | | const getPurchaseProductMatchKey = (record) => { |
| | | if (!record || typeof record !== 'object') return '' |
| | | return record.purchaseContractNumber || |
| | | record.purchaseContractNo || |
| | | record.salesContractNo || |
| | | record.salesContractNumber || |
| | | '' |
| | | } |
| | | |
| | | const mergeLegacyProductDataIntoLedgers = (payload) => { |
| | | if (!payload || typeof payload !== 'object' || Array.isArray(payload)) return payload |
| | | if (!Array.isArray(payload.purchaseLedgers) || !Array.isArray(payload.productData) || !payload.productData.length) { |
| | | return payload |
| | | } |
| | | |
| | | const ledgers = payload.purchaseLedgers.map(ledger => ({ |
| | | ...ledger, |
| | | productData: Array.isArray(ledger.productData) |
| | | ? ledger.productData.map(normalizePurchaseProductRecord) |
| | | : [] |
| | | })) |
| | | const unmatchedProducts = [] |
| | | |
| | | payload.productData.map(normalizePurchaseProductRecord).forEach(product => { |
| | | const productMatchKey = getPurchaseProductMatchKey(product) |
| | | const matchedLedger = ledgers.find(ledger => { |
| | | const ledgerKeys = [ |
| | | ledger.purchaseContractNumber, |
| | | ledger.purchaseContractNo, |
| | | ledger.salesContractNo, |
| | | ledger.salesContractNumber |
| | | ].filter(Boolean) |
| | | return productMatchKey && ledgerKeys.includes(productMatchKey) |
| | | }) |
| | | |
| | | if (matchedLedger) { |
| | | matchedLedger.productData.push(product) |
| | | } else if (ledgers.length === 1) { |
| | | ledgers[0].productData.push(product) |
| | | } else { |
| | | unmatchedProducts.push(product) |
| | | } |
| | | }) |
| | | |
| | | const nextPayload = { |
| | | ...payload, |
| | | purchaseLedgers: ledgers |
| | | } |
| | | |
| | | if (unmatchedProducts.length) { |
| | | nextPayload.productData = unmatchedProducts |
| | | } else { |
| | | delete nextPayload.productData |
| | | } |
| | | |
| | | return nextPayload |
| | | } |
| | | |
| | | const filterPurchaseLedgerRecord = (record) => { |
| | | if (!record || typeof record !== 'object' || Array.isArray(record)) return record |
| | | const normalizedRecord = { |
| | | ...record, |
| | | productData: Array.isArray(record.productData) |
| | | ? record.productData.map(normalizePurchaseProductRecord) |
| | | : record.productData |
| | | } |
| | | return Object.entries(normalizedRecord).reduce((result, [key, value]) => { |
| | | if (purchaseLedgerAllowedFieldKeys.has(key)) { |
| | | result[key] = value |
| | | } |
| | | return result |
| | | }, {}) |
| | | } |
| | | |
| | | const sanitizePurchasePayloadForSubmit = (payload, businessType) => { |
| | | if (businessType !== 'purchase_ledger' || !payload || typeof payload !== 'object') return payload |
| | | |
| | | const sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload }) |
| | | if (Array.isArray(sanitized.purchaseLedgers)) { |
| | | sanitized.purchaseLedgers = sanitized.purchaseLedgers.map(filterPurchaseLedgerRecord) |
| | | } |
| | | |
| | | purchaseApprovalFieldKeys.forEach(key => { |
| | | if (!Array.isArray(sanitized)) { |
| | | delete sanitized[key] |
| | | } |
| | | }) |
| | | |
| | | return sanitized |
| | | } |
| | | |
| | | const getVisiblePurchaseMissingFields = (analysisData) => { |
| | | const fields = Array.isArray(analysisData?.missingFields) ? analysisData.missingFields : [] |
| | | const visibleFields = analysisData?.businessType === 'purchase_ledger' |
| | | ? fields.filter(field => !purchaseApprovalFieldKeys.has(field)) |
| | | : fields |
| | | return visibleFields.map(field => purchasePayloadFieldLabelMap[field] || field) |
| | | } |
| | | |
| | | const formatDateParts = (year, month, day) => { |
| | | const normalizedYear = Number(year) |
| | | const normalizedMonth = Number(month) |
| | | const normalizedDay = Number(day) |
| | | if (!normalizedYear || !normalizedMonth || !normalizedDay) return '' |
| | | |
| | | const date = new Date(normalizedYear, normalizedMonth - 1, normalizedDay) |
| | | if ( |
| | | date.getFullYear() !== normalizedYear || |
| | | date.getMonth() !== normalizedMonth - 1 || |
| | | date.getDate() !== normalizedDay |
| | | ) { |
| | | return '' |
| | | } |
| | | |
| | | return [ |
| | | String(normalizedYear).padStart(4, '0'), |
| | | String(normalizedMonth).padStart(2, '0'), |
| | | String(normalizedDay).padStart(2, '0') |
| | | ].join('-') |
| | | } |
| | | |
| | | const normalizeDateString = (value) => { |
| | | if (typeof value !== 'string') return value |
| | | const text = value.trim() |
| | | if (!text) return value |
| | | |
| | | let match = text.match(/^(\d{4})-(\d{1,2})-(\d{1,2})(?:[T\s].*)?$/) |
| | | if (match) return formatDateParts(match[1], match[2], match[3]) || value |
| | | |
| | | match = text.match(/^(\d{4})\/(\d{1,2})\/(\d{1,2})(?:\s.*)?$/) |
| | | if (match) return formatDateParts(match[1], match[2], match[3]) || value |
| | | |
| | | match = text.match(/^(\d{4})年(\d{1,2})月(\d{1,2})日?(?:\s.*)?$/) |
| | | if (match) return formatDateParts(match[1], match[2], match[3]) || value |
| | | |
| | | match = text.match(/^(\d{1,2})\/(\d{1,2})\/(\d{2}|\d{4})(?:\s.*)?$/) |
| | | if (match) { |
| | | const year = match[3].length === 2 ? Number(`20${match[3]}`) : Number(match[3]) |
| | | return formatDateParts(year, match[1], match[2]) || value |
| | | } |
| | | |
| | | return value |
| | | } |
| | | |
| | | const normalizePurchasePayloadDates = (value, key = '') => { |
| | | if (Array.isArray(value)) { |
| | | return value.map(item => normalizePurchasePayloadDates(item, key)) |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | return Object.entries(value).reduce((result, [itemKey, item]) => { |
| | | result[itemKey] = normalizePurchasePayloadDates(item, itemKey) |
| | | return result |
| | | }, {}) |
| | | } |
| | | return purchaseDateFieldKeys.has(key) ? normalizeDateString(value) : value |
| | | } |
| | | |
| | | const isEmptyValue = (value) => { |
| | | if (value === null || value === undefined || value === '') return true |
| | | if (Array.isArray(value)) return value.every(item => isEmptyValue(item)) |
| | | if (typeof value === 'object') return Object.values(value).every(item => isEmptyValue(item)) |
| | | return false |
| | | } |
| | | |
| | | const isPurchasePayloadEmpty = (payload) => isEmptyValue(payload) |
| | | |
| | | const getPurchaseConfirmDescription = (analysisData) => { |
| | | if (!analysisData) return '' |
| | | if (isPurchasePayloadEmpty(analysisData.payload)) { |
| | | return '我没有从文件中提取到完整的采购业务数据,暂时不能直接生成采购台账。' |
| | | } |
| | | return analysisData.description || '已整理出待确认的采购业务数据,请核对后提交。' |
| | | } |
| | | |
| | | const confirmPurchaseAnalysis = async (message) => { |
| | | if (!message?.purchaseAnalysisData || message.confirming || message.confirmed) return |
| | | |
| | | let payload |
| | | try { |
| | | const parsedPayload = message.payloadText?.trim() ? JSON.parse(message.payloadText) : {} |
| | | payload = sanitizePurchasePayloadForSubmit( |
| | | normalizePurchasePayloadDates(normalizePurchasePayload(parsedPayload)), |
| | | message.purchaseAnalysisData.businessType |
| | | ) |
| | | } catch (err) { |
| | | message.confirmResult = '待提交数据不是合法 JSON,请修改后再确认' |
| | | message.confirmed = false |
| | | return |
| | | } |
| | | |
| | | message.confirming = true |
| | | message.confirmResult = '' |
| | | |
| | | try { |
| | | const res = await request.post(`${currentAssistant.value.apiBase}/analyze-files/confirm`, { |
| | | businessType: message.purchaseAnalysisData.businessType, |
| | | payload |
| | | }) |
| | | message.confirmed = true |
| | | message.confirmResult = res?.msg || '确认成功,业务处理已提交' |
| | | ElMessage.success(message.confirmResult) |
| | | } catch (err) { |
| | | message.confirmed = false |
| | | message.confirmResult = err?.message || '确认失败,请检查数据后重试' |
| | | } finally { |
| | | message.confirming = false |
| | | } |
| | | } |
| | | |
| | | const scrollToBottom = () => { |
| | | nextTick(() => { |
| | | if (messageListRef.value) { |
| | |
| | | }) |
| | | } |
| | | |
| | | const handleFileChange = (file) => { |
| | | const handleFileChange = (file, fileList = []) => { |
| | | if (!file) return |
| | | const rawFile = file.raw |
| | | if (rawFile) { |
| | | // 限制文件大小,例如 10MB |
| | | const nextFiles = currentAssistant.value.allowMultipleFileUpload |
| | | ? fileList.map(item => item.raw).filter(Boolean) |
| | | : [file.raw].filter(Boolean) |
| | | |
| | | const validFiles = nextFiles.filter(rawFile => { |
| | | const isLt10M = rawFile.size / 1024 / 1024 < 10 |
| | | if (!isLt10M) { |
| | | ElMessage.error('文件大小不能超过 10MB!') |
| | | return |
| | | ElMessage.error(`${rawFile.name} 文件大小不能超过 10MB!`) |
| | | } |
| | | selectedFile.value = rawFile |
| | | } |
| | | return isLt10M |
| | | }) |
| | | |
| | | selectedFiles.value = validFiles |
| | | uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw)) |
| | | } |
| | | |
| | | const removeSelectedFile = () => { |
| | | selectedFile.value = null |
| | | const removeSelectedFile = (index) => { |
| | | selectedFiles.value.splice(index, 1) |
| | | uploadFileList.value.splice(index, 1) |
| | | } |
| | | |
| | | const analyzeFile = async (file, message = '') => { |
| | | const analyzeFiles = async (files, message = '') => { |
| | | const uploadFiles = Array.isArray(files) ? files : [files].filter(Boolean) |
| | | if (!uploadFiles.length) return |
| | | if (isSending.value) return |
| | | isSending.value = true |
| | | currentAbortController.value = new AbortController() |
| | | |
| | | const userMsg = message ? `${message}\n[上传文件分析] ${file.name}` : `[上传文件分析] ${file.name}` |
| | | const fileNames = uploadFiles.map(file => file.name).join('、') |
| | | const userMsg = message ? `${message}\n[上传文件分析] ${fileNames}` : `[上传文件分析] ${fileNames}` |
| | | messages.value.push({ |
| | | isUser: true, |
| | | content: userMsg, |
| | |
| | | scrollToBottom() |
| | | |
| | | const formData = new FormData() |
| | | formData.append('file', file) |
| | | const fileFieldName = currentAssistant.value.allowMultipleFileUpload ? 'files' : 'file' |
| | | uploadFiles.forEach(file => formData.append(fileFieldName, file)) |
| | | formData.append('memoryId', uuid.value) |
| | | if (message.trim()) { |
| | | formData.append('message', message.trim()) |
| | | } |
| | | |
| | | request.post(`${currentAssistant.value.apiBase}/analyze-file`, formData, { |
| | | const analyzeUrl = currentAssistant.value.fileAnalyzeUrl || `${currentAssistant.value.apiBase}/analyze-file` |
| | | request.post(analyzeUrl, formData, { |
| | | headers: { |
| | | 'Content-Type': 'multipart/form-data' |
| | | }, |
| | |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | |
| | | const extracted = extractEmbeddedSuccessJson(currentMsg.content) |
| | | if (extracted) { |
| | | applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart) |
| | | } |
| | | |
| | | // 最终解析确保图表渲染 |
| | | if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | |
| | | }).catch(err => { |
| | | if (err.name === 'CanceledError' || err.name === 'AbortError') { |
| | | console.log('Analysis aborted by user') |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | return |
| | | } |
| | | console.error('File analysis error:', err) |
| | |
| | | |
| | | const sendMessage = () => { |
| | | const msg = inputMessage.value?.trim() || '' |
| | | if ((msg || selectedFile.value) && !isSending.value) { |
| | | if (selectedFile.value) { |
| | | analyzeFile(selectedFile.value, msg) |
| | | selectedFile.value = null |
| | | if ((msg || selectedFiles.value.length) && !isSending.value) { |
| | | if (selectedFiles.value.length) { |
| | | analyzeFiles([...selectedFiles.value], msg) |
| | | selectedFiles.value = [] |
| | | uploadFileList.value = [] |
| | | } else { |
| | | sendRequest(msg) |
| | | } |
| | |
| | | |
| | | const parsedData = extractJson(fullText) |
| | | if (parsedData) { |
| | | currentMsg.type = parsedData.type || '' |
| | | if (currentMsg.type === 'todo_list' && parsedData.data) { |
| | | currentMsg.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | currentMsg.chartOptions = parsedData.charts |
| | | currentMsg.chartRenderReady = true |
| | | // 每次解析成功都尝试渲染/更新图表,以支持流式更新 |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | } |
| | | applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true) |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | const finalParsed = extractJson(currentMsg.content) |
| | | if (finalParsed) { |
| | | currentMsg.type = finalParsed.type || '' |
| | | if (currentMsg.type === 'todo_list' && finalParsed.data) { |
| | | currentMsg.tableData = finalParsed.data |
| | | } |
| | | if (finalParsed.charts && Object.keys(finalParsed.charts).length > 0) { |
| | | currentMsg.chartOptions = finalParsed.charts |
| | | currentMsg.chartRenderReady = true |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | } |
| | | applyStructuredMessageData(currentMsg, finalParsed, botMsgIndex) |
| | | } |
| | | } |
| | | }).catch(err => { |
| | |
| | | |
| | | // 尝试提取 JSON 部分 |
| | | const extracted = extractEmbeddedSuccessJson(output) |
| | | const startIdx = extracted ? extracted.startIdx : output.indexOf('{"success"') |
| | | const startMatch = output.match(/\{\s*"success"\s*:/) |
| | | const startIdx = extracted ? extracted.startIdx : (startMatch?.index ?? -1) |
| | | |
| | | // 如果还在代码块中且未结束,显示提示文字 |
| | | if (state && ((state.jsonBlockStartPos !== -1) || (state.jsBlockStartPos !== -1)) && state.blockEndPos === -1) { |
| | |
| | | } |
| | | |
| | | if (parsed.description) { |
| | | display = parsed.description |
| | | display = parsed.action === 'confirm_required' |
| | | ? getPurchaseConfirmDescription(parsed) |
| | | : parsed.description |
| | | } |
| | | |
| | | if (!display) { |
| | |
| | | } |
| | | |
| | | if (parsed.description) { |
| | | display = parsed.description |
| | | display = parsed.action === 'confirm_required' |
| | | ? getPurchaseConfirmDescription(parsed) |
| | | : parsed.description |
| | | } |
| | | |
| | | if (!display) { |
| | |
| | | } |
| | | } |
| | | |
| | | .purchase-confirm-card { |
| | | margin-top: 12px; |
| | | width: 100%; |
| | | background: #fff; |
| | | border: 1px solid rgba(0, 85, 212, 0.12); |
| | | border-radius: 12px; |
| | | box-shadow: $shadow-card; |
| | | padding: 14px; |
| | | color: #1a1a2e; |
| | | } |
| | | |
| | | .purchase-confirm-header { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: space-between; |
| | | gap: 12px; |
| | | font-size: 15px; |
| | | font-weight: 700; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .purchase-confirm-desc { |
| | | margin-bottom: 12px; |
| | | color: #374151; |
| | | font-size: 13px; |
| | | line-height: 1.6; |
| | | } |
| | | |
| | | .purchase-empty-state { |
| | | margin-bottom: 12px; |
| | | padding: 12px; |
| | | border-radius: 10px; |
| | | background: linear-gradient(135deg, rgba(255, 247, 237, 0.96), rgba(255, 255, 255, 0.98)); |
| | | border: 1px solid rgba(245, 158, 11, 0.25); |
| | | |
| | | .empty-title { |
| | | font-size: 14px; |
| | | font-weight: 700; |
| | | color: #92400e; |
| | | margin-bottom: 6px; |
| | | } |
| | | |
| | | .empty-desc { |
| | | color: #78350f; |
| | | font-size: 13px; |
| | | line-height: 1.6; |
| | | } |
| | | } |
| | | |
| | | .purchase-alert { |
| | | border-radius: 8px; |
| | | padding: 10px 12px; |
| | | margin-bottom: 10px; |
| | | font-size: 13px; |
| | | |
| | | ul { |
| | | margin: 6px 0 0; |
| | | padding-left: 18px; |
| | | } |
| | | |
| | | &.warning { |
| | | background: rgba(230, 162, 60, 0.12); |
| | | color: #9a5b00; |
| | | } |
| | | |
| | | &.missing { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | align-items: center; |
| | | gap: 8px; |
| | | background: rgba(245, 108, 108, 0.1); |
| | | color: #b42318; |
| | | } |
| | | } |
| | | |
| | | .purchase-preview { |
| | | margin-bottom: 12px; |
| | | |
| | | ul { |
| | | margin: 6px 0 0; |
| | | padding-left: 18px; |
| | | font-size: 13px; |
| | | line-height: 1.7; |
| | | } |
| | | } |
| | | |
| | | .purchase-section-title { |
| | | margin: 10px 0 6px; |
| | | font-size: 13px; |
| | | font-weight: 700; |
| | | color: $deep-blue; |
| | | } |
| | | |
| | | .payload-editor { |
| | | :deep(.el-textarea__inner) { |
| | | font-family: Consolas, Monaco, monospace; |
| | | font-size: 12px; |
| | | line-height: 1.55; |
| | | } |
| | | } |
| | | |
| | | .payload-editor-tip { |
| | | margin-top: 6px; |
| | | font-size: 12px; |
| | | line-height: 1.5; |
| | | color: #6b7280; |
| | | } |
| | | |
| | | .purchase-confirm-actions { |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: flex-end; |
| | | gap: 12px; |
| | | margin-top: 12px; |
| | | |
| | | .confirm-result { |
| | | flex: 1; |
| | | font-size: 13px; |
| | | |
| | | &.success { |
| | | color: #1f9d55; |
| | | } |
| | | |
| | | &.error { |
| | | color: #d93025; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .input-area { |
| | | padding: 18px 20px; |
| | | background: linear-gradient(180deg, rgba(232, 242, 255, 0.95) 0%, #fff 100%); |
| | |
| | | background: #fff; |
| | | } |
| | | |
| | | .selected-file-list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .selected-file-tag { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | border: 1px solid rgba(0, 85, 212, 0.2); |
| | | border-radius: 10px; |
| | | padding: 8px 12px; |
| | | margin-bottom: 12px; |
| | | gap: 10px; |
| | | width: fit-content; |
| | | max-width: 100%; |