feat(小程序): 优化玩伴会话与家长发消息

This commit is contained in:
stu2not
2026-04-22 17:25:49 +08:00
parent f21e0afcd0
commit 6947ead855
5 changed files with 969 additions and 390 deletions

View File

@@ -70,6 +70,7 @@
flex: 1; flex: 1;
overflow-y: auto; overflow-y: auto;
padding: 24px; padding: 24px;
box-sizing: border-box;
} }
.time-divider { .time-divider {
@@ -92,16 +93,33 @@
&.user { &.user {
flex-direction: row-reverse; flex-direction: row-reverse;
.message-main {
align-items: flex-end;
}
} }
&.ai { &.peer {
.ai-avatar { .peer-avatar {
margin-right: 14px; margin-right: 14px;
flex-shrink: 0; flex-shrink: 0;
} }
} }
} }
.message-main {
display: flex;
flex-direction: column;
max-width: 76%;
}
.message-meta {
display: block;
font-size: 22px;
color: #999999;
margin-bottom: 8px;
}
.user-avatar { .user-avatar {
width: 68px; width: 68px;
height: 68px; height: 68px;
@@ -117,7 +135,7 @@
} }
} }
.ai-avatar { .peer-avatar {
width: 68px; width: 68px;
height: 68px; height: 68px;
background: #FEF3E8; background: #FEF3E8;
@@ -132,7 +150,7 @@
} }
.message-bubble { .message-bubble {
max-width: 68%; max-width: 100%;
padding: 18px 22px; padding: 18px 22px;
font-size: 30px; font-size: 30px;
line-height: 1.5; line-height: 1.5;
@@ -148,7 +166,7 @@
margin-right: 14px; margin-right: 14px;
} }
.ai & { .peer & {
background: #FFFFFF; background: #FFFFFF;
color: #1A1A1A; color: #1A1A1A;
border-radius: 20px 20px 20px 6px; border-radius: 20px 20px 20px 6px;
@@ -156,51 +174,53 @@
} }
} }
.audio-message { .empty-chat {
min-height: 320px;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center;
}
.audio-icon { .empty-chat-text {
width: 48px; font-size: 28px;
height: 48px; color: #999999;
background: rgba(255, 255, 255, 0.3); }
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 20px;
color: inherit;
margin-right: 14px;
flex-shrink: 0;
transition: background 0.2s;
&.playing { .composer {
background: rgba(255, 255, 255, 0.6); background: #FFFFFF;
} border-top: 1px solid #EEEEEE;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 16px;
}
&.orange { .composer-input {
background: rgba(255, 140, 66, 0.2); flex: 1;
&.playing { min-height: 76px;
background: rgba(255, 140, 66, 0.4); background: #F5F7FA;
} border-radius: 38px;
} padding: 0 24px;
} font-size: 28px;
color: #1A1A1A;
}
.audio-info { .composer-send {
display: flex; min-width: 132px;
flex-direction: column; height: 76px;
border-radius: 38px;
background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
.audio-title { &.disabled {
font-size: 28px; opacity: 0.55;
font-weight: 500;
color: inherit;
margin-bottom: 4px;
}
.audio-duration {
font-size: 24px;
opacity: 0.8;
}
} }
} }
.composer-send-text {
font-size: 28px;
color: #FFFFFF;
font-weight: 600;
}

View File

