index.vue 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537
  1. <template>
  2. <div class="ai-assist-wrapper">
  3. <div class="ai-assist-container" v-if="!showDataBoard && !showPerformanceAnalysis && !showUseReports">
  4. <!-- 消息区域 -->
  5. <div class="messages-area" ref="messagesRef">
  6. <div v-if="messages.length === 0" class="welcome-message">
  7. <h2>您好,今天有什么可以帮到您</h2>
  8. </div>
  9. <div v-else class="messages-list">
  10. <div v-for="msg in messages" :key="msg.id" :class="['message-item', msg.type]">
  11. <!-- 用户消息 -->
  12. <div v-if="msg.type === 'user'" class="user-bubble">{{ msg.text }}</div>
  13. <!-- AI 回复 -->
  14. <div v-else class="assistant-bubble">
  15. <!-- 进度步骤 -->
  16. <div v-if="msg.steps.length" class="progress-steps">
  17. <div v-for="step in msg.steps" :key="step.step" :class="['step-item', step.status]">
  18. <span class="step-icon">
  19. <svg v-if="step.status === 'running'" class="spin-icon" viewBox="0 0 24 24" fill="none">
  20. <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" stroke-dasharray="32"
  21. stroke-dashoffset="10" />
  22. </svg>
  23. <svg v-else viewBox="0 0 24 24" fill="none">
  24. <path d="M5 13l4 4L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round"
  25. stroke-linejoin="round" />
  26. </svg>
  27. </span>
  28. <span class="step-name">{{ step.name }}</span>
  29. <span v-if="step.detail" class="step-detail">{{ step.detail }}</span>
  30. <span v-if="step.duration != null" class="step-duration">{{ step.duration }}s</span>
  31. </div>
  32. </div>
  33. <!-- 结果内容 -->
  34. <div v-if="msg.result" class="result-content">
  35. <!-- 知识问答 -->
  36. <div v-if="msg.result.query_type === 'knowledge'" class="knowledge-answer">
  37. <div class="answer-text">{{ msg.result.answer }}</div>
  38. <div v-if="msg.result.sources && msg.result.sources.length" class="sources">
  39. <span class="sources-label">参考来源:</span>
  40. <el-tag v-for="s in msg.result.sources" :key="s.filename" size="small" class="source-tag">
  41. {{ s.filename }}
  42. </el-tag>
  43. </div>
  44. </div>
  45. <!-- 数据查询 / 混合查询 -->
  46. <div v-if="msg.result.query_type === 'data' || msg.result.query_type === 'hybrid'">
  47. <div v-if="msg.result.answer" class="knowledge-answer" style="margin-bottom: 12px;">
  48. <div class="answer-text">{{ msg.result.answer }}</div>
  49. </div>
  50. <div class="result-summary">{{ msg.result.message }}</div>
  51. <el-table v-if="msg.result.rows && msg.result.rows.length" :data="msg.result.rows" border size="small"
  52. max-height="360" class="result-table">
  53. <el-table-column v-for="col in msg.result.columns" :key="col" :prop="col" :label="col"
  54. min-width="100" />
  55. </el-table>
  56. <div v-else-if="msg.result.rows" class="no-data">暂无数据</div>
  57. </div>
  58. <!-- 耗时 -->
  59. <div v-if="msg.result.timing && msg.result.timing.total" class="timing-info">
  60. 共耗时 {{ msg.result.timing.total }}s
  61. </div>
  62. </div>
  63. <!-- 错误 -->
  64. <div v-if="msg.status === 'error'" class="error-message">
  65. <svg viewBox="0 0 24 24" fill="none" width="16" height="16">
  66. <circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2" />
  67. <path d="M12 8v4m0 4h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round" />
  68. </svg>
  69. {{ msg.errorText }}
  70. </div>
  71. </div>
  72. </div>
  73. </div>
  74. </div>
  75. <!-- 底部输入区 -->
  76. <div class="chat-container">
  77. <div class="quick-action">
  78. <el-button class="report-btn" @click="handleReportClick">质控分析报告</el-button>
  79. <el-button class="report-btn" @click="handlePerformanceClick">绩效分析报告</el-button>
  80. <el-button class="report-btn" @click="handleUseReportClick">使用报表</el-button>
  81. </div>
  82. <!--请输入您的问题,按 Enter 发送...-->
  83. <div class="input-container">
  84. <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="功能开发中,请敬请期待"
  85. class="chat-input" @keydown.enter.exact.prevent="handleSend" :disabled="true" />
  86. <el-button :disabled="!inputMessage.trim() || isLoading" circle class="send-btn" @click="handleSend">
  87. <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
  88. <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
  89. </svg>
  90. </el-button>
  91. </div>
  92. </div>
  93. </div>
  94. <DataBoard v-if="showDataBoard" @back="handleBack" />
  95. <PerformanceAnalysis v-if="showPerformanceAnalysis" @back="handleBack" />
  96. <UseReports v-if="showUseReports" @back="handleBack" />
  97. </div>
  98. </template>
  99. <script setup>
  100. import { ref, nextTick, onMounted, onActivated, onDeactivated } from 'vue'
  101. import DataBoard from './components/dataBoard.vue'
  102. import PerformanceAnalysis from './components/performanceAnalysis.vue'
  103. import useUserStore from '@/store/modules/user'
  104. import UseReports from './components/useReports.vue'
  105. const userStore = useUserStore()
  106. const AI_SERVICE_URL = import.meta.env.VITE_AI_SERVICE_URL || 'http://localhost:8000'
  107. const inputMessage = ref('')
  108. const isLoading = ref(false)
  109. const messages = ref([])
  110. const messagesRef = ref(null)
  111. const showDataBoard = ref(false)
  112. const showPerformanceAnalysis = ref(false)
  113. const showUseReports = ref(false)
  114. onMounted(() => resetState())
  115. onActivated(() => resetState())
  116. onDeactivated(() => resetState())
  117. const resetState = () => {
  118. showDataBoard.value = false
  119. showPerformanceAnalysis.value = false
  120. inputMessage.value = ''
  121. messages.value = []
  122. showUseReports.value = false
  123. isLoading.value = false
  124. }
  125. const scrollToBottom = async () => {
  126. await nextTick()
  127. if (messagesRef.value) {
  128. messagesRef.value.scrollTop = messagesRef.value.scrollHeight
  129. }
  130. }
  131. const handleSend = async () => {
  132. const text = inputMessage.value.trim()
  133. if (!text || isLoading.value) return
  134. inputMessage.value = ''
  135. isLoading.value = true
  136. // 添加用户消息
  137. messages.value.push({ id: Date.now(), type: 'user', text })
  138. await scrollToBottom()
  139. // 添加 AI 占位消息
  140. const assistantMsg = {
  141. id: Date.now() + 1,
  142. type: 'assistant',
  143. status: 'loading',
  144. steps: [],
  145. result: null,
  146. errorText: ''
  147. }
  148. messages.value.push(assistantMsg)
  149. await scrollToBottom()
  150. try {
  151. const response = await fetch(`${AI_SERVICE_URL}/api/smart-query-stream`, {
  152. method: 'POST',
  153. headers: { 'Content-Type': 'application/json' },
  154. body: JSON.stringify({
  155. question: text,
  156. user_id: String(userStore.id || 'anonymous'),
  157. llm_type: 'claude'
  158. })
  159. })
  160. if (!response.ok) {
  161. throw new Error(`服务请求失败 (${response.status})`)
  162. }
  163. const reader = response.body.getReader()
  164. const decoder = new TextDecoder()
  165. let buffer = ''
  166. while (true) {
  167. const { done, value } = await reader.read()
  168. if (done) break
  169. buffer += decoder.decode(value, { stream: true })
  170. // SSE 事件以两个换行符分隔
  171. const blocks = buffer.split('\n\n')
  172. buffer = blocks.pop() // 保留未完整的块
  173. for (const block of blocks) {
  174. if (!block.trim()) continue
  175. const lines = block.split('\n')
  176. let eventType = ''
  177. let dataStr = ''
  178. for (const line of lines) {
  179. if (line.startsWith('event: ')) eventType = line.slice(7).trim()
  180. else if (line.startsWith('data: ')) dataStr = line.slice(6).trim()
  181. }
  182. if (!dataStr) continue
  183. let data
  184. try { data = JSON.parse(dataStr) } catch { continue }
  185. if (eventType === 'progress') {
  186. const existing = assistantMsg.steps.find(s => s.step === data.step)
  187. if (existing) {
  188. Object.assign(existing, data)
  189. } else {
  190. assistantMsg.steps.push({ ...data })
  191. }
  192. // 触发响应式更新
  193. messages.value = [...messages.value]
  194. await scrollToBottom()
  195. } else if (eventType === 'result') {
  196. // 将所有仍在 running 的步骤标记为 done
  197. assistantMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
  198. assistantMsg.result = data
  199. assistantMsg.status = 'done'
  200. messages.value = [...messages.value]
  201. await scrollToBottom()
  202. } else if (eventType === 'error') {
  203. assistantMsg.status = 'error'
  204. assistantMsg.errorText = data.message || '请求失败,请稍后重试'
  205. messages.value = [...messages.value]
  206. }
  207. }
  208. }
  209. // 若未收到 result 事件
  210. if (assistantMsg.status === 'loading') {
  211. assistantMsg.status = 'error'
  212. assistantMsg.errorText = '未收到服务响应,请检查 AI 服务是否正常运行'
  213. messages.value = [...messages.value]
  214. }
  215. } catch (e) {
  216. assistantMsg.status = 'error'
  217. assistantMsg.errorText = e.message || '网络错误,请检查 AI 服务连接'
  218. messages.value = [...messages.value]
  219. } finally {
  220. isLoading.value = false
  221. await scrollToBottom()
  222. }
  223. }
  224. const handleReportClick = () => { showDataBoard.value = true }
  225. const handlePerformanceClick = () => { showPerformanceAnalysis.value = true }
  226. const handleUseReportClick = () => { showUseReports.value = true }
  227. const handleBack = () => {
  228. showDataBoard.value = false
  229. showPerformanceAnalysis.value = false
  230. showUseReports.value = false
  231. }
  232. </script>
  233. <style scoped>
  234. .ai-assist-wrapper {
  235. background: #f5f5f5;
  236. min-height: 100vh;
  237. }
  238. .ai-assist-container {
  239. height: 100vh;
  240. display: flex;
  241. flex-direction: column;
  242. background: linear-gradient(135deg, #f5f7fa 0%, #c3cfe2 100%);
  243. padding: 0 20px 20px;
  244. box-sizing: border-box;
  245. }
  246. /* ===== 消息区域 ===== */
  247. .messages-area {
  248. flex: 1;
  249. overflow-y: auto;
  250. padding: 20px 0;
  251. }
  252. .welcome-message {
  253. height: 100%;
  254. display: flex;
  255. align-items: center;
  256. justify-content: center;
  257. }
  258. .welcome-message h2 {
  259. color: #333;
  260. font-size: 28px;
  261. font-weight: 400;
  262. margin: 0;
  263. opacity: 0.8;
  264. }
  265. .messages-list {
  266. display: flex;
  267. flex-direction: column;
  268. gap: 16px;
  269. }
  270. .message-item {
  271. display: flex;
  272. }
  273. .message-item.user {
  274. justify-content: flex-end;
  275. }
  276. .message-item.assistant {
  277. justify-content: flex-start;
  278. }
  279. /* 用户气泡 */
  280. .user-bubble {
  281. max-width: 70%;
  282. background: #557DDB;
  283. color: #fff;
  284. padding: 10px 16px;
  285. border-radius: 18px 18px 4px 18px;
  286. font-size: 14px;
  287. line-height: 1.6;
  288. word-break: break-word;
  289. }
  290. /* AI 气泡 */
  291. .assistant-bubble {
  292. max-width: 80%;
  293. background: #fff;
  294. padding: 14px 16px;
  295. border-radius: 18px 18px 18px 4px;
  296. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
  297. font-size: 14px;
  298. line-height: 1.6;
  299. }
  300. /* ===== 进度步骤 ===== */
  301. .progress-steps {
  302. display: flex;
  303. flex-direction: column;
  304. gap: 6px;
  305. margin-bottom: 10px;
  306. }
  307. .step-item {
  308. display: flex;
  309. align-items: center;
  310. gap: 6px;
  311. font-size: 13px;
  312. color: #888;
  313. }
  314. .step-item.done {
  315. color: #67c23a;
  316. }
  317. .step-item.running {
  318. color: #557DDB;
  319. }
  320. .step-icon {
  321. width: 16px;
  322. height: 16px;
  323. flex-shrink: 0;
  324. display: flex;
  325. align-items: center;
  326. justify-content: center;
  327. }
  328. .spin-icon {
  329. animation: spin 1s linear infinite;
  330. }
  331. @keyframes spin {
  332. from {
  333. transform: rotate(0deg);
  334. }
  335. to {
  336. transform: rotate(360deg);
  337. }
  338. }
  339. .step-detail {
  340. color: #aaa;
  341. font-size: 12px;
  342. }
  343. .step-duration {
  344. color: #aaa;
  345. font-size: 12px;
  346. margin-left: auto;
  347. }
  348. /* ===== 结果内容 ===== */
  349. .result-content {
  350. margin-top: 4px;
  351. }
  352. .knowledge-answer .answer-text {
  353. color: #333;
  354. white-space: pre-wrap;
  355. line-height: 1.8;
  356. }
  357. .sources {
  358. margin-top: 10px;
  359. display: flex;
  360. align-items: center;
  361. flex-wrap: wrap;
  362. gap: 6px;
  363. }
  364. .sources-label {
  365. font-size: 12px;
  366. color: #999;
  367. }
  368. .source-tag {
  369. font-size: 12px;
  370. }
  371. .result-summary {
  372. color: #666;
  373. font-size: 13px;
  374. margin-bottom: 8px;
  375. }
  376. .result-table {
  377. width: 100%;
  378. }
  379. .no-data {
  380. color: #999;
  381. font-size: 13px;
  382. text-align: center;
  383. padding: 12px 0;
  384. }
  385. .timing-info {
  386. margin-top: 10px;
  387. font-size: 12px;
  388. color: #bbb;
  389. text-align: right;
  390. }
  391. .error-message {
  392. display: flex;
  393. align-items: center;
  394. gap: 6px;
  395. color: #f56c6c;
  396. font-size: 13px;
  397. }
  398. /* ===== 底部输入区 ===== */
  399. .chat-container {
  400. flex-shrink: 0;
  401. }
  402. .quick-action {
  403. margin-bottom: 10px;
  404. display: flex;
  405. gap: 8px;
  406. }
  407. .report-btn {
  408. color: #557DDB;
  409. border: 1px solid #557DDB;
  410. padding: 8px 16px;
  411. border-radius: 4px;
  412. font-size: 14px;
  413. }
  414. .report-btn:hover {
  415. background-color: #557DDB;
  416. color: white;
  417. }
  418. .input-container {
  419. position: relative;
  420. }
  421. .chat-input :deep(.el-textarea__inner) {
  422. border: 1px solid #e0e0e0;
  423. border-radius: 12px;
  424. padding: 16px;
  425. font-size: 14px;
  426. resize: none;
  427. box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
  428. padding-right: 60px;
  429. }
  430. .send-btn {
  431. position: absolute;
  432. right: 8px;
  433. bottom: 8px;
  434. background-color: #557DDB;
  435. color: white;
  436. border: none;
  437. display: flex;
  438. align-items: center;
  439. justify-content: center;
  440. z-index: 10;
  441. }
  442. .send-btn:hover:not(.is-disabled) {
  443. background-color: #3a6bd9;
  444. }
  445. .send-btn.is-disabled {
  446. background-color: #c0c4cc;
  447. cursor: not-allowed;
  448. }
  449. @media (max-width: 768px) {
  450. .ai-assist-container {
  451. padding: 0 16px 16px;
  452. }
  453. .welcome-message h2 {
  454. font-size: 24px;
  455. }
  456. .user-bubble,
  457. .assistant-bubble {
  458. max-width: 95%;
  459. }
  460. }
  461. </style>