From e522a99b9144bb7b299dc2a90844817e4dcad8d6 Mon Sep 17 00:00:00 2001
From: 云 <2163098428@qq.com>
Date: 星期四, 07 五月 2026 13:38:38 +0800
Subject: [PATCH] chore(config): 更新测试环境API端口配置

---
 src/components/AIChatSidebar/index.vue | 1542 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
 1 files changed, 1,482 insertions(+), 60 deletions(-)

diff --git a/src/components/AIChatSidebar/index.vue b/src/components/AIChatSidebar/index.vue
index 548ec31..369d75d 100644
--- a/src/components/AIChatSidebar/index.vue
+++ b/src/components/AIChatSidebar/index.vue
@@ -212,6 +212,202 @@
                 </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>
+                  <div class="payload-toolbar">
+                    <el-button
+                        size="small"
+                        plain
+                        :disabled="message.confirming || message.confirmed"
+                        @click="addPurchaseRootField(message)"
+                    >
+                      <el-icon><Plus /></el-icon>
+                      鏂板椤跺眰瀛楁
+                    </el-button>
+                  </div>
+                  <div class="payload-tree-table-wrapper">
+                    <el-table
+                        :data="message.payloadTreeData || []"
+                        row-key="id"
+                        border
+                        stripe
+                        size="small"
+                        default-expand-all
+                        :tree-props="{ children: 'children' }"
+                        empty-text="鏆傛棤寰呯‘璁ゆ暟鎹�"
+                    >
+                      <el-table-column label="瀛楁" min-width="240">
+                        <template #default="{ row }">
+                          <div class="payload-key-cell">
+                            <template v-if="row.parentType === 'object'">
+                              <el-input
+                                  v-if="row.keyEditable"
+                                  v-model="row.key"
+                                  size="small"
+                                  :disabled="message.confirming || message.confirmed"
+                                  placeholder="瀛楁鍚�"
+                              />
+                              <div v-else class="payload-fixed-key" :title="row.key">
+                                <span>{{ getPurchaseFieldLabel(row.key) }}</span>
+                                <small v-if="getPurchaseFieldLabel(row.key) !== row.key">{{ row.key }}</small>
+                              </div>
+                            </template>
+                            <span v-else class="payload-array-index">{{ getPurchaseArrayItemLabel(row, message) }}</span>
+                          </div>
+                        </template>
+                      </el-table-column>
+                      <el-table-column label="绫诲瀷" width="130" align="center">
+                        <template #default="{ row }">
+                          <el-select
+                              v-model="row.valueType"
+                              size="small"
+                              :disabled="message.confirming || message.confirmed"
+                              @change="handlePurchaseNodeTypeChange(message, row)"
+                          >
+                            <el-option
+                                v-for="option in purchaseValueTypeOptions"
+                                :key="option.value"
+                                :label="option.label"
+                                :value="option.value"
+                            />
+                          </el-select>
+                        </template>
+                      </el-table-column>
+                      <el-table-column label="鍊�" min-width="250">
+                        <template #default="{ row }">
+                          <div v-if="row.valueType === 'object'" class="payload-container-cell">
+                            瀵硅薄锛坽{ row.children?.length || 0 }}锛�
+                          </div>
+                          <div v-else-if="row.valueType === 'array'" class="payload-container-cell">
+                            鏁扮粍锛坽{ row.children?.length || 0 }}锛�
+                          </div>
+                          <el-switch
+                              v-else-if="row.valueType === 'boolean'"
+                              v-model="row.value"
+                              size="small"
+                              :disabled="message.confirming || message.confirmed"
+                          />
+                          <span v-else-if="row.valueType === 'null'" class="payload-null-value">null</span>
+                          <el-input
+                              v-else
+                              v-model="row.value"
+                              size="small"
+                              :placeholder="row.valueType === 'number' ? '璇疯緭鍏ユ暟瀛�' : '璇疯緭鍏ュ唴瀹�'"
+                              :disabled="message.confirming || message.confirmed"
+                          />
+                        </template>
+                      </el-table-column>
+                      <el-table-column label="鎿嶄綔" width="180" align="center">
+                        <template #default="{ row }">
+                          <div class="payload-row-actions">
+                            <el-tooltip v-if="row.valueType === 'object'" content="鏂板瀛楁" placement="top">
+                              <el-button
+                                  :icon="Plus"
+                                  circle
+                                  size="small"
+                                  text
+                                  type="primary"
+                                  :disabled="message.confirming || message.confirmed"
+                                  @click="addPurchaseChildNode(message, row)"
+                              />
+                            </el-tooltip>
+                            <el-tooltip v-else-if="row.valueType === 'array'" content="鏂板鏁扮粍椤�" placement="top">
+                              <el-button
+                                  :icon="Plus"
+                                  circle
+                                  size="small"
+                                  text
+                                  type="primary"
+                                  :disabled="message.confirming || message.confirmed"
+                                  @click="addPurchaseChildNode(message, row)"
+                              />
+                            </el-tooltip>
+                            <el-tooltip v-if="row.parentType === 'array'" content="鏂板鍚岀骇椤�" placement="top">
+                              <el-button
+                                  :icon="Plus"
+                                  circle
+                                  size="small"
+                                  text
+                                  type="primary"
+                                  :disabled="message.confirming || message.confirmed"
+                                  @click="addPurchaseSiblingNode(message, row)"
+                              />
+                            </el-tooltip>
+                            <el-tooltip content="鍒犻櫎褰撳墠椤�" placement="top">
+                              <el-button
+                                  :icon="Delete"
+                                  circle
+                                  size="small"
+                                  text
+                                  type="danger"
+                                  :disabled="message.confirming || message.confirmed"
+                                  @click="removePurchaseNode(message, row)"
+                              />
+                            </el-tooltip>
+                          </div>
+                        </template>
+                      </el-table-column>
+                    </el-table>
+                  </div>
+                  <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="confirmPurchaseAnalysisFromTable(message)"
+                    >
+                      纭骞舵墽琛�
+                    </el-button>
+                  </div>
+                </div>
+
                 <div v-if="message.isTyping" class="typing-indicator">
                   <span class="dot"></span>
                   <span class="dot"></span>
