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.

1128 lines
27 KiB
Vue

This file contains invisible Unicode characters!

This file contains invisible Unicode characters that may be processed differently from what appears below. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to reveal hidden characters.

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

<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" />
</view>
</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>
</view>
</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>
</view>
</view>
<!-- welcome sentence -->
<view class="welcome">
您好非常高兴与您交流今天有什么可以帮到您
</view>
<!-- 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>
</view>
<view class="guess-item" @tap="onSuggestionTap('今日销售数据')">
<text>今日销售数据</text>
<text class="arrow"></text>
</view>
<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>
</view>
</view>
</view>
<view style="height: 12px;" />
</scroll-view>
<!-- 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>
</view>
<!-- 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" />
</view>
</scroll-view>
<view class="drawer-footer">
<view class="user-icon">👤</view>
<text class="user-name">用户名</text>
<view class="footer-gear" @tap="clearAllHistory">⚙️</view>
</view>
</view>
</view>
</uni-popup>
<!-- 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>
</view>
<view v-if="isRecording" class="mask-layer" @touchmove.stop.prevent>
</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)
// 获取文件信息
const fileInfo = await new Promise((resolve, reject) => {
uni.getFileInfo({
filePath: tempFilePath,
success: resolve,
fail: reject
})
})
console.log('文件大小:', fileInfo.size)
// 使用 UniApp 的上传文件 API
const uploadRes = await new Promise((resolve, reject) => {
uni.uploadFile({
// url: 'http://192.168.133.83:8000/recognize_speech',
url: 'http://192.168.10.44:8000/recognize_speech',
filePath: tempFilePath,
name: 'speech', // 对应后端的 UploadFile 参数名
formData: {
'format': 'amr',
'rate': 16000,
'channel': 1,
'cuid': 'uniapp_user',
'audio_len': fileInfo.size
},
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))
}
})
})
console.log('语音识别响应:', uploadRes)
const result = uploadRes.data
if (result.status === 'success') {
return result.result
} else {
throw new Error(result.error || '识别失败')
}
} catch (error) {
console.error('语音识别错误:', error)
uni.showToast({
title: '识别失败: ' + (error.message || '网络错误'),
icon: 'none'
})
return null
}
},
// ==================== 历史记录管理 ====================
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'
const url = 'http://192.168.10.44:9020/api/chat'
// const url = 'http://106.227.91.181:9020/api/chat' // 如需切换线上,改这里即可
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)
})
})
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'
})
},
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
try {
this.recorder = uni.getRecorderManager && uni.getRecorderManager()
} catch (e) {
this.recorder = null
}
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);
});
}
})
}
},
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) {}
} else {
if (this.recordSimTimer) clearTimeout(this.recordSimTimer)
this.recordSimTimer = setTimeout(() => {}, 60000)
}
},
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);
}
}
},
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() {
this.$nextTick(() => {
uni.createSelectorQuery().select('.content').boundingClientRect((rect) => {
uni.pageScrollTo({
scrollTop: rect.height,
duration: 300,
class: '.content'
});
}).exec();
})
},
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'
})
}
}
}
</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>