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 {
display: flex; min-height: 320px;
align-items: center;
.audio-icon {
width: 48px;
height: 48px;
background: rgba(255, 255, 255, 0.3);
border-radius: 50%;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
font-size: 20px;
color: inherit;
margin-right: 14px;
flex-shrink: 0;
transition: background 0.2s;
&.playing {
background: rgba(255, 255, 255, 0.6);
} }
&.orange { .empty-chat-text {
background: rgba(255, 140, 66, 0.2);
&.playing {
background: rgba(255, 140, 66, 0.4);
}
}
}
.audio-info {
display: flex;
flex-direction: column;
.audio-title {
font-size: 28px; font-size: 28px;
font-weight: 500; color: #999999;
color: inherit;
margin-bottom: 4px;
} }
.audio-duration { .composer {
font-size: 24px; background: #FFFFFF;
opacity: 0.8; border-top: 1px solid #EEEEEE;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
display: flex;
align-items: center;
gap: 16px;
} }
.composer-input {
flex: 1;
min-height: 76px;
background: #F5F7FA;
border-radius: 38px;
padding: 0 24px;
font-size: 28px;
color: #1A1A1A;
}
.composer-send {
min-width: 132px;
height: 76px;
border-radius: 38px;
background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
&.disabled {
opacity: 0.55;
} }
} }
.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()
}
}, [conversationId])
const loadChatData = async () => { const loadChatData = async (conversationId: number) => {
const char = await getCharacter(conversationId) 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) { if (char) {
setCharacter(char) setCharacter(char)
Taro.setNavigationBarTitle({ title: char.name }) Taro.setNavigationBarTitle({ title: char.name })
} }
const msgs = await getMessages(conversationId) const msgs = await getMessages(conversationId, conversationSource, decodedConversationTypeName)
setMessages(msgs) setMessages(msgs)
} } catch (error: any) {
console.error('[chat-detail] load failed:', error)
const handlePlayAudio = (msg: ChatMessage) => { Taro.showToast({
if (!msg.audioUrl) return title: error?.message || '加载失败,请稍后重试',
icon: 'none',
// 如果点的是正在播放的那条,就停止
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(() => { } finally {
setPlayingId(null) setLoading(false)
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,11 +174,21 @@ 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 className='empty-chat'>
<Text className='empty-chat-text'>...</Text>
</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}> <View key={msg.id}>
{(index === 0 || messages[index - 1].type !== msg.type || {(index === 0 || messages[index - 1].time !== msg.time) && (
messages[index - 1].time !== msg.time) && (
<View className='time-divider'> <View className='time-divider'>
<Text>{msg.time}</Text> <Text>{msg.time}</Text>
</View> </View>
@@ -107,50 +196,50 @@ export default function ChatDetail() {
{msg.type === 'user' ? ( {msg.type === 'user' ? (
<View className='message-item user'> <View className='message-item user'>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'> <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> <Text>{msg.content}</Text>
)} </View>
</View> </View>
<View className='user-avatar'> <View className='user-avatar'>
<Text className='avatar-img'>👦</Text> <Text className='avatar-img'>{msg.senderType === 'parent' ? '👨' : '👦'}</Text>
</View> </View>
</View> </View>
) : ( ) : (
<View className='message-item ai'> <View className='message-item peer'>
<View className='ai-avatar'> <View className='peer-avatar'>
<Text className='robot-emoji'>{character.icon}</Text> <Text className='robot-emoji'>{msg.senderType === 'child' ? '🧒' : character.icon}</Text>
</View> </View>
<View className='message-main'>
<Text className='message-meta'>{formatParticipantLabel(msg)}</Text>
<View className='message-bubble'> <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> <Text>{msg.content}</Text>
)} </View>
</View> </View>
</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,38 +1,77 @@
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 () => {
if (!getToken()) {
Taro.reLaunch({ url: '/pages/login/index' })
return
}
setLoading(true)
try {
const data = await getConversations() const data = await getConversations()
setConversations(data) 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>
{loading ? (
<View className='loading'>
<Text>...</Text>
</View>
) : conversations.length === 0 ? (
<View className='empty-card'>
<Text className='empty-title'></Text>
</View>
) : (
<ScrollView className='conversation-list' scrollY> <ScrollView className='conversation-list' scrollY>
{conversations.map((conversation) => ( {conversations.map((conversation) => (
<View <View
key={conversation.id} key={conversation.key}
className='conversation-item' className='conversation-item'
onClick={() => handleConversationClick(conversation)} onClick={() => handleConversationClick(conversation)}
> >
@@ -41,23 +80,23 @@ export default function Chat() {
</View> </View>
<View className='conversation-content'> <View className='conversation-content'>
<View className='conversation-header'> <View className='conversation-header'>
<View className='conversation-name-wrap'>
<Text className='conversation-name'>{conversation.name}</Text> <Text className='conversation-name'>{conversation.name}</Text>
<Text className={`conversation-tag ${conversation.peerKind}`}>{conversation.typeLabel}</Text>
</View>
<Text className='conversation-time'>{conversation.time}</Text> <Text className='conversation-time'>{conversation.time}</Text>
</View> </View>
<View className='conversation-footer'> <View className='conversation-footer'>
<Text className='last-message' numberOfLines={1}> <Text className='last-message' numberOfLines={1}>
{conversation.lastMessage} {conversation.lastMessage}
</Text> </Text>
{conversation.unreadCount && conversation.unreadCount > 0 && (
<View className='unread-badge'>
<Text className='unread-count'>{conversation.unreadCount}</Text>
</View>
)}
</View> </View>
{conversation.childName && <Text className='conversation-meta'>{conversation.childName}</Text>}
</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,
name: '智慧小博士',
avatar: '📚',
lastMessage: '好的,我来给你讲一个关于恐龙的故事...',
time: '昨天'
},
{
id: 3,
name: '音乐小精灵',
avatar: '🎵',
lastMessage: '《拔萝卜》已经唱完啦,还要听别的吗?',
time: '昨天'
},
{
id: 4,
name: '故事大王',
avatar: '📖',
lastMessage: '从前有一只勇敢的小兔子...',
time: '周一'
},
{
id: 5,
name: '英语小老师',
avatar: '🔤',
lastMessage: 'Apple, A-P-P-L-E, apple!',
time: '周一'
} }
interface DeviceAiMessageListResponse {
items: DeviceAiMessageItem[]
total: number
next_cursor?: number | null
}
interface ChildConversationItem {
conversation_id: number
conversation_type: number
conversation_type_name: string
peer_type: 'child' | 'parent'
peer_id: string
peer_name?: string | null
last_message_preview?: string | null
last_message_at?: string | null
message_count: number
}
interface ChildConversationListResponse {
items: ChildConversationItem[]
total: number
next_cursor?: number | null
}
interface ChildConversationMessageItem {
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()}`
}
function formatDetailTime(value?: string | null): string {
if (!value) return ''
const date = new Date(value)
if (Number.isNaN(date.getTime())) return value
const month = `${date.getMonth() + 1}`.padStart(2, '0')
const day = `${date.getDate()}`.padStart(2, '0')
const hour = `${date.getHours()}`.padStart(2, '0')
const minute = `${date.getMinutes()}`.padStart(2, '0')
return `${month}-${day} ${hour}:${minute}`
}
function formatImPreview(
item: Pick<ChildConversationMessageItem, 'content_type' | 'content_text' | 'media_transcript_text' | 'content_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 === 3) return '[图片]'
if (item.content_type === 4) {
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
return title ? `[卡片] ${title}` : '[卡片]'
}
return '[消息]'
}
function createClientMessageId(): string {
return `parent-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
}
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)
}
}
return {
deviceId: binding?.device_id || '',
childId: binding?.child_id || null,
childName,
currentUserId: getCurrentUserId(),
}
}
async function getDeviceMessages(context?: BindingContext): Promise<DeviceAiMessageItem[]> {
const { deviceId } = context || (await getBindingContext())
if (!deviceId) return []
try {
const response = await request<DeviceAiMessageListResponse>(`/devices/${deviceId}/messages?limit=100`)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getChildConversations(context?: BindingContext): Promise<ChildConversationItem[]> {
const { childId } = context || (await getBindingContext())
if (!childId) return []
try {
const response = await request<ChildConversationListResponse>(`/children/${childId}/conversations?limit=100`)
return response.items || []
} catch (error: any) {
if (error?.status === 404) return []
throw error
}
}
async function getChildConversationMessages(
conversationId: number,
context?: BindingContext
): Promise<ChildConversationMessageItem[]> {
const { childId } = context || (await getBindingContext())
if (!childId || conversationId <= 0) return []
const response = await request<ChildConversationMessageListResponse>(
`/children/${childId}/conversations/${conversationId}/messages?limit=100`
)
return response.items || []
}
function toImConversation(item: ChildConversationItem, context: BindingContext): ChatConversation {
const isParentConversation = isParentChildConversation(item.conversation_type_name)
const peerKind: PeerKind = item.peer_type === 'parent' ? 'parent' : 'child'
const meta = PEER_META[peerKind]
return {
id: item.conversation_id,
key: `im-${item.conversation_id}`,
source: 'im',
peerKind,
name: isParentConversation ? '家长沟通' : 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 ? '还没有消息,点进去发送第一条' : '暂无消息'),
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,
deviceId: context.deviceId,
channelLabel: isParentConversation ? '微信小程序' : '设备端',
canSend: isParentConversation,
}
}
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 mockMessages: Record<number, ChatMessage[]> = { (item) =>
1: [ isParentChildConversation(item.conversationTypeName) &&
{ item.parentUserId === context.currentUserId
id: 1, )
type: 'user',
content: '天空为什么是蓝色的呀?', if (!hasParentConversation) {
time: '今天 14:30' const syntheticConversation = createSyntheticParentConversation(context)
}, if (syntheticConversation) {
{ conversations.push(syntheticConversation)
id: 2,
type: 'ai',
content: '因为太阳光里面其实藏着七种颜色呀。当光穿过大气层的时候,蓝色的光最调皮,到处乱跑,所以我们看到的天空就是蓝色的啦!',
time: '今天 14:31'
},
{
id: 3,
type: 'user',
content: '给我唱一首拔萝卜吧!',
time: '今天 15:15'
},
{
id: 4,
type: 'ai',
content: '',
time: '今天 15:15',
isAudio: true,
audioDuration: '01:45'
} }
],
2: [
{
id: 1,
type: 'user',
content: '你知道恐龙吗?',
time: '昨天 10:30'
},
{
id: 2,
type: 'ai',
content: '当然知道!恐龙是生活在很久很久以前的大型爬行动物,有的恐龙比房子还要大呢!',
time: '昨天 10:31'
},
{
id: 3,
type: 'user',
content: '那给我讲一个恐龙的故事吧',
time: '昨天 10:32'
},
{
id: 4,
type: 'ai',
content: '好的,我来给你讲一个关于恐龙的故事...',
time: '昨天 10:33'
}
],
3: [
{
id: 1,
type: 'user',
content: '我想听拔萝卜',
time: '昨天 16:00'
},
{
id: 2,
type: 'ai',
content: '',
time: '昨天 16:01',
isAudio: true,
audioDuration: '02:30'
},
{
id: 3,
type: 'ai',
content: '《拔萝卜》已经唱完啦,还要听别的吗?',
time: '昨天 16:04'
}
],
4: [
{
id: 1,
type: 'user',
content: '给我讲个故事吧',
time: '周一 20:00'
},
{
id: 2,
type: 'ai',
content: '从前有一只勇敢的小兔子...',
time: '周一 20:01'
}
],
5: [
{
id: 1,
type: 'user',
content: '苹果用英语怎么说?',
time: '周一 18:00'
},
{
id: 2,
type: 'ai',
content: 'Apple, A-P-P-L-E, apple!',
time: '周一 18:01'
}
]
} }
// Mock 数据 - 角色信息 return conversations.sort((left, right) => {
const mockCharacters: Record<number, Character> = { const rankDiff = getConversationRank(left) - getConversationRank(right)
1: { id: 1, name: '阳光知心伴伴', description: '温柔音色 / 开朗耐心', icon: '🎤' }, if (rankDiff !== 0) return rankDiff
2: { id: 2, name: '智慧小博士', description: '博学多才 / 爱讲故事', icon: '📚' }, return parseTime(right.sortAt) - parseTime(left.sortAt)
3: { id: 3, name: '音乐小精灵', description: '歌声甜美 / 会唱儿歌', icon: '🎵' },
4: { id: 4, name: '故事大王', description: '故事丰富 / 生动有趣', icon: '📖' },
5: { id: 5, name: '英语小老师', description: '英语启蒙 / 发音标准', icon: '🔤' }
}
/**
* 获取会话列表
*/
export function getConversations(): Promise<ChatConversation[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([...mockConversations])
}, 300)
}) })
} }
/** 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)
* @param characterId 角色ID
*/ return items.map((item) => ({
export function getCharacter(characterId: number): Promise<Character | null> { id: item.id,
return new Promise((resolve) => { type: isParentConversation
setTimeout(() => { ? item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
resolve(mockCharacters[characterId] || null) ? 'user'
}, 300) : '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
source: ConversationSource
peerKind?: PeerKind
name?: string
roleKey?: string
conversationTypeName?: string
childId?: number | null
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(),
},
}) })
} }