You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

1133 lines
28 KiB
Vue

4 months ago
<template>
<view class="ai-page">
<uni-nav-bar left-icon="left" @clickLeft="openDrawer" title="AI对话">
<template v-slot:left>
<view class="hamburger">
<view class="line" />
<view class="line" />
<view class="line" />
4 months ago
</view>
4 months ago
</template>
<template v-slot:right>
<view class="nav-right">
<!-- <view class="gear" @tap="onSettingTap"></view> -->
<image src="../../static/set.png" mode="widthFix" @tap="onSettingTap" style="width: 18px;"></image>
4 months ago
</view>
4 months ago
</template>
</uni-nav-bar>
<!-- scrollable content -->
<scroll-view class="content" :scroll-y="true" show-scrollbar="false"
scroll-with-animation ref="scrollView">
<!-- greeting card -->
<view class="greet-card">
<image src="../../static/ai.webp" mode="widthFix" style="width: 60px;margin-right: 10px;"></image>
<view class="greet-text">
<view class="hi">HI{{ timeOfDayText }}</view>
<view class="sub">我是萃星科技智能体</view>
4 months ago
</view>
</view>
4 months ago
<!-- welcome sentence -->
<view class="welcome">
您好非常高兴与您交流今天有什么可以帮到您
</view>
4 months ago
4 months ago
<!-- suggestions -->
<view class="guess-panel">
<view class="guess-title">猜你想问</view>
<view class="guess-list">
<view class="guess-item" @tap="onSuggestionTap('今日出入库数据')">
<text>今日出入库数据</text>
<text class="arrow"></text>
4 months ago
</view>
4 months ago
<view class="guess-item" @tap="onSuggestionTap('今日销售数据')">
<text>今日销售数据</text>
<text class="arrow"></text>
4 months ago
</view>
4 months ago
<view class="guess-item" @tap="onSuggestionTap('今日生产数据')">
<text>今日生产数据</text>
<text class="arrow"></text>
</view>
</view>
</view>
<!-- conversation -->
<view class="chat">
<view v-for="m in messages" :key="m.id" :id="'msg-' + m.id" :class="['msg', m.role]">
<view v-if="m.role === 'user'" class="bubble user-bubble">
<text v-if="m.inputType === 'text'">{{ m.content }}</text>
<view class="text-voice" v-else @tap="playVoice(m.inputContent,m.id)">
<text>{{m.duration }}</text>
<image class="voice-play" src="../../static/voice-play.png" mode="widthFix"></image>
</view>
</view>
<view v-else class="bubble ai-bubble">
<view v-if="m.type === 'card'" class="ai-card">
<view class="ai-card-title">{{ m.title }}</view>
<view class="ai-card-body">{{ m.content }}</view>
</view>
<view v-else-if="m.loading" class="ai-loading">
<view class="loading-dot"></view>
<view class="loading-dot"></view>
<view class="loading-dot"></view>
</view>
<view v-else>
<text>{{ m.displayText !== undefined ? m.displayText : m.content }}</text>
<!-- <text class="listen-btn" @tap="onListen(m.content)">🔊</text> -->
</view>
4 months ago
</view>
</view>
</view>
4 months ago
<view style="height: 12px;" />
4 months ago
</scroll-view>
4 months ago
<!-- bottom dock: quick actions + input bar -->
<view class="dock">
<scroll-view class="quick-actions horizontal" scroll-x show-scrollbar="false">
<view class="qa-btn minor" @tap="onSwitchModel"></view>
<view class="qa-btn" @tap="onQuickAsk('你是谁?')"></view>
<view class="qa-btn" @tap="onQuickAsk('今日任务有哪些?')"></view>
<view class="qa-btn" @tap="onQuickAsk('展示一份报表示例')"></view>
<view class="qa-btn" @tap="onQuickAsk('生成日报模版')"></view>
</scroll-view>
<view class="input-bar">
<input class="input" confirm-type="send" :value="inputText" @input="onInput" @confirm="onSend()"
placeholder="你可以说…" placeholder-class="ph" />
<view :class="['mic', { recording: isRecording }]" @touchstart.stop="onPressMic"
@touchmove.stop="onMoveMic" @touchend.stop="onReleaseMic">🎙</view>
<button class="send" type="primary" @tap="onSend"></button>
</view>
4 months ago
</view>
4 months ago
<!-- left drawer -->
<uni-popup ref="popup" background-color="#fff" type="left" :z-index="10090" @change="onPopupChange" style="z-index: 99999;width: 100vw" >
<view class="drawer-mask">
<view class="drawer">
<scroll-view class="drawer-scroll" scroll-y show-scrollbar="false">
<view v-for="g in historyGroups" :key="g.date" class="drawer-group">
<view class="drawer-date">{{ g.date }}</view>
<view v-for="(t, idx) in g.items" :key="idx" class="drawer-item" @tap="onHistoryItemTap(t)" @longpress="onLongPressHistory(t)">
{{ t }}
</view>
<view class="drawer-divider" />
4 months ago
</view>
4 months ago
</scroll-view>
<view class="drawer-footer">
<view class="user-icon">👤</view>
<text class="user-name">用户名</text>
<view class="footer-gear" @tap="clearAllHistory"></view>
4 months ago
</view>
</view>
</view>
4 months ago
</uni-popup>
4 months ago
4 months ago
<!-- Voice recording overlay -->
<view v-if="isRecording" class="record-mask">
<view class="record-box" :class="{ cancel: willCancel }">
<view class="record-icon">🎙</view>
<view class="record-text">{{ willCancel ? '松开手指,取消发送' : '手指上滑,取消发送' }}</view>
</view>
4 months ago
</view>
4 months ago
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent>
4 months ago
4 months ago
</view>
</view>
</template>
<script>
const HISTORY_KEY = 'chat_history_groups'
export default {
data() {
return {
inputText: '',
messages: [
// {
// id: 1,
// role: 'user',
// type: 'text',
// content: '帮我统计一下今日的销售数据',
// inputType: 'text'
// },
// {
// id: 2,
// role: 'assistant',
// type: 'card',
// title: '今日销售数据统计结果如下:',
// content: '内容内容........................'
// }
],
scrollInto: '',
drawerOpen: false,
historyGroups: [
],
isRecording: false,
isLoading:false,
willCancel: false,
recorder: null,
recordStartY: 0,
recordStartTs: 0,
recordSimTimer: null,
// show: false,
innerAudioContext: null,
popupVisible: false,
typewriterTimers: {},
}
},
computed: {
timeOfDayText() {
const h = new Date().getHours()
if (h < 6) return '凌晨好'
if (h < 12) return '上午好'
if (h < 18) return '下午好'
return '晚上好'
}
},
mounted() {
this.loadChatHistory()
this.scrollToBottom();
},
beforeDestroy() {
// 清理所有打字机定时器
Object.values(this.typewriterTimers).forEach(timer => {
if (timer) clearInterval(timer)
})
this.typewriterTimers = {}
// 清理录音定时器
if (this.recordSimTimer) {
clearTimeout(this.recordSimTimer)
}
},
methods: {
// 新增方法:上传音频并识别
async recognizeAudio(tempFilePath) {
try {
console.log('开始语音识别,文件路径:', tempFilePath)
4 months ago
4 months ago
// 获取文件信息
const fileInfo = await new Promise((resolve, reject) => {
uni.getFileInfo({
filePath: tempFilePath,
success: resolve,
fail: reject
})
})
4 months ago
4 months ago
console.log('文件大小:', fileInfo.size)
4 months ago
4 months ago
// 使用 UniApp 的上传文件 API
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
// url: 'http://192.168.133.83:8000/recognize_speech',
4 months ago
// url: 'http://192.168.10.44:8000/recognize_speech',
// url: 'http://192.168.1.18:8000/recognize_speech',
url: 'http://106.227.91.181:8000/recognize_speech',
4 months ago
filePath: tempFilePath,
name: 'speech', // 对应后端的 UploadFile 参数名
formData: {
'format': 'amr',
'rate': 16000,
'channel': 1,
'cuid': 'uniapp_user',
'audio_len': fileInfo.size
4 months ago
},
4 months ago
success: (res) => {
console.log('上传响应:', res)
if (res.statusCode === 200) {
try {
// 尝试解析返回的 JSON 数据
const data = JSON.parse(res.data)
resolve({ statusCode: 200, data })
} catch (e) {
reject(new Error('响应解析失败: ' + e.message))
}
} else {
reject(new Error(`上传失败: ${res.statusCode}`))
}
},
fail: (err) => {
reject(new Error('上传请求失败: ' + err.errMsg))
}
})
})
4 months ago
4 months ago
console.log('语音识别响应:', uploadRes)
const result = uploadRes.data
if (result.status === 'success') {
return result.result
} else {
throw new Error(result.error || '识别失败')
4 months ago
}
4 months ago
} catch (error) {
console.error('语音识别错误:', error)
4 months ago
uni.showToast({
4 months ago
title: '识别失败: ' + (error.message || '网络错误'),
icon: 'none'
4 months ago
})
4 months ago
return null
}
4 months ago
},
4 months ago
// ==================== 历史记录管理 ====================
formatDate(date) {
const y = date.getFullYear()
const m = String(date.getMonth() + 1).padStart(2, '0')
const d = String(date.getDate()).padStart(2, '0')
return `${y}${m}${d}`
},
loadChatHistory() {
try {
const data = uni.getStorageSync(HISTORY_KEY)
if (data && Array.isArray(data.groups)) {
this.historyGroups = data.groups
} else {
this.historyGroups = []
}
} catch (e) {
this.historyGroups = []
}
},
addToHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []
const today = this.formatDate(new Date())
let todayGroup = groups.find(g => g.date === today)
if (!todayGroup) {
todayGroup = { date: today, items: [] }
groups.unshift(todayGroup)
}
if (!todayGroup.items.includes(text)) {
todayGroup.items.unshift(text)
}
// 限制大小
if (todayGroup.items.length > 50) todayGroup.items = todayGroup.items.slice(0, 50)
if (groups.length > 30) groups = groups.slice(0, 30)
this.historyGroups = groups
uni.setStorageSync(HISTORY_KEY, { groups, updatedAt: Date.now() })
},
removeFromHistory(text) {
let groups = uni.getStorageSync(HISTORY_KEY)?.groups || []
groups.forEach(group => {
group.items = group.items.filter(item => item !== text)
})
groups = groups.filter(g => g.items.length > 0)
this.historyGroups = groups
uni.setStorageSync(HISTORY_KEY, { groups, updatedAt: Date.now() })
},
clearAllHistory() {
uni.showModal({
title: '清除全部',
content: '将删除所有对话记录,此操作不可恢复',
success: (res) => {
if (res.confirm) {
uni.removeStorageSync(HISTORY_KEY)
this.historyGroups = []
uni.showToast({ title: '已清除', icon: 'success' })
}
}
})
},
onLongPressHistory(text) {
uni.showModal({
title: '删除记录',
content: '确定删除这条对话记录?',
success: (res) => {
if (res.confirm) {
this.removeFromHistory(text)
}
}
})
},
// 工具
removeMessage(id) {
const idx = this.messages.findIndex(m => m.id === id)
if (idx > -1) this.messages.splice(idx, 1)
},
addAssistantMessage(id, content) {
this.messages.push({ id, role: 'assistant', type: 'text', content, displayText: '' })
},
async getAIResponse(message){
try {
// const url = 'http://192.168.133.83:9020/api/chat'
4 months ago
// const url = 'http://192.168.10.44:9020/api/chat'
const url = 'http://106.227.91.181:9020/api/chat' // 如需切换线上,改这里即可
4 months ago
const headers = { 'Content-Type': 'application/json' }
const data = { message }
// console.log(data)
// const [error, res] = await uni.request({
// url,
// method: 'POST',
// header: headers,
// data
// })
// console.log(res)
// 使用 Promise 风格
const res = await new Promise((resolve, reject) => {
uni.request({
url,
method: 'POST',
header: headers,
data,
success: (res) => resolve(res),
fail: (err) => reject(err)
})
4 months ago
})
4 months ago
console.log('请求响应:', res)
if (res.statusCode !== 200) {
throw new Error(`HTTP错误! 状态码: ${res.statusCode}`)
}
return res.data?.result?.data || '未获取到有效回复'
} catch (error) {
console.error('AI请求错误:', error)
return `抱歉,出了点问题: ${error.errMsg || error.message}`
}
},
playVoice(voicePath) {
if (!voicePath) {
uni.showToast({ title: '无可播放的语音', icon: 'none' })
return
}
if (!this.innerAudioContext) {
this.innerAudioContext = uni.createInnerAudioContext()
this.innerAudioContext.autoplay = false
this.innerAudioContext.onError(() => {
uni.showToast({ title: '播放失败', icon: 'none' })
});
}
try { this.innerAudioContext.stop() } catch(e) {}
console.log(voicePath)
this.innerAudioContext.src = voicePath
this.innerAudioContext.play()
},
onSettingTap() {
uni.navigateTo({
url: '/pages/setting/index'
4 months ago
})
4 months ago
},
onSuggestionTap(text) {
this.inputText = text
this.onSend();
},
onQuickAsk(text) {
this.inputText = text
this.onSend()
},
onSwitchModel() {
uni.showToast({
title: '已切换为通用模型',
icon: 'none'
})
},
onInput(e) {
this.inputText = e.detail.value
},
openDrawer() {
this.$refs.popup.open()
},
onPopupChange(e){
// e.show: true when opened, false when closed
this.popupVisible = !!(e && (e.show === true))
},
// ===== Voice input (WeChat-like) =====
ensureRecorder() {
if (this.recorder) return
4 months ago
try {
4 months ago
this.recorder = uni.getRecorderManager && uni.getRecorderManager()
} catch (e) {
this.recorder = null
4 months ago
}
4 months ago
if (this.recorder) {
this.recorder.onStart()
this.recorder.onStop( async(res) => {
const duration = Date.now() - this.recordStartTs;
const tempFilePath = res.tempFilePath; // 添加这行从res中获取文件路径
if (this.willCancel || duration < 700) {
uni.showToast({
title: duration < 700 ? '说话时间太短' : '已取消',
icon: 'none'
})
return
}
// 显示加载
uni.showLoading({ title: '识别中...' });
// TODO: 上传 res.tempFilePath 做识别;现用 mock
// this.handleRecognizedText(this.mockSpeechToText(duration), res.tempFilePath, Math.ceil(
// duration / 100) / 10)
// 真实识别
const recognizedText = await this.recognizeAudio(tempFilePath);
uni.hideLoading();
if (recognizedText) {
// 成功:填入输入框
this.inputText = recognizedText;
this.$nextTick(() => {
// 可选:自动发送
// this.onSend('voice', tempFilePath, Math.ceil(duration / 100) / 10);
});
}
4 months ago
})
4 months ago
}
},
onPressMic(e) {
this.ensureRecorder()
this.isRecording = true
// this.show = true
this.willCancel = false
this.recordStartTs = Date.now()
this.recordStartY = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : 0
if (this.recorder) {
try {
this.recorder.start({
format: 'amr',
sampleRate: 16000,
encodeBitRate: 16000, // 编码比特率
frameSize: 4, // 帧大小
numberOfChannels: 1,
duration: 60000
})
} catch (err) {}
4 months ago
} else {
4 months ago
if (this.recordSimTimer) clearTimeout(this.recordSimTimer)
this.recordSimTimer = setTimeout(() => {}, 60000)
4 months ago
}
4 months ago
},
onMoveMic(e) {
if (!this.isRecording) return;
const y = (e.changedTouches && e.changedTouches[0]) ? e.changedTouches[0].clientY : 0;
this.willCancel = (this.recordStartY - y) > 60
},
onReleaseMic() {
console.log('onReleaseMic');
if (!this.isRecording) return
const cancel = this.willCancel
this.isRecording = false;
this.show = false
if (this.recorder) {
try {
this.recorder.stop()
} catch (err) {
console.log('err', err);
}
4 months ago
}
4 months ago
},
handleRecognizedText(text, tempFilePath, duration) {
if (!text) return
this.inputText = text
this.onSend('voice', tempFilePath, duration) // 传 'voice'
},
mockSpeechToText(ms) {
const sec = Math.ceil(ms / 100) / 10
const pool = [
`语音输入 ${sec}s模拟识别帮我统计今天销售额`,
`语音输入 ${sec}s模拟识别查询订单20388993483`,
`语音输入 ${sec}s模拟识别生成日报`
]
return pool[Math.floor(Math.random() * pool.length)]
},
onHistoryItemTap(text) {
this.inputText = text
this.onSend();
this.$refs.popup.close()
},
async onSend(inputType = 'text', inputContent = '', duration = undefined) {
const text = (this.inputText || '').trim()
if (!text || this.isLoading) return
const baseId = Date.now()
// 1. 用户消息
this.messages.push({
id: baseId,
role: 'user',
type: 'text',
content: text,
inputType,
inputContent,
duration
})
// 2. loading 消息
const loadingId = baseId + 0.5
this.messages.push({
id: loadingId,
role: 'assistant',
loading: true
})
this.scrollToBottom()
this.inputText = ''
this.isLoading = true
this.addToHistory(text)
try {
// 3. 真正等待 AI 回复
const reply = await this.getAIResponse(text)
// 4. 移除 loading
const loadingIdx = this.messages.findIndex(m => m.id === loadingId)
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1)
// 5. 添加回复 + 打字机
const replyId = baseId + 1
this.messages.push({
id: replyId,
role: 'assistant',
type: 'text',
content: reply,
displayText: ''
})
this.typewriter(replyId, reply)
} catch (e) {
// 出错时也展示
const loadingIdx = this.messages.findIndex(m => m.id === loadingId)
if (loadingIdx > -1) this.messages.splice(loadingIdx, 1)
this.messages.push({
id: baseId + 1,
role: 'assistant',
content: `请求出错:${e.message || e}`
})
} finally {
this.isLoading = false
this.$nextTick(() => this.scrollToBottom())
}
},
typewriter(messageId, fullText) {
const msg = this.messages.find(m => m.id === messageId)
if (!msg) return
// 清理之前的定时器(如果存在)
if (this.typewriterTimers[messageId]) {
clearInterval(this.typewriterTimers[messageId])
}
let index = 0
msg.displayText = fullText.substring(0, index + 1);
index += 1;
const speed = 50 // 每个字符间隔50ms
const timer = setInterval(() => {
if (index < fullText.length) {
msg.displayText = fullText.substring(0, index + 1)
index++
// 打字过程中自动滚动到底部
this.$nextTick(() => {
this.scrollToBottom()
})
} else {
clearInterval(timer)
delete this.typewriterTimers[messageId]
// 完成后使用完整文本
msg.displayText = fullText
}
}, speed)
this.typewriterTimers[messageId] = timer
},
scrollToBottom() {
4 months ago
let self = this;
this.$nextTick(() => {
uni.createSelectorQuery().select('.content').boundingClientRect((rect) => {
if(self.height !== rect.height){
self.height = rect.height;
uni.pageScrollTo({
scrollTop: rect.height,
duration: 300,
class: '.content'
});
}
}).exec();
})
},
4 months ago
mockReply(text) {
const candidates = [
'好的,我已经为您处理。',
'收到请求,以下是结果的概览。',
'我理解了,这是一个示例回复。',
'已记录,稍后将完善报表。'
]
const pick = candidates[Math.floor(Math.random() * candidates.length)]
return pick + '(已收到:“' + text + '”)'
},
onListen(text) {
try {
// H5: Web Speech API
if (typeof window !== 'undefined' && window.speechSynthesis) {
const u = new SpeechSynthesisUtterance(String(text))
u.lang = 'zh-CN'
u.rate = 1
u.pitch = 1
window.speechSynthesis.cancel()
window.speechSynthesis.speak(u)
return
}
} catch (e) {}
uni.showToast({
title: '当前端不支持语音播放',
icon: 'none'
})
}
4 months ago
}
}
4 months ago
</script>
<style scoped>
::v-deep .uni-nav-bar-text{
font-size: 18px !important;
}
::v-deeo .uni-navbar--border{
/* border-bottom: 0px !important; */
border-bottom: 1px solid #fff !important;
}
.ai-page {
height: 100vh;
display: flex;
flex-direction: column;
background: #f7f8fc;
}
.nav {
height: 44px;
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 12px;
background: #ffffff;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
position: fixed;
left: 0;
right: 0;
top: 0;
z-index: 9;
}
.nav-title {
font-size: 16px;
font-weight: 600;
}
.hamburger {
width: 18px;
}
.hamburger .line {
height: 2px;
background: #333;
margin: 3px 0;
border-radius: 2px;
}
.gear {
width: 18px;
height: 18px;
position: relative;
color: #000;
}
.content {
flex: 1;
padding: 16px 12px 68px 12px;
background-color: #f7f8fc;
width: 100%;
box-sizing: border-box;
}
.greet-card {
display: flex;
align-items: center;
background: #fff;
border-radius: 14px;
padding: 12px;
margin-bottom: 10px;
}
.avatar-inner {
font-size: 26px;
}
.greet-text .hi {
font-size: 16px;
font-weight: 700;
color: #0b56ff;
}
.greet-text .sub {
font-size: 12px;
color: #4a76b1;
margin-top: 4px;
}
.welcome {
font-size: 13px;
color: #333;
background: #fff;
border-radius: 12px;
padding: 10px 12px;
margin: 12px 0;
}
.guess-panel {
background: #fff;
border-radius: 14px;
padding: 10px;
margin-bottom: 16px;
}
.guess-title {
color: #5f6fff;
font-size: 14px;
margin-bottom: 8px;
}
.guess-list {
display: flex;
flex-direction: column;
}
.guess-item {
background: #f7f8fc;
border-radius: 10px;
padding: 12px;
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 10px;
box-sizing: border-box;
}
.guess-item:last-child {
margin-bottom: 0;
}
.guess-item .arrow {
color: #9aa3b2;
font-size: 18px;
}
.chat {
margin: 6px 0 12px;
}
.msg {
margin: 10px 0;
display: flex;
}
.msg.user {
justify-content: flex-end;
}
.bubble {
max-width: 80%;
padding: 10px 12px;
border-radius: 14px;
font-size: 14px;
line-height: 1.5;
}
.user-bubble {
background: #4e7bff;
color: #fff;
border-bottom-right-radius: 4px;
margin-right: 6px;
}
.ai-bubble {
background: #fff;
color: #333;
border-bottom-left-radius: 4px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.listen-btn {
margin-left: 8px;
color: #6b7280;
font-size: 14px;
}
.ai-card-title {
color: #5f6fff;
font-weight: 600;
margin-bottom: 6px;
}
.ai-card-body {
color: #666;
}
/* loading animation */
.ai-loading {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 0;
}
.loading-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: #9ca3af;
animation: loading-bounce 1.4s ease-in-out infinite both;
}
.loading-dot:nth-child(1) {
animation-delay: -0.32s;
}
.loading-dot:nth-child(2) {
animation-delay: -0.16s;
}
@keyframes loading-bounce {
0%, 80%, 100% {
transform: scale(0.8);
opacity: 0.5;
}
40% {
transform: scale(1.2);
opacity: 1;
}
}
/* bottom dock */
.dock {
position: fixed;
left: 0;
right: 0;
bottom: 0;
background: #f7f8fc;
box-shadow: 0 -4px 12px rgba(0, 0, 0, 0.06);
padding-bottom: constant(safe-area-inset-bottom);
padding-bottom: env(safe-area-inset-bottom);
}
.quick-actions {
padding: 6px 10px 4px;
}
.quick-actions.horizontal {
white-space: nowrap;
width: 95%;
}
.qa-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 96px;
text-align: center;
background: #fff;
border-radius: 10px;
padding: 8px 10px;
font-size: 12px;
color: #3b3f45;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
margin-right: 10px;
}
.qa-btn.minor {
background: #eff1ff;
color: #4e7bff;
}
.qa-btn:last-child {
margin-right: 0;
}
.input-bar {
display: flex;
align-items: center;
padding: 8px 10px 12px;
gap: 8px;
background: #f7f8fc;
}
.input {
flex: 1;
background: #fff;
border-radius: 24px;
padding: 10px 14px;
font-size: 14px;
}
.ph {
color: #9aa3b2;
}
.mic {
width: 36px;
height: 36px;
border-radius: 18px;
background: #fff;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06);
}
.mic.recording {
background: #fffbf0;
box-shadow: 0 0 0 2px rgba(255, 193, 7, .25) inset;
}
.send {
height: 36px;
line-height: 36px;
padding: 0 14px;
border-radius: 18px;
background: #4e7bff;
color: #fff;
font-size: 14px;
}
/* drawer */
.drawer-mask {
width: 70vw;
height: 100vh;
}
.drawer {
width: 100%;
height: 100vh;
background: #fff;
border-top-right-radius: 8px;
border-bottom-right-radius: 8px;
display: flex;
flex-direction: column;
}
.drawer.show {
transform: translateX(0);
}
.drawer-scroll {
height: calc(100vh - 64px);
padding: 12px;
box-sizing: border-box;
}
.drawer-group {
padding: 10px 8px 0;
}
.drawer-date {
color: #9aa3b2;
font-size: 12px;
margin-bottom: 8px;
}
.drawer-item {
color: #333;
font-size: 13px;
line-height: 20px;
margin: 6px 0;
}
.drawer-divider {
height: 1px;
background: #eeeeee;
margin: 12px 0;
}
.drawer-footer {
padding: 12px;
border-top: 1px solid #eeeeee;
display: flex;
align-items: center;
}
.drawer-footer {
/* fixed height for calc above */
height: 64px;
}
.user-icon {
width: 24px;
text-align: center;
}
.user-name {
flex: 1;
font-size: 14px;
color: #333;
}
.footer-gear {
width: 24px;
text-align: center;
}
/* voice overlay */
.record-mask {
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
display: flex;
align-items: center;
justify-content: center;
z-index: 9999;
}
.record-box {
background: rgba(0, 0, 0, .75);
color: #fff;
padding: 16px 18px;
border-radius: 12px;
display: flex;
align-items: center;
gap: 10px;
min-width: 220rpx;
}
.record-box.cancel {
background: rgba(221, 44, 0, .85);
}
.record-icon {
font-size: 20px;
}
.record-text {
font-size: 14px;
}
.text-voice {
display: flex;
align-items: center;
}
.voice-play {
width: 20px;
margin-left: 5px;
}
.mask-layer{
position: fixed;
left: 0;
right: 0;
top: 0;
bottom: 0;
background-color: rgba(0, 0, 0, .1);
}
</style>