完善家长留言录音与会话展示

This commit is contained in:
stu2not
2026-05-28 18:15:10 +08:00
parent 38fef3d2af
commit b2f5631461
2 changed files with 150 additions and 23 deletions

View File

@@ -85,8 +85,8 @@ const DEFAULT_RECORD_PROFILE: RecordProfile = {
const COMPAT_RECORD_PROFILE: RecordProfile = { const COMPAT_RECORD_PROFILE: RecordProfile = {
format: 'aac', format: 'aac',
mimeType: 'audio/aac', mimeType: 'audio/aac',
sampleRate: 16000, sampleRate: 44100,
encodeBitRate: 64000, encodeBitRate: 192000,
} }
const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = { const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = {
@@ -105,6 +105,11 @@ const FALLBACK_RUNTIME_INFO: VoiceRuntimeInfo = {
} }
const CHAT_BOTTOM_ANCHOR_ID = 'chat-bottom' 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 { function compareVersion(left: string, right: string): number {
const leftParts = left.split('.').map((item) => Number(item) || 0) const leftParts = left.split('.').map((item) => Number(item) || 0)
@@ -162,7 +167,7 @@ function readVoiceRuntimeInfo(): VoiceRuntimeInfo {
} }
function getPreferredRecordProfile(runtime: VoiceRuntimeInfo): RecordProfile { 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 return DEFAULT_RECORD_PROFILE
} }
@@ -230,13 +235,21 @@ export default function ChatDetail() {
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null) const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null) const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
const activeConversationIdRef = useRef(initialConversationId) const activeConversationIdRef = useRef(initialConversationId)
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {}) const latestLoadChatDataRef = useRef<(conversationId: number, options?: LoadChatOptions) => Promise<void>>(
async () => {}
)
const recordingChildIdRef = useRef<number | null>(null) const recordingChildIdRef = useRef<number | null>(null)
const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE) const recordProfileRef = useRef<RecordProfile>(DEFAULT_RECORD_PROFILE)
const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO) const runtimeInfoRef = useRef<VoiceRuntimeInfo>(FALLBACK_RUNTIME_INFO)
const recordStartedAtRef = useRef<number>(0) const recordStartedAtRef = useRef<number>(0)
const playingMessageIdRef = useRef<number | null>(null) const playingMessageIdRef = useRef<number | null>(null)
const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null) const scrollTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const recordStartTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const hasLoadedChatRef = useRef(false)
const skipNextAutoLoadRef = useRef(false)
const recordPressedRef = useRef(false)
const recordStartPendingRef = useRef(false)
const recordingActiveRef = useRef(false)
const scrollToBottom = () => { const scrollToBottom = () => {
if (scrollTimerRef.current) { if (scrollTimerRef.current) {
@@ -311,6 +324,10 @@ export default function ChatDetail() {
clearTimeout(scrollTimerRef.current) clearTimeout(scrollTimerRef.current)
scrollTimerRef.current = null scrollTimerRef.current = null
} }
if (recordStartTimerRef.current) {
clearTimeout(recordStartTimerRef.current)
recordStartTimerRef.current = null
}
} }
}, []) }, [])
@@ -323,11 +340,18 @@ export default function ChatDetail() {
}, [playingMessageId]) }, [playingMessageId])
useEffect(() => { useEffect(() => {
if (skipNextAutoLoadRef.current) {
skipNextAutoLoadRef.current = false
return
}
void loadChatData(activeConversationId) void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind]) }, [activeConversationId, conversationSource, conversationPeerKind])
const loadChatData = async (conversationId: number) => { const loadChatData = async (conversationId: number, options: LoadChatOptions = {}) => {
setLoading(true) const shouldShowLoading = options.showLoading ?? !hasLoadedChatRef.current
if (shouldShowLoading) {
setLoading(true)
}
try { try {
const char = await getCharacter({ const char = await getCharacter({
conversationId, conversationId,
@@ -352,6 +376,7 @@ export default function ChatDetail() {
if (msgs.length > 0) { if (msgs.length > 0) {
scrollToBottom() scrollToBottom()
} }
hasLoadedChatRef.current = true
} catch (error: any) { } catch (error: any) {
console.error('[chat-detail] load failed:', error) console.error('[chat-detail] load failed:', error)
Taro.showToast({ Taro.showToast({
@@ -375,6 +400,8 @@ export default function ChatDetail() {
recorderManagerRef.current = recorderManager recorderManagerRef.current = recorderManager
recorderManager.onStart(() => { recorderManager.onStart(() => {
recordingActiveRef.current = true
recordStartPendingRef.current = false
console.info('[record] started', { console.info('[record] started', {
profile: recordProfileRef.current, profile: recordProfileRef.current,
runtime: runtimeInfoRef.current, runtime: runtimeInfoRef.current,
@@ -384,6 +411,8 @@ export default function ChatDetail() {
recorderManager.onError((error) => { recorderManager.onError((error) => {
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0 recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
console.error('[record] error', { console.error('[record] error', {
@@ -400,6 +429,8 @@ export default function ChatDetail() {
const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0 const fallbackDurationMs = startedAt > 0 ? Date.now() - startedAt : 0
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0 recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
@@ -447,9 +478,11 @@ export default function ChatDetail() {
messageId: response.message?.id, messageId: response.message?.id,
}) })
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) { if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
skipNextAutoLoadRef.current = true
setActiveConversationId(response.conversation_id) setActiveConversationId(response.conversation_id)
await latestLoadChatDataRef.current(response.conversation_id, { showLoading: false })
} else { } else {
await latestLoadChatDataRef.current(activeConversationIdRef.current) await latestLoadChatDataRef.current(activeConversationIdRef.current, { showLoading: false })
} }
Taro.showToast({ title: '留言已发送', icon: 'success' }) Taro.showToast({ title: '留言已发送', icon: 'success' })
} catch (error: any) { } catch (error: any) {
@@ -502,8 +535,15 @@ export default function ChatDetail() {
} }
} }
const handleRecordStart = async () => { const resetRecordStartTimer = () => {
if (sending || recording) return if (recordStartTimerRef.current) {
clearTimeout(recordStartTimerRef.current)
recordStartTimerRef.current = null
}
}
const startRecording = async () => {
if (sending || recordingActiveRef.current || recordStartPendingRef.current) return
if (!character.canSend || !character.childId) { if (!character.canSend || !character.childId) {
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' }) Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
return return
@@ -515,8 +555,17 @@ export default function ChatDetail() {
return return
} }
recordStartPendingRef.current = true
const hasPermission = await ensureRecordPermission() 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 runtime = readVoiceRuntimeInfo()
const profile = getPreferredRecordProfile(runtime) const profile = getPreferredRecordProfile(runtime)
@@ -524,6 +573,7 @@ export default function ChatDetail() {
recordProfileRef.current = profile recordProfileRef.current = profile
recordingChildIdRef.current = character.childId recordingChildIdRef.current = character.childId
recordStartedAtRef.current = Date.now() recordStartedAtRef.current = Date.now()
recordingActiveRef.current = true
setRecording(true) setRecording(true)
setRecordHint('松开发送留言') setRecordHint('松开发送留言')
console.info('[record] start request', { console.info('[record] start request', {
@@ -542,6 +592,8 @@ export default function ChatDetail() {
} catch (error: any) { } catch (error: any) {
recordingChildIdRef.current = null recordingChildIdRef.current = null
recordStartedAtRef.current = 0 recordStartedAtRef.current = 0
recordStartPendingRef.current = false
recordingActiveRef.current = false
setRecording(false) setRecording(false)
setRecordHint('长按开始留言') setRecordHint('长按开始留言')
console.error('[record] start failed:', { 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 = () => { 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 const recorderManager = recorderManagerRef.current
if (!recorderManager) { if (!recorderManager) {
console.warn('[record] stop skipped: recorder unavailable') console.warn('[record] stop skipped: recorder unavailable')
recordingChildIdRef.current = null
recordStartedAtRef.current = 0
setRecording(false)
setRecordHint('长按开始留言')
return return
} }
@@ -568,7 +658,21 @@ export default function ChatDetail() {
console.info('[record] stop request', { console.info('[record] stop request', {
elapsedMs: recordStartedAtRef.current > 0 ? Date.now() - recordStartedAtRef.current : 0, 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) => { const handlePlayAudio = (message: ChatMessage) => {
@@ -706,7 +810,7 @@ export default function ChatDetail() {
</View> </View>
<View <View
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`} className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
onLongPress={handleRecordStart} onTouchStart={handleRecordTouchStart}
onTouchEnd={handleRecordStop} onTouchEnd={handleRecordStop}
onTouchCancel={handleRecordStop} onTouchCancel={handleRecordStop}
> >

View File

@@ -296,6 +296,17 @@ function getPeerDisplayName(peerKind: PeerKind, peerId?: string, peerName?: stri
return getParticipantDisplayName(peerKind, peerId, peerName) 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 { function getConversationRank(conversation: ChatConversation): number {
if (isParentChildConversation(conversation.conversationTypeName)) return 0 if (isParentChildConversation(conversation.conversationTypeName)) return 0
return 1 return 1
@@ -371,27 +382,34 @@ function toImConversation(item: ChildConversationItem, context: BindingContext):
const isParentConversation = isParentChildConversation(item.conversation_type_name) const isParentConversation = isParentChildConversation(item.conversation_type_name)
const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child' const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child'
const meta = PEER_META[peerKind] 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 { return {
id: item.conversation_id, id: item.conversation_id,
key: `im-${item.conversation_id}`, key: `im-${item.conversation_id}`,
source: 'im', source: 'im',
peerKind, 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, avatar: meta.icon,
typeLabel: isParentConversation ? '家长沟通' : meta.label, typeLabel: isParentConversation ? (canSend ? '家长沟通' : '留言记录') : meta.label,
description: isParentConversation ? '家长通过微信小程序和孩子直接沟通' : meta.description, description: isParentConversation
lastMessage: item.last_message_preview || (isParentConversation ? '还没有消息,点进去发送第一条' : '暂无消息'), ? canSend
? '家长通过微信小程序和孩子直接沟通'
: '其他家长和孩子的留言记录'
: meta.description,
lastMessage: item.last_message_preview || (isParentConversation ? (canSend ? '还没有消息,点进去发送第一条' : '暂无留言记录') : '暂无消息'),
time: formatListTime(item.last_message_at), time: formatListTime(item.last_message_at),
sortAt: item.last_message_at || '', sortAt: item.last_message_at || '',
conversationTypeName: item.conversation_type_name, conversationTypeName: item.conversation_type_name,
peerId: item.peer_id, peerId: item.peer_id,
childId: context.childId, childId: context.childId,
childName: context.childName, childName: context.childName,
parentUserId: isParentConversation ? parseNumericId(item.peer_id) : null, parentUserId,
deviceId: context.deviceId, deviceId: context.deviceId,
channelLabel: isParentConversation ? '微信小程序' : '设备端', channelLabel: isParentConversation ? '微信小程序' : '设备端',
canSend: isParentConversation, canSend,
} }
} }
@@ -596,6 +614,7 @@ export async function getCharacter(options: {
channelLabel, channelLabel,
canSend = false, canSend = false,
} = options } = options
const context = await getBindingContext()
if (source === 'ai') { if (source === 'ai') {
const meta = getAiMeta(roleKey) const meta = getAiMeta(roleKey)
@@ -618,10 +637,14 @@ export async function getCharacter(options: {
} }
if (isParentChildConversation(conversationTypeName)) { if (isParentChildConversation(conversationTypeName)) {
const normalizedParentUserId = parentUserId || parseNumericId(peerId)
const canSendParentMessage = Boolean(canSend && normalizedParentUserId === context.currentUserId)
const normalizedName = name || getParentConversationName(normalizedParentUserId, peerId || '', null, context)
return { return {
id: conversationId, id: conversationId,
name: '家长沟通', name: normalizedName,
description: '微信小程序 <-> 儿童设备', description: canSendParentMessage ? '微信小程序 <-> 儿童设备' : '其他家长和孩子的留言记录',
icon: PEER_META.parent.icon, icon: PEER_META.parent.icon,
peerKind: 'parent', peerKind: 'parent',
source, source,
@@ -629,10 +652,10 @@ export async function getCharacter(options: {
childId, childId,
childName, childName,
peerId, peerId,
parentUserId: parentUserId || parseNumericId(peerId), parentUserId: normalizedParentUserId,
deviceId, deviceId,
channelLabel: channelLabel || '微信小程序', channelLabel: channelLabel || '微信小程序',
canSend: canSend || true, canSend: canSendParentMessage,
} }
} }