| | |
| | | <template> |
| | | <div class="ai-chat-sidebar-wrapper"> |
| | | <!-- 悬浮图标 --> |
| | | <div class="ai-chat-trigger" @click="toggleSidebar" v-show="!visible"> |
| | | <div v-if="!hideTrigger" 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> |
| | |
| | | <!-- 侧边栏对话框 --> |
| | | <el-drawer |
| | | v-model="visible" |
| | | :size="drawerSize" |
| | | direction="rtl" |
| | | :size="computedDrawerSize" |
| | | :direction="drawerDirection" |
| | | :with-header="true" |
| | | class="ai-chat-drawer" |
| | | :modal="false" |
| | | modal-class="ai-chat-overlay" |
| | | :show-close="false" |
| | | :append-to-body="false" |
| | | :close-on-press-escape="!hideTrigger" |
| | | :close-on-click-modal="!hideTrigger" |
| | | @close="handleClose" |
| | | > |
| | | <template #header> |
| | |
| | | <el-icon :size="18"><Plus /></el-icon> |
| | | </el-button> |
| | | </el-tooltip> |
| | | <div class="action-divider"></div> |
| | | <el-tooltip content="关闭" placement="bottom"> |
| | | <el-button |
| | | v-if="headerExtraActionText" |
| | | link |
| | | class="header-action-btn header-action-btn--text" |
| | | @click="handleHeaderExtraAction" |
| | | > |
| | | {{ headerExtraActionText }} |
| | | </el-button> |
| | | <div v-if="!hideTrigger" class="action-divider"></div> |
| | | <el-tooltip v-if="!hideTrigger" content="关闭" placement="bottom"> |
| | | <el-button link class="header-action-btn close-btn" @click="handleManualClose"> |
| | | <el-icon :size="18"><Close /></el-icon> |
| | | </el-button> |
| | |
| | | <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 class="assistant-model-shell"> |
| | | <div class="assistant-model-cut"> |
| | | <img |
| | | v-if="currentAssistantAvatar" |
| | | class="assistant-model-img" |
| | | :src="currentAssistantAvatar" |
| | | :alt="currentAssistant.label" |
| | | /> |
| | | <div v-else class="assistant-model-fallback"> |
| | | <el-icon :size="30"><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"> |
| | |
| | | </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" |
| | |
| | | <div class="message-content"> |
| | | <!-- 文本内容 --> |
| | | <div class="text-box" v-html="message.htmlContent"></div> |
| | | |
| | | <div v-if="message.localUploadFiles?.length" class="message-local-file-list"> |
| | | <div |
| | | v-for="(file, fileIndex) in message.localUploadFiles" |
| | | :key="`${file.previewId || file.name}-${fileIndex}`" |
| | | :class="['message-local-file-item', { clickable: !!file.accessUrl && !file.isImage }]" |
| | | @click="handleMessageFileClick(file)" |
| | | > |
| | | <el-image |
| | | v-if="file.isImage && file.previewUrl" |
| | | :src="file.previewUrl" |
| | | :preview-src-list="getImagePreviewList(message.localUploadFiles)" |
| | | :initial-index="getImagePreviewInitialIndex(message.localUploadFiles, file.previewUrl)" |
| | | :z-index="4000" |
| | | preview-teleported |
| | | fit="cover" |
| | | class="message-local-file-thumb" |
| | | /> |
| | | <el-icon v-else class="message-local-file-icon"><Document /></el-icon> |
| | | <div class="message-local-file-meta"> |
| | | <span |
| | | :class="['message-local-file-name', { clickable: !!file.accessUrl }]" |
| | | :title="file.name" |
| | | @click.stop="openMessageAttachment(file)" |
| | | > |
| | | {{ file.name }} |
| | | </span> |
| | | <small v-if="Number(file.size) > 0" class="message-local-file-size">{{ formatFileSize(file.size) }}</small> |
| | | </div> |
| | | </div> |
| | | </div> |
| | | |
| | | <!-- 图表内容 --> |
| | | <div v-if="message.chartOptions && message.chartRenderReady" class="charts-wrapper"> |
| | |
| | | </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> |
| | |
| | | action="#" |
| | | :auto-upload="false" |
| | | :show-file-list="false" |
| | | v-model:file-list="uploadFileList" |
| | | :multiple="currentAssistant.allowMultipleFileUpload" |
| | | :on-change="handleFileChange" |
| | | :disabled="isSending" |
| | | > |
| | |
| | | </el-upload> |
| | | </div> |
| | | <div class="input-box"> |
| | | <div v-if="selectedFile" class="selected-file-tag"> |
| | | <el-icon><Document /></el-icon> |
| | | <span class="file-name">{{ selectedFile.name }}</span> |
| | | <el-icon class="remove-file" @click="removeSelectedFile"><Close /></el-icon> |
| | | <div v-if="selectedFiles.length" class="selected-file-list"> |
| | | <div v-for="(file, fileIndex) in selectedFileSnapshots" :key="`${file.previewId || file.name}-${fileIndex}`" class="selected-file-tag"> |
| | | <el-image |
| | | v-if="file.isImage && file.previewUrl" |
| | | :src="file.previewUrl" |
| | | :preview-src-list="getImagePreviewList(selectedFileSnapshots)" |
| | | :initial-index="getImagePreviewInitialIndex(selectedFileSnapshots, file.previewUrl)" |
| | | :z-index="4000" |
| | | preview-teleported |
| | | fit="cover" |
| | | class="selected-file-thumb" |
| | | /> |
| | | <el-icon v-else><Document /></el-icon> |
| | | <div class="selected-file-meta"> |
| | | <span class="file-name">{{ file.name }}</span> |
| | | <small class="file-size">{{ formatFileSize(file.size) }}</small> |
| | | </div> |
| | | <el-icon class="remove-file" @click="removeSelectedFile(fileIndex)"><Close /></el-icon> |
| | | </div> |
| | | </div> |
| | | <el-input |
| | | v-model="inputMessage" |
| | | type="textarea" |
| | | :rows="selectedFile ? 2 : 3" |
| | | :rows="selectedFiles.length ? 2 : 3" |
| | | :placeholder="currentAssistant.placeholder" |
| | | resize="none" |
| | | @keydown.enter.exact.prevent="sendMessage" |
| | |
| | | <el-button |
| | | type="primary" |
| | | class="send-btn" |
| | | :disabled="isSending || (!inputMessage.trim() && !selectedFile)" |
| | | :disabled="isSending || (!inputMessage.trim() && !selectedFiles.length)" |
| | | @click="sendMessage" |
| | | aria-label="发送" |
| | | > |
| | |
| | | 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 { Cpu, User, Plus, Timer, Delete, ChatDotSquare, VideoPause, Upload, Document, Close, Promotion, RefreshRight } from '@element-plus/icons-vue' |
| | | import { ElMessage } from 'element-plus' |
| | | import { builtInAssistants, generalAssistant } from './assistants' |
| | | import todoAssistantAvatar from '@/assets/AI/待办助手.png' |
| | | import salesAssistantAvatar from '@/assets/AI/销售助手.png' |
| | | import purchaseAssistantAvatar from '@/assets/AI/采购助手.png' |
| | | import productionAssistantAvatar from '@/assets/AI/生产助手.png' |
| | | import financeAssistantAvatar from '@/assets/AI/财务助手.png' |
| | | import bossAssistantAvatar from '@/assets/AI/待办助手.png' |
| | | |
| | | const emit = defineEmits(['header-extra-action']) |
| | | |
| | | const props = defineProps({ |
| | | assistants: { |
| | |
| | | defaultAssistant: { |
| | | type: String, |
| | | default: '' |
| | | }, |
| | | hideTrigger: { |
| | | type: Boolean, |
| | | default: false |
| | | }, |
| | | autoOpen: { |
| | | type: Boolean, |
| | | default: false |
| | | }, |
| | | drawerSize: { |
| | | type: [String, Number], |
| | | default: '' |
| | | }, |
| | | drawerDirection: { |
| | | type: String, |
| | | default: 'rtl' |
| | | }, |
| | | headerExtraActionText: { |
| | | 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: false, |
| | | emptySessionText: '暂无采购会话' |
| | | } |
| | | ] |
| | | |
| | | const hideTrigger = computed(() => props.hideTrigger) |
| | | const headerExtraActionText = computed(() => String(props.headerExtraActionText || '').trim()) |
| | | const drawerDirection = computed(() => (props.drawerDirection === 'ttb' || props.drawerDirection === 'btt' || props.drawerDirection === 'ltr' || props.drawerDirection === 'rtl') |
| | | ? props.drawerDirection |
| | | : 'rtl') |
| | | 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 assistantAvatarByKey = { |
| | | general: todoAssistantAvatar, |
| | | todo: todoAssistantAvatar, |
| | | purchase: purchaseAssistantAvatar, |
| | | sales: salesAssistantAvatar, |
| | | production: productionAssistantAvatar, |
| | | finance: financeAssistantAvatar, |
| | | boss: bossAssistantAvatar |
| | | } |
| | | const currentAssistantAvatar = computed(() => { |
| | | const assistant = currentAssistant.value || {} |
| | | return assistant.avatar || assistantAvatarByKey[assistant.key] || '' |
| | | }) |
| | | const showAssistantSwitch = computed(() => assistants.value.length > 1) |
| | | const quickPromptLimit = 3 |
| | | const quickPromptStart = ref(0) |
| | | const quickPrompts = computed(() => { |
| | |
| | | if (Array.isArray(assistant.quickPrompts) && assistant.quickPrompts.length) { |
| | | return assistant.quickPrompts |
| | | } |
| | | return assistantQuickPromptMap[assistant.key] || assistantQuickPromptMap.general |
| | | return generalAssistant.quickPrompts || [] |
| | | }) |
| | | const displayedQuickPrompts = computed(() => { |
| | | const prompts = quickPrompts.value || [] |
| | |
| | | |
| | | const visible = ref(false) |
| | | const windowWidth = ref(window.innerWidth) |
| | | const drawerSize = computed(() => { |
| | | const responsiveDrawerSize = computed(() => { |
| | | if (windowWidth.value < 768) return '100%' |
| | | if (windowWidth.value < 1200) return '500px' |
| | | return '600px' |
| | | if (windowWidth.value < 1200) return '50%' |
| | | return '50%' |
| | | }) |
| | | const computedDrawerSize = computed(() => props.drawerSize || responsiveDrawerSize.value) |
| | | const messageListRef = ref(null) |
| | | const isSending = ref(false) |
| | | const currentAbortController = ref(null) |
| | | const inputMessage = ref('') |
| | | const selectedFile = ref(null) |
| | | const selectedFiles = ref([]) |
| | | const uploadFileList = ref([]) |
| | | const selectedFileSnapshots = 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 isImageFileType = (fileType = '') => String(fileType || '').toLowerCase().startsWith('image/') |
| | | const imageFilePathPattern = /\.(png|jpe?g|gif|webp|bmp|svg)$/i |
| | | |
| | | const getPathnameFromFilePath = (filePath = '') => { |
| | | const rawPath = String(filePath || '').trim() |
| | | if (!rawPath) return '' |
| | | try { |
| | | const baseOrigin = typeof window !== 'undefined' ? window.location.origin : 'http://localhost' |
| | | return new URL(rawPath, baseOrigin).pathname || '' |
| | | } catch (err) { |
| | | return rawPath.split('?')[0] |
| | | } |
| | | } |
| | | |
| | | const isImageFilePath = (filePath = '') => { |
| | | const pathname = getPathnameFromFilePath(filePath).toLowerCase() |
| | | return imageFilePathPattern.test(pathname) |
| | | } |
| | | |
| | | const getHistoryFileName = (filePath = '', index = 0) => { |
| | | const pathname = getPathnameFromFilePath(filePath) |
| | | const fileName = pathname.split('/').filter(Boolean).pop() |
| | | if (!fileName) return `file-${index + 1}` |
| | | try { |
| | | return decodeURIComponent(fileName) |
| | | } catch (err) { |
| | | return fileName |
| | | } |
| | | } |
| | | |
| | | const getImagePreviewList = (files = []) => { |
| | | if (!Array.isArray(files)) return [] |
| | | return files |
| | | .filter(item => item?.isImage && item?.previewUrl) |
| | | .map(item => item.previewUrl) |
| | | } |
| | | |
| | | const getImagePreviewInitialIndex = (files = [], previewUrl = '') => { |
| | | const list = getImagePreviewList(files) |
| | | const index = list.indexOf(previewUrl) |
| | | return index >= 0 ? index : 0 |
| | | } |
| | | |
| | | const formatFileSize = (size) => { |
| | | const bytes = Number(size) |
| | | if (!Number.isFinite(bytes) || bytes <= 0) return '0 B' |
| | | if (bytes < 1024) return `${bytes} B` |
| | | if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1).replace(/\.0$/, '')} KB` |
| | | return `${(bytes / (1024 * 1024)).toFixed(1).replace(/\.0$/, '')} MB` |
| | | } |
| | | |
| | | const createLocalFileSnapshot = (file, index = 0) => { |
| | | const rawFile = typeof File !== 'undefined' && file instanceof File ? file : null |
| | | const fileType = rawFile?.type || '' |
| | | const isImage = isImageFileType(fileType) |
| | | const canCreateObjectURL = typeof URL !== 'undefined' && typeof URL.createObjectURL === 'function' |
| | | const previewUrl = isImage && rawFile && canCreateObjectURL ? URL.createObjectURL(rawFile) : '' |
| | | return { |
| | | previewId: `${rawFile?.name || 'file'}-${rawFile?.size || 0}-${rawFile?.lastModified || Date.now()}-${index}`, |
| | | name: rawFile?.name || `file-${index + 1}`, |
| | | size: rawFile?.size || 0, |
| | | type: fileType, |
| | | isImage, |
| | | previewUrl, |
| | | accessUrl: '', |
| | | rawFile, |
| | | isObjectUrl: !!previewUrl |
| | | } |
| | | } |
| | | |
| | | const createHistoryFileSnapshot = (filePath, memoryId = '', messageIndex = 0, fileIndex = 0) => { |
| | | const normalizedPath = String(filePath || '').trim() |
| | | if (!normalizedPath) return null |
| | | const isImage = isImageFilePath(normalizedPath) |
| | | return { |
| | | previewId: `${memoryId || 'history'}-${messageIndex}-${fileIndex}`, |
| | | name: getHistoryFileName(normalizedPath, fileIndex), |
| | | size: 0, |
| | | type: '', |
| | | isImage, |
| | | previewUrl: isImage ? normalizedPath : '', |
| | | accessUrl: normalizedPath, |
| | | rawFile: null, |
| | | isObjectUrl: false |
| | | } |
| | | } |
| | | |
| | | const revokeLocalFileSnapshots = (snapshots = []) => { |
| | | const canRevokeObjectURL = typeof URL !== 'undefined' && typeof URL.revokeObjectURL === 'function' |
| | | if (!canRevokeObjectURL) return |
| | | if (!Array.isArray(snapshots)) return |
| | | snapshots.forEach((snapshot) => { |
| | | if (snapshot?.isObjectUrl && snapshot?.previewUrl) { |
| | | URL.revokeObjectURL(snapshot.previewUrl) |
| | | } |
| | | }) |
| | | } |
| | | |
| | | const mapHistoryFilePathsToSnapshots = (filePaths = [], memoryId = '', messageIndex = 0) => { |
| | | if (!Array.isArray(filePaths)) return [] |
| | | return filePaths |
| | | .map((filePath, fileIndex) => createHistoryFileSnapshot(filePath, memoryId, messageIndex, fileIndex)) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | const openMessageAttachment = (file) => { |
| | | const accessUrl = String(file?.accessUrl || '').trim() |
| | | if (!accessUrl) return |
| | | if (typeof window === 'undefined' || typeof window.open !== 'function') return |
| | | window.open(accessUrl, '_blank', 'noopener,noreferrer') |
| | | } |
| | | |
| | | const handleMessageFileClick = (file) => { |
| | | if (!file?.accessUrl || file?.isImage) return |
| | | openMessageAttachment(file) |
| | | } |
| | | |
| | | const revokeMessageLocalFileSnapshots = (messageList = []) => { |
| | | if (!Array.isArray(messageList)) return |
| | | messageList.forEach((msg) => { |
| | | if (Array.isArray(msg?.localUploadFiles)) { |
| | | revokeLocalFileSnapshots(msg.localUploadFiles) |
| | | msg.localUploadFiles = [] |
| | | } |
| | | }) |
| | | } |
| | | |
| | | const clearSelectedFiles = ({ releaseSnapshots = true } = {}) => { |
| | | if (releaseSnapshots) { |
| | | revokeLocalFileSnapshots(selectedFileSnapshots.value) |
| | | } |
| | | selectedFiles.value = [] |
| | | uploadFileList.value = [] |
| | | selectedFileSnapshots.value = [] |
| | | } |
| | | |
| | | const abortCurrentRequest = () => { |
| | | if (!currentAbortController.value) return |
| | |
| | | |
| | | const selectSession = async (session) => { |
| | | showHistory.value = false |
| | | clearSelectedFiles() |
| | | 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) { |
| | | revokeMessageLocalFileSnapshots(messages.value) |
| | | disposeCharts() |
| | | messages.value = [] |
| | | const historyMsgs = res.data || [] |
| | |
| | | |
| | | const messageObj = { |
| | | isUser, |
| | | content: msg.content, |
| | | content: msg.content || '', |
| | | htmlContent: '', |
| | | isTyping: false, |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | tableData: null, |
| | | payloadTreeData: null, |
| | | payloadHiddenData: null, |
| | | localUploadFiles: isUser ? mapHistoryFilePathsToSnapshots(msg.filePaths, uuid.value, idx) : [] |
| | | } |
| | | |
| | | messages.value.push(messageObj) |
| | |
| | | } |
| | | |
| | | // 解析历史消息中的 JSON |
| | | const extracted = extractEmbeddedSuccessJson(msg.content) |
| | | const extracted = extractEmbeddedSuccessJson(msg.content || '') |
| | | if (extracted) { |
| | | applyStructuredMessageData(messageObj, extracted.data, botMsgIndex) |
| | | } |
| | | |
| | | updateOutputState(msg.content, botMsgIndex) |
| | | messageObj.htmlContent = convertStreamOutput(msg.content, botMsgIndex) |
| | | updateOutputState(msg.content || '', botMsgIndex) |
| | | messageObj.htmlContent = convertStreamOutput(msg.content || '', botMsgIndex) |
| | | } else { |
| | | messageObj.htmlContent = convertTextToHtml(msg.content) |
| | | messageObj.htmlContent = convertTextToHtml(msg.content || '') |
| | | } |
| | | }) |
| | | scrollToBottom() |
| | |
| | | } |
| | | |
| | | onMounted(() => { |
| | | initUUID() |
| | | // 初始欢迎 |
| | | if (messages.value.length === 0) { |
| | | hello() |
| | | if (props.autoOpen) { |
| | | visible.value = true |
| | | } |
| | | initUUID() |
| | | window.addEventListener('resize', handleWindowResize) |
| | | }) |
| | | |
| | | onUnmounted(() => { |
| | | revokeMessageLocalFileSnapshots(messages.value) |
| | | clearSelectedFiles() |
| | | disposeCharts() |
| | | window.removeEventListener('resize', handleWindowResize) |
| | | }) |
| | |
| | | if (!prevKey || nextKey === prevKey) return |
| | | |
| | | abortCurrentRequest() |
| | | revokeMessageLocalFileSnapshots(messages.value) |
| | | disposeCharts() |
| | | messages.value = [] |
| | | outputState.value = {} |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | clearSelectedFiles() |
| | | inputMessage.value = '' |
| | | quickPromptStart.value = 0 |
| | | initUUID() |
| | | hello() |
| | | }) |
| | | |
| | | watch(() => props.defaultAssistant, (nextKey) => { |
| | | if (!nextKey || nextKey === selectedAssistantKey.value) return |
| | | if (!assistants.value.some(item => item.key === nextKey)) return |
| | | selectedAssistantKey.value = nextKey |
| | | }) |
| | | |
| | | watch(() => props.autoOpen, (nextValue) => { |
| | | if (nextValue) { |
| | | visible.value = true |
| | | } |
| | | }) |
| | | |
| | | const handleWindowResize = () => { |
| | | windowWidth.value = window.innerWidth |
| | | } |
| | | |
| | | const handleHeaderExtraAction = () => { |
| | | emit('header-extra-action') |
| | | } |
| | | |
| | | const toggleSidebar = () => { |
| | |
| | | } |
| | | |
| | | const handleClose = () => { |
| | | if (hideTrigger.value) return |
| | | visible.value = false |
| | | } |
| | | |
| | | const handleManualClose = () => { |
| | | if (hideTrigger.value) return |
| | | if (isSending.value) { |
| | | abortCurrentRequest() |
| | | } |
| | |
| | | uuid.value = storedUUID |
| | | } |
| | | |
| | | const hello = () => { |
| | | sendRequest(currentAssistant.value.welcomeMessage || '你好') |
| | | } |
| | | |
| | | const newChat = () => { |
| | | revokeMessageLocalFileSnapshots(messages.value) |
| | | disposeCharts() |
| | | messages.value = [] |
| | | outputState.value = {} |
| | | sessions.value = [] |
| | | showHistory.value = false |
| | | selectedFile.value = null |
| | | clearSelectedFiles() |
| | | quickPromptStart.value = 0 |
| | | localStorage.removeItem(currentAssistant.value.storageKey) |
| | | initUUID() |
| | | hello() |
| | | } |
| | | |
| | | const handleNewChat = () => { |
| | |
| | | const extractEmbeddedSuccessJson = (text) => { |
| | | if (!text || typeof text !== 'string') return null |
| | | |
| | | const startIdx = text.indexOf('{"success"') |
| | | if (startIdx === -1) return null |
| | | const startMatch = text.match(/\{\s*"success"\s*:/) |
| | | if (!startMatch) return null |
| | | const startIdx = startMatch.index ?? -1 |
| | | if (startIdx < 0) return null |
| | | |
| | | for (let i = startIdx; i < text.length; i++) { |
| | | if (text[i] !== '{') continue |
| | |
| | | messageObj.tableData = parsedData.data |
| | | } |
| | | |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | messageObj.chartOptions = parsedData.charts |
| | | if (parsedData.action === 'confirm_required' && parsedData.businessType) { |
| | | messageObj.type = 'purchase_analysis_confirm' |
| | | messageObj.purchaseAnalysisData = parsedData |
| | | if (!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) { |
| | |
| | | } |
| | | } |
| | | |
| | | 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)) { |
| | | return visibleValue.map((item, index) => mergePurchasePayloadWithHidden(item, hiddenValue[index])) |
| | | } |
| | | |
| | | 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 purchaseIntegerFieldKeys = new Set([ |
| | | 'id', |
| | | 'supplierId', |
| | | 'recorderId', |
| | | 'salesContractNoId', |
| | | 'salesLedgerId', |
| | | 'Type', |
| | | 'businessPersonId', |
| | | 'productId', |
| | | 'productModelId', |
| | | 'ticketRegistrationId', |
| | | 'type', |
| | | 'approvalStatus', |
| | | 'inventoryWarningQuantity' |
| | | ]) |
| | | |
| | | const purchaseDecimalFieldKeys = new Set([ |
| | | 'invoiceAmount', |
| | | 'contractAmount', |
| | | 'receiptPaymentAmount', |
| | | 'unReceiptPaymentAmount', |
| | | 'quantity', |
| | | 'taxRate', |
| | | 'taxInclusiveUnitPrice', |
| | | 'taxInclusiveTotalPrice', |
| | | 'taxExclusiveTotalPrice', |
| | | 'priceWithTax', |
| | | 'totalPriceWithTax' |
| | | ]) |
| | | |
| | | const purchaseBooleanFieldKeys = new Set([ |
| | | 'hasChildren', |
| | | 'isWhite', |
| | | 'isInspected', |
| | | 'isChecked' |
| | | ]) |
| | | |
| | | const purchaseStringFieldKeys = new Set([ |
| | | 'entryDateStart', |
| | | 'entryDateEnd', |
| | | 'purchaseContractNumber', |
| | | 'supplierName', |
| | | 'recorderName', |
| | | 'salesContractNo', |
| | | 'projectName', |
| | | 'entryDate', |
| | | 'executionDate', |
| | | 'remarks', |
| | | 'attachmentMaterials', |
| | | 'createdAt', |
| | | 'updatedAt', |
| | | 'phoneNumber', |
| | | 'invoiceNumber', |
| | | 'paymentMethod', |
| | | 'templateName', |
| | | 'productCategory', |
| | | 'specificationModel', |
| | | 'unit', |
| | | 'invoiceType' |
| | | ]) |
| | | |
| | | const purchaseGenericArrayFieldKeys = new Set([ |
| | | 'purchaseLedgers', |
| | | 'productData' |
| | | ]) |
| | | |
| | | const purchaseStringArrayFieldKeys = new Set(['tempFileIds']) |
| | | |
| | | const purchaseObjectArrayFieldKeys = new Set(['SalesLedgerFiles']) |
| | | |
| | | 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 prunePurchaseProductRecord = (record) => { |
| | | if (!record || typeof record !== 'object' || Array.isArray(record)) return null |
| | | const normalizedRecord = normalizePurchaseProductRecord(record) |
| | | const hasVisibleFieldValue = Object.entries(normalizedRecord).some(([key, value]) => { |
| | | if (shouldHidePurchaseField(key)) return false |
| | | return hasMeaningfulPayloadValue(value) |
| | | }) |
| | | return hasVisibleFieldValue ? normalizedRecord : null |
| | | } |
| | | |
| | | const normalizeAndFilterPurchaseProductData = (value) => { |
| | | if (!Array.isArray(value)) return value |
| | | return value |
| | | .map(item => prunePurchaseProductRecord(item)) |
| | | .filter(Boolean) |
| | | } |
| | | |
| | | 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: normalizeAndFilterPurchaseProductData(ledger.productData) || [] |
| | | })) |
| | | const unmatchedProducts = [] |
| | | |
| | | normalizeAndFilterPurchaseProductData(payload.productData).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: normalizeAndFilterPurchaseProductData(record.productData) |
| | | } |
| | | return Object.entries(normalizedRecord).reduce((result, [key, value]) => { |
| | | if (purchaseLedgerAllowedFieldKeys.has(key)) { |
| | | result[key] = value |
| | | } |
| | | return result |
| | | }, {}) |
| | | } |
| | | |
| | | const normalizeAttachmentMaterialsValue = (value) => { |
| | | if (value === null || value === undefined) return value |
| | | if (typeof value === 'string') return value |
| | | if (typeof value === 'number' || typeof value === 'boolean') return String(value) |
| | | try { |
| | | return JSON.stringify(value) |
| | | } catch (err) { |
| | | return String(value) |
| | | } |
| | | } |
| | | |
| | | const normalizePurchaseAttachmentMaterialsField = (value) => { |
| | | if (Array.isArray(value)) { |
| | | return value.map(item => normalizePurchaseAttachmentMaterialsField(item)) |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | return Object.entries(value).reduce((result, [key, item]) => { |
| | | if (key === 'attachmentMaterials') { |
| | | result[key] = normalizeAttachmentMaterialsValue(item) |
| | | } else { |
| | | result[key] = normalizePurchaseAttachmentMaterialsField(item) |
| | | } |
| | | return result |
| | | }, {}) |
| | | } |
| | | return value |
| | | } |
| | | |
| | | const normalizePurchaseNumericText = (text = '') => { |
| | | return String(text) |
| | | .replace(/[,\s,]/g, '') |
| | | .replace(/[¥¥元%]/g, '') |
| | | } |
| | | |
| | | const parsePurchaseNumberValue = (value) => { |
| | | if (typeof value === 'number') { |
| | | return Number.isFinite(value) ? value : null |
| | | } |
| | | if (typeof value === 'boolean') { |
| | | return value ? 1 : 0 |
| | | } |
| | | if (typeof value !== 'string') { |
| | | return null |
| | | } |
| | | const text = normalizePurchaseNumericText(value.trim()) |
| | | if (!text) return null |
| | | if (!/^[-+]?\d+(\.\d+)?$/.test(text)) return null |
| | | const numberValue = Number(text) |
| | | return Number.isFinite(numberValue) ? numberValue : null |
| | | } |
| | | |
| | | const normalizePurchaseIntegerFieldValue = (value) => { |
| | | if (value === null || value === undefined) return value |
| | | if (value === '') return null |
| | | const numberValue = parsePurchaseNumberValue(value) |
| | | if (numberValue === null) return null |
| | | return Math.trunc(numberValue) |
| | | } |
| | | |
| | | const normalizePurchaseDecimalFieldValue = (value) => { |
| | | if (value === null || value === undefined) return value |
| | | if (value === '') return null |
| | | const numberValue = parsePurchaseNumberValue(value) |
| | | return numberValue === null ? null : numberValue |
| | | } |
| | | |
| | | const normalizePurchaseBooleanFieldValue = (value) => { |
| | | if (value === null || value === undefined) return value |
| | | if (value === '') return null |
| | | if (typeof value === 'boolean') return value |
| | | if (typeof value === 'number') return value !== 0 |
| | | if (typeof value !== 'string') return null |
| | | |
| | | const text = value.trim().toLowerCase() |
| | | if (!text) return null |
| | | if (['true', '1', 'yes', 'y', '是', '已', 'checked'].includes(text)) return true |
| | | if (['false', '0', 'no', 'n', '否', '未', 'unchecked'].includes(text)) return false |
| | | return null |
| | | } |
| | | |
| | | const normalizePurchaseStringFieldValue = (value) => { |
| | | if (value === null || value === undefined) return value |
| | | if (typeof value === 'string') return value |
| | | if (typeof value === 'number' || typeof value === 'boolean') return String(value) |
| | | try { |
| | | return JSON.stringify(value) |
| | | } catch (err) { |
| | | return String(value) |
| | | } |
| | | } |
| | | |
| | | const normalizePurchaseStringArrayFieldValue = (value) => { |
| | | if (Array.isArray(value)) { |
| | | return value |
| | | .map(item => normalizePurchaseStringFieldValue(item)) |
| | | .filter(item => item !== null && item !== undefined && item !== '') |
| | | } |
| | | if (value === null || value === undefined || value === '') return [] |
| | | if (typeof value === 'string') { |
| | | const text = value.trim() |
| | | if (!text) return [] |
| | | if (/^\[.*\]$/.test(text)) { |
| | | try { |
| | | const parsedValue = JSON.parse(text) |
| | | if (Array.isArray(parsedValue)) { |
| | | return parsedValue |
| | | .map(item => normalizePurchaseStringFieldValue(item)) |
| | | .filter(item => item !== null && item !== undefined && item !== '') |
| | | } |
| | | } catch (err) { |
| | | // Keep as plain text when not valid JSON array. |
| | | } |
| | | } |
| | | const splitValues = text |
| | | .split(/[,\n,]/) |
| | | .map(item => item.trim()) |
| | | .filter(Boolean) |
| | | return splitValues.length > 1 ? splitValues : [text] |
| | | } |
| | | const normalizedValue = normalizePurchaseStringFieldValue(value) |
| | | return normalizedValue === null || normalizedValue === undefined || normalizedValue === '' |
| | | ? [] |
| | | : [normalizedValue] |
| | | } |
| | | |
| | | const normalizePurchaseObjectArrayFieldValue = (value) => { |
| | | if (Array.isArray(value)) { |
| | | return value.filter(item => item && typeof item === 'object') |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | return [value] |
| | | } |
| | | return [] |
| | | } |
| | | |
| | | const normalizePurchaseValueByFieldKey = (fieldKey, value) => { |
| | | if (fieldKey === 'attachmentMaterials') return normalizeAttachmentMaterialsValue(value) |
| | | if (purchaseIntegerFieldKeys.has(fieldKey)) return normalizePurchaseIntegerFieldValue(value) |
| | | if (purchaseDecimalFieldKeys.has(fieldKey)) return normalizePurchaseDecimalFieldValue(value) |
| | | if (purchaseBooleanFieldKeys.has(fieldKey)) return normalizePurchaseBooleanFieldValue(value) |
| | | if (purchaseStringArrayFieldKeys.has(fieldKey)) return normalizePurchaseStringArrayFieldValue(value) |
| | | if (purchaseObjectArrayFieldKeys.has(fieldKey)) return normalizePurchaseObjectArrayFieldValue(value) |
| | | if (purchaseStringFieldKeys.has(fieldKey)) return normalizePurchaseStringFieldValue(value) |
| | | return value |
| | | } |
| | | |
| | | const normalizePurchasePayloadFieldTypes = (value, fieldKey = '') => { |
| | | if (purchaseGenericArrayFieldKeys.has(fieldKey)) { |
| | | if (Array.isArray(value)) { |
| | | return value.map(item => normalizePurchasePayloadFieldTypes(item)) |
| | | } |
| | | if (value && typeof value === 'object') { |
| | | return [normalizePurchasePayloadFieldTypes(value)] |
| | | } |
| | | return [] |
| | | } |
| | | |
| | | if (purchaseStringArrayFieldKeys.has(fieldKey)) { |
| | | return normalizePurchaseStringArrayFieldValue(value) |
| | | } |
| | | |
| | | if (purchaseObjectArrayFieldKeys.has(fieldKey)) { |
| | | return normalizePurchaseObjectArrayFieldValue(value) |
| | | } |
| | | |
| | | if (Array.isArray(value)) { |
| | | return value.map(item => normalizePurchasePayloadFieldTypes(item)) |
| | | } |
| | | |
| | | if (value && typeof value === 'object') { |
| | | return Object.entries(value).reduce((result, [key, item]) => { |
| | | result[key] = normalizePurchasePayloadFieldTypes(item, key) |
| | | return result |
| | | }, {}) |
| | | } |
| | | |
| | | return normalizePurchaseValueByFieldKey(fieldKey, value) |
| | | } |
| | | |
| | | const sanitizePurchasePayloadForSubmit = (payload, businessType) => { |
| | | if (businessType !== 'purchase_ledger' || !payload || typeof payload !== 'object') return payload |
| | | |
| | | let sanitized = mergeLegacyProductDataIntoLedgers(Array.isArray(payload) ? [...payload] : { ...payload }) |
| | | sanitized = normalizePurchaseAttachmentMaterialsField(sanitized) |
| | | sanitized = normalizePurchasePayloadFieldTypes(sanitized) |
| | | if (Array.isArray(sanitized.purchaseLedgers)) { |
| | | sanitized.purchaseLedgers = sanitized.purchaseLedgers.map(filterPurchaseLedgerRecord) |
| | | } |
| | | if (Array.isArray(sanitized.productData)) { |
| | | sanitized.productData = normalizeAndFilterPurchaseProductData(sanitized.productData) |
| | | } |
| | | if (Array.isArray(sanitized.productData) && !sanitized.productData.length) { |
| | | delete sanitized.productData |
| | | } |
| | | |
| | | 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) { |
| | |
| | | }) |
| | | } |
| | | |
| | | const handleFileChange = (file) => { |
| | | const handleFileChange = (file, fileList = []) => { |
| | | if (!file) return |
| | | const rawFile = file.raw |
| | | if (rawFile) { |
| | | // 限制文件大小,例如 10MB |
| | | const nextFiles = currentAssistant.value.allowMultipleFileUpload |
| | | ? fileList.map(item => item.raw).filter(Boolean) |
| | | : [file.raw].filter(Boolean) |
| | | |
| | | const validFiles = nextFiles.filter(rawFile => { |
| | | const isLt10M = rawFile.size / 1024 / 1024 < 10 |
| | | if (!isLt10M) { |
| | | ElMessage.error('文件大小不能超过 10MB!') |
| | | return |
| | | ElMessage.error(`${rawFile.name} 文件大小不能超过 10MB!`) |
| | | } |
| | | selectedFile.value = rawFile |
| | | } |
| | | return isLt10M |
| | | }) |
| | | |
| | | clearSelectedFiles() |
| | | selectedFiles.value = validFiles |
| | | uploadFileList.value = fileList.filter(item => item.raw && validFiles.includes(item.raw)) |
| | | selectedFileSnapshots.value = validFiles.map((rawFile, index) => createLocalFileSnapshot(rawFile, index)) |
| | | } |
| | | |
| | | const removeSelectedFile = () => { |
| | | selectedFile.value = null |
| | | const removeSelectedFile = (index) => { |
| | | const [removedSnapshot] = selectedFileSnapshots.value.splice(index, 1) |
| | | revokeLocalFileSnapshots(removedSnapshot ? [removedSnapshot] : []) |
| | | selectedFiles.value.splice(index, 1) |
| | | uploadFileList.value.splice(index, 1) |
| | | } |
| | | |
| | | const analyzeFile = async (file, message = '') => { |
| | | const analyzeFiles = async (files, message = '', localFileSnapshots = []) => { |
| | | const uploadFiles = Array.isArray(files) ? files : [files].filter(Boolean) |
| | | if (!uploadFiles.length) return |
| | | if (isSending.value) return |
| | | isSending.value = true |
| | | currentAbortController.value = new AbortController() |
| | | |
| | | const userMsg = message ? `${message}\n[上传文件分析] ${file.name}` : `[上传文件分析] ${file.name}` |
| | | const fileNames = uploadFiles.map(file => file.name).join('、') |
| | | const userMsg = message ? `${message}\n[上传文件分析] ${fileNames}` : `[上传文件分析] ${fileNames}` |
| | | messages.value.push({ |
| | | isUser: true, |
| | | content: userMsg, |
| | | htmlContent: convertTextToHtml(userMsg), |
| | | isTyping: false |
| | | isTyping: false, |
| | | localUploadFiles: Array.isArray(localFileSnapshots) ? localFileSnapshots : [] |
| | | }) |
| | | |
| | | const botMsgIndex = messages.value.length |
| | |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | tableData: null, |
| | | payloadTreeData: null, |
| | | payloadHiddenData: null |
| | | }) |
| | | |
| | | outputState.value[botMsgIndex] = { |
| | |
| | | scrollToBottom() |
| | | |
| | | const formData = new FormData() |
| | | formData.append('file', file) |
| | | const fileFieldName = currentAssistant.value.allowMultipleFileUpload ? 'files' : 'file' |
| | | uploadFiles.forEach(file => formData.append(fileFieldName, file)) |
| | | formData.append('memoryId', uuid.value) |
| | | if (message.trim()) { |
| | | formData.append('message', message.trim()) |
| | | } |
| | | |
| | | request.post(`${currentAssistant.value.apiBase}/analyze-file`, formData, { |
| | | const analyzeUrl = currentAssistant.value.fileAnalyzeUrl || `${currentAssistant.value.apiBase}/analyze-file` |
| | | request.post(analyzeUrl, formData, { |
| | | headers: { |
| | | 'Content-Type': 'multipart/form-data' |
| | | }, |
| | |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | |
| | | const extracted = extractEmbeddedSuccessJson(currentMsg.content) |
| | | if (extracted) { |
| | | applyStructuredMessageData(currentMsg, extracted.data, botMsgIndex, !outputState.value[botMsgIndex].hasRenderedChart) |
| | | } |
| | | |
| | | // 最终解析确保图表渲染 |
| | | if (currentMsg.chartOptions && !outputState.value[botMsgIndex].hasRenderedChart) { |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | |
| | | }).catch(err => { |
| | | if (err.name === 'CanceledError' || err.name === 'AbortError') { |
| | | console.log('Analysis aborted by user') |
| | | isSending.value = false |
| | | currentAbortController.value = null |
| | | return |
| | | } |
| | | console.error('File analysis error:', err) |
| | |
| | | |
| | | const sendMessage = () => { |
| | | const msg = inputMessage.value?.trim() || '' |
| | | if ((msg || selectedFile.value) && !isSending.value) { |
| | | if (selectedFile.value) { |
| | | analyzeFile(selectedFile.value, msg) |
| | | selectedFile.value = null |
| | | if ((msg || selectedFiles.value.length) && !isSending.value) { |
| | | if (selectedFiles.value.length) { |
| | | const localFileSnapshots = selectedFileSnapshots.value |
| | | analyzeFiles([...selectedFiles.value], msg, localFileSnapshots) |
| | | clearSelectedFiles({ releaseSnapshots: false }) |
| | | } else { |
| | | sendRequest(msg) |
| | | } |
| | |
| | | chartOptions: null, |
| | | chartRenderReady: false, |
| | | type: '', |
| | | tableData: null |
| | | tableData: null, |
| | | payloadTreeData: null, |
| | | payloadHiddenData: null |
| | | } |
| | | messages.value.push(botMsg) |
| | | |
| | |
| | | |
| | | const parsedData = extractJson(fullText) |
| | | if (parsedData) { |
| | | currentMsg.type = parsedData.type || '' |
| | | if (currentMsg.type === 'todo_list' && parsedData.data) { |
| | | currentMsg.tableData = parsedData.data |
| | | } |
| | | if (parsedData.charts && Object.keys(parsedData.charts).length > 0) { |
| | | currentMsg.chartOptions = parsedData.charts |
| | | currentMsg.chartRenderReady = true |
| | | // 每次解析成功都尝试渲染/更新图表,以支持流式更新 |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | } |
| | | applyStructuredMessageData(currentMsg, parsedData, botMsgIndex, true) |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | const finalParsed = extractJson(currentMsg.content) |
| | | if (finalParsed) { |
| | | currentMsg.type = finalParsed.type || '' |
| | | if (currentMsg.type === 'todo_list' && finalParsed.data) { |
| | | currentMsg.tableData = finalParsed.data |
| | | } |
| | | if (finalParsed.charts && Object.keys(finalParsed.charts).length > 0) { |
| | | currentMsg.chartOptions = finalParsed.charts |
| | | currentMsg.chartRenderReady = true |
| | | renderCharts(botMsgIndex, currentMsg.chartOptions) |
| | | } |
| | | applyStructuredMessageData(currentMsg, finalParsed, botMsgIndex) |
| | | } |
| | | } |
| | | }).catch(err => { |
| | |
| | | |
| | | // 尝试提取 JSON 部分 |
| | | const extracted = extractEmbeddedSuccessJson(output) |
| | | const startIdx = extracted ? extracted.startIdx : output.indexOf('{"success"') |
| | | const startMatch = output.match(/\{\s*"success"\s*:/) |
| | | const startIdx = extracted ? extracted.startIdx : (startMatch?.index ?? -1) |
| | | |
| | | // 如果还在代码块中且未结束,显示提示文字 |
| | | if (state && ((state.jsonBlockStartPos !== -1) || (state.jsBlockStartPos !== -1)) && state.blockEndPos === -1) { |
| | |
| | | } |
| | | |
| | | if (parsed.description) { |
| | | display = parsed.description |
| | | display = parsed.action === 'confirm_required' |
| | | ? getPurchaseConfirmDescription(parsed) |
| | | : parsed.description |
| | | } |
| | | |
| | | if (!display) { |
| | |
| | | } |
| | | |
| | | if (parsed.description) { |
| | | display = parsed.description |
| | | display = parsed.action === 'confirm_required' |
| | | ? getPurchaseConfirmDescription(parsed) |
| | | : parsed.description |
| | | } |
| | | |
| | | if (!display) { |
| | |
| | | height: 100%; |
| | | } |
| | | :deep(.el-drawer__header) { |
| | | margin-bottom: 0; |
| | | padding: 0; |
| | | margin-bottom: 0 !important; |
| | | padding: 0 !important; |
| | | border-bottom: 1px solid rgba(255, 255, 255, 0.12); |
| | | background: $gradient-dark; |
| | | color: #fff; |
| | | } |
| | |
| | | justify-content: space-between; |
| | | align-items: center; |
| | | width: 100%; |
| | | padding: 18px 20px; |
| | | padding: 12px 18px; |
| | | background: $gradient-dark; |
| | | position: relative; |
| | | overflow: hidden; |
| | |
| | | opacity: 1; |
| | | } |
| | | } |
| | | |
| | | :deep(.header-action-btn--text) { |
| | | width: auto !important; |
| | | min-width: 104px; |
| | | padding: 8px 14px !important; |
| | | font-size: 14px; |
| | | font-weight: 600; |
| | | white-space: nowrap; |
| | | } |
| | | } |
| | | |
| | | .assistant-switcher { |
| | |
| | | top: 0; |
| | | left: 0; |
| | | right: 0; |
| | | height: 240px; |
| | | height: 128px; |
| | | background: linear-gradient(180deg, rgba(0, 85, 212, 0.06) 0%, transparent 100%); |
| | | pointer-events: none; |
| | | } |
| | |
| | | border-radius: 2px; |
| | | } |
| | | } |
| | | |
| | | .message-local-file-list { |
| | | margin-top: 8px; |
| | | display: grid; |
| | | gap: 8px; |
| | | max-width: 100%; |
| | | } |
| | | |
| | | .message-local-file-item { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 10px; |
| | | padding: 8px 10px; |
| | | border-radius: 10px; |
| | | border: 1px solid rgba(88, 117, 255, 0.2); |
| | | background: rgba(255, 255, 255, 0.9); |
| | | max-width: 100%; |
| | | |
| | | &.clickable { |
| | | cursor: pointer; |
| | | transition: all 0.2s ease; |
| | | |
| | | &:hover { |
| | | border-color: rgba(44, 109, 255, 0.38); |
| | | background: rgba(243, 247, 255, 0.96); |
| | | } |
| | | } |
| | | } |
| | | |
| | | .message-local-file-thumb { |
| | | width: 40px; |
| | | height: 40px; |
| | | border-radius: 6px; |
| | | overflow: hidden; |
| | | flex-shrink: 0; |
| | | border: 1px solid rgba(124, 148, 255, 0.26); |
| | | background: #f4f7ff; |
| | | cursor: zoom-in; |
| | | |
| | | :deep(.el-image__inner) { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | } |
| | | |
| | | .message-local-file-icon { |
| | | font-size: 20px; |
| | | color: $primary-blue; |
| | | flex-shrink: 0; |
| | | } |
| | | |
| | | .message-local-file-meta { |
| | | min-width: 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .message-local-file-name { |
| | | font-size: 12px; |
| | | color: #1f2a44; |
| | | font-weight: 600; |
| | | white-space: nowrap; |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | |
| | | &.clickable { |
| | | color: $primary-blue; |
| | | cursor: pointer; |
| | | |
| | | &:hover { |
| | | text-decoration: underline; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .message-local-file-size { |
| | | font-size: 11px; |
| | | color: #7f8ba1; |
| | | line-height: 1.2; |
| | | } |
| | | } |
| | | |
| | | &.bot-message { |
| | |
| | | } |
| | | } |
| | | |
| | | .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%); |
| | |
| | | background: #fff; |
| | | } |
| | | |
| | | .selected-file-list { |
| | | display: flex; |
| | | flex-wrap: wrap; |
| | | gap: 8px; |
| | | margin-bottom: 12px; |
| | | } |
| | | |
| | | .selected-file-tag { |
| | | display: flex; |
| | | align-items: center; |
| | |
| | | border: 1px solid rgba(0, 85, 212, 0.2); |
| | | border-radius: 10px; |
| | | padding: 8px 12px; |
| | | margin-bottom: 12px; |
| | | gap: 10px; |
| | | width: fit-content; |
| | | max-width: 100%; |
| | |
| | | font-size: 18px; |
| | | } |
| | | |
| | | .selected-file-thumb { |
| | | width: 30px; |
| | | height: 30px; |
| | | border-radius: 6px; |
| | | overflow: hidden; |
| | | border: 1px solid rgba(0, 85, 212, 0.2); |
| | | flex-shrink: 0; |
| | | cursor: zoom-in; |
| | | |
| | | :deep(.el-image__inner) { |
| | | width: 100%; |
| | | height: 100%; |
| | | } |
| | | } |
| | | |
| | | .selected-file-meta { |
| | | min-width: 0; |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 2px; |
| | | } |
| | | |
| | | .file-name { |
| | | font-size: 13px; |
| | | color: $deep-blue; |
| | |
| | | overflow: hidden; |
| | | text-overflow: ellipsis; |
| | | font-weight: 600; |
| | | } |
| | | |
| | | .file-size { |
| | | font-size: 11px; |
| | | color: #5f86b4; |
| | | line-height: 1.1; |
| | | } |
| | | |
| | | .remove-file { |
| | |
| | | |
| | | .chat-hero { |
| | | display: grid; |
| | | grid-template-columns: 164px minmax(0, 1fr); |
| | | gap: 18px; |
| | | align-items: start; |
| | | padding: 14px 18px 6px; |
| | | grid-template-columns: 176px minmax(0, 1fr); |
| | | gap: 14px; |
| | | align-items: stretch; |
| | | padding: 8px 18px 4px; |
| | | |
| | | &.compact { |
| | | grid-template-columns: 122px minmax(0, 1fr); |
| | | gap: 12px; |
| | | padding: 8px 18px 2px; |
| | | grid-template-columns: 132px minmax(0, 1fr); |
| | | gap: 10px; |
| | | padding: 4px 18px 2px; |
| | | } |
| | | } |
| | | |
| | | .assistant-stand { |
| | | position: relative; |
| | | min-height: 252px; |
| | | min-height: 206px; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-direction: column; |
| | | padding-top: 18px; |
| | | padding-top: 8px; |
| | | overflow: hidden; |
| | | |
| | | &.compact { |
| | | min-height: 176px; |
| | | padding-top: 8px; |
| | | min-height: 160px; |
| | | padding-top: 4px; |
| | | } |
| | | |
| | | &.thinking { |
| | | .assistant-halo { |
| | | opacity: 1; |
| | | transform: scale(1.08); |
| | | filter: blur(8px); |
| | | transform: scale(1.12); |
| | | filter: blur(9px); |
| | | } |
| | | |
| | | .assistant-scan-ring { |
| | | opacity: 1; |
| | | animation-duration: 1.6s; |
| | | opacity: 0.95; |
| | | animation-duration: 1.5s; |
| | | } |
| | | |
| | | .assistant-orbit { |
| | | opacity: 1; |
| | | opacity: 0.76; |
| | | } |
| | | |
| | | .assistant-bot { |
| | | transform: translateY(-4px) scale(1.02); |
| | | .assistant-model-shell { |
| | | transform: translateY(-5px) scale(1.02); |
| | | } |
| | | |
| | | .assistant-bot-head { |
| | | box-shadow: 0 0 30px rgba(80, 157, 255, 0.36); |
| | | .assistant-model-cut { |
| | | animation-duration: 2.2s; |
| | | } |
| | | |
| | | .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-model-img { |
| | | filter: saturate(1.06) drop-shadow(0 18px 20px rgba(22, 48, 80, 0.22)); |
| | | } |
| | | |
| | | .assistant-status { |
| | |
| | | animation: thinkingDot 1s ease-in-out infinite; |
| | | } |
| | | |
| | | .assistant-base-lg { |
| | | animation-duration: 1.8s; |
| | | } |
| | | |
| | | .assistant-base-md { |
| | | animation-duration: 1.5s; |
| | | } |
| | | |
| | | .assistant-base-sm { |
| | | box-shadow: 0 0 24px rgba(255, 93, 122, 0.48); |
| | | box-shadow: 0 0 24px rgba(30, 91, 255, 0.36); |
| | | animation-duration: 1.25s; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .assistant-halo { |
| | | position: absolute; |
| | | top: 22px; |
| | | width: 130px; |
| | | height: 130px; |
| | | top: 24px; |
| | | width: 146px; |
| | | height: 146px; |
| | | 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%); |
| | | background: radial-gradient(circle, rgba(31, 122, 114, 0.26) 0%, rgba(30, 91, 255, 0.2) 42%, rgba(109, 65, 237, 0.12) 66%, transparent 80%); |
| | | filter: blur(6px); |
| | | opacity: 0.82; |
| | | opacity: 0.78; |
| | | transition: all 0.35s ease; |
| | | } |
| | | |
| | | .assistant-scan-ring { |
| | | position: absolute; |
| | | top: 40px; |
| | | width: 132px; |
| | | height: 132px; |
| | | top: 44px; |
| | | width: 136px; |
| | | height: 136px; |
| | | border-radius: 50%; |
| | | border: 1px solid rgba(90, 159, 224, 0.22); |
| | | border: 1px solid rgba(67, 145, 223, 0.24); |
| | | box-shadow: inset 0 0 16px rgba(255, 255, 255, 0.25); |
| | | opacity: 0.55; |
| | | opacity: 0.52; |
| | | animation: scanRing 4s linear infinite; |
| | | } |
| | | |
| | | .assistant-orbit { |
| | | position: absolute; |
| | | top: 52px; |
| | | width: 150px; |
| | | height: 150px; |
| | | width: 156px; |
| | | height: 156px; |
| | | border-radius: 50%; |
| | | border: 1px dashed rgba(92, 135, 255, 0.22); |
| | | opacity: 0.45; |
| | | border: 1px dashed rgba(92, 135, 255, 0.24); |
| | | opacity: 0.42; |
| | | } |
| | | |
| | | .assistant-orbit-a { |
| | | animation: orbitRotate 8s linear infinite; |
| | | animation: orbitRotate 8.6s linear infinite; |
| | | } |
| | | |
| | | .assistant-orbit-b { |
| | | width: 118px; |
| | | height: 118px; |
| | | width: 124px; |
| | | height: 124px; |
| | | top: 68px; |
| | | border-color: rgba(255, 108, 150, 0.22); |
| | | animation: orbitRotateReverse 5.6s linear infinite; |
| | | border-color: rgba(31, 122, 114, 0.24); |
| | | animation: orbitRotateReverse 6.2s linear infinite; |
| | | } |
| | | |
| | | .assistant-bot { |
| | | .assistant-model-shell { |
| | | position: relative; |
| | | z-index: 1; |
| | | width: 148px; |
| | | height: 178px; |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | align-items: flex-end; |
| | | justify-content: center; |
| | | margin-top: 12px; |
| | | margin-top: 4px; |
| | | 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%; |
| | | bottom: 2px; |
| | | width: 164px; |
| | | height: 42px; |
| | | transform: translateX(-50%); |
| | | background: linear-gradient(135deg, #54bfff, #7a41ff); |
| | | box-shadow: 0 0 14px rgba(84, 191, 255, 0.65); |
| | | border-radius: 50%; |
| | | background: radial-gradient( |
| | | ellipse at center, |
| | | rgba(43, 126, 211, 0.32) 0%, |
| | | rgba(43, 126, 211, 0.14) 46%, |
| | | rgba(43, 126, 211, 0) 74% |
| | | ); |
| | | filter: blur(2.6px); |
| | | animation: baseGlow 4.6s ease-in-out infinite; |
| | | z-index: 1; |
| | | } |
| | | |
| | | &::after { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 50%; |
| | | bottom: 10px; |
| | | width: 138px; |
| | | height: 28px; |
| | | transform: translateX(-50%); |
| | | border-radius: 50%; |
| | | border: 1px solid rgba(36, 116, 198, 0.6); |
| | | box-shadow: |
| | | inset 0 0 0 1px rgba(255, 255, 255, 0.58), |
| | | 0 0 22px rgba(42, 116, 196, 0.24); |
| | | animation: basePulse 3.2s ease-in-out infinite; |
| | | z-index: 4; |
| | | } |
| | | } |
| | | |
| | | .assistant-bot-antenna-left { |
| | | left: 36px; |
| | | transform: rotate(-14deg); |
| | | } |
| | | |
| | | .assistant-bot-antenna-right { |
| | | right: 36px; |
| | | transform: rotate(14deg); |
| | | } |
| | | |
| | | .assistant-bot-head { |
| | | .assistant-model-cut { |
| | | position: relative; |
| | | width: 132px; |
| | | height: 178px; |
| | | z-index: 6; |
| | | display: flex; |
| | | align-items: flex-end; |
| | | justify-content: center; |
| | | transform-origin: center 84%; |
| | | animation: avatarFloat 3.2s ease-in-out infinite; |
| | | } |
| | | |
| | | .assistant-model-img { |
| | | width: 100%; |
| | | height: 100%; |
| | | object-fit: contain; |
| | | object-position: center bottom; |
| | | display: block; |
| | | filter: saturate(1.03) drop-shadow(0 14px 18px rgba(22, 49, 79, 0.2)); |
| | | transition: filter 0.35s ease; |
| | | } |
| | | |
| | | .assistant-model-fallback { |
| | | 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); |
| | | height: 92px; |
| | | border-radius: 24px; |
| | | color: #fff; |
| | | background: linear-gradient(145deg, rgba(31, 122, 114, 0.9), rgba(30, 91, 255, 0.9)); |
| | | border: 1px solid rgba(255, 255, 255, 0.3); |
| | | box-shadow: 0 12px 24px rgba(31, 85, 173, 0.22); |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | } |
| | | |
| | | .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 { |
| | | .assistant-base { |
| | | position: absolute; |
| | | left: 50%; |
| | | bottom: 16px; |
| | | width: 22px; |
| | | height: 4px; |
| | | bottom: 8px; |
| | | 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); |
| | | border: 1px solid rgba(36, 116, 198, 0.28); |
| | | background: radial-gradient( |
| | | ellipse at center, |
| | | rgba(255, 255, 255, 0.94) 0%, |
| | | rgba(81, 164, 233, 0.16) 58%, |
| | | rgba(30, 91, 255, 0.06) 100% |
| | | ); |
| | | box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.2); |
| | | } |
| | | |
| | | .assistant-status { |
| | | position: relative; |
| | | z-index: 1; |
| | | margin-top: 14px; |
| | | padding: 6px 12px; |
| | | margin-top: 7px; |
| | | padding: 5px 10px; |
| | | border-radius: 999px; |
| | | font-size: 12px; |
| | | font-size: 11px; |
| | | font-weight: 600; |
| | | color: $deep-blue; |
| | | background: rgba(255, 255, 255, 0.95); |
| | |
| | | } |
| | | |
| | | .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; |
| | | pointer-events: none; |
| | | } |
| | | |
| | | .assistant-base-md { |
| | | bottom: 6px; |
| | | width: 88px; |
| | | height: 20px; |
| | | border-color: rgba(255, 93, 122, 0.34); |
| | | bottom: 15px; |
| | | width: 104px; |
| | | height: 22px; |
| | | border-color: rgba(36, 116, 198, 0.48); |
| | | animation: basePulse 2.8s ease-in-out infinite; |
| | | } |
| | | |
| | | .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)); |
| | | bottom: 20px; |
| | | width: 68px; |
| | | height: 14px; |
| | | background: linear-gradient(90deg, rgba(31, 122, 114, 0.82), rgba(45, 124, 255, 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); |
| | | } |
| | | box-shadow: 0 0 18px rgba(45, 124, 255, 0.34); |
| | | animation: basePulse 2.2s ease-in-out infinite; |
| | | } |
| | | |
| | | @keyframes orbitRotate { |
| | |
| | | } |
| | | } |
| | | |
| | | .assistant-base-lg { |
| | | width: 142px; |
| | | height: 32px; |
| | | animation: basePulse 3.4s ease-in-out infinite; |
| | | |
| | | &::before { |
| | | content: ''; |
| | | position: absolute; |
| | | left: 50%; |
| | | top: 50%; |
| | | width: 130px; |
| | | height: 130px; |
| | | transform: translate(-50%, -50%); |
| | | border-radius: 50%; |
| | | background: conic-gradient( |
| | | from 180deg, |
| | | transparent 0deg, |
| | | rgba(36, 116, 198, 0.65) 48deg, |
| | | transparent 114deg, |
| | | rgba(36, 116, 198, 0.55) 212deg, |
| | | transparent 286deg, |
| | | rgba(31, 122, 114, 0.45) 334deg, |
| | | transparent 360deg |
| | | ); |
| | | -webkit-mask: radial-gradient(circle, transparent 61%, #000 62%, #000 68%, transparent 70%); |
| | | mask: radial-gradient(circle, transparent 61%, #000 62%, #000 68%, transparent 70%); |
| | | opacity: 0.62; |
| | | animation: baseSpin 9s linear infinite; |
| | | } |
| | | } |
| | | |
| | | @keyframes avatarFloat { |
| | | 0%, |
| | | 100% { |
| | | transform: translateY(0); |
| | | } |
| | | 50% { |
| | | transform: translateY(-7px); |
| | | } |
| | | } |
| | | |
| | | @keyframes basePulse { |
| | | 0%, |
| | | 100% { |
| | | transform: translateX(-50%) scale(1); |
| | | opacity: 0.88; |
| | | } |
| | | 50% { |
| | | transform: translateX(-50%) scale(1.05); |
| | | opacity: 0.98; |
| | | } |
| | | } |
| | | |
| | | @keyframes baseSpin { |
| | | from { |
| | | transform: translate(-50%, -50%) rotate(0deg); |
| | | } |
| | | to { |
| | | transform: translate(-50%, -50%) rotate(360deg); |
| | | } |
| | | } |
| | | |
| | | @keyframes baseGlow { |
| | | 0%, |
| | | 100% { |
| | | transform: translateX(-50%) scaleX(1); |
| | | opacity: 0.82; |
| | | } |
| | | 50% { |
| | | transform: translateX(-50%) scaleX(1.06); |
| | | opacity: 0.96; |
| | | } |
| | | } |
| | | |
| | | .welcome-card { |
| | | position: relative; |
| | | padding: 14px 14px 12px; |
| | | align-self: stretch; |
| | | min-height: 206px; |
| | | padding: 9px 10px 8px; |
| | | border-radius: 16px; |
| | | background: |
| | | linear-gradient(#fff, #fff) padding-box, |
| | |
| | | box-shadow: 0 16px 36px rgba(0, 85, 212, 0.12); |
| | | |
| | | &.compact { |
| | | padding: 10px 12px; |
| | | min-height: 160px; |
| | | padding: 8px 9px 7px; |
| | | border-radius: 12px; |
| | | box-shadow: 0 8px 16px rgba(0, 85, 212, 0.07); |
| | | |
| | |
| | | } |
| | | |
| | | .welcome-title { |
| | | font-size: 17px; |
| | | line-height: 1.3; |
| | | font-size: 16px; |
| | | line-height: 1.25; |
| | | |
| | | br { |
| | | display: none; |
| | |
| | | } |
| | | |
| | | .welcome-desc { |
| | | margin-top: 6px; |
| | | font-size: 12px; |
| | | line-height: 1.55; |
| | | margin-top: 4px; |
| | | font-size: 11px; |
| | | line-height: 1.5; |
| | | } |
| | | |
| | | .quick-prompt-list { |
| | | margin-top: 10px; |
| | | gap: 6px; |
| | | margin-top: 8px; |
| | | gap: 5px; |
| | | } |
| | | |
| | | .quick-prompt-btn { |
| | | padding: 8px 10px; |
| | | font-size: 12px; |
| | | padding: 7px 9px; |
| | | font-size: 11px; |
| | | border-radius: 7px; |
| | | } |
| | | |
| | | .more-prompts-btn { |
| | | margin-top: 8px; |
| | | font-size: 12px; |
| | | margin-top: 6px; |
| | | font-size: 11px; |
| | | } |
| | | } |
| | | } |
| | | |
| | | .welcome-eyebrow { |
| | | font-size: 11px; |
| | | font-size: 10px; |
| | | font-weight: 700; |
| | | letter-spacing: 2px; |
| | | color: rgba(0, 85, 212, 0.58); |
| | | margin-bottom: 8px; |
| | | margin-bottom: 5px; |
| | | } |
| | | |
| | | .welcome-title { |
| | | margin: 0; |
| | | font-size: 26px; |
| | | line-height: 1.2; |
| | | font-size: 20px; |
| | | line-height: 1.15; |
| | | font-weight: 800; |
| | | color: #172033; |
| | | |
| | | br { |
| | | display: none; |
| | | } |
| | | } |
| | | |
| | | .welcome-desc { |
| | | margin: 10px 0 0; |
| | | font-size: 13px; |
| | | line-height: 1.7; |
| | | margin: 5px 0 0; |
| | | font-size: 12px; |
| | | line-height: 1.5; |
| | | color: #5f6980; |
| | | } |
| | | |
| | | .quick-prompt-list { |
| | | display: grid; |
| | | gap: 8px; |
| | | margin-top: 14px; |
| | | gap: 6px; |
| | | margin-top: 8px; |
| | | } |
| | | |
| | | .quick-prompt-btn { |
| | | width: 100%; |
| | | border: none; |
| | | border-radius: 10px; |
| | | padding: 11px 14px; |
| | | border-radius: 9px; |
| | | padding: 7px 10px; |
| | | text-align: left; |
| | | font-size: 13px; |
| | | font-size: 12px; |
| | | font-weight: 600; |
| | | color: #fff; |
| | | cursor: pointer; |
| | |
| | | } |
| | | |
| | | .more-prompts-btn { |
| | | margin-top: 10px; |
| | | padding: 0 12px; |
| | | height: 32px; |
| | | margin-top: 6px; |
| | | padding: 0 10px; |
| | | height: 26px; |
| | | 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-size: 12px; |
| | | font-weight: 600; |
| | | cursor: pointer; |
| | | display: inline-flex; |
| | |
| | | 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)); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | .welcome-title { |
| | | font-size: 21px; |
| | | } |
| | | |
| | | .hero-dot-grid { |
| | | grid-template-columns: repeat(12, 1fr); |
| | | gap: 6px; |
| | | padding: 0 14px 12px; |
| | | } |
| | | |
| | | .message-list { |