家长端支持语音留言发送

This commit is contained in:
stu2not
2026-05-05 16:32:31 +08:00
parent 12a7a3a116
commit 2dba5bcf7c
7 changed files with 415 additions and 56 deletions

View File

@@ -218,36 +218,41 @@
border-top: 1px solid #EEEEEE;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 16px;
flex-direction: column;
gap: 14px;
}
.composer-input {
flex: 1;
min-height: 76px;
background: #F5F7FA;
border-radius: 38px;
padding: 0 24px;
font-size: 28px;
color: #1A1A1A;
.composer-tip {
text-align: center;
text {
font-size: 24px;
color: #999999;
}
}
.composer-send {
min-width: 132px;
height: 76px;
border-radius: 38px;
.composer-record {
width: 100%;
min-height: 88px;
border-radius: 44px;
background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
box-shadow: 0 10px 24px rgba(255, 140, 66, 0.2);
&.recording {
background: #FF6A3D;
}
&.disabled {
opacity: 0.55;
}
}
.composer-send-text {
font-size: 28px;
.composer-record-text {
font-size: 30px;
color: #FFFFFF;
font-weight: 600;
letter-spacing: 2px;
}

View File

@@ -1,10 +1,10 @@
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import { View, Text, ScrollView, Image } from '@tarojs/components'
import { useEffect, useRef, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import {
getMessages,
getCharacter,
sendParentMessage,
sendParentVoiceMessage,
ChatMessage,
Character,
ConversationSource,
@@ -88,10 +88,15 @@ export default function ChatDetail() {
})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [loading, setLoading] = useState(true)
const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
const [recording, setRecording] = useState(false)
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
const [recordHint, setRecordHint] = useState('长按开始留言')
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
const activeConversationIdRef = useRef(initialConversationId)
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {})
const recordingChildIdRef = useRef<number | null>(null)
useEffect(() => {
const audioContext = Taro.createInnerAudioContext()
@@ -114,6 +119,10 @@ export default function ChatDetail() {
}
}, [])
useEffect(() => {
activeConversationIdRef.current = activeConversationId
}, [activeConversationId])
useEffect(() => {
void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind])
@@ -152,39 +161,139 @@ export default function ChatDetail() {
}
}
latestLoadChatDataRef.current = loadChatData
useEffect(() => {
if (typeof Taro.getRecorderManager !== 'function') return
const recorderManager = Taro.getRecorderManager()
recorderManagerRef.current = recorderManager
recorderManager.onError((error) => {
recordingChildIdRef.current = null
setRecording(false)
setRecordHint('长按开始留言')
console.error('[chat-detail] recorder failed:', error)
Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
})
recorderManager.onStop(async (result) => {
const currentChildId = recordingChildIdRef.current
recordingChildIdRef.current = null
setRecording(false)
setRecordHint('长按开始留言')
if (!currentChildId) return
if (!result.tempFilePath) {
Taro.showToast({ title: '录音文件不存在', icon: 'none' })
return
}
const durationMs = Math.max(0, Number(result.duration || 0))
if (durationMs < 800) {
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
return
}
setSending(true)
try {
const response = await sendParentVoiceMessage(currentChildId, {
filePath: result.tempFilePath,
durationMs,
})
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
setActiveConversationId(response.conversation_id)
} else {
await latestLoadChatDataRef.current(activeConversationIdRef.current)
}
Taro.showToast({ title: '留言已发送', icon: 'success' })
} catch (error: any) {
console.error('[chat-detail] voice send failed:', error)
Taro.showToast({
title: error?.message || '留言发送失败,请重试',
icon: 'none',
})
} finally {
setSending(false)
}
})
return () => {
recorderManagerRef.current = null
}
}, [])
const handleBack = () => {
Taro.navigateBack()
}
const handleSend = async () => {
const normalizedContent = inputText.trim()
if (!normalizedContent || sending) return
const ensureRecordPermission = async (): Promise<boolean> => {
try {
const settings = await Taro.getSetting()
if (settings.authSetting['scope.record']) return true
await Taro.authorize({ scope: 'scope.record' })
return true
} catch {
const modal = await Taro.showModal({
title: '需要录音权限',
content: '请允许小程序使用麦克风后再留言',
confirmText: '去设置',
})
if (modal.confirm) {
await Taro.openSetting()
}
return false
}
}
const handleRecordStart = async () => {
if (sending || recording) return
if (!character.canSend || !character.childId) {
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
return
}
setSending(true)
const recorderManager = recorderManagerRef.current
if (!recorderManager) {
Taro.showToast({ title: '当前环境不支持录音', icon: 'none' })
return
}
const hasPermission = await ensureRecordPermission()
if (!hasPermission) return
recordingChildIdRef.current = character.childId
setRecording(true)
setRecordHint('松开发送留言')
try {
const response = await sendParentMessage(character.childId, normalizedContent)
setInputText('')
if (response.conversation_id && response.conversation_id !== activeConversationId) {
setActiveConversationId(response.conversation_id)
} else {
await loadChatData(activeConversationId)
}
Taro.showToast({ title: '已发送', icon: 'success' })
recorderManager.start({
duration: 60000,
format: 'mp3',
numberOfChannels: 1,
sampleRate: 16000,
encodeBitRate: 96000,
})
} catch (error: any) {
console.error('[chat-detail] send failed:', error)
recordingChildIdRef.current = null
setRecording(false)
setRecordHint('长按开始留言')
console.error('[chat-detail] recorder start failed:', error)
Taro.showToast({
title: error?.message || '发送失败,请重试',
title: '无法开始录音,请重试',
icon: 'none',
})
} finally {
setSending(false)
}
}
const handleRecordStop = () => {
if (!recording) return
const recorderManager = recorderManagerRef.current
if (!recorderManager) return
setRecordHint('正在处理留言...')
recorderManager.stop()
}
const handlePlayAudio = (message: ChatMessage) => {
if (message.contentType !== 2 || !message.mediaUrl) {
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
@@ -213,10 +322,13 @@ export default function ChatDetail() {
const renderMessageBody = (msg: ChatMessage) => {
if (msg.contentType === 2 && msg.mediaUrl) {
const isPlaying = playingMessageId === msg.id
const isLeaveMessage = msg.extJson?.message_kind === 'leave_message'
return (
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
<Text className='audio-bubble-text'>{msg.mediaTranscriptText || '点击播放语音'}</Text>
<Text className='audio-bubble-text'>
{msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')}
</Text>
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
</View>
)
@@ -250,7 +362,7 @@ export default function ChatDetail() {
) : messages.length === 0 ? (
<View className='empty-chat'>
<Text className='empty-chat-text'>
{character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'}
{character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
</Text>
</View>
) : (
@@ -294,17 +406,18 @@ export default function ChatDetail() {
{character.canSend && character.childId && (
<View className='composer'>
<Input
className='composer-input'
value={inputText}
placeholder='输入要发给孩子的消息'
maxlength={500}
confirmType='send'
onInput={(event) => setInputText(event.detail.value)}
onConfirm={handleSend}
/>
<View className={`composer-send ${sending ? 'disabled' : ''}`} onClick={handleSend}>
<Text className='composer-send-text'>{sending ? '发送' : '发送'}</Text>
<View className='composer-tip'>
<Text>{sending ? '正在发送留言...' : recordHint}</Text>
</View>
<View
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
onLongPress={handleRecordStart}
onTouchEnd={handleRecordStop}
onTouchCancel={handleRecordStop}
>
<Text className='composer-record-text'>
{sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
</Text>
</View>
</View>
)}

View File

@@ -1,6 +1,8 @@
import Taro from '@tarojs/taro'
import { getCurrentUserId } from './auth'
import { request } from './api'
import { ApiError, BASE_URL, getToken, request } from './api'
import { loadCurrentChildBindingContext } from './binding'
import { handleUnauthorized } from './session'
export type ConversationSource = 'im' | 'ai'
export type PeerKind = 'child' | 'parent' | 'ai'
@@ -49,6 +51,7 @@ export interface ChatMessage {
senderName?: string
receiverName?: string
channelLabel?: string
extJson?: Record<string, any> | null
}
export interface Character {
@@ -201,10 +204,18 @@ function formatDetailTime(value?: string | null): string {
}
function formatImPreview(
item: Pick<ChildConversationMessageItem, 'content_type' | 'content_text' | 'media_transcript_text' | 'content_json'>
item: Pick<
ChildConversationMessageItem,
'content_type' | 'content_text' | 'media_transcript_text' | 'content_json' | 'ext_json'
>
): string {
if (item.content_type === 1) return item.content_text || '文本消息'
if (item.content_type === 2) return item.media_transcript_text || '[语音]'
if (item.content_type === 2) {
if (item.ext_json?.message_kind === 'leave_message') {
return item.media_transcript_text || '[留言]'
}
return item.media_transcript_text || '[语音]'
}
if (item.content_type === 3) return '[图片]'
if (item.content_type === 4) {
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
@@ -361,7 +372,7 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
avatar: PEER_META.parent.icon,
typeLabel: '家长沟通',
description: '家长通过微信小程序和孩子直接沟通',
lastMessage: '还没有消息,点进去发送第一条',
lastMessage: '还没有留言,点进去发送第一条',
time: '',
sortAt: '',
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
@@ -471,6 +482,7 @@ export async function getMessages(
senderId: item.sender_id,
receiverType: item.receiver_type,
receiverId: item.receiver_id,
extJson: item.ext_json || null,
senderName:
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? '我'
@@ -594,3 +606,68 @@ export async function sendParentMessage(childId: number, content: string): Promi
},
})
}
function parseUploadResponseData(rawData: any): any {
if (typeof rawData !== 'string') return rawData
try {
return JSON.parse(rawData)
} catch {
return { detail: rawData }
}
}
function getUploadErrorMessage(data: any): string {
if (!data) return '留言发送失败'
if (typeof data.detail === 'string') return data.detail
if (typeof data.message === 'string') return data.message
if (typeof data.errMsg === 'string') return data.errMsg
return '留言发送失败'
}
export async function sendParentVoiceMessage(
childId: number,
options: { filePath: string; durationMs: number; transcriptText?: string }
): Promise<ConversationMessageCreateResponse> {
if (!childId) {
throw new Error('当前会话不支持留言')
}
if (!options.filePath) {
throw new Error('录音文件不存在')
}
const token = getToken()
const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message`
const uploadResponse: any = await new Promise((resolve, reject) => {
Taro.uploadFile({
url: requestUrl,
filePath: options.filePath,
name: 'file',
header: token
? {
Authorization: `Bearer ${token}`,
}
: {},
formData: {
duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
client_msg_id: createClientMessageId(),
transcript_text: options.transcriptText || '',
},
success: resolve,
fail: reject,
})
}).catch((error: any) => {
const message = String(error?.errMsg || error?.message || 'request:fail')
throw new ApiError(0, message)
})
if (uploadResponse.statusCode === 401) {
handleUnauthorized({ redirect: true })
}
const data = parseUploadResponseData(uploadResponse.data)
if (uploadResponse.statusCode >= 400) {
throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
}
return data as ConversationMessageCreateResponse
}