| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809 |
- <template>
- <view class="ai-chat-page">
- <!-- 顶部导航 -->
- <view class="nav-bar">
- <view class="back-btn" @click="goBack">
- <text class="back-icon">‹</text>
- </view>
- <text class="nav-title">AI数据助手</text>
- <view class="nav-right" @click="clearChat">
- <text class="clear-btn">清空</text>
- </view>
- </view>
- <!-- 消息列表 -->
- <scroll-view
- class="msg-list"
- scroll-y
- :scroll-top="scrollTop"
- :scroll-with-animation="true"
- @scrolltolower="onScrollBottom"
- >
- <!-- 欢迎语 -->
- <view v-if="messages.length === 0" class="welcome">
- <image class="welcome-icon" src="/static/images/logo.png" mode="aspectFit" />
- <text class="welcome-title">您好!我是机场安检数据助手</text>
- <text class="welcome-sub">请问有什么可以帮您查询的?</text>
- <view class="example-tags">
- <view
- v-for="tag in exampleTags"
- :key="tag"
- class="tag"
- @click="sendExample(tag)"
- >{{ tag }}</view>
- </view>
- </view>
- <!-- 消息气泡 -->
- <view
- v-for="(msg, idx) in messages"
- :key="idx"
- :class="['msg-row', msg.role === 'user' ? 'msg-user' : 'msg-assistant']"
- >
- <!-- AI头像 -->
- <view v-if="msg.role === 'assistant'" class="avatar">
- <text class="avatar-text">AI</text>
- </view>
- <view class="bubble-wrap">
- <!-- 进度提示(流式过程中) -->
- <view v-if="msg.steps && msg.steps.length" class="steps">
- <view v-for="(step, si) in msg.steps" :key="si" :class="['step', step.status]">
- <text class="step-dot">{{ step.status === 'done' ? '✓' : step.status === 'running' ? '…' : '·' }}</text>
- <text class="step-name">{{ step.name }}</text>
- <text v-if="step.duration" class="step-time"> {{ step.duration }}s</text>
- </view>
- </view>
- <!-- 文字回答(知识库/AI直接回答) -->
- <view v-if="msg.answer" class="bubble knowledge">
- <text class="bubble-text" selectable>{{ msg.answer }}</text>
- <view v-if="msg.sources && msg.sources.length" class="sources">
- <text class="source-label">来源:</text>
- <text class="source-item" v-for="s in msg.sources" :key="s.filename">{{ s.filename }}</text>
- </view>
- </view>
- <!-- 数据查询结果 -->
- <view v-else-if="msg.role === 'assistant' && !msg.loading" class="bubble data">
- <text v-if="msg.message" class="result-msg" :class="{ error: msg.isError }">{{ msg.message }}</text>
- <!-- 数据表格 -->
- <view v-if="msg.rows && msg.rows.length" class="data-table">
- <scroll-view scroll-x class="table-scroll">
- <view class="table-head">
- <text v-for="col in msg.columns" :key="col" class="th">{{ col }}</text>
- </view>
- <view v-for="(row, ri) in msg.rows" :key="ri" :class="['table-row', ri % 2 === 0 ? 'even' : '']">
- <text v-for="col in msg.columns" :key="col" class="td">{{ row[col] != null ? row[col] : '-' }}</text>
- </view>
- </scroll-view>
- </view>
- <!-- SQL 折叠 -->
- <view v-if="msg.sql" class="sql-block">
- <view class="sql-toggle" @click="toggleSql(idx)">
- <text class="sql-label">SQL</text>
- <text class="sql-arrow">{{ msg.showSql ? '▲' : '▼' }}</text>
- </view>
- <view v-if="msg.showSql" class="sql-code">
- <text selectable>{{ msg.sql }}</text>
- </view>
- </view>
- </view>
- <!-- 用户消息气泡 -->
- <view v-if="msg.role === 'user'" class="bubble user">
- <text class="bubble-text" selectable>{{ msg.text }}</text>
- </view>
- <!-- 加载中 -->
- <view v-if="msg.loading" class="bubble loading">
- <view class="dot-loader">
- <view class="dot" /><view class="dot" /><view class="dot" />
- </view>
- </view>
- </view>
- </view>
- <!-- 底部留白 -->
- <view style="height: 20rpx;" />
- </scroll-view>
- <!-- 意图澄清 -->
- <view v-if="clarifyQuestion" class="clarify-bar">
- <text class="clarify-text">{{ clarifyQuestion }}</text>
- </view>
- <!-- 输入区 -->
- <view class="input-bar">
- <!-- 麦克风按钮(同时支持触摸和鼠标,方便桌面浏览器测试) -->
- <view
- :class="['mic-btn', recording ? 'recording' : '']"
- @touchstart.prevent="onPressStart"
- @touchend.prevent="onPressEnd"
- @touchcancel.prevent="onPressCancel"
- @mousedown.prevent="onMouseDown"
- >
- <text class="mic-icon">{{ recording ? '●' : '🎤' }}</text>
- </view>
- <input
- v-model="inputText"
- class="input"
- :placeholder="recording ? '正在录音...' : '请输入您的问题...'"
- :disabled="loading || recording"
- confirm-type="send"
- @confirm="sendMessage"
- />
- <view :class="['send-btn', (loading || recording) ? 'disabled' : '']" @click="sendMessage">
- <text class="send-text">{{ loading ? '…' : '发送' }}</text>
- </view>
- </view>
- <!-- 录音提示浮层 -->
- <view v-if="recording" class="recording-overlay">
- <view class="recording-inner">
- <text class="recording-icon">🎤</text>
- <text class="recording-tip">松手发送,上滑取消</text>
- <view class="recording-waves">
- <view v-for="i in 5" :key="i" class="wave" :style="{ animationDelay: (i * 0.1) + 's' }" />
- </view>
- </view>
- </view>
- </view>
- </template>
- <script>
- const AI_BASE = 'http://airport-test.samsundot.com:9015'
- const AI_URL = AI_BASE + '/api/query-stream'
- const ASR_URL = AI_BASE + '/api/asr'
- export default {
- name: 'AiChat',
- data() {
- return {
- inputText: '',
- messages: [],
- loading: false,
- scrollTop: 0,
- clarifyQuestion: '',
- sessionId: 'app_' + Date.now(),
- history: [],
- exampleTags: ['各班组查获排名', '打火机查获数量', '旅检一部人员资质', '全站党员人数'],
- // 录音相关(Web Audio 直接采集 PCM)
- recording: false,
- pressing: false,
- recordCancelled: false,
- audioCtx: null,
- mediaStream: null,
- sourceNode: null,
- scriptNode: null,
- pcmChunks: [],
- pcmSampleRate: 16000,
- }
- },
- methods: {
- // ============ 按压事件包装 ============
- onMouseDown(e) {
- // 鼠标:松手可能在按钮外,用 window 级监听
- this._winUp = () => this.onPressEnd()
- window.addEventListener('mouseup', this._winUp, { once: true })
- this.onPressStart(e)
- },
- onPressStart(e) {
- if (this.loading || this.pressing) return
- this.pressing = true
- this.startRecord()
- },
- onPressEnd(e) {
- if (!this.pressing) return
- this.pressing = false
- this.stopRecord()
- },
- onPressCancel(e) {
- if (!this.pressing) return
- this.pressing = false
- this.recordCancelled = true
- this.stopRecord()
- },
- // ============ 录音相关(Web Audio 直接采集原始 PCM)============
- async startRecord() {
- this.recordCancelled = false
- this.pcmChunks = []
- try {
- const stream = await navigator.mediaDevices.getUserMedia({ audio: true })
- // 异步竞态:若 await 期间已松手,直接放弃
- if (!this.pressing) { stream.getTracks().forEach(t => t.stop()); return }
- this.mediaStream = stream
- const AudioCtx = window.AudioContext || window.webkitAudioContext
- const ctx = new AudioCtx()
- if (ctx.state === 'suspended') await ctx.resume()
- this.audioCtx = ctx
- this.pcmSampleRate = ctx.sampleRate
- const source = ctx.createMediaStreamSource(stream)
- const node = ctx.createScriptProcessor(4096, 1, 1)
- node.onaudioprocess = (ev) => {
- // 只要节点还连着就采集(不依赖 recording 标志,避免漏帧)
- this.pcmChunks.push(new Float32Array(ev.inputBuffer.getChannelData(0)))
- }
- source.connect(node)
- node.connect(ctx.destination)
- this.sourceNode = source
- this.scriptNode = node
- this.recording = true
- // 若设置过程中已松手,立即停止并提交
- if (!this.pressing) this.stopRecord()
- } catch (err) {
- this.recording = false
- let msg = '无法访问麦克风'
- if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
- msg = '当前环境不支持录音(需HTTPS或localhost)'
- } else if (err && err.name === 'NotAllowedError') {
- msg = '麦克风权限被拒绝,请在浏览器允许'
- } else if (err && err.name === 'NotFoundError') {
- msg = '未检测到麦克风设备'
- }
- uni.showToast({ title: msg, icon: 'none', duration: 2500 })
- }
- },
- _teardownAudio() {
- try { if (this.scriptNode) { this.scriptNode.disconnect(); this.scriptNode.onaudioprocess = null } } catch (e) {}
- try { if (this.sourceNode) this.sourceNode.disconnect() } catch (e) {}
- try { if (this.mediaStream) this.mediaStream.getTracks().forEach(t => t.stop()) } catch (e) {}
- try { if (this.audioCtx) this.audioCtx.close() } catch (e) {}
- this.scriptNode = null
- this.sourceNode = null
- this.mediaStream = null
- this.audioCtx = null
- },
- stopRecord() {
- if (!this.recording) return
- this.recording = false
- const rate = this.pcmSampleRate
- const chunks = this.pcmChunks
- this._teardownAudio()
- if (!this.recordCancelled) this.submitAudio(chunks, rate)
- },
- async submitAudio(chunks, srcRate) {
- // 合并所有 PCM 帧
- let total = 0
- for (const c of chunks) total += c.length
- if (total < srcRate * 0.3) { // 少于0.3秒视为无效
- uni.showToast({ title: '录音太短,请长按说话', icon: 'none' })
- return
- }
- const merged = new Float32Array(total)
- let pos = 0
- for (const c of chunks) { merged.set(c, pos); pos += c.length }
- uni.showLoading({ title: '识别中...' })
- try {
- const wavBlob = this.encodeWav16k(merged, srcRate)
- const formData = new FormData()
- formData.append('file', wavBlob, 'voice.wav')
- const resp = await fetch(ASR_URL, { method: 'POST', body: formData })
- const data = await resp.json()
- uni.hideLoading()
- if (data.success && data.text) {
- this.inputText = data.text
- this.$nextTick(() => this.sendMessage())
- } else {
- uni.showToast({ title: data.message || '未识别到内容', icon: 'none' })
- }
- } catch (err) {
- uni.hideLoading()
- uni.showToast({ title: '识别失败:' + (err.message || '请重试'), icon: 'none' })
- }
- },
- // Float32 PCM → 混(已单声道)→ 重采样16k → 16bit PCM WAV
- encodeWav16k(mono, srcRate) {
- const dstRate = 16000
- const dstLen = Math.round(mono.length * dstRate / srcRate)
- const out = new Float32Array(dstLen)
- const ratio = srcRate / dstRate
- for (let i = 0; i < dstLen; i++) {
- const p = i * ratio
- const idx = Math.floor(p)
- const frac = p - idx
- const s0 = mono[idx] || 0
- const s1 = mono[idx + 1] !== undefined ? mono[idx + 1] : s0
- out[i] = s0 + (s1 - s0) * frac
- }
- const buffer = new ArrayBuffer(44 + dstLen * 2)
- const view = new DataView(buffer)
- const writeStr = (off, str) => { for (let i = 0; i < str.length; i++) view.setUint8(off + i, str.charCodeAt(i)) }
- writeStr(0, 'RIFF')
- view.setUint32(4, 36 + dstLen * 2, true)
- writeStr(8, 'WAVE')
- writeStr(12, 'fmt ')
- view.setUint32(16, 16, true)
- view.setUint16(20, 1, true)
- view.setUint16(22, 1, true)
- view.setUint32(24, dstRate, true)
- view.setUint32(28, dstRate * 2, true)
- view.setUint16(32, 2, true)
- view.setUint16(34, 16, true)
- writeStr(36, 'data')
- view.setUint32(40, dstLen * 2, true)
- let off = 44
- for (let i = 0; i < dstLen; i++) {
- const s = Math.max(-1, Math.min(1, out[i]))
- view.setInt16(off, s < 0 ? s * 0x8000 : s * 0x7FFF, true)
- off += 2
- }
- return new Blob([view], { type: 'audio/wav' })
- },
- // ============ 导航 ============
- goBack() {
- uni.navigateBack()
- },
- clearChat() {
- this.messages = []
- this.history = []
- this.clarifyQuestion = ''
- this.sessionId = 'app_' + Date.now()
- },
- sendExample(text) {
- this.inputText = text
- this.sendMessage()
- },
- toggleSql(idx) {
- this.$set(this.messages[idx], 'showSql', !this.messages[idx].showSql)
- },
- onScrollBottom() {},
- scrollToBottom() {
- this.$nextTick(() => {
- this.scrollTop = 999999
- })
- },
- getUserId() {
- try {
- const userStr = uni.getStorageSync('userInfo') || uni.getStorageSync('user')
- if (userStr) {
- const u = typeof userStr === 'string' ? JSON.parse(userStr) : userStr
- return String(u.userId || u.user_id || u.id || 1)
- }
- } catch (e) {}
- return '1'
- },
- async sendMessage() {
- const question = this.inputText.trim()
- if (!question || this.loading) return
- this.inputText = ''
- this.clarifyQuestion = ''
- this.loading = true
- // 添加用户消息
- this.messages.push({ role: 'user', text: question })
- // 添加 AI 占位消息
- const aiIdx = this.messages.length
- this.messages.push({
- role: 'assistant',
- loading: true,
- steps: [],
- message: '',
- sql: '',
- columns: [],
- rows: [],
- answer: '',
- sources: [],
- showSql: false,
- })
- this.scrollToBottom()
- try {
- const response = await fetch(AI_URL, {
- method: 'POST',
- headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({
- question,
- user_id: this.getUserId(),
- llm_type: 'qwen',
- session_id: this.sessionId,
- history: this.history.slice(-3),
- }),
- })
- if (!response.ok) throw new Error('请求失败 ' + response.status)
- const reader = response.body.getReader()
- const decoder = new TextDecoder()
- let buffer = ''
- while (true) {
- const { done, value } = await reader.read()
- if (done) break
- buffer += decoder.decode(value, { stream: true })
- const lines = buffer.split('\n')
- buffer = lines.pop()
- for (const line of lines) {
- if (line.startsWith('data: ')) {
- try {
- const data = JSON.parse(line.slice(6))
- this.handleEvent(aiIdx, data)
- } catch (e) {}
- }
- }
- }
- } catch (err) {
- this.$set(this.messages, aiIdx, {
- ...this.messages[aiIdx],
- loading: false,
- isError: true,
- message: '请求失败,请检查网络后重试',
- })
- }
- this.loading = false
- this.scrollToBottom()
- },
- handleEvent(aiIdx, data) {
- const msg = this.messages[aiIdx]
- if (data.step !== undefined && data.name) {
- // 进度步骤
- const steps = [...(msg.steps || [])]
- const si = steps.findIndex(s => s.name === data.name)
- const step = { name: data.name, status: data.status, duration: data.duration }
- if (si >= 0) steps[si] = step
- else steps.push(step)
- this.$set(this.messages, aiIdx, { ...msg, steps })
- return
- }
- if (data.question) {
- // 意图澄清
- this.clarifyQuestion = data.question
- this.$set(this.messages, aiIdx, { ...msg, loading: false })
- return
- }
- if (data.columns !== undefined || data.answer !== undefined) {
- // 最终结果
- this.$set(this.messages, aiIdx, {
- ...msg,
- loading: false,
- message: data.message || '',
- sql: data.sql || '',
- columns: data.columns || [],
- rows: data.rows || [],
- answer: data.answer || '',
- sources: data.sources || [],
- isError: !data.success,
- showSql: false,
- })
- // 记录历史
- this.history.push({
- question: (this.messages[aiIdx - 1] && this.messages[aiIdx - 1].text) || '',
- sql: data.sql || '',
- })
- this.scrollToBottom()
- }
- },
- },
- }
- </script>
- <style lang="scss" scoped>
- $primary: #1890ff;
- $bg: #f5f6fa;
- $bubble-user: #1890ff;
- $bubble-ai: #ffffff;
- $text-main: #333;
- $text-sub: #999;
- .ai-chat-page {
- display: flex;
- flex-direction: column;
- height: 100vh;
- background: $bg;
- font-size: 28rpx;
- }
- /* 导航栏 */
- .nav-bar {
- display: flex;
- align-items: center;
- padding: 20rpx 30rpx;
- padding-top: calc(20rpx + env(safe-area-inset-top));
- background: #fff;
- border-bottom: 1rpx solid #eee;
- position: sticky;
- top: 0;
- z-index: 10;
- }
- .back-btn { width: 80rpx; display: flex; align-items: center; }
- .back-icon { font-size: 50rpx; color: $primary; line-height: 1; }
- .nav-title { flex: 1; text-align: center; font-size: 32rpx; font-weight: 600; color: $text-main; }
- .nav-right { width: 80rpx; text-align: right; }
- .clear-btn { font-size: 26rpx; color: $text-sub; }
- /* 消息列表 */
- .msg-list {
- flex: 1;
- overflow: hidden;
- padding: 20rpx 0;
- }
- /* 欢迎区 */
- .welcome {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 80rpx 40rpx 40rpx;
- gap: 16rpx;
- }
- .welcome-icon { width: 120rpx; height: 120rpx; border-radius: 24rpx; }
- .welcome-title { font-size: 32rpx; font-weight: 600; color: $text-main; }
- .welcome-sub { font-size: 26rpx; color: $text-sub; }
- .example-tags {
- display: flex;
- flex-wrap: wrap;
- justify-content: center;
- gap: 16rpx;
- margin-top: 20rpx;
- }
- .tag {
- padding: 12rpx 24rpx;
- background: #fff;
- border: 1rpx solid #d0e8ff;
- border-radius: 40rpx;
- font-size: 24rpx;
- color: $primary;
- }
- /* 消息行 */
- .msg-row {
- display: flex;
- padding: 16rpx 24rpx;
- gap: 16rpx;
- }
- .msg-user { flex-direction: row-reverse; }
- .msg-assistant { flex-direction: row; }
- /* 头像 */
- .avatar {
- width: 72rpx;
- height: 72rpx;
- border-radius: 50%;
- background: $primary;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- }
- .avatar-text { color: #fff; font-size: 22rpx; font-weight: 700; }
- /* 气泡包裹 */
- .bubble-wrap { flex: 1; display: flex; flex-direction: column; gap: 8rpx; max-width: 90%; }
- /* 气泡 */
- .bubble {
- border-radius: 16rpx;
- padding: 20rpx 24rpx;
- word-break: break-all;
- }
- .bubble.user {
- background: $bubble-user;
- border-bottom-right-radius: 4rpx;
- .bubble-text { color: #fff; }
- }
- .bubble.data, .bubble.knowledge {
- background: $bubble-ai;
- border-bottom-left-radius: 4rpx;
- box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06);
- }
- .bubble-text { line-height: 1.6; color: $text-main; }
- /* 进度步骤 */
- .steps {
- display: flex;
- flex-direction: column;
- gap: 6rpx;
- padding: 0 4rpx;
- }
- .step {
- display: flex;
- align-items: center;
- gap: 8rpx;
- font-size: 22rpx;
- color: $text-sub;
- &.done { color: #52c41a; }
- &.running { color: $primary; }
- }
- .step-dot { font-size: 20rpx; width: 24rpx; }
- .step-time { font-size: 20rpx; }
- /* 结果状态 */
- .result-msg {
- font-size: 24rpx;
- color: $text-sub;
- display: block;
- margin-bottom: 12rpx;
- &.error { color: #ff4d4f; }
- }
- /* 表格 */
- .data-table { margin-top: 8rpx; }
- .table-scroll { width: 100%; }
- .table-head, .table-row {
- display: flex;
- min-width: max-content;
- }
- .table-head { background: #f0f7ff; }
- .table-row.even { background: #fafafa; }
- .th, .td {
- min-width: 120rpx;
- padding: 12rpx 16rpx;
- font-size: 24rpx;
- white-space: nowrap;
- border-bottom: 1rpx solid #f0f0f0;
- }
- .th { font-weight: 600; color: $primary; }
- .td { color: $text-main; }
- /* SQL 折叠 */
- .sql-block { margin-top: 12rpx; }
- .sql-toggle {
- display: flex;
- align-items: center;
- gap: 8rpx;
- padding: 8rpx 0;
- }
- .sql-label { font-size: 22rpx; color: $text-sub; background: #f0f0f0; padding: 2rpx 12rpx; border-radius: 6rpx; }
- .sql-arrow { font-size: 20rpx; color: $text-sub; }
- .sql-code {
- background: #1e1e1e;
- border-radius: 8rpx;
- padding: 16rpx;
- text { font-size: 22rpx; color: #d4d4d4; line-height: 1.5; }
- }
- /* 知识库来源 */
- .sources { margin-top: 12rpx; padding-top: 12rpx; border-top: 1rpx solid #f0f0f0; }
- .source-label { font-size: 22rpx; color: $text-sub; }
- .source-item { font-size: 22rpx; color: $primary; }
- /* 加载动画 */
- .loading { background: $bubble-ai !important; box-shadow: 0 2rpx 8rpx rgba(0,0,0,.06); }
- .dot-loader { display: flex; gap: 8rpx; align-items: center; height: 32rpx; }
- .dot {
- width: 12rpx; height: 12rpx; border-radius: 50%; background: #ccc;
- animation: bounce 1.2s infinite ease-in-out;
- &:nth-child(2) { animation-delay: 0.2s; }
- &:nth-child(3) { animation-delay: 0.4s; }
- }
- @keyframes bounce {
- 0%, 80%, 100% { transform: scale(0.6); opacity: 0.4; }
- 40% { transform: scale(1); opacity: 1; }
- }
- /* 澄清提示 */
- .clarify-bar {
- background: #fff7e6;
- border-top: 1rpx solid #ffe58f;
- padding: 16rpx 30rpx;
- }
- .clarify-text { font-size: 26rpx; color: #d46b08; }
- /* 输入栏 */
- .input-bar {
- display: flex;
- align-items: center;
- padding: 16rpx 24rpx;
- padding-bottom: calc(16rpx + env(safe-area-inset-bottom));
- background: #fff;
- border-top: 1rpx solid #eee;
- gap: 16rpx;
- }
- .input {
- flex: 1;
- height: 72rpx;
- padding: 0 24rpx;
- background: #f5f6fa;
- border-radius: 36rpx;
- font-size: 28rpx;
- color: $text-main;
- border: 1rpx solid #e8e8e8;
- }
- .send-btn {
- width: 100rpx;
- height: 72rpx;
- background: $primary;
- border-radius: 36rpx;
- display: flex;
- align-items: center;
- justify-content: center;
- &.disabled { background: #ccc; }
- }
- .send-text { color: #fff; font-size: 26rpx; }
- /* 麦克风按钮 */
- .mic-btn {
- width: 72rpx;
- height: 72rpx;
- border-radius: 50%;
- background: #f0f0f0;
- display: flex;
- align-items: center;
- justify-content: center;
- flex-shrink: 0;
- transition: background 0.2s;
- &.recording {
- background: #ff4d4f;
- animation: micPulse 0.8s infinite;
- }
- }
- .mic-icon { font-size: 32rpx; line-height: 1; }
- /* 用阴影呼吸代替 scale,避免缩放触发 mouseleave */
- @keyframes micPulse {
- 0%, 100% { box-shadow: 0 0 0 0 rgba(255,77,79,0.5); }
- 50% { box-shadow: 0 0 0 12rpx rgba(255,77,79,0); }
- }
- /* 录音浮层 */
- .recording-overlay {
- position: fixed;
- inset: 0;
- background: rgba(0,0,0,.5);
- display: flex;
- align-items: center;
- justify-content: center;
- z-index: 200;
- }
- .recording-inner {
- background: #fff;
- border-radius: 24rpx;
- padding: 60rpx 80rpx;
- display: flex;
- flex-direction: column;
- align-items: center;
- gap: 20rpx;
- }
- .recording-icon { font-size: 80rpx; }
- .recording-tip { font-size: 26rpx; color: #666; }
- .recording-waves {
- display: flex;
- gap: 8rpx;
- align-items: center;
- height: 50rpx;
- }
- .wave {
- width: 8rpx;
- background: #1890ff;
- border-radius: 4rpx;
- animation: wave 0.8s infinite ease-in-out alternate;
- }
- @keyframes wave {
- 0% { height: 10rpx; }
- 100%{ height: 50rpx; }
- }
- </style>
|