ai-c/pages/support/support.js

496 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.

// 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 })
})
}
})