import { getParticipantArtwork } from '@/utils/artwork' import { getPageStyle } from '@/utils/page-style' import { View, Text, ScrollView, Image } from '@tarojs/components' import { useEffect, useRef, useState } from 'react' import Taro, { useRouter } from '@tarojs/taro' import { getMessages, getCharacter, sendParentVoiceMessage, ChatMessage, Character, ConversationSource, PeerKind, } from '../../../services/chat' import { useSystemBanner } from '@/components/system-banner/use-system-banner' import { usePageShare } from '@/utils/page-share' import './index.scss' function normalizePeerKind(value?: string): PeerKind { if (value === 'child' || value === 'parent' || value === 'ai') return value return 'ai' } function normalizeSource(value?: string): ConversationSource { return value === 'im' ? 'im' : 'ai' } function normalizeNumericId(value?: string): number | null { const normalized = Number(value) if (!Number.isFinite(normalized) || normalized <= 0) return null return normalized } function formatParticipantLabel(message: ChatMessage): string { const senderType = message.senderType || 'unknown' const senderId = message.senderId || '--' const name = message.senderName || '' if (senderType === 'parent') { return name === '我' ? '我' : name || '家长' } if (senderType === 'child') { return name || `宝贝ID ${senderId}` } if (senderType === 'device') { return name || `设备ID ${senderId}` } if (senderType === 'ai') { return name || 'AI' } return `${senderType} ${senderId}` } function formatAudioDuration(durationMs?: number | null): string { const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000)) return `${totalSeconds}s` } type VoiceRuntimeInfo = { platform: string system: string brand: string model: string version: string SDKVersion: string isIOS: boolean isAndroid: boolean isHarmony: boolean canUseRecorderManager: boolean canUseInnerAudioContext: boolean supportsMp3Record: boolean } type RecordProfile = { format: 'mp3' | 'aac' mimeType: string sampleRate: number encodeBitRate: number } const DEFAULT_RECORD_PROFILE: RecordProfile = { format: 'mp3', mimeType: 'audio/mpeg', sampleRate: 16000, encodeBitRate: 96000, } const COMPAT_RECORD_PROFILE: RecordProfile = { format: 'aac', mimeType: 'audio/aac', sampleRate: 44100, encodeBitRate: 192000, } const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = { platform: '', system: '', brand: '', model: '', version: '', SDKVersion: '', isIOS: false, isAndroid: false, isHarmony: false, canUseRecorderManager: false, canUseInnerAudioContext: false, supportsMp3Record: true, } const CHAT_BOTTOM_ANCHOR_ID = 'chat-bottom' const RECORD_LONG_PRESS_DELAY_MS = 350 type LoadChatOptions = { showLoading?: boolean scrollToBottom?: 'jump' | 'animate' | false } function compareVersion(left: string, right: string): number { const leftParts = left.split('.').map((item) => Number(item) || 0) const rightParts = right.split('.').map((item) => Number(item) || 0) const length = Math.max(leftParts.length, rightParts.length) for (let index = 0; index < length; index += 1) { const diff = (leftParts[index] || 0) - (rightParts[index] || 0) if (diff !== 0) return diff } return 0 } function getCanIUse(apiName: string): boolean { try { if (typeof Taro.canIUse !== 'function') return false return Boolean(Taro.canIUse(apiName)) } catch { return false } } function readVoiceRuntimeInfo(): VoiceRuntimeInfo { let systemInfo: Record = {} try { systemInfo = Taro.getSystemInfoSync() as Record } catch (error) { console.warn('[voice-runtime] getSystemInfoSync failed', error) } const platform = String(systemInfo.platform || '').toLowerCase() const system = String(systemInfo.system || '') const brand = String(systemInfo.brand || '') const model = String(systemInfo.model || '') const version = String(systemInfo.version || '') const SDKVersion = String(systemInfo.SDKVersion || '') const systemText = `${system} ${brand} ${model}`.toLowerCase() const isHarmony = systemText.includes('harmony') || systemText.includes('openharmony') || systemText.includes('鸿蒙') const isIOS = platform === 'ios' || systemText.includes('ios') const isAndroid = platform === 'android' || systemText.includes('android') || isHarmony return { platform, system, brand, model, version, SDKVersion, isIOS, isAndroid, isHarmony, canUseRecorderManager: typeof Taro.getRecorderManager === 'function', canUseInnerAudioContext: typeof Taro.createInnerAudioContext === 'function', supportsMp3Record: !SDKVersion || compareVersion(SDKVersion, '2.6.0') >= 0, } } function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile { if (runtime.isIOS || runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE return DEFAULT_RECORD_PROFILE } function getAudioUrlDebugInfo(rawUrl: string) { const protocolMatch = rawUrl.match(/^([a-z]+):\/\//i) let host = '' try { const match = rawUrl.match(/^[a-z]+:\/\/([^/?#]+)/i) host = match?.[1] || '' } catch { host = '' } return { rawUrl, protocol: protocolMatch?.[1]?.toLowerCase() || '', host, isHttp: rawUrl.startsWith('http://'), isHttps: rawUrl.startsWith('https://'), } } export default function ChatDetail() { usePageShare() const systemBanner = useSystemBanner() const router = useRouter() const params = router.params const initialConversationId = normalizeNumericId(params.id) || 0 const conversationSource = normalizeSource(params.source) const conversationPeerKind = normalizePeerKind(params.peerKind) const decodedRoleKey = decodeURIComponent(params.roleKey || '') const decodedName = decodeURIComponent(params.name || '玩伴') const decodedConversationTypeName = decodeURIComponent(params.conversationTypeName || '') const decodedPeerId = decodeURIComponent(params.peerId || '') const decodedDeviceId = decodeURIComponent(params.deviceId || '') const decodedChildName = decodeURIComponent(params.childName || '') const childId = normalizeNumericId(params.childId) const parentUserId = normalizeNumericId(params.parentUserId) const canSend = params.canSend === '1' const [activeConversationId, setActiveConversationId] = useState(initialConversationId) const [character, setCharacter] = useState({ id: initialConversationId, name: decodedName, description: '交流记录', icon: '🤖', source: conversationSource, peerKind: conversationPeerKind, conversationTypeName: decodedConversationTypeName, childId, childName: decodedChildName, peerId: decodedPeerId, parentUserId, deviceId: decodedDeviceId, channelLabel: '--', canSend, }) const [messages, setMessages] = useState([]) const [loading, setLoading] = useState(true) const [sending, setSending] = useState(false) const [recording, setRecording] = useState(false) const [playingMessageId, setPlayingMessageId] = useState(null) const [recordHint, setRecordHint] = useState('长按开始留言') const [scrollTarget, setScrollTarget] = useState('') const [scrollWithAnimation, setScrollWithAnimation] = useState(false) const audioContextRef = useRef(null) const recorderManagerRef = useRef(null) const activeConversationIdRef = useRef(initialConversationId) const latestLoadChatDataRef = useRef<(conversationId: number, options?: LoadChatOptions) => Promise>( async () => {} ) const recordingChildIdRef = useRef(null) const recordProfileRef = useRef(DEFAULT_RECORD_PROFILE) const runtimeInfoRef = useRef(FALLBACK_RUNTIME_INFO) const recordStartedAtRef = useRef(0) const playingMessageIdRef = useRef(null) const scrollTimerRef = useRef | null>(null) const recordStartTimerRef = useRef | null>(null) const hasLoadedChatRef = useRef(false) const skipNextAutoLoadRef = useRef(false) const recordPressedRef = useRef(false) const recordStartPendingRef = useRef(false) const recordingActiveRef = useRef(false) const scrollToBottom = (animated: boolean) => { if (scrollTimerRef.current) { clearTimeout(scrollTimerRef.current) } setScrollWithAnimation(animated) setScrollTarget('') scrollTimerRef.current = setTimeout(() => { setScrollWithAnimation(animated) setScrollTarget(CHAT_BOTTOM_ANCHOR_ID) scrollTimerRef.current = null }, 80) } useEffect(() => { runtimeInfoRef.current = readVoiceRuntimeInfo() recordProfileRef.current = getPreferredRecordProfile(runtimeInfoRef.current) console.info('[voice-runtime] detected', { ...runtimeInfoRef.current, preferredRecordProfile: recordProfileRef.current, canIUse: { getRecorderManager: getCanIUse('getRecorderManager'), createInnerAudioContext: getCanIUse('createInnerAudioContext'), uploadFile: getCanIUse('uploadFile'), }, }) }, []) useEffect(() => { if (typeof Taro.createInnerAudioContext !== 'function') { console.warn('[audio] InnerAudioContext unavailable'); return; } // 关键1: 忽略 iOS 静音开关 Taro.setInnerAudioOption({ obeyMuteSwitch: false }); // 关键2: 使用 useWebAudioImplement 尝试绕过原生问题 const audioContext = Taro.createInnerAudioContext({ useWebAudioImplement: true, // 基础库 2.19.0+ }); audioContextRef.current = audioContext; audioContext.onEnded(() => { setPlayingMessageId(null); }); audioContext.onStop(() => { setPlayingMessageId(null); }); audioContext.onError((error) => { console.error('[audio] error detail:', JSON.stringify(error)); setPlayingMessageId(null); Taro.showToast({ title: `播放失败: ${error.errMsg || '错误'}`, icon: 'none' }); }); audioContext.onPlay(() => { console.info('[audio] play event', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) }) audioContext.onWaiting(() => { console.info('[audio] waiting', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) }) audioContext.onCanplay(() => { console.info('[audio] canplay', { playingMessageId: playingMessageIdRef.current, src: audioContext.src }) }) return () => { audioContext.destroy() audioContextRef.current = null } }, []) useEffect(() => { return () => { if (scrollTimerRef.current) { clearTimeout(scrollTimerRef.current) scrollTimerRef.current = null } if (recordStartTimerRef.current) { clearTimeout(recordStartTimerRef.current) recordStartTimerRef.current = null } } }, []) useEffect(() => { activeConversationIdRef.current = activeConversationId }, [activeConversationId]) useEffect(() => { playingMessageIdRef.current = playingMessageId }, [playingMessageId]) useEffect(() => { if (skipNextAutoLoadRef.current) { skipNextAutoLoadRef.current = false return } void loadChatData(activeConversationId) }, [activeConversationId, conversationSource, conversationPeerKind]) const loadChatData = async (conversationId: number, options: LoadChatOptions = {}) => { const shouldShowLoading = options.showLoading ?? !hasLoadedChatRef.current if (shouldShowLoading) { setLoading(true) } try { const char = await getCharacter({ conversationId, source: conversationSource, peerKind: conversationPeerKind, name: decodedName, roleKey: decodedRoleKey, conversationTypeName: decodedConversationTypeName, childId, childName: decodedChildName, peerId: decodedPeerId, parentUserId, deviceId: decodedDeviceId, canSend, }) if (char) { setCharacter(char) Taro.setNavigationBarTitle({ title: char.name }) } const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName) setMessages(msgs) const scrollMode = options.scrollToBottom ?? (!hasLoadedChatRef.current ? 'jump' : false) if (msgs.length > 0 && scrollMode) { scrollToBottom(scrollMode === 'animate') } hasLoadedChatRef.current = true } catch (error: any) { console.error('[chat-detail] load failed:', error) Taro.showToast({ title: error?.message || '加载失败,请稍后重试', icon: 'none', }) } finally { setLoading(false) } } latestLoadChatDataRef.current = loadChatData useEffect(() => { if (typeof Taro.getRecorderManager !== 'function') { console.warn('[record] RecorderManager unavailable', runtimeInfoRef.current) return } const recorderManager = Taro.getRecorderManager() recorderManagerRef.current = recorderManager recorderManager.onStart(() => { recordingActiveRef.current = true recordStartPendingRef.current = false console.info('[record] started', { profile: recordProfileRef.current, runtime: runtimeInfoRef.current, }) }) recorderManager.onError((error) => { recordingChildIdRef.current = null recordStartedAtRef.current = 0 recordStartPendingRef.current = false recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') console.error('[record] error', { error, profile: recordProfileRef.current, runtime: runtimeInfoRef.current, }) Taro.showToast({ title: '录音失败,请重试', icon: 'none' }) }) recorderManager.onStop(async (result) => { const currentChildId = recordingChildIdRef.current const startedAt = recordStartedAtRef.current const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0 recordingChildIdRef.current = null recordStartedAtRef.current = 0 recordStartPendingRef.current = false recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') console.info('[record] stopped', { childId: currentChildId, tempFilePath: result.tempFilePath, duration: result.duration, fileSize: result.fileSize, fallbackDurationMs, profile: recordProfileRef.current, runtime: runtimeInfoRef.current, }) if (!currentChildId) return if (!result.tempFilePath) { console.warn('[record] missing tempFilePath', { result }) Taro.showToast({ title: '录音文件不存在', icon: 'none' }) return } const durationMs = Math.max(0, Number(result.duration || 0) || fallbackDurationMs) if (durationMs < 800) { console.warn('[record] too short', { durationMs, resultDuration: result.duration, fallbackDurationMs, }) Taro.showToast({ title: '留言太短,请重试', icon: 'none' }) return } setSending(true) try { const profile = recordProfileRef.current const response = await sendParentVoiceMessage(currentChildId, { filePath: result.tempFilePath, durationMs, audioFormat: profile.format, mimeType: profile.mimeType, runtime: runtimeInfoRef.current, }) console.info('[record] uploaded', { childId: currentChildId, conversationId: response.conversation_id, messageId: response.message?.id, }) if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) { skipNextAutoLoadRef.current = true setActiveConversationId(response.conversation_id) await latestLoadChatDataRef.current(response.conversation_id, { showLoading: false, scrollToBottom: 'animate', }) } else { await latestLoadChatDataRef.current(activeConversationIdRef.current, { showLoading: false, scrollToBottom: 'animate', }) } 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 ensureRecordPermission = async (): Promise => { try { const settings = await Taro.getSetting() const granted = Boolean(settings.authSetting['scope.record']) console.info('[record] permission status', { granted, runtime: runtimeInfoRef.current, }) if (granted) return true await Taro.authorize({ scope: 'scope.record' }) console.info('[record] permission granted by authorize') return true } catch (error) { console.warn('[record] permission denied or authorize failed', { error, runtime: runtimeInfoRef.current, }) const modal = await Taro.showModal({ title: '需要录音权限', content: '请允许小程序使用麦克风后再留言', confirmText: '去设置', }) if (modal.confirm) { console.info('[record] openSetting requested') await Taro.openSetting() } return false } } const resetRecordStartTimer = () => { if (recordStartTimerRef.current) { clearTimeout(recordStartTimerRef.current) recordStartTimerRef.current = null } } const startRecording = async () => { if (sending || recordingActiveRef.current || recordStartPendingRef.current) return if (!character.canSend || !character.childId) { Taro.showToast({ title: '当前会话不支持发送', icon: 'none' }) return } const recorderManager = recorderManagerRef.current if (!recorderManager) { Taro.showToast({ title: '当前环境不支持录音', icon: 'none' }) return } recordStartPendingRef.current = true const hasPermission = await ensureRecordPermission() if (!recordPressedRef.current) { recordStartPendingRef.current = false console.info('[record] start skipped: touch already ended') return } if (!hasPermission) { recordStartPendingRef.current = false return } const runtime = readVoiceRuntimeInfo() const profile = getPreferredRecordProfile(runtime) runtimeInfoRef.current = runtime recordProfileRef.current = profile recordingChildIdRef.current = character.childId recordStartedAtRef.current = Date.now() recordingActiveRef.current = true setRecording(true) setRecordHint('松开发送留言') console.info('[record] start request', { childId: character.childId, profile, runtime, }) try { recorderManager.start({ duration: 60000, format: profile.format, numberOfChannels: 1, sampleRate: profile.sampleRate, encodeBitRate: profile.encodeBitRate, }) } catch (error: any) { recordingChildIdRef.current = null recordStartedAtRef.current = 0 recordStartPendingRef.current = false recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') console.error('[record] start failed:', { error, profile, runtime, }) Taro.showToast({ title: '无法开始录音,请重试', icon: 'none', }) } } const handleRecordTouchStart = () => { if (sending || recordingActiveRef.current || recordStartPendingRef.current) return recordPressedRef.current = true resetRecordStartTimer() setRecordHint('继续按住开始录音') console.info('[record] touch start', { delayMs: RECORD_LONG_PRESS_DELAY_MS, runtime: runtimeInfoRef.current, }) recordStartTimerRef.current = setTimeout(() => { recordStartTimerRef.current = null if (!recordPressedRef.current) return console.info('[record] long press threshold reached') void startRecording() }, RECORD_LONG_PRESS_DELAY_MS) } const handleRecordStop = () => { recordPressedRef.current = false resetRecordStartTimer() console.info('[record] touch end', { active: recordingActiveRef.current, pending: recordStartPendingRef.current, elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0, }) if (recordStartPendingRef.current && !recordingActiveRef.current) { setRecordHint('长按开始留言') return } if (!recordingActiveRef.current) { setRecordHint('长按开始留言') return } recordingActiveRef.current = false const recorderManager = recorderManagerRef.current if (!recorderManager) { console.warn('[record] stop skipped: recorder unavailable') recordingChildIdRef.current = null recordStartedAtRef.current = 0 setRecording(false) setRecordHint('长按开始留言') return } setRecordHint('正在处理留言...') console.info('[record] stop request', { elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0, }) try { recorderManager.stop() } catch (error) { recordingChildIdRef.current = null recordStartedAtRef.current = 0 recordStartPendingRef.current = false setRecording(false) setRecordHint('长按开始留言') console.error('[record] stop failed', { error, profile: recordProfileRef.current, runtime: runtimeInfoRef.current, }) Taro.showToast({ title: '录音停止失败,请重试', icon: 'none' }) } } const handlePlayAudio = (message: ChatMessage) => { if (message.contentType !== 2 || !message.mediaUrl) { Taro.showToast({ title: '语音地址不存在', icon: 'none' }) return } const urlInfo = getAudioUrlDebugInfo(message.mediaUrl) console.info('[audio] play request', { messageId: message.id, conversationId: message.conversationId, contentType: message.contentType, mediaDurationMs: message.mediaDurationMs, mediaMimeType: message.mediaMimeType, url: urlInfo, runtime: runtimeInfoRef.current, }) if (urlInfo.isHttp) { console.warn('[audio] http url may fail in experience/release builds', urlInfo) } const audioContext = audioContextRef.current if (!audioContext) { console.warn('[audio] play skipped: audio context unavailable', runtimeInfoRef.current) Taro.showToast({ title: '播放器未就绪', icon: 'none' }) return } if (playingMessageId === message.id) { console.info('[audio] stop current', { messageId: message.id }) audioContext.stop() setPlayingMessageId(null) return } audioContext.stop() audioContext.src = message.mediaUrl audioContext.autoplay = true audioContext.play() console.info('[audio] play invoked', { messageId: message.id, srcRaw: message.mediaUrl, src: audioContext.src, }) setPlayingMessageId(message.id) } const renderMessageBody = (msg: ChatMessage) => { if (msg.contentType === 2 && msg.mediaUrl) { const isPlaying = playingMessageId === msg.id return ( handlePlayAudio(msg)}> {isPlaying ? '[]' : '>'} {formatAudioDuration(msg.mediaDurationMs)} ) } return {msg.content} } return ( {character.name} {character.description} {loading ? ( 加载中... ) : messages.length === 0 ? ( {character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'} ) : ( messages.map((msg, index) => ( {(index === 0 || messages[index - 1].time !== msg.time) && ( {msg.time} )} {msg.type === 'user' ? ( {formatParticipantLabel(msg)} {renderMessageBody(msg)} ) : ( {formatParticipantLabel(msg)} {renderMessageBody(msg)} )} )) )} {character.canSend && character.childId && ( {sending ? '正在发送留言...' : recordHint} {sending ? '发送中...' : recording ? '松开发送' : '按住留言'} )} {systemBanner} ) }