Files
JoyD/Claw/client/wechat_app/custom-tab-bar/index.js
2026-04-21 13:46:20 +08:00

378 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 自定义底部导航栏组件 - 公众号客服交互模式
*
* 功能清单:
* 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])
}
})
}
}
})