Files
JoyD/Claw/client/wechat_app/custom-tab-bar/index.js

378 lines
12 KiB
JavaScript
Raw Normal View History

2026-04-21 13:46:20 +08:00
/**
* 自定义底部导航栏组件 - 公众号客服交互模式
*
* 功能清单
* 1. 3个Tab切换首页/设备/任务
* 2. 右侧键盘图标 跳转到聊天页
* 3. 聊天输入模式 - 文字模式默认
* 4. 左侧🔊小喇叭 切换到语音录制模式
* 5. 语音模式下左侧键盘 切回文字模式
* 6. 按住说话 松开发送
* 7. 语音转文字模拟
*/
Component({
data: {
// ---- TabBar 相关 ----
currentTab: 0,
tabList: [
{ pagePath: '/pages/index/index', icon: '/assets/icons/home.png', selectedIcon: '/assets/icons/home-active.png', text: '首页' },
{ pagePath: '/pages/chat/chat', icon: '/assets/icons/chat.png', selectedIcon: '/assets/icons/chat-active.png', text: '设备' },
{ pagePath: '/pages/task/task', icon: '/assets/icons/task.png', selectedIcon: '/assets/icons/task-active.png', text: '任务' }
],
// ---- 聊天面板相关 ----
showChatPanel: false,
// ---- 输入模式 ----
voiceMode: false,
inputText: '',
hasInput: false,
inputFocus: true,
// ---- 语音录制相关 ----
isRecording: false,
recordStartTime: 0,
recorderManager: null,
tempFilePath: '',
// ---- 语音转文字 ----
isVoiceToText: false,
// ---- 软键盘适配 ----
keyboardHeight: 0,
tabBarBottom: 0,
},
lifetimes: {
attached() {
this._initRecorder()
this._initAudioPlayer()
this._calcKeyboardStyle()
}
},
pageLifetimes: {
show() {
const pages = getCurrentPages()
const currentPage = pages[pages.length - 1]
const isChatPage = currentPage && currentPage.route === 'pages/chat/chat'
console.log('[TabBar] 页面显示:', currentPage ? currentPage.route : '未知', '→', isChatPage ? '聊天模式' : '导航模式')
this.setData({ showChatPanel: isChatPage })
if (!isChatPage) {
this.setData({ keyboardHeight: 0, inputText: '', hasInput: false })
this._calcKeyboardStyle()
}
}
},
methods: {
/* ==========================================================
TabBar 切换
========================================================== */
onSwitchTab(e) {
const { index, path } = e.currentTarget.dataset
if (index === this.data.currentTab) return
console.log('[TabBar] 切换到:', this.data.tabList[index].text)
this.setData({ currentTab: index })
wx.switchTab({
url: path,
fail: (err) => {
console.error('[TabBar] 页面跳转失败:', err)
this.setData({ currentTab: index === 0 ? 1 : 0 })
}
})
},
/* ==========================================================
聊天页面跳转
========================================================== */
goToChat() {
this._stopRecordingIfNeeded()
wx.switchTab({ url: '/pages/chat/chat' })
},
goHome() {
this._stopRecordingIfNeeded()
this.setData({
keyboardHeight: 0,
inputText: '',
hasInput: false,
voiceMode: false,
isRecording: false,
isVoiceToText: false,
inputFocus: false
})
this._calcKeyboardStyle()
wx.switchTab({ url: '/pages/index/index' })
},
/* ==========================================================
输入模式切换文字 语音
========================================================== */
switchToVoiceMode() {
console.log('[输入] 切换到语音模式')
wx.hideKeyboard()
this.setData({ voiceMode: true, inputFocus: false })
wx.showToast({ title: '已切换为语音模式', icon: 'none', duration: 1000 })
},
switchToTextMode() {
console.log('[输入] 切换到文字模式')
this._stopRecordingIfNeeded()
this.setData({ voiceMode: false, isRecording: false, inputFocus: true })
wx.showToast({ title: '已切换为文字模式', icon: 'none', duration: 1000 })
},
/* ==========================================================
文字消息收发
========================================================== */
onInputChange(e) {
const val = e.detail.value
this.setData({ inputText: val, hasInput: !!val.trim() })
},
_calcKeyboardStyle() {
const sys = wx.getSystemInfoSync()
const rpxToPx = sys.windowWidth / 750
const tabH = Math.round(100 * rpxToPx)
const kbH = this.data.keyboardHeight
this.setData({ tabBarBottom: kbH })
},
onInputFocus(e) {
const h = e.detail.height || 0
console.log('[键盘] focus 高度:', h, 'px')
if (h > 0) {
this.setData({ keyboardHeight: h })
this._calcKeyboardStyle()
}
},
onKeyboardHeightChange(e) {
const h = e.detail.height || 0
console.log('[键盘] 实时高度:', h, 'px')
this.setData({ keyboardHeight: h })
this._calcKeyboardStyle()
},
onInputBlur() {
this.setData({ keyboardHeight: 0 })
this._calcKeyboardStyle()
},
sendTextMessage() {
const text = this.data.inputText.trim()
if (!text) {
wx.showToast({ title: '请输入消息', icon: 'none' })
return
}
this.setData({ inputText: '', hasInput: false })
const pages = getCurrentPages()
const chatPage = pages.find(p => p.route === 'pages/chat/chat')
if (chatPage && chatPage.sendMessage) {
chatPage.sendMessage(text)
}
},
/* ==========================================================
语音录制功能
========================================================== */
_initRecorder() {
try {
const manager = wx.getRecorderManager()
manager.onStart(() => {
console.log('[录音] 开始录制')
this.setData({ isRecording: true, recordStartTime: Date.now() })
})
manager.onStop((res) => {
console.log('[录音] 录制结束:', res.tempFilePath, res.duration + 'ms')
const duration = res.duration || (Date.now() - this.data.recordStartTime)
this.setData({ isRecording: false, tempFilePath: res.tempFilePath || '' })
if (duration < 500) {
console.log('[录音] 录制时间过短,忽略')
return
}
this._sendVoiceMessage(res.tempFilePath || this.data.tempFilePath, duration)
})
manager.onError((err) => {
console.error('[录音] 错误:', err)
this.setData({ isRecording: false })
wx.showToast({ title: '录音失败: ' + (err.errMsg || '未知错误'), icon: 'none' })
})
this.setData({ recorderManager: manager })
} catch (e) {
console.warn('[录音] 初始化失败,可能不支持录音:', e)
this.setData({ recorderManager: null })
}
},
startRecordVoice() {
const mgr = this.data.recorderManager
if (!mgr) {
wx.showToast({ title: '录音功能不可用', icon: 'none' })
return
}
console.log('[录音] 手指按下,开始录音...')
wx.vibrateShort({ type: 'medium' })
mgr.start({ duration: 60000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' })
},
stopRecordVoice() {
const mgr = this.data.recorderManager
if (!mgr || !this.data.isRecording) return
console.log('[录音] 手指松开,停止录音')
wx.vibrateShort({ type: 'light' })
mgr.stop()
},
cancelRecordVoice() {
const mgr = this.data.recorderManager
if (!mgr || !this.data.isRecording) return
console.log('[录音] 取消录音')
wx.vibrateShort({ type: 'light' })
mgr.stop()
this.setData({ isRecording: false, tempFilePath: '' })
wx.showToast({ title: '已取消发送', icon: 'none' })
},
_stopRecordingIfNeeded() {
if (this.data.isRecording && this.data.recorderManager) {
try { this.data.recorderManager.stop() } catch (e) { /* ignore */ }
this.setData({ isRecording: false })
}
},
_sendVoiceMessage(tempFilePath, durationMs) {
const durationSec = Math.ceil(durationMs / 1000)
const pages = getCurrentPages()
const chatPage = pages.find(p => p.route === 'pages/chat/chat')
if (!chatPage || !chatPage.sendMessage) return
chatPage.sendMessage(`[语音消息 ${durationSec}秒]`)
setTimeout(() => {
chatPage.sendMessage('🎤 我已经收到您的语音消息了。目前语音识别功能还在完善中,您也可以直接打字告诉我需要什么帮助哦~')
}, 1200)
},
/* ==========================================================
语音转文字点击🎤按钮触发
========================================================== */
toggleVoiceToText() {
if (this.data.isVoiceToText) {
this.stopVoiceToText()
} else {
this.startVoiceToText()
}
},
startVoiceToText() {
console.log('[语音转文字] 🎤 开始识别')
const mgr = this.data.recorderManager
if (!mgr) {
wx.showToast({ title: '语音功能不可用', icon: 'none' })
return
}
this.setData({ isVoiceToText: true, inputFocus: false, inputText: '' })
wx.vibrateShort({ type: 'medium' })
wx.showToast({ title: '请说话...', icon: 'none', duration: 1500 })
mgr.start({ duration: 30000, sampleRate: 16000, numberOfChannels: 1, encodeBitRate: 48000, format: 'mp3' })
},
stopVoiceToText() {
console.log('[语音转文字] ⏹ 停止识别')
wx.vibrateShort({ type: 'light' })
wx.showToast({ title: '正在识别...', icon: 'loading', duration: 2000 })
const mgr = this.data.recorderManager
if (mgr && this.data.tempFilePath) {
try { mgr.stop() } catch (e) { /* ignore */ }
}
this.setData({ isVoiceToText: false })
this._recognizeVoice(this.data.tempFilePath)
},
_recognizeVoice(audioPath) {
if (!audioPath) {
console.warn('[语音转文字] 没有音频文件,跳过识别')
return
}
/*
// 方案一:调用微信同声传译插件(需在 app.json 声明 plugin
const plugin = requirePlugin('WechatSI')
const manager = plugin.getRecordRecognitionManager()
manager.onStop((res) => {
const text = res.result || ''
if (text.trim()) {
this.setData({ inputText: text, inputFocus: true })
}
})
manager.onError((err) => {
wx.showToast({ title: '识别失败', icon: 'none' })
})
*/
// 方案二:模拟返回(开发调试用)
setTimeout(() => {
const mockResults = ['帮我查看一下设备状态', '打开客厅灯', '今天的任务有哪些', '你好']
const recognizedText = mockResults[Math.floor(Math.random() * mockResults.length)]
this.setData({ inputText: recognizedText, hasInput: !!recognizedText.trim(), inputFocus: true })
wx.hideToast()
console.log(`[语音转文字] ✅ 模拟识别结果已写入: "${recognizedText}"`)
}, 1500)
},
/* ==========================================================
语音播放
========================================================== */
_initAudioPlayer() {
try {
const ctx = wx.createInnerAudioContext()
ctx.onPlay(() => { this.setData({ isPlayingVoice: true }) })
ctx.onEnded(() => { this.setData({ isPlayingVoice: false }) })
ctx.onError((err) => {
console.error('[播放] 错误:', err)
this.setData({ isPlayingVoice: false })
})
this.setData({ innerAudioContext: ctx })
} catch (e) {
this.setData({ innerAudioContext: null })
}
},
/* ==========================================================
工具按钮
========================================================== */
onTapEmoji() {
console.log('[输入] 表情按钮点击')
wx.showToast({ title: '表情功能开发中', icon: 'none' })
},
onRightBtnTap() {
if (this.data.hasInput) {
this.sendTextMessage()
} else {
this.onTapMore()
}
},
onTapMore() {
wx.showActionSheet({
itemList: ['发送图片', '拍摄照片', '发送文件', '位置信息'],
success: (res) => {
const actions = ['image', 'camera', 'file', 'location']
console.log('[更多] 选择了:', actions[res.tapIndex])
}
})
}
}
})