@@ -1,83 +1,162 @@
import { View, Text, ScrollView, Image } from '@tarojs/components' import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import { useState, useEffect, useRef } from 'react' import { useEffect, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro' import Taro, { useRouter } from '@tarojs/taro'
import { getMessages, getCharacter, ChatMessage, Character } from '../../../services/chat' import {
getMessages,
getCharacter,
sendParentMessage,
ChatMessage,
Character,
ConversationSource,
PeerKind,
} from '../../../services/chat'
import './index.scss' 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}`
}
export default function ChatDetail() { export default function ChatDetail() {
const router = useRouter() const router = useRouter()
const { id } = router.params const params = router.params
const conversationId = parseInt(id || '1')
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<Character>({ const [character, setCharacter] = useState<Character>({
id: conversationId, id: initialConversationId,
name: decodeURIComponent(router.params.name || 'AI 玩伴'), name: decodedName,
description: '温柔音色 / 开朗耐心', description: '交流记录',
icon: '🎤' icon: '🤖',
source: conversationSource,
peerKind: conversationPeerKind,
conversationTypeName: decodedConversationTypeName,
childId,
childName: decodedChildName,
peerId: decodedPeerId,
parentUserId,
deviceId: decodedDeviceId,
channelLabel: '--',
canSend,
}) })
const [messages, setMessages] = useState<ChatMessage[]>([]) const [messages, setMessages] = useState<ChatMessage[]>([])
const [playingId, setPlayingId] = useState<number | null>(null) const [loading, setLoading] = useState(true)
const audioCtx = useRef<Taro.InnerAudioContext | null>(null) const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => { useEffect(() => {
loadChatData() void loadChatData(activeConversationId)
return () => { }, [activeConversationId, conversationSource, conversationPeerKind])
// 页面卸载时销毁音频
audioCtx.current?.destroy() const loadChatData = async (conversationId: number) => {
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)
} catch (error: any) {
console.error('[chat-detail] load failed:', error)
Taro.showToast({
title: error?.message || '加载失败,请稍后重试',
icon: 'none',
})
} finally {
setLoading(false)
} }
}, [conversationId])
const loadChatData = async () => {
const char = await getCharacter(conversationId)
if (char) {
setCharacter(char)
Taro.setNavigationBarTitle({ title: char.name })
}
const msgs = await getMessages(conversationId)
setMessages(msgs)
}
const handlePlayAudio = (msg: ChatMessage) => {
if (!msg.audioUrl) return
// 如果点的是正在播放的那条,就停止
if (playingId === msg.id) {
audioCtx.current?.stop()
audioCtx.current?.destroy()
audioCtx.current = null
setPlayingId(null)
return
}
// 停掉上一条
audioCtx.current?.stop()
audioCtx.current?.destroy()
const ctx = Taro.createInnerAudioContext()
ctx.src = msg.audioUrl
ctx.autoplay = true
ctx.onPlay(() => setPlayingId(msg.id))
ctx.onEnded(() => {
setPlayingId(null)
ctx.destroy()
audioCtx.current = null
})
ctx.onError(() => {
setPlayingId(null)
ctx.destroy()
audioCtx.current = null
Taro.showToast({ title: '播放失败', icon: 'none' })
})
audioCtx.current = ctx
} }
const handleBack = () => { const handleBack = () => {
audioCtx.current?.stop()
audioCtx.current?.destroy()
Taro.navigateBack() Taro.navigateBack()
} }
const handleSend = async () => {
const normalizedContent = inputText.trim()
if (!normalizedContent || sending) return
if (!character.canSend || !character.childId) {
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
return
}
setSending(true)
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' })
} catch (error: any) {
console.error('[chat-detail] send failed:', error)
Taro.showToast({
title: error?.message || '发送失败,请重试',
icon: 'none',
})
} finally {
setSending(false)
}
}
return ( return (
<View className='chat-detail-page'> <View className='chat-detail-page'>
<View className='chat-header'> <View className='chat-header'>
@@ -95,62 +174,72 @@ export default function ChatDetail() {
</View> </View>
</View> </View>
<ScrollView className='chat-list' scrollY> <ScrollView className='chat-list' scrollY scrollWithAnimation>
{messages.map((msg, index) => ( {loading ? (
<View key={msg.id}> <View className='empty-chat'>
{(index === 0 || messages[index - 1].type !== msg.type || <Text className='empty-chat-text'>...</Text>
messages[index - 1].time !== msg.time) && (
<View className='time-divider'>
<Text>{msg.time}</Text>
</View>
)}
{msg.type === 'user' ? (
<View className='message-item user'>
<View className='message-bubble'>
{msg.isAudio ? (
<View className='audio-message' onClick={() => handlePlayAudio(msg)}>
<View className={`audio-icon ${playingId === msg.id ? 'playing' : ''}`}>
{playingId === msg.id ? '⏸' : '▶'}
</View>
<View className='audio-info'>
<Text className='audio-title'></Text>
<Text className='audio-duration'>{msg.audioDuration}</Text>
</View>
</View>
) : (
<Text>{msg.content}</Text>
)}
</View>
<View className='user-avatar'>
<Text className='avatar-img'>👦</Text>
</View>
</View>
) : (
<View className='message-item ai'>
<View className='ai-avatar'>
<Text className='robot-emoji'>{character.icon}</Text>
</View>
<View className='message-bubble'>
{msg.isAudio ? (
<View className='audio-message' onClick={() => handlePlayAudio(msg)}>
<View className={`audio-icon orange ${playingId === msg.id ? 'playing' : ''}`}>
{playingId === msg.id ? '⏸' : '▶'}
</View>
<View className='audio-info'>
<Text className='audio-title'></Text>
<Text className='audio-duration'>{msg.audioDuration}</Text>
</View>
</View>
) : (
<Text>{msg.content}</Text>
)}
</View>
</View>
)}
</View> </View>
))} ) : messages.length === 0 ? (
<View className='empty-chat'>
<Text className='empty-chat-text'>
{character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'}
</Text>
</View>
) : (
messages.map((msg, index) => (
<View key={msg.id}>
{(index === 0 || messages[index - 1].time !== msg.time) && (
<View className='time-divider'>
<Text>{msg.time}</Text>
</View>
)}
{msg.type === 'user' ? (
<View className='message-item user'>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
<Text>{msg.content}</Text>
</View>
</View>
<View className='user-avatar'>
<Text className='avatar-img'>{msg.senderType === 'parent' ? '👨' : '👦'}</Text>
</View>
</View>
) : (
<View className='message-item peer'>
<View className='peer-avatar'>
<Text className='robot-emoji'>{msg.senderType === 'child' ? '🧒' : character.icon}</Text>
</View>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'>
<Text>{msg.content}</Text>
</View>
</View>
</View>
)}
</View>
))
)}
</ScrollView> </ScrollView>
{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>
</View>
)}
</View> </View>
) )
} }

View File

@@ -14,15 +14,8 @@
font-weight: 700; font-weight: 700;
color: #1A1A1A; color: #1A1A1A;
display: block; display: block;
margin-bottom: 12px;
line-height: 1.2; line-height: 1.2;
} }
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
} }
.conversation-list { .conversation-list {
@@ -74,19 +67,49 @@
.conversation-header { .conversation-header {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: flex-start;
margin-bottom: 12px; margin-bottom: 12px;
.conversation-name {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
}
.conversation-time { .conversation-time {
font-size: 24px; font-size: 24px;
color: #999999; color: #999999;
flex-shrink: 0; flex-shrink: 0;
margin-left: 16px;
}
}
.conversation-name-wrap {
display: flex;
align-items: center;
flex-wrap: wrap;
min-width: 0;
}
.conversation-name {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
margin-right: 12px;
}
.conversation-tag {
height: 40px;
padding: 0 14px;
border-radius: 999px;
font-size: 22px;
line-height: 40px;
color: #FFFFFF;
&.child {
background: #5B8FF9;
}
&.parent {
background: #36CFC9;
}
&.ai {
background: #FF8C42;
} }
} }
@@ -106,6 +129,13 @@
} }
} }
.conversation-meta {
display: block;
margin-top: 10px;
font-size: 24px;
color: #A0A0A0;
}
.unread-badge { .unread-badge {
min-width: 36px; min-width: 36px;
height: 36px; height: 36px;
@@ -123,3 +153,31 @@
font-weight: 600; font-weight: 600;
} }
} }
.loading {
min-height: 50vh;
display: flex;
align-items: center;
justify-content: center;
text {
font-size: 30px;
color: #999999;
}
}
.empty-card {
background: #FFFFFF;
border-radius: 20px;
margin: 0 24px;
padding: 48px 32px;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
text-align: center;
}
.empty-title {
display: block;
font-size: 34px;
font-weight: 700;
color: #1A1A1A;
}

View File

@@ -1,63 +1,102 @@
import { View, Text, ScrollView } from '@tarojs/components' import { View, Text, ScrollView } from '@tarojs/components'
import { useState, useEffect } from 'react' import { useState } from 'react'
import Taro from '@tarojs/taro' import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { getConversations, ChatConversation } from '../../services/chat' import { getConversations, ChatConversation } from '../../services/chat'
import './index.scss' import './index.scss'
export default function Chat() { export default function Chat() {
const [conversations, setConversations] = useState<ChatConversation[]>([]) const [conversations, setConversations] = useState<ChatConversation[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => { useDidShow(() => {
loadConversations() void loadConversations()
}, []) })
const loadConversations = async () => { const loadConversations = async () => {
const data = await getConversations() if (!getToken()) {
setConversations(data) Taro.reLaunch({ url: '/pages/login/index' })
return
}
setLoading(true)
try {
const data = await getConversations()
setConversations(data)
} catch (error: any) {
console.error('[chat] load failed:', error)
Taro.showToast({
title: error?.message || '加载失败,请稍后重试',
icon: 'none',
})
} finally {
setLoading(false)
}
} }
const handleConversationClick = (conversation: ChatConversation) => { const handleConversationClick = (conversation: ChatConversation) => {
Taro.navigateTo({ Taro.navigateTo({
url: `/pages/chat/detail/index?id=${conversation.id}&name=${encodeURIComponent(conversation.name)}` url:
`/pages/chat/detail/index?id=${conversation.id}` +
`&source=${conversation.source}` +
`&name=${encodeURIComponent(conversation.name)}` +
`&peerKind=${conversation.peerKind}` +
`&roleKey=${encodeURIComponent(conversation.roleKey || '')}` +
`&conversationTypeName=${encodeURIComponent(conversation.conversationTypeName || '')}` +
`&peerId=${encodeURIComponent(conversation.peerId || '')}` +
`&childId=${conversation.childId || ''}` +
`&childName=${encodeURIComponent(conversation.childName || '')}` +
`&parentUserId=${conversation.parentUserId || ''}` +
`&deviceId=${encodeURIComponent(conversation.deviceId || '')}` +
`&channelLabel=${encodeURIComponent(conversation.channelLabel || '')}` +
`&canSend=${conversation.canSend ? '1' : '0'}`,
}) })
} }
return ( return (
<View className='chat-page'> <View className='chat-page'>
<View className='page-header'> <View className='page-header'>
<Text className='page-title'>AI </Text> <Text className='page-title'></Text>
<Text className='page-subtitle'></Text>
</View> </View>
<ScrollView className='conversation-list' scrollY> {loading ? (
{conversations.map((conversation) => ( <View className='loading'>
<View <Text>...</Text>
key={conversation.id} </View>
className='conversation-item' ) : conversations.length === 0 ? (
onClick={() => handleConversationClick(conversation)} <View className='empty-card'>
> <Text className='empty-title'></Text>
<View className='conversation-avatar'> </View>
<Text className='avatar-icon'>{conversation.avatar}</Text> ) : (
</View> <ScrollView className='conversation-list' scrollY>
<View className='conversation-content'> {conversations.map((conversation) => (
<View className='conversation-header'> <View
<Text className='conversation-name'>{conversation.name}</Text> key={conversation.key}
<Text className='conversation-time'>{conversation.time}</Text> className='conversation-item'
onClick={() => handleConversationClick(conversation)}
>
<View className='conversation-avatar'>
<Text className='avatar-icon'>{conversation.avatar}</Text>
</View> </View>
<View className='conversation-footer'> <View className='conversation-content'>
<Text className='last-message' numberOfLines={1}> <View className='conversation-header'>
{conversation.lastMessage} <View className='conversation-name-wrap'>
</Text> <Text className='conversation-name'>{conversation.name}</Text>
{conversation.unreadCount && conversation.unreadCount > 0 && ( <Text className={`conversation-tag ${conversation.peerKind}`}>{conversation.typeLabel}</Text>
<View className='unread-badge'>
<Text className='unread-count'>{conversation.unreadCount}</Text>
</View> </View>
)} <Text className='conversation-time'>{conversation.time}</Text>
</View>
<View className='conversation-footer'>
<Text className='last-message' numberOfLines={1}>
{conversation.lastMessage}
</Text>
</View>
{conversation.childName && <Text className='conversation-meta'>{conversation.childName}</Text>}
</View> </View>
</View> </View>
</View> ))}
))} </ScrollView>
</ScrollView> )}
</View> </View>
) )
} }

View File

@@ -1,21 +1,50 @@
// 聊天相关接口定义 import { getCurrentUserId } from './auth'
import { request } from './api'
import { resolveActiveBinding } from './binding'
import { getChild } from './child'
export type ConversationSource = 'im' | 'ai'
export type PeerKind = 'child' | 'parent' | 'ai'
const PARENT_CHILD_CONVERSATION_TYPE_NAME = 'parent_child'
export interface ChatConversation { export interface ChatConversation {
id: number id: number
key: string
source: ConversationSource
peerKind: PeerKind
name: string name: string
avatar: string avatar: string
typeLabel: string
description: string
lastMessage: string lastMessage: string
time: string time: string
unreadCount?: number sortAt: string
roleKey?: string
conversationTypeName?: string
peerId?: string
childId?: number | null
childName?: string
parentUserId?: number | null
deviceId?: string
channelLabel: string
canSend: boolean
isSynthetic?: boolean
} }
export interface ChatMessage { export interface ChatMessage {
id: number id: number
type: 'user' | 'ai' type: 'user' | 'peer'
content: string content: string
time: string time: string
isAudio?: boolean conversationId: number
audioDuration?: string senderType?: string
senderId?: string
receiverType?: string
receiverId?: string
senderName?: string
receiverName?: string
channelLabel?: string
} }
export interface Character { export interface Character {
@@ -23,196 +52,540 @@ export interface Character {
name: string name: string
description: string description: string
icon: string icon: string
peerKind: PeerKind
source: ConversationSource
conversationTypeName?: string
childId?: number | null
childName?: string
peerId?: string
parentUserId?: number | null
deviceId?: string
channelLabel: string
canSend: boolean
} }
// Mock 数据 - 会话列表 interface DeviceAiMessageItem {
const mockConversations: ChatConversation[] = [ id: number
{ conversation_id: number
id: 1, role_key: string
name: '阳光知心伴伴', is_user: boolean
avatar: '🎤', speaker: 'user' | 'assistant'
lastMessage: '因为太阳光里面其实藏着七种颜色呀...', content: string
time: '14:31', timestamp: number
unreadCount: 2 created_at: string
}, }
{
id: 2, interface DeviceAiMessageListResponse {
name: '智慧小博士', items: DeviceAiMessageItem[]
avatar: '📚', total: number
lastMessage: '好的,我来给你讲一个关于恐龙的故事...', next_cursor?: number | null
time: '昨天' }
},
{ interface ChildConversationItem {
id: 3, conversation_id: number
name: '音乐小精灵', conversation_type: number
avatar: '🎵', conversation_type_name: string
lastMessage: '《拔萝卜》已经唱完啦,还要听别的吗?', peer_type: 'child' | 'parent'
time: '昨天' peer_id: string
}, peer_name?: string | null
{ last_message_preview?: string | null
id: 4, last_message_at?: string | null
name: '故事大王', message_count: number
avatar: '📖', }
lastMessage: '从前有一只勇敢的小兔子...',
time: '周一' interface ChildConversationListResponse {
}, items: ChildConversationItem[]
{ total: number
id: 5, next_cursor?: number | null
name: '英语小老师', }
avatar: '🔤',
lastMessage: 'Apple, A-P-P-L-E, apple!', interface ChildConversationMessageItem {
time: '周一' id: number
conversation_id: number
seq: number
sender_type: 'child' | 'parent'
sender_id: string
receiver_type: 'child' | 'parent'
receiver_id: string
content_type: number
content_text?: string | null
content_json?: Record<string, any> | null
media_file_key?: string | null
media_duration_ms?: number | null
media_mime_type?: string | null
media_size_bytes?: number | null
media_transcript_text?: string | null
client_msg_id?: string | null
sender_name_snapshot?: string | null
receiver_name_snapshot?: string | null
ext_json?: Record<string, any> | null
created_at: string
}
interface ChildConversationMessageListResponse {
conversation_id: number
has_more: boolean
next_cursor_seq?: number | null
items: ChildConversationMessageItem[]
}
interface ConversationMessageCreateResponse {
idempotent: boolean
conversation_id: number
conversation_type: number
conversation_type_name: string
message: ChildConversationMessageItem
}
interface BindingContext {
deviceId: string
childId: number | null
childName: string
currentUserId: number
}
const PEER_META: Record<PeerKind, { icon: string; label: string; description: string }> = {
child: { icon: '🧒', label: '儿童', description: '孩子正在交流的儿童玩伴' },
parent: { icon: '👨', label: '家长', description: '家长通过微信小程序和孩子沟通' },
ai: { icon: '🤖', label: 'AI', description: '孩子与 AI 的历史交流' },
}
const AI_ROLE_META: Record<string, { name: string; icon: string; description: string }> = {
assistant: { name: 'AI', icon: '🤖', description: '孩子与 AI 的历史交流' },
}
export function isParentChildConversation(conversationTypeName?: string | null): boolean {
return conversationTypeName === PARENT_CHILD_CONVERSATION_TYPE_NAME
}
function parseTime(value?: string | null): number {
if (!value) return 0
const timestamp = new Date(value).getTime()
return Number.isNaN(timestamp) ? 0 : timestamp
}
function parseNumericId(value?: string | number | null): number | null {
const normalizedValue = Number(value)
if (!Number.isFinite(normalizedValue) || normalizedValue <= 0) return null
return normalizedValue
}
function formatListTime(value?: string | null): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const now = new Date()
const isSameDay =
date.getFullYear() === now.getFullYear() &&
date.getMonth() === now.getMonth() &&
date.getDate() === now.getDate()
if (isSameDay) {
return `${`${date.getHours()}`.padStart(2, '0')}:${`${date.getMinutes()}`.padStart(2, '0')}`
} }
] return `${date.getMonth() + 1}/${date.getDate()}`
}
// Mock 数据 - 消息列表 function formatDetailTime(value?: string | null): string {
const mockMessages: Record<number, ChatMessage[]> = { if (!value) return ''
1: [ const date = new Date(value)
{ if (Number.isNaN(date.getTime())) return value
id: 1, const month = `${date.getMonth() + 1}`.padStart(2, '0')
type: 'user', const day = `${date.getDate()}`.padStart(2, '0')
content: '天空为什么是蓝色的呀?', const hour = `${date.getHours()}`.padStart(2, '0')
time: '今天 14:30' const minute = `${date.getMinutes()}`.padStart(2, '0')
}, return `${month}-${day} ${hour}:${minute}`
{ }
id: 2,
type: 'ai', function formatImPreview(
content: '因为太阳光里面其实藏着七种颜色呀。当光穿过大气层的时候,蓝色的光最调皮,到处乱跑,所以我们看到的天空就是蓝色的啦!', item: Pick<ChildConversationMessageItem, 'content_type' | 'content_text' | 'media_transcript_text' | 'content_json'>
time: '今天 14:31' ): string {
}, if (item.content_type === 1) return item.content_text || '文本消息'
{ if (item.content_type === 2) return item.media_transcript_text || '[语音]'
id: 3, if (item.content_type === 3) return '[图片]'
type: 'user', if (item.content_type === 4) {
content: '给我唱一首拔萝卜吧!', const title = String(item.content_json?.title || item.content_json?.type || '').trim()
time: '今天 15:15' return title ? `[卡片] ${title}` : '[卡片]'
}, }
{ return '[消息]'
id: 4, }
type: 'ai',
content: '', function createClientMessageId(): string {
time: '今天 15:15', return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
isAudio: true, }
audioDuration: '01:45'
function getAiMeta(roleKey?: string): { name: string; icon: string; description: string } {
if (roleKey && AI_ROLE_META[roleKey]) return AI_ROLE_META[roleKey]
return {
name: roleKey || 'AI',
icon: '🤖',
description: `孩子与 ${roleKey || 'AI'} 的历史交流`,
}
}
function getParticipantDisplayName(type: string, id?: string, name?: string | null): string {
if (name && name.trim()) return name.trim()
if (type === 'child') return id ? `儿童 ${id}` : '儿童'
if (type === 'parent') return id ? `家长 ${id}` : '家长'
return id || 'AI'
}
function getPeerDisplayName(peerKind: PeerKind, peerId?: string, peerName?: string | null, roleKey?: string): string {
if (peerKind === 'ai') return getAiMeta(roleKey).name
return getParticipantDisplayName(peerKind, peerId, peerName)
}
function getConversationRank(conversation: ChatConversation): number {
if (isParentChildConversation(conversation.conversationTypeName)) return 0
return 1
}
async function getBindingContext(): Promise<BindingContext> {
const binding = await resolveActiveBinding()
let childName = String(binding?.child_name || '').trim()
if (!childName && binding?.child_id) {
try {
const child = await getChild(binding.child_id)
childName = String(child?.child_name || '').trim()
} catch (error) {
console.error('[chat] load child name failed:', error)
} }
], }
2: [
{ return {
id: 1, deviceId: binding?.device_id || '',
type: 'user', childId: binding?.child_id || null,
content: '你知道恐龙吗?', childName,
time: '昨天 10:30' currentUserId: getCurrentUserId(),
}, }
{ }
id: 2,
type: 'ai', async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMessageItem[]> {
content: '当然知道!恐龙是生活在很久很久以前的大型爬行动物,有的恐龙比房子还要大呢!', const { deviceId } = context || (await getBindingContext())
time: '昨天 10:31' if (!deviceId) return []
},
{ try {
id: 3, const response = await request<DeviceAiMessageListResponse>(`/devices/${deviceId}/messages?limit=100`)
type: 'user', return response.items || []
content: '那给我讲一个恐龙的故事吧', } catch (error: any) {
time: '昨天 10:32' if (error?.status === 404) return []
}, throw error
{ }
id: 4, }
type: 'ai',
content: '好的,我来给你讲一个关于恐龙的故事...', async function getChildConversations(context?: BindingContext): Promise<ChildConversationItem[]> {
time: '昨天 10:33' const { childId } = context || (await getBindingContext())
} if (!childId) return []
],
3: [ try {
{ const response = await request<ChildConversationListResponse>(`/children/${childId}/conversations?limit=100`)
id: 1, return response.items || []
type: 'user', } catch (error: any) {
content: '我想听拔萝卜', if (error?.status === 404) return []
time: '昨天 16:00' throw error
}, }
{ }
id: 2,
type: 'ai', async function getChildConversationMessages(
content: '', conversationId: number,
time: '昨天 16:01', context?: BindingContext
isAudio: true, ): Promise<ChildConversationMessageItem[]> {
audioDuration: '02:30' const { childId } = context || (await getBindingContext())
}, if (!childId || conversationId <= 0) return []
{
id: 3, const response = await request<ChildConversationMessageListResponse>(
type: 'ai', `/children/${childId}/conversations/${conversationId}/messages?limit=100`
content: '《拔萝卜》已经唱完啦,还要听别的吗?', )
time: '昨天 16:04' return response.items || []
} }
],
4: [ function toImConversation(item: ChildConversationItem, context: BindingContext): ChatConversation {
{ const isParentConversation = isParentChildConversation(item.conversation_type_name)
id: 1, const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child'
type: 'user', const meta = PEER_META[peerKind]
content: '给我讲个故事吧',
time: '周一 20:00' return {
}, id: item.conversation_id,
{ key: `im-${item.conversation_id}`,
id: 2, source: 'im',
type: 'ai', peerKind,
content: '从前有一只勇敢的小兔子...', name: isParentConversation ? '家长沟通' : getPeerDisplayName(peerKind, item.peer_id, item.peer_name),
time: '周一 20:01' avatar: meta.icon,
} typeLabel: isParentConversation ? '家长沟通' : meta.label,
], description: isParentConversation ? '家长通过微信小程序和孩子直接沟通' : meta.description,
5: [ lastMessage: item.last_message_preview || (isParentConversation ? '还没有消息,点进去发送第一条' : '暂无消息'),
{ time: formatListTime(item.last_message_at),
id: 1, sortAt: item.last_message_at || '',
type: 'user', conversationTypeName: item.conversation_type_name,
content: '苹果用英语怎么说?', peerId: item.peer_id,
time: '周一 18:00' childId: context.childId,
}, childName: context.childName,
{ parentUserId: isParentConversation ? parseNumericId(item.peer_id) : null,
id: 2, deviceId: context.deviceId,
type: 'ai', channelLabel: isParentConversation ? '微信小程序' : '设备端',
content: 'Apple, A-P-P-L-E, apple!', canSend: isParentConversation,
time: '周一 18:01' }
}
function toAiConversation(item: DeviceAiMessageItem, context: BindingContext): ChatConversation {
const meta = getAiMeta(item.role_key)
return {
id: item.conversation_id,
key: `ai-${item.conversation_id}`,
source: 'ai',
peerKind: 'ai',
name: meta.name,
avatar: meta.icon,
typeLabel: PEER_META.ai.label,
description: meta.description,
lastMessage: item.content || '暂无消息',
time: formatListTime(item.created_at),
sortAt: item.created_at,
roleKey: item.role_key,
conversationTypeName: 'ai',
peerId: item.role_key,
childId: context.childId,
childName: context.childName,
parentUserId: null,
deviceId: context.deviceId,
channelLabel: 'AI',
canSend: false,
}
}
function createSyntheticParentConversation(context: BindingContext): ChatConversation | null {
if (!context.childId || !context.currentUserId) return null
return {
id: 0,
key: `im-parent-child-${context.childId}-${context.currentUserId}`,
source: 'im',
peerKind: 'parent',
name: '家长沟通',
avatar: PEER_META.parent.icon,
typeLabel: '家长沟通',
description: '家长通过微信小程序和孩子直接沟通',
lastMessage: '还没有消息,点进去发送第一条',
time: '',
sortAt: '',
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
peerId: String(context.currentUserId),
childId: context.childId,
childName: context.childName,
parentUserId: context.currentUserId,
deviceId: context.deviceId,
channelLabel: '微信小程序',
canSend: true,
isSynthetic: true,
}
}
export async function getConversations(): Promise<ChatConversation[]> {
const context = await getBindingContext()
const [imConversationItems, aiMessages] = await Promise.all([getChildConversations(context), getDeviceMessages(context)])
const imConversations = imConversationItems.map((item) => toImConversation(item, context))
const aiConversationMap = new Map<number, DeviceAiMessageItem>()
for (const item of aiMessages) {
if (!aiConversationMap.has(item.conversation_id)) {
aiConversationMap.set(item.conversation_id, item)
} }
}
const conversations = [
...imConversations,
...Array.from(aiConversationMap.values()).map((item) => toAiConversation(item, context)),
] ]
}
// Mock 数据 - 角色信息 const hasParentConversation = conversations.some(
const mockCharacters: Record<number, Character> = { (item) =>
1: { id: 1, name: '阳光知心伴伴', description: '温柔音色 / 开朗耐心', icon: '🎤' }, isParentChildConversation(item.conversationTypeName) &&
2: { id: 2, name: '智慧小博士', description: '博学多才 / 爱讲故事', icon: '📚' }, item.parentUserId === context.currentUserId
3: { id: 3, name: '音乐小精灵', description: '歌声甜美 / 会唱儿歌', icon: '🎵' }, )
4: { id: 4, name: '故事大王', description: '故事丰富 / 生动有趣', icon: '📖' },
5: { id: 5, name: '英语小老师', description: '英语启蒙 / 发音标准', icon: '🔤' }
}
/** if (!hasParentConversation) {
* 获取会话列表 const syntheticConversation = createSyntheticParentConversation(context)
*/ if (syntheticConversation) {
export function getConversations(): Promise<ChatConversation[]> { conversations.push(syntheticConversation)
return new Promise((resolve) => { }
setTimeout(() => { }
resolve([...mockConversations])
}, 300) return conversations.sort((left, right) => {
const rankDiff = getConversationRank(left) - getConversationRank(right)
if (rankDiff !== 0) return rankDiff
return parseTime(right.sortAt) - parseTime(left.sortAt)
}) })
} }
/** export async function getMessages(
* 获取会话消息列表 conversationId: number,
* @param conversationId 会话ID source: ConversationSource,
*/ conversationTypeName?: string
export function getMessages(conversationId: number): Promise<ChatMessage[]> { ): Promise<ChatMessage[]> {
return new Promise((resolve) => { const context = await getBindingContext()
setTimeout(() => {
resolve(mockMessages[conversationId] || []) if (source === 'ai') {
}, 300) const items = await getDeviceMessages(context)
}) return items
.filter((item) => item.conversation_id === conversationId)
.slice()
.reverse()
.map((item) => ({
id: item.id,
type: item.is_user ? 'user' : 'peer',
content: item.content || '暂无内容',
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
senderType: item.is_user ? 'device' : 'ai',
senderId: item.is_user ? context.deviceId : item.role_key,
receiverType: item.is_user ? 'ai' : 'device',
receiverId: item.is_user ? item.role_key : context.deviceId,
senderName: item.is_user ? context.childName || '孩子设备' : getAiMeta(item.role_key).name,
receiverName: item.is_user ? getAiMeta(item.role_key).name : context.childName || '孩子设备',
channelLabel: 'AI',
}))
}
const items = await getChildConversationMessages(conversationId, context)
const isParentConversation = isParentChildConversation(conversationTypeName)
return items.map((item) => ({
id: item.id,
type: isParentConversation
? item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? 'user'
: 'peer'
: item.sender_type === 'child' && item.sender_id === String(context.childId || '')
? 'user'
: 'peer',
content: formatImPreview(item),
time: formatDetailTime(item.created_at),
conversationId: item.conversation_id,
senderType: item.sender_type,
senderId: item.sender_id,
receiverType: item.receiver_type,
receiverId: item.receiver_id,
senderName:
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? '我'
: item.sender_type === 'child' && item.sender_id === String(context.childId || '')
? context.childName || item.sender_name_snapshot || '孩子'
: getParticipantDisplayName(item.sender_type, item.sender_id, item.sender_name_snapshot),
receiverName:
item.receiver_type === 'parent' && item.receiver_id === String(context.currentUserId)
? '我'
: item.receiver_type === 'child' && item.receiver_id === String(context.childId || '')
? context.childName || item.receiver_name_snapshot || '孩子'
: getParticipantDisplayName(item.receiver_type, item.receiver_id, item.receiver_name_snapshot),
channelLabel:
isParentConversation && (item.sender_type === 'parent' || item.receiver_type === 'parent')
? '微信小程序'
: '设备端',
}))
} }
/** export async function getCharacter(options: {
* 获取角色信息 conversationId: number
* @param characterId 角色ID source: ConversationSource
*/ peerKind?: PeerKind
export function getCharacter(characterId: number): Promise<Character | null> { name?: string
return new Promise((resolve) => { roleKey?: string
setTimeout(() => { conversationTypeName?: string
resolve(mockCharacters[characterId] || null) childId?: number | null
}, 300) childName?: string
peerId?: string
parentUserId?: number | null
deviceId?: string
channelLabel?: string
canSend?: boolean
}): Promise<Character | null> {
const {
conversationId,
source,
peerKind = 'ai',
name,
roleKey,
conversationTypeName,
childId,
childName,
peerId,
parentUserId,
deviceId,
channelLabel,
canSend = false,
} = options
if (source === 'ai') {
const meta = getAiMeta(roleKey)
return {
id: conversationId,
name: name || meta.name,
description: 'AI 对话通道',
icon: meta.icon,
peerKind: 'ai',
source,
conversationTypeName: 'ai',
childId,
childName,
peerId: peerId || roleKey,
parentUserId: null,
deviceId,
channelLabel: channelLabel || 'AI',
canSend: false,
}
}
if (isParentChildConversation(conversationTypeName)) {
return {
id: conversationId,
name: '家长沟通',
description: '微信小程序 <-> 儿童设备',
icon: PEER_META.parent.icon,
peerKind: 'parent',
source,
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
childId,
childName,
peerId,
parentUserId: parentUserId || parseNumericId(peerId),
deviceId,
channelLabel: channelLabel || '微信小程序',
canSend: canSend || true,
}
}
const meta = PEER_META[peerKind]
return {
id: conversationId,
name: getPeerDisplayName(peerKind, peerId, name),
description: peerKind === 'child' ? '儿童设备间的聊天记录' : meta.description,
icon: meta.icon,
peerKind,
source,
conversationTypeName,
childId,
childName,
peerId,
parentUserId,
deviceId,
channelLabel: channelLabel || '设备端',
canSend,
}
}
export async function sendParentMessage(childId: number, content: string): Promise<ConversationMessageCreateResponse> {
const normalizedContent = content.trim()
if (!normalizedContent) {
throw new Error('请输入消息内容')
}
return request<ConversationMessageCreateResponse>(`/children/${childId}/messages`, {
method: 'POST',
data: {
content_type: 1,
content_text: normalizedContent,
client_msg_id: createClientMessageId(),
},
}) })
} }