@@ -235,6 +431,8 @@
                   action="#"
                   :auto-upload="false"
                   :show-file-list="false"
+                  v-model:file-list="uploadFileList"
+                  :multiple="currentAssistant.allowMultipleFileUpload"
                   :on-change="handleFileChange"
                   :disabled="isSending"
               >
@@ -244,15 +442,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 +460,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 +518,9 @@
     placeholder: '璇疯緭鍏ラ噰璐棶棰�... (Enter 鍙戦��, Shift+Enter 鎹㈣)',
     welcomeMessage: '浣犲ソ',
     description: '鎴戝彲浠ュ崗鍔╀綘鍒嗘瀽閲囪喘璁㈠崟銆佸埌璐ц繘搴︺�佷緵搴斿晢琛ㄧ幇鍜屼粯娆炬儏鍐碉紝甯姪浣犲揩閫熷畾浣嶉噰璐紓甯搞��',
-    allowFileUpload: false,
+    allowFileUpload: true,
+    allowMultipleFileUpload: true,
+    fileAnalyzeUrl: '/purchase-ai/analyze-files',
     emptySessionText: '鏆傛棤閲囪喘浼氳瘽'
   }
 ]
@@ -378,21 +580,214 @@
 const windowWidth = ref(window.innerWidth)
 const drawerSize = computed(() => {
   if (windowWidth.value < 768) return '100%'
-  if (windowWidth.value < 1200) return '500px'
-  return '600px'
+  if (windowWidth.value < 1200) return '50%'
+  return '50%'
 })
 const messageListRef = ref(null)
 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: '鏄惁璐ㄦ',
+  isChecked: '鏄惁璐ㄦ'
+}
+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',
+  isChecked: 'isInspected'
+}
 
 // 鍘嗗彶浼氳瘽鐩稿叧
