家长端支持语音留言发送

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
}

View File

@@ -178,11 +178,18 @@ class ImDAO(BaseDAO):
participant_b_id = str(high_id)
return participant_a_id, participant_b_id, f"{participant_a_id}:{participant_b_id}"
async def _build_preview(self, content_type: int, content_text: str | None) -> str:
async def _build_preview(
self,
content_type: int,
content_text: str | None,
ext_json: dict[str, Any] | None = None,
) -> str:
if content_type == 1:
return (content_text or "").strip()[:255]
if content_type == 2:
return "[audio]"
if (ext_json or {}).get("message_kind") == "leave_message":
return "[留言]"
return "[语音]"
if content_type == 3:
return "[image]"
return "[json]"
@@ -239,7 +246,11 @@ class ImDAO(BaseDAO):
return conversation_id, True
now_sql = "CURRENT_TIMESTAMP(3)"
preview = await self._build_preview(payload.content_type, payload.content_text)
preview = await self._build_preview(
payload.content_type,
payload.content_text,
payload.ext_json,
)
try:
conversation_row = await self._get_conversation_by_id(conversation_id=conversation_id, lock=True)

View File

@@ -3,7 +3,7 @@ import logging
from collections.abc import Mapping
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query, Request, Response, status
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, Response, UploadFile, status
from sqlalchemy import text
try:
@@ -442,6 +442,64 @@ async def create_child_message_for_parent(
)
@router.post(
"/{child_id}/voice-message",
response_model=ConversationMessageCreateResponse,
status_code=status.HTTP_201_CREATED,
)
async def create_child_voice_message_for_parent(
child_id: int,
request: Request,
response: Response,
file: UploadFile = File(...),
duration_ms: int = Form(..., ge=0),
client_msg_id: str = Form(..., min_length=1, max_length=64),
transcript_text: str | None = Form(default=None, max_length=1000),
current_user_id: int = Depends(get_current_user_id),
) -> ConversationMessageCreateResponse:
try:
content = await file.read()
result = await im_service.create_parent_child_voice_message(
parent_user_id=current_user_id,
child_id=child_id,
filename=file.filename,
content_type=file.content_type,
content=content,
media_duration_ms=duration_ms,
media_transcript_text=transcript_text,
client_msg_id=client_msg_id,
ext_json={
"message_kind": "leave_message",
"source": "parent_weapp_voice",
},
)
finally:
await file.close()
if result.idempotent:
response.status_code = status.HTTP_200_OK
logger.info(
"parent child voice message created",
extra={
"event": "parent_child_voice_message_create",
"request_id": getattr(request.state, "request_id", None),
"user_id": current_user_id,
"child_id": child_id,
"conversation_id": result.conversation_id,
"idempotent": result.idempotent,
},
)
return ConversationMessageCreateResponse(
idempotent=result.idempotent,
conversation_id=result.conversation_id,
conversation_type=result.conversation_type,
conversation_type_name=result.conversation_type_name,
message=result.message,
)
@router.get(
"/{child_id}/conversations/{conversation_id}/messages",
response_model=ChildConversationMessageListResponse,

View File

@@ -2,6 +2,7 @@ from dataclasses import dataclass
import hashlib
import json
from collections.abc import Mapping
from pathlib import Path
from typing import Any
from fastapi import HTTPException
@@ -36,6 +37,25 @@ PARTICIPANT_TYPE_NAMES = {
CHILD_PARTICIPANT_TYPE: "child",
}
_AUDIO_CONTENT_TYPE_TO_EXT = {
"audio/mpeg": "mp3",
"audio/mp3": "mp3",
"audio/aac": "aac",
"audio/wav": "wav",
"audio/x-wav": "wav",
"audio/x-m4a": "m4a",
"audio/mp4": "m4a",
"audio/webm": "webm",
"application/octet-stream": "mp3",
}
_AUDIO_EXTENSION_ALIASES = {
".mp3": "mp3",
".aac": "aac",
".m4a": "m4a",
".wav": "wav",
".webm": "webm",
}
def conversation_type_name(conversation_type: int) -> str:
return CONVERSATION_TYPE_NAMES.get(conversation_type, f"unknown_{conversation_type}")
@@ -66,6 +86,20 @@ def normalize_content_json(value: Any) -> dict[str, Any] | None:
return None
def normalize_audio_extension(*, filename: str | None, content_type: str | None) -> tuple[str, str]:
normalized_content_type = (content_type or "").strip().lower() or "audio/mpeg"
if normalized_content_type in _AUDIO_CONTENT_TYPE_TO_EXT:
return _AUDIO_CONTENT_TYPE_TO_EXT[normalized_content_type], normalized_content_type
suffix = Path(filename or "").suffix.lower()
if suffix in _AUDIO_EXTENSION_ALIASES:
extension = _AUDIO_EXTENSION_ALIASES[suffix]
fallback_content_type = "audio/m4a" if extension == "m4a" else f"audio/{extension}"
return extension, fallback_content_type
raise HTTPException(status_code=415, detail="unsupported audio file type")
def row_to_message_item(row: Mapping[str, Any]) -> ChildConversationMessageItem:
return ChildConversationMessageItem(
id=int(row["id"]),
@@ -201,6 +235,58 @@ class ImService(DatabaseServiceBase):
finally:
await db_session.close()
async def create_parent_child_voice_message(
self,
*,
parent_user_id: int,
child_id: int,
filename: str | None,
content_type: str | None,
content: bytes,
media_duration_ms: int | None,
media_transcript_text: str | None,
client_msg_id: str,
ext_json: dict[str, Any] | None = None,
) -> ConversationMessageCreateResult:
if not content:
raise HTTPException(status_code=400, detail="audio file is empty")
extension, normalized_content_type = normalize_audio_extension(
filename=filename,
content_type=content_type,
)
stored_audio = await self.audio_storage.upload_audio(
device_id=f"parent-{parent_user_id}",
content=content,
content_type=normalized_content_type,
extension=extension,
)
payload = ParentChildMessageCreateRequest(
content_type=2,
media_file_key=stored_audio.file_key,
media_duration_ms=media_duration_ms,
media_mime_type=normalized_content_type,
media_size_bytes=len(content),
media_transcript_text=(media_transcript_text or "").strip() or None,
client_msg_id=client_msg_id,
ext_json=ext_json,
)
try:
result = await self.create_parent_child_message(
parent_user_id=parent_user_id,
child_id=child_id,
payload=payload,
)
except Exception:
try:
await self.audio_storage.delete_audio(stored_audio.file_key)
except MessageAudioStorageError:
pass
raise
return result
async def _create_device_message_with_payload(
self,
*,

View File

@@ -113,6 +113,15 @@ class MessageAudioStorageService:
Expired=settings.cos_avatar_url_expire_seconds,
)
async def delete_audio(self, file_key_or_url: str) -> None:
self._assert_ready()
normalized_key = self._normalize_file_key(file_key_or_url)
await asyncio.to_thread(
self._get_client().delete_object,
Bucket=settings.cos_bucket_message,
Key=normalized_key,
)
def _upload_audio_sync(
self,
*,