AIChat.vue 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501
  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, onMounted } 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 {
  112. askAIQuestion,
  113. uploadFileApi,
  114. getLatestQuestionList,
  115. type AIQuestionParams,
  116. type UploadFileResponse,
  117. type ChatContent,
  118. type LatestQuestionResponse,
  119. type AIQuestionResponse
  120. } from '../api';
  121. import { ElMessage } from 'element-plus/es'
  122. import * as internal from 'stream';
  123. const renderMarkdown = (content: string): string => {
  124. // Configure marked with options
  125. marked.setOptions({
  126. breaks: true,
  127. gfm: true
  128. });
  129. try {
  130. return marked.parse(content) as string;
  131. } catch (error) {
  132. console.error('Markdown parsing error:', error);
  133. return content.replace(/</g, '<').replace(/>/g, '>'); // Escape HTML if parsing fails
  134. }
  135. };
  136. const exportToWord = async (content: string) => {
  137. // 先将markdown转换为HTML
  138. let htmlContent = '';
  139. try {
  140. const result = await marked.parse(content, {
  141. breaks: true,
  142. gfm: true
  143. });
  144. htmlContent = result;
  145. } catch (error) {
  146. console.error('Markdown parsing error:', error);
  147. htmlContent = content; // Fallback to raw content
  148. }
  149. // 创建一个临时的div来解析HTML
  150. const tempDiv = document.createElement('div');
  151. tempDiv.innerHTML = htmlContent;
  152. // 准备Word文档的段落
  153. const docChildren: Paragraph[] = [];
  154. // 处理每个HTML元素并转换为Word文档元素
  155. Array.from(tempDiv.childNodes).forEach((node) => {
  156. if (node.nodeType === Node.TEXT_NODE) {
  157. // 处理纯文本节点
  158. if (node.textContent && node.textContent.trim()) {
  159. docChildren.push(
  160. new Paragraph({
  161. children: [new TextRun({ text: node.textContent.trim() })],
  162. })
  163. );
  164. }
  165. } else if (node.nodeType === Node.ELEMENT_NODE) {
  166. const element = node as HTMLElement;
  167. // 根据HTML标签类型创建不同的Word元素
  168. if (element.tagName === 'H1' || element.tagName === 'H2' || element.tagName === 'H3') {
  169. docChildren.push(
  170. new Paragraph({
  171. heading: element.tagName === 'H1' ? 'Heading1' : element.tagName === 'H2' ? 'Heading2' : 'Heading3',
  172. children: [
  173. new TextRun({
  174. text: element.textContent || '',
  175. bold: true,
  176. size: element.tagName === 'H1' ? 36 : element.tagName === 'H2' ? 32 : 28,
  177. }),
  178. ],
  179. })
  180. );
  181. } else if (element.tagName === 'P') {
  182. docChildren.push(
  183. new Paragraph({
  184. children: [new TextRun({ text: element.textContent || '' })],
  185. spacing: { after: 200 },
  186. })
  187. );
  188. } else if (element.tagName === 'UL' || element.tagName === 'OL') {
  189. // 处理列表
  190. Array.from(element.children).forEach((li, index) => {
  191. docChildren.push(
  192. new Paragraph({
  193. children: [
  194. new TextRun({ text: element.tagName === 'UL' ? '• ' : `${index + 1}. ` }),
  195. new TextRun({ text: li.textContent || '' }),
  196. ],
  197. indent: { left: 720 }, // 缩进
  198. spacing: { after: 120 },
  199. })
  200. );
  201. });
  202. } else if (element.tagName === 'BLOCKQUOTE') {
  203. docChildren.push(
  204. new Paragraph({
  205. children: [new TextRun({ text: element.textContent || '', italics: true })],
  206. indent: { left: 720 },
  207. border: { left: { color: '#CCCCCC', size: 6, space: 15, style: 'single' } },
  208. spacing: { after: 200 },
  209. })
  210. );
  211. } else if (element.tagName === 'PRE') {
  212. // 代码块
  213. docChildren.push(
  214. new Paragraph({
  215. children: [new TextRun({
  216. text: element.textContent || '',
  217. font: { name: 'Courier New' }
  218. })],
  219. shading: {
  220. fill: '#F6F8FA',
  221. type: 'clear'
  222. },
  223. spacing: { after: 200 },
  224. })
  225. );
  226. } else {
  227. // 其他元素默认处理
  228. docChildren.push(
  229. new Paragraph({
  230. children: [new TextRun({ text: element.textContent || '' })],
  231. })
  232. );
  233. }
  234. }
  235. });
  236. // 创建Word文档
  237. const doc = new Document({
  238. sections: [{
  239. properties: {},
  240. children: docChildren,
  241. }],
  242. });
  243. const blob = await Packer.toBlob(doc);
  244. const url = URL.createObjectURL(blob);
  245. const a = document.createElement('a');
  246. a.href = url;
  247. a.download = 'AI分析报告.docx';
  248. a.click();
  249. URL.revokeObjectURL(url);
  250. };
  251. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  252. type SystemTableType = 'clue' | 'business_opportunity' | 'customer' | 'contact' | 'contract' | 'order' | 'product';
  253. interface ChatMessage {
  254. role: 'user' | 'assistant';
  255. content: string;
  256. loading?: boolean;
  257. }
  258. // Data source selection
  259. const dataSource = ref<DataSourceType>('system');
  260. const systemTable = ref<SystemTableType>('clue');
  261. const getFirstDayOfMonth = () => {
  262. const date = new Date();
  263. return new Date(date.getFullYear(), date.getMonth(), 1);
  264. };
  265. const uploadFile = ref<File | null>(null);
  266. const uploadedFilePath = ref<string>('');
  267. const beforeUpload = (file: File) => {
  268. uploadFile.value = file;
  269. uploadedFilePath.value = '';
  270. return true;
  271. };
  272. const handleUpload = async (options: any) => {
  273. try {
  274. const result = await uploadFileApi(options.file);
  275. if (result.code === 'ok') {
  276. uploadedFilePath.value = result.data;
  277. // Show upload success message
  278. ElMessage.success("上传成功,请输入问题并发送")
  279. } else {
  280. console.error('Upload failed:', result);
  281. }
  282. } catch (error) {
  283. console.error('Upload error:', error);
  284. }
  285. };
  286. const dateRange = ref([getFirstDayOfMonth(), new Date()]);
  287. // Chat functionality
  288. const inputMessage = ref('请进行数据分析,给一个总结报告,不超过300字');
  289. const loading = ref(false);
  290. const messages = reactive<ChatMessage[]>([]);
  291. const questionId = ref<number | null>(null);
  292. const isSameDay = (dateString: string, compareDate: Date) => {
  293. // Parse yyyy-MM-dd hh:mm:ss format
  294. const [datePart] = dateString.split(' ');
  295. const [year, month, day] = datePart.split('-').map(Number);
  296. return year === compareDate.getFullYear() &&
  297. month - 1 === compareDate.getMonth() && // Months are 0-indexed in JS
  298. day === compareDate.getDate();
  299. };
  300. onMounted(async () => {
  301. try {
  302. const result = await getLatestQuestionList();
  303. if (result.code === 'ok' && result.data.contents) {
  304. result.data.contents.forEach(content => {
  305. messages.push({
  306. role: content.type === 0 ? 'assistant' : 'user',
  307. content: content.content
  308. });
  309. // Check if createTime is today and set questionId
  310. if (content.createTime && content.questionId) {
  311. if (isSameDay(content.createTime, new Date())) {
  312. questionId.value = content.questionId;
  313. }
  314. }
  315. });
  316. } else {
  317. // Default message if no history
  318. messages.push({ role: 'assistant', content: '你好,需要分析查询哪些数据,请交给我' });
  319. }
  320. } catch (error) {
  321. console.error('Failed to load chat history:', error);
  322. messages.push({ role: 'assistant', content: '你好,需要分析查询哪些数据,请交给我' });
  323. }
  324. });
  325. const sendMessage = async () => {
  326. if (!inputMessage.value.trim() || loading.value) return;
  327. loading.value = true;
  328. const userMessage: ChatMessage = { role: 'user', content: inputMessage.value };
  329. messages.push(userMessage);
  330. const thinkingIndex = messages.length;
  331. messages.push({ role: 'assistant', content: 'AI正在思考', loading: true });
  332. try {
  333. type DataSourceType = 'system' | 'custom' | 'upload' | 'free';
  334. const dataSourceMap: Record<DataSourceType, number> = {
  335. 'system': 1,
  336. 'custom': 2,
  337. 'upload': 3,
  338. 'free': 4
  339. };
  340. const params: AIQuestionParams & { questionId?: number } = {
  341. questionDataSource: dataSourceMap[dataSource.value],
  342. sourceContent: dataSource.value === 'system' ? systemTable.value : '',
  343. content: inputMessage.value,
  344. startDate: dateRange.value[0]?.toISOString().split('T')[0],
  345. endDate: dateRange.value[1]?.toISOString().split('T')[0],
  346. url: dataSource.value === 'upload' ? uploadedFilePath.value : ''
  347. };
  348. // Only include questionId if we have one from today's conversation
  349. if (questionId.value) {
  350. params.questionId = questionId.value;
  351. }
  352. const result = await askAIQuestion(params);
  353. messages[thinkingIndex] = {
  354. role: 'assistant',
  355. content: result.data.queryRes || '根据您的请求,我已分析了相关数据。分析结果显示...',
  356. loading: false
  357. };
  358. questionId.value = result.data.questionId;
  359. } catch (error) {
  360. console.error('API error:', error);
  361. messages[thinkingIndex] = {
  362. role: 'assistant',
  363. content: '抱歉,请求处理失败,请稍后再试',
  364. loading: false
  365. };
  366. }
  367. inputMessage.value = '';
  368. loading.value = false;
  369. // 触发滚动到底部
  370. nextTick(() => {
  371. const container = document.querySelector('.overflow-y-auto');
  372. if (container) {
  373. container.scrollTop = container.scrollHeight;
  374. }
  375. });
  376. };
  377. </script>
  378. <style scoped>
  379. :deep(.el-textarea__inner) {
  380. resize: none;
  381. }
  382. .markdown-body {
  383. line-height: 1.5;
  384. word-wrap: break-word;
  385. }
  386. .markdown-body :deep(h1),
  387. .markdown-body :deep(h2),
  388. .markdown-body :deep(h3),
  389. .markdown-body :deep(h4),
  390. .markdown-body :deep(h5),
  391. .markdown-body :deep(h6) {
  392. margin-top: 0.5em;
  393. margin-bottom: 0.5em;
  394. font-weight: bold;
  395. }
  396. .markdown-body :deep(p) {
  397. margin-bottom: 1em;
  398. }
  399. .markdown-body :deep(ul),
  400. .markdown-body :deep(ol) {
  401. padding-left: 2em;
  402. margin-bottom: 1em;
  403. }
  404. .markdown-body :deep(li) {
  405. margin-bottom: 0.5em;
  406. }
  407. .markdown-body :deep(code) {
  408. background-color: rgba(175, 184, 193, 0.2);
  409. border-radius: 3px;
  410. padding: 0.2em 0.4em;
  411. font-family: monospace;
  412. }
  413. .markdown-body :deep(pre) {
  414. background-color: #f6f8fa;
  415. border-radius: 3px;
  416. padding: 1em;
  417. overflow: auto;
  418. margin-bottom: 1em;
  419. }
  420. .markdown-body :deep(blockquote) {
  421. border-left: 4px solid #dfe2e5;
  422. color: #6a737d;
  423. padding-left: 1em;
  424. margin-left: 0;
  425. margin-bottom: 1em;
  426. }
  427. .loading-dots {
  428. display: inline-flex;
  429. align-items: center;
  430. gap: 2px;
  431. }
  432. .loading-dots span {
  433. display: inline-block;
  434. width: 6px;
  435. height: 6px;
  436. border-radius: 50%;
  437. background-color: currentColor;
  438. animation: bounce 1.4s infinite ease-in-out both;
  439. }
  440. .loading-dots span:nth-child(1) {
  441. animation-delay: -0.32s;
  442. }
  443. .loading-dots span:nth-child(2) {
  444. animation-delay: -0.16s;
  445. }
  446. @keyframes bounce {
  447. 0%, 80%, 100% {
  448. transform: scale(0);
  449. } 40% {
  450. transform: scale(1.0);
  451. }
  452. }
  453. </style>