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;
overflow-y: auto;
padding: 24px;
box-sizing: border-box;
}
.time-divider {
@@ -92,16 +93,33 @@
&.user {
flex-direction: row-reverse;
.message-main {
align-items: flex-end;
}
}
&.ai {
.ai-avatar {
&.peer {
.peer-avatar {
margin-right: 14px;
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 {
width: 68px;
height: 68px;
@@ -117,7 +135,7 @@
}
}
.ai-avatar {
.peer-avatar {
width: 68px;
height: 68px;
background: #FEF3E8;
@@ -132,7 +150,7 @@
}
.message-bubble {
max-width: 68%;
max-width: 100%;
padding: 18px 22px;
font-size: 30px;
line-height: 1.5;
@@ -148,7 +166,7 @@
margin-right: 14px;
}
.ai & {
.peer & {
background: #FFFFFF;
color: #1A1A1A;
border-radius: 20px 20px 20px 6px;
@@ -156,51 +174,53 @@
}
}
.audio-message {
.empty-chat {
min-height: 320px;
display: flex;
align-items: center;
justify-content: center;
}
.audio-icon {
width: 48px;
height: 48px;
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;
.empty-chat-text {
font-size: 28px;
color: #999999;
}
&.playing {
background: rgba(255, 255, 255, 0.6);
}
.composer {
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 {
background: rgba(255, 140, 66, 0.2);
&.playing {
background: rgba(255, 140, 66, 0.4);
}
}
}
.composer-input {
flex: 1;
min-height: 76px;
background: #F5F7FA;
border-radius: 38px;
padding: 0 24px;
font-size: 28px;
color: #1A1A1A;
}
.audio-info {
display: flex;
flex-direction: column;
.composer-send {
min-width: 132px;
height: 76px;
border-radius: 38px;
background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
.audio-title {
font-size: 28px;
font-weight: 500;
color: inherit;
margin-bottom: 4px;
}
.audio-duration {
font-size: 24px;
opacity: 0.8;
}
&.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 { useState, useEffect, useRef } from 'react'
import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
import { useEffect, useState } from 'react'
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'
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() {
const router = useRouter()
const { id } = router.params
const conversationId = parseInt(id || '1')
const params = router.params
const initialConversationId = normalizeNumericId(params.id) || 0
const conversationSource = normalizeSource(params.source)
const conversationPeerKind = normalizePeerKind(params.peerKind)
const decodedRoleKey = decodeURIComponent(params.roleKey || '')
const decodedName = decodeURIComponent(params.name || '玩伴')
const decodedConversationTypeName = decodeURIComponent(params.conversationTypeName || '')
const decodedPeerId = decodeURIComponent(params.peerId || '')
const decodedDeviceId = decodeURIComponent(params.deviceId || '')
const decodedChildName = decodeURIComponent(params.childName || '')
const childId = normalizeNumericId(params.childId)
const parentUserId = normalizeNumericId(params.parentUserId)
const canSend = params.canSend === '1'
const [activeConversationId, setActiveConversationId] = useState(initialConversationId)
const [character, setCharacter] = useState<Character>({
id: conversationId,
name: decodeURIComponent(router.params.name || 'AI 玩伴'),
description: '温柔音色 / 开朗耐心',
icon: '🎤'
id: initialConversationId,
name: decodedName,
description: '交流记录',
icon: '🤖',
source: conversationSource,
peerKind: conversationPeerKind,
conversationTypeName: decodedConversationTypeName,
childId,
childName: decodedChildName,
peerId: decodedPeerId,
parentUserId,
deviceId: decodedDeviceId,
channelLabel: '--',
canSend,
})
const [messages, setMessages] = useState<ChatMessage[]>([])
const [playingId, setPlayingId] = useState<number | null>(null)
const audioCtx = useRef<Taro.InnerAudioContext | null>(null)
const [loading, setLoading] = useState(true)
const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
useEffect(() => {
loadChatData()
return () => {
// 页面卸载时销毁音频
audioCtx.current?.destroy()
void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind])
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 = () => {
audioCtx.current?.stop()
audioCtx.current?.destroy()
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 (
<View className='chat-detail-page'>
<View className='chat-header'>
@@ -95,62 +174,72 @@ export default function ChatDetail() {
</View>
</View>
<ScrollView className='chat-list' scrollY>
{messages.map((msg, index) => (
<View key={msg.id}>
{(index === 0 || messages[index - 1].type !== msg.type ||
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>
)}
<ScrollView className='chat-list' scrollY scrollWithAnimation>
{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}>
{(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>
{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 File

@@ -14,15 +14,8 @@
font-weight: 700;
color: #1A1A1A;
display: block;
margin-bottom: 12px;
line-height: 1.2;
}
.page-subtitle {
font-size: 28px;
color: #666666;
line-height: 1.4;
}
}
.conversation-list {
@@ -74,19 +67,49 @@
.conversation-header {
display: flex;
justify-content: space-between;
align-items: center;
align-items: flex-start;
margin-bottom: 12px;
.conversation-name {
font-size: 32px;
font-weight: 600;
color: #1A1A1A;
}
.conversation-time {
font-size: 24px;
color: #999999;
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 {
min-width: 36px;
height: 36px;
@@ -123,3 +153,31 @@
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 { useState, useEffect } from 'react'
import Taro from '@tarojs/taro'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { getConversations, ChatConversation } from '../../services/chat'
import './index.scss'
export default function Chat() {
const [conversations, setConversations] = useState<ChatConversation[]>([])
const [loading, setLoading] = useState(true)
useEffect(() => {
loadConversations()
}, [])
useDidShow(() => {
void loadConversations()
})
const loadConversations = async () => {
const data = await getConversations()
setConversations(data)
if (!getToken()) {
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) => {
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 (
<View className='chat-page'>
<View className='page-header'>
<Text className='page-title'>AI </Text>
<Text className='page-subtitle'></Text>
<Text className='page-title'></Text>
</View>
<ScrollView className='conversation-list' scrollY>
{conversations.map((conversation) => (
<View
key={conversation.id}
className='conversation-item'
onClick={() => handleConversationClick(conversation)}
>
<View className='conversation-avatar'>
<Text className='avatar-icon'>{conversation.avatar}</Text>
</View>
<View className='conversation-content'>
<View className='conversation-header'>
<Text className='conversation-name'>{conversation.name}</Text>
<Text className='conversation-time'>{conversation.time}</Text>
{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>
{conversations.map((conversation) => (
<View
key={conversation.key}
className='conversation-item'
onClick={() => handleConversationClick(conversation)}
>
<View className='conversation-avatar'>
<Text className='avatar-icon'>{conversation.avatar}</Text>
</View>
<View className='conversation-footer'>
<Text className='last-message' numberOfLines={1}>
{conversation.lastMessage}
</Text>
{conversation.unreadCount && conversation.unreadCount > 0 && (
<View className='unread-badge'>
<Text className='unread-count'>{conversation.unreadCount}</Text>
<View className='conversation-content'>
<View className='conversation-header'>
<View className='conversation-name-wrap'>
<Text className='conversation-name'>{conversation.name}</Text>
<Text className={`conversation-tag ${conversation.peerKind}`}>{conversation.typeLabel}</Text>
</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>
))}
</ScrollView>
))}
</ScrollView>
)}
</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 {
id: number
key: string
source: ConversationSource
peerKind: PeerKind
name: string
avatar: string
typeLabel: string
description: string
lastMessage: 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 {
id: number
type: 'user' | 'ai'
type: 'user' | 'peer'
content: string
time: string
isAudio?: boolean
audioDuration?: string
conversationId: number
senderType?: string
senderId?: string
receiverType?: string
receiverId?: string
senderName?: string
receiverName?: string
channelLabel?: string
}
export interface Character {
@@ -23,196 +52,540 @@ export interface Character {
name: string
description: 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 数据 - 会话列表
const mockConversations: ChatConversation[] = [
{
id: 1,
name: '阳光知心伴伴',
avatar: '🎤',
lastMessage: '因为太阳光里面其实藏着七种颜色呀...',
time: '14:31',
unreadCount: 2
},
{
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 DeviceAiMessageItem {
id: number
conversation_id: number
role_key: string
is_user: boolean
speaker: 'user' | 'assistant'
content: string
timestamp: number
created_at: string
}
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()}`
}
// Mock 数据 - 消息列表
const mockMessages: Record<number, ChatMessage[]> = {
1: [
{
id: 1,
type: 'user',
content: '天空为什么是蓝色的呀?',
time: '今天 14:30'
},
{
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'
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)
}
],
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'
}
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 mockCharacters: Record<number, Character> = {
1: { id: 1, name: '阳光知心伴伴', description: '温柔音色 / 开朗耐心', icon: '🎤' },
2: { id: 2, name: '智慧小博士', description: '博学多才 / 爱讲故事', icon: '📚' },
3: { id: 3, name: '音乐小精灵', description: '歌声甜美 / 会唱儿歌', icon: '🎵' },
4: { id: 4, name: '故事大王', description: '故事丰富 / 生动有趣', icon: '📖' },
5: { id: 5, name: '英语小老师', description: '英语启蒙 / 发音标准', icon: '🔤' }
}
const hasParentConversation = conversations.some(
(item) =>
isParentChildConversation(item.conversationTypeName) &&
item.parentUserId === context.currentUserId
)
/**
* 获取会话列表
*/
export function getConversations(): Promise<ChatConversation[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve([...mockConversations])
}, 300)
if (!hasParentConversation) {
const syntheticConversation = createSyntheticParentConversation(context)
if (syntheticConversation) {
conversations.push(syntheticConversation)
}
}
return conversations.sort((left, right) => {
const rankDiff = getConversationRank(left) - getConversationRank(right)
if (rankDiff !== 0) return rankDiff
return parseTime(right.sortAt) - parseTime(left.sortAt)
})
}
/**
* 获取会话消息列表
* @param conversationId 会话ID
*/
export function getMessages(conversationId: number): Promise<ChatMessage[]> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(mockMessages[conversationId] || [])
}, 300)
})
export async function getMessages(
conversationId: number,
source: ConversationSource,
conversationTypeName?: string
): Promise<ChatMessage[]> {
const context = await getBindingContext()
if (source === 'ai') {
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')
? '微信小程序'
: '设备端',
}))
}
/**
* 获取角色信息
* @param characterId 角色ID
*/
export function getCharacter(characterId: number): Promise<Character | null> {
return new Promise((resolve) => {
setTimeout(() => {
resolve(mockCharacters[characterId] || null)
}, 300)
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(),
},
})
}