AIChat.vue 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449
  1. <template>
  2. <div class="border-gray-200 border rounded p-3 h-full flex flex-col" style="min-height: 750px; max-height: 750px;">
  3. <div class="text-sm font-medium mb-3">DeepSeek大模型CRM数据分析</div>
  4. <!-- Chat messages container with fixed height and scrolling -->
  5. <div class="mb-3 border-gray-200 border rounded overflow-y-auto flex-grow" style="min-height: 0;">
  6. <div class="p-3 flex flex-col gap-3">
  7. <div v-for="(message, index) in messages" :key="index" class="flex" :class="{'justify-end': message.role === 'user', 'justify-start': message.role === 'assistant'}">
  8. <div v-if="message.role === 'assistant'" class="flex items-start gap-2">
  9. <el-avatar :size="24" class="bg-gray-200 flex items-center justify-center">
  10. <el-icon><ChatLineRound /></el-icon>
  11. </el-avatar>
  12. <div class="border-gray-200 border rounded p-2 text-sm max-w-[80%] bg-gray-50 relative">
  13. <div class="markdown-body" v-html="renderMarkdown(message.content)"></div>
  14. <div v-if="message.loading" class="loading-dots">
  15. <span></span>
  16. <span></span>
  17. <span></span>
  18. </div>
  19. <el-button
  20. v-if="!message.loading && message.role === 'assistant' && index > 0"
  21. @click="exportToWord(message.content)"
  22. size="small"
  23. type="text"
  24. class="absolute -bottom-3 -right-3"
  25. :icon="Download"
  26. />
  27. </div>
  28. </div>
  29. <div v-if="message.role === 'user'" class="flex items-start gap-2 justify-end">
  30. <div class="border-gray-200 border rounded p-2 text-sm max-w-[80%] bg-blue-50 markdown-body" v-html="renderMarkdown(message.content)">
  31. </div>
  32. <el-avatar :size="24" class="bg-gray-200 flex items-center justify-center">
  33. <el-icon><User /></el-icon>
  34. </el-avatar>
  35. </div>
  36. </div>
  37. </div>
  38. </div>
  39. <!-- Data source selection -->
  40. <div class="mb-3 flex gap-2">
  41. <div>
  42. <span class="text-sm mr-2">数据来源</span>
  43. <el-select v-model="dataSource" size="small" style="width: 120px">
  44. <el-option label="系统表" value="system" />
  45. <el-option label="自定义报表" value="custom" />
  46. <el-option label="本地上传" value="upload" />
  47. <el-option label="自由交流" value="free" />
  48. </el-select>
  49. </div>
  50. <div v-if="dataSource === 'system'">
  51. <el-select v-model="systemTable" size="small" style="width: 120px">
  52. <el-option label="线索" value="clue" />
  53. <el-option label="商机" value="business_opportunity" />
  54. <el-option label="客户" value="customer" />
  55. <el-option label="联系人" value="contact" />
  56. <el-option label="合同" value="contract" />
  57. <el-option label="销售订单" value="order" />
  58. <el-option label="产品" value="product" />
  59. </el-select>
  60. </div>
  61. <div v-if="dataSource === 'upload'">
  62. <el-upload
  63. :show-file-list="false"
  64. :before-upload="beforeUpload"
  65. :http-request="handleUpload"
  66. accept=".xlsx,.xls"
  67. >
  68. <el-button size="small" type="primary">上传Excel</el-button> <span style="margin-left:5px;color:orange">{{ uploadedFilePath?'上传成功':'' }}</span>
  69. </el-upload>
  70. </div>
  71. <div class="ml-auto">
  72. <span class="text-sm mr-2">时间段</span>
  73. <el-date-picker
  74. v-model="dateRange"
  75. type="daterange"
  76. size="small"
  77. range-separator="/"
  78. start-placeholder="开始日期"
  79. end-placeholder="结束日期"
  80. format="YYYY-MM-DD"
  81. />
  82. </div>
  83. </div>
  84. <!-- Input area -->
  85. <div class="flex gap-2 items-end mt-auto">
  86. <el-input
  87. v-model="inputMessage"
  88. type="textarea"
  89. :rows="2"
  90. placeholder="请进行数据分析,给一个总结报告,不超过300字"
  91. class="flex-1"
  92. resize="none"
  93. />
  94. <el-button
  95. type="primary"
  96. @click="sendMessage"
  97. :disabled="loading || !inputMessage.trim()"
  98. size="small"
  99. style="height: 53px; "
  100. >
  101. 发送
  102. </el-button>
  103. </div>
  104. </div>
  105. </template>
  106. <script lang="ts" setup>
  107. import { ref, reactive, nextTick, computed } from 'vue';
  108. import { marked } from 'marked';
  109. import { ChatLineRound, User, Download } from '@element-plus/icons-vue';
  110. import { Document, Paragraph, TextRun, Packer } from 'docx';
  111. import { askAIQuestion, uploadFileApi, type AIQuestionParams, type UploadFileResponse } from '../api';
  112. import { ElMessage } from 'element-plus/es'
  113. const renderMarkdown = (content: string): string => {
  114. // Configure marked with options
  115. marked.setOptions({
  116. breaks: true,
  117. gfm: true
  118. });
  119. try {
  120. return marked.parse(content) as string;
  121. } catch (error) {
  122. console.error('Markdown parsing error:', error);
  123. return content.replace(/</g, '<').replace(/>/g, '>'); // Escape HTML if parsing fails
  124. }
  125. };
  126. const exportToWord = async (content: string) => {
  127. // 先将markdown转换为HTML
  128. let htmlContent = '';
  129. try {
  130. const result = await marked.parse(content, {
  131. breaks: true,
  132. gfm: true
  133. });
  134. htmlContent = result;
  135. } catch (error) {
  136. console.error('Markdown parsing error:', error);
  137. htmlContent = content; // Fallback to raw content
  138. }
  139. // 创建一个临时的div来解析HTML
  140. const tempDiv = document.createElement('div');
  141. tempDiv.innerHTML = htmlContent;
  142. // 准备Word文档的段落
  143. const docChildren: Paragraph[] = [];
  144. // 处理每个HTML元素并转换为Word文档元素
  145. Array.from(tempDiv.childNodes).forEach((node) => {
  146. if (node.nodeType === Node.TEXT_NODE) {
  147. // 处理纯文本节点
  148. if (node.textContent && node.textContent.trim()) {
  149. docChildren.push(
  150. new Paragraph({
  151. children: [new TextRun({ text: node.textContent.trim() })],
  152. })
  153. );
  154. }
  155. } else if (node.nodeType === Node.ELEMENT_NODE) {
  156. const element = node as HTMLElement;
  157. // 根据HTML标签类型创建不同的Word元素
  158. if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
  159. docChildren.push(
  160. new Paragraph({
  161. heading: element.tagName === 'H1' ? 'Heading1' : element.tagName === 'H2' ? 'Heading2' : 'Heading3',
  162. children: [
  163. new TextRun({
  164. text: element.textContent || '',
  165. bold: true,
  166. size: element.tagName === 'H1' ? 36 : element.tagName === 'H2' ? 32 : 28,
  167. }),
  168. ],
  169. })
  170. );
  171. } else if (element.tagName === 'P') {
  172. docChildren.push(
  173. new Paragraph({
  174. children: [new TextRun({ text: element.textContent || '' })],
  175. spacing: { after: 200 },
  176. })
  177. );
  178. } else if (element.tagName === 'UL' || element.tagName === 'OL') {
  179. // 处理列表
  180. Array.from(element.children).forEach((li, index) => {
  181. docChildren.push(
  182. new Paragraph({
  183. children: [
  184. new TextRun({ text: element.tagName === 'UL' ? '• ' : `${index + 1}. ` }),
  185. new TextRun({ text: li.textContent || '' }),
  186. ],
  187. indent: { left: 720 }, // 缩进
  188. spacing: { after: 120 },
  189. })
  190. );
  191. });
  192. } else if (element.tagName === 'BLOCKQUOTE') {
  193. docChildren.push(
  194. new Paragraph({
  195. children: [new TextRun({ text: element.textContent || '', italics: true })],
  196. indent: { left: 720 },
  197. border: { left: { color: '#CCCCCC', size: 6, space: 15, style: 'single' } },
  198. spacing: { after: 200 },
  199. })
  200. );
  201. } else if (element.tagName === 'PRE') {
  202. // 代码块
  203. docChildren.push(
  204. new Paragraph({
  205. children: [new TextRun({
  206. text: element.textContent || '',
  207. font: { name: 'Courier New' }
  208. })],
  209. shading: {
  210. fill: '#F6F8FA',
  211. type: 'clear'
  212. },
  213. spacing: { after: 200 },
  214. })
  215. );
  216. } else {
  217. // 其他元素默认处理
  218. docChildren.push(
  219. new Paragraph({
  220. children: [new TextRun({ text: element.textContent || '' })],
  221. })
  222. );
  223. }
  224. }
  225. });
  226. // 创建Word文档
  227. const doc = new Document({
  228. sections: [{
  229. properties: {},
  230. children: docChildren,
  231. }],
  232. });
  233. const blob = await Packer.toBlob(doc);
  234. const url = URL.createObjectURL(blob);
  235. const a = document.createElement('a');
  236. a.href = url;
  237. a.download = 'AI分析报告.docx';
  238. a.click();
  239. URL.revokeObjectURL(url);
  240. };
  241. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  242. type SystemTableType = 'clue' | 'business_opportunity' | 'customer' | 'contact' | 'contract' | 'order' | 'product';
  243. interface ChatMessage {
  244. role: 'user' | 'assistant';
  245. content: string;
  246. loading?: boolean;
  247. }
  248. // Data source selection
  249. const dataSource = ref<DataSourceType>('system');
  250. const systemTable = ref<SystemTableType>('clue');
  251. const getFirstDayOfMonth = () => {
  252. const date = new Date();
  253. return new Date(date.getFullYear(), date.getMonth(), 1);
  254. };
  255. const uploadFile = ref<File | null>(null);
  256. const uploadedFilePath = ref<string>('');
  257. const beforeUpload = (file: File) => {
  258. uploadFile.value = file;
  259. uploadedFilePath.value = '';
  260. return true;
  261. };
  262. const handleUpload = async (options: any) => {
  263. try {
  264. const result = await uploadFileApi(options.file);
  265. if (result.code === 'ok') {
  266. uploadedFilePath.value = result.data;
  267. // Show upload success message
  268. ElMessage.success("上传成功,请输入问题并发送")
  269. } else {
  270. console.error('Upload failed:', result);
  271. }
  272. } catch (error) {
  273. console.error('Upload error:', error);
  274. }
  275. };
  276. const dateRange = ref([getFirstDayOfMonth(), new Date()]);
  277. // Chat functionality
  278. const inputMessage = ref('请进行数据分析,给一个总结报告,不超过300字');
  279. const loading = ref(false);
  280. const messages = reactive<ChatMessage[]>([
  281. { role: 'assistant', content: '你好,需要分析查询哪些数据,请交给我' },
  282. ]);
  283. const sendMessage = async () => {
  284. if (!inputMessage.value.trim() || loading.value) return;
  285. loading.value = true;
  286. const userMessage: ChatMessage = { role: 'user', content: inputMessage.value };
  287. messages.push(userMessage);
  288. const thinkingIndex = messages.length;
  289. messages.push({ role: 'assistant', content: 'AI正在思考', loading: true });
  290. try {
  291. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  292. const dataSourceMap: Record<DataSourceType, number> = {
  293. 'system': 1,
  294. 'custom': 2,
  295. 'upload': 3,
  296. 'free': 4
  297. };
  298. const params: AIQuestionParams = {
  299. questionDataSource: dataSourceMap[dataSource.value],
  300. sourceContent: dataSource.value === 'system' ? systemTable.value : '',
  301. content: inputMessage.value,
  302. startDate: dateRange.value[0]?.toISOString().split('T')[0],
  303. endDate: dateRange.value[1]?.toISOString().split('T')[0],
  304. url: dataSource.value === 'upload' ? uploadedFilePath.value : ''
  305. };
  306. const result = await askAIQuestion(params);
  307. messages[thinkingIndex] = {
  308. role: 'assistant',
  309. content: result.data || '根据您的请求,我已分析了相关数据。分析结果显示...',
  310. loading: false
  311. };
  312. } catch (error) {
  313. console.error('API error:', error);
  314. messages[thinkingIndex] = {
  315. role: 'assistant',
  316. content: '抱歉,请求处理失败,请稍后再试',
  317. loading: false
  318. };
  319. }
  320. inputMessage.value = '';
  321. loading.value = false;
  322. // 触发滚动到底部
  323. nextTick(() => {
  324. const container = document.querySelector('.overflow-y-auto');
  325. if (container) {
  326. container.scrollTop = container.scrollHeight;
  327. }
  328. });
  329. };
  330. </script>
  331. <style scoped>
  332. :deep(.el-textarea__inner) {
  333. resize: none;
  334. }
  335. .markdown-body {
  336. line-height: 1.5;
  337. word-wrap: break-word;
  338. }
  339. .markdown-body :deep(h1),
  340. .markdown-body :deep(h2),
  341. .markdown-body :deep(h3),
  342. .markdown-body :deep(h4),
  343. .markdown-body :deep(h5),
  344. .markdown-body :deep(h6) {
  345. margin-top: 0.5em;
  346. margin-bottom: 0.5em;
  347. font-weight: bold;
  348. }
  349. .markdown-body :deep(p) {
  350. margin-bottom: 1em;
  351. }
  352. .markdown-body :deep(ul),
  353. .markdown-body :deep(ol) {
  354. padding-left: 2em;
  355. margin-bottom: 1em;
  356. }
  357. .markdown-body :deep(li) {
  358. margin-bottom: 0.5em;
  359. }
  360. .markdown-body :deep(code) {
  361. background-color: rgba(175, 184, 193, 0.2);
  362. border-radius: 3px;
  363. padding: 0.2em 0.4em;
  364. font-family: monospace;
  365. }
  366. .markdown-body :deep(pre) {
  367. background-color: #f6f8fa;
  368. border-radius: 3px;
  369. padding: 1em;
  370. overflow: auto;
  371. margin-bottom: 1em;
  372. }
  373. .markdown-body :deep(blockquote) {
  374. border-left: 4px solid #dfe2e5;
  375. color: #6a737d;
  376. padding-left: 1em;
  377. margin-left: 0;
  378. margin-bottom: 1em;
  379. }
  380. .loading-dots {
  381. display: inline-flex;
  382. align-items: center;
  383. gap: 2px;
  384. }
  385. .loading-dots span {
  386. display: inline-block;
  387. width: 6px;
  388. height: 6px;
  389. border-radius: 50%;
  390. background-color: currentColor;
  391. animation: bounce 1.4s infinite ease-in-out both;
  392. }
  393. .loading-dots span:nth-child(1) {
  394. animation-delay: -0.32s;
  395. }
  396. .loading-dots span:nth-child(2) {
  397. animation-delay: -0.16s;
  398. }
  399. @keyframes bounce {
  400. 0%, 80%, 100% {
  401. transform: scale(0);
  402. } 40% {
  403. transform: scale(1.0);
  404. }
  405. }
  406. </style>