Browse Source

Merge branch 'feature/ai-chat' into dev

huoyi 1 week ago
parent
commit
a25ad52b13
4 changed files with 873 additions and 0 deletions
  1. 7 0
      src/pages.json
  2. 808 0
      src/pages/ai-chat/index.vue
  3. 29 0
      src/pages/home/index.vue
  4. 29 0
      src/pages/work/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": "登录"

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

@@ -0,0 +1,808 @@
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] != null ? 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
+      <!-- 麦克风按钮(同时支持触摸和鼠标,方便桌面浏览器测试) -->
121
+      <view
122
+        :class="['mic-btn', recording ? 'recording' : '']"
123
+        @touchstart.prevent="onPressStart"
124
+        @touchend.prevent="onPressEnd"
125
+        @touchcancel.prevent="onPressCancel"
126
+        @mousedown.prevent="onMouseDown"
127
+      >
128
+        <text class="mic-icon">{{ recording ? '●' : '🎤' }}</text>
129
+      </view>
130
+
131
+      <input
132
+        v-model="inputText"
133
+        class="input"
134
+        :placeholder="recording ? '正在录音...' : '请输入您的问题...'"
135
+        :disabled="loading || recording"
136
+        confirm-type="send"
137
+        @confirm="sendMessage"
138
+      />
139
+      <view :class="['send-btn', (loading || recording) ? 'disabled' : '']" @click="sendMessage">
140
+        <text class="send-text">{{ loading ? '…' : '发送' }}</text>
141
+      </view>
142
+    </view>
143
+
144
+    <!-- 录音提示浮层 -->
145
+    <view v-if="recording" class="recording-overlay">
146
+      <view class="recording-inner">
147
+        <text class="recording-icon">🎤</text>
148
+        <text class="recording-tip">松手发送,上滑取消</text>
149
+        <view class="recording-waves">
150
+          <view v-for="i in 5" :key="i" class="wave" :style="{ animationDelay: (i * 0.1) + 's' }" />
151
+        </view>
152
+      </view>
153
+    </view>
154
+  </view>
155
+</template>
156
+
157
+<script>
158
+const AI_BASE = 'http://airport-test.samsundot.com:9015'
159
+const AI_URL  = AI_BASE + '/api/query-stream'
160
+const ASR_URL = AI_BASE + '/api/asr'
161
+
162
+export default {
163
+  name: 'AiChat',
164
+  data() {
165
+    return {
166
+      inputText: '',
167
+      messages: [],
168
+      loading: false,
169
+      scrollTop: 0,
170
+      clarifyQuestion: '',
171
+      sessionId: 'app_' + Date.now(),
172
+      history: [],
173
+      exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
174
+      // 录音相关(Web Audio 直接采集 PCM)
175
+      recording: false,
176
+      pressing: false,
177
+      recordCancelled: false,
178
+      audioCtx: null,
179
+      mediaStream: null,
180
+      sourceNode: null,
181
+      scriptNode: null,
182
+      pcmChunks: [],
183
+      pcmSampleRate: 16000,
184
+    }
185
+  },
186
+  methods: {
187
+    // ============ 按压事件包装 ============
188
+    onMouseDown(e) {
189
+      // 鼠标:松手可能在按钮外,用 window 级监听
190
+      this._winUp = () => this.onPressEnd()
191
+      window.addEventListener('mouseup', this._winUp, { once: true })
192
+      this.onPressStart(e)
193
+    },
194
+    onPressStart(e) {
195
+      if (this.loading || this.pressing) return
196
+      this.pressing = true
197
+      this.startRecord()
198
+    },
199
+    onPressEnd(e) {
200
+      if (!this.pressing) return
201
+      this.pressing = false
202
+      this.stopRecord()
203
+    },
204
+    onPressCancel(e) {
205
+      if (!this.pressing) return
206
+      this.pressing = false
207
+      this.recordCancelled = true
208
+      this.stopRecord()
209
+    },
210
+
211
+    // ============ 录音相关(Web Audio 直接采集原始 PCM)============
212
+    async startRecord() {
213
+      this.recordCancelled = false
214
+      this.pcmChunks = []
215
+
216
+      try {
217
+        const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
218
+        // 异步竞态:若 await 期间已松手,直接放弃
219
+        if (!this.pressing) { stream.getTracks().forEach(t => t.stop()); return }
220
+        this.mediaStream = stream
221
+
222
+        const AudioCtx = window.AudioContext || window.webkitAudioContext
223
+        const ctx = new AudioCtx()
224
+        if (ctx.state === 'suspended') await ctx.resume()
225
+        this.audioCtx = ctx
226
+        this.pcmSampleRate = ctx.sampleRate
227
+
228
+        const source = ctx.createMediaStreamSource(stream)
229
+        const node = ctx.createScriptProcessor(4096, 1, 1)
230
+        node.onaudioprocess = (ev) => {
231
+          // 只要节点还连着就采集(不依赖 recording 标志,避免漏帧)
232
+          this.pcmChunks.push(new Float32Array(ev.inputBuffer.getChannelData(0)))
233
+        }
234
+        source.connect(node)
235
+        node.connect(ctx.destination)
236
+
237
+        this.sourceNode = source
238
+        this.scriptNode = node
239
+        this.recording = true
240
+
241
+        // 若设置过程中已松手,立即停止并提交
242
+        if (!this.pressing) this.stopRecord()
243
+      } catch (err) {
244
+        this.recording = false
245
+        let msg = '无法访问麦克风'
246
+        if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
247
+          msg = '当前环境不支持录音(需HTTPS或localhost)'
248
+        } else if (err && err.name === 'NotAllowedError') {
249
+          msg = '麦克风权限被拒绝,请在浏览器允许'
250
+        } else if (err && err.name === 'NotFoundError') {
251
+          msg = '未检测到麦克风设备'
252
+        }
253
+        uni.showToast({ title: msg, icon: 'none', duration: 2500 })
254
+      }
255
+    },
256
+
257
+    _teardownAudio() {
258
+      try { if (this.scriptNode) { this.scriptNode.disconnect(); this.scriptNode.onaudioprocess = null } } catch (e) {}
259
+      try { if (this.sourceNode) this.sourceNode.disconnect() } catch (e) {}
260
+      try { if (this.mediaStream) this.mediaStream.getTracks().forEach(t => t.stop()) } catch (e) {}
261
+      try { if (this.audioCtx) this.audioCtx.close() } catch (e) {}
262
+      this.scriptNode = null
263
+      this.sourceNode = null
264
+      this.mediaStream = null
265
+      this.audioCtx = null
266
+    },
267
+
268
+    stopRecord() {
269
+      if (!this.recording) return
270
+      this.recording = false
271
+      const rate = this.pcmSampleRate
272
+      const chunks = this.pcmChunks
273
+      this._teardownAudio()
274
+      if (!this.recordCancelled) this.submitAudio(chunks, rate)
275
+    },
276
+
277
+    async submitAudio(chunks, srcRate) {
278
+      // 合并所有 PCM 帧
279
+      let total = 0
280
+      for (const c of chunks) total += c.length
281
+      if (total < srcRate * 0.3) {  // 少于0.3秒视为无效
282
+        uni.showToast({ title: '录音太短,请长按说话', icon: 'none' })
283
+        return
284
+      }
285
+      const merged = new Float32Array(total)
286
+      let pos = 0
287
+      for (const c of chunks) { merged.set(c, pos); pos += c.length }
288
+
289
+      uni.showLoading({ title: '识别中...' })
290
+      try {
291
+        const wavBlob = this.encodeWav16k(merged, srcRate)
292
+        const formData = new FormData()
293
+        formData.append('file', wavBlob, 'voice.wav')
294
+
295
+        const resp = await fetch(ASR_URL, { method: 'POST', body: formData })
296
+        const data = await resp.json()
297
+        uni.hideLoading()
298
+        if (data.success && data.text) {
299
+          this.inputText = data.text
300
+          this.$nextTick(() => this.sendMessage())
301
+        } else {
302
+          uni.showToast({ title: data.message || '未识别到内容', icon: 'none' })
303
+        }
304
+      } catch (err) {
305
+        uni.hideLoading()
306
+        uni.showToast({ title: '识别失败:' + (err.message || '请重试'), icon: 'none' })
307
+      }
308
+    },
309
+
310
+    // Float32 PCM → 混(已单声道)→ 重采样16k → 16bit PCM WAV
311
+    encodeWav16k(mono, srcRate) {
312
+      const dstRate = 16000
313
+      const dstLen = Math.round(mono.length * dstRate / srcRate)
314
+      const out = new Float32Array(dstLen)
315
+      const ratio = srcRate / dstRate
316
+      for (let i = 0; i < dstLen; i++) {
317
+        const p = i * ratio
318
+        const idx = Math.floor(p)
319
+        const frac = p - idx
320
+        const s0 = mono[idx] || 0
321
+        const s1 = mono[idx + 1] !== undefined ? mono[idx + 1] : s0
322
+        out[i] = s0 + (s1 - s0) * frac
323
+      }
324
+
325
+      const buffer = new ArrayBuffer(44 + dstLen * 2)
326
+      const view = new DataView(buffer)
327
+      const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)) }
328
+      writeStr(0, 'RIFF')
329
+      view.setUint32(4, 36 + dstLen * 2, true)
330
+      writeStr(8, 'WAVE')
331
+      writeStr(12, 'fmt ')
332
+      view.setUint32(16, 16, true)
333
+      view.setUint16(20, 1, true)
334
+      view.setUint16(22, 1, true)
335
+      view.setUint32(24, dstRate, true)
336
+      view.setUint32(28, dstRate * 2, true)
337
+      view.setUint16(32, 2, true)
338
+      view.setUint16(34, 16, true)
339
+      writeStr(36, 'data')
340
+      view.setUint32(40, dstLen * 2, true)
341
+      let off = 44
342
+      for (let i = 0; i < dstLen; i++) {
343
+        const s = Math.max(-1, Math.min(1, out[i]))
344
+        view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
345
+        off += 2
346
+      }
347
+      return new Blob([view], { type: 'audio/wav' })
348
+    },
349
+
350
+    // ============ 导航 ============
351
+    goBack() {
352
+      uni.navigateBack()
353
+    },
354
+
355
+    clearChat() {
356
+      this.messages = []
357
+      this.history = []
358
+      this.clarifyQuestion = ''
359
+      this.sessionId = 'app_' + Date.now()
360
+    },
361
+
362
+    sendExample(text) {
363
+      this.inputText = text
364
+      this.sendMessage()
365
+    },
366
+
367
+    toggleSql(idx) {
368
+      this.$set(this.messages[idx], 'showSql', !this.messages[idx].showSql)
369
+    },
370
+
371
+    onScrollBottom() {},
372
+
373
+    scrollToBottom() {
374
+      this.$nextTick(() => {
375
+        this.scrollTop = 999999
376
+      })
377
+    },
378
+
379
+    getUserId() {
380
+      try {
381
+        const userStr = uni.getStorageSync('userInfo') || uni.getStorageSync('user')
382
+        if (userStr) {
383
+          const u = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
384
+          return String(u.userId || u.user_id || u.id || 1)
385
+        }
386
+      } catch (e) {}
387
+      return '1'
388
+    },
389
+
390
+    async sendMessage() {
391
+      const question = this.inputText.trim()
392
+      if (!question || this.loading) return
393
+
394
+      this.inputText = ''
395
+      this.clarifyQuestion = ''
396
+      this.loading = true
397
+
398
+      // 添加用户消息
399
+      this.messages.push({ role: 'user', text: question })
400
+
401
+      // 添加 AI 占位消息
402
+      const aiIdx = this.messages.length
403
+      this.messages.push({
404
+        role: 'assistant',
405
+        loading: true,
406
+        steps: [],
407
+        message: '',
408
+        sql: '',
409
+        columns: [],
410
+        rows: [],
411
+        answer: '',
412
+        sources: [],
413
+        showSql: false,
414
+      })
415
+      this.scrollToBottom()
416
+
417
+      try {
418
+        const response = await fetch(AI_URL, {
419
+          method: 'POST',
420
+          headers: { 'Content-Type': 'application/json' },
421
+          body: JSON.stringify({
422
+            question,
423
+            user_id: this.getUserId(),
424
+            llm_type: 'qwen',
425
+            session_id: this.sessionId,
426
+            history: this.history.slice(-3),
427
+          }),
428
+        })
429
+
430
+        if (!response.ok) throw new Error('请求失败 ' + response.status)
431
+
432
+        const reader = response.body.getReader()
433
+        const decoder = new TextDecoder()
434
+        let buffer = ''
435
+
436
+        while (true) {
437
+          const { done, value } = await reader.read()
438
+          if (done) break
439
+
440
+          buffer += decoder.decode(value, { stream: true })
441
+          const lines = buffer.split('\n')
442
+          buffer = lines.pop()
443
+
444
+          for (const line of lines) {
445
+            if (line.startsWith('data: ')) {
446
+              try {
447
+                const data = JSON.parse(line.slice(6))
448
+                this.handleEvent(aiIdx, data)
449
+              } catch (e) {}
450
+            }
451
+          }
452
+        }
453
+      } catch (err) {
454
+        this.$set(this.messages, aiIdx, {
455
+          ...this.messages[aiIdx],
456
+          loading: false,
457
+          isError: true,
458
+          message: '请求失败,请检查网络后重试',
459
+        })
460
+      }
461
+
462
+      this.loading = false
463
+      this.scrollToBottom()
464
+    },
465
+
466
+    handleEvent(aiIdx, data) {
467
+      const msg = this.messages[aiIdx]
468
+
469
+      if (data.step !== undefined && data.name) {
470
+        // 进度步骤
471
+        const steps = [...(msg.steps || [])]
472
+        const si = steps.findIndex(s => s.name === data.name)
473
+        const step = { name: data.name, status: data.status, duration: data.duration }
474
+        if (si >= 0) steps[si] = step
475
+        else steps.push(step)
476
+        this.$set(this.messages, aiIdx, { ...msg, steps })
477
+        return
478
+      }
479
+
480
+      if (data.question) {
481
+        // 意图澄清
482
+        this.clarifyQuestion = data.question
483
+        this.$set(this.messages, aiIdx, { ...msg, loading: false })
484
+        return
485
+      }
486
+
487
+      if (data.columns !== undefined || data.answer !== undefined) {
488
+        // 最终结果
489
+        this.$set(this.messages, aiIdx, {
490
+          ...msg,
491
+          loading: false,
492
+          message: data.message || '',
493
+          sql: data.sql || '',
494
+          columns: data.columns || [],
495
+          rows: data.rows || [],
496
+          answer: data.answer || '',
497
+          sources: data.sources || [],
498
+          isError: !data.success,
499
+          showSql: false,
500
+        })
501
+        // 记录历史
502
+        this.history.push({
503
+          question: (this.messages[aiIdx - 1] && this.messages[aiIdx - 1].text) || '',
504
+          sql: data.sql || '',
505
+        })
506
+        this.scrollToBottom()
507
+      }
508
+    },
509
+  },
510
+}
511
+</script>
512
+
513
+<style lang="scss" scoped>
514
+$primary: #1890ff;
515
+$bg: #f5f6fa;
516
+$bubble-user: #1890ff;
517
+$bubble-ai: #ffffff;
518
+$text-main: #333;
519
+$text-sub: #999;
520
+
521
+.ai-chat-page {
522
+  display: flex;
523
+  flex-direction: column;
524
+  height: 100vh;
525
+  background: $bg;
526
+  font-size: 28rpx;
527
+}
528
+
529
+/* 导航栏 */
530
+.nav-bar {
531
+  display: flex;
532
+  align-items: center;
533
+  padding: 20rpx 30rpx;
534
+  padding-top: calc(20rpx + env(safe-area-inset-top));
535
+  background: #fff;
536
+  border-bottom: 1rpx solid #eee;
537
+  position: sticky;
538
+  top: 0;
539
+  z-index: 10;
540
+}
541
+.back-btn { width: 80rpx; display: flex; align-items: center; }
542
+.back-icon { font-size: 50rpx; color: $primary; line-height: 1; }
543
+.nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; color: $text-main; }
544
+.nav-right { width: 80rpx; text-align: right; }
545
+.clear-btn { font-size: 26rpx; color: $text-sub; }
546
+
547
+/* 消息列表 */
548
+.msg-list {
549
+  flex: 1;
550
+  overflow: hidden;
551
+  padding: 20rpx 0;
552
+}
553
+
554
+/* 欢迎区 */
555
+.welcome {
556
+  display: flex;
557
+  flex-direction: column;
558
+  align-items: center;
559
+  padding: 80rpx 40rpx 40rpx;
560
+  gap: 16rpx;
561
+}
562
+.welcome-icon { width: 120rpx; height: 120rpx; border-radius: 24rpx; }
563
+.welcome-title { font-size: 32rpx; font-weight: 600; color: $text-main; }
564
+.welcome-sub { font-size: 26rpx; color: $text-sub; }
565
+.example-tags {
566
+  display: flex;
567
+  flex-wrap: wrap;
568
+  justify-content: center;
569
+  gap: 16rpx;
570
+  margin-top: 20rpx;
571
+}
572
+.tag {
573
+  padding: 12rpx 24rpx;
574
+  background: #fff;
575
+  border: 1rpx solid #d0e8ff;
576
+  border-radius: 40rpx;
577
+  font-size: 24rpx;
578
+  color: $primary;
579
+}
580
+
581
+/* 消息行 */
582
+.msg-row {
583
+  display: flex;
584
+  padding: 16rpx 24rpx;
585
+  gap: 16rpx;
586
+}
587
+.msg-user { flex-direction: row-reverse; }
588
+.msg-assistant { flex-direction: row; }
589
+
590
+/* 头像 */
591
+.avatar {
592
+  width: 72rpx;
593
+  height: 72rpx;
594
+  border-radius: 50%;
595
+  background: $primary;
596
+  display: flex;
597
+  align-items: center;
598
+  justify-content: center;
599
+  flex-shrink: 0;
600
+}
601
+.avatar-text { color: #fff; font-size: 22rpx; font-weight: 700; }
602
+
603
+/* 气泡包裹 */
604
+.bubble-wrap { flex: 1; display: flex; flex-direction: column; gap: 8rpx; max-width: 90%; }
605
+
606
+/* 气泡 */
607
+.bubble {
608
+  border-radius: 16rpx;
609
+  padding: 20rpx 24rpx;
610
+  word-break: break-all;
611
+}
612
+.bubble.user {
613
+  background: $bubble-user;
614
+  border-bottom-right-radius: 4rpx;
615
+  .bubble-text { color: #fff; }
616
+}
617
+.bubble.data, .bubble.knowledge {
618
+  background: $bubble-ai;
619
+  border-bottom-left-radius: 4rpx;
620
+  box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06);
621
+}
622
+.bubble-text { line-height: 1.6; color: $text-main; }
623
+
624
+/* 进度步骤 */
625
+.steps {
626
+  display: flex;
627
+  flex-direction: column;
628
+  gap: 6rpx;
629
+  padding: 0 4rpx;
630
+}
631
+.step {
632
+  display: flex;
633
+  align-items: center;
634
+  gap: 8rpx;
635
+  font-size: 22rpx;
636
+  color: $text-sub;
637
+  &.done { color: #52c41a; }
638
+  &.running { color: $primary; }
639
+}
640
+.step-dot { font-size: 20rpx; width: 24rpx; }
641
+.step-time { font-size: 20rpx; }
642
+
643
+/* 结果状态 */
644
+.result-msg {
645
+  font-size: 24rpx;
646
+  color: $text-sub;
647
+  display: block;
648
+  margin-bottom: 12rpx;
649
+  &.error { color: #ff4d4f; }
650
+}
651
+
652
+/* 表格 */
653
+.data-table { margin-top: 8rpx; }
654
+.table-scroll { width: 100%; }
655
+.table-head, .table-row {
656
+  display: flex;
657
+  min-width: max-content;
658
+}
659
+.table-head { background: #f0f7ff; }
660
+.table-row.even { background: #fafafa; }
661
+.th, .td {
662
+  min-width: 120rpx;
663
+  padding: 12rpx 16rpx;
664
+  font-size: 24rpx;
665
+  white-space: nowrap;
666
+  border-bottom: 1rpx solid #f0f0f0;
667
+}
668
+.th { font-weight: 600; color: $primary; }
669
+.td { color: $text-main; }
670
+
671
+/* SQL 折叠 */
672
+.sql-block { margin-top: 12rpx; }
673
+.sql-toggle {
674
+  display: flex;
675
+  align-items: center;
676
+  gap: 8rpx;
677
+  padding: 8rpx 0;
678
+}
679
+.sql-label { font-size: 22rpx; color: $text-sub; background: #f0f0f0; padding: 2rpx 12rpx; border-radius: 6rpx; }
680
+.sql-arrow { font-size: 20rpx; color: $text-sub; }
681
+.sql-code {
682
+  background: #1e1e1e;
683
+  border-radius: 8rpx;
684
+  padding: 16rpx;
685
+  text { font-size: 22rpx; color: #d4d4d4; line-height: 1.5; }
686
+}
687
+
688
+/* 知识库来源 */
689
+.sources { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid #f0f0f0; }
690
+.source-label { font-size: 22rpx; color: $text-sub; }
691
+.source-item { font-size: 22rpx; color: $primary; }
692
+
693
+/* 加载动画 */
694
+.loading { background: $bubble-ai !important; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
695
+.dot-loader { display: flex; gap: 8rpx; align-items: center; height: 32rpx; }
696
+.dot {
697
+  width: 12rpx; height: 12rpx; border-radius: 50%; background: #ccc;
698
+  animation: bounce 1.2s infinite ease-in-out;
699
+  &:nth-child(2) { animation-delay: 0.2s; }
700
+  &:nth-child(3) { animation-delay: 0.4s; }
701
+}
702
+@keyframes bounce {
703
+  0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
704
+  40% { transform: scale(1); opacity: 1; }
705
+}
706
+
707
+/* 澄清提示 */
708
+.clarify-bar {
709
+  background: #fff7e6;
710
+  border-top: 1rpx solid #ffe58f;
711
+  padding: 16rpx 30rpx;
712
+}
713
+.clarify-text { font-size: 26rpx; color: #d46b08; }
714
+
715
+/* 输入栏 */
716
+.input-bar {
717
+  display: flex;
718
+  align-items: center;
719
+  padding: 16rpx 24rpx;
720
+  padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
721
+  background: #fff;
722
+  border-top: 1rpx solid #eee;
723
+  gap: 16rpx;
724
+}
725
+.input {
726
+  flex: 1;
727
+  height: 72rpx;
728
+  padding: 0 24rpx;
729
+  background: #f5f6fa;
730
+  border-radius: 36rpx;
731
+  font-size: 28rpx;
732
+  color: $text-main;
733
+  border: 1rpx solid #e8e8e8;
734
+}
735
+.send-btn {
736
+  width: 100rpx;
737
+  height: 72rpx;
738
+  background: $primary;
739
+  border-radius: 36rpx;
740
+  display: flex;
741
+  align-items: center;
742
+  justify-content: center;
743
+  &.disabled { background: #ccc; }
744
+}
745
+.send-text { color: #fff; font-size: 26rpx; }
746
+
747
+/* 麦克风按钮 */
748
+.mic-btn {
749
+  width: 72rpx;
750
+  height: 72rpx;
751
+  border-radius: 50%;
752
+  background: #f0f0f0;
753
+  display: flex;
754
+  align-items: center;
755
+  justify-content: center;
756
+  flex-shrink: 0;
757
+  transition: background 0.2s;
758
+  &.recording {
759
+    background: #ff4d4f;
760
+    animation: micPulse 0.8s infinite;
761
+  }
762
+}
763
+.mic-icon { font-size: 32rpx; line-height: 1; }
764
+
765
+/* 用阴影呼吸代替 scale,避免缩放触发 mouseleave */
766
+@keyframes micPulse {
767
+  0%, 100% { box-shadow: 0 0 0 0 rgba(255,77,79,0.5); }
768
+  50% { box-shadow: 0 0 0 12rpx rgba(255,77,79,0); }
769
+}
770
+
771
+/* 录音浮层 */
772
+.recording-overlay {
773
+  position: fixed;
774
+  inset: 0;
775
+  background: rgba(0,0,0,.5);
776
+  display: flex;
777
+  align-items: center;
778
+  justify-content: center;
779
+  z-index: 200;
780
+}
781
+.recording-inner {
782
+  background: #fff;
783
+  border-radius: 24rpx;
784
+  padding: 60rpx 80rpx;
785
+  display: flex;
786
+  flex-direction: column;
787
+  align-items: center;
788
+  gap: 20rpx;
789
+}
790
+.recording-icon { font-size: 80rpx; }
791
+.recording-tip { font-size: 26rpx; color: #666; }
792
+.recording-waves {
793
+  display: flex;
794
+  gap: 8rpx;
795
+  align-items: center;
796
+  height: 50rpx;
797
+}
798
+.wave {
799
+  width: 8rpx;
800
+  background: #1890ff;
801
+  border-radius: 4rpx;
802
+  animation: wave 0.8s infinite ease-in-out alternate;
803
+}
804
+@keyframes wave {
805
+  0%  { height: 10rpx; }
806
+  100%{ height: 50rpx; }
807
+}
808
+</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>

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

@@ -33,6 +33,11 @@
33 33
         </uni-grid>
34 34
       </view>
35 35
     </div>
36
+
37
+    <!-- AI助手悬浮按钮 -->
38
+    <view class="ai-float-btn" @click="openAiChat">
39
+      <text class="ai-float-icon">AI</text>
40
+    </view>
36 41
   </home-container>
37 42
 </template>
38 43
 <script>
@@ -93,6 +98,9 @@ export default {
93 98
     updateCurrentDate() {
94 99
       this.currentDate = this.formatDate(new Date());
95 100
     },
101
+    openAiChat() {
102
+      uni.navigateTo({ url: '/pages/ai-chat/index' });
103
+    },
96 104
     handleGridClick(url) {
97 105
       console.log('点击了宫格:', url, this.role);
98 106
       uni.navigateTo({
@@ -218,4 +226,25 @@ export default {
218 226
   font-size: 24rpx;
219 227
   color: #999;
220 228
 }
229
+
230
+.ai-float-btn {
231
+  position: fixed;
232
+  right: 40rpx;
233
+  bottom: 180rpx;
234
+  width: 100rpx;
235
+  height: 100rpx;
236
+  border-radius: 50%;
237
+  background: linear-gradient(135deg, #1890ff, #096dd9);
238
+  display: flex;
239
+  align-items: center;
240
+  justify-content: center;
241
+  box-shadow: 0 8rpx 24rpx rgba(24, 144, 255, 0.45);
242
+  z-index: 99;
243
+}
244
+.ai-float-icon {
245
+  color: #fff;
246
+  font-size: 28rpx;
247
+  font-weight: 700;
248
+  letter-spacing: 2rpx;
249
+}
221 250
 </style>