diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index ad04f3d..4ab5329 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -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; } diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index aa9d399..60ac3b5 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -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([]) const [loading, setLoading] = useState(true) - const [inputText, setInputText] = useState('') const [sending, setSending] = useState(false) + const [recording, setRecording] = useState(false) const [playingMessageId, setPlayingMessageId] = useState(null) + const [recordHint, setRecordHint] = useState('长按开始留言') const audioContextRef = useRef(null) + const recorderManagerRef = useRef(null) + const activeConversationIdRef = useRef(initialConversationId) + const latestLoadChatDataRef = useRef<(conversationId: number) => Promise>(async () => {}) + const recordingChildIdRef = useRef(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 => { + 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 ( handlePlayAudio(msg)}> {isPlaying ? '[]' : '>'} - {msg.mediaTranscriptText || '点击播放语音'} + + {msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')} + {formatAudioDuration(msg.mediaDurationMs)} ) @@ -250,7 +362,7 @@ export default function ChatDetail() { ) : messages.length === 0 ? ( - {character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'} + {character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'} ) : ( @@ -294,17 +406,18 @@ export default function ChatDetail() { {character.canSend && character.childId && ( - setInputText(event.detail.value)} - onConfirm={handleSend} - /> - - {sending ? '发送中' : '发送'} + + {sending ? '正在发送留言...' : recordHint} + + + + {sending ? '发送中...' : recording ? '松开发送' : '按住留言'} + )} diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index 55d71ab..3d8b68f 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -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 | null } export interface Character { @@ -201,10 +204,18 @@ function formatDetailTime(value?: string | null): string { } function formatImPreview( - item: Pick + 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 { + 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 +} diff --git a/talkingq-url/banban/dao/im.py b/talkingq-url/banban/dao/im.py index dcc0447..137c737 100644 --- a/talkingq-url/banban/dao/im.py +++ b/talkingq-url/banban/dao/im.py @@ -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) diff --git a/talkingq-url/banban/routers/im.py b/talkingq-url/banban/routers/im.py index 958973d..c89d2ba 100644 --- a/talkingq-url/banban/routers/im.py +++ b/talkingq-url/banban/routers/im.py @@ -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, diff --git a/talkingq-url/banban/service/im.py b/talkingq-url/banban/service/im.py index 74d3b86..f8b732d 100644 --- a/talkingq-url/banban/service/im.py +++ b/talkingq-url/banban/service/im.py @@ -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, *, diff --git a/talkingq-url/banban/service/message_audio_storage.py b/talkingq-url/banban/service/message_audio_storage.py index eb749d3..46e3b47 100644 --- a/talkingq-url/banban/service/message_audio_storage.py +++ b/talkingq-url/banban/service/message_audio_storage.py @@ -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, *,