+const purchaseValueTypeOptions = [
+  { label: '鏂囨湰', value: 'string' },
+  { label: '鏁板瓧', value: 'number' },
+  { label: '甯冨皵', value: 'boolean' },
+  { label: '绌哄��', value: 'null' },
+  { label: '瀵硅薄', value: 'object' },
+  { label: '鏁扮粍', value: 'array' }
+]
+const purchaseContainerValueTypes = new Set(['object', 'array'])
+const purchaseHiddenFieldKeySet = new Set(['templatename', 'approvalstatus', 'phonenumber', 'type'])
+const purchaseHiddenKeyWordList = [
+  'attachment',
+  'file',
+  'invoice',
+  'ticketregistration',
+  'receiptpayment',
+  'payment'
+]
+const purchaseHiddenChineseKeywordList = ['闄勪欢', '寮�绁�', '鏉ョエ', '鍥炴', '浠樻']
+let purchasePayloadTreeNodeSeed = 0
+
+const shouldHidePurchaseField = (fieldKey = '') => {
+  const rawKey = String(fieldKey || '')
+  if (!rawKey) return false
+  const normalizedFieldKey = purchasePayloadFieldKeyMap[rawKey] || rawKey
+  const lowerKey = String(normalizedFieldKey).toLowerCase()
+
+  if (lowerKey.endsWith('id') || lowerKey.endsWith('ids')) return true
+  if (purchaseHiddenFieldKeySet.has(lowerKey)) return true
+  if (purchaseHiddenKeyWordList.some(keyword => lowerKey.includes(keyword))) return true
+  if (purchaseHiddenChineseKeywordList.some(keyword => rawKey.includes(keyword))) return true
+  return false
+}
+
 const showHistory = ref(false)
 const sessions = ref([])
 const loadingSessions = ref(false)
@@ -464,7 +859,9 @@
           chartOptions: null,
           chartRenderReady: false,
           type: '',
-          tableData: null
+          tableData: null,
+          payloadTreeData: null,
+          payloadHiddenData: null
         }
 
         messages.value.push(messageObj)
@@ -544,7 +941,8 @@
   outputState.value = {}
   sessions.value = []
   showHistory.value = false
-  selectedFile.value = null
+  selectedFiles.value = []
+  uploadFileList.value = []
   inputMessage.value = ''
   quickPromptStart.value = 0
   initUUID()
@@ -592,7 +990,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 +1027,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 +1093,25 @@
     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 (!Array.isArray(messageObj.payloadTreeData) || !messageObj.payloadTreeData.length) {
+      initializePurchasePayloadTree(messageObj, parsedData.payload || {})
+    }
+    if (!messageObj.payloadText) {
+      const payloadFromTree = buildPurchasePayloadFromNodes(messageObj.payloadTreeData, 'object')
+      const payloadWithHidden = mergePurchasePayloadWithHidden(payloadFromTree, messageObj.payloadHiddenData)
+      messageObj.payloadText = JSON.stringify(localizePurchasePayload(payloadWithHidden), 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 +1123,815 @@
   }
 }
 
