diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index 3b0f28f..fbb68be 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -85,8 +85,8 @@ const DEFAULT_RECORD_PROFILE: RecordProfile = { const COMPAT_RECORD_PROFILE: RecordProfile = { format: 'aac', mimeType: 'audio/aac', - sampleRate: 16000, - encodeBitRate: 64000, + sampleRate: 44100, + encodeBitRate: 192000, } const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = { @@ -105,6 +105,11 @@ const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = { } const CHAT_BOTTOM_ANCHOR_ID = 'chat-bottom' +const RECORD_LONG_PRESS_DELAY_MS = 350 + +type LoadChatOptions = { + showLoading?: boolean +} function compareVersion(left: string, right: string): number { const leftParts = left.split('.').map((item) => Number(item) || 0) @@ -162,7 +167,7 @@ function readVoiceRuntimeInfo(): VoiceRuntimeInfo { } function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile { - if (runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE + if (runtime.isIOS || runtime.isHarmony || !runtime.supportsMp3Record) return COMPAT_RECORD_PROFILE return DEFAULT_RECORD_PROFILE } @@ -230,13 +235,21 @@ export default function ChatDetail() { const audioContextRef = useRef(null) const recorderManagerRef = useRef(null) const activeConversationIdRef = useRef(initialConversationId) - const latestLoadChatDataRef = useRef<(conversationId: number) => Promise>(async () => {}) + 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 = () => { if (scrollTimerRef.current) { @@ -311,6 +324,10 @@ export default function ChatDetail() { clearTimeout(scrollTimerRef.current) scrollTimerRef.current = null } + if (recordStartTimerRef.current) { + clearTimeout(recordStartTimerRef.current) + recordStartTimerRef.current = null + } } }, []) @@ -323,11 +340,18 @@ export default function ChatDetail() { }, [playingMessageId]) useEffect(() => { + if (skipNextAutoLoadRef.current) { + skipNextAutoLoadRef.current = false + return + } void loadChatData(activeConversationId) }, [activeConversationId, conversationSource, conversationPeerKind]) - const loadChatData = async (conversationId: number) => { - setLoading(true) + const loadChatData = async (conversationId: number, options: LoadChatOptions = {}) => { + const shouldShowLoading = options.showLoading ?? !hasLoadedChatRef.current + if (shouldShowLoading) { + setLoading(true) + } try { const char = await getCharacter({ conversationId, @@ -352,6 +376,7 @@ export default function ChatDetail() { if (msgs.length > 0) { scrollToBottom() } + hasLoadedChatRef.current = true } catch (error: any) { console.error('[chat-detail] load failed:', error) Taro.showToast({ @@ -375,6 +400,8 @@ export default function ChatDetail() { recorderManagerRef.current = recorderManager recorderManager.onStart(() => { + recordingActiveRef.current = true + recordStartPendingRef.current = false console.info('[record] started', { profile: recordProfileRef.current, runtime: runtimeInfoRef.current, @@ -384,6 +411,8 @@ export default function ChatDetail() { recorderManager.onError((error) => { recordingChildIdRef.current = null recordStartedAtRef.current = 0 + recordStartPendingRef.current = false + recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') console.error('[record] error', { @@ -400,6 +429,8 @@ export default function ChatDetail() { const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0 recordingChildIdRef.current = null recordStartedAtRef.current = 0 + recordStartPendingRef.current = false + recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') @@ -447,9 +478,11 @@ export default function ChatDetail() { 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 }) } else { - await latestLoadChatDataRef.current(activeConversationIdRef.current) + await latestLoadChatDataRef.current(activeConversationIdRef.current, { showLoading: false }) } Taro.showToast({ title: '留言已发送', icon: 'success' }) } catch (error: any) { @@ -502,8 +535,15 @@ export default function ChatDetail() { } } - const handleRecordStart = async () => { - if (sending || recording) return + 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 @@ -515,8 +555,17 @@ export default function ChatDetail() { return } + recordStartPendingRef.current = true const hasPermission = await ensureRecordPermission() - if (!hasPermission) return + 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) @@ -524,6 +573,7 @@ export default function ChatDetail() { recordProfileRef.current = profile recordingChildIdRef.current = character.childId recordStartedAtRef.current = Date.now() + recordingActiveRef.current = true setRecording(true) setRecordHint('松开发送留言') console.info('[record] start request', { @@ -542,6 +592,8 @@ export default function ChatDetail() { } catch (error: any) { recordingChildIdRef.current = null recordStartedAtRef.current = 0 + recordStartPendingRef.current = false + recordingActiveRef.current = false setRecording(false) setRecordHint('长按开始留言') console.error('[record] start failed:', { @@ -556,11 +608,49 @@ export default function ChatDetail() { } } + 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 = () => { - if (!recording) return + 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 } @@ -568,7 +658,21 @@ export default function ChatDetail() { console.info('[record] stop request', { elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0, }) - recorderManager.stop() + 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) => { @@ -706,7 +810,7 @@ export default function ChatDetail() { diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index 2f02ea4..7ad9237 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -296,6 +296,17 @@ function getPeerDisplayName(peerKind: PeerKind, peerId?: string, peerName?: stri return getParticipantDisplayName(peerKind, peerId, peerName) } +function getParentConversationName(parentUserId: number | null, peerId: string, peerName: string | null | undefined, context: BindingContext): string { + if (parentUserId && parentUserId === context.currentUserId) return '家长沟通' + + const displayName = getParticipantDisplayName('parent', peerId, peerName) + return `${displayName}的留言记录` +} + +function canSendParentConversation(parentUserId: number | null, context: BindingContext): boolean { + return Boolean(parentUserId && parentUserId === context.currentUserId) +} + function getConversationRank(conversation: ChatConversation): number { if (isParentChildConversation(conversation.conversationTypeName)) return 0 return 1 @@ -371,27 +382,34 @@ function toImConversation(item: ChildConversationItem, context: BindingContext): const isParentConversation = isParentChildConversation(item.conversation_type_name) const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child' const meta = PEER_META[peerKind] + const parentUserId = isParentConversation ? parseNumericId(item.peer_id) : null + const canSend = isParentConversation ? canSendParentConversation(parentUserId, context) : false + const parentConversationName = getParentConversationName(parentUserId, item.peer_id, item.peer_name, context) return { id: item.conversation_id, key: `im-${item.conversation_id}`, source: 'im', peerKind, - name: isParentConversation ? '家长沟通' : getPeerDisplayName(peerKind, item.peer_id, item.peer_name), + name: isParentConversation ? parentConversationName : getPeerDisplayName(peerKind, item.peer_id, item.peer_name), avatar: meta.icon, - typeLabel: isParentConversation ? '家长沟通' : meta.label, - description: isParentConversation ? '家长通过微信小程序和孩子直接沟通' : meta.description, - lastMessage: item.last_message_preview || (isParentConversation ? '还没有消息,点进去发送第一条' : '暂无消息'), + typeLabel: isParentConversation ? (canSend ? '家长沟通' : '留言记录') : meta.label, + description: isParentConversation + ? canSend + ? '家长通过微信小程序和孩子直接沟通' + : '其他家长和孩子的留言记录' + : meta.description, + lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'), time: formatListTime(item.last_message_at), sortAt: item.last_message_at || '', conversationTypeName: item.conversation_type_name, peerId: item.peer_id, childId: context.childId, childName: context.childName, - parentUserId: isParentConversation ? parseNumericId(item.peer_id) : null, + parentUserId, deviceId: context.deviceId, channelLabel: isParentConversation ? '微信小程序' : '设备端', - canSend: isParentConversation, + canSend, } } @@ -596,6 +614,7 @@ export async function getCharacter(options: { channelLabel, canSend = false, } = options + const context = await getBindingContext() if (source === 'ai') { const meta = getAiMeta(roleKey) @@ -618,10 +637,14 @@ export async function getCharacter(options: { } if (isParentChildConversation(conversationTypeName)) { + const normalizedParentUserId = parentUserId || parseNumericId(peerId) + const canSendParentMessage = Boolean(canSend && normalizedParentUserId === context.currentUserId) + const normalizedName = name || getParentConversationName(normalizedParentUserId, peerId || '', null, context) + return { id: conversationId, - name: '家长沟通', - description: '微信小程序 <-> 儿童设备', + name: normalizedName, + description: canSendParentMessage ? '微信小程序 <-> 儿童设备' : '其他家长和孩子的留言记录', icon: PEER_META.parent.icon, peerKind: 'parent', source, @@ -629,10 +652,10 @@ export async function getCharacter(options: { childId, childName, peerId, - parentUserId: parentUserId || parseNumericId(peerId), + parentUserId: normalizedParentUserId, deviceId, channelLabel: channelLabel || '微信小程序', - canSend: canSend || true, + canSend: canSendParentMessage, } }