chenhj
2026-04-30 67eb69b58fcc17aff9995d552cac80921f7c764c
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">
              <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">{{ selectedFile.name }}</span>
                <el-icon class="remove-file" @click="removeSelectedFile"><Close /></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: '销售合同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)
@@ -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',
  '审核人',
  '审批人',
  '审批人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) {
@@ -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
                // 每次解析成功都尝试渲染/更新图表,以支持流式更新
                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)
  // 如果还在代码块中且未结束,显示提示文字
  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%;