index.vue 14 KB

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