Bladeren bron

feat: 新增 AI数据助手 聊天页面

- src/pages/ai-chat/index.vue:完整聊天界面
  - SSE流式接收(fetch + ReadableStream)
  - 进度步骤展示、数据表格、SQL折叠块、知识库回答
  - 意图澄清提示、加载动画、对话历史
- pages.json:注册 ai-chat 路由(自定义导航栏)
- home/index.vue:右下角悬浮 AI 按钮入口

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
simonlll 1 week geleden
bovenliggende
commit
e4a541ae41
3 gewijzigde bestanden met toevoegingen van 584 en 0 verwijderingen
  1. 7 0
      src/pages.json
  2. 548 0
      src/pages/ai-chat/index.vue
  3. 29 0
      src/pages/home/index.vue

+ 7 - 0
src/pages.json

@@ -1,6 +1,13 @@
1 1
 {
2 2
   "pages": [
3 3
     {
4
+      "path": "pages/ai-chat/index",
5
+      "style": {
6
+        "navigationBarTitleText": "AI数据助手",
7
+        "navigationStyle": "custom"
8
+      }
9
+    },
10
+    {
4 11
       "path": "pages/login",
5 12
       "style": {
6 13
         "navigationBarTitleText": "登录"

+ 548 - 0
src/pages/ai-chat/index.vue

@@ -0,0 +1,548 @@
1
+<template>
2
+  <view class="ai-chat-page">
3
+    <!-- 顶部导航 -->
4
+    <view class="nav-bar">
5
+      <view class="back-btn" @click="goBack">
6
+        <text class="back-icon">‹</text>
7
+      </view>
8
+      <text class="nav-title">AI数据助手</text>
9
+      <view class="nav-right" @click="clearChat">
10
+        <text class="clear-btn">清空</text>
11
+      </view>
12
+    </view>
13
+
14
+    <!-- 消息列表 -->
15
+    <scroll-view
16
+      class="msg-list"
17
+      scroll-y
18
+      :scroll-top="scrollTop"
19
+      :scroll-with-animation="true"
20
+      @scrolltolower="onScrollBottom"
21
+    >
22
+      <!-- 欢迎语 -->
23
+      <view v-if="messages.length === 0" class="welcome">
24
+        <image class="welcome-icon" src="/static/images/logo.png" mode="aspectFit" />
25
+        <text class="welcome-title">您好!我是机场安检数据助手</text>
26
+        <text class="welcome-sub">请问有什么可以帮您查询的?</text>
27
+        <view class="example-tags">
28
+          <view
29
+            v-for="tag in exampleTags"
30
+            :key="tag"
31
+            class="tag"
32
+            @click="sendExample(tag)"
33
+          >{{ tag }}</view>
34
+        </view>
35
+      </view>
36
+
37
+      <!-- 消息气泡 -->
38
+      <view
39
+        v-for="(msg, idx) in messages"
40
+        :key="idx"
41
+        :class="['msg-row', msg.role === 'user' ? 'msg-user' : 'msg-assistant']"
42
+      >
43
+        <!-- AI头像 -->
44
+        <view v-if="msg.role === 'assistant'" class="avatar">
45
+          <text class="avatar-text">AI</text>
46
+        </view>
47
+
48
+        <view class="bubble-wrap">
49
+          <!-- 进度提示(流式过程中) -->
50
+          <view v-if="msg.steps && msg.steps.length" class="steps">
51
+            <view v-for="(step, si) in msg.steps" :key="si" :class="['step', step.status]">
52
+              <text class="step-dot">{{ step.status === 'done' ? '✓' : step.status === 'running' ? '…' : '·' }}</text>
53
+              <text class="step-name">{{ step.name }}</text>
54
+              <text v-if="step.duration" class="step-time"> {{ step.duration }}s</text>
55
+            </view>
56
+          </view>
57
+
58
+          <!-- 文字回答(知识库/AI直接回答) -->
59
+          <view v-if="msg.answer" class="bubble knowledge">
60
+            <text class="bubble-text" selectable>{{ msg.answer }}</text>
61
+            <view v-if="msg.sources && msg.sources.length" class="sources">
62
+              <text class="source-label">来源:</text>
63
+              <text class="source-item" v-for="s in msg.sources" :key="s.filename">{{ s.filename }}</text>
64
+            </view>
65
+          </view>
66
+
67
+          <!-- 数据查询结果 -->
68
+          <view v-else-if="msg.role === 'assistant' && !msg.loading" class="bubble data">
69
+            <text v-if="msg.message" class="result-msg" :class="{ error: msg.isError }">{{ msg.message }}</text>
70
+
71
+            <!-- 数据表格 -->
72
+            <view v-if="msg.rows && msg.rows.length" class="data-table">
73
+              <scroll-view scroll-x class="table-scroll">
74
+                <view class="table-head">
75
+                  <text v-for="col in msg.columns" :key="col" class="th">{{ col }}</text>
76
+                </view>
77
+                <view v-for="(row, ri) in msg.rows" :key="ri" :class="['table-row', ri % 2 === 0 ? 'even' : '']">
78
+                  <text v-for="col in msg.columns" :key="col" class="td">{{ row[col] ?? '-' }}</text>
79
+                </view>
80
+              </scroll-view>
81
+            </view>
82
+
83
+            <!-- SQL 折叠 -->
84
+            <view v-if="msg.sql" class="sql-block">
85
+              <view class="sql-toggle" @click="toggleSql(idx)">
86
+                <text class="sql-label">SQL</text>
87
+                <text class="sql-arrow">{{ msg.showSql ? '▲' : '▼' }}</text>
88
+              </view>
89
+              <view v-if="msg.showSql" class="sql-code">
90
+                <text selectable>{{ msg.sql }}</text>
91
+              </view>
92
+            </view>
93
+          </view>
94
+
95
+          <!-- 用户消息气泡 -->
96
+          <view v-if="msg.role === 'user'" class="bubble user">
97
+            <text class="bubble-text" selectable>{{ msg.text }}</text>
98
+          </view>
99
+
100
+          <!-- 加载中 -->
101
+          <view v-if="msg.loading" class="bubble loading">
102
+            <view class="dot-loader">
103
+              <view class="dot" /><view class="dot" /><view class="dot" />
104
+            </view>
105
+          </view>
106
+        </view>
107
+      </view>
108
+
109
+      <!-- 底部留白 -->
110
+      <view style="height: 20rpx;" />
111
+    </scroll-view>
112
+
113
+    <!-- 意图澄清 -->
114
+    <view v-if="clarifyQuestion" class="clarify-bar">
115
+      <text class="clarify-text">{{ clarifyQuestion }}</text>
116
+    </view>
117
+
118
+    <!-- 输入区 -->
119
+    <view class="input-bar">
120
+      <input
121
+        v-model="inputText"
122
+        class="input"
123
+        placeholder="请输入您的问题..."
124
+        :disabled="loading"
125
+        confirm-type="send"
126
+        @confirm="sendMessage"
127
+      />
128
+      <view :class="['send-btn', loading ? 'disabled' : '']" @click="sendMessage">
129
+        <text class="send-text">{{ loading ? '…' : '发送' }}</text>
130
+      </view>
131
+    </view>
132
+  </view>
133
+</template>
134
+
135
+<script>
136
+const AI_URL = 'https://airport.samsundot.com/api/query-stream'
137
+
138
+export default {
139
+  name: 'AiChat',
140
+  data() {
141
+    return {
142
+      inputText: '',
143
+      messages: [],
144
+      loading: false,
145
+      scrollTop: 0,
146
+      clarifyQuestion: '',
147
+      sessionId: 'app_' + Date.now(),
148
+      history: [],
149
+      exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
150
+    }
151
+  },
152
+  methods: {
153
+    goBack() {
154
+      uni.navigateBack()
155
+    },
156
+
157
+    clearChat() {
158
+      this.messages = []
159
+      this.history = []
160
+      this.clarifyQuestion = ''
161
+      this.sessionId = 'app_' + Date.now()
162
+    },
163
+
164
+    sendExample(text) {
165
+      this.inputText = text
166
+      this.sendMessage()
167
+    },
168
+
169
+    toggleSql(idx) {
170
+      this.$set(this.messages[idx], 'showSql', !this.messages[idx].showSql)
171
+    },
172
+
173
+    onScrollBottom() {},
174
+
175
+    scrollToBottom() {
176
+      this.$nextTick(() => {
177
+        this.scrollTop = 999999
178
+      })
179
+    },
180
+
181
+    getUserId() {
182
+      try {
183
+        const userStr = uni.getStorageSync('userInfo') || uni.getStorageSync('user')
184
+        if (userStr) {
185
+          const u = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
186
+          return String(u.userId || u.user_id || u.id || 1)
187
+        }
188
+      } catch (e) {}
189
+      return '1'
190
+    },
191
+
192
+    async sendMessage() {
193
+      const question = this.inputText.trim()
194
+      if (!question || this.loading) return
195
+
196
+      this.inputText = ''
197
+      this.clarifyQuestion = ''
198
+      this.loading = true
199
+
200
+      // 添加用户消息
201
+      this.messages.push({ role: 'user', text: question })
202
+
203
+      // 添加 AI 占位消息
204
+      const aiIdx = this.messages.length
205
+      this.messages.push({
206
+        role: 'assistant',
207
+        loading: true,
208
+        steps: [],
209
+        message: '',
210
+        sql: '',
211
+        columns: [],
212
+        rows: [],
213
+        answer: '',
214
+        sources: [],
215
+        showSql: false,
216
+      })
217
+      this.scrollToBottom()
218
+
219
+      try {
220
+        const response = await fetch(AI_URL, {
221
+          method: 'POST',
222
+          headers: { 'Content-Type': 'application/json' },
223
+          body: JSON.stringify({
224
+            question,
225
+            user_id: this.getUserId(),
226
+            llm_type: 'qwen',
227
+            session_id: this.sessionId,
228
+            history: this.history.slice(-3),
229
+          }),
230
+        })
231
+
232
+        if (!response.ok) throw new Error('请求失败 ' + response.status)
233
+
234
+        const reader = response.body.getReader()
235
+        const decoder = new TextDecoder()
236
+        let buffer = ''
237
+
238
+        while (true) {
239
+          const { done, value } = await reader.read()
240
+          if (done) break
241
+
242
+          buffer += decoder.decode(value, { stream: true })
243
+          const lines = buffer.split('\n')
244
+          buffer = lines.pop()
245
+
246
+          for (const line of lines) {
247
+            if (line.startsWith('data: ')) {
248
+              try {
249
+                const data = JSON.parse(line.slice(6))
250
+                this.handleEvent(aiIdx, data)
251
+              } catch (e) {}
252
+            }
253
+          }
254
+        }
255
+      } catch (err) {
256
+        this.$set(this.messages, aiIdx, {
257
+          ...this.messages[aiIdx],
258
+          loading: false,
259
+          isError: true,
260
+          message: '请求失败,请检查网络后重试',
261
+        })
262
+      }
263
+
264
+      this.loading = false
265
+      this.scrollToBottom()
266
+    },
267
+
268
+    handleEvent(aiIdx, data) {
269
+      const msg = this.messages[aiIdx]
270
+
271
+      if (data.step !== undefined && data.name) {
272
+        // 进度步骤
273
+        const steps = [...(msg.steps || [])]
274
+        const si = steps.findIndex(s => s.name === data.name)
275
+        const step = { name: data.name, status: data.status, duration: data.duration }
276
+        if (si >= 0) steps[si] = step
277
+        else steps.push(step)
278
+        this.$set(this.messages, aiIdx, { ...msg, steps })
279
+        return
280
+      }
281
+
282
+      if (data.question) {
283
+        // 意图澄清
284
+        this.clarifyQuestion = data.question
285
+        this.$set(this.messages, aiIdx, { ...msg, loading: false })
286
+        return
287
+      }
288
+
289
+      if (data.columns !== undefined || data.answer !== undefined) {
290
+        // 最终结果
291
+        this.$set(this.messages, aiIdx, {
292
+          ...msg,
293
+          loading: false,
294
+          message: data.message || '',
295
+          sql: data.sql || '',
296
+          columns: data.columns || [],
297
+          rows: data.rows || [],
298
+          answer: data.answer || '',
299
+          sources: data.sources || [],
300
+          isError: !data.success,
301
+          showSql: false,
302
+        })
303
+        // 记录历史
304
+        this.history.push({
305
+          question: this.messages[aiIdx - 1]?.text || '',
306
+          sql: data.sql || '',
307
+        })
308
+        this.scrollToBottom()
309
+      }
310
+    },
311
+  },
312
+}
313
+</script>
314
+
315
+<style lang="scss" scoped>
316
+$primary: #1890ff;
317
+$bg: #f5f6fa;
318
+$bubble-user: #1890ff;
319
+$bubble-ai: #ffffff;
320
+$text-main: #333;
321
+$text-sub: #999;
322
+
323
+.ai-chat-page {
324
+  display: flex;
325
+  flex-direction: column;
326
+  height: 100vh;
327
+  background: $bg;
328
+  font-size: 28rpx;
329
+}
330
+
331
+/* 导航栏 */
332
+.nav-bar {
333
+  display: flex;
334
+  align-items: center;
335
+  padding: 20rpx 30rpx;
336
+  padding-top: calc(20rpx + env(safe-area-inset-top));
337
+  background: #fff;
338
+  border-bottom: 1rpx solid #eee;
339
+  position: sticky;
340
+  top: 0;
341
+  z-index: 10;
342
+}
343
+.back-btn { width: 80rpx; display: flex; align-items: center; }
344
+.back-icon { font-size: 50rpx; color: $primary; line-height: 1; }
345
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; color: $text-main; }
346
+.nav-right { width: 80rpx; text-align: right; }
347
+.clear-btn { font-size: 26rpx; color: $text-sub; }
348
+
349
+/* 消息列表 */
350
+.msg-list {
351
+  flex: 1;
352
+  overflow: hidden;
353
+  padding: 20rpx 0;
354
+}
355
+
356
+/* 欢迎区 */
357
+.welcome {
358
+  display: flex;
359
+  flex-direction: column;
360
+  align-items: center;
361
+  padding: 80rpx 40rpx 40rpx;
362
+  gap: 16rpx;
363
+}
364
+.welcome-icon { width: 120rpx; height: 120rpx; border-radius: 24rpx; }
365
+.welcome-title { font-size: 32rpx; font-weight: 600; color: $text-main; }
366
+.welcome-sub { font-size: 26rpx; color: $text-sub; }
367
+.example-tags {
368
+  display: flex;
369
+  flex-wrap: wrap;
370
+  justify-content: center;
371
+  gap: 16rpx;
372
+  margin-top: 20rpx;
373
+}
374
+.tag {
375
+  padding: 12rpx 24rpx;
376
+  background: #fff;
377
+  border: 1rpx solid #d0e8ff;
378
+  border-radius: 40rpx;
379
+  font-size: 24rpx;
380
+  color: $primary;
381
+}
382
+
383
+/* 消息行 */
384
+.msg-row {
385
+  display: flex;
386
+  padding: 16rpx 24rpx;
387
+  gap: 16rpx;
388
+}
389
+.msg-user { flex-direction: row-reverse; }
390
+.msg-assistant { flex-direction: row; }
391
+
392
+/* 头像 */
393
+.avatar {
394
+  width: 72rpx;
395
+  height: 72rpx;
396
+  border-radius: 50%;
397
+  background: $primary;
398
+  display: flex;
399
+  align-items: center;
400
+  justify-content: center;
401
+  flex-shrink: 0;
402
+}
403
+.avatar-text { color: #fff; font-size: 22rpx; font-weight: 700; }
404
+
405
+/* 气泡包裹 */
406
+.bubble-wrap { flex: 1; display: flex; flex-direction: column; gap: 8rpx; max-width: 90%; }
407
+
408
+/* 气泡 */
409
+.bubble {
410
+  border-radius: 16rpx;
411
+  padding: 20rpx 24rpx;
412
+  word-break: break-all;
413
+}
414
+.bubble.user {
415
+  background: $bubble-user;
416
+  border-bottom-right-radius: 4rpx;
417
+  .bubble-text { color: #fff; }
418
+}
419
+.bubble.data, .bubble.knowledge {
420
+  background: $bubble-ai;
421
+  border-bottom-left-radius: 4rpx;
422
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06);
423
+}
424
+.bubble-text { line-height: 1.6; color: $text-main; }
425
+
426
+/* 进度步骤 */
427
+.steps {
428
+  display: flex;
429
+  flex-direction: column;
430
+  gap: 6rpx;
431
+  padding: 0 4rpx;
432
+}
433
+.step {
434
+  display: flex;
435
+  align-items: center;
436
+  gap: 8rpx;
437
+  font-size: 22rpx;
438
+  color: $text-sub;
439
+  &.done { color: #52c41a; }
440
+  &.running { color: $primary; }
441
+}
442
+.step-dot { font-size: 20rpx; width: 24rpx; }
443
+.step-time { font-size: 20rpx; }
444
+
445
+/* 结果状态 */
446
+.result-msg {
447
+  font-size: 24rpx;
448
+  color: $text-sub;
449
+  display: block;
450
+  margin-bottom: 12rpx;
451
+  &.error { color: #ff4d4f; }
452
+}
453
+
454
+/* 表格 */
455
+.data-table { margin-top: 8rpx; }
456
+.table-scroll { width: 100%; }
457
+.table-head, .table-row {
458
+  display: flex;
459
+  min-width: max-content;
460
+}
461
+.table-head { background: #f0f7ff; }
462
+.table-row.even { background: #fafafa; }
463
+.th, .td {
464
+  min-width: 120rpx;
465
+  padding: 12rpx 16rpx;
466
+  font-size: 24rpx;
467
+  white-space: nowrap;
468
+  border-bottom: 1rpx solid #f0f0f0;
469
+}
470
+.th { font-weight: 600; color: $primary; }
471
+.td { color: $text-main; }
472
+
473
+/* SQL 折叠 */
474
+.sql-block { margin-top: 12rpx; }
475
+.sql-toggle {
476
+  display: flex;
477
+  align-items: center;
478
+  gap: 8rpx;
479
+  padding: 8rpx 0;
480
+}
481
+.sql-label { font-size: 22rpx; color: $text-sub; background: #f0f0f0; padding: 2rpx 12rpx; border-radius: 6rpx; }
482
+.sql-arrow { font-size: 20rpx; color: $text-sub; }
483
+.sql-code {
484
+  background: #1e1e1e;
485
+  border-radius: 8rpx;
486
+  padding: 16rpx;
487
+  text { font-size: 22rpx; color: #d4d4d4; line-height: 1.5; }
488
+}
489
+
490
+/* 知识库来源 */
491
+.sources { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid #f0f0f0; }
492
+.source-label { font-size: 22rpx; color: $text-sub; }
493
+.source-item { font-size: 22rpx; color: $primary; }
494
+
495
+/* 加载动画 */
496
+.loading { background: $bubble-ai !important; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
497
+.dot-loader { display: flex; gap: 8rpx; align-items: center; height: 32rpx; }
498
+.dot {
499
+  width: 12rpx; height: 12rpx; border-radius: 50%; background: #ccc;
500
+  animation: bounce 1.2s infinite ease-in-out;
501
+  &:nth-child(2) { animation-delay: 0.2s; }
502
+  &:nth-child(3) { animation-delay: 0.4s; }
503
+}
504
+@keyframes bounce {
505
+  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
506
+  40% { transform: scale(1); opacity: 1; }
507
+}
508
+
509
+/* 澄清提示 */
510
+.clarify-bar {
511
+  background: #fff7e6;
512
+  border-top: 1rpx solid #ffe58f;
513
+  padding: 16rpx 30rpx;
514
+}
515
+.clarify-text { font-size: 26rpx; color: #d46b08; }
516
+
517
+/* 输入栏 */
518
+.input-bar {
519
+  display: flex;
520
+  align-items: center;
521
+  padding: 16rpx 24rpx;
522
+  padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
523
+  background: #fff;
524
+  border-top: 1rpx solid #eee;
525
+  gap: 16rpx;
526
+}
527
+.input {
528
+  flex: 1;
529
+  height: 72rpx;
530
+  padding: 0 24rpx;
531
+  background: #f5f6fa;
532
+  border-radius: 36rpx;
533
+  font-size: 28rpx;
534
+  color: $text-main;
535
+  border: 1rpx solid #e8e8e8;
536
+}
537
+.send-btn {
538
+  width: 100rpx;
539
+  height: 72rpx;
540
+  background: $primary;
541
+  border-radius: 36rpx;
542
+  display: flex;
543
+  align-items: center;
544
+  justify-content: center;
545
+  &.disabled { background: #ccc; }
546
+}
547
+.send-text { color: #fff; font-size: 26rpx; }
548
+</style>

+ 29 - 0
src/pages/home/index.vue

@@ -37,6 +37,11 @@
37 37
       </div>
38 38
     </div>
39 39
 
40
+    <!-- AI助手悬浮按钮 -->
41
+    <view class="ai-float-btn" @click="openAiChat">
42
+      <text class="ai-float-icon">AI</text>
43
+    </view>
44
+
40 45
     <Notice ref="notice" />
41 46
     <!--  今日待办  -->
42 47
     <!-- <myToDo /> -->
@@ -118,6 +123,9 @@ export default {
118 123
 
119 124
   },
120 125
   methods: {
126
+    openAiChat() {
127
+      uni.navigateTo({ url: '/pages/ai-chat/index' });
128
+    },
121 129
     goworkDocu() {
122 130
       uni.navigateTo({
123 131
         url: '/pages/workDocu/index'
@@ -367,4 +375,25 @@ img {
367 375
   width: 100%;
368 376
   margin-bottom: 32rpx;
369 377
 }
378
+
379
+.ai-float-btn {
380
+  position: fixed;
381
+  right: 40rpx;
382
+  bottom: 180rpx;
383
+  width: 100rpx;
384
+  height: 100rpx;
385
+  border-radius: 50%;
386
+  background: linear-gradient(135deg, #1890ff, #096dd9);
387
+  display: flex;
388
+  align-items: center;
389
+  justify-content: center;
390
+  box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.45);
391
+  z-index: 99;
392
+}
393
+.ai-float-icon {
394
+  color: #fff;
395
+  font-size: 28rpx;
396
+  font-weight: 700;
397
+  letter-spacing: 2rpx;
398
+}
370 399
 </style>