diff --git a/banban-mini/config/dev.js b/banban-mini/config/dev.js index 7dcc8a3..747dcc5 100644 --- a/banban-mini/config/dev.js +++ b/banban-mini/config/dev.js @@ -5,7 +5,7 @@ module.exports = { defineConstants: { __APP_ENV__: '"development"', // Update this to your LAN backend when testing on a real device. - __API_BASE_URL__: '"http://192.168.101.86:8001"', + __API_BASE_URL__: '"https://banban.api.talkingq.com"', }, mini: {}, h5: {} diff --git a/banban-mini/config/prod.js b/banban-mini/config/prod.js index a0664a7..8a8c429 100644 --- a/banban-mini/config/prod.js +++ b/banban-mini/config/prod.js @@ -5,7 +5,7 @@ module.exports = { defineConstants: { __APP_ENV__: '"production"', // Replace with the production backend before release builds. - __API_BASE_URL__: '"http://192.168.101.86:8001"', + __API_BASE_URL__: '"https://banban.api.talkingq.com"', }, mini: {}, h5: {} diff --git a/banban-mini/src/config/env.ts b/banban-mini/src/config/env.ts index 514bce5..78b32df 100644 --- a/banban-mini/src/config/env.ts +++ b/banban-mini/src/config/env.ts @@ -8,7 +8,7 @@ export interface AppConfig { export const APP_CONFIG: AppConfig = { appEnv: typeof __APP_ENV__ === 'undefined' ? 'development' : __APP_ENV__, - apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'http://127.0.0.1:8001' : __API_BASE_URL__, + apiBaseUrl: typeof __API_BASE_URL__ === 'undefined' ? 'https://banban.api.talkingq.com' : __API_BASE_URL__, } export const API_BASE_URL = APP_CONFIG.apiBaseUrl diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss index 007b012..d9cb7c9 100644 --- a/banban-mini/src/pages/bind/index.scss +++ b/banban-mini/src/pages/bind/index.scss @@ -218,6 +218,13 @@ color: #D46B08; } +.pending-tip-subtext { + display: block; + margin-top: 8px; + font-size: 24px; + color: #8C5400; +} + .child-list { display: flex; flex-wrap: wrap; diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index aaf8d6f..8abeb44 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -1,11 +1,31 @@ import { View, Text, Image, Input, Button } from '@tarojs/components' -import { useState } from 'react' -import Taro, { useDidShow } from '@tarojs/taro' +import { useEffect, useRef, useState } from 'react' +import Taro, { useDidShow, useDidHide } from '@tarojs/taro' + import { getToken } from '@/services/auth' -import { directBind, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId } from '@/services/binding' -import { Child, createChild, getChildren } from '@/services/child' +import { + getNFCBindSession, + resolveActiveBinding, + setBindingChild, + setSelectedBindingDeviceId, + startNFCBind, +} from '@/services/binding' +import { + Child, + createChild, + getChildren, + resolveChildSelection, + setSelectedChildId as setStoredSelectedChildId, +} from '@/services/child' + import './index.scss' +const SESSION_STATUS_PENDING = 1 +const SESSION_STATUS_COMPLETED = 2 +const SESSION_STATUS_EXPIRED = 3 +const SESSION_STATUS_FAILED = 4 +const SESSION_STATUS_CANCELLED = 5 + function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber: string } { const raw = String(rawValue || '').trim() if (!raw) return { deviceId: '', serialNumber: '' } @@ -71,15 +91,99 @@ export default function Bind() { const [selectedChildId, setSelectedChildId] = useState(null) const [newChildName, setNewChildName] = useState('') const [pendingDeviceId, setPendingDeviceId] = useState(null) + const [bindToken, setBindToken] = useState('') + const [bindStatus, setBindStatus] = useState(null) + const [cardUUID, setCardUUID] = useState('') + const [bindHint, setBindHint] = useState('') + const pollingRef = useRef | null>(null) useDidShow(() => { void loadPageData() }) + useDidHide(() => { + stopPolling() + }) + + useEffect(() => () => stopPolling(), []) + + const hasSelectedOrNewChild = selectedChildId !== null || Boolean(newChildName.trim()) + const isPendingBinding = Boolean(pendingDeviceId) + const isPollingBind = bindStatus === SESSION_STATUS_PENDING && Boolean(bindToken) + const canEditDeviceFields = hasSelectedOrNewChild && !isPollingBind + const goDevicePage = () => { Taro.reLaunch({ url: '/pages/device/index' }) } + const stopPolling = () => { + if (pollingRef.current) { + clearTimeout(pollingRef.current) + pollingRef.current = null + } + } + + const schedulePoll = () => { + stopPolling() + pollingRef.current = setTimeout(() => { + void pollBindSession() + }, 1500) + } + + const resetBindSessionState = () => { + stopPolling() + setBindToken('') + setBindStatus(null) + setCardUUID('') + setBindHint('') + } + + const pollBindSession = async () => { + if (!bindToken) return + + try { + const session = await getNFCBindSession(bindToken) + setBindStatus(session.status) + setCardUUID(session.card_uuid || '') + + if (session.status === SESSION_STATUS_PENDING) { + setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下') + schedulePoll() + return + } + + stopPolling() + + if (session.status === SESSION_STATUS_COMPLETED) { + setSelectedBindingDeviceId(session.device_id) + setBindHint(session.card_uuid ? `绑定完成,卡号 ${session.card_uuid}` : '绑定完成') + Taro.showToast({ title: '设备绑定成功', icon: 'success' }) + setTimeout(() => { + goDevicePage() + }, 500) + return + } + + if (session.status === SESSION_STATUS_EXPIRED) { + setBindHint('绑定会话已过期,请重新扫码并贴卡') + return + } + + if (session.status === SESSION_STATUS_FAILED) { + setBindHint('贴卡绑定失败,请重试') + return + } + + if (session.status === SESSION_STATUS_CANCELLED) { + setBindHint('当前绑定已被新的绑定流程替代,请重新扫码') + return + } + } catch (error: any) { + console.error('[bind] poll bind session failed:', error) + setBindHint(error?.message || '查询绑定状态失败,请重试') + } + } + const loadPageData = async () => { if (!getToken()) { Taro.reLaunch({ url: '/pages/login/index' }) @@ -90,17 +194,15 @@ export default function Bind() { try { const [childResponse, activeBinding] = await Promise.all([getChildren(), resolveActiveBinding()]) const currentChildren = childResponse.items || [] - + const currentChild = resolveChildSelection(currentChildren) setChildren(currentChildren) - setSelectedChildId((currentSelectedChildId) => currentSelectedChildId || currentChildren[0]?.child_id || null) + setSelectedChildId(currentChild?.child_id || null) if (activeBinding?.device_id && !activeBinding.child_id) { setPendingDeviceId(activeBinding.device_id) setDeviceId(activeBinding.device_id) } else { setPendingDeviceId(null) - setDeviceId('') - setSerialNumber('') } } catch (error: any) { console.error('[bind] load failed:', error) @@ -122,13 +224,21 @@ export default function Bind() { const child = await createChild({ child_name: childName }) setChildren((currentChildren) => [child, ...currentChildren]) setSelectedChildId(child.child_id) + setStoredSelectedChildId(child.child_id) setNewChildName('') return child.child_id } const handleScanCode = () => { - setIsScanning(true) + if (!canEditDeviceFields) { + Taro.showToast({ + title: '请先选择或新建儿童资料', + icon: 'none', + }) + return + } + setIsScanning(true) Taro.scanCode({ onlyFromCamera: true, scanType: ['qrCode', 'barCode'], @@ -145,11 +255,7 @@ export default function Bind() { }, fail: (err) => { setIsScanning(false) - - if (err.errMsg && err.errMsg.includes('cancel')) { - return - } - + if (err.errMsg && err.errMsg.includes('cancel')) return Taro.showToast({ title: '扫码失败,请重试', icon: 'none', @@ -159,22 +265,21 @@ export default function Bind() { } const handleSubmit = async () => { - if (submitting) return + if (submitting || isPollingBind) return setSubmitting(true) - Taro.showLoading({ title: pendingDeviceId ? '关联中...' : '绑定中...' }) + Taro.showLoading({ title: isPendingBinding ? '关联中...' : '发送绑卡指令...' }) try { const childId = await ensureChildId() + if (!childId) { + throw new Error('请先选择儿童或填写新的儿童昵称') + } - if (pendingDeviceId) { - if (!childId) { - throw new Error('请选择儿童或填写新儿童昵称') - } - - await setBindingChild(pendingDeviceId, { child_id: childId }) + if (isPendingBinding) { + await setBindingChild(pendingDeviceId!, { child_id: childId }) setSelectedBindingDeviceId(pendingDeviceId) - Taro.showToast({ title: '绑定完成', icon: 'success' }) + Taro.showToast({ title: '关联完成', icon: 'success' }) setTimeout(() => { goDevicePage() }, 300) @@ -188,30 +293,21 @@ export default function Bind() { throw new Error('请输入设备序列号') } - const result = await directBind({ + resetBindSessionState() + const session = await startNFCBind({ device_id: deviceId.trim(), serial_number: serialNumber.trim(), - ...(childId ? { child_id: childId } : {}), + child_id: childId, }) - setSelectedBindingDeviceId(result.device_id) - - if (childId || result.child_id) { - Taro.showToast({ title: '设备绑定成功', icon: 'success' }) - setTimeout(() => { - goDevicePage() - }, 300) - return - } - - setPendingDeviceId(result.device_id) - setDeviceId(result.device_id) - Taro.showToast({ - title: '设备已绑定,请继续关联儿童', - icon: 'none', - }) + setBindToken(session.bind_token) + setBindStatus(session.status) + setBindHint('已发送绑卡指令,请去设备上贴自己的卡') + schedulePoll() + Taro.showToast({ title: '请去设备上贴卡', icon: 'none' }) } catch (error: any) { console.error('[bind] submit failed:', error) + setBindHint(error?.message || '绑定失败,请重试') Taro.showToast({ title: error?.message || '绑定失败,请重试', icon: 'none', @@ -235,23 +331,25 @@ export default function Bind() { return ( - {pendingDeviceId ? '补全绑定' : '绑定设备'} + {isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'} - {pendingDeviceId ? '设备已绑到当前账号,请补充儿童资料完成配置' : '扫描二维码或手动填写设备号和序列号'} + {isPendingBinding + ? '当前设备已存在待补全关系,请先补充儿童资料' + : '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'} - + - {isScanning ? '正在扫码...' : '点击扫码'} - 请将二维码放入框内 + + {!canEditDeviceFields ? '请先完成儿童资料' : isScanning ? '正在扫码...' : '点击扫码'} + + + {!canEditDeviceFields ? '先选儿童,再扫设备二维码' : '扫描设备二维码或条码'} + @@ -264,39 +362,7 @@ export default function Bind() { - 设备信息 - - - 设备号 - setDeviceId(event.detail.value)} - /> - - - - 设备序列号 - setSerialNumber(event.detail.value)} - /> - - - {pendingDeviceId && ( - - 当前待完成设备:{pendingDeviceId} - - )} - - - - 儿童信息 + 儿童资料 {children.length > 0 && ( @@ -305,7 +371,11 @@ export default function Bind() { key={child.child_id} className={`child-item ${selectedChildId === child.child_id ? 'selected' : ''}`} onClick={() => { - setSelectedChildId((currentChildId) => (currentChildId === child.child_id ? null : child.child_id)) + setSelectedChildId((currentChildId) => { + const nextChildId = currentChildId === child.child_id ? null : child.child_id + setStoredSelectedChildId(nextChildId) + return nextChildId + }) setNewChildName('') }} > @@ -325,16 +395,65 @@ export default function Bind() { setNewChildName(event.detail.value) if (event.detail.value) { setSelectedChildId(null) + setStoredSelectedChildId(null) } }} /> - 优先选择已有儿童,也可以直接输入一个新昵称。 + + {children.length > 0 + ? '可以选已有儿童,也可以在这里新建一个儿童后再绑定设备' + : '当前还没有儿童资料,请先创建一个儿童资料'} + - @@ -342,15 +461,15 @@ export default function Bind() { 绑定帮助 1 - 先扫描二维码,自动填入设备号和序列号。 + 先选择已有儿童,或者在本页新建一个儿童 2 - 如果二维码里只有设备号,请手动补充序列号。 + 扫描设备二维码,确认设备号和序列号正确 3 - 没有儿童资料时,可以直接在本页新建并完成绑定。 + 点击发送绑卡指令,然后去设备上贴自己的卡完成确认 diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss index d10d348..ad04f3d 100644 --- a/banban-mini/src/pages/chat/detail/index.scss +++ b/banban-mini/src/pages/chat/detail/index.scss @@ -174,6 +174,33 @@ } } +.audio-bubble { + min-width: 220px; + display: flex; + align-items: center; + gap: 14px; + + &.playing { + opacity: 0.82; + } +} + +.audio-bubble-icon { + font-size: 24px; + font-weight: 700; + line-height: 1; +} + +.audio-bubble-text { + flex: 1; + font-size: 28px; +} + +.audio-bubble-duration { + font-size: 24px; + opacity: 0.8; +} + .empty-chat { min-height: 320px; display: flex; diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx index 9e8d4f5..aa9d399 100644 --- a/banban-mini/src/pages/chat/detail/index.tsx +++ b/banban-mini/src/pages/chat/detail/index.tsx @@ -1,5 +1,5 @@ import { View, Text, ScrollView, Image, Input } from '@tarojs/components' -import { useEffect, useState } from 'react' +import { useEffect, useRef, useState } from 'react' import Taro, { useRouter } from '@tarojs/taro' import { getMessages, @@ -47,6 +47,11 @@ function formatParticipantLabel(message: ChatMessage): string { return `${senderType} ${senderId}` } +function formatAudioDuration(durationMs?: number | null): string { + const totalSeconds = Math.max(1, Math.round((durationMs || 0) / 1000)) + return `${totalSeconds}s` +} + export default function ChatDetail() { const router = useRouter() const params = router.params @@ -85,6 +90,29 @@ export default function ChatDetail() { const [loading, setLoading] = useState(true) const [inputText, setInputText] = useState('') const [sending, setSending] = useState(false) + const [playingMessageId, setPlayingMessageId] = useState(null) + const audioContextRef = useRef(null) + + useEffect(() => { + const audioContext = Taro.createInnerAudioContext() + audioContextRef.current = audioContext + + audioContext.onEnded(() => { + setPlayingMessageId(null) + }) + audioContext.onStop(() => { + setPlayingMessageId(null) + }) + audioContext.onError(() => { + setPlayingMessageId(null) + Taro.showToast({ title: '语音播放失败', icon: 'none' }) + }) + + return () => { + audioContext.destroy() + audioContextRef.current = null + } + }, []) useEffect(() => { void loadChatData(activeConversationId) @@ -157,6 +185,46 @@ export default function ChatDetail() { } } + const handlePlayAudio = (message: ChatMessage) => { + if (message.contentType !== 2 || !message.mediaUrl) { + Taro.showToast({ title: '语音地址不存在', icon: 'none' }) + return + } + + const audioContext = audioContextRef.current + if (!audioContext) { + Taro.showToast({ title: '播放器未就绪', icon: 'none' }) + return + } + + if (playingMessageId === message.id) { + audioContext.stop() + setPlayingMessageId(null) + return + } + + audioContext.stop() + audioContext.src = message.mediaUrl + audioContext.autoplay = true + audioContext.play() + setPlayingMessageId(message.id) + } + + const renderMessageBody = (msg: ChatMessage) => { + if (msg.contentType === 2 && msg.mediaUrl) { + const isPlaying = playingMessageId === msg.id + return ( + handlePlayAudio(msg)}> + {isPlaying ? '[]' : '>'} + {msg.mediaTranscriptText || '点击播放语音'} + {formatAudioDuration(msg.mediaDurationMs)} + + ) + } + + return {msg.content} + } + return ( @@ -199,7 +267,7 @@ export default function ChatDetail() { {formatParticipantLabel(msg)} - {msg.content} + {renderMessageBody(msg)} @@ -214,7 +282,7 @@ export default function ChatDetail() { {formatParticipantLabel(msg)} - {msg.content} + {renderMessageBody(msg)} diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx index a93fd6a..d6f1a11 100644 --- a/banban-mini/src/pages/chat/index.tsx +++ b/banban-mini/src/pages/chat/index.tsx @@ -65,7 +65,7 @@ export default function Chat() { ) : conversations.length === 0 ? ( - 暂无玩伴会话 + 当前孩子暂无会话 ) : ( diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 4cbf338..18dd9a2 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -348,6 +348,14 @@ color: #1A1A1A; } +.empty-subtitle { + display: block; + margin-top: 16px; + font-size: 26px; + line-height: 1.6; + color: #666666; +} + .empty-btn { margin-top: 28px; height: 92px; @@ -378,3 +386,46 @@ font-weight: 600; color: #1A1A1A; } + +.child-focus-card { + display: flex; + align-items: center; + background: #FFFFFF; + border-radius: 24px; + margin: 0 24px 24px; + padding: 28px 24px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04); +} + +.child-focus-avatar { + width: 96px; + height: 96px; + border-radius: 50%; + background: #FEF3E8; + display: flex; + align-items: center; + justify-content: center; + margin-right: 20px; +} + +.child-focus-icon { + font-size: 40px; +} + +.child-focus-info { + flex: 1; +} + +.child-focus-name { + display: block; + font-size: 32px; + font-weight: 700; + color: #1A1A1A; +} + +.child-focus-desc { + display: block; + margin-top: 8px; + font-size: 26px; + color: #666666; +} diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 492c70c..cd54cc6 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -2,8 +2,8 @@ import { View, Text, Switch, Slider, Image } from '@tarojs/components' import { useState } from 'react' import Taro, { useDidShow } from '@tarojs/taro' import { getToken } from '@/services/auth' -import { Binding, resolveActiveBinding } from '@/services/binding' -import { Child, getChild } from '@/services/child' +import { Binding, loadCurrentChildBindingContext } from '@/services/binding' +import { Child } from '@/services/child' import './index.scss' export default function Device() { @@ -25,20 +25,9 @@ export default function Device() { setIsLoading(true) try { - const activeBinding = await resolveActiveBinding() - setBinding(activeBinding) - - if (activeBinding?.child_id) { - try { - const currentChild = await getChild(activeBinding.child_id) - setChild(currentChild) - } catch (error) { - console.error('[device] load child failed:', error) - setChild(null) - } - } else { - setChild(null) - } + const context = await loadCurrentChildBindingContext() + setChild(context.currentChild) + setBinding(context.currentBinding) } catch (error: any) { console.error('[device] load failed:', error) Taro.showToast({ @@ -72,7 +61,7 @@ export default function Device() { ) } - if (!binding) { + if (!child) { return ( @@ -83,7 +72,39 @@ export default function Device() { - 先绑定一台伴伴设备 + 先添加一个儿童资料 + 添加后才能为孩子绑定专属设备并查看聊天和定位信息 + Taro.switchTab({ url: '/pages/sleep/index' })}> + 去添加孩子 + + + + ) + } + + if (!binding) { + return ( + + + {child.child_name} 的伴伴 + + + + + 🧒 + + + {child.child_name} + 当前孩子已选中,还没有绑定设备 + + + + + + + + 给当前孩子绑定一台设备 + 绑定后,这个孩子的聊天记录、定位和设备状态都会显示在首页 Taro.navigateTo({ url: '/pages/bind/index' })}> 去绑定设备 @@ -92,8 +113,8 @@ export default function Device() { ) } - const childName = child?.child_name || '未设置' - const pageTitle = child?.child_name ? `${child.child_name} 的伴伴` : '伴伴设备' + const childName = child.child_name || '未设置' + const pageTitle = `${childName} 的伴伴` const battery = 82 const daysLeft = 4 @@ -106,7 +127,7 @@ export default function Device() { - {binding.child_id ? '已完成绑定' : '待关联儿童'} + 已完成绑定 @@ -124,12 +145,12 @@ export default function Device() { - 设备号 - {binding.device_id} + 当前儿童 + {childName} - 儿童昵称 - {childName} + 当前设备 + {binding.device_id} 绑定时间 @@ -195,12 +216,6 @@ export default function Device() { - - {!binding.child_id && ( - Taro.navigateTo({ url: '/pages/bind/index' })}> - 继续完成儿童关联 - - )} ) } diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx index 207aa17..eb59e99 100644 --- a/banban-mini/src/pages/location/index.tsx +++ b/banban-mini/src/pages/location/index.tsx @@ -26,8 +26,8 @@ const DEFAULT_COORDINATES = { const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [ { key: 'current', label: '当前位置' }, - { key: 'today', label: '今日轨迹' }, - { key: 'recent', label: '最近点位' }, + // { key: 'today', label: '今日轨迹' }, + // { key: 'recent', label: '最近点位' }, ] function formatTime(value?: string | null): string { @@ -339,7 +339,7 @@ export default function Location() { ) : ( - 暂无设备位置 + 当前孩子暂无设备位置 ) ) : trajectory.length > 0 ? ( @@ -384,7 +384,7 @@ export default function Location() { 暂无轨迹 - {trajectoryMode === 'today' ? '今天还没有新的历史点位。' : '最近时间范围内没有新的历史点位。'} + {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'} )} diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx index f0b98c8..1bc9834 100644 --- a/banban-mini/src/pages/login/index.tsx +++ b/banban-mini/src/pages/login/index.tsx @@ -62,9 +62,11 @@ export default function Login() { }, 300) } catch (error: any) { console.error('[login] failed:', error) - Taro.showToast({ - title: error?.message || '登录失败,请重试', - icon: 'none', + const message = String(error?.message || '登录失败,请重试') + Taro.showModal({ + title: '登录失败', + content: message, + showCancel: false, }) } finally { Taro.hideLoading() diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx index a788c25..6f55381 100644 --- a/banban-mini/src/pages/sleep/index.tsx +++ b/banban-mini/src/pages/sleep/index.tsx @@ -5,13 +5,11 @@ import { clearToken, getCurrentUserId, getToken } from '@/services/auth' import { BindingListItem, clearSelectedBindingDeviceId, - getBindings, - resolveBindingSelection, - setBindingChild, + loadCurrentChildBindingContext, setSelectedBindingDeviceId, unbindDevice, } from '@/services/binding' -import { Child, createChild, getChildren, updateChild } from '@/services/child' +import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child' import './index.scss' interface MenuItem { @@ -28,8 +26,9 @@ export default function Sleep() { const [children, setChildren] = useState([]) const [bindings, setBindings] = useState([]) const [binding, setBinding] = useState(null) + const [currentChild, setCurrentChild] = useState(null) const [showModal, setShowModal] = useState(false) - const [showDeviceModal, setShowDeviceModal] = useState(false) + const [showChildModal, setShowChildModal] = useState(false) const [modalType, setModalType] = useState<'add' | 'edit'>('add') const [childName, setChildName] = useState('') const [editingChildId, setEditingChildId] = useState(null) @@ -47,13 +46,11 @@ export default function Sleep() { setLoading(true) try { - const [childResponse, bindingResponse] = await Promise.all([getChildren(), getBindings(undefined, 100)]) - const bindingItems = bindingResponse.items || [] - const activeBinding = resolveBindingSelection(bindingItems) - - setChildren(childResponse.items || []) - setBindings(bindingItems) - setBinding(activeBinding) + const context = await loadCurrentChildBindingContext() + setChildren(context.children) + setBindings(context.bindings as BindingListItem[]) + setCurrentChild(context.currentChild) + setBinding((context.currentBinding as BindingListItem | null) || null) setParentInfo(Taro.getStorageSync('userInfo') || {}) } catch (error: any) { console.error('[manage] load failed:', error) @@ -66,8 +63,7 @@ export default function Sleep() { } } - const currentChild = binding?.child_id ? children.find((item) => item.child_id === binding.child_id) || null : null - const currentChildName = currentChild?.child_name || binding?.child_name || (binding && !binding.child_id ? '待关联' : '未设置') + const currentChildName = currentChild?.child_name || '未设置' const handleOpenModal = (type: 'add' | 'edit', child?: Child) => { setModalType(type) @@ -82,12 +78,19 @@ export default function Sleep() { setEditingChildId(null) } - const handleSelectBinding = (targetBinding: BindingListItem) => { - setSelectedBindingDeviceId(targetBinding.device_id) - setBinding(targetBinding) - setShowDeviceModal(false) + const handleSelectChild = (child: Child) => { + setSelectedChildId(child.child_id) + const nextBinding = bindings.find((item) => item.child_id === child.child_id) || null + if (nextBinding?.device_id) { + setSelectedBindingDeviceId(nextBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + setCurrentChild(child) + setBinding(nextBinding) + setShowChildModal(false) Taro.showToast({ - title: '已切换设备', + title: '已切换当前孩子', icon: 'success', }) } @@ -102,12 +105,8 @@ export default function Sleep() { try { if (modalType === 'add') { const createdChild = await createChild({ child_name: normalizedName }) - if (binding && !binding.child_id) { - await setBindingChild(binding.device_id, { child_id: createdChild.child_id }) - Taro.showToast({ title: '已创建并关联', icon: 'success' }) - } else { - Taro.showToast({ title: '创建成功', icon: 'success' }) - } + setSelectedChildId(createdChild.child_id) + Taro.showToast({ title: '创建成功', icon: 'success' }) } else if (editingChildId) { await updateChild(editingChildId, { child_name: normalizedName }) Taro.showToast({ title: '修改成功', icon: 'success' }) @@ -124,54 +123,19 @@ export default function Sleep() { } } - const handleChildMenu = () => { - if (binding && !binding.child_id && children.length > 0) { - Taro.showActionSheet({ - itemList: [...children.map((item) => item.child_name), '新建儿童资料'], - success: async (result) => { - if (result.tapIndex === children.length) { - handleOpenModal('add') - return - } - - const targetChild = children[result.tapIndex] - if (!targetChild) return - - try { - await setBindingChild(binding.device_id, { child_id: targetChild.child_id }) - Taro.showToast({ title: '关联成功', icon: 'success' }) - await loadData() - } catch (error: any) { - Taro.showToast({ - title: error?.message || '关联失败,请重试', - icon: 'none', - }) - } - }, - }) - return - } - - if (currentChild) { - handleOpenModal('edit', currentChild) - return - } - - handleOpenModal('add') - } - const handleUnbind = () => { if (!binding) return Taro.showModal({ title: '解除设备绑定', - content: '确定要解除当前设备绑定吗?', + content: `确定要解除 ${currentChildName} 当前绑定的设备吗?`, confirmColor: '#FF8C42', success: async (result) => { if (!result.confirm) return try { await unbindDevice(binding.device_id) + clearSelectedBindingDeviceId() Taro.showToast({ title: '已解绑', icon: 'success' }) await loadData() } catch (error: any) { @@ -193,6 +157,7 @@ export default function Sleep() { if (!result.confirm) return clearToken() clearSelectedBindingDeviceId() + clearSelectedChildId() Taro.removeStorageSync('userInfo') Taro.reLaunch({ url: '/pages/login/index' }) }, @@ -202,21 +167,33 @@ export default function Sleep() { const handleMenuClick = (item: MenuItem) => { if (item.disabled) return - if (item.name === '儿童资料 (用于称呼)') { - handleChildMenu() + if (item.name === '当前孩子') { + setShowChildModal(true) return } - if (item.name === '绑定新设备') { + if (item.name === '编辑当前孩子') { + if (currentChild) { + handleOpenModal('edit', currentChild) + } else { + handleOpenModal('add') + } + return + } + + if (item.name === '绑定设备') { + if (!currentChild) { + handleOpenModal('add') + Taro.showToast({ + title: '请先添加儿童资料', + icon: 'none', + }) + return + } Taro.navigateTo({ url: '/pages/bind/index' }) return } - if (item.name === '切换设备') { - setShowDeviceModal(true) - return - } - if (item.name === '解除设备绑定') { handleUnbind() } @@ -237,24 +214,24 @@ export default function Sleep() { { icon: require('../../assets/tab-icons/orange-robot.png'), iconBgClass: 'orange', - name: '儿童资料 (用于称呼)', + name: '当前孩子', value: currentChildName, arrow: true, }, - { - icon: require('../../assets/tab-icons/rings.png'), - iconBgClass: 'green', - name: '切换设备', - value: bindings.length > 0 ? `共 ${bindings.length} 台` : '暂无设备', - arrow: true, - }, { icon: require('../../assets/tab-icons/orange-robot.png'), iconBgClass: 'orange', - name: '绑定新设备', + name: '编辑当前孩子', value: '', arrow: true, }, + { + icon: require('../../assets/tab-icons/rings.png'), + iconBgClass: 'green', + name: '绑定设备', + value: binding?.device_id ? `当前: ${binding.device_id}` : '未绑定', + arrow: true, + }, { icon: require('../../assets/tab-icons/broken-rings.png'), iconBgClass: 'red', @@ -284,23 +261,23 @@ export default function Sleep() { 用户 ID: {userId || '--'} - {bindings.length > 0 ? `已绑定 ${bindings.length} 台` : '未绑定设备'} + {currentChild ? '已选择当前孩子' : '未选择孩子'} - 已绑定设备数 - {bindings.length} 台 - - - 当前设备 - {binding?.device_id || '未绑定'} + 儿童资料数 + {children.length} 个 当前儿童 {currentChildName} + + 当前设备 + {binding?.device_id || '未绑定'} + @@ -333,39 +310,40 @@ export default function Sleep() { 伴伴 Companion V1.1.0 - {showDeviceModal && ( - setShowDeviceModal(false)}> + {showChildModal && ( + setShowChildModal(false)}> { event.stopPropagation() }} > - 切换设备 - {bindings.length === 0 ? ( - 当前还没有已绑定设备 + 切换当前孩子 + {children.length === 0 ? ( + 当前还没有儿童资料 ) : ( - {bindings.map((item) => { - const isActive = binding?.device_id === item.device_id + {children.map((item) => { + const isActive = currentChild?.child_id === item.child_id + const itemBinding = bindings.find((bindingItem) => bindingItem.child_id === item.child_id) || null return ( handleSelectBinding(item)} + onClick={() => handleSelectChild(item)} > - {item.device_id} + {item.child_name} {isActive && 当前} - {item.child_name || '待关联儿童'} + {itemBinding?.device_id || '未绑定设备'} ) })} )} - setShowDeviceModal(false)}> + setShowChildModal(false)}> 关闭 diff --git a/banban-mini/src/services/api.ts b/banban-mini/src/services/api.ts index bda8f4d..c6ff480 100644 --- a/banban-mini/src/services/api.ts +++ b/banban-mini/src/services/api.ts @@ -13,6 +13,7 @@ class ApiError extends Error { function getErrorDetail(data: any): string { if (!data) return 'Request failed' + if (typeof data.errMsg === 'string') return data.errMsg if (typeof data.detail === 'string') return data.detail if (Array.isArray(data.detail)) { return data.detail.map((item: any) => String(item?.msg || item)).join('; ') @@ -30,6 +31,8 @@ async function request( } = {} ): Promise { const token = getToken() + const requestUrl = `${BASE_URL}${url}` + const requestMethod = options.method || 'GET' const headers: Record = { 'Content-Type': 'application/json', ...options.headers, @@ -41,22 +44,19 @@ async function request( let response try { response = await Taro.request({ - url: `${BASE_URL}${url}`, - method: options.method || 'GET', + url: requestUrl, + method: requestMethod, data: options.data, header: headers, timeout: REQUEST_TIMEOUT_MS, }) } catch (error: any) { - const message = String(error?.errMsg || error?.message || '') - const detail = message.includes('timeout') - ? '请求超时,请检查后端服务是否可访问' - : '网络请求失败,请检查后端地址和网络连接' - throw new ApiError(0, detail) + const message = String(error?.errMsg || error?.message || 'request:fail') + throw new ApiError(0, message) } if (response.statusCode === 401) { - handleUnauthorized({ redirect: !url.startsWith('/auth/') }) + handleUnauthorized({ redirect: !url.startsWith('/banban/auth/') }) } if (response.statusCode >= 400) { diff --git a/banban-mini/src/services/auth.ts b/banban-mini/src/services/auth.ts index 9b2ef9a..cc09b57 100644 --- a/banban-mini/src/services/auth.ts +++ b/banban-mini/src/services/auth.ts @@ -30,7 +30,7 @@ export interface Parent { } export async function wechatLogin(data: WechatLoginPayload): Promise { - const session = await request('/auth/login', { + const session = await request('/banban/auth/login', { method: 'POST', data, }) @@ -39,14 +39,14 @@ export async function wechatLogin(data: WechatLoginPayload): Promise { - return request(`/parents/${userId}`) + return request(`/banban/parents/${userId}`) } export async function updateParent( userId: number, data: { nickname?: string; avatar_url?: string; phone?: string } ): Promise { - return request(`/parents/${userId}`, { + return request(`/banban/parents/${userId}`, { method: 'PATCH', data, }) diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index e0501a5..97e66db 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -1,5 +1,7 @@ import Taro from '@tarojs/taro' + import { request } from './api' +import { Child, clearSelectedChildId, getChildren, resolveChildSelection, setSelectedChildId } from './child' const SELECTED_BINDING_DEVICE_ID_KEY = 'selectedBindingDeviceId' @@ -26,12 +28,38 @@ export interface BindingListResponse { next_cursor?: number | null } +export interface BindingChildContext { + currentChild: Child | null + currentBinding: Binding | null +} + export interface DirectBindPayload { device_id: string serial_number: string child_id?: number } +export interface NFCSessionStartPayload { + device_id: string + serial_number: string + child_id?: number +} + +export interface NFCSessionStartResponse { + bind_token: string + expires_at: string + status: number +} + +export interface NFCSessionStatus { + bind_token: string + device_id: string + child_id: number | null + status: number + expires_at: string + card_uuid?: string | null +} + export function getSelectedBindingDeviceId(): string | null { const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) const deviceId = String(value || '').trim() @@ -67,7 +95,7 @@ export function resolveBindingSelection(items: export async function getCurrentBinding(): Promise { try { - return await request('/bindings/current') + return await request('/banban/bindings/current') } catch (error: any) { if (error?.status === 404) return null throw error @@ -75,16 +103,13 @@ export async function getCurrentBinding(): Promise { } export async function getBindings(cursor?: number, limit: number = 20): Promise { - const query = buildQuery({ - cursor, - limit, - }) - return request(`/bindings?${query}`) + const query = buildQuery({ cursor, limit }) + return request(`/banban/bindings?${query}`) } export async function getBinding(deviceId: string): Promise { try { - return await request(`/bindings/${deviceId}`) + return await request(`/banban/bindings/${deviceId}`) } catch (error: any) { if (error?.status === 404) return null throw error @@ -100,7 +125,6 @@ export async function resolveActiveBinding(): Promise { setSelectedBindingDeviceId(selectedBinding.device_id) return selectedBinding } - clearSelectedBindingDeviceId() } @@ -115,24 +139,92 @@ export async function resolveActiveBinding(): Promise { } export async function directBind(data: DirectBindPayload): Promise<{ device_id: string; child_id: number | null }> { - return request('/bindings/direct', { + return request('/banban/bindings/direct', { method: 'POST', data }) +} + +export async function startNFCBind(data: NFCSessionStartPayload): Promise { + return request('/banban/bindings/start', { method: 'POST', data, }) } +export async function getNFCBindSession(bindToken: string): Promise { + return request(`/banban/bindings/sessions/${bindToken}`) +} + export async function setBindingChild( deviceId: string, data: { child_id: number } ): Promise<{ device_id: string; child_id: number | null }> { - return request(`/bindings/${deviceId}/child`, { + return request(`/banban/bindings/${deviceId}/child`, { method: 'PATCH', data, }) } export async function unbindDevice(deviceId: string): Promise { - return request(`/bindings/${deviceId}`, { + return request(`/banban/bindings/${deviceId}`, { method: 'DELETE', }) } + +export function resolveBindingForChild( + items: T[], + childId?: number | null +): T | null { + const normalizedChildId = Number(childId || 0) + if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) return null + + const childBindings = items.filter((item) => item.child_id === normalizedChildId) + if (childBindings.length === 0) return null + + const selectedDeviceId = getSelectedBindingDeviceId() + if (selectedDeviceId) { + const selectedBinding = childBindings.find((item: any) => item.device_id === selectedDeviceId) || null + if (selectedBinding) return selectedBinding + } + + return childBindings[0] || null +} + +export function syncChildAndBindingSelection(children: Child[], bindings: Binding[]): BindingChildContext { + const currentChild = resolveChildSelection(children) + const currentBinding = currentChild ? resolveBindingForChild(bindings, currentChild.child_id) : null + + if (currentChild) { + setSelectedChildId(currentChild.child_id) + } else { + clearSelectedChildId() + } + + if (currentBinding?.device_id) { + setSelectedBindingDeviceId(currentBinding.device_id) + } else { + clearSelectedBindingDeviceId() + } + + return { + currentChild, + currentBinding, + } +} + +export async function loadCurrentChildBindingContext(): Promise<{ + children: Child[] + bindings: Binding[] + currentChild: Child | null + currentBinding: Binding | null +}> { + const [childResponse, bindingResponse] = await Promise.all([getChildren(undefined, 100), getBindings(undefined, 100)]) + const children = childResponse.items || [] + const bindings = bindingResponse.items || [] + const { currentChild, currentBinding } = syncChildAndBindingSelection(children, bindings) + + return { + children, + bindings, + currentChild, + currentBinding, + } +} diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts index b5b8505..55d71ab 100644 --- a/banban-mini/src/services/chat.ts +++ b/banban-mini/src/services/chat.ts @@ -1,7 +1,6 @@ import { getCurrentUserId } from './auth' import { request } from './api' -import { resolveActiveBinding } from './binding' -import { getChild } from './child' +import { loadCurrentChildBindingContext } from './binding' export type ConversationSource = 'im' | 'ai' export type PeerKind = 'child' | 'parent' | 'ai' @@ -38,6 +37,11 @@ export interface ChatMessage { content: string time: string conversationId: number + contentType?: number + mediaUrl?: string | null + mediaDurationMs?: number | null + mediaMimeType?: string | null + mediaTranscriptText?: string | null senderType?: string senderId?: string receiverType?: string @@ -240,21 +244,12 @@ function getConversationRank(conversation: ChatConversation): number { } 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) - } - } + const context = await loadCurrentChildBindingContext() + const childName = String(context.currentChild?.child_name || context.currentBinding?.child_name || '').trim() return { - deviceId: binding?.device_id || '', - childId: binding?.child_id || null, + deviceId: context.currentBinding?.device_id || '', + childId: context.currentChild?.child_id || null, childName, currentUserId: getCurrentUserId(), } @@ -265,7 +260,7 @@ async function getDeviceMessages(context?: BindingContext): Promise(`/devices/${deviceId}/messages?limit=100`) + const response = await request(`/banban/devices/${deviceId}/messages?limit=100`) return response.items || [] } catch (error: any) { if (error?.status === 404) return [] @@ -278,7 +273,7 @@ async function getChildConversations(context?: BindingContext): Promise(`/children/${childId}/conversations?limit=100`) + const response = await request(`/banban/children/${childId}/conversations?limit=100`) return response.items || [] } catch (error: any) { if (error?.status === 404) return [] @@ -294,7 +289,7 @@ async function getChildConversationMessages( if (!childId || conversationId <= 0) return [] const response = await request( - `/children/${childId}/conversations/${conversationId}/messages?limit=100` + `/banban/children/${childId}/conversations/${conversationId}/messages?limit=100` ) return response.items || [] } @@ -437,6 +432,11 @@ export async function getMessages( content: item.content || '暂无内容', time: formatDetailTime(item.created_at), conversationId: item.conversation_id, + contentType: 1, + mediaUrl: null, + mediaDurationMs: null, + mediaMimeType: null, + mediaTranscriptText: null, senderType: item.is_user ? 'device' : 'ai', senderId: item.is_user ? context.deviceId : item.role_key, receiverType: item.is_user ? 'ai' : 'device', @@ -462,6 +462,11 @@ export async function getMessages( content: formatImPreview(item), time: formatDetailTime(item.created_at), conversationId: item.conversation_id, + contentType: item.content_type, + mediaUrl: item.media_file_key || null, + mediaDurationMs: item.media_duration_ms || null, + mediaMimeType: item.media_mime_type || null, + mediaTranscriptText: item.media_transcript_text || null, senderType: item.sender_type, senderId: item.sender_id, receiverType: item.receiver_type, @@ -580,7 +585,7 @@ export async function sendParentMessage(childId: number, content: string): Promi throw new Error('请输入消息内容') } - return request(`/children/${childId}/messages`, { + return request(`/banban/children/${childId}/messages`, { method: 'POST', data: { content_type: 1, diff --git a/banban-mini/src/services/child.ts b/banban-mini/src/services/child.ts index 67b8f29..f645901 100644 --- a/banban-mini/src/services/child.ts +++ b/banban-mini/src/services/child.ts @@ -1,5 +1,8 @@ +import Taro from '@tarojs/taro' import { request } from './api' +const SELECTED_CHILD_ID_KEY = 'selectedChildId' + function buildQuery(params: Record): string { return Object.entries(params) .filter(([, value]) => value !== undefined && value !== null && value !== '') @@ -21,12 +24,44 @@ export interface ChildListResponse { next_cursor?: number | null } +export function getSelectedChildId(): number | null { + const value = Number(Taro.getStorageSync(SELECTED_CHILD_ID_KEY) || 0) + return Number.isFinite(value) && value > 0 ? value : null +} + +export function setSelectedChildId(childId?: number | null) { + const normalizedChildId = Number(childId || 0) + if (!Number.isFinite(normalizedChildId) || normalizedChildId <= 0) { + Taro.removeStorageSync(SELECTED_CHILD_ID_KEY) + return + } + Taro.setStorageSync(SELECTED_CHILD_ID_KEY, normalizedChildId) +} + +export function clearSelectedChildId() { + Taro.removeStorageSync(SELECTED_CHILD_ID_KEY) +} + +export function resolveChildSelection(items: T[]): T | null { + const selectedChildId = getSelectedChildId() + const selectedChild = selectedChildId ? items.find((item) => item.child_id === selectedChildId) || null : null + const currentChild = selectedChild || items[0] || null + + if (currentChild) { + setSelectedChildId(currentChild.child_id) + } else { + clearSelectedChildId() + } + + return currentChild +} + export async function createChild(data: { child_name: string child_gender?: number child_birthday?: string }): Promise { - return request('/children', { + return request('/banban/children', { method: 'POST', data, }) @@ -37,18 +72,18 @@ export async function getChildren(cursor?: number, limit: number = 20): Promise< cursor, limit, }) - return request(`/children?${query}`) + return request(`/banban/children?${query}`) } export async function getChild(childId: number): Promise { - return request(`/children/${childId}`) + return request(`/banban/children/${childId}`) } export async function updateChild( childId: number, data: { child_name?: string; child_gender?: number; child_birthday?: string } ): Promise { - return request(`/children/${childId}`, { + return request(`/banban/children/${childId}`, { method: 'PATCH', data, }) diff --git a/banban-mini/src/services/location.ts b/banban-mini/src/services/location.ts index 7375451..2d5948d 100644 --- a/banban-mini/src/services/location.ts +++ b/banban-mini/src/services/location.ts @@ -1,5 +1,5 @@ import { request } from './api' -import { resolveActiveBinding } from './binding' +import { loadCurrentChildBindingContext } from './binding' export interface DeviceLocation { child_id: number @@ -32,11 +32,12 @@ export interface DeviceTrajectoryResponse { } export async function getCurrentDeviceLocation(deviceId?: string): Promise { - const resolvedDeviceId = String(deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' + const resolvedDeviceId = + String(deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || '' if (!resolvedDeviceId) return null try { - return await request(`/devices/${resolvedDeviceId}/location`) + return await request(`/banban/devices/${resolvedDeviceId}/location`) } catch (error: any) { if (error?.status === 404) return null throw error @@ -49,7 +50,8 @@ export async function getDeviceTrajectory(params?: { endAt?: string limit?: number }): Promise { - const resolvedDeviceId = String(params?.deviceId || '').trim() || (await resolveActiveBinding())?.device_id || '' + const resolvedDeviceId = + String(params?.deviceId || '').trim() || (await loadCurrentChildBindingContext()).currentBinding?.device_id || '' if (!resolvedDeviceId) return null const query = new URLSearchParams() @@ -59,7 +61,7 @@ export async function getDeviceTrajectory(params?: { try { const suffix = query.toString() ? `?${query.toString()}` : '' - return await request(`/devices/${resolvedDeviceId}/trajectory${suffix}`) + return await request(`/banban/devices/${resolvedDeviceId}/trajectory${suffix}`) } catch (error: any) { if (error?.status === 404) return null throw error