496 lines
12 KiB
JavaScript
496 lines
12 KiB
JavaScript
// pages/support/support.js
|
||
const app = getApp()
|
||
const api = require('../../utils/api')
|
||
const util = require('../../utils/util')
|
||
const imageUrl = require('../../utils/imageUrl')
|
||
|
||
// 常用表情
|
||
const EMOJIS = [
|
||
"😊", "😀", "😁", "😃", "😂", "🤣", "😅", "😆", "😉", "😋", "😎", "😍", "😘", "🥰", "😗", "😙",
|
||
"🙂", "🤗", "🤩", "🤔", "😐", "😑", "😶", "🙄", "😏", "😣", "😥", "😮", "😯", "😪", "😫", "😴",
|
||
"😌", "😛", "😜", "😝", "😒", "😓", "😔", "😕", "🙃", "😲", "😖", "😞", "😟", "😤", "😢", "😭",
|
||
"😨", "😩", "😬", "😰", "😱", "😳", "😵", "😡", "😠", "😷", "🤒", "🤕", "😇", "🥳", "🥺",
|
||
"👋", "👌", "✌️", "🤞", "👍", "👎", "👏", "🙌", "🤝", "🙏", "💪", "❤️", "🧡", "💛", "💚", "💙",
|
||
"💜", "🖤", "💔", "💕", "💖", "💗", "💘", "💝", "🌹", "🌺", "🌻", "🌼", "🌷", "🎉", "🎊", "🎁"
|
||
]
|
||
|
||
Page({
|
||
data: {
|
||
statusBarHeight: 44,
|
||
navHeight: 96,
|
||
myAvatar: '/images/default-avatar.svg',
|
||
messages: [],
|
||
inputText: '',
|
||
inputFocus: false,
|
||
isTyping: false,
|
||
ticketId: '',
|
||
scrollIntoView: '',
|
||
scrollTop: 0,
|
||
pollingTimer: null,
|
||
|
||
// 新增状态
|
||
isVoiceMode: false,
|
||
isRecording: false,
|
||
showEmoji: false,
|
||
showMorePanel: false,
|
||
voiceCancelHint: false,
|
||
recordingDuration: 0,
|
||
emojis: EMOJIS,
|
||
playingVoiceId: null
|
||
},
|
||
|
||
onLoad() {
|
||
const { statusBarHeight, navHeight, userInfo } = app.globalData
|
||
const myAvatar = imageUrl.getAvatarUrl(userInfo?.avatar)
|
||
|
||
this.setData({
|
||
statusBarHeight,
|
||
navHeight,
|
||
myAvatar
|
||
})
|
||
|
||
this.initSupport()
|
||
},
|
||
|
||
onAvatarError() {
|
||
this.setData({
|
||
myAvatar: '/images/default-avatar.svg'
|
||
})
|
||
},
|
||
|
||
onUnload() {
|
||
this.stopPolling()
|
||
},
|
||
|
||
onHide() {
|
||
this.stopPolling()
|
||
},
|
||
|
||
onShow() {
|
||
if (this.data.ticketId) {
|
||
this.startPolling()
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 初始化客服会话
|
||
*/
|
||
async initSupport() {
|
||
wx.showLoading({ title: '加载中...' })
|
||
try {
|
||
const guestId = wx.getStorageSync('guestId') || util.generateId()
|
||
if (!wx.getStorageSync('guestId')) {
|
||
wx.setStorageSync('guestId', guestId)
|
||
}
|
||
|
||
// 获取已有咨询列表
|
||
const res = await api.customerService.getList(guestId)
|
||
const data = res.data || {}
|
||
const tickets = data.tickets || []
|
||
|
||
if (tickets.length > 0) {
|
||
// 使用最近的一个工单
|
||
const latestTicket = tickets[0]
|
||
this.setData({ ticketId: latestTicket.id })
|
||
await this.loadMessages(latestTicket.id)
|
||
} else {
|
||
console.log('[support] No existing tickets found.')
|
||
}
|
||
} catch (err) {
|
||
console.error('[support] initSupport error:', err)
|
||
} finally {
|
||
wx.hideLoading()
|
||
this.startPolling()
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 加载消息历史
|
||
*/
|
||
async loadMessages(ticketId) {
|
||
try {
|
||
const res = await api.customerService.getDetail(ticketId)
|
||
if (res.success && res.data) {
|
||
const messages = res.data.messages.map(msg => ({
|
||
id: msg.id,
|
||
isMe: msg.senderType === 'user',
|
||
text: msg.type === 'text' ? msg.content : (msg.type === 'image' ? '[图片]' : (msg.type === 'voice' ? '[语音]' : msg.content)),
|
||
type: msg.type || 'text',
|
||
imageUrl: msg.type === 'image' ? imageUrl.getFullImageUrl(msg.content) : '',
|
||
audioUrl: msg.type === 'voice' ? msg.content : '',
|
||
duration: msg.duration || 0,
|
||
time: util.formatTime(new Date(msg.createdAt), 'HH:mm'),
|
||
senderName: msg.senderName
|
||
}))
|
||
|
||
// 如果有新消息才更新,避免闪烁
|
||
if (JSON.stringify(messages) !== JSON.stringify(this.data.messages)) {
|
||
this.setData({ messages }, () => {
|
||
this.scrollToBottom()
|
||
})
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('[support] loadMessages error:', err)
|
||
}
|
||
},
|
||
|
||
/**
|
||
* 发送消息
|
||
*/
|
||
async onSend() {
|
||
const content = this.data.inputText.trim()
|
||
if (!content || this.isSending) return
|
||
|
||
// 收起键盘和面板
|
||
this.setData({
|
||
showEmoji: false,
|
||
showMorePanel: false
|
||
})
|
||
|
||
await this.sendMessage(content, 'text')
|
||
},
|
||
|
||
/**
|
||
* 统一发送消息方法
|
||
* @param {string} content - 消息内容(文本或URL)
|
||
* @param {string} type - 消息类型 (text/image/voice)
|
||
* @param {number} duration - 语音时长
|
||
*/
|
||
async sendMessage(content, type = 'text', duration = 0) {
|
||
if (this.isSending) return
|
||
this.isSending = true
|
||
|
||
const tempId = util.generateId()
|
||
const now = new Date()
|
||
|
||
// 先在本地显示
|
||
const userMsg = {
|
||
id: tempId,
|
||
isMe: true,
|
||
text: type === 'text' ? content : (type === 'image' ? '[图片]' : '[语音]'),
|
||
type: type,
|
||
imageUrl: type === 'image' ? content : '',
|
||
audioUrl: type === 'voice' ? content : '',
|
||
duration: duration,
|
||
time: util.formatTime(now, 'HH:mm'),
|
||
uploading: true
|
||
}
|
||
|
||
this.setData({
|
||
messages: [...this.data.messages, userMsg],
|
||
inputText: '',
|
||
inputFocus: type === 'text' // 仅文本发送后聚焦
|
||
}, () => {
|
||
this.scrollToBottom()
|
||
})
|
||
|
||
try {
|
||
const payload = {
|
||
content: content,
|
||
type: type,
|
||
duration: duration,
|
||
userName: app.globalData.userInfo?.nickname || '访客'
|
||
}
|
||
|
||
if (this.data.ticketId) {
|
||
// 回复已有工单
|
||
await api.customerService.reply({
|
||
ticketId: this.data.ticketId,
|
||
...payload
|
||
})
|
||
} else {
|
||
// 创建新工单
|
||
const guestId = wx.getStorageSync('guestId')
|
||
const res = await api.customerService.create({
|
||
category: 'other',
|
||
guestId: guestId,
|
||
...payload
|
||
})
|
||
if (res.success && res.data) {
|
||
this.setData({ ticketId: res.data.ticketId })
|
||
}
|
||
}
|
||
|
||
// 更新本地消息状态
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.id === tempId) {
|
||
return { ...msg, uploading: false }
|
||
}
|
||
return msg
|
||
})
|
||
this.setData({ messages })
|
||
|
||
// 发送后立即拉取一次
|
||
if (this.data.ticketId) {
|
||
await this.loadMessages(this.data.ticketId)
|
||
}
|
||
} catch (err) {
|
||
console.error('[support] send message error:', err)
|
||
wx.showToast({ title: '发送失败', icon: 'none' })
|
||
|
||
// 更新失败状态
|
||
const messages = this.data.messages.map(msg => {
|
||
if (msg.id === tempId) {
|
||
return { ...msg, uploading: false, error: true }
|
||
}
|
||
return msg
|
||
})
|
||
this.setData({ messages })
|
||
} finally {
|
||
this.isSending = false
|
||
}
|
||
},
|
||
|
||
onInput(e) {
|
||
this.setData({ inputText: e.detail.value })
|
||
},
|
||
|
||
/**
|
||
* 开始轮询
|
||
*/
|
||
startPolling() {
|
||
this.stopPolling()
|
||
this.data.pollingTimer = setInterval(() => {
|
||
if (this.data.ticketId) {
|
||
this.loadMessages(this.data.ticketId)
|
||
}
|
||
}, 4000) // 每4秒轮询一次
|
||
},
|
||
|
||
/**
|
||
* 停止轮询
|
||
*/
|
||
stopPolling() {
|
||
if (this.data.pollingTimer) {
|
||
clearInterval(this.data.pollingTimer)
|
||
this.data.pollingTimer = null
|
||
}
|
||
},
|
||
|
||
onBack() {
|
||
wx.navigateBack()
|
||
},
|
||
|
||
onTapChatArea() {
|
||
this.setData({
|
||
inputFocus: false,
|
||
showEmoji: false,
|
||
showMorePanel: false
|
||
})
|
||
},
|
||
|
||
scrollToBottom() {
|
||
this.setData({
|
||
scrollIntoView: 'chat-bottom-anchor'
|
||
})
|
||
},
|
||
|
||
// ==================== 底部功能区逻辑 ====================
|
||
|
||
onVoiceMode() {
|
||
this.setData({
|
||
isVoiceMode: !this.data.isVoiceMode,
|
||
showEmoji: false,
|
||
showMorePanel: false,
|
||
inputFocus: false
|
||
})
|
||
},
|
||
|
||
onEmojiToggle() {
|
||
this.setData({
|
||
inputFocus: false
|
||
})
|
||
|
||
setTimeout(() => {
|
||
this.setData({
|
||
showEmoji: !this.data.showEmoji,
|
||
showMorePanel: false,
|
||
isVoiceMode: false
|
||
})
|
||
}, 50)
|
||
},
|
||
|
||
onAddMore() {
|
||
this.setData({
|
||
inputFocus: false
|
||
})
|
||
|
||
setTimeout(() => {
|
||
this.setData({
|
||
showMorePanel: !this.data.showMorePanel,
|
||
showEmoji: false,
|
||
isVoiceMode: false
|
||
})
|
||
}, 50)
|
||
},
|
||
|
||
onClosePanels() {
|
||
this.setData({
|
||
showEmoji: false,
|
||
showMorePanel: false
|
||
})
|
||
},
|
||
|
||
onEmojiSelect(e) {
|
||
const emoji = e.currentTarget.dataset.emoji
|
||
this.setData({
|
||
inputText: this.data.inputText + emoji
|
||
})
|
||
},
|
||
|
||
// ==================== 语音录制 ====================
|
||
|
||
onVoiceTouchStart(e) {
|
||
this.touchStartY = e.touches[0].clientY
|
||
this.setData({
|
||
isRecording: true,
|
||
voiceCancelHint: false,
|
||
recordingDuration: 0
|
||
})
|
||
|
||
const recorderManager = wx.getRecorderManager()
|
||
recorderManager.start({
|
||
duration: 60000,
|
||
format: 'mp3'
|
||
})
|
||
|
||
this.recorderManager = recorderManager
|
||
|
||
this.recordingTimer = setInterval(() => {
|
||
this.setData({
|
||
recordingDuration: this.data.recordingDuration + 1
|
||
})
|
||
}, 1000)
|
||
},
|
||
|
||
onVoiceTouchMove(e) {
|
||
const moveY = e.touches[0].clientY
|
||
const diff = this.touchStartY - moveY
|
||
this.setData({
|
||
voiceCancelHint: diff > 50
|
||
})
|
||
},
|
||
|
||
onVoiceTouchEnd() {
|
||
clearInterval(this.recordingTimer)
|
||
const { voiceCancelHint, recordingDuration } = this.data
|
||
|
||
this.setData({ isRecording: false })
|
||
|
||
if (this.recorderManager) {
|
||
this.recorderManager.stop()
|
||
}
|
||
|
||
if (voiceCancelHint) {
|
||
util.showToast('已取消')
|
||
return
|
||
}
|
||
|
||
if (recordingDuration < 1) {
|
||
util.showError('录音时间太短')
|
||
return
|
||
}
|
||
|
||
this.recorderManager.onStop(async (res) => {
|
||
const tempFilePath = res.tempFilePath
|
||
|
||
// 上传语音
|
||
try {
|
||
const uploadRes = await api.uploadFile(tempFilePath, 'audio')
|
||
if (uploadRes.success && uploadRes.data && uploadRes.data.url) {
|
||
await this.sendMessage(uploadRes.data.url, 'voice', recordingDuration)
|
||
} else {
|
||
throw new Error('Upload failed')
|
||
}
|
||
} catch (err) {
|
||
console.error('语音上传失败', err)
|
||
util.showError('语音发送失败')
|
||
}
|
||
})
|
||
},
|
||
|
||
onVoiceTouchCancel() {
|
||
clearInterval(this.recordingTimer)
|
||
this.setData({ isRecording: false })
|
||
if (this.recorderManager) {
|
||
this.recorderManager.stop()
|
||
}
|
||
},
|
||
|
||
// ==================== 图片/拍照 ====================
|
||
|
||
onChooseImage() {
|
||
this.setData({ showMorePanel: false })
|
||
wx.chooseMedia({
|
||
count: 9,
|
||
mediaType: ['image'],
|
||
sourceType: ['album'],
|
||
success: (res) => {
|
||
res.tempFiles.forEach(file => {
|
||
this.uploadAndSendImage(file.tempFilePath)
|
||
})
|
||
}
|
||
})
|
||
},
|
||
|
||
onTakePhoto() {
|
||
this.setData({ showMorePanel: false })
|
||
wx.chooseMedia({
|
||
count: 1,
|
||
mediaType: ['image'],
|
||
sourceType: ['camera'],
|
||
camera: 'back',
|
||
success: (res) => {
|
||
this.uploadAndSendImage(res.tempFiles[0].tempFilePath)
|
||
}
|
||
})
|
||
},
|
||
|
||
async uploadAndSendImage(filePath) {
|
||
try {
|
||
const uploadRes = await api.uploadFile(filePath, 'uploads')
|
||
if (uploadRes.success && uploadRes.data && uploadRes.data.url) {
|
||
const fullUrl = imageUrl.getFullImageUrl(uploadRes.data.url)
|
||
await this.sendMessage(fullUrl, 'image')
|
||
} else {
|
||
throw new Error('Upload failed')
|
||
}
|
||
} catch (err) {
|
||
console.error('图片上传失败', err)
|
||
util.showError('图片发送失败')
|
||
}
|
||
},
|
||
|
||
// ==================== 预览与播放 ====================
|
||
|
||
onPreviewImage(e) {
|
||
const url = e.currentTarget.dataset.url
|
||
wx.previewImage({
|
||
current: url,
|
||
urls: [url]
|
||
})
|
||
},
|
||
|
||
onPlayVoice(e) {
|
||
const { id, url } = e.currentTarget.dataset
|
||
if (!url) return
|
||
|
||
const innerAudioContext = wx.createInnerAudioContext()
|
||
innerAudioContext.src = url
|
||
innerAudioContext.play()
|
||
|
||
this.setData({ playingVoiceId: id })
|
||
|
||
innerAudioContext.onEnded(() => {
|
||
this.setData({ playingVoiceId: null })
|
||
innerAudioContext.destroy()
|
||
})
|
||
|
||
innerAudioContext.onError((res) => {
|
||
console.error(res.errMsg)
|
||
this.setData({ playingVoiceId: null })
|
||
})
|
||
}
|
||
})
|