diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index 13588a2..d10d348 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -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; +} diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index bd58efd..9e8d4f5 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -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({ - 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([]) - const [playingId, setPlayingId] = useState(null) - const audioCtx = useRef(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 ( @@ -95,62 +174,72 @@ export default function ChatDetail() { - - {messages.map((msg, index) => ( - - {(index === 0 || messages[index - 1].type !== msg.type || - messages[index - 1].time !== msg.time) && ( - - {msg.time} - - )} - - {msg.type === 'user' ? ( - - - {msg.isAudio ? ( - handlePlayAudio(msg)}> - - {playingId === msg.id ? '⏸' : '▶'} - - - 录音文件 - {msg.audioDuration} - - - ) : ( - {msg.content} - )} - - - 👦 - - - ) : ( - - - {character.icon} - - - {msg.isAudio ? ( - handlePlayAudio(msg)}> - - {playingId === msg.id ? '⏸' : '▶'} - - - 录音文件 - {msg.audioDuration} - - - ) : ( - {msg.content} - )} - - - )} + + {loading ? ( + + 加载中... - ))} + ) : messages.length === 0 ? ( + + + {character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'} + + + ) : ( + messages.map((msg, index) => ( + + {(index === 0 || messages[index - 1].time !== msg.time) && ( + + {msg.time} + + )} + + {msg.type === 'user' ? ( + + + {formatParticipantLabel(msg)} + + {msg.content} + + + + {msg.senderType === 'parent' ? '👨' : '👦'} + + + ) : ( + + + {msg.senderType === 'child' ? '🧒' : character.icon} + + + {formatParticipantLabel(msg)} + + {msg.content} + + + + )} + + )) + )} + + {character.canSend && character.childId && ( + + setInputText(event.detail.value)} + onConfirm={handleSend} + /> + + {sending ? '发送中' : '发送'} + + + )} ) } diff --git a/banban-mini/src/pages/chat/index.scss b/banban-mini/src/pages/chat/index.scss index 78e3a17..663e425 100644 --- a/banban-mini/src/pages/chat/index.scss +++ b/banban-mini/src/pages/chat/index.scss @@ -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; +} diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index 2565f93..a93fd6a 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -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([]) + 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 ( - AI 玩伴 - 选择玩伴开始对话 + 玩伴 - - {conversations.map((conversation) => ( - handleConversationClick(conversation)} - > - - {conversation.avatar} - - - - {conversation.name} - {conversation.time} + {loading ? ( + + 加载中... + + ) : conversations.length === 0 ? ( + + 暂无玩伴会话 + + ) : ( + + {conversations.map((conversation) => ( + handleConversationClick(conversation)} + > + + {conversation.avatar} - - - {conversation.lastMessage} - - {conversation.unreadCount && conversation.unreadCount > 0 && ( - - {conversation.unreadCount} + + + + {conversation.name} + {conversation.typeLabel} - )} + {conversation.time} + + + + {conversation.lastMessage} + + + {conversation.childName && {conversation.childName}} - - ))} - + ))} + + )} ) } diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index cc7b1e2..b5b8505 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -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 | 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 | 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 = { + child: { icon: '🧒', label: '儿童', description: '孩子正在交流的儿童玩伴' }, + parent: { icon: '👨', label: '家长', description: '家长通过微信小程序和孩子沟通' }, + ai: { icon: '🤖', label: 'AI', description: '孩子与 AI 的历史交流' }, +} + +const AI_ROLE_META: Record = { + 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 = { - 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 +): 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 { + 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 { + const { deviceId } = context || (await getBindingContext()) + if (!deviceId) return [] + + try { + const response = await request(`/devices/${deviceId}/messages?limit=100`) + return response.items || [] + } catch (error: any) { + if (error?.status === 404) return [] + throw error + } +} + +async function getChildConversations(context?: BindingContext): Promise { + const { childId } = context || (await getBindingContext()) + if (!childId) return [] + + try { + const response = await request(`/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 { + const { childId } = context || (await getBindingContext()) + if (!childId || conversationId <= 0) return [] + + const response = await request( + `/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 { + 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() + + 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 = { - 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 { - 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 { - return new Promise((resolve) => { - setTimeout(() => { - resolve(mockMessages[conversationId] || []) - }, 300) - }) +export async function getMessages( + conversationId: number, + source: ConversationSource, + conversationTypeName?: string +): Promise { + 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 { - 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 { + 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 { + const normalizedContent = content.trim() + if (!normalizedContent) { + throw new Error('请输入消息内容') + } + + return request(`/children/${childId}/messages`, { + method: 'POST', + data: { + content_type: 1, + content_text: normalizedContent, + client_msg_id: createClientMessageId(), + }, }) }