From 6da0a35c994d78280196b1f3b3799b79abd1ffbc Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 30 四月 2026 17:37:04 +0800
Subject: [PATCH] feat(AIChatSidebar): 添加采购分析确认卡片和多文件上传功能
---
src/components/AIChatSidebar/index.vue | 918 +++++++++++++++++++++++++++++++++++++++++++++++++++++---
1 files changed, 863 insertions(+), 55 deletions(-)
diff --git a/src/components/AIChatSidebar/index.vue b/src/components/AIChatSidebar/index.vue
index 548ec31..3bc2b8f 100644
--- a/src/components/AIChatSidebar/index.vue
+++ b/src/components/AIChatSidebar/index.vue
@@ -212,6 +212,75 @@
</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>
@@ -235,6 +304,8 @@
action="#"
:auto-upload="false"
:show-file-list="false"
+ v-model:file-list="uploadFileList"
+ :multiple="currentAssistant.allowMultipleFileUpload"
:on-change="handleFileChange"
:disabled="isSending"
>
@@ -244,15 +315,17 @@
</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"
@@ -260,7 +333,7 @@
<el-button
type="primary"
class="send-btn"
- :disabled="isSending || (!inputMessage.trim() && !selectedFile)"
+ :disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)"
@click="sendMessage"
aria-label="鍙戦��"
>
@@ -318,7 +391,9 @@
placeholder: '璇疯緭鍏ラ噰璐棶棰�... (Enter 鍙戦��, Shift+Enter 鎹㈣)',
welcomeMessage: '浣犲ソ',
description: '鎴戝彲浠ュ崗鍔╀綘鍒嗘瀽閲囪喘璁㈠崟銆佸埌璐ц繘搴︺�佷緵搴斿晢琛ㄧ幇鍜屼粯娆炬儏鍐碉紝甯姪浣犲揩閫熷畾浣嶉噰璐紓甯搞��',
- allowFileUpload: false,
+ allowFileUpload: true,
+ allowMultipleFileUpload: true,
+ fileAnalyzeUrl: '/purchase-ai/analyze-files',
emptySessionText: '鏆傛棤閲囪喘浼氳瘽'
}
]
@@ -385,12 +460,169 @@
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: '閿�鍞悎鍚孖D',
+ approveUserIds: '瀹℃壒鐢ㄦ埛ID鍒楄〃',
+ entryDateStart: '褰曞叆寮�濮嬫棩鏈�',
+ entryDateEnd: '褰曞叆缁撴潫鏃ユ湡',
+ id: 'ID',
+ supplierId: '渚涘簲鍟咺D',
+ projectName: '椤圭洰鍚嶇О',
+ supplierName: '渚涘簲鍟嗗悕绉�',
+ isWhite: '鏄惁鐧藉悕鍗�',
+ recorderId: '褰曞叆浜篒D',
+ recorderName: '褰曞叆浜�',
+ contractDate: '鎵ц鏃ユ湡',
+ executionDate: '鎵ц鏃ユ湡',
+ inputPerson: '褰曞叆浜�',
+ inputDate: '褰曞叆鏃ユ湡',
+ entryDate: '褰曞叆鏃ユ湡',
+ paymentMethod: '浠樻鏂瑰紡',
+ auditors: '瀹℃壒浜�',
+ approverId: '瀹℃壒浜篒D',
+ approvalStatus: '瀹℃壒鐘舵��',
+ remark: '澶囨敞',
+ remarks: '澶囨敞',
+ attachmentMaterials: '闄勪欢鏉愭枡',
+ createdAt: '鍒涘缓鏃堕棿',
+ updatedAt: '鏇存柊鏃堕棿',
+ salesLedgerId: '閿�鍞彴璐D',
+ hasChildren: '鏄惁鏈夊瓙椤�',
+ Type: '绫诲瀷',
+ type: '绫诲瀷',
+ tempFileIds: '涓存椂鏂囦欢ID',
+ SalesLedgerFiles: '閿�鍞彴璐﹂檮浠�',
+ phoneNumber: '鑱旂郴鐢佃瘽',
+ businessPersonId: '涓氬姟鍛業D',
+ productId: '浜у搧ID',
+ productModelId: '浜у搧鍨嬪彿ID',
+ invoiceNumber: '鍙戠エ鍙风爜',
+ invoiceAmount: '鍙戠エ閲戦',
+ ticketRegistrationId: '寮�绁ㄧ櫥璁癐D',
+ 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',
+ 閿�鍞悎鍚孖D: 'salesContractNoId',
+ 瀹℃壒鐢ㄦ埛ID鍒楄〃: 'approveUserIds',
+ 褰曞叆寮�濮嬫棩鏈�: 'entryDateStart',
+ 褰曞叆缁撴潫鏃ユ湡: 'entryDateEnd',
+ ID: 'id',
+ 椤圭洰鍚嶇О: 'projectName',
+ 渚涘簲鍟咺D: 'supplierId',
+ 渚涘簲鍟嗗悕绉�: 'supplierName',
+ 鏄惁鐧藉悕鍗�: 'isWhite',
+ 褰曞叆浜篒D: 'recorderId',
+ 褰曞叆浜�: 'recorderName',
+ 绛捐鏃ユ湡: 'executionDate',
+ 鎵ц鏃ユ湡: 'executionDate',
+ 褰曞叆鏃ユ湡: 'entryDate',
+ 浠樻鏂瑰紡: 'paymentMethod',
+ 瀹℃牳浜�: 'approverId',
+ 瀹℃壒浜�: 'approverId',
+ 瀹℃壒浜篒D: 'approverId',
+ 瀹℃壒鐘舵��: 'approvalStatus',
+ 澶囨敞: 'remarks',
+ 闄勪欢鏉愭枡: 'attachmentMaterials',
+ 鍒涘缓鏃堕棿: 'createdAt',
+ 鏇存柊鏃堕棿: 'updatedAt',
+ 閿�鍞彴璐D: 'salesLedgerId',
+ 鏄惁鏈夊瓙椤�: 'hasChildren',
+ 绫诲瀷: 'type',
+ 涓存椂鏂囦欢ID: 'tempFileIds',
+ 閿�鍞彴璐﹂檮浠�: 'SalesLedgerFiles',
+ 鑱旂郴鐢佃瘽: 'phoneNumber',
+ 涓氬姟鍛業D: 'businessPersonId',
+ 浜у搧ID: 'productId',
+ 浜у搧鍨嬪彿ID: 'productModelId',
+ 鍙戠エ鍙风爜: 'invoiceNumber',
+ 鍙戠エ閲戦: 'invoiceAmount',
+ 寮�绁ㄧ櫥璁癐D: '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)
@@ -544,7 +776,8 @@
outputState.value = {}
sessions.value = []
showHistory.value = false
- selectedFile.value = null
+ selectedFiles.value = []
+ uploadFileList.value = []
inputMessage.value = ''
quickPromptStart.value = 0
initUUID()
@@ -592,7 +825,8 @@
outputState.value = {}
sessions.value = []
showHistory.value = false
- selectedFile.value = null
+ selectedFiles.value = []
+ uploadFileList.value = []
quickPromptStart.value = 0
localStorage.removeItem(currentAssistant.value.storageKey)
initUUID()
@@ -628,8 +862,10 @@
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
@@ -692,8 +928,20 @@
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) {
@@ -705,6 +953,425 @@
}
}
+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',
+ '瀹℃牳浜�',
+ '瀹℃壒浜�',
+ '瀹℃壒浜篒D',
+ '瀹℃壒鐢ㄦ埛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) {
@@ -713,30 +1380,38 @@
})
}
-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,
@@ -767,13 +1442,15 @@
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'
},
@@ -803,6 +1480,11 @@
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)
@@ -811,6 +1493,8 @@
}).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)
@@ -827,10 +1511,11 @@
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)
}
@@ -915,16 +1600,7 @@
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
- // 姣忔瑙f瀽鎴愬姛閮藉皾璇曟覆鏌�/鏇存柊鍥捐〃锛屼互鏀寔娴佸紡鏇存柊
- renderCharts(botMsgIndex, currentMsg.chartOptions)
- }
+ applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true)
}
}
@@ -960,15 +1636,7 @@
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 => {
@@ -1021,7 +1689,8 @@
// 灏濊瘯鎻愬彇 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)
// 濡傛灉杩樺湪浠g爜鍧椾腑涓旀湭缁撴潫锛屾樉绀烘彁绀烘枃瀛�
if (state && ((state.jsonBlockStartPos !== -1) || (state.jsBlockStartPos !== -1)) && state.blockEndPos === -1) {
@@ -1040,7 +1709,9 @@
}
if (parsed.description) {
- display = parsed.description
+ display = parsed.action === 'confirm_required'
+ ? getPurchaseConfirmDescription(parsed)
+ : parsed.description
}
if (!display) {
@@ -1066,7 +1737,9 @@
}
if (parsed.description) {
- display = parsed.description
+ display = parsed.action === 'confirm_required'
+ ? getPurchaseConfirmDescription(parsed)
+ : parsed.description
}
if (!display) {
@@ -2008,6 +2681,135 @@
}
}
+.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%);
@@ -2086,6 +2888,13 @@
background: #fff;
}
+ .selected-file-list {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 8px;
+ margin-bottom: 12px;
+ }
+
.selected-file-tag {
display: flex;
align-items: center;
@@ -2093,7 +2902,6 @@
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%;
--
Gitblit v1.9.3