Przeglądaj źródła

feat(assistant,score): 新增表格排序、SQL折叠和AI对话优化

1. 为评分事件页面添加后端排序支持,默认按事件时间降序
2. 为AI助手页面新增SQL代码折叠展示、意图澄清提示和历史会话支持
3. 优化AI接口配置和输入框状态,修复占位文本和禁用逻辑
4. 重构SSE事件处理逻辑,统一事件回调处理
huoyi 1 tydzień temu
rodzic
commit
0ddc27ca76
2 zmienionych plików z 197 dodań i 69 usunięć
  1. 180 66
      src/views/assistant/index.vue
  2. 17 3
      src/views/score/event/index.vue

+ 180 - 66
src/views/assistant/index.vue

@@ -60,6 +60,17 @@
60 60
                       min-width="100" />
61 61
                   </el-table>
62 62
                   <div v-else-if="msg.result.rows" class="no-data">暂无数据</div>
63
+                  
64
+                  <!-- SQL 折叠 -->
65
+                  <div v-if="msg.result.sql" class="sql-block">
66
+                    <div class="sql-toggle" @click="toggleSql(msg.id)">
67
+                      <span class="sql-label">SQL</span>
68
+                      <span class="sql-arrow">{{ msg.showSql ? '▲' : '▼' }}</span>
69
+                    </div>
70
+                    <div v-if="msg.showSql" class="sql-code">
71
+                      <pre>{{ msg.result.sql }}</pre>
72
+                    </div>
73
+                  </div>
63 74
                 </div>
64 75
 
65 76
                 <!-- 耗时 -->
@@ -81,6 +92,11 @@
81 92
         </div>
82 93
       </div>
83 94
 
95
+      <!-- 意图澄清 -->
96
+      <div v-if="clarifyQuestion" class="clarify-bar">
97
+        <span class="clarify-text">{{ clarifyQuestion }}</span>
98
+      </div>
99
+
84 100
       <!-- 底部输入区 -->
85 101
       <div class="chat-container">
86 102
         <div class="quick-action">
@@ -88,10 +104,9 @@
88 104
           <el-button class="report-btn" @click="handlePerformanceClick">绩效分析报告</el-button>
89 105
           <el-button class="report-btn" @click="handleUseReportClick">使用报表</el-button>
90 106
         </div>
91
-        <!--请输入您的问题,按 Enter 发送...-->
92 107
         <div class="input-container">
93
-          <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="功能开发中,请敬请期待"
94
-            class="chat-input" @keydown.enter.exact.prevent="handleSend" :disabled="true" />
108
+          <el-input v-model="inputMessage" type="textarea" :rows="3" placeholder="请输入您的问题,按 Enter 发送..."
109
+            class="chat-input" @keydown.enter.exact.prevent="handleSend" />
95 110
           <el-button :disabled="!inputMessage.trim() || isLoading" circle class="send-btn" @click="handleSend">
96 111
             <svg viewBox="0 0 24 24" fill="currentColor" width="18" height="18">
97 112
               <path d="M2.01 21L23 12 2.01 3 2 10l15 2-15 2z" />
@@ -114,7 +129,9 @@ import PerformanceAnalysis from './components/performanceAnalysis.vue'
114 129
 import useUserStore from '@/store/modules/user'
115 130
 import UseReports from './components/useReports.vue'
116 131
 const userStore = useUserStore()
117
-const AI_SERVICE_URL = import.meta.env.VITE_AI_SERVICE_URL || 'http://localhost:8000'
132
+
133
+const AI_BASE = import.meta.env.VITE_AI_SERVICE_URL || 'http://airport-test.samsundot.com:9015'
134
+const AI_URL = AI_BASE + '/api/query-stream'
118 135
 
119 136
 const inputMessage = ref('')
120 137
 const isLoading = ref(false)
@@ -123,6 +140,9 @@ const messagesRef = ref(null)
123 140
 const showDataBoard = ref(false)
124 141
 const showPerformanceAnalysis = ref(false)
125 142
 const showUseReports = ref(false)
143
+const sessionId = ref('app_' + Date.now())
144
+const history = ref([])
145
+const clarifyQuestion = ref('')
126 146
 
127 147
 onMounted(() => resetState())
128 148
 onActivated(() => resetState())
