AIChat.vue 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610
  1. <template>
  2. <div class="border-gray-200 p-3 h-full flex flex-col" style="min-height: 750px; max-height: 750px;">
  3. <div class="text-sm font-medium mb-3 flex justify-between">
  4. <span style="color: #075985">DeepSeek大模型CRM数据分析</span>
  5. <span class="text-gray-500 text-right">开会做PPT利器!</span>
  6. </div>
  7. <!-- Chat messages container with fixed height and scrolling -->
  8. <div class="mb-3 border-gray-200 border rounded overflow-y-auto flex-grow" style="min-height: 0;">
  9. <div class="p-3 flex flex-col gap-3">
  10. <div v-for="(message, index) in messages" :key="index" class="flex" :class="{'justify-end': message.role === 'user', 'justify-start': message.role === 'assistant'}">
  11. <div v-if="message.role === 'assistant'" class="flex items-start gap-2">
  12. <el-avatar :size="24" class="bg-gray-200 flex items-center justify-center">
  13. <el-icon><ChatLineRound /></el-icon>
  14. </el-avatar>
  15. <div class="border-gray-200 border rounded p-2 text-sm max-w-[80%] bg-gray-50 relative">
  16. <div class="text-xs text-gray-500 mb-1" v-if="message.dataSource">
  17. [{{ message.dataSource }}{{ message.dataName ? '-' + message.dataName : '' }}]
  18. </div>
  19. <div class="markdown-body" v-html="renderMarkdown(message.content)"></div>
  20. <div v-if="message.loading" class="loading-dots">
  21. <span></span>
  22. <span></span>
  23. <span></span>
  24. </div>
  25. <el-button
  26. v-if="!message.loading && message.role === 'assistant' && index > 0 && '抱歉,请求处理失败,请稍后再试' != message.content && '无数据' != message.content"
  27. @click="exportToWord(message.content)"
  28. size="small"
  29. type="text"
  30. class="absolute -bottom-3 -right-3"
  31. :icon="Download"
  32. />
  33. </div>
  34. </div>
  35. <div v-if="message.role === 'user'" class="flex items-start gap-2 justify-end">
  36. <div class="border-gray-200 border rounded p-2 text-sm max-w-[80%] bg-blue-50">
  37. <div class="text-xs text-gray-500 mb-1" v-if="message.dataSource">
  38. [{{ message.dataSource }}{{ message.dataName ? '-' + message.dataName : '' }}]
  39. </div>
  40. <div class="markdown-body" v-html="renderMarkdown(message.content)"></div>
  41. </div>
  42. <el-avatar :size="24" class="bg-gray-200 flex items-center justify-center">
  43. <el-icon><User /></el-icon>
  44. </el-avatar>
  45. </div>
  46. </div>
  47. </div>
  48. </div>
  49. <!-- Data source selection -->
  50. <div class="mb-3 flex gap-2">
  51. <div>
  52. <span class="text-sm mr-2">数据来源</span>
  53. <el-select v-model="dataSource" size="small" style="width: 120px">
  54. <el-option label="系统表" value="system" />
  55. <el-option label="自定义报表" value="custom" />
  56. <el-option label="本地上传" value="upload" />
  57. <el-option label="自由交流" value="free" />
  58. </el-select>
  59. </div>
  60. <div v-if="dataSource === 'system'">
  61. <el-select v-model="systemTable" size="small" style="width: 120px">
  62. <el-option label="线索" value="clue" />
  63. <el-option label="商机" value="business_opportunity" />
  64. <el-option label="客户" value="custom" />
  65. <el-option label="联系人" value="contacts" />
  66. <el-option label="合同" value="contract" />
  67. <el-option label="销售订单" value="sales_order" />
  68. <el-option label="产品" value="product" />
  69. </el-select>
  70. </div>
  71. <div v-if="dataSource === 'custom'">
  72. <el-select
  73. v-model="selectedReportId"
  74. size="small"
  75. style="width: 200px"
  76. placeholder="选择报表"
  77. @focus="loadCustomReports"
  78. >
  79. <el-option
  80. v-for="report in customReports"
  81. :key="report.relateFormId"
  82. :label="report.storeName"
  83. :value="report.relateFormId"
  84. />
  85. </el-select>
  86. </div>
  87. <div v-if="dataSource === 'upload'">
  88. <el-upload
  89. :show-file-list="false"
  90. :before-upload="beforeUpload"
  91. :http-request="handleUpload"
  92. accept=".xlsx,.xls"
  93. >
  94. <el-button size="small" type="primary">上传Excel</el-button> <span style="margin-left:5px;color:orange">{{ uploadedFilePath?'上传成功':'' }}</span>
  95. </el-upload>
  96. </div>
  97. <div v-if="dataSource === 'system'" class="ml-auto">
  98. <span class="text-sm mr-2">时间段</span>
  99. <el-date-picker
  100. v-model="dateRange"
  101. type="daterange"
  102. size="small"
  103. range-separator="/"
  104. start-placeholder="开始日期"
  105. end-placeholder="结束日期"
  106. format="YYYY-MM-DD"
  107. value-format="YYYY-MM-DD"
  108. />
  109. </div>
  110. </div>
  111. <!-- Input area -->
  112. <div class="flex gap-2 items-end mt-auto">
  113. <el-input
  114. v-model="inputMessage"
  115. type="textarea"
  116. :rows="2"
  117. placeholder="请进行数据分析,给一个总结报告,不超过300字"
  118. class="flex-1"
  119. resize="none"
  120. @keyup.enter="inputMessage.trim() && !loading ? sendMessage() : null"
  121. />
  122. <el-button
  123. type="primary"
  124. @click="sendMessage"
  125. :disabled="loading || !inputMessage.trim()"
  126. size="small"
  127. style="height: 53px; "
  128. >
  129. 发送
  130. </el-button>
  131. </div>
  132. </div>
  133. </template>
  134. <script lang="ts" setup>
  135. import { ref, reactive, nextTick, computed, onMounted } from 'vue';
  136. import { marked } from 'marked';
  137. import { ChatLineRound, User, Download } from '@element-plus/icons-vue';
  138. import { Document, Paragraph, TextRun, Packer } from 'docx';
  139. import {
  140. askAIQuestion,
  141. uploadFileApi,
  142. getLatestQuestionList,
  143. getCustomReports,
  144. type AIQuestionParams,
  145. type UploadFileResponse,
  146. type ChatContent,
  147. type LatestQuestionResponse,
  148. type AIQuestionResponse,
  149. type CustomReport
  150. } from '../api';
  151. import { ElMessage } from 'element-plus/es'
  152. import * as internal from 'stream';
  153. const renderMarkdown = (content: string): string => {
  154. // Configure marked with options
  155. marked.setOptions({
  156. breaks: true,
  157. gfm: true
  158. });
  159. try {
  160. return marked.parse(content) as string;
  161. } catch (error) {
  162. console.error('Markdown parsing error:', error);
  163. return content.replace(/</g, '<').replace(/>/g, '>'); // Escape HTML if parsing fails
  164. }
  165. };
  166. const exportToWord = async (content: string) => {
  167. // 先将markdown转换为HTML
  168. let htmlContent = '';
  169. try {
  170. const result = await marked.parse(content, {
  171. breaks: true,
  172. gfm: true
  173. });
  174. htmlContent = result;
  175. } catch (error) {
  176. console.error('Markdown parsing error:', error);
  177. htmlContent = content; // Fallback to raw content
  178. }
  179. // 创建一个临时的div来解析HTML
  180. const tempDiv = document.createElement('div');
  181. tempDiv.innerHTML = htmlContent;
  182. // 准备Word文档的段落
  183. const docChildren: Paragraph[] = [];
  184. // 处理每个HTML元素并转换为Word文档元素
  185. Array.from(tempDiv.childNodes).forEach((node) => {
  186. if (node.nodeType === Node.TEXT_NODE) {
  187. // 处理纯文本节点
  188. if (node.textContent && node.textContent.trim()) {
  189. docChildren.push(
  190. new Paragraph({
  191. children: [new TextRun({ text: node.textContent.trim() })],
  192. })
  193. );
  194. }
  195. } else if (node.nodeType === Node.ELEMENT_NODE) {
  196. const element = node as HTMLElement;
  197. // 根据HTML标签类型创建不同的Word元素
  198. if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
  199. docChildren.push(
  200. new Paragraph({
  201. heading: element.tagName === 'H1' ? 'Heading1' : element.tagName === 'H2' ? 'Heading2' : 'Heading3',
  202. children: [
  203. new TextRun({
  204. text: element.textContent || '',
  205. bold: true,
  206. size: element.tagName === 'H1' ? 36 : element.tagName === 'H2' ? 32 : 28,
  207. }),
  208. ],
  209. })
  210. );
  211. } else if (element.tagName === 'P') {
  212. docChildren.push(
  213. new Paragraph({
  214. children: [new TextRun({ text: element.textContent || '' })],
  215. spacing: { after: 200 },
  216. })
  217. );
  218. } else if (element.tagName === 'UL' || element.tagName === 'OL') {
  219. // 处理列表
  220. Array.from(element.children).forEach((li, index) => {
  221. docChildren.push(
  222. new Paragraph({
  223. children: [
  224. new TextRun({ text: element.tagName === 'UL' ? '• ' : `${index + 1}. ` }),
  225. new TextRun({ text: li.textContent || '' }),
  226. ],
  227. indent: { left: 720 }, // 缩进
  228. spacing: { after: 120 },
  229. })
  230. );
  231. });
  232. } else if (element.tagName === 'BLOCKQUOTE') {
  233. docChildren.push(
  234. new Paragraph({
  235. children: [new TextRun({ text: element.textContent || '', italics: true })],
  236. indent: { left: 720 },
  237. border: { left: { color: '#CCCCCC', size: 6, space: 15, style: 'single' } },
  238. spacing: { after: 200 },
  239. })
  240. );
  241. } else if (element.tagName === 'PRE') {
  242. // 代码块
  243. docChildren.push(
  244. new Paragraph({
  245. children: [new TextRun({
  246. text: element.textContent || '',
  247. font: { name: 'Courier New' }
  248. })],
  249. shading: {
  250. fill: '#F6F8FA',
  251. type: 'clear'
  252. },
  253. spacing: { after: 200 },
  254. })
  255. );
  256. } else {
  257. // 其他元素默认处理
  258. docChildren.push(
  259. new Paragraph({
  260. children: [new TextRun({ text: element.textContent || '' })],
  261. })
  262. );
  263. }
  264. }
  265. });
  266. // 创建Word文档
  267. const doc = new Document({
  268. sections: [{
  269. properties: {},
  270. children: docChildren,
  271. }],
  272. });
  273. const blob = await Packer.toBlob(doc);
  274. const url = URL.createObjectURL(blob);
  275. const a = document.createElement('a');
  276. a.href = url;
  277. a.download = 'AI分析报告.docx';
  278. a.click();
  279. URL.revokeObjectURL(url);
  280. };
  281. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  282. type SystemTableType = 'clue' | 'business_opportunity' | 'custom' | 'contacts' | 'contract' | 'sales_order' | 'product';
  283. interface ChatMessage {
  284. role: 'user' | 'assistant';
  285. content: string;
  286. loading?: boolean;
  287. dataSource?: string;
  288. dataName?: string;
  289. }
  290. // Data source selection
  291. const dataSource = ref<DataSourceType>('system');
  292. const systemTable = ref<SystemTableType>('clue');
  293. const customReports = ref<CustomReport[]>([]);
  294. const selectedReportId = ref<string>('');
  295. const formatDate = (date: Date) => {
  296. const year = date.getFullYear();
  297. const month = String(date.getMonth() + 1).padStart(2, '0');
  298. const day = String(date.getDate()).padStart(2, '0');
  299. return `${year}-${month}-${day}`;
  300. };
  301. const getFirstDayOfMonth = () => {
  302. const date = new Date();
  303. return new Date(date.getFullYear(), date.getMonth(), 1);
  304. };
  305. const uploadFile = ref<File | null>(null);
  306. const uploadedFilePath = ref<string>('');
  307. const beforeUpload = (file: File) => {
  308. uploadFile.value = file;
  309. uploadedFilePath.value = '';
  310. return true;
  311. };
  312. const handleUpload = async (options: any) => {
  313. try {
  314. const result = await uploadFileApi(options.file);
  315. if (result.code === 'ok') {
  316. uploadedFilePath.value = result.data;
  317. // Show upload success message
  318. ElMessage.success("上传成功,请输入问题并发送")
  319. } else {
  320. console.error('Upload failed:', result);
  321. }
  322. } catch (error) {
  323. console.error('Upload error:', error);
  324. }
  325. };
  326. const dateRange = ref([formatDate(getFirstDayOfMonth()), formatDate(new Date())]);
  327. // Chat functionality
  328. const inputMessage = ref('请进行数据分析,给一个总结报告,不超过300字');
  329. const loading = ref(false);
  330. const messages = reactive<ChatMessage[]>([]);
  331. const questionId = ref<number | null>(null);
  332. const loadCustomReports = async () => {
  333. if (customReports.value.length === 0) {
  334. try {
  335. const result = await getCustomReports();
  336. if (result.code === 'ok') {
  337. customReports.value = result.data;
  338. }
  339. } catch (error) {
  340. console.error('Failed to load custom reports:', error);
  341. ElMessage.error('加载自定义报表失败');
  342. }
  343. }
  344. };
  345. const isSameDay = (dateString: string, compareDate: Date) => {
  346. // Parse yyyy-MM-dd hh:mm:ss format
  347. const [datePart] = dateString.split(' ');
  348. const [year, month, day] = datePart.split('-').map(Number);
  349. return year === compareDate.getFullYear() &&
  350. month - 1 === compareDate.getMonth() && // Months are 0-indexed in JS
  351. day === compareDate.getDate();
  352. };
  353. onMounted(async () => {
  354. try {
  355. const result = await getLatestQuestionList();
  356. if (result.code === 'ok' && result.data.contents) {
  357. result.data.contents.forEach(content => {
  358. messages.push({
  359. role: content.type === 0 ? 'assistant' : 'user',
  360. content: content.content
  361. });
  362. // Check if createTime is today and set questionId
  363. if (content.createTime && content.questionId) {
  364. if (isSameDay(content.createTime, new Date())) {
  365. questionId.value = content.questionId;
  366. }
  367. }
  368. });
  369. } else {
  370. // Default message if no history
  371. messages.push({ role: 'assistant', content: '你好,需要分析查询哪些数据,请交给我' });
  372. }
  373. } catch (error) {
  374. console.error('Failed to load chat history:', error);
  375. messages.push({ role: 'assistant', content: '你好,需要分析查询哪些数据,请交给我' });
  376. }
  377. // 触发滚动到底部
  378. nextTick(() => {
  379. const container = document.querySelector('.overflow-y-auto');
  380. if (container) {
  381. container.scrollTop = container.scrollHeight;
  382. }
  383. });
  384. });
  385. const sendMessage = async () => {
  386. if (!inputMessage.value.trim() || loading.value) return;
  387. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  388. const dataSourceNameMap: Record<DataSourceType, string> = {
  389. 'system': '系统表',
  390. 'custom': '自定义报表',
  391. 'upload': '本地上传',
  392. 'free': '自由交流'
  393. };
  394. const dataSourceMap: Record<DataSourceType, number> = {
  395. 'system': 1,
  396. 'custom': 2,
  397. 'upload': 3,
  398. 'free': 4
  399. };
  400. const sourceName = dataSourceNameMap[dataSource.value]
  401. const dataName = dataSource.value === 'system' ?
  402. systemTable.value === 'clue' ? '线索' :
  403. systemTable.value === 'business_opportunity' ? '商机' :
  404. systemTable.value === 'custom' ? '客户' :
  405. systemTable.value === 'contacts' ? '联系人' :
  406. systemTable.value === 'contract' ? '合同' :
  407. systemTable.value === 'sales_order' ? '销售订单' : '产品' :
  408. dataSource.value === 'custom' ?
  409. customReports.value.find(r => r.relateFormId === selectedReportId.value)?.storeName || '' :
  410. dataSource.value === 'upload' ?
  411. uploadFile.value?.name || '' : ''
  412. const finalContent = '['+sourceName+(dataName?('-'+dataName):'')+']'+'<br>' + inputMessage.value;
  413. if (!dataName) {
  414. if (dataSource.value == 'custom') {
  415. //提示:请选择报表
  416. ElMessage.error({
  417. message: "请选择报表",
  418. type: "error",
  419. duration: 2000,
  420. })
  421. return
  422. } else if (dataSource.value == 'upload') {
  423. ElMessage.error({
  424. message: "请上传数据文件(仅支持excel格式)",
  425. type: "error",
  426. duration: 2000,
  427. })
  428. return
  429. }
  430. }
  431. loading.value = true;
  432. const userMessage: ChatMessage = { role: 'user', content: finalContent };
  433. messages.push(userMessage);
  434. const thinkingIndex = messages.length;
  435. messages.push({ role: 'assistant', content: 'AI正在思考', loading: true });
  436. try {
  437. console.log('发送日期=='+dateRange);
  438. console.log(dateRange.value[0]);
  439. const params: AIQuestionParams & { questionId?: number } = {
  440. questionDataSource: dataSourceMap[dataSource.value],
  441. sourceContent: dataSource.value === 'system' ? systemTable.value :
  442. dataSource.value === 'custom' ? selectedReportId.value : '',
  443. content: finalContent,
  444. startDate: dateRange.value[0],
  445. endDate: dateRange.value[1],
  446. url: dataSource.value === 'upload' ? uploadedFilePath.value : ''
  447. };
  448. // Only include questionId if we have one from today's conversation
  449. if (questionId.value) {
  450. params.questionId = questionId.value;
  451. }
  452. // reset message input
  453. inputMessage.value = '';
  454. //上方聊天记录页面自动滚动到最下面
  455. nextTick(() => {
  456. const container = document.querySelector('.overflow-y-auto');
  457. if (container) {
  458. container.scrollTop = container.scrollHeight;
  459. }
  460. });
  461. const result = await askAIQuestion(params);
  462. messages[thinkingIndex] = {
  463. role: 'assistant',
  464. content: result.data.queryRes || '根据您的请求,我已分析了相关数据。分析结果显示...',
  465. loading: false
  466. };
  467. questionId.value = result.data.questionId;
  468. } catch (error) {
  469. console.error('API error:', error);
  470. messages[thinkingIndex] = {
  471. role: 'assistant',
  472. content: '抱歉,请求处理失败,请稍后再试',
  473. loading: false
  474. };
  475. }
  476. loading.value = false;
  477. // 触发滚动到底部
  478. nextTick(() => {
  479. const container = document.querySelector('.overflow-y-auto');
  480. if (container) {
  481. container.scrollTop = container.scrollHeight;
  482. }
  483. });
  484. };
  485. </script>
  486. <style scoped>
  487. :deep(.el-textarea__inner) {
  488. resize: none;
  489. }
  490. .markdown-body {
  491. line-height: 1.5;
  492. word-wrap: break-word;
  493. }
  494. .markdown-body :deep(h1),
  495. .markdown-body :deep(h2),
  496. .markdown-body :deep(h3),
  497. .markdown-body :deep(h4),
  498. .markdown-body :deep(h5),
  499. .markdown-body :deep(h6) {
  500. margin-top: 0.5em;
  501. margin-bottom: 0.5em;
  502. font-weight: bold;
  503. }
  504. .markdown-body :deep(p) {
  505. margin-bottom: 1em;
  506. }
  507. .markdown-body :deep(ul),
  508. .markdown-body :deep(ol) {
  509. padding-left: 2em;
  510. margin-bottom: 1em;
  511. }
  512. .markdown-body :deep(li) {
  513. margin-bottom: 0.5em;
  514. }
  515. .markdown-body :deep(code) {
  516. background-color: rgba(175, 184, 193, 0.2);
  517. border-radius: 3px;
  518. padding: 0.2em 0.4em;
  519. font-family: monospace;
  520. }
  521. .markdown-body :deep(pre) {
  522. background-color: #f6f8fa;
  523. border-radius: 3px;
  524. padding: 1em;
  525. overflow: auto;
  526. margin-bottom: 1em;
  527. }
  528. .markdown-body :deep(blockquote) {
  529. border-left: 4px solid #dfe2e5;
  530. color: #6a737d;
  531. padding-left: 1em;
  532. margin-left: 0;
  533. margin-bottom: 1em;
  534. }
  535. .loading-dots {
  536. display: inline-flex;
  537. align-items: center;
  538. gap: 2px;
  539. }
  540. .loading-dots span {
  541. display: inline-block;
  542. width: 6px;
  543. height: 6px;
  544. border-radius: 50%;
  545. background-color: currentColor;
  546. animation: bounce 1.4s infinite ease-in-out both;
  547. }
  548. .loading-dots span:nth-child(1) {
  549. animation-delay: -0.32s;
  550. }
  551. .loading-dots span:nth-child(2) {
  552. animation-delay: -0.16s;
  553. }
  554. @keyframes bounce {
  555. 0%, 80%, 100% {
  556. transform: scale(0);
  557. } 40% {
  558. transform: scale(1.0);
  559. }
  560. }
  561. </style>