index.vue 23 KB

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