@@ -135,6 +155,9 @@ const resetState = () => {
135 155
   messages.value = []
136 156
   showUseReports.value = false
137 157
   isLoading.value = false
158
+  sessionId.value = 'app_' + Date.now()
159
+  history.value = []
160
+  clarifyQuestion.value = ''
138 161
 }
139 162
 
140 163
 const scrollToBottom = async () => {
@@ -144,11 +167,84 @@ const scrollToBottom = async () => {
144 167
   }
145 168
 }
146 169
 
170
+const getUserId = () => {
171
+  return String(userStore.id || 1)
172
+}
173
+
174
+const toggleSql = (msgId) => {
175
+  const msg = messages.value.find(m => m.id === msgId)
176
+  if (msg) {
177
+    msg.showSql = !msg.showSql
178
+  }
179
+}
180
+
181
+const handleEvent = (aiMsg, data) => {
182
+  if (data.step !== undefined && data.name) {
183
+    // 进度步骤
184
+    const steps = [...(aiMsg.steps || [])]
185
+    const si = steps.findIndex(s => s.name === data.name)
186
+    const step = { 
187
+      step: si >= 0 ? si : steps.length, 
188
+      name: data.name, 
189
+      status: data.status, 
190
+      duration: data.duration 
191
+    }
192
+    if (si >= 0) {
193
+      steps[si] = step
194
+    } else {
195
+      steps.push(step)
196
+    }
197
+    aiMsg.steps = steps
198
+    messages.value = [...messages.value]
199
+    return
200
+  }
201
+
202
+  if (data.question) {
203
+    // 意图澄清
204
+    clarifyQuestion.value = data.question
205
+    aiMsg.status = 'done'
206
+    messages.value = [...messages.value]
207
+    return
208
+  }
209
+
210
+  if (data.columns !== undefined || data.answer !== undefined) {
211
+    // 最终结果
212
+    aiMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
213
+    
214
+    // 适配数据结构
215
+    aiMsg.result = {
216
+      query_type: data.answer ? 'knowledge' : 'data',
217
+      answer: data.answer || '',
218
+      message: data.message || '',
219
+      sql: data.sql || '',
220
+      columns: data.columns || [],
221
+      rows: data.rows || [],
222
+      sources: data.sources || [],
223
+      timing: { total: 0 },
224
+      success: data.success !== false
225
+    }
226
+    aiMsg.showSql = false
227
+    aiMsg.isError = !data.success
228
+    aiMsg.status = 'done'
229
+    messages.value = [...messages.value]
230
+    
231
+    // 记录历史
232
+    const prevUserMsg = messages.value.find(m => m.id < aiMsg.id && m.type === 'user')
233
+    if (prevUserMsg) {
234
+      history.value.push({
235
+        question: prevUserMsg.text || '',
236
+        sql: data.sql || '',
237
+      })
238
+    }
239
+  }
240
+}
241
+
147 242
 const handleSend = async () => {
148 243
   const text = inputMessage.value.trim()
149 244
   if (!text || isLoading.value) return
150 245
 
151 246
   inputMessage.value = ''
247
+  clarifyQuestion.value = ''
152 248
   isLoading.value = true
153 249
 
154 250
   // 添加用户消息
@@ -162,25 +258,27 @@ const handleSend = async () => {
162 258
     status: 'loading',
163 259
     steps: [],
164 260
     result: null,
165
-    errorText: ''
261
+    errorText: '',
262
+    showSql: false,
263
+    isError: false
166 264
   }
167 265
   messages.value.push(assistantMsg)
168 266
   await scrollToBottom()
169 267
 
170 268
   try {
171
-    const response = await fetch(`${AI_SERVICE_URL}/api/smart-query-stream`, {
269
+    const response = await fetch(AI_URL, {
172 270
       method: 'POST',
173 271
       headers: { 'Content-Type': 'application/json' },
174 272
       body: JSON.stringify({
175 273
         question: text,
176
-        user_id: String(userStore.id || 'anonymous'),
177
-        llm_type: 'claude'
178
-      })
274
+        user_id: getUserId(),
275
+        llm_type: 'qwen',
276
+        session_id: sessionId.value,
277
+        history: history.value.slice(-3),
278
+      }),
179 279
     })
180 280
 
181
-    if (!response.ok) {
182
-      throw new Error(`服务请求失败 (${response.status})`)
183
-    }
281
+    if (!response.ok) throw new Error('请求失败 ' + response.status)
184 282
 
185 283
     const reader = response.body.getReader()
186 284
     const decoder = new TextDecoder()
@@ -191,66 +289,27 @@ const handleSend = async () => {
191 289
       if (done) break
192 290
 
193 291
       buffer += decoder.decode(value, { stream: true })
194
-      // SSE 事件以两个换行符分隔
195
-      const blocks = buffer.split('\n\n')
196
-      buffer = blocks.pop() // 保留未完整的块
197
-
198
-      for (const block of blocks) {
199
-        if (!block.trim()) continue
200
-        const lines = block.split('\n')
201
-        let eventType = ''
202
-        let dataStr = ''
203
-        for (const line of lines) {
204
-          if (line.startsWith('event: ')) eventType = line.slice(7).trim()
205
-          else if (line.startsWith('data: ')) dataStr = line.slice(6).trim()
206
-        }
207
-        if (!dataStr) continue
208
-
209
-        let data
210
-        try { data = JSON.parse(dataStr) } catch { continue }
211
-
212
-        if (eventType === 'progress') {
213
-          const existing = assistantMsg.steps.find(s => s.step === data.step)
214
-          if (existing) {
215
-            Object.assign(existing, data)
216
-          } else {
217
-            assistantMsg.steps.push({ ...data })
218
-          }
219
-          // 触发响应式更新
220
-          messages.value = [...messages.value]
221
-          await scrollToBottom()
222
-
223
-        } else if (eventType === 'result') {
224
-          // 将所有仍在 running 的步骤标记为 done
225
-          assistantMsg.steps.forEach(s => { if (s.status === 'running') s.status = 'done' })
226
-          assistantMsg.result = data
227
-          assistantMsg.status = 'done'
228
-          messages.value = [...messages.value]
229
-          await scrollToBottom()
230
-
231
-        } else if (eventType === 'error') {
232
-          assistantMsg.status = 'error'
233
-          assistantMsg.errorText = data.message || '请求失败,请稍后重试'
234
-          messages.value = [...messages.value]
292
+      const lines = buffer.split('\n')
293
+      buffer = lines.pop()
294
+
295
+      for (const line of lines) {
296
+        if (line.startsWith('data: ')) {
297
+          try {
298
+            const data = JSON.parse(line.slice(6))
299
+            handleEvent(assistantMsg, data)
300
+            await scrollToBottom()
301
+          } catch (e) {}
235 302
         }
236 303
       }
237 304
     }
238
-
239
-    // 若未收到 result 事件
240
-    if (assistantMsg.status === 'loading') {
241
-      assistantMsg.status = 'error'
242
-      assistantMsg.errorText = '未收到服务响应,请检查 AI 服务是否正常运行'
243
-      messages.value = [...messages.value]
244
-    }
245
-
246
-  } catch (e) {
305
+  } catch (err) {
247 306
     assistantMsg.status = 'error'
248
-    assistantMsg.errorText = e.message || '网络错误,请检查 AI 服务连接'
307
+    assistantMsg.errorText = '请求失败,请检查网络后重试'
249 308
     messages.value = [...messages.value]
250
-  } finally {
251
-    isLoading.value = false
252
-    await scrollToBottom()
253 309
   }
310
+
311
+  isLoading.value = false
312
+  await scrollToBottom()
254 313
 }
255 314
 
256 315
 const handleReportClick = () => { showDataBoard.value = true }
@@ -459,6 +518,61 @@ const handleBack = () => {
459 518
   font-size: 13px;
460 519
 }
461 520
 
521
+/* ===== SQL 折叠样式 ===== */
522
+.sql-block {
523
+  margin-top: 12px;
524
+}
525
+
526
+.sql-toggle {
527
+  display: flex;
528
+  align-items: center;
529
+  gap: 8px;
530
+  cursor: pointer;
531
+  color: #557DDB;
532
+  font-size: 13px;
533
+}
534
+
535
+.sql-label {
536
+  background: #e6f0ff;
537
+  padding: 2px 8px;
538
+  border-radius: 4px;
539
+  font-weight: 600;
540
+}
541
+
542
+.sql-arrow {
543
+  font-size: 12px;
544
+}
545
+
546
+.sql-code {
547
+  margin-top: 8px;
548
+  background: #f5f7fa;
549
+  padding: 12px;
550
+  border-radius: 8px;
551
+  overflow-x: auto;
552
+}
553
+
554
+.sql-code pre {
555
+  margin: 0;
556
+  font-size: 12px;
557
+  color: #333;
558
+  white-space: pre-wrap;
559
+  word-break: break-all;
560
+}
561
+
562
+/* ===== 意图澄清 ===== */
563
+.clarify-bar {
564
+  background: #fff3cd;
565
+  border: 1px solid #ffc107;
566
+  padding: 12px 16px;
567
+  border-radius: 8px;
568
+  margin-bottom: 16px;
569
+}
570
+
571
+.clarify-text {
572
+  color: #856404;
573
+  font-size: 14px;
574
+}
575
+
462 576
 /* ===== 底部输入区 ===== */
463 577
 .chat-container {
464 578
   flex-shrink: 0;

+ 17 - 3
src/views/score/event/index.vue

@@ -96,13 +96,13 @@
96 96
       <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" />
97 97
     </el-row>
98 98
 
99
-    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange" style="width: 100%;" fit="true"
100
-      :scrollbar-always-on="true">
99
+    <el-table v-loading="loading" :data="list" @selection-change="handleSelectionChange" @sort-change="handleSortChange" style="width: 100%;" fit="true"
100
+      :scrollbar-always-on="true" :default-sort="{ prop: sortField, order: sortOrder }">
101 101
       <el-table-column type="selection" width="55" align="center" resizable />
102 102
       <el-table-column label="配分层级" align="center" prop="org" width="110" resizable>
103 103
         <template #default="{ row }"><dict-tag :options="score_level" :value="row.org" /></template>
104 104
       </el-table-column>
105
-      <el-table-column label="事件时间" align="center" prop="eventTime" width="120" resizable>
105
+      <el-table-column label="事件时间" align="center" prop="eventTime" width="120" resizable sortable="custom">
106 106
         <template #default="{ row }">{{ parseTime(row.eventTime, '{y}-{m}-{d}') }}</template>
107 107
       </el-table-column>
108 108
       <el-table-column label="维度" align="center" prop="dimensionName" width="150" resizable />
@@ -332,6 +332,8 @@ const channelOptions = ref([])
332 332
 const postTreeData = ref([])
333 333
 
334 334
 const queryParams = reactive({ pageNum: 1, pageSize: 10, personId: null, personName: '', deptId: null, deptName: '', teamId: null, teamName: '', groupId: null, groupName: '', dimensionId: null, sourceType: '', org: '', scoreType: null, regionalId: null, channelId: null, postId: null })
335
+const sortField = ref('eventTime')
336
+const sortOrder = ref('descending')
335 337
 const form = reactive({ id: null, dimensionId: null, dimensionName: '', indicatorId: null, level2Id: null, level2Name: '', level3Id: null, level3Name: '', level4Id: null, level4Name: '', eventTime: '', location: '', personId: null, deptName: '', deptId: null, teamId: null, groupId: null, scoreValue: 0, cascadeScore: 0, eventDesc: '', remark: '', org: '', regionalId: null, channelId: null, postId: null })
336 338
 const formChannelOptions = ref([])
337 339
 const rules = computed(() => {
@@ -540,9 +542,21 @@ function getList() {
540 542
   loading.value = true
541 543
   const p = { ...queryParams }
542 544
   if (dateRange.value?.length === 2) { p['params[beginTime]'] = dateRange.value[0]; p['params[endTime]'] = dateRange.value[1] }
545
+  // 添加排序参数
546
+  if (sortField.value && sortOrder.value) {
547
+    const orderType = sortOrder.value === 'ascending' ? 'asc' : 'desc'
548
+    p[`sorts[${sortField.value}]`] = orderType
549
+  }
543 550
   listScoreEvent(p).then(r => { list.value = r.rows; total.value = r.total }).finally(() => loading.value = false)
544 551
 }
545 552
 
553
+function handleSortChange({ prop, order }) {
554
+  sortField.value = prop
555
+  sortOrder.value = order
556
+  queryParams.pageNum = 1
557
+  getList()
558
+}
559
+
546 560
 function handleQuery() { queryParams.pageNum = 1; getList() }
547 561
 function resetQuery() {
548 562
   dateRange.value = []