<template>
|
<div class="ai-chat-sidebar-wrapper">
|
<!-- 悬浮图标 -->
|
<div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible">
|
<el-tooltip :content="currentAssistant.tooltip" placement="left">
|
<div class="trigger-icon">
|
<el-icon :size="30" color="#fff"><component :is="currentAssistant.icon" /></el-icon>
|
</div>
|
</el-tooltip>
|
</div>
|
|
<!-- 侧边栏对话框 -->
|
<el-drawer
|
v-model="visible"
|
:size="drawerSize"
|
direction="rtl"
|
:with-header="true"
|
class="ai-chat-drawer"
|
:modal="false"
|
modal-class="ai-chat-overlay"
|
:show-close="false"
|
:append-to-body="false"
|
@close="handleClose"
|
>
|
<template #header>
|
<div class="drawer-header">
|
<div class="header-left">
|
<el-icon :size="20" class="header-icon"><component :is="currentAssistant.icon" /></el-icon>
|
<span class="title">{{ currentAssistant.title }}</span>
|
</div>
|
<div v-if="showAssistantSwitch" class="assistant-switcher">
|
<el-radio-group v-model="selectedAssistantKey" size="small">
|
<el-radio-button
|
v-for="assistant in assistants"
|
:key="assistant.key"
|
:label="assistant.key"
|
>
|
{{ assistant.label }}
|
</el-radio-button>
|
</el-radio-group>
|
</div>
|
<div class="header-actions">
|
<el-tooltip content="会话历史" placement="bottom">
|
<el-button link class="header-action-btn" @click="handleToggleHistory">
|
<el-icon :size="18"><Timer /></el-icon>
|
</el-button>
|
</el-tooltip>
|
<el-tooltip content="开启新会话" placement="bottom">
|
<el-button link class="header-action-btn" @click="handleNewChat">
|
<el-icon :size="18"><Plus /></el-icon>
|
</el-button>
|
</el-tooltip>
|
<div class="action-divider"></div>
|
<el-tooltip content="关闭" placement="bottom">
|
<el-button link class="header-action-btn close-btn" @click="handleManualClose">
|
<el-icon :size="18"><Close /></el-icon>
|
</el-button>
|
</el-tooltip>
|
</div>
|
</div>
|
</template>
|
|
<div class="chat-container">
|
<!-- 历史会话列表 -->
|
<div v-if="showHistory" class="history-panel">
|
<div class="history-header">
|
<span>最近会话</span>
|
<el-button link type="primary" @click="showHistory = false">返回对话</el-button>
|
</div>
|
<el-skeleton :loading="loadingSessions" animated>
|
<template #template>
|
<div v-for="i in 5" :key="i" style="padding: 10px">
|
<el-skeleton-item variant="p" style="width: 80%" />
|
</div>
|
</template>
|
<div class="session-list">
|
<div
|
v-for="session in sessions"
|
:key="session.memoryId"
|
:class="['session-item', { active: uuid === session.memoryId }]"
|
@click="selectSession(session)"
|
>
|
<el-icon><ChatDotSquare /></el-icon>
|
<span class="session-name" :title="session.lastMessage || '新会话'">
|
{{ session.lastMessage || '新会话' }}
|
</span>
|
<el-button
|
link
|
type="danger"
|
class="delete-btn"
|
@click.stop="handleDeleteSession(session.memoryId)"
|
>
|
<el-icon><Delete /></el-icon>
|
</el-button>
|
</div>
|
<el-empty v-if="sessions.length === 0" :description="currentAssistant.emptySessionText" />
|
</div>
|
</el-skeleton>
|
</div>
|
|
<div v-else class="chat-main">
|
<div :class="['chat-hero', { compact: hasMessages }]">
|
<div :class="['assistant-stand', { thinking: isSending, compact: hasMessages }]">
|
<div class="assistant-halo"></div>
|
<div class="assistant-scan-ring"></div>
|
<div class="assistant-orbit assistant-orbit-a"></div>
|
<div class="assistant-orbit assistant-orbit-b"></div>
|
<div class="assistant-bot">
|
<div class="assistant-bot-antenna assistant-bot-antenna-left"></div>
|
<div class="assistant-bot-antenna assistant-bot-antenna-right"></div>
|
<div class="assistant-bot-head">
|
<div class="assistant-bot-head-glow"></div>
|
<div class="assistant-bot-eye assistant-bot-eye-left"></div>
|
<div class="assistant-bot-eye assistant-bot-eye-right"></div>
|
<div class="assistant-bot-mouth"></div>
|
</div>
|
<div class="assistant-bot-neck"></div>
|
<div class="assistant-bot-body">
|
<div class="assistant-bot-core">
|
<div class="assistant-bot-core-ring"></div>
|
<el-icon :size="22"><component :is="currentAssistant.icon" /></el-icon>
|
</div>
|
<div class="assistant-bot-arm assistant-bot-arm-left"></div>
|
<div class="assistant-bot-arm assistant-bot-arm-right"></div>
|
</div>
|
</div>
|
<div class="assistant-status">
|
<span class="assistant-status-dot"></span>
|
{{ isSending ? '思考中...' : currentAssistant.label }}
|
</div>
|
<div class="assistant-base assistant-base-lg"></div>
|
<div class="assistant-base assistant-base-md"></div>
|
<div class="assistant-base assistant-base-sm"></div>
|
</div>
|
|
<div :class="['welcome-card', { compact: hasMessages }]">
|
<div class="welcome-eyebrow">智能助手</div>
|
<h3 class="welcome-title">
|
您好
|
<br />
|
我是{{ currentAssistant.label }}分析解读助手
|
</h3>
|
<p class="welcome-desc">
|
{{ currentAssistant.description || '我可以围绕业务问题提供解读、查询建议和分析支持,帮助你更快完成判断与处理。' }}
|
</p>
|
|
<div class="quick-prompt-list">
|
<button
|
v-for="prompt in displayedQuickPrompts"
|
:key="prompt"
|
type="button"
|
class="quick-prompt-btn"
|
:disabled="isSending"
|
@click="sendQuickPrompt(prompt)"
|
>
|
{{ prompt }}
|
</button>
|
</div>
|
|
<button
|
v-if="quickPrompts.length > quickPromptLimit"
|
type="button"
|
class="more-prompts-btn"
|
@click="refreshQuickPrompts"
|
>
|
<el-icon><RefreshRight /></el-icon>
|
<span>换一换</span>
|
</button>
|
</div>
|
</div>
|
|
<div v-show="!hasMessages" class="hero-dot-grid" aria-hidden="true">
|
<span v-for="dot in 28" :key="dot"></span>
|
</div>
|
|
<div class="message-list" ref="messageListRef">
|
<div
|
v-for="(message, index) in messages"
|
:key="index"
|
:class="['message-item', message.isUser ? 'user-message' : 'bot-message']"
|
>
|
<div class="avatar">
|
<el-icon v-if="message.isUser"><User /></el-icon>
|
<el-icon v-else><Cpu /></el-icon>
|
</div>
|
<div class="message-content">
|
<!-- 文本内容 -->
|
<div class="text-box" v-html="message.htmlContent"></div>
|
|
<!-- 图表内容 -->
|
<div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper">
|
<div
|
v-for="(option, key) in message.chartOptions"
|
:key="key"
|
class="chart-item"
|
:id="`ai-chart-${index}-${key}`"
|
></div>
|
</div>
|
|
<!-- 表格内容 -->
|
<div v-if="message.type === 'todo_list' && message.tableData" class="table-wrapper">
|
<el-table :data="message.tableData.items" border stripe size="small" style="width: 100%">
|
<el-table-column
|
v-for="col in message.tableData.columns"
|
:key="col"
|
:prop="col"
|
:label="columnLabelMap[col] || col"
|
min-width="100"
|
show-overflow-tooltip
|
/>
|
</el-table>
|
</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>
|
<span class="dot"></span>
|
</div>
|
</div>
|
</div>
|
</div>
|
|
<div class="input-area">
|
<div class="input-actions">
|
<el-button link class="utility-action-btn" type="primary" size="small" @click="handleNewChat">
|
<el-icon><Plus /></el-icon>新会话
|
</el-button>
|
<el-button v-if="isSending" link class="utility-action-btn stop-action-btn" type="danger" size="small" @click="stopGeneration">
|
<el-icon><VideoPause /></el-icon>停止生成
|
</el-button>
|
<el-upload
|
v-if="currentAssistant.allowFileUpload"
|
class="file-upload-trigger"
|
action="#"
|
:auto-upload="false"
|
:show-file-list="false"
|
v-model:file-list="uploadFileList"
|
:multiple="currentAssistant.allowMultipleFileUpload"
|
:on-change="handleFileChange"
|
:disabled="isSending"
|
>
|
<el-button link class="utility-action-btn upload-action-btn" type="primary" size="small" :disabled="isSending">
|
<el-icon><Upload /></el-icon>分析文件
|
</el-button>
|
</el-upload>
|
</div>
|
<div class="input-box">
|
<div v-if="selectedFiles.length" class="selected-file-list">
|
<div v-for="(file, fileIndex) in selectedFiles" :key="`${file.name}-${fileIndex}`" class="selected-file-tag">
|
<el-icon><Document /></el-icon>
|
<span class="file-name">{{ file.name }}</span>
|
<el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon>
|
</div>
|
</div>
|
<el-input
|
v-model="inputMessage"
|
type="textarea"
|
:rows="selectedFiles.length ? 2 : 3"
|
:placeholder="currentAssistant.placeholder"
|
resize="none"
|
@keydown.enter.exact.prevent="sendMessage"
|
/>
|
<el-button
|
type="primary"
|
class="send-btn"
|
:disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)"
|
@click="sendMessage"
|
aria-label="发送"
|
>
|
<el-icon><Promotion /></el-icon>
|
</el-button>
|
</div>
|
</div>
|
</div>
|
</div>
|
</el-drawer>
|
</div>
|
</template>
|
|
<script setup>
|
import { ref, onMounted, onUnmounted, nextTick, watch, computed } from 'vue'
|
import request from '@/utils/request'
|
import * as echarts from 'echarts'
|
import { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, ShoppingCart, Promotion, RefreshRight } from '@element-plus/icons-vue'
|
import { ElMessage } from 'element-plus'
|
|
const props = defineProps({
|
assistants: {
|
type: Array,
|
default: () => []
|
},
|
defaultAssistant: {
|
type: String,
|
default: ''
|
}
|
})
|
|
const builtInAssistants = [
|
{
|
key: 'general',
|
label: '待办助理',
|
title: '待办智能助理',
|
tooltip: '待办助手',
|
icon: Cpu,
|
apiBase: '/xiaozhi',
|
storageKey: 'ai_chat_uuid',
|
placeholder: '请输入您的问题... (Enter 发送, Shift+Enter 换行)',
|
welcomeMessage: '你好',
|
description: '我可以回答你的问题,为你提供业务数据解读信息、处理建议和辅助决策支持。',
|
allowFileUpload: true,
|
emptySessionText: '暂无历史会话'
|
},
|
{
|
key: 'purchase',
|
label: '采购助理',
|
title: '采购智能助理',
|
tooltip: '采购智能助理',
|
icon: ShoppingCart,
|
apiBase: '/purchase-ai',
|
storageKey: 'purchase_ai_chat_uuid',
|
placeholder: '请输入采购问题... (Enter 发送, Shift+Enter 换行)',
|
welcomeMessage: '你好',
|
description: '我可以协助你分析采购订单、到货进度、供应商表现和付款情况,帮助你快速定位采购异常。',
|
allowFileUpload: true,
|
allowMultipleFileUpload: true,
|
fileAnalyzeUrl: '/purchase-ai/analyze-files',
|
emptySessionText: '暂无采购会话'
|
}
|
]
|
|
const assistants = computed(() => props.assistants?.length ? props.assistants : builtInAssistants)
|
const selectedAssistantKey = ref(props.defaultAssistant || assistants.value[0]?.key || 'general')
|
const currentAssistant = computed(() => assistants.value.find(item => item.key === selectedAssistantKey.value) || assistants.value[0] || builtInAssistants[0])
|
const showAssistantSwitch = computed(() => assistants.value.length > 1)
|
const assistantQuickPromptMap = {
|
general: [
|
'我当前有哪些审批待办需要处理?',
|
'帮我列出今天新增的审批待办。',
|
'当前待我审批的单据,按时间倒序列出来。',
|
'我发起的审批里,哪些还在处理中?',
|
'查询流程编号 XXX 的审批详情。',
|
'流程编号 XXX 现在卡在哪个审批节点?当前审批人是谁?',
|
'帮我查看流程编号 XXX 的审批流转记录。',
|
'近7天我的审批待办统计情况怎么样?',
|
'本月我的审批中,通过、驳回、处理中各有多少?',
|
'近30天各类型审批数量分布是什么?',
|
'帮我审批通过流程编号 XXX,备注“同意”。',
|
'帮我驳回流程编号 XXX,备注“请补充说明”。',
|
'撤销我刚刚对流程编号 XXX 的审批操作。',
|
'帮我修改流程编号 XXX 的备注为“已补充附件”。',
|
'删除我发起的流程编号 XXX。'
|
],
|
purchase: [
|
'本月采购金额排名前十的物料有哪些?',
|
'哪些采购订单还未入库?',
|
'最近7天供应商到货异常有哪些?',
|
'帮我统计待付款采购单',
|
'列出本月采购退货情况'
|
]
|
}
|
const quickPromptLimit = 3
|
const quickPromptStart = ref(0)
|
const quickPrompts = computed(() => {
|
const assistant = currentAssistant.value || {}
|
if (Array.isArray(assistant.quickPrompts) && assistant.quickPrompts.length) {
|
return assistant.quickPrompts
|
}
|
return assistantQuickPromptMap[assistant.key] || assistantQuickPromptMap.general
|
})
|
const displayedQuickPrompts = computed(() => {
|
const prompts = quickPrompts.value || []
|
if (prompts.length <= quickPromptLimit) return prompts
|
|
const result = []
|
for (let i = 0; i < quickPromptLimit; i++) {
|
result.push(prompts[(quickPromptStart.value + i) % prompts.length])
|
}
|
return result
|
})
|
const hasMessages = computed(() => messages.value.length > 0)
|
|
const visible = ref(false)
|
const windowWidth = ref(window.innerWidth)
|
const drawerSize = computed(() => {
|
if (windowWidth.value < 768) return '100%'
|
if (windowWidth.value < 1200) return '50%'
|
return '50%'
|
})
|
const messageListRef = ref(null)
|
const isSending = ref(false)
|
const currentAbortController = ref(null)
|
const inputMessage = ref('')
|
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: '是否质检',
|
isChecked: '是否质检'
|
}
|
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',
|
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)
|
|
const abortCurrentRequest = () => {
|
if (!currentAbortController.value) return
|
|
currentAbortController.value.abort()
|
currentAbortController.value = null
|
isSending.value = false
|
|
const lastMsg = messages.value[messages.value.length - 1]
|
if (lastMsg && !lastMsg.isUser) {
|
lastMsg.isTyping = false
|
}
|
}
|
|
const toggleHistory = () => {
|
showHistory.value = !showHistory.value
|
if (showHistory.value) {
|
loadSessions()
|
}
|
}
|
|
const handleToggleHistory = () => {
|
if (isSending.value) {
|
abortCurrentRequest()
|
}
|
toggleHistory()
|
}
|
|
const loadSessions = async () => {
|
loadingSessions.value = true
|
try {
|
const res = await request.get(`${currentAssistant.value.apiBase}/history/sessions`)
|
if (res.code === 200) {
|
sessions.value = res.data || []
|
}
|
} catch (err) {
|
console.error('Failed to load sessions', err)
|
} finally {
|
loadingSessions.value = false
|
}
|
}
|
|
const selectSession = async (session) => {
|
showHistory.value = false
|
uuid.value = session.memoryId
|
localStorage.setItem(currentAssistant.value.storageKey, uuid.value)
|
|
// 加载会话消息
|
try {
|
const res = await request.get(`${currentAssistant.value.apiBase}/history/messages/${uuid.value}`)
|
if (res.code === 200) {
|
disposeCharts()
|
messages.value = []
|
const historyMsgs = res.data || []
|
|
// 重新构造消息列表并解析
|
historyMsgs.forEach((msg, idx) => {
|
const isUser = msg.role === 'user'
|
const botMsgIndex = messages.value.length
|
|
const messageObj = {
|
isUser,
|
content: msg.content,
|
htmlContent: '',
|
isTyping: false,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null,
|
payloadTreeData: null,
|
payloadHiddenData: null
|
}
|
|
messages.value.push(messageObj)
|
|
if (!isUser) {
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
// 解析历史消息中的 JSON
|
const extracted = extractEmbeddedSuccessJson(msg.content)
|
if (extracted) {
|
applyStructuredMessageData(messageObj, extracted.data, botMsgIndex)
|
}
|
|
updateOutputState(msg.content, botMsgIndex)
|
messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex)
|
} else {
|
messageObj.htmlContent = convertTextToHtml(msg.content)
|
}
|
})
|
scrollToBottom()
|
}
|
} catch (err) {
|
console.error('Failed to load messages', err)
|
}
|
}
|
|
const handleDeleteSession = async (memoryId) => {
|
try {
|
const res = await request.delete(`${currentAssistant.value.apiBase}/history/${memoryId}`)
|
if (res.code === 200) {
|
loadSessions()
|
if (uuid.value === memoryId) {
|
newChat()
|
}
|
}
|
} catch (err) {
|
console.error('Failed to delete session', err)
|
}
|
}
|
|
const columnLabelMap = {
|
approveId: '审批编号',
|
approveType: '审批类型',
|
approveUserName: '审批人',
|
approveUserCurrentName: '当前处理人',
|
approveReason: '审批原因',
|
approveStatus: '审批状态',
|
createTime: '创建时间'
|
}
|
|
onMounted(() => {
|
initUUID()
|
// 初始欢迎
|
if (messages.value.length === 0) {
|
hello()
|
}
|
window.addEventListener('resize', handleWindowResize)
|
})
|
|
onUnmounted(() => {
|
disposeCharts()
|
window.removeEventListener('resize', handleWindowResize)
|
})
|
|
watch(selectedAssistantKey, (nextKey, prevKey) => {
|
if (!prevKey || nextKey === prevKey) return
|
|
abortCurrentRequest()
|
disposeCharts()
|
messages.value = []
|
outputState.value = {}
|
sessions.value = []
|
showHistory.value = false
|
selectedFiles.value = []
|
uploadFileList.value = []
|
inputMessage.value = ''
|
quickPromptStart.value = 0
|
initUUID()
|
hello()
|
})
|
|
const handleWindowResize = () => {
|
windowWidth.value = window.innerWidth
|
}
|
|
const toggleSidebar = () => {
|
visible.value = !visible.value
|
if (visible.value) {
|
scrollToBottom()
|
}
|
}
|
|
const handleClose = () => {
|
visible.value = false
|
}
|
|
const handleManualClose = () => {
|
if (isSending.value) {
|
abortCurrentRequest()
|
}
|
handleClose()
|
}
|
|
const initUUID = () => {
|
let storedUUID = localStorage.getItem(currentAssistant.value.storageKey)
|
if (!storedUUID) {
|
storedUUID = Math.random().toString(36).substring(2, 10) + Date.now().toString(36).substring(4)
|
localStorage.setItem(currentAssistant.value.storageKey, storedUUID)
|
}
|
uuid.value = storedUUID
|
}
|
|
const hello = () => {
|
sendRequest(currentAssistant.value.welcomeMessage || '你好')
|
}
|
|
const newChat = () => {
|
disposeCharts()
|
messages.value = []
|
outputState.value = {}
|
sessions.value = []
|
showHistory.value = false
|
selectedFiles.value = []
|
uploadFileList.value = []
|
quickPromptStart.value = 0
|
localStorage.removeItem(currentAssistant.value.storageKey)
|
initUUID()
|
hello()
|
}
|
|
const handleNewChat = () => {
|
if (isSending.value) {
|
abortCurrentRequest()
|
}
|
newChat()
|
}
|
|
const sendQuickPrompt = (prompt) => {
|
if (!prompt || isSending.value) return
|
inputMessage.value = prompt
|
sendMessage()
|
}
|
|
const refreshQuickPrompts = () => {
|
const prompts = quickPrompts.value || []
|
if (prompts.length <= quickPromptLimit) return
|
quickPromptStart.value = (quickPromptStart.value + quickPromptLimit) % prompts.length
|
}
|
|
const disposeCharts = () => {
|
Object.values(chartInstances.value).forEach(chart => chart.dispose())
|
resizeHandlers.value.forEach(handler => window.removeEventListener('resize', handler))
|
chartInstances.value = {}
|
resizeHandlers.value = []
|
}
|
|
const extractEmbeddedSuccessJson = (text) => {
|
if (!text || typeof text !== 'string') 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
|
|
let depth = 0
|
let inString = false
|
let escaped = false
|
|
for (let j = i; j < text.length; j++) {
|
const char = text[j]
|
|
if (inString) {
|
if (escaped) {
|
escaped = false
|
} else if (char === '\\') {
|
escaped = true
|
} else if (char === '"') {
|
inString = false
|
}
|
continue
|
}
|
|
if (char === '"') {
|
inString = true
|
continue
|
}
|
|
if (char === '{') {
|
depth++
|
} else if (char === '}') {
|
depth--
|
if (depth === 0) {
|
const candidate = text.slice(i, j + 1)
|
try {
|
const parsed = JSON.parse(candidate)
|
if (parsed?.success === true) {
|
return {
|
data: parsed,
|
startIdx: i,
|
endIdx: j + 1
|
}
|
}
|
} catch (err) {
|
continue
|
}
|
}
|
}
|
}
|
}
|
|
return null
|
}
|
|
const applyStructuredMessageData = (messageObj, parsedData, msgIndex, shouldRenderCharts = true) => {
|
if (!messageObj || !parsedData?.success) return
|
|
messageObj.type = parsedData.type || ''
|
|
if (messageObj.type === 'todo_list' && parsedData.data) {
|
messageObj.tableData = parsedData.data
|
}
|
|
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) {
|
renderCharts(msgIndex, messageObj.chartOptions)
|
if (outputState.value[msgIndex]) {
|
outputState.value[msgIndex].hasRenderedChart = true
|
}
|
}
|
}
|
}
|
|
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',
|
'审核人',
|
'审批人',
|
'审批人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 => {
|
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) {
|
messageListRef.value.scrollTop = messageListRef.value.scrollHeight
|
}
|
})
|
}
|
|
const handleFileChange = (file, fileList = []) => {
|
if (!file) return
|
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(`${rawFile.name} 文件大小不能超过 10MB!`)
|
}
|
return isLt10M
|
})
|
|
selectedFiles.value = validFiles
|
uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw))
|
}
|
|
const removeSelectedFile = (index) => {
|
selectedFiles.value.splice(index, 1)
|
uploadFileList.value.splice(index, 1)
|
}
|
|
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 fileNames = uploadFiles.map(file => file.name).join('、')
|
const userMsg = message ? `${message}\n[上传文件分析] ${fileNames}` : `[上传文件分析] ${fileNames}`
|
messages.value.push({
|
isUser: true,
|
content: userMsg,
|
htmlContent: convertTextToHtml(userMsg),
|
isTyping: false
|
})
|
|
const botMsgIndex = messages.value.length
|
messages.value.push({
|
isUser: false,
|
content: '',
|
htmlContent: '',
|
isTyping: true,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null,
|
payloadTreeData: null,
|
payloadHiddenData: null
|
})
|
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
scrollToBottom()
|
|
const formData = new FormData()
|
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())
|
}
|
|
const analyzeUrl = currentAssistant.value.fileAnalyzeUrl || `${currentAssistant.value.apiBase}/analyze-file`
|
request.post(analyzeUrl, formData, {
|
headers: {
|
'Content-Type': 'multipart/form-data'
|
},
|
signal: currentAbortController.value.signal,
|
onDownloadProgress: (e) => {
|
const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
|
if (!fullText) return
|
|
const currentMsg = messages.value[botMsgIndex]
|
if (!currentMsg) return
|
|
currentMsg.content = fullText
|
|
// 解析 JSON 数据(针对嵌入式 JSON)
|
const extracted = extractEmbeddedSuccessJson(fullText)
|
if (extracted) {
|
applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart)
|
}
|
|
updateOutputState(fullText, botMsgIndex)
|
currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
|
scrollToBottom()
|
}
|
}).then(() => {
|
const currentMsg = messages.value[botMsgIndex]
|
currentMsg.isTyping = false
|
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)
|
outputState.value[botMsgIndex].hasRenderedChart = true
|
}
|
}).catch(err => {
|
if (err.name === 'CanceledError' || err.name === 'AbortError') {
|
console.log('Analysis aborted by user')
|
isSending.value = false
|
currentAbortController.value = null
|
return
|
}
|
console.error('File analysis error:', err)
|
const errorMsg = '抱歉,文件分析过程中遇到了一点问题,请稍后再试。'
|
if (messages.value[botMsgIndex]) {
|
messages.value[botMsgIndex].content = errorMsg
|
messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
|
messages.value[botMsgIndex].isTyping = false
|
}
|
isSending.value = false
|
currentAbortController.value = null
|
})
|
}
|
|
const sendMessage = () => {
|
const msg = inputMessage.value?.trim() || ''
|
if ((msg || selectedFiles.value.length) && !isSending.value) {
|
if (selectedFiles.value.length) {
|
analyzeFiles([...selectedFiles.value], msg)
|
selectedFiles.value = []
|
uploadFileList.value = []
|
} else {
|
sendRequest(msg)
|
}
|
inputMessage.value = ''
|
}
|
}
|
|
const stopGeneration = () => {
|
abortCurrentRequest()
|
}
|
|
const sendRequest = (message) => {
|
isSending.value = true
|
currentAbortController.value = new AbortController()
|
|
// 用户消息
|
messages.value.push({
|
isUser: true,
|
content: message,
|
htmlContent: convertTextToHtml(message),
|
isTyping: false
|
})
|
|
// 机器人占位
|
const botMsgIndex = messages.value.length
|
const botMsg = {
|
isUser: false,
|
content: '',
|
htmlContent: '',
|
isTyping: true,
|
chartOptions: null,
|
chartRenderReady: false,
|
type: '',
|
tableData: null,
|
payloadTreeData: null,
|
payloadHiddenData: null
|
}
|
messages.value.push(botMsg)
|
|
outputState.value[botMsgIndex] = {
|
isPaused: false,
|
jsonBlockStartPos: -1,
|
jsBlockStartPos: -1,
|
blockEndPos: -1,
|
hasRenderedChart: false
|
}
|
|
scrollToBottom()
|
|
request.post(`${currentAssistant.value.apiBase}/chat`,
|
{ memoryId: uuid.value, message },
|
{
|
signal: currentAbortController.value.signal,
|
onDownloadProgress: (e) => {
|
// 兼容不同版本的 axios 获取响应文本的方式
|
const fullText = e.target ? e.target.responseText : (e.event ? e.event.target.responseText : '')
|
if (!fullText) return
|
|
const currentMsg = messages.value[botMsgIndex]
|
if (!currentMsg) return
|
|
currentMsg.content = fullText
|
|
// 尝试提取并解析 JSON
|
const extracted = extractEmbeddedSuccessJson(fullText)
|
if (extracted) {
|
applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
|
} else {
|
const extractJson = (text) => {
|
const startIdx = text.indexOf('{"success": true')
|
if (startIdx === -1) return null
|
|
// 从后往前找最后一个 '}'
|
const lastBraceIdx = text.lastIndexOf('}')
|
if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
|
|
const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
|
try {
|
return JSON.parse(potentialJson)
|
} catch (err) {
|
return null
|
}
|
}
|
|
const parsedData = extractJson(fullText)
|
if (parsedData) {
|
applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true)
|
}
|
|
}
|
|
updateOutputState(fullText, botMsgIndex)
|
currentMsg.htmlContent = convertStreamOutput(fullText, botMsgIndex)
|
scrollToBottom()
|
}
|
}
|
).then(() => {
|
const currentMsg = messages.value[botMsgIndex]
|
currentMsg.isTyping = false
|
isSending.value = false
|
currentAbortController.value = null
|
|
// 最终解析
|
const extracted = extractEmbeddedSuccessJson(currentMsg.content)
|
if (extracted) {
|
applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex)
|
} else {
|
const extractJson = (text) => {
|
const startIdx = text.indexOf('{"success": true')
|
if (startIdx === -1) return null
|
const lastBraceIdx = text.lastIndexOf('}')
|
if (lastBraceIdx === -1 || lastBraceIdx < startIdx) return null
|
const potentialJson = text.substring(startIdx, lastBraceIdx + 1)
|
try {
|
return JSON.parse(potentialJson)
|
} catch (err) {
|
return null
|
}
|
}
|
|
const finalParsed = extractJson(currentMsg.content)
|
if (finalParsed) {
|
applyStructuredMessageData(currentMsg, finalParsed, botMsgIndex)
|
}
|
}
|
}).catch(err => {
|
if (err.name === 'CanceledError' || err.name === 'AbortError') {
|
console.log('Request aborted by user')
|
return
|
}
|
console.error('AI Chat Error:', err)
|
const errorMsg = '抱歉,我现在遇到了一点问题,请稍后再试。'
|
if (messages.value[botMsgIndex]) {
|
messages.value[botMsgIndex].content = errorMsg
|
messages.value[botMsgIndex].htmlContent = convertTextToHtml(errorMsg)
|
messages.value[botMsgIndex].isTyping = false
|
}
|
isSending.value = false
|
currentAbortController.value = null
|
})
|
}
|
|
const updateOutputState = (text, msgIndex) => {
|
const state = outputState.value[msgIndex]
|
if (state.jsonBlockStartPos === -1) {
|
const pos = text.indexOf('```json')
|
if (pos !== -1) { state.jsonBlockStartPos = pos; state.isPaused = true }
|
}
|
if (state.jsBlockStartPos === -1) {
|
const pos = text.indexOf('```javascript') !== -1 ? text.indexOf('```javascript') : text.indexOf('```js')
|
if (pos !== -1) { state.jsBlockStartPos = pos; state.isPaused = true }
|
}
|
if ((state.jsonBlockStartPos !== -1 || state.jsBlockStartPos !== -1) && state.blockEndPos === -1) {
|
const startCheck = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos + 7 : state.jsBlockStartPos + (text.includes('javascript') ? 13 : 5)
|
const endPos = text.indexOf('```', startCheck)
|
if (endPos !== -1) { state.blockEndPos = endPos + 3; state.isPaused = false }
|
}
|
}
|
|
const convertTextToHtml = (text) => {
|
if (!text) return ''
|
return text
|
.replace(/&/g, '&')
|
.replace(/</g, '<')
|
.replace(/>/g, '>')
|
.replace(/\n/g, '<br>')
|
}
|
|
const convertStreamOutput = (output, msgIndex) => {
|
if (!output) return ''
|
const state = outputState.value[msgIndex]
|
let display = output
|
|
// 尝试提取 JSON 部分
|
const extracted = extractEmbeddedSuccessJson(output)
|
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) {
|
const startPos = state.jsonBlockStartPos !== -1 ? state.jsonBlockStartPos : state.jsBlockStartPos
|
const textBeforeBlock = display.substring(0, startPos).trim()
|
display = textBeforeBlock || '正在分析数据并生成图表...'
|
return convertTextToHtml(display)
|
}
|
|
if (extracted) {
|
const parsed = extracted.data
|
display = (output.substring(0, extracted.startIdx) + output.substring(extracted.endIdx)).trim()
|
|
if (/^[\s}\],,。.::;;]+$/.test(display)) {
|
display = ''
|
}
|
|
if (parsed.description) {
|
display = parsed.action === 'confirm_required'
|
? getPurchaseConfirmDescription(parsed)
|
: parsed.description
|
}
|
|
if (!display) {
|
if (parsed.type === 'todo_list') {
|
display = '已为您整理好相关数据。'
|
} else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
|
display = '已为您生成分析图表。'
|
} else {
|
display = '正在为您展示分析结果...'
|
}
|
}
|
} else if (startIdx !== -1) {
|
const lastBraceIdx = output.lastIndexOf('}')
|
if (lastBraceIdx !== -1 && lastBraceIdx > startIdx) {
|
const potentialJson = output.substring(startIdx, lastBraceIdx + 1)
|
try {
|
const parsed = JSON.parse(potentialJson)
|
// 成功解析,移除 JSON 部分显示文字
|
display = (output.substring(0, startIdx) + output.substring(lastBraceIdx + 1)).trim()
|
|
if (/^[\s}\],,。.::;;]+$/.test(display)) {
|
display = ''
|
}
|
|
if (parsed.description) {
|
display = parsed.action === 'confirm_required'
|
? getPurchaseConfirmDescription(parsed)
|
: parsed.description
|
}
|
|
if (!display) {
|
if (parsed.type === 'todo_list') {
|
display = '已为您整理好相关数据:'
|
} else if (parsed.charts && Object.keys(parsed.charts).length > 0) {
|
display = '已为您生成分析图表:'
|
} else {
|
display = '正在为您展示分析结果...'
|
}
|
}
|
} catch (e) {
|
// 解析失败,说明 JSON 还在传输中或格式不正确
|
display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
|
}
|
} else {
|
// 找到了开始但还没找到结束
|
display = output.substring(0, startIdx).trim() || '正在分析数据并生成图表...'
|
}
|
}
|
|
let html = convertTextToHtml(display)
|
|
// 还原代码块
|
html = html.replace(/```(javascript|js)([\s\S]*?)```/g, '<pre class="code-block js-code">$2</pre>')
|
html = html.replace(/```([\s\S]*?)```/g, '<pre class="code-block">$1</pre>')
|
|
return html || '...'
|
}
|
|
const renderCharts = (msgIndex, chartOptions) => {
|
nextTick(() => {
|
Object.keys(chartOptions).forEach(key => {
|
const id = `ai-chart-${msgIndex}-${key}`
|
const tryInit = (count = 0) => {
|
const dom = document.getElementById(id)
|
if (dom) {
|
if (chartInstances.value[id]) {
|
// 如果已经初始化过,直接更新数据
|
const chart = chartInstances.value[id]
|
const option = normalizeAiChartOption(chartOptions[key])
|
if (option) chart.setOption(option)
|
return
|
}
|
|
const chart = echarts.init(dom)
|
chartInstances.value[id] = chart
|
const option = normalizeAiChartOption(chartOptions[key])
|
if (option) {
|
chart.setOption(option)
|
} else {
|
console.warn('Invalid chart option for:', id, chartOptions[key])
|
}
|
|
const handler = () => chart.resize()
|
resizeHandlers.value.push(handler)
|
window.addEventListener('resize', handler)
|
} else if (count < 15) { // 稍微增加重试次数
|
setTimeout(() => tryInit(count + 1), 200)
|
}
|
}
|
tryInit()
|
})
|
})
|
}
|
|
// 格式化 AI 返回的图表配置,将其转换为标准的 ECharts 配置
|
const formatChartOption = (rawOption) => {
|
if (!rawOption) return null
|
|
// 如果已经是标准 ECharts 配置(包含 series),则直接返回
|
const hasSeries = rawOption.series && Array.isArray(rawOption.series)
|
|
// 尝试转换简易格式
|
try {
|
const isPie = rawOption.type === 'pie' || (rawOption.title && rawOption.title.includes('占比'))
|
|
const option = {
|
title: {
|
text: rawOption.title || '',
|
left: 'center',
|
textStyle: { fontSize: 14 }
|
},
|
tooltip: {
|
trigger: isPie ? 'item' : 'axis'
|
},
|
legend: {
|
bottom: '0'
|
},
|
grid: {
|
left: '3%',
|
right: '4%',
|
bottom: '15%',
|
containLabel: true
|
},
|
xAxis: isPie ? undefined : {
|
type: 'category',
|
data: rawOption.xAxisData || (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : []),
|
name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : ''
|
},
|
yAxis: isPie ? undefined : {
|
type: 'value',
|
name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
|
},
|
series: rawOption.series || [{
|
name: rawOption.title || '数值',
|
type: rawOption.type || 'line',
|
data: rawOption.seriesData || (Array.isArray(rawOption.data) ? rawOption.data : []),
|
smooth: true,
|
radius: isPie ? '50%' : undefined
|
}]
|
}
|
|
// 针对饼图的特殊处理
|
if (isPie && !option.series[0].data && Array.isArray(rawOption.data)) {
|
option.series[0].data = rawOption.data
|
}
|
|
return option
|
} catch (err) {
|
console.error('Chart option conversion failed:', err)
|
return null
|
}
|
}
|
|
const normalizeAiChartOption = (rawOption) => {
|
if (!rawOption) return null
|
|
try {
|
const hasSeries = Array.isArray(rawOption.series) && rawOption.series.length > 0
|
const firstSeriesType = hasSeries ? rawOption.series[0]?.type : rawOption.type
|
const titleConfig = rawOption.title && typeof rawOption.title === 'object'
|
? rawOption.title
|
: null
|
const tooltipConfig = rawOption.tooltip && typeof rawOption.tooltip === 'object'
|
? rawOption.tooltip
|
: null
|
const legendConfig = rawOption.legend && typeof rawOption.legend === 'object'
|
? rawOption.legend
|
: null
|
const rawXAxisConfig = rawOption.xAxis && typeof rawOption.xAxis === 'object' && !Array.isArray(rawOption.xAxis)
|
? rawOption.xAxis
|
: null
|
const rawYAxisConfig = rawOption.yAxis && typeof rawOption.yAxis === 'object' && !Array.isArray(rawOption.yAxis)
|
? rawOption.yAxis
|
: null
|
const titleText = typeof rawOption.title === 'string' ? rawOption.title : rawOption.title?.text || ''
|
const isPie = firstSeriesType === 'pie' || titleText.includes('占比')
|
const baseXAxisData = Array.isArray(rawOption.xAxisData)
|
? rawOption.xAxisData
|
: (Array.isArray(rawOption.xAxis) ? rawOption.xAxis : (Array.isArray(rawXAxisConfig?.data) ? rawXAxisConfig.data : []))
|
const fallbackSeries = [{
|
name: titleText || '数据',
|
type: rawOption.type || 'line',
|
data: Array.isArray(rawOption.seriesData) ? rawOption.seriesData : (Array.isArray(rawOption.data) ? rawOption.data : [])
|
}]
|
const normalizedSeries = (hasSeries ? rawOption.series : fallbackSeries).map((seriesItem, index) => {
|
const seriesType = seriesItem?.type || rawOption.type || 'line'
|
const nextSeries = {
|
...seriesItem,
|
name: seriesItem?.name || titleText || `系列${index + 1}`,
|
type: seriesType
|
}
|
|
if (isPie) {
|
nextSeries.radius = nextSeries.radius || '55%'
|
nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : (Array.isArray(rawOption.data) ? rawOption.data : [])
|
} else {
|
nextSeries.smooth = typeof nextSeries.smooth === 'boolean' ? nextSeries.smooth : seriesType === 'line'
|
nextSeries.data = Array.isArray(nextSeries.data) ? nextSeries.data : []
|
}
|
|
return nextSeries
|
})
|
const categorySource = !isPie
|
? normalizedSeries.find(seriesItem => Array.isArray(seriesItem.data) && seriesItem.data.every(item => item && typeof item === 'object' && 'name' in item && 'value' in item))
|
: null
|
const xAxisData = categorySource
|
? categorySource.data.map(item => item.name)
|
: baseXAxisData
|
const finalSeries = !isPie && categorySource
|
? normalizedSeries.map(seriesItem => ({
|
...seriesItem,
|
data: Array.isArray(seriesItem.data)
|
? seriesItem.data.map(item => (item && typeof item === 'object' && 'value' in item ? item.value : item))
|
: []
|
}))
|
: normalizedSeries
|
|
return {
|
title: titleConfig || {
|
text: titleText,
|
left: 'center',
|
textStyle: { fontSize: 14 }
|
},
|
tooltip: tooltipConfig || {
|
trigger: isPie ? 'item' : 'axis'
|
},
|
legend: legendConfig || {
|
bottom: '0'
|
},
|
grid: isPie ? undefined : {
|
left: '3%',
|
right: '4%',
|
bottom: '15%',
|
containLabel: true
|
},
|
xAxis: isPie ? undefined : {
|
...(rawXAxisConfig || {}),
|
type: 'category',
|
data: xAxisData,
|
name: typeof rawOption.xAxis === 'string' ? rawOption.xAxis : (rawXAxisConfig?.name || '')
|
},
|
yAxis: isPie ? undefined : (rawYAxisConfig || {
|
type: 'value',
|
name: typeof rawOption.yAxis === 'string' ? rawOption.yAxis : ''
|
}),
|
series: finalSeries
|
}
|
} catch (err) {
|
console.error('AI chart normalization failed:', err, rawOption)
|
return formatChartOption(rawOption)
|
}
|
}
|
|
watch(messages, () => scrollToBottom(), { deep: true })
|
</script>
|
|
<style lang="scss">
|
.ai-chat-overlay {
|
pointer-events: none !important;
|
background: transparent !important;
|
}
|
|
.ai-chat-overlay .el-drawer {
|
pointer-events: auto;
|
}
|
</style>
|
|
<style scoped lang="scss">
|
$primary-blue: #0055d4;
|
$secondary-blue: #2e8ce0;
|
$light-blue: #7ab8ff;
|
$pale-blue: #c5dcff;
|
$ice-white: #e8f2ff;
|
$deep-blue: #003b8e;
|
$deepest-blue: #002b66;
|
$gradient-blue: linear-gradient(145deg, #004fc7 0%, #0066e0 40%, #2580e8 70%, #5a9fe0 100%);
|
$gradient-dark: linear-gradient(145deg, #003b8e 0%, #0055d4 50%, #0077e8 100%);
|
$gradient-ice: linear-gradient(180deg, #e0ecff 0%, #d4e5ff 50%, #e8f0ff 100%);
|
$shadow-blue: 0 8px 40px rgba(0, 85, 212, 0.35);
|
$shadow-deep: 0 12px 48px rgba(0, 40, 120, 0.4);
|
$shadow-card: 0 6px 24px rgba(0, 51, 136, 0.12);
|
|
.ai-chat-sidebar-wrapper {
|
position: static;
|
z-index: 2000;
|
pointer-events: auto;
|
|
:deep(.el-drawer) {
|
pointer-events: auto;
|
}
|
}
|
|
.ai-chat-trigger {
|
pointer-events: auto;
|
position: fixed;
|
right: 24px;
|
bottom: 100px;
|
width: 56px;
|
height: 56px;
|
background: $gradient-dark;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
cursor: pointer;
|
box-shadow: $shadow-deep, 0 0 0 2px rgba(0, 85, 212, 0.3) inset, 0 0 30px rgba(0, 119, 232, 0.2);
|
transition: all 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
z-index: 2001;
|
animation: triggerPulse 3s ease-in-out infinite;
|
|
&::before {
|
content: '';
|
position: absolute;
|
inset: -6px;
|
background: linear-gradient(135deg, rgba(0, 85, 212, 0.4), rgba(0, 136, 232, 0.3), rgba(90, 159, 224, 0.2));
|
border-radius: 50%;
|
z-index: -1;
|
filter: blur(16px);
|
animation: glowPulse 2s ease-in-out infinite alternate;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
inset: 0;
|
border-radius: 50%;
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.3) 0%, transparent 50%);
|
pointer-events: none;
|
}
|
|
&:hover {
|
transform: scale(1.12) translateY(-4px);
|
box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.4) inset, 0 0 50px rgba(0, 136, 232, 0.3);
|
|
&::before {
|
animation: glowPulse 1s ease-in-out infinite alternate;
|
}
|
|
.trigger-icon {
|
transform: rotate(-8deg) scale(1.05);
|
filter: drop-shadow(0 0 8px rgba(255, 255, 255, 0.5));
|
}
|
}
|
|
.trigger-icon {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
color: #fff;
|
filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2));
|
}
|
}
|
|
@keyframes triggerPulse {
|
0%, 100% {
|
box-shadow: $shadow-blue, 0 0 0 2px rgba(0, 85, 212, 0.25) inset, 0 0 20px rgba(0, 119, 232, 0.15);
|
}
|
50% {
|
box-shadow: $shadow-deep, 0 0 0 3px rgba(0, 136, 232, 0.35) inset, 0 0 40px rgba(0, 136, 232, 0.25);
|
}
|
}
|
|
@keyframes glowPulse {
|
0% {
|
opacity: 0.6;
|
transform: scale(1);
|
}
|
100% {
|
opacity: 1;
|
transform: scale(1.1);
|
}
|
}
|
|
.ai-chat-drawer {
|
:deep(.el-drawer__body) {
|
padding: 0;
|
overflow: hidden;
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
}
|
:deep(.el-drawer__header) {
|
margin-bottom: 0;
|
padding: 0;
|
background: $gradient-dark;
|
color: #fff;
|
}
|
}
|
|
.drawer-header {
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
width: 100%;
|
padding: 18px 20px;
|
background: $gradient-dark;
|
position: relative;
|
overflow: hidden;
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: -60%;
|
right: -25%;
|
width: 250px;
|
height: 250px;
|
background: radial-gradient(circle, rgba(0, 136, 232, 0.4) 0%, transparent 70%);
|
pointer-events: none;
|
animation: headerGlow 4s ease-in-out infinite alternate;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
bottom: -40%;
|
left: -15%;
|
width: 200px;
|
height: 200px;
|
background: radial-gradient(circle, rgba(0, 85, 212, 0.3) 0%, transparent 70%);
|
pointer-events: none;
|
animation: headerGlow 5s ease-in-out infinite alternate-reverse;
|
}
|
|
.header-left {
|
display: flex;
|
align-items: center;
|
gap: 12px;
|
position: relative;
|
z-index: 1;
|
|
.header-icon {
|
color: rgba(255, 255, 255, 0.95);
|
filter: drop-shadow(0 2px 6px rgba(0, 0, 0, 0.2));
|
animation: iconFloat 3s ease-in-out infinite;
|
}
|
|
.title {
|
font-size: 17px;
|
font-weight: 600;
|
color: #fff;
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.15);
|
letter-spacing: 0.5px;
|
}
|
}
|
|
.header-actions {
|
display: flex;
|
align-items: center;
|
gap: 10px;
|
position: relative;
|
z-index: 1;
|
|
.action-divider {
|
width: 1px;
|
height: 16px;
|
background: rgba(255, 255, 255, 0.2);
|
margin: 0 2px;
|
}
|
|
:deep(.el-button) {
|
color: rgba(255, 255, 255, 0.85);
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
background: rgba(255, 255, 255, 0.12);
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
border-radius: 8px;
|
padding: 8px;
|
height: 32px;
|
width: 32px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
|
&:hover {
|
color: #fff;
|
background: rgba(255, 255, 255, 0.25);
|
border-color: rgba(255, 255, 255, 0.3);
|
transform: translateY(-1px);
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
}
|
|
&:active {
|
transform: translateY(0);
|
}
|
|
&.close-btn {
|
background: rgba(255, 255, 255, 0.1);
|
&:hover {
|
background: rgba(245, 108, 108, 0.8);
|
border-color: rgba(245, 108, 108, 0.5);
|
}
|
}
|
}
|
|
:deep(.header-action-btn) {
|
position: relative;
|
overflow: hidden;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.18), rgba(255, 255, 255, 0.08));
|
border: 1px solid rgba(255, 255, 255, 0.16);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.12);
|
|
&::before {
|
content: '';
|
position: absolute;
|
inset: 0;
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 55%);
|
pointer-events: none;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
top: -120%;
|
left: -40%;
|
width: 60%;
|
height: 260%;
|
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.28), transparent);
|
transform: rotate(24deg);
|
opacity: 0;
|
transition: all 0.35s ease;
|
}
|
|
&:hover::after {
|
left: 100%;
|
opacity: 1;
|
}
|
}
|
}
|
|
.assistant-switcher {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex: 1;
|
padding: 0 12px;
|
position: relative;
|
z-index: 1;
|
|
:deep(.el-radio-group) {
|
display: flex;
|
gap: 6px;
|
flex-wrap: wrap;
|
justify-content: center;
|
padding: 4px;
|
border-radius: 999px;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.14), rgba(255, 255, 255, 0.08));
|
border: 1px solid rgba(255, 255, 255, 0.12);
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.14), 0 10px 18px rgba(0, 0, 0, 0.1);
|
}
|
|
:deep(.el-radio-button__inner) {
|
border-radius: 999px;
|
border: 1px solid rgba(255, 255, 255, 0.18);
|
background: rgba(255, 255, 255, 0.12);
|
color: rgba(255, 255, 255, 0.86);
|
box-shadow: none;
|
padding: 7px 14px;
|
font-weight: 500;
|
}
|
|
:deep(.el-radio-button:first-child .el-radio-button__inner),
|
:deep(.el-radio-button:last-child .el-radio-button__inner) {
|
border-radius: 999px;
|
}
|
|
:deep(.el-radio-button__original-radio:checked + .el-radio-button__inner) {
|
background: #fff;
|
color: $primary-blue;
|
border-color: #fff;
|
box-shadow: 0 6px 14px rgba(0, 40, 120, 0.16);
|
}
|
}
|
}
|
|
@keyframes headerGlow {
|
0% {
|
opacity: 0.6;
|
transform: scale(1);
|
}
|
100% {
|
opacity: 1;
|
transform: scale(1.15);
|
}
|
}
|
|
@keyframes iconFloat {
|
0%, 100% {
|
transform: translateY(0) rotate(0);
|
}
|
50% {
|
transform: translateY(-2px) rotate(3deg);
|
}
|
}
|
|
.chat-container {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
width: 100%;
|
background: $ice-white;
|
position: relative;
|
overflow: hidden;
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
height: 240px;
|
background: linear-gradient(180deg, rgba(0, 85, 212, 0.06) 0%, transparent 100%);
|
pointer-events: none;
|
}
|
}
|
|
.history-panel {
|
position: absolute;
|
inset: 0;
|
background: linear-gradient(180deg, #fff 0%, $ice-white 100%);
|
z-index: 10;
|
display: flex;
|
flex-direction: column;
|
box-shadow: -8px 0 32px rgba(0, 85, 212, 0.15);
|
|
.history-header {
|
padding: 18px 20px;
|
border-bottom: 1px solid rgba(0, 85, 212, 0.12);
|
display: flex;
|
justify-content: space-between;
|
align-items: center;
|
font-weight: 600;
|
font-size: 14px;
|
color: $deep-blue;
|
background: linear-gradient(135deg, rgba(0, 85, 212, 0.08) 0%, rgba(0, 136, 232, 0.05) 100%);
|
position: relative;
|
|
&::after {
|
content: '';
|
position: absolute;
|
bottom: 0;
|
left: 0;
|
right: 0;
|
height: 1px;
|
background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.2), transparent);
|
}
|
}
|
|
.session-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 12px 16px;
|
|
&::-webkit-scrollbar {
|
width: 8px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: linear-gradient(180deg, $secondary-blue, $primary-blue);
|
border-radius: 4px;
|
box-shadow: 0 0 6px rgba(0, 85, 212, 0.25);
|
}
|
|
.session-item {
|
display: flex;
|
align-items: center;
|
padding: 14px 16px;
|
margin-bottom: 6px;
|
border-radius: 12px;
|
cursor: pointer;
|
transition: all 0.3s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
gap: 12px;
|
position: relative;
|
border: 1px solid transparent;
|
background: #fff;
|
animation: sessionSlideIn 0.35s ease;
|
|
@keyframes sessionSlideIn {
|
from {
|
opacity: 0;
|
transform: translateX(-15px);
|
}
|
to {
|
opacity: 1;
|
transform: translateX(0);
|
}
|
}
|
|
&:hover {
|
background: linear-gradient(135deg, rgba(0, 85, 212, 0.06) 0%, rgba(0, 136, 232, 0.08) 100%);
|
border-color: rgba(0, 85, 212, 0.12);
|
box-shadow: 0 4px 16px rgba(0, 85, 212, 0.1);
|
transform: translateX(4px);
|
|
.delete-btn {
|
opacity: 1;
|
transform: scale(1);
|
}
|
}
|
|
&.active {
|
background: linear-gradient(135deg, rgba(0, 85, 212, 0.12) 0%, rgba(0, 136, 232, 0.15) 100%);
|
border-color: rgba(0, 85, 212, 0.25);
|
color: $primary-blue;
|
box-shadow: 0 4px 16px rgba(0, 85, 212, 0.15);
|
|
.el-icon {
|
color: $primary-blue;
|
}
|
}
|
|
.el-icon {
|
font-size: 18px;
|
flex-shrink: 0;
|
color: $secondary-blue;
|
transition: color 0.2s;
|
}
|
|
.session-name {
|
flex: 1;
|
font-size: 13px;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
color: #1a1a2e;
|
font-weight: 500;
|
}
|
|
.delete-btn {
|
opacity: 0;
|
transform: scale(0.8);
|
transition: all 0.25s ease;
|
padding: 6px;
|
border-radius: 6px;
|
color: #c0c4cc;
|
|
&:hover {
|
color: #fff;
|
background: rgba(245, 108, 108, 0.85);
|
transform: scale(1.1) rotate(8deg);
|
}
|
}
|
}
|
}
|
}
|
|
.chat-main {
|
display: flex;
|
flex-direction: column;
|
height: 100%;
|
flex: 1;
|
overflow: hidden;
|
}
|
|
.message-list {
|
flex: 1;
|
overflow-y: auto;
|
padding: 24px 20px;
|
display: flex;
|
flex-direction: column;
|
gap: 20px;
|
background: linear-gradient(180deg, transparent 0%, rgba(0, 85, 212, 0.02) 100%);
|
|
&::-webkit-scrollbar {
|
width: 8px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: linear-gradient(180deg, $secondary-blue, $primary-blue);
|
border-radius: 4px;
|
box-shadow: 0 0 8px rgba(0, 85, 212, 0.3);
|
}
|
}
|
|
.message-item {
|
display: flex;
|
gap: 14px;
|
width: 100%;
|
animation: messageSlideIn 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
@keyframes messageSlideIn {
|
from {
|
opacity: 0;
|
transform: translateY(20px) scale(0.95);
|
}
|
to {
|
opacity: 1;
|
transform: translateY(0) scale(1);
|
}
|
}
|
|
.avatar {
|
width: 42px;
|
height: 42px;
|
border-radius: 14px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-shrink: 0;
|
font-size: 24px;
|
position: relative;
|
overflow: hidden;
|
|
&::before {
|
content: '';
|
position: absolute;
|
inset: 0;
|
background: inherit;
|
filter: blur(10px);
|
opacity: 0.5;
|
z-index: -1;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
top: -50%;
|
left: -50%;
|
width: 200%;
|
height: 200%;
|
background: linear-gradient(45deg, transparent 40%, rgba(255, 255, 255, 0.2) 50%, transparent 60%);
|
animation: shimmer 3s infinite;
|
}
|
}
|
|
.message-content {
|
flex: 1;
|
overflow-x: hidden;
|
display: flex;
|
flex-direction: column;
|
max-width: calc(100% - 56px);
|
|
.text-box {
|
padding: 14px 20px;
|
border-radius: 18px;
|
font-size: 14px;
|
line-height: 1.7;
|
word-break: break-word;
|
max-width: 100%;
|
width: fit-content;
|
overflow-x: auto;
|
transition: all 0.3s ease;
|
position: relative;
|
|
&::-webkit-scrollbar {
|
height: 4px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: rgba(0, 85, 212, 0.25);
|
border-radius: 2px;
|
}
|
}
|
}
|
|
&.bot-message {
|
.message-content {
|
align-items: flex-start;
|
}
|
.avatar {
|
background: $gradient-dark;
|
color: #fff;
|
box-shadow: 0 6px 20px rgba(0, 85, 212, 0.35);
|
}
|
.text-box {
|
background: #fff;
|
color: #1a1a2e;
|
box-shadow: $shadow-card;
|
border: 1px solid rgba(0, 85, 212, 0.08);
|
border-top-left-radius: 6px;
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
height: 1px;
|
background: linear-gradient(90deg, rgba(0, 85, 212, 0.15), transparent);
|
}
|
}
|
}
|
|
&.user-message {
|
flex-direction: row-reverse;
|
.message-content {
|
align-items: flex-end;
|
}
|
.avatar {
|
background: linear-gradient(145deg, #5a9fe0, #3d8bd4);
|
color: #fff;
|
box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
|
}
|
.text-box {
|
background: $gradient-dark;
|
color: #fff;
|
border-top-right-radius: 6px;
|
box-shadow: 0 6px 24px rgba(0, 85, 212, 0.3);
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: 0;
|
left: 0;
|
right: 0;
|
height: 1px;
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3));
|
}
|
}
|
}
|
}
|
|
@keyframes shimmer {
|
0% {
|
transform: translateX(-100%) rotate(45deg);
|
}
|
100% {
|
transform: translateX(100%) rotate(45deg);
|
}
|
}
|
|
.charts-wrapper {
|
margin-top: 12px;
|
display: flex;
|
flex-direction: column;
|
gap: 12px;
|
overflow-x: auto;
|
width: 100%;
|
padding-bottom: 8px;
|
|
&::-webkit-scrollbar {
|
height: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: linear-gradient(90deg, $light-blue, $secondary-blue);
|
border-radius: 3px;
|
}
|
}
|
|
.chart-item {
|
width: 100%;
|
min-width: 300px;
|
height: 300px;
|
border-radius: 12px;
|
padding: 12px;
|
margin-bottom: 12px;
|
}
|
|
.table-wrapper {
|
margin-top: 12px;
|
background: #fff;
|
border-radius: 12px;
|
overflow: hidden;
|
overflow-x: auto;
|
width: 100%;
|
box-shadow: $shadow-card;
|
border: 1px solid rgba(0, 122, 255, 0.06);
|
|
&::-webkit-scrollbar {
|
height: 6px;
|
}
|
&::-webkit-scrollbar-thumb {
|
background: linear-gradient(90deg, $light-blue, $secondary-blue);
|
border-radius: 3px;
|
}
|
|
.el-table {
|
min-width: 300px;
|
--el-table-border-color: rgba(0, 122, 255, 0.08);
|
--el-table-header-bg-color: $ice-white;
|
}
|
}
|
|
.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%);
|
border-top: 1px solid rgba(0, 85, 212, 0.1);
|
position: relative;
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: 0;
|
left: 20px;
|
right: 20px;
|
height: 1px;
|
background: linear-gradient(90deg, transparent, rgba(0, 85, 212, 0.15), transparent);
|
}
|
|
.input-actions {
|
display: flex;
|
gap: 14px;
|
margin-bottom: 12px;
|
align-items: center;
|
|
.file-upload-trigger {
|
display: inline-flex;
|
align-items: center;
|
}
|
|
:deep(.utility-action-btn) {
|
position: relative;
|
height: 34px;
|
padding: 0 14px;
|
border-radius: 999px;
|
border: 1px solid rgba(92, 119, 255, 0.18);
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98), rgba(236, 243, 255, 0.98));
|
color: $primary-blue;
|
font-weight: 600;
|
box-shadow: 0 10px 20px rgba(0, 85, 212, 0.08);
|
transition: all 0.25s ease;
|
|
.el-icon {
|
margin-right: 5px;
|
}
|
|
&:hover:not(.is-disabled) {
|
color: #fff;
|
border-color: transparent;
|
background: linear-gradient(135deg, #1f6dff 0%, #6b38ef 100%);
|
box-shadow: 0 14px 24px rgba(64, 90, 255, 0.2);
|
transform: translateY(-1px);
|
}
|
}
|
|
:deep(.stop-action-btn) {
|
border-color: rgba(255, 99, 123, 0.18);
|
color: #d33e5e;
|
|
&:hover:not(.is-disabled) {
|
background: linear-gradient(135deg, #f5536e 0%, #a33cff 100%);
|
}
|
}
|
}
|
|
.input-box {
|
padding: 16px;
|
position: relative;
|
background: #fff;
|
border: 2px solid rgba(0, 85, 212, 0.12);
|
border-radius: 16px;
|
margin: 0 4px;
|
transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
|
&:focus-within {
|
border-color: $primary-blue;
|
box-shadow: 0 0 0 4px rgba(0, 85, 212, 0.12), $shadow-deep;
|
transform: translateY(-2px);
|
background: #fff;
|
}
|
|
.selected-file-list {
|
display: flex;
|
flex-wrap: wrap;
|
gap: 8px;
|
margin-bottom: 12px;
|
}
|
|
.selected-file-tag {
|
display: flex;
|
align-items: center;
|
background: linear-gradient(135deg, rgba(0, 85, 212, 0.1) 0%, rgba(0, 136, 232, 0.15) 100%);
|
border: 1px solid rgba(0, 85, 212, 0.2);
|
border-radius: 10px;
|
padding: 8px 12px;
|
gap: 10px;
|
width: fit-content;
|
max-width: 100%;
|
animation: tagSlideIn 0.3s ease;
|
|
@keyframes tagSlideIn {
|
from {
|
opacity: 0;
|
transform: translateX(-10px);
|
}
|
to {
|
opacity: 1;
|
transform: translateX(0);
|
}
|
}
|
|
.el-icon {
|
color: $primary-blue;
|
font-size: 18px;
|
}
|
|
.file-name {
|
font-size: 13px;
|
color: $deep-blue;
|
white-space: nowrap;
|
overflow: hidden;
|
text-overflow: ellipsis;
|
font-weight: 600;
|
}
|
|
.remove-file {
|
cursor: pointer;
|
color: $secondary-blue;
|
transition: all 0.2s;
|
padding: 4px;
|
border-radius: 50%;
|
|
&:hover {
|
color: #fff;
|
background: rgba(245, 108, 108, 0.8);
|
transform: scale(1.1) rotate(90deg);
|
}
|
}
|
}
|
|
:deep(.el-textarea__inner) {
|
padding: 0;
|
padding-bottom: 35px;
|
border: none;
|
box-shadow: none;
|
background: transparent;
|
font-family: inherit;
|
font-size: 14px;
|
line-height: 1.6;
|
color: #1a1a2e;
|
|
&::placeholder {
|
color: #7ab8ff;
|
}
|
|
&:focus {
|
box-shadow: none;
|
}
|
}
|
|
.send-btn {
|
position: absolute;
|
right: 16px;
|
bottom: 16px;
|
padding: 10px 22px;
|
background: $gradient-dark;
|
border: none;
|
border-radius: 10px;
|
font-weight: 600;
|
font-size: 14px;
|
color: #fff;
|
box-shadow: 0 6px 20px rgba(0, 85, 212, 0.4);
|
transition: all 0.35s cubic-bezier(0.175, 0.885, 0.32, 1.275);
|
overflow: hidden;
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
letter-spacing: 0.3px;
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: 0;
|
left: -100%;
|
width: 100%;
|
height: 100%;
|
background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
transition: left 0.5s;
|
}
|
|
&:hover:not(:disabled) {
|
transform: translateY(-3px) scale(1.02);
|
box-shadow: 0 10px 30px rgba(0, 85, 212, 0.5);
|
|
&::before {
|
left: 100%;
|
}
|
}
|
|
&:active:not(:disabled) {
|
transform: translateY(-1px) scale(0.98);
|
}
|
|
&:disabled {
|
background: linear-gradient(145deg, #b0b0b0, #c5c5c5);
|
box-shadow: none;
|
cursor: not-allowed;
|
}
|
|
.el-icon {
|
font-size: 15px;
|
transform: translateY(-1px);
|
}
|
}
|
}
|
}
|
|
.typing-indicator {
|
display: flex;
|
gap: 5px;
|
padding: 10px 14px;
|
background: #fff;
|
border-radius: 14px;
|
width: fit-content;
|
box-shadow: $shadow-card;
|
margin-top: 6px;
|
border: 1px solid rgba(0, 122, 255, 0.06);
|
border-top-left-radius: 4px;
|
|
.dot {
|
width: 7px;
|
height: 7px;
|
background: $secondary-blue;
|
border-radius: 50%;
|
animation: typing 1.4s infinite ease-in-out;
|
|
&:nth-child(2) {
|
animation-delay: 0.2s;
|
background: $primary-blue;
|
}
|
&:nth-child(3) {
|
animation-delay: 0.4s;
|
background: $deep-blue;
|
}
|
}
|
}
|
|
@keyframes typing {
|
0%, 80%, 100% {
|
transform: scale(0.6);
|
opacity: 0.4;
|
}
|
40% {
|
transform: scale(1);
|
opacity: 1;
|
}
|
}
|
|
.code-block {
|
background: linear-gradient(145deg, #1a1a2e, #16213e);
|
color: #a8d8ff;
|
padding: 14px;
|
border-radius: 10px;
|
font-family: 'Fira Code', 'Consolas', monospace;
|
margin: 10px 0;
|
overflow-x: auto;
|
border: 1px solid rgba(90, 200, 250, 0.15);
|
box-shadow: inset 0 2px 8px rgba(0, 0, 0, 0.2);
|
|
&.js-code {
|
color: #5ac8fa;
|
}
|
}
|
|
.chat-main {
|
background:
|
radial-gradient(circle at top left, rgba(46, 140, 224, 0.12) 0%, transparent 34%),
|
linear-gradient(180deg, #fff 0%, #f7fbff 46%, #fff 100%);
|
}
|
|
.chat-hero {
|
display: grid;
|
grid-template-columns: 164px minmax(0, 1fr);
|
gap: 18px;
|
align-items: start;
|
padding: 14px 18px 6px;
|
|
&.compact {
|
grid-template-columns: 122px minmax(0, 1fr);
|
gap: 12px;
|
padding: 8px 18px 2px;
|
}
|
}
|
|
.assistant-stand {
|
position: relative;
|
min-height: 252px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-direction: column;
|
padding-top: 18px;
|
overflow: hidden;
|
|
&.compact {
|
min-height: 176px;
|
padding-top: 8px;
|
}
|
|
&.thinking {
|
.assistant-halo {
|
opacity: 1;
|
transform: scale(1.08);
|
filter: blur(8px);
|
}
|
|
.assistant-scan-ring {
|
opacity: 1;
|
animation-duration: 1.6s;
|
}
|
|
.assistant-orbit {
|
opacity: 1;
|
}
|
|
.assistant-bot {
|
transform: translateY(-4px) scale(1.02);
|
}
|
|
.assistant-bot-head {
|
box-shadow: 0 0 30px rgba(80, 157, 255, 0.36);
|
}
|
|
.assistant-bot-eye {
|
animation: robotBlinkFast 1.1s infinite;
|
box-shadow: 0 0 16px rgba(72, 186, 255, 0.95);
|
}
|
|
.assistant-bot-mouth {
|
width: 28px;
|
opacity: 1;
|
animation: robotTalk 1.2s ease-in-out infinite;
|
}
|
|
.assistant-bot-core {
|
animation: corePulse 1.4s ease-in-out infinite;
|
box-shadow: 0 0 24px rgba(78, 120, 255, 0.26);
|
}
|
|
.assistant-bot-core-ring {
|
animation: coreRotate 3s linear infinite;
|
}
|
|
.assistant-status {
|
color: #6a3bee;
|
box-shadow: 0 10px 22px rgba(106, 59, 238, 0.14);
|
}
|
|
.assistant-status-dot {
|
background: #6a3bee;
|
box-shadow: 0 0 12px rgba(106, 59, 238, 0.9);
|
animation: thinkingDot 1s ease-in-out infinite;
|
}
|
|
.assistant-base-sm {
|
box-shadow: 0 0 24px rgba(255, 93, 122, 0.48);
|
}
|
}
|
}
|
|
.assistant-halo {
|
position: absolute;
|
top: 22px;
|
width: 130px;
|
height: 130px;
|
border-radius: 50%;
|
background: radial-gradient(circle, rgba(46, 140, 224, 0.3) 0%, rgba(0, 85, 212, 0.18) 42%, rgba(113, 54, 244, 0.12) 60%, transparent 78%);
|
filter: blur(6px);
|
opacity: 0.82;
|
transition: all 0.35s ease;
|
}
|
|
.assistant-scan-ring {
|
position: absolute;
|
top: 40px;
|
width: 132px;
|
height: 132px;
|
border-radius: 50%;
|
border: 1px solid rgba(90, 159, 224, 0.22);
|
box-shadow: inset 0 0 16px rgba(255, 255, 255, 0.25);
|
opacity: 0.55;
|
animation: scanRing 4s linear infinite;
|
}
|
|
.assistant-orbit {
|
position: absolute;
|
top: 52px;
|
width: 150px;
|
height: 150px;
|
border-radius: 50%;
|
border: 1px dashed rgba(92, 135, 255, 0.22);
|
opacity: 0.45;
|
}
|
|
.assistant-orbit-a {
|
animation: orbitRotate 8s linear infinite;
|
}
|
|
.assistant-orbit-b {
|
width: 118px;
|
height: 118px;
|
top: 68px;
|
border-color: rgba(255, 108, 150, 0.22);
|
animation: orbitRotateReverse 5.6s linear infinite;
|
}
|
|
.assistant-bot {
|
position: relative;
|
z-index: 1;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
margin-top: 12px;
|
transition: transform 0.35s ease;
|
}
|
|
.assistant-bot-antenna {
|
position: absolute;
|
top: -4px;
|
width: 4px;
|
height: 20px;
|
border-radius: 999px;
|
background: linear-gradient(180deg, #fefefe, #aac9ff);
|
|
&::before {
|
content: '';
|
position: absolute;
|
top: -6px;
|
left: 50%;
|
width: 10px;
|
height: 10px;
|
border-radius: 50%;
|
transform: translateX(-50%);
|
background: linear-gradient(135deg, #54bfff, #7a41ff);
|
box-shadow: 0 0 14px rgba(84, 191, 255, 0.65);
|
}
|
}
|
|
.assistant-bot-antenna-left {
|
left: 36px;
|
transform: rotate(-14deg);
|
}
|
|
.assistant-bot-antenna-right {
|
right: 36px;
|
transform: rotate(14deg);
|
}
|
|
.assistant-bot-head {
|
position: relative;
|
width: 92px;
|
height: 78px;
|
border-radius: 28px;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e8f1ff 100%);
|
border: 1px solid rgba(0, 85, 212, 0.14);
|
box-shadow: 0 16px 32px rgba(0, 85, 212, 0.14);
|
}
|
|
.assistant-bot-head-glow {
|
position: absolute;
|
inset: 10px 16px auto;
|
height: 20px;
|
border-radius: 999px;
|
background: linear-gradient(180deg, rgba(0, 85, 212, 0.16), transparent);
|
}
|
|
.assistant-bot-eye {
|
position: absolute;
|
top: 30px;
|
width: 16px;
|
height: 16px;
|
border-radius: 50%;
|
background: radial-gradient(circle, #8ef0ff 0%, #56c0ff 42%, #2869ff 100%);
|
box-shadow: 0 0 12px rgba(72, 186, 255, 0.72);
|
animation: robotBlink 3.2s infinite;
|
}
|
|
.assistant-bot-eye-left {
|
left: 22px;
|
}
|
|
.assistant-bot-eye-right {
|
right: 22px;
|
}
|
|
.assistant-bot-mouth {
|
position: absolute;
|
left: 50%;
|
bottom: 16px;
|
width: 22px;
|
height: 4px;
|
transform: translateX(-50%);
|
border-radius: 999px;
|
background: linear-gradient(90deg, rgba(72, 186, 255, 0.2), rgba(72, 186, 255, 0.9), rgba(72, 186, 255, 0.2));
|
}
|
|
.assistant-bot-neck {
|
width: 16px;
|
height: 10px;
|
border-radius: 0 0 10px 10px;
|
background: linear-gradient(180deg, #dceaff, #bdd5ff);
|
margin-top: -2px;
|
}
|
|
.assistant-bot-body {
|
position: relative;
|
width: 104px;
|
height: 92px;
|
margin-top: 2px;
|
border-radius: 28px 28px 34px 34px;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.98) 0%, #e3eeff 100%);
|
border: 1px solid rgba(0, 85, 212, 0.14);
|
box-shadow: 0 18px 36px rgba(0, 85, 212, 0.16);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.assistant-bot-arm {
|
position: absolute;
|
top: 18px;
|
width: 16px;
|
height: 44px;
|
border-radius: 999px;
|
background: linear-gradient(180deg, #eff5ff, #c7dbff);
|
border: 1px solid rgba(0, 85, 212, 0.12);
|
}
|
|
.assistant-bot-arm-left {
|
left: -10px;
|
transform: rotate(16deg);
|
}
|
|
.assistant-bot-arm-right {
|
right: -10px;
|
transform: rotate(-16deg);
|
}
|
|
.assistant-bot-core {
|
position: relative;
|
width: 46px;
|
height: 46px;
|
border-radius: 50%;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
color: $primary-blue;
|
background: radial-gradient(circle, rgba(255, 255, 255, 1) 0%, #dae8ff 55%, #adc7ff 100%);
|
}
|
|
.assistant-bot-core-ring {
|
position: absolute;
|
inset: -6px;
|
border-radius: 50%;
|
border: 1px solid rgba(88, 135, 255, 0.3);
|
border-top-color: rgba(255, 96, 139, 0.85);
|
border-right-color: rgba(79, 145, 255, 0.9);
|
}
|
|
.assistant-status {
|
position: relative;
|
z-index: 1;
|
margin-top: 14px;
|
padding: 6px 12px;
|
border-radius: 999px;
|
font-size: 12px;
|
font-weight: 600;
|
color: $deep-blue;
|
background: rgba(255, 255, 255, 0.95);
|
border: 1px solid rgba(0, 85, 212, 0.12);
|
box-shadow: 0 8px 20px rgba(0, 85, 212, 0.08);
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
}
|
|
.assistant-status-dot {
|
width: 8px;
|
height: 8px;
|
border-radius: 50%;
|
background: #2e8ce0;
|
box-shadow: 0 0 10px rgba(46, 140, 224, 0.72);
|
}
|
|
.assistant-base {
|
position: absolute;
|
bottom: 0;
|
left: 50%;
|
transform: translateX(-50%);
|
border-radius: 50%;
|
border: 2px solid rgba(255, 93, 122, 0.22);
|
background: radial-gradient(circle, rgba(255, 255, 255, 0.9) 0%, rgba(255, 111, 145, 0.1) 70%, transparent 100%);
|
}
|
|
.assistant-base-lg {
|
width: 118px;
|
height: 30px;
|
}
|
|
.assistant-base-md {
|
bottom: 6px;
|
width: 88px;
|
height: 20px;
|
border-color: rgba(255, 93, 122, 0.34);
|
}
|
|
.assistant-base-sm {
|
bottom: 11px;
|
width: 54px;
|
height: 10px;
|
background: linear-gradient(90deg, rgba(255, 93, 122, 0.95), rgba(255, 173, 188, 0.9));
|
border: none;
|
box-shadow: 0 0 18px rgba(255, 93, 122, 0.38);
|
}
|
|
@keyframes robotBlink {
|
0%, 44%, 48%, 100% {
|
transform: scaleY(1);
|
}
|
46% {
|
transform: scaleY(0.14);
|
}
|
}
|
|
@keyframes robotBlinkFast {
|
0%, 100% {
|
transform: scaleY(1);
|
}
|
50% {
|
transform: scaleY(0.3);
|
}
|
}
|
|
@keyframes robotTalk {
|
0%, 100% {
|
transform: translateX(-50%) scaleX(1);
|
}
|
50% {
|
transform: translateX(-50%) scaleX(1.35);
|
}
|
}
|
|
@keyframes corePulse {
|
0%, 100% {
|
transform: scale(1);
|
filter: brightness(1);
|
}
|
50% {
|
transform: scale(1.08);
|
filter: brightness(1.08);
|
}
|
}
|
|
@keyframes coreRotate {
|
from {
|
transform: rotate(0deg);
|
}
|
to {
|
transform: rotate(360deg);
|
}
|
}
|
|
@keyframes orbitRotate {
|
from {
|
transform: rotate(0deg);
|
}
|
to {
|
transform: rotate(360deg);
|
}
|
}
|
|
@keyframes orbitRotateReverse {
|
from {
|
transform: rotate(360deg);
|
}
|
to {
|
transform: rotate(0deg);
|
}
|
}
|
|
@keyframes scanRing {
|
0%, 100% {
|
transform: scale(0.96);
|
opacity: 0.42;
|
}
|
50% {
|
transform: scale(1.04);
|
opacity: 0.86;
|
}
|
}
|
|
@keyframes thinkingDot {
|
0%, 100% {
|
transform: scale(1);
|
}
|
50% {
|
transform: scale(1.35);
|
}
|
}
|
|
.welcome-card {
|
position: relative;
|
padding: 14px 14px 12px;
|
border-radius: 16px;
|
background:
|
linear-gradient(#fff, #fff) padding-box,
|
linear-gradient(135deg, rgba(255, 64, 96, 0.85), rgba(117, 65, 255, 0.9)) border-box;
|
border: 1px solid transparent;
|
box-shadow: 0 16px 36px rgba(0, 85, 212, 0.12);
|
|
&.compact {
|
padding: 10px 12px;
|
border-radius: 12px;
|
box-shadow: 0 8px 16px rgba(0, 85, 212, 0.07);
|
|
.welcome-eyebrow {
|
margin-bottom: 4px;
|
}
|
|
.welcome-title {
|
font-size: 17px;
|
line-height: 1.3;
|
|
br {
|
display: none;
|
}
|
}
|
|
.welcome-desc {
|
margin-top: 6px;
|
font-size: 12px;
|
line-height: 1.55;
|
}
|
|
.quick-prompt-list {
|
margin-top: 10px;
|
gap: 6px;
|
}
|
|
.quick-prompt-btn {
|
padding: 8px 10px;
|
font-size: 12px;
|
border-radius: 7px;
|
}
|
|
.more-prompts-btn {
|
margin-top: 8px;
|
font-size: 12px;
|
}
|
}
|
}
|
|
.welcome-eyebrow {
|
font-size: 11px;
|
font-weight: 700;
|
letter-spacing: 2px;
|
color: rgba(0, 85, 212, 0.58);
|
margin-bottom: 8px;
|
}
|
|
.welcome-title {
|
margin: 0;
|
font-size: 26px;
|
line-height: 1.2;
|
font-weight: 800;
|
color: #172033;
|
}
|
|
.welcome-desc {
|
margin: 10px 0 0;
|
font-size: 13px;
|
line-height: 1.7;
|
color: #5f6980;
|
}
|
|
.quick-prompt-list {
|
display: grid;
|
gap: 8px;
|
margin-top: 14px;
|
}
|
|
.quick-prompt-btn {
|
width: 100%;
|
border: none;
|
border-radius: 10px;
|
padding: 11px 14px;
|
text-align: left;
|
font-size: 13px;
|
font-weight: 600;
|
color: #fff;
|
cursor: pointer;
|
background: linear-gradient(90deg, #ff4c55 0%, #7c38ef 100%);
|
box-shadow: 0 12px 22px rgba(124, 56, 239, 0.18);
|
transition: transform 0.25s ease, box-shadow 0.25s ease, opacity 0.2s ease;
|
position: relative;
|
overflow: hidden;
|
|
&::before {
|
content: '';
|
position: absolute;
|
inset: 0;
|
background: linear-gradient(135deg, rgba(255, 255, 255, 0.22), transparent 56%);
|
pointer-events: none;
|
}
|
|
&::after {
|
content: '';
|
position: absolute;
|
top: -120%;
|
left: -30%;
|
width: 45%;
|
height: 260%;
|
background: linear-gradient(180deg, transparent, rgba(255, 255, 255, 0.3), transparent);
|
transform: rotate(22deg);
|
opacity: 0;
|
transition: all 0.35s ease;
|
}
|
|
&:hover:not(:disabled) {
|
transform: translateY(-2px) scale(1.01);
|
box-shadow: 0 16px 28px rgba(124, 56, 239, 0.24);
|
|
&::after {
|
left: 100%;
|
opacity: 1;
|
}
|
}
|
|
&:disabled {
|
cursor: not-allowed;
|
opacity: 0.65;
|
}
|
}
|
|
.more-prompts-btn {
|
margin-top: 10px;
|
padding: 0 12px;
|
height: 32px;
|
border: 1px solid rgba(208, 65, 81, 0.12);
|
border-radius: 999px;
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.96), rgba(255, 241, 245, 0.96));
|
color: #d04151;
|
font-size: 13px;
|
font-weight: 600;
|
cursor: pointer;
|
display: inline-flex;
|
align-items: center;
|
gap: 6px;
|
box-shadow: 0 10px 18px rgba(208, 65, 81, 0.08);
|
transition: all 0.25s ease;
|
|
&:hover {
|
transform: translateY(-1px);
|
background: linear-gradient(135deg, #ff5570 0%, #8a3df6 100%);
|
border-color: transparent;
|
color: #fff;
|
box-shadow: 0 14px 24px rgba(138, 61, 246, 0.18);
|
}
|
}
|
|
.hero-dot-grid {
|
display: grid;
|
grid-template-columns: repeat(14, 1fr);
|
gap: 7px;
|
padding: 0 18px 14px;
|
|
span {
|
display: block;
|
width: 100%;
|
aspect-ratio: 1;
|
border-radius: 2px;
|
background: linear-gradient(135deg, rgba(255, 110, 138, 0.95), rgba(255, 190, 201, 0.55));
|
}
|
}
|
|
.message-list {
|
padding: 8px 18px 18px;
|
gap: 16px;
|
background: transparent;
|
}
|
|
.input-area {
|
padding: 12px 18px 16px;
|
background: #fff;
|
border-top: none;
|
|
&::before {
|
display: none;
|
}
|
|
.input-box {
|
padding: 14px 16px 16px;
|
border: 1px solid rgba(123, 56, 239, 0.9);
|
border-radius: 22px;
|
margin: 0;
|
transition: all 0.25s ease;
|
box-shadow: 0 14px 34px rgba(0, 85, 212, 0.08);
|
|
&:focus-within {
|
border-color: #7c38ef;
|
box-shadow: 0 0 0 3px rgba(124, 56, 239, 0.1), 0 18px 40px rgba(0, 85, 212, 0.12);
|
transform: none;
|
}
|
|
:deep(.el-textarea__inner) {
|
padding-right: 58px;
|
padding-bottom: 0;
|
min-height: 104px;
|
|
&::placeholder {
|
color: #a0a9bc;
|
}
|
}
|
|
.send-btn {
|
right: 25px;
|
top: 50%;
|
transform: translateY(-50%);
|
width: 36px;
|
min-width: 36px;
|
height: 36px;
|
padding: 0;
|
background: linear-gradient(135deg, #ff5570 0%, #7a36f2 58%, #2d79ff 100%);
|
border-radius: 50%;
|
box-shadow: 0 12px 24px rgba(109, 50, 236, 0.24);
|
transition: all 0.25s ease;
|
gap: 0;
|
|
&:hover:not(:disabled) {
|
transform: translateY(calc(-50% - 1px)) scale(1.04);
|
box-shadow: 0 16px 28px rgba(109, 50, 236, 0.3);
|
}
|
|
&:active:not(:disabled) {
|
transform: translateY(-50%) scale(0.96);
|
}
|
|
.el-icon {
|
margin: 0;
|
font-size: 16px;
|
transform: translate(0, -1px);
|
}
|
}
|
}
|
}
|
|
@media (max-width: 767px) {
|
.chat-hero {
|
grid-template-columns: 1fr;
|
gap: 10px;
|
padding: 14px 14px 6px;
|
|
&.compact {
|
padding: 8px 14px 4px;
|
}
|
}
|
|
.assistant-stand {
|
min-height: 184px;
|
}
|
|
.welcome-card {
|
padding: 12px 12px 10px;
|
}
|
|
.welcome-title {
|
font-size: 21px;
|
}
|
|
.hero-dot-grid {
|
grid-template-columns: repeat(12, 1fr);
|
gap: 6px;
|
padding: 0 14px 12px;
|
}
|
|
.message-list {
|
padding: 8px 14px 14px;
|
}
|
|
.input-area {
|
padding: 10px 14px 14px;
|
}
|
|
.input-area .input-actions {
|
gap: 10px;
|
flex-wrap: wrap;
|
}
|
}
|
</style>
|