+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 hasMeaningfulPayloadValue = (value) => {
+  if (value === null || value === undefined) return false
+  if (typeof value === 'string') return value.trim() !== ''
+  if (Array.isArray(value)) return value.some(item => hasMeaningfulPayloadValue(item))
+  if (typeof value === 'object') return Object.values(value).some(item => hasMeaningfulPayloadValue(item))
+  return true
+}
+
+const mergeMappedPayloadValue = (existingValue, incomingValue) => {
+  if (existingValue === undefined) return incomingValue
+
+  const existingHasValue = hasMeaningfulPayloadValue(existingValue)
+  const incomingHasValue = hasMeaningfulPayloadValue(incomingValue)
+
+  if (existingHasValue && !incomingHasValue) return existingValue
+  if (!existingHasValue && incomingHasValue) return incomingValue
+
+  if (
+    existingValue &&
+    incomingValue &&
+    typeof existingValue === 'object' &&
+    typeof incomingValue === 'object' &&
+    !Array.isArray(existingValue) &&
+    !Array.isArray(incomingValue)
+  ) {
+    return { ...existingValue, ...incomingValue }
+  }
+
+  return incomingValue
+}
+
+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]) => {
+      const mappedKey = keyMap[key] || key
+      const mappedValue = mapPayloadKeys(item, keyMap)
+      result[mappedKey] = mergeMappedPayloadValue(result[mappedKey], mappedValue)
+      return result
+    }, {})
+  }
+  return value
+}
+
+const clonePurchasePayloadValue = (value) => {
+  if (Array.isArray(value)) {
+    return value.map(item => clonePurchasePayloadValue(item))
+  }
+  if (value && typeof value === 'object') {
+    return Object.entries(value).reduce((result, [key, item]) => {
+      result[key] = clonePurchasePayloadValue(item)
+      return result
+    }, {})
+  }
+  return value
+}
+
+const splitPurchasePayloadByVisibility = (value) => {
+  if (Array.isArray(value)) {
+    const splitItems = value.map(item => splitPurchasePayloadByVisibility(item))
+    const visible = splitItems.map(item => item.visible)
+    const hidden = splitItems.map(item => item.hidden)
+    return { visible, hidden }
+  }
+
+  if (value && typeof value === 'object') {
+    const visible = {}
+    const hidden = {}
+
+    Object.entries(value).forEach(([key, item]) => {
+      if (shouldHidePurchaseField(key)) {
+        hidden[key] = clonePurchasePayloadValue(item)
+        return
+      }
+      const child = splitPurchasePayloadByVisibility(item)
+      visible[key] = child.visible
+      if (hasMeaningfulPayloadValue(child.hidden)) {
+        hidden[key] = child.hidden
+      }
+    })
+
+    return { visible, hidden }
+  }
+
+  return { visible: value, hidden: undefined }
+}
+
+const mergePurchasePayloadWithHidden = (visibleValue, hiddenValue) => {
+  if (hiddenValue === undefined || hiddenValue === null) return visibleValue
+  if (visibleValue === undefined || visibleValue === null) return clonePurchasePayloadValue(hiddenValue)
+
+  if (Array.isArray(visibleValue) && Array.isArray(hiddenValue)) {
+    const maxLength = Math.max(visibleValue.length, hiddenValue.length)
+    const merged = []
+    for (let i = 0; i < maxLength; i++) {
+      merged[i] = mergePurchasePayloadWithHidden(visibleValue[i], hiddenValue[i])
+    }
+    return merged
+  }
+
+  if (
+    visibleValue &&
+    hiddenValue &&
+    typeof visibleValue === 'object' &&
+    typeof hiddenValue === 'object' &&
+    !Array.isArray(visibleValue) &&
+    !Array.isArray(hiddenValue)
+  ) {
+    const merged = { ...clonePurchasePayloadValue(hiddenValue) }
+    Object.entries(visibleValue).forEach(([key, item]) => {
+      merged[key] = mergePurchasePayloadWithHidden(item, merged[key])
+    })
+    return merged
+  }
+
+  return visibleValue
+}
+
+const localizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldLabelMap)
+
+const normalizePurchasePayload = (payload) => mapPayloadKeys(payload, purchasePayloadFieldKeyMap)
+
+const createPurchasePayloadNodeId = () => `purchase-node-${Date.now()}-${purchasePayloadTreeNodeSeed++}`
+
+const detectPurchaseValueType = (value) => {
+  if (Array.isArray(value)) return 'array'
+  if (value === null) return 'null'
+  const valueType = typeof value
+  if (valueType === 'number') return 'number'
+  if (valueType === 'boolean') return 'boolean'
+  if (valueType === 'object') return 'object'
+  return 'string'
+}
+
+const normalizePurchaseNodeValueForEdit = (value, valueType) => {
+  if (valueType === 'number') return value === null || value === undefined ? '' : String(value)
+  if (valueType === 'boolean') return Boolean(value)
+  if (valueType === 'null') return ''
+  return value === null || value === undefined ? '' : String(value)
+}
+
+const createPurchaseTreeNode = ({
+  key = '',
+  parentType = 'object',
+  keyEditable = false,
+  valueType = 'string',
+  value = '',
+  children = []
+} = {}) => ({
+  id: createPurchasePayloadNodeId(),
+  key,
+  parentType,
+  keyEditable,
+  valueType,
+  value,
+  children
+})
+
+const reorderPurchaseObjectEntries = (value) => {
+  const entries = Object.entries(value || {})
+  const productDataIndex = entries.findIndex(([key]) => key === 'productData')
+  if (productDataIndex <= -1 || productDataIndex === entries.length - 1) {
+    return entries
+  }
+  const [productDataEntry] = entries.splice(productDataIndex, 1)
+  entries.push(productDataEntry)
+  return entries
+}
+
+const buildPurchasePayloadTreeNodes = (value, parentType = 'object') => {
+  if (Array.isArray(value)) {
+    return value.map(item => {
+      const itemType = detectPurchaseValueType(item)
+      const node = createPurchaseTreeNode({
+        key: '',
+        parentType: 'array',
+        keyEditable: false,
+        valueType: itemType,
+        value: normalizePurchaseNodeValueForEdit(item, itemType)
+      })
+      if (purchaseContainerValueTypes.has(itemType)) {
+        node.children = buildPurchasePayloadTreeNodes(item, itemType)
+      }
+      return node
+    })
+  }
+
+  if (value && typeof value === 'object') {
+    return reorderPurchaseObjectEntries(value).map(([key, item]) => {
+      const itemType = detectPurchaseValueType(item)
+      const node = createPurchaseTreeNode({
+        key,
+        parentType,
+        keyEditable: false,
+        valueType: itemType,
+        value: normalizePurchaseNodeValueForEdit(item, itemType)
+      })
+      if (purchaseContainerValueTypes.has(itemType)) {
+        node.children = buildPurchasePayloadTreeNodes(item, itemType)
+      }
+      return node
+    })
+  }
+
+  return []
+}
+
+const initializePurchasePayloadTree = (messageObj, payload = {}) => {
+  const sourcePayload = payload && typeof payload === 'object' && !Array.isArray(payload)
+    ? payload
+    : {}
+  const { visible, hidden } = splitPurchasePayloadByVisibility(sourcePayload)
+  const visiblePayload = visible && typeof visible === 'object' && !Array.isArray(visible) ? visible : {}
+  messageObj.payloadTreeData = buildPurchasePayloadTreeNodes(visiblePayload, 'object')
+  messageObj.payloadHiddenData = hidden && typeof hidden === 'object' ? hidden : {}
+}
+
+const getPurchaseFieldLabel = (fieldKey) => purchasePayloadFieldLabelMap[fieldKey] || fieldKey || '瀛楁'
+
+const createPurchaseDefaultNode = (parentType = 'object') => createPurchaseTreeNode({
+  key: parentType === 'object' ? 'newField' : '',
+  parentType,
+  keyEditable: parentType === 'object',
+  valueType: 'string',
+  value: ''
+})
+
+const getPurchaseScalarNodeValue = (node) => {
+  if (node.valueType === 'null') return null
+  if (node.valueType === 'boolean') return Boolean(node.value)
+  if (node.valueType === 'number') {
+    const text = String(node.value ?? '').trim()
+    if (!text) return null
+    const numberValue = Number(text)
+    return Number.isFinite(numberValue) ? numberValue : text
+  }
+  return node.value === null || node.value === undefined ? '' : String(node.value)
+}
+
+const buildPurchasePayloadFromNodes = (nodes, parentType = 'object') => {
+  if (!Array.isArray(nodes)) {
+    return parentType === 'array' ? [] : {}
+  }
+
+  if (parentType === 'array') {
+    return nodes.map(node => {
+      if (purchaseContainerValueTypes.has(node.valueType)) {
+        return buildPurchasePayloadFromNodes(node.children, node.valueType)
+      }
+      return getPurchaseScalarNodeValue(node)
+    })
+  }
+
+  return nodes.reduce((result, node, index) => {
+    const rawKey = String(node.key ?? '').trim()
+    const key = rawKey || `field_${index + 1}`
+    if (purchaseContainerValueTypes.has(node.valueType)) {
+      result[key] = buildPurchasePayloadFromNodes(node.children, node.valueType)
+    } else {
+      result[key] = getPurchaseScalarNodeValue(node)
+    }
+    return result
+  }, {})
+}
+
+const findPurchaseNodeLocation = (nodes, targetId, parentNode = null) => {
+  if (!Array.isArray(nodes)) return null
+  for (let index = 0; index < nodes.length; index++) {
+    const node = nodes[index]
+    if (node.id === targetId) {
+      return {
+        siblings: nodes,
+        index,
+        node,
+        parentNode
+      }
+    }
+    const next = findPurchaseNodeLocation(node.children, targetId, node)
+    if (next) return next
+  }
+  return null
+}
+
+const getPurchaseArrayItemLabel = (row, message) => {
+  const location = findPurchaseNodeLocation(message?.payloadTreeData, row.id)
+  return `[${(location?.index ?? 0) + 1}]`
+}
+
+const handlePurchaseNodeTypeChange = (message, row) => {
+  if (!message || !row) return
+  if (purchaseContainerValueTypes.has(row.valueType)) {
+    row.children = []
+    row.value = ''
+    return
+  }
+  row.children = []
+  if (row.valueType === 'boolean') {
+    row.value = false
+  } else if (row.valueType === 'null') {
+    row.value = ''
+  } else {
+    row.value = ''
+  }
+}
+
+const addPurchaseRootField = (message) => {
+  if (!message) return
+  if (!Array.isArray(message.payloadTreeData)) {
+    message.payloadTreeData = []
+  }
+  message.payloadTreeData.push(createPurchaseDefaultNode('object'))
+}
+
+const addPurchaseChildNode = (message, row) => {
+  if (!message || !row || !purchaseContainerValueTypes.has(row.valueType)) return
+  if (!Array.isArray(row.children)) {
+    row.children = []
+  }
+  row.children.push(createPurchaseDefaultNode(row.valueType))
+}
+
+const addPurchaseSiblingNode = (message, row) => {
+  if (!message || !row) return
+  const location = findPurchaseNodeLocation(message.payloadTreeData, row.id)
+  if (!location || location.node.parentType !== 'array') return
+  location.siblings.splice(location.index + 1, 0, createPurchaseDefaultNode('array'))
+}
+
+const removePurchaseNode = (message, row) => {
+  if (!message || !row) return
+  const location = findPurchaseNodeLocation(message.payloadTreeData, row.id)
+  if (!location) return
+  location.siblings.splice(location.index, 1)
+}
+
+const hasPurchaseNodeValidationError = (nodes, parentType = 'object') => {
+  if (!Array.isArray(nodes)) return false
+  return nodes.some((node) => {
+    if (parentType === 'object' && !String(node.key ?? '').trim()) {
+      return true
+    }
+    if (node.valueType === 'number') {
+      const text = String(node.value ?? '').trim()
+      if (text && !Number.isFinite(Number(text))) {
+        return true
+      }
+    }
+    if (purchaseContainerValueTypes.has(node.valueType)) {
+      return hasPurchaseNodeValidationError(node.children, node.valueType)
+    }
+    return false
+  })
+}
+
+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 => {
+      if (purchaseApprovalFieldKeys.has(field)) return false
+      const normalizedField = purchasePayloadFieldKeyMap[field] || field
+      return !shouldHidePurchaseField(normalizedField) && !shouldHidePurchaseField(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 confirmPurchaseAnalysisFromTable = async (message) => {
+  if (!message?.purchaseAnalysisData || message.confirming || message.confirmed) return
+
+  if (!Array.isArray(message.payloadTreeData)) {
+    initializePurchasePayloadTree(message, message.purchaseAnalysisData.payload || {})
+  }
+  if (hasPurchaseNodeValidationError(message.payloadTreeData, 'object')) {
+    message.confirmResult = '璇峰厛琛ュ叏瀛楁鍚嶏紝骞剁‘淇濇暟瀛楀瓧娈靛~鍐欏悎娉曟暟瀛�'
+    message.confirmed = false
+    return
+  }
+
+  let payload
+  try {
+    const draftPayload = buildPurchasePayloadFromNodes(message.payloadTreeData, 'object')
+    const mergedPayload = mergePurchasePayloadWithHidden(draftPayload, message.payloadHiddenData)
+    const normalizedPayload = normalizePurchasePayload(mergedPayload)
+    payload = sanitizePurchasePayloadForSubmit(
+      normalizePurchasePayloadDates(normalizedPayload),
+      message.purchaseAnalysisData.businessType
+    )
+    message.payloadText = JSON.stringify(localizePurchasePayload(normalizedPayload), null, 2)
+  } catch (err) {
+    message.confirmResult = '寰呮彁浜ゆ暟鎹牸寮忔湁璇紝璇锋鏌ュ悗鍐嶇‘璁�'
+    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 +1940,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,
@@ -753,7 +1988,9 @@
     chartOptions: null,
     chartRenderReady: false,
     type: '',
-    tableData: null
+    tableData: null,
+    payloadTreeData: null,
+    payloadHiddenData: null
   })
 
   outputState.value[botMsgIndex] = {
@@ -767,13 +2004,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 +2042,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 +2055,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 +2073,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)
     }
@@ -864,7 +2111,9 @@
     chartOptions: null,
     chartRenderReady: false,
     type: '',
-    tableData: null
+    tableData: null,
+    payloadTreeData: null,
+    payloadHiddenData: null
   }
   messages.value.push(botMsg)
 
@@ -915,16 +2164,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 +2200,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 +2253,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 +2273,9 @@
     }
 
     if (parsed.description) {
-      display = parsed.description
+      display = parsed.action === 'confirm_required'
+        ? getPurchaseConfirmDescription(parsed)
+        : parsed.description
     }
 
     if (!display) {
@@ -1066,7 +2301,9 @@
         }
 
         if (parsed.description) {
-          display = parsed.description
+          display = parsed.action === 'confirm_required'
+            ? getPurchaseConfirmDescription(parsed)
+            : parsed.description
         }
 
         if (!display) {
@@ -2008,6 +3245,185 @@
   }
 }
 
+.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-toolbar {
+  display: flex;
+  justify-content: flex-end;
+  margin-bottom: 8px;
+}
+
+.payload-tree-table-wrapper {
+  border: 1px solid rgba(0, 85, 212, 0.1);
+  border-radius: 10px;
+  overflow: auto;
+
+  :deep(.el-table) {
+    --el-table-header-bg-color: #f5f8ff;
+    --el-table-border-color: rgba(0, 85, 212, 0.08);
+  }
+}
+
+.payload-key-cell {
+  display: flex;
+  align-items: center;
+  min-height: 28px;
+}
+
+.payload-fixed-key {
+  display: flex;
+  flex-direction: column;
+  gap: 2px;
+  line-height: 1.3;
+  color: #1f2937;
+
+  small {
+    font-size: 11px;
+    color: #6b7280;
+  }
+}
+
+.payload-array-index {
+  font-size: 12px;
+  color: #475467;
+}
+
+.payload-container-cell {
+  color: #344054;
+  font-size: 12px;
+}
+
+.payload-null-value {
+  color: #6b7280;
+  font-size: 12px;
+}
+
+.payload-row-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 4px;
+}
+
+.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 +3502,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 +3516,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