diff --git a/banban-mini/src/pages/bind/index.scss b/banban-mini/src/pages/bind/index.scss
index d9cb7c9..b1937ed 100644
--- a/banban-mini/src/pages/bind/index.scss
+++ b/banban-mini/src/pages/bind/index.scss
@@ -27,19 +27,39 @@
.bind-header {
padding: 80px 32px 32px;
+ position: relative;
text-align: center;
+ .back-btn {
+ position: absolute;
+ left: 32px;
+ top: 80px;
+ display: flex;
+ align-items: center;
+ padding: 12px 18px;
+ border-radius: 999px;
+ background: #FFFFFF;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
+ }
+
+ .back-icon {
+ width: 28px;
+ height: 28px;
+ margin-right: 10px;
+ }
+
+ .back-text {
+ font-size: 26px;
+ color: #1A1A1A;
+ font-weight: 500;
+ }
+
.title {
font-size: 44px;
font-weight: 700;
color: #1A1A1A;
display: block;
- margin-bottom: 12px;
- }
-
- .subtitle {
- font-size: 28px;
- color: #666666;
+ padding: 0 120px;
}
}
diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx
index 8abeb44..003246a 100644
--- a/banban-mini/src/pages/bind/index.tsx
+++ b/banban-mini/src/pages/bind/index.tsx
@@ -116,6 +116,14 @@ export default function Bind() {
Taro.reLaunch({ url: '/pages/device/index' })
}
+ const handleBack = () => {
+ if (getCurrentPages().length > 1) {
+ Taro.navigateBack()
+ return
+ }
+ Taro.switchTab({ url: '/pages/device/index' })
+ }
+
const stopPolling = () => {
if (pollingRef.current) {
clearTimeout(pollingRef.current)
@@ -123,10 +131,10 @@ export default function Bind() {
}
}
- const schedulePoll = () => {
+ const schedulePoll = (nextBindToken?: string) => {
stopPolling()
pollingRef.current = setTimeout(() => {
- void pollBindSession()
+ void pollBindSession(nextBindToken)
}, 1500)
}
@@ -138,17 +146,18 @@ export default function Bind() {
setBindHint('')
}
- const pollBindSession = async () => {
- if (!bindToken) return
+ const pollBindSession = async (nextBindToken?: string) => {
+ const activeBindToken = (nextBindToken || bindToken).trim()
+ if (!activeBindToken) return
try {
- const session = await getNFCBindSession(bindToken)
+ const session = await getNFCBindSession(activeBindToken)
setBindStatus(session.status)
setCardUUID(session.card_uuid || '')
if (session.status === SESSION_STATUS_PENDING) {
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
- schedulePoll()
+ schedulePoll(activeBindToken)
return
}
@@ -303,7 +312,7 @@ export default function Bind() {
setBindToken(session.bind_token)
setBindStatus(session.status)
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
- schedulePoll()
+ schedulePoll(session.bind_token)
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
} catch (error: any) {
console.error('[bind] submit failed:', error)
@@ -331,12 +340,11 @@ export default function Bind() {
return (
+
+
+ 返回
+
{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}
-
- {isPendingBinding
- ? '当前设备已存在待补全关系,请先补充儿童资料'
- : '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
-
diff --git a/banban-mini/src/pages/chat/detail/index.scss b/banban-mini/src/pages/chat/detail/index.scss
index ad04f3d..4ab5329 100644
--- a/banban-mini/src/pages/chat/detail/index.scss
+++ b/banban-mini/src/pages/chat/detail/index.scss
@@ -218,36 +218,41 @@
border-top: 1px solid #EEEEEE;
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
display: flex;
- align-items: center;
- gap: 16px;
+ flex-direction: column;
+ gap: 14px;
}
-.composer-input {
- flex: 1;
- min-height: 76px;
- background: #F5F7FA;
- border-radius: 38px;
- padding: 0 24px;
- font-size: 28px;
- color: #1A1A1A;
+.composer-tip {
+ text-align: center;
+
+ text {
+ font-size: 24px;
+ color: #999999;
+ }
}
-.composer-send {
- min-width: 132px;
- height: 76px;
- border-radius: 38px;
+.composer-record {
+ width: 100%;
+ min-height: 88px;
+ border-radius: 44px;
background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
+ box-shadow: 0 10px 24px rgba(255, 140, 66, 0.2);
+
+ &.recording {
+ background: #FF6A3D;
+ }
&.disabled {
opacity: 0.55;
}
}
-.composer-send-text {
- font-size: 28px;
+.composer-record-text {
+ font-size: 30px;
color: #FFFFFF;
font-weight: 600;
+ letter-spacing: 2px;
}
diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx
index aa9d399..60ac3b5 100644
--- a/banban-mini/src/pages/chat/detail/index.tsx
+++ b/banban-mini/src/pages/chat/detail/index.tsx
@@ -1,10 +1,10 @@
-import { View, Text, ScrollView, Image, Input } from '@tarojs/components'
+import { View, Text, ScrollView, Image } from '@tarojs/components'
import { useEffect, useRef, useState } from 'react'
import Taro, { useRouter } from '@tarojs/taro'
import {
getMessages,
getCharacter,
- sendParentMessage,
+ sendParentVoiceMessage,
ChatMessage,
Character,
ConversationSource,
@@ -88,10 +88,15 @@ export default function ChatDetail() {
})
const [messages, setMessages] = useState([])
const [loading, setLoading] = useState(true)
- const [inputText, setInputText] = useState('')
const [sending, setSending] = useState(false)
+ const [recording, setRecording] = useState(false)
const [playingMessageId, setPlayingMessageId] = useState(null)
+ const [recordHint, setRecordHint] = useState('长按开始留言')
const audioContextRef = useRef(null)
+ const recorderManagerRef = useRef(null)
+ const activeConversationIdRef = useRef(initialConversationId)
+ const latestLoadChatDataRef = useRef<(conversationId: number) => Promise>(async () => {})
+ const recordingChildIdRef = useRef(null)
useEffect(() => {
const audioContext = Taro.createInnerAudioContext()
@@ -114,6 +119,10 @@ export default function ChatDetail() {
}
}, [])
+ useEffect(() => {
+ activeConversationIdRef.current = activeConversationId
+ }, [activeConversationId])
+
useEffect(() => {
void loadChatData(activeConversationId)
}, [activeConversationId, conversationSource, conversationPeerKind])
@@ -152,39 +161,139 @@ export default function ChatDetail() {
}
}
+ latestLoadChatDataRef.current = loadChatData
+
+ useEffect(() => {
+ if (typeof Taro.getRecorderManager !== 'function') return
+
+ const recorderManager = Taro.getRecorderManager()
+ recorderManagerRef.current = recorderManager
+
+ recorderManager.onError((error) => {
+ recordingChildIdRef.current = null
+ setRecording(false)
+ setRecordHint('长按开始留言')
+ console.error('[chat-detail] recorder failed:', error)
+ Taro.showToast({ title: '录音失败,请重试', icon: 'none' })
+ })
+
+ recorderManager.onStop(async (result) => {
+ const currentChildId = recordingChildIdRef.current
+ recordingChildIdRef.current = null
+ setRecording(false)
+ setRecordHint('长按开始留言')
+
+ if (!currentChildId) return
+ if (!result.tempFilePath) {
+ Taro.showToast({ title: '录音文件不存在', icon: 'none' })
+ return
+ }
+
+ const durationMs = Math.max(0, Number(result.duration || 0))
+ if (durationMs < 800) {
+ Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
+ return
+ }
+
+ setSending(true)
+ try {
+ const response = await sendParentVoiceMessage(currentChildId, {
+ filePath: result.tempFilePath,
+ durationMs,
+ })
+ if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
+ setActiveConversationId(response.conversation_id)
+ } else {
+ await latestLoadChatDataRef.current(activeConversationIdRef.current)
+ }
+ Taro.showToast({ title: '留言已发送', icon: 'success' })
+ } catch (error: any) {
+ console.error('[chat-detail] voice send failed:', error)
+ Taro.showToast({
+ title: error?.message || '留言发送失败,请重试',
+ icon: 'none',
+ })
+ } finally {
+ setSending(false)
+ }
+ })
+
+ return () => {
+ recorderManagerRef.current = null
+ }
+ }, [])
+
const handleBack = () => {
Taro.navigateBack()
}
- const handleSend = async () => {
- const normalizedContent = inputText.trim()
- if (!normalizedContent || sending) return
+ const ensureRecordPermission = async (): Promise => {
+ try {
+ const settings = await Taro.getSetting()
+ if (settings.authSetting['scope.record']) return true
+ await Taro.authorize({ scope: 'scope.record' })
+ return true
+ } catch {
+ const modal = await Taro.showModal({
+ title: '需要录音权限',
+ content: '请允许小程序使用麦克风后再留言',
+ confirmText: '去设置',
+ })
+ if (modal.confirm) {
+ await Taro.openSetting()
+ }
+ return false
+ }
+ }
+
+ const handleRecordStart = async () => {
+ if (sending || recording) return
if (!character.canSend || !character.childId) {
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
return
}
- setSending(true)
+ const recorderManager = recorderManagerRef.current
+ if (!recorderManager) {
+ Taro.showToast({ title: '当前环境不支持录音', icon: 'none' })
+ return
+ }
+
+ const hasPermission = await ensureRecordPermission()
+ if (!hasPermission) return
+
+ recordingChildIdRef.current = character.childId
+ setRecording(true)
+ setRecordHint('松开发送留言')
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' })
+ recorderManager.start({
+ duration: 60000,
+ format: 'mp3',
+ numberOfChannels: 1,
+ sampleRate: 16000,
+ encodeBitRate: 96000,
+ })
} catch (error: any) {
- console.error('[chat-detail] send failed:', error)
+ recordingChildIdRef.current = null
+ setRecording(false)
+ setRecordHint('长按开始留言')
+ console.error('[chat-detail] recorder start failed:', error)
Taro.showToast({
- title: error?.message || '发送失败,请重试',
+ title: '无法开始录音,请重试',
icon: 'none',
})
- } finally {
- setSending(false)
}
}
+ const handleRecordStop = () => {
+ if (!recording) return
+ const recorderManager = recorderManagerRef.current
+ if (!recorderManager) return
+
+ setRecordHint('正在处理留言...')
+ recorderManager.stop()
+ }
+
const handlePlayAudio = (message: ChatMessage) => {
if (message.contentType !== 2 || !message.mediaUrl) {
Taro.showToast({ title: '语音地址不存在', icon: 'none' })
@@ -213,10 +322,13 @@ export default function ChatDetail() {
const renderMessageBody = (msg: ChatMessage) => {
if (msg.contentType === 2 && msg.mediaUrl) {
const isPlaying = playingMessageId === msg.id
+ const isLeaveMessage = msg.extJson?.message_kind === 'leave_message'
return (
handlePlayAudio(msg)}>
{isPlaying ? '[]' : '>'}
- {msg.mediaTranscriptText || '点击播放语音'}
+
+ {msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')}
+
{formatAudioDuration(msg.mediaDurationMs)}
)
@@ -250,7 +362,7 @@ export default function ChatDetail() {
) : messages.length === 0 ? (
- {character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'}
+ {character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
) : (
@@ -294,17 +406,18 @@ export default function ChatDetail() {
{character.canSend && character.childId && (
- setInputText(event.detail.value)}
- onConfirm={handleSend}
- />
-
- {sending ? '发送中' : '发送'}
+
+ {sending ? '正在发送留言...' : recordHint}
+
+
+
+ {sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
+
)}
diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss
index 18dd9a2..80464f7 100644
--- a/banban-mini/src/pages/device/index.scss
+++ b/banban-mini/src/pages/device/index.scss
@@ -10,71 +10,74 @@
padding: 80px 32px 24px;
.page-title {
+ display: block;
font-size: 48px;
+ line-height: 1.2;
font-weight: 700;
color: #1A1A1A;
- display: block;
- line-height: 1.2;
}
}
.status-card {
- background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
- border-radius: 28px;
- padding: 40px 36px;
- margin: 0 24px 32px;
position: relative;
overflow: hidden;
+ margin: 0 24px 32px;
+ padding: 40px 36px;
+ border-radius: 28px;
+ background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
&::before {
content: '';
position: absolute;
- right: -20px;
top: 50%;
- transform: translateY(-50%);
+ right: -20px;
width: 200px;
height: 200px;
- background: rgba(255, 255, 255, 0.1);
border-radius: 50%;
+ transform: translateY(-50%);
+ background: rgba(255, 255, 255, 0.1);
}
}
.status-badge {
display: inline-flex;
align-items: center;
- background: rgba(255, 255, 255, 0.25);
- border-radius: 20px;
- padding: 10px 20px;
margin-bottom: 24px;
+ padding: 10px 20px;
+ border-radius: 20px;
+ background: rgba(255, 255, 255, 0.25);
.status-dot {
width: 12px;
height: 12px;
- background: #4CD964;
- border-radius: 50%;
margin-right: 8px;
+ border-radius: 50%;
+ background: #4CD964;
}
.status-text {
font-size: 24px;
- color: #FFFFFF;
font-weight: 500;
+ color: #FFFFFF;
}
}
.battery-section {
+ position: relative;
+ z-index: 1;
+
.battery-percent {
+ display: block;
+ margin-bottom: 12px;
font-size: 72px;
+ line-height: 1;
font-weight: 700;
color: #FFFFFF;
- line-height: 1;
- margin-bottom: 12px;
- display: block;
.percent {
+ margin-left: 4px;
font-size: 32px;
font-weight: 600;
- margin-left: 4px;
}
}
@@ -84,44 +87,75 @@
}
}
+.status-meta {
+ position: relative;
+ z-index: 1;
+ display: flex;
+ flex-wrap: wrap;
+ gap: 12px;
+ margin-top: 24px;
+}
+
+.status-pill {
+ min-width: 140px;
+ padding: 12px 18px;
+ border-radius: 18px;
+ background: rgba(255, 255, 255, 0.18);
+}
+
+.status-pill-label {
+ display: block;
+ margin-bottom: 4px;
+ font-size: 22px;
+ color: rgba(255, 255, 255, 0.78);
+}
+
+.status-pill-value {
+ display: block;
+ font-size: 28px;
+ line-height: 1.2;
+ font-weight: 700;
+ color: #FFFFFF;
+}
+
.device-avatar {
position: absolute;
- right: 36px;
top: 50%;
- transform: translateY(-50%);
- width: 120px;
- height: 120px;
- background: rgba(255, 255, 255, 0.2);
- border-radius: 50%;
+ right: 36px;
display: flex;
align-items: center;
justify-content: center;
+ width: 120px;
+ height: 120px;
border: 3px solid rgba(255, 255, 255, 0.3);
+ border-radius: 50%;
+ transform: translateY(-50%);
+ background: rgba(255, 255, 255, 0.2);
}
.robot-icon {
.robot-img {
width: 56px;
height: 56px;
- background: #FFFFFF;
border-radius: 50%;
+ background: #FFFFFF;
}
}
.section-title {
+ display: block;
+ margin: 0 24px 20px;
font-size: 32px;
font-weight: 700;
color: #1A1A1A;
- margin: 0 24px 20px;
- display: block;
}
.control-card {
- background: #FFFFFF;
- border-radius: 16px;
- margin: 0 24px 24px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
overflow: hidden;
+ margin: 0 24px 24px;
+ border-radius: 16px;
+ background: #FFFFFF;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
}
.control-item {
@@ -135,62 +169,70 @@
align-items: stretch;
padding: 20px 24px 24px;
}
+}
- .volume-header {
- display: flex;
- align-items: center;
- margin-bottom: 16px;
+.control-left {
+ display: flex;
+ align-items: center;
+ flex: 1;
+ min-width: 0;
+}
- .control-left {
- flex: 1;
- }
+.icon-bg {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ width: 72px;
+ height: 72px;
+ margin-right: 20px;
+ border-radius: 50%;
+
+ &.purple {
+ background: #F0E6FF;
}
+ &.orange {
+ background: #FFF0E6;
+ }
+
+ .control-icon-img {
+ width: 36px;
+ height: 36px;
+ }
+}
+
+.control-info {
+ display: flex;
+ flex-direction: column;
+ min-width: 0;
+}
+
+.control-name {
+ margin-bottom: 6px;
+ font-size: 32px;
+ font-weight: 600;
+ color: #1A1A1A;
+}
+
+.control-detail {
+ font-size: 26px;
+ font-weight: 500;
+ color: #FF8C42;
+}
+
+.divider {
+ height: 1px;
+ margin: 0 24px;
+ background: #F0F0F0;
+}
+
+.volume-header {
+ display: flex;
+ align-items: center;
+ margin-bottom: 16px;
+
.control-left {
- display: flex;
- align-items: center;
flex: 1;
-
- .icon-bg {
- width: 72px;
- height: 72px;
- border-radius: 50%;
- display: flex;
- align-items: center;
- justify-content: center;
- margin-right: 20px;
-
- &.purple {
- background: #F0E6FF;
- }
-
- &.orange {
- background: #FFF0E6;
- }
-
- .control-icon-img {
- width: 36px;
- height: 36px;
- }
- }
-
- .control-info {
- display: flex;
- flex-direction: column;
-
- .control-name {
- font-size: 32px;
- font-weight: 600;
- color: #1A1A1A;
- margin-bottom: 6px;
- }
-
- .control-detail {
- font-size: 26px;
- color: #FF8C42;
- font-weight: 500;
- }
- }
}
}
@@ -200,9 +242,9 @@
padding: 0 8px;
.volume-icon {
+ flex-shrink: 0;
width: 24px;
height: 24px;
- flex-shrink: 0;
}
.slider {
@@ -211,90 +253,64 @@
}
}
-.divider {
- height: 1px;
- background: #F0F0F0;
- margin: 0 24px;
-}
-
-.quick-card {
- background: #FFFFFF;
- border-radius: 24px;
- margin: 0 24px 24px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
+.fold-card {
overflow: hidden;
-}
-
-.quick-grid {
- display: flex;
- justify-content: space-around;
- padding: 24px 16px;
-}
-
-.quick-item {
- display: flex;
- flex-direction: column;
- align-items: center;
- padding: 16px;
-
- &:active {
- opacity: 0.7;
- }
-}
-
-.quick-icon {
- width: 88px;
- height: 88px;
- border-radius: 20px;
- display: flex;
- align-items: center;
- justify-content: center;
- font-size: 40px;
- margin-bottom: 12px;
-
- &.bg-orange {
- background: #FEF3E8;
- }
-
- &.bg-green {
- background: #E8F8F0;
- }
-
- &.bg-blue {
- background: #E8F0FE;
- }
-}
-
-.quick-text {
- font-size: 26px;
- color: #666666;
- font-weight: 500;
-}
-
-.loading {
- min-height: 100%;
- display: flex;
- align-items: center;
- justify-content: center;
-
- text {
- font-size: 32px;
- color: #999999;
- }
-}
-
-.detail-card {
- background: #FFFFFF;
- border-radius: 20px;
margin: 0 24px 24px;
- padding: 10px 24px;
+ border-radius: 20px;
+ background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
}
+.fold-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 24px;
+}
+
+.fold-heading {
+ flex: 1;
+ min-width: 0;
+ margin-right: 20px;
+}
+
+.fold-title {
+ display: block;
+ font-size: 30px;
+ font-weight: 700;
+ color: #1A1A1A;
+}
+
+.fold-subtitle {
+ display: block;
+ margin-top: 8px;
+ font-size: 24px;
+ color: #666666;
+ word-break: break-all;
+}
+
+.fold-action {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-width: 88px;
+ height: 56px;
+ padding: 0 20px;
+ border-radius: 999px;
+ background: #FEF3E8;
+ font-size: 24px;
+ font-weight: 600;
+ color: #FF8C42;
+}
+
+.detail-body {
+ padding: 0 24px 10px;
+}
+
.detail-row {
display: flex;
- justify-content: space-between;
align-items: center;
+ justify-content: space-between;
padding: 20px 0;
border-bottom: 1px solid #F3F3F3;
@@ -311,29 +327,41 @@
.detail-value {
max-width: 60%;
font-size: 28px;
- color: #1A1A1A;
text-align: right;
word-break: break-all;
+ color: #1A1A1A;
+}
+
+.loading {
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ min-height: 100%;
+
+ text {
+ font-size: 32px;
+ color: #999999;
+ }
}
.empty-card {
- background: #FFFFFF;
- border-radius: 24px;
margin: 0 24px;
padding: 48px 32px;
+ border-radius: 24px;
+ background: #FFFFFF;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
text-align: center;
}
.empty-robot {
- width: 132px;
- height: 132px;
- background: #FEF3E8;
- border-radius: 50%;
- margin: 0 auto 24px;
display: flex;
align-items: center;
justify-content: center;
+ width: 132px;
+ height: 132px;
+ margin: 0 auto 24px;
+ border-radius: 50%;
+ background: #FEF3E8;
.robot-img {
width: 68px;
@@ -357,55 +385,40 @@
}
.empty-btn {
- margin-top: 28px;
- height: 92px;
- border-radius: 16px;
- background: #FF8C42;
display: flex;
align-items: center;
justify-content: center;
+ height: 92px;
+ margin-top: 28px;
+ border-radius: 16px;
+ background: #FF8C42;
}
.empty-btn-text {
font-size: 30px;
+ font-weight: 600;
color: #FFFFFF;
- font-weight: 600;
-}
-
-.helper-card {
- background: #FFFFFF;
- border-radius: 20px;
- margin: 0 24px 24px;
- padding: 28px 24px;
- box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
-}
-
-.helper-title {
- display: block;
- font-size: 30px;
- 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;
+ border-radius: 24px;
+ background: #FFFFFF;
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;
+ width: 96px;
+ height: 96px;
margin-right: 20px;
+ border-radius: 50%;
+ background: #FEF3E8;
}
.child-focus-icon {
diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx
index cd54cc6..a14b8a8 100644
--- a/banban-mini/src/pages/device/index.tsx
+++ b/banban-mini/src/pages/device/index.tsx
@@ -1,17 +1,53 @@
-import { View, Text, Switch, Slider, Image } from '@tarojs/components'
+import { View, Text, Slider, Image, Switch } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
import { Child } from '@/services/child'
+import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device'
import './index.scss'
+function formatTime(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 formatCoordinates(status: DeviceStatus | null): string {
+ if (!status || status.lat === null || status.lat === undefined || status.lng === null || status.lng === undefined) {
+ return '--'
+ }
+ return `${status.lat.toFixed(6)}, ${status.lng.toFixed(6)}`
+}
+
+function getSignalLabel(value?: number | null): string {
+ if (value === null || value === undefined) return '--'
+ const normalized = Number(value)
+ if (!Number.isFinite(normalized)) return '--'
+ if (normalized <= 4) {
+ if (normalized >= 3) return '强'
+ if (normalized >= 2) return '中'
+ return '弱'
+ }
+ if (normalized >= 67) return '强'
+ if (normalized >= 34) return '中'
+ return '弱'
+}
+
export default function Device() {
const [binding, setBinding] = useState(null)
const [child, setChild] = useState(null)
+ const [deviceStatus, setDeviceStatus] = useState(null)
const [isLoading, setIsLoading] = useState(true)
const [sleepEnabled, setSleepEnabled] = useState(true)
- const [volume, setVolume] = useState(50)
+ const [isDetailExpanded, setIsDetailExpanded] = useState(false)
+ const [isSavingVolume, setIsSavingVolume] = useState(false)
+ const [volumeValue, setVolumeValue] = useState(0)
useDidShow(() => {
void loadDeviceInfo()
@@ -28,6 +64,15 @@ export default function Device() {
const context = await loadCurrentChildBindingContext()
setChild(context.currentChild)
setBinding(context.currentBinding)
+
+ if (context.currentBinding?.device_id) {
+ const nextStatus = await getDeviceStatus(context.currentBinding.device_id)
+ setDeviceStatus(nextStatus)
+ setVolumeValue(nextStatus?.volume ?? 0)
+ } else {
+ setDeviceStatus(null)
+ setVolumeValue(0)
+ }
} catch (error: any) {
console.error('[device] load failed:', error)
Taro.showToast({
@@ -40,15 +85,45 @@ export default function Device() {
}
const handleSleepToggle = (e: any) => {
- setSleepEnabled(e.detail.value)
+ const nextValue = Boolean(e.detail?.value)
+ setSleepEnabled(nextValue)
Taro.showToast({
- title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭',
+ title: nextValue ? '休眠展示已开启' : '休眠展示已关闭',
icon: 'none',
})
}
- const handleVolumeChange = (e: any) => {
- setVolume(e.detail.value)
+ const handleToggleDetail = () => {
+ setIsDetailExpanded((current) => !current)
+ }
+
+ const handleVolumeChanging = (e: any) => {
+ setVolumeValue(Number(e.detail?.value || 0))
+ }
+
+ const handleVolumeCommit = async (e: any) => {
+ if (!binding?.device_id) return
+ const nextLevel = Number(e.detail?.value || 0)
+ const fallbackLevel = deviceStatus?.volume ?? volumeValue ?? 0
+ setVolumeValue(nextLevel)
+ setIsSavingVolume(true)
+
+ try {
+ await setDeviceVolume(nextLevel, binding.device_id)
+ setDeviceStatus((current) => (current ? { ...current, volume: nextLevel } : current))
+ Taro.showToast({
+ title: '音量指令已发送',
+ icon: 'success',
+ })
+ } catch (error: any) {
+ setVolumeValue(deviceStatus?.volume ?? fallbackLevel)
+ Taro.showToast({
+ title: error?.message || '音量设置失败',
+ icon: 'none',
+ })
+ } finally {
+ setIsSavingVolume(false)
+ }
}
if (isLoading) {
@@ -115,8 +190,12 @@ export default function Device() {
const childName = child.child_name || '未设置'
const pageTitle = `${childName} 的伴伴`
- const battery = 82
- const daysLeft = 4
+ const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
+ const batteryDisplay = batteryValue === null || batteryValue === undefined ? '--' : String(batteryValue)
+ const signalLabel = getSignalLabel(deviceStatus?.signal)
+ const versionLabel = deviceStatus?.version || '--'
+ const coordinateLabel = formatCoordinates(deviceStatus)
+ const volumeLabel = `${volumeValue}%`
return (
@@ -131,10 +210,20 @@ export default function Device() {
- {battery}
- %
+ {batteryDisplay}
+ {batteryDisplay !== '--' && %}
- 模拟电量展示 (预计续航 {daysLeft} 天)
+ 当前电量
+
+
+
+ 信号
+ {signalLabel}
+
+
+ 版本
+ {versionLabel}
+
@@ -143,31 +232,12 @@ export default function Device() {
-
-
- 当前儿童
- {childName}
-
-
- 当前设备
- {binding.device_id}
-
-
- 绑定时间
- {binding.bound_at || '--'}
-
-
-
- 基础控制
+ 设备控制
-
+
定时休眠
@@ -189,7 +259,10 @@ export default function Device() {
mode='aspectFit'
/>
- 外放音量
+
+ 外放音量
+ {isSavingVolume ? '发送中...' : `当前 ${volumeLabel}`}
+
@@ -202,8 +275,9 @@ export default function Device() {
className='slider'
min={0}
max={100}
- value={volume}
- onChange={handleVolumeChange}
+ value={volumeValue}
+ onChanging={handleVolumeChanging}
+ onChange={handleVolumeCommit}
activeColor='#FF8C42'
backgroundColor='#E5E5E5'
blockColor='#FF8C42'
@@ -216,6 +290,45 @@ export default function Device() {
+
+
+
+
+ 设备详情
+ {binding.device_id}
+
+ {isDetailExpanded ? '收起' : '展开'}
+
+
+ {isDetailExpanded && (
+
+
+ 当前儿童
+ {childName}
+
+
+ 当前设备
+ {binding.device_id}
+
+
+ 绑定时间
+ {formatTime(binding.bound_at)}
+
+
+ 当前坐标
+ {coordinateLabel}
+
+
+ 状态更新时间
+ {formatTime(deviceStatus?.settings_updated_at)}
+
+
+ 定位更新时间
+ {formatTime(deviceStatus?.location_updated_at)}
+
+
+ )}
+
)
}
diff --git a/banban-mini/src/pages/location/index.scss b/banban-mini/src/pages/location/index.scss
index 5edceae..74d393f 100644
--- a/banban-mini/src/pages/location/index.scss
+++ b/banban-mini/src/pages/location/index.scss
@@ -157,6 +157,17 @@
}
}
+.location-empty-shell {
+ margin: 0 24px;
+}
+
+.location-empty-card {
+ background: #FFFFFF;
+ border-radius: 20px;
+ padding: 32px 24px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
+}
+
.location-stack {
display: flex;
flex-direction: column;
@@ -187,3 +198,19 @@
color: #666666;
line-height: 1.5;
}
+
+.location-empty-action {
+ margin-top: 24px;
+ height: 88px;
+ border-radius: 16px;
+ background: #FF8C42;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.location-empty-action-text {
+ font-size: 30px;
+ color: #FFFFFF;
+ font-weight: 600;
+}
diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx
index eb59e99..ab83d11 100644
--- a/banban-mini/src/pages/location/index.tsx
+++ b/banban-mini/src/pages/location/index.tsx
@@ -2,6 +2,7 @@ import { View, Text, Map } from '@tarojs/components'
import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
+import { loadCurrentChildBindingContext } from '@/services/binding'
import {
getCurrentDeviceLocation,
getDeviceTrajectory,
@@ -26,8 +27,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 {
@@ -46,6 +47,56 @@ function formatAccuracy(value?: number | null): string {
return `${value} 米`
}
+function formatOptionalNumber(value?: number | null, digits = 1): string | null {
+ if (value === undefined || value === null) return null
+ return Number(value).toFixed(digits)
+}
+
+function buildLocationDetailLines(
+ point: DeviceLocation | DeviceTrajectoryPoint,
+ options?: { includeIdentity?: boolean }
+): string[] {
+ const lines: string[] = []
+
+ if (options?.includeIdentity) {
+ lines.push(`设备 ${point.device_id} · ${point.child_name || '未命名儿童'}`)
+ }
+
+ lines.push(`上报时间 ${formatTime(point.device_time)}`)
+
+ if (point.server_time) {
+ lines.push(`服务端时间 ${formatTime(point.server_time)}`)
+ }
+ if (point.coord_type) {
+ lines.push(`坐标系 ${point.coord_type}`)
+ }
+ if (point.accuracy_m !== null && point.accuracy_m !== undefined) {
+ lines.push(`定位精度 ${formatAccuracy(point.accuracy_m)}`)
+ }
+
+ const altitudeText = formatOptionalNumber(point.altitude_m)
+ if (altitudeText) {
+ lines.push(`海拔 ${altitudeText} 米`)
+ }
+
+ const speedText = formatOptionalNumber(point.speed_mps)
+ if (speedText) {
+ lines.push(`速度 ${speedText} 米/秒`)
+ }
+
+ if (point.heading_deg !== null && point.heading_deg !== undefined) {
+ lines.push(`方向 ${point.heading_deg}°`)
+ }
+ if (point.source !== null && point.source !== undefined && point.source > 0) {
+ lines.push(`定位来源 ${point.source}`)
+ }
+ if (point.battery_pct !== null && point.battery_pct !== undefined) {
+ lines.push(`设备电量 ${point.battery_pct}%`)
+ }
+
+ return lines
+}
+
function getTodayStartISOString(): string {
const now = new Date()
now.setHours(0, 0, 0, 0)
@@ -72,6 +123,9 @@ export default function Location() {
const [selectedPoint, setSelectedPoint] = useState(null)
const [trajectoryMode, setTrajectoryMode] = useState('current')
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
+ const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
+ null
+ )
useDidShow(() => {
void loadLocation()
@@ -90,13 +144,44 @@ export default function Location() {
}
setLoading(true)
+ setEmptyState(null)
if (nextMode) {
setTrajectoryMode(nextMode)
setSelectedPoint(null)
}
try {
- const currentLocation = await getCurrentDeviceLocation()
+ const context = await loadCurrentChildBindingContext()
+ if (!context.currentChild) {
+ setDeviceLocation(null)
+ setTrajectory([])
+ setSelectedPoint(null)
+ setCoordinates(DEFAULT_COORDINATES)
+ setEmptyState({
+ title: '还没有当前孩子',
+ desc: '请先去设置与管理页创建或选择一个孩子,再查看定位。',
+ actionText: '去设置与管理',
+ actionUrl: '/pages/sleep/index',
+ })
+ return
+ }
+
+ const resolvedDeviceId = context.currentBinding?.device_id || ''
+ if (!resolvedDeviceId) {
+ setDeviceLocation(null)
+ setTrajectory([])
+ setSelectedPoint(null)
+ setCoordinates(DEFAULT_COORDINATES)
+ setEmptyState({
+ title: '当前孩子还没有绑定设备',
+ desc: '先给当前孩子绑定一台设备,定位和轨迹页才会有内容。',
+ actionText: '去绑定设备',
+ actionUrl: '/pages/bind/index',
+ })
+ return
+ }
+
+ const currentLocation = await getCurrentDeviceLocation(resolvedDeviceId)
setDeviceLocation(currentLocation)
const nextCoordinates = currentLocation
@@ -109,12 +194,14 @@ export default function Location() {
let points: DeviceTrajectoryPoint[] = []
if (targetMode === 'today') {
const response = await getDeviceTrajectory({
+ deviceId: resolvedDeviceId,
startAt: getTodayStartISOString(),
limit: 200,
})
points = response?.items || []
} else if (targetMode === 'recent') {
const response = await getDeviceTrajectory({
+ deviceId: resolvedDeviceId,
startAt: getRecentStartISOString(72),
limit: 100,
})
@@ -267,128 +354,144 @@ export default function Location() {
设备定位
-
- {MODE_OPTIONS.map((item) => (
- {
- if (trajectoryMode === item.key) return
- void loadLocation(false, item.key)
- }}
- >
- {item.label}
-
- ))}
-
-
-
-
-
-
-
-
- {summaryTitle}
-
- {loading
- ? '正在获取设备位置...'
- : trajectoryMode === 'current'
- ? `更新于 ${formatTime(deviceLocation?.updated_at)}`
- : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
-
-
- loadLocation(true)}>
- ↻
+ {emptyState ? (
+
+
+ {emptyState.title}
+ {emptyState.desc}
+ {
+ if (emptyState.actionUrl === '/pages/sleep/index') {
+ Taro.switchTab({ url: emptyState.actionUrl })
+ return
+ }
+ Taro.navigateTo({ url: emptyState.actionUrl })
+ }}
+ >
+ {emptyState.actionText}
+
+ ) : (
+ <>
+
+ {MODE_OPTIONS.map((item) => (
+ {
+ if (trajectoryMode === item.key) return
+ void loadLocation(false, item.key)
+ }}
+ >
+ {item.label}
+
+ ))}
+
- {trajectoryMode === 'current' ? (
- deviceLocation ? (
-
-
- 📍
+
+
+
+
+
+
+ {summaryTitle}
+
+ {loading
+ ? '正在获取设备位置...'
+ : trajectoryMode === 'current'
+ ? `更新于 ${formatTime(deviceLocation?.updated_at)}`
+ : `轨迹点 ${trajectory.length} 个 · 最近更新 ${formatTime(trajectory[trajectory.length - 1]?.device_time)}`}
+
-
-
- {deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
-
-
- 设备 {deviceLocation.device_id} · {deviceLocation.child_name || '未命名儿童'}
-
- 上报时间 {formatTime(deviceLocation.device_time)}
-
- 坐标系 {deviceLocation.coord_type} · 精度 {formatAccuracy(deviceLocation.accuracy_m)}
-
- {deviceLocation.battery_pct !== null && deviceLocation.battery_pct !== undefined && (
- 设备电量 {deviceLocation.battery_pct}%
+ loadLocation(true)}>
+ ↻
+
+
+
+ {trajectoryMode === 'current' ? (
+ deviceLocation ? (
+
+
+ 📍
+
+
+
+ {deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
+
+ {buildLocationDetailLines(deviceLocation, { includeIdentity: true }).map((line, index) => (
+
+ {line}
+
+ ))}
+
+
+ ) : (
+
+ 当前孩子暂无设备位置
+
+ )
+ ) : trajectory.length > 0 ? (
+
+ {selectedPoint && (
+
+
+ ●
+
+
+
+ {selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
+
+ {buildLocationDetailLines(selectedPoint).map((line, index) => (
+
+ {line}
+
+ ))}
+
+
)}
+
+
+
+ 🛣️
+
+
+ 共 {trajectory.length} 个轨迹点
+
+ 起点 {formatTime(trajectory[0].device_time)} · {trajectory[0].lat.toFixed(6)}, {trajectory[0].lng.toFixed(6)}
+
+
+ 终点 {formatTime(trajectory[trajectory.length - 1].device_time)} ·{' '}
+ {trajectory[trajectory.length - 1].lat.toFixed(6)}, {trajectory[trajectory.length - 1].lng.toFixed(6)}
+
+
+
-
- ) : (
-
- 当前孩子暂无设备位置
-
- )
- ) : trajectory.length > 0 ? (
-
- {selectedPoint && (
-
-
- ●
-
-
-
- {selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
-
- 上报时间 {formatTime(selectedPoint.device_time)}
-
- 坐标系 {selectedPoint.coord_type} · 精度 {formatAccuracy(selectedPoint.accuracy_m)}
-
- {selectedPoint.battery_pct !== null && selectedPoint.battery_pct !== undefined && (
- 设备电量 {selectedPoint.battery_pct}%
- )}
-
+ ) : (
+
+ 暂无轨迹
+
+ {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
+
)}
-
-
-
- 🛣️
-
-
- 共 {trajectory.length} 个轨迹点
-
- 起点 {formatTime(trajectory[0].device_time)} · {trajectory[0].lat.toFixed(6)}, {trajectory[0].lng.toFixed(6)}
-
-
- 终点 {formatTime(trajectory[trajectory.length - 1].device_time)} ·{' '}
- {trajectory[trajectory.length - 1].lat.toFixed(6)}, {trajectory[trajectory.length - 1].lng.toFixed(6)}
-
-
-
- ) : (
-
- 暂无轨迹
-
- {trajectoryMode === 'today' ? '当前孩子今天还没有新的历史点位。' : '当前孩子最近没有新的历史点位。'}
-
-
- )}
-
+ >
+ )}
)
}
diff --git a/banban-mini/src/pages/login/index.scss b/banban-mini/src/pages/login/index.scss
index 26c0287..e0b1763 100644
--- a/banban-mini/src/pages/login/index.scss
+++ b/banban-mini/src/pages/login/index.scss
@@ -72,6 +72,33 @@
}
}
+.nickname-card {
+ background: #FFFFFF;
+ border-radius: 20px;
+ padding: 28px 24px;
+ margin-bottom: 32px;
+ box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
+
+ .nickname-label {
+ display: block;
+ font-size: 28px;
+ color: #1A1A1A;
+ font-weight: 600;
+ margin-bottom: 18px;
+ }
+
+ .nickname-input {
+ width: 100%;
+ height: 88px;
+ box-sizing: border-box;
+ padding: 0 24px;
+ border-radius: 16px;
+ background: #F5F7FA;
+ font-size: 30px;
+ color: #1A1A1A;
+ }
+}
+
.login-btn-wrapper {
margin-bottom: 32px;
diff --git a/banban-mini/src/pages/login/index.tsx b/banban-mini/src/pages/login/index.tsx
index 1bc9834..810a2b3 100644
--- a/banban-mini/src/pages/login/index.tsx
+++ b/banban-mini/src/pages/login/index.tsx
@@ -1,4 +1,4 @@
-import { View, Text, Button } from '@tarojs/components'
+import { View, Text, Button, Input } from '@tarojs/components'
import { useEffect, useState } from 'react'
import Taro from '@tarojs/taro'
import { getToken, wechatLogin } from '@/services/auth'
@@ -6,16 +6,31 @@ import './index.scss'
export default function Login() {
const [submitting, setSubmitting] = useState(false)
+ const [nickname, setNickname] = useState('')
useEffect(() => {
if (getToken()) {
Taro.reLaunch({ url: '/pages/device/index' })
+ return
+ }
+
+ const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
+ nickname?: string
+ }
+ if (cachedUserInfo.nickname) {
+ setNickname(cachedUserInfo.nickname)
}
}, [])
const handleLogin = async () => {
if (submitting) return
+ const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
+ nickname?: string
+ avatar_url?: string
+ }
+ const normalizedNickname = nickname.trim() || cachedUserInfo.nickname?.trim() || ''
+
setSubmitting(true)
Taro.showLoading({ title: '登录中...' })
@@ -25,35 +40,26 @@ export default function Login() {
throw new Error('未获取到微信登录凭证')
}
- let userInfo:
- | {
- nickName?: string
- avatarUrl?: string
- }
- | null = null
-
- try {
- const profileRes = await Taro.getUserProfile({
- desc: '用于完善家长资料展示',
- })
- userInfo = profileRes.userInfo || null
- } catch (error) {
- console.log('[login] getUserProfile skipped:', error)
- }
-
- await wechatLogin({
+ const session = await wechatLogin({
code: loginRes.code,
- nickname: userInfo?.nickName,
- avatar_url: userInfo?.avatarUrl,
+ nickname: normalizedNickname || undefined,
+ avatar_url: cachedUserInfo.avatar_url,
})
- if (userInfo) {
+ const nextUserInfo = {
+ nickname: session.nickname || normalizedNickname,
+ avatar_url: cachedUserInfo.avatar_url,
+ }
+
+ if (nextUserInfo.nickname || nextUserInfo.avatar_url) {
Taro.setStorageSync('userInfo', {
- nickname: userInfo.nickName,
- avatar_url: userInfo.avatarUrl,
+ nickname: nextUserInfo.nickname,
+ avatar_url: nextUserInfo.avatar_url,
})
+ setNickname(nextUserInfo.nickname || '')
} else {
Taro.removeStorageSync('userInfo')
+ setNickname('')
}
Taro.showToast({ title: '登录成功', icon: 'success' })
@@ -100,6 +106,18 @@ export default function Login() {
+
+ 家长昵称
+ setNickname(event.detail.value)}
+ />
+
+
- {parentInfo.nickname || '家长用户'}
- 用户 ID: {userId || '--'}
+ {parentDisplayName}
{currentChild ? '已选择当前孩子' : '未选择孩子'}
@@ -342,6 +358,11 @@ export default function Sleep() {
})}
)}
+
+
+ + 新增孩子
+
+
setShowChildModal(false)}>
关闭
diff --git a/banban-mini/src/services/api.ts b/banban-mini/src/services/api.ts
index c6ff480..407b8ba 100644
--- a/banban-mini/src/services/api.ts
+++ b/banban-mini/src/services/api.ts
@@ -4,6 +4,10 @@ import { getCurrentUserId, getToken, handleUnauthorized } from './session'
const BASE_URL = API_BASE_URL
const REQUEST_TIMEOUT_MS = 10000
+const ERROR_DETAIL_MAP: Record = {
+ 'Request failed': '请求失败',
+ 'device is already bound, unbind it before binding again': '设备已绑定,请先解绑后再重新绑定',
+}
class ApiError extends Error {
constructor(public status: number, message: string) {
@@ -12,14 +16,14 @@ class ApiError extends Error {
}
function getErrorDetail(data: any): string {
- if (!data) return 'Request failed'
+ if (!data) return ERROR_DETAIL_MAP['Request failed']
if (typeof data.errMsg === 'string') return data.errMsg
- if (typeof data.detail === 'string') return data.detail
+ if (typeof data.detail === 'string') return ERROR_DETAIL_MAP[data.detail] || data.detail
if (Array.isArray(data.detail)) {
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
}
if (typeof data.message === 'string') return data.message
- return 'Request failed'
+ return ERROR_DETAIL_MAP['Request failed']
}
async function request(
diff --git a/banban-mini/src/services/auth.ts b/banban-mini/src/services/auth.ts
index cc09b57..e5f404a 100644
--- a/banban-mini/src/services/auth.ts
+++ b/banban-mini/src/services/auth.ts
@@ -17,6 +17,7 @@ export interface LoginSession {
token_type: string
expires_in: number
user_id: number
+ nickname?: string
}
export interface Parent {
diff --git a/banban-mini/src/services/chat.ts b/banban-mini/src/services/chat.ts
index 55d71ab..3d8b68f 100644
--- a/banban-mini/src/services/chat.ts
+++ b/banban-mini/src/services/chat.ts
@@ -1,6 +1,8 @@
+import Taro from '@tarojs/taro'
import { getCurrentUserId } from './auth'
-import { request } from './api'
+import { ApiError, BASE_URL, getToken, request } from './api'
import { loadCurrentChildBindingContext } from './binding'
+import { handleUnauthorized } from './session'
export type ConversationSource = 'im' | 'ai'
export type PeerKind = 'child' | 'parent' | 'ai'
@@ -49,6 +51,7 @@ export interface ChatMessage {
senderName?: string
receiverName?: string
channelLabel?: string
+ extJson?: Record | null
}
export interface Character {
@@ -201,10 +204,18 @@ function formatDetailTime(value?: string | null): string {
}
function formatImPreview(
- item: Pick
+ item: Pick<
+ ChildConversationMessageItem,
+ 'content_type' | 'content_text' | 'media_transcript_text' | 'content_json' | 'ext_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 === 2) {
+ if (item.ext_json?.message_kind === 'leave_message') {
+ return item.media_transcript_text || '[留言]'
+ }
+ 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()
@@ -361,7 +372,7 @@ function createSyntheticParentConversation(context: BindingContext): ChatConvers
avatar: PEER_META.parent.icon,
typeLabel: '家长沟通',
description: '家长通过微信小程序和孩子直接沟通',
- lastMessage: '还没有消息,点进去发送第一条',
+ lastMessage: '还没有留言,点进去发送第一条',
time: '',
sortAt: '',
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
@@ -471,6 +482,7 @@ export async function getMessages(
senderId: item.sender_id,
receiverType: item.receiver_type,
receiverId: item.receiver_id,
+ extJson: item.ext_json || null,
senderName:
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
? '我'
@@ -594,3 +606,68 @@ export async function sendParentMessage(childId: number, content: string): Promi
},
})
}
+
+function parseUploadResponseData(rawData: any): any {
+ if (typeof rawData !== 'string') return rawData
+ try {
+ return JSON.parse(rawData)
+ } catch {
+ return { detail: rawData }
+ }
+}
+
+function getUploadErrorMessage(data: any): string {
+ if (!data) return '留言发送失败'
+ if (typeof data.detail === 'string') return data.detail
+ if (typeof data.message === 'string') return data.message
+ if (typeof data.errMsg === 'string') return data.errMsg
+ return '留言发送失败'
+}
+
+export async function sendParentVoiceMessage(
+ childId: number,
+ options: { filePath: string; durationMs: number; transcriptText?: string }
+): Promise {
+ if (!childId) {
+ throw new Error('当前会话不支持留言')
+ }
+ if (!options.filePath) {
+ throw new Error('录音文件不存在')
+ }
+
+ const token = getToken()
+ const requestUrl = `${BASE_URL}/banban/children/${childId}/voice-message`
+ const uploadResponse: any = await new Promise((resolve, reject) => {
+ Taro.uploadFile({
+ url: requestUrl,
+ filePath: options.filePath,
+ name: 'file',
+ header: token
+ ? {
+ Authorization: `Bearer ${token}`,
+ }
+ : {},
+ formData: {
+ duration_ms: `${Math.max(1, Math.round(options.durationMs || 0))}`,
+ client_msg_id: createClientMessageId(),
+ transcript_text: options.transcriptText || '',
+ },
+ success: resolve,
+ fail: reject,
+ })
+ }).catch((error: any) => {
+ const message = String(error?.errMsg || error?.message || 'request:fail')
+ throw new ApiError(0, message)
+ })
+
+ if (uploadResponse.statusCode === 401) {
+ handleUnauthorized({ redirect: true })
+ }
+
+ const data = parseUploadResponseData(uploadResponse.data)
+ if (uploadResponse.statusCode >= 400) {
+ throw new ApiError(uploadResponse.statusCode, getUploadErrorMessage(data))
+ }
+
+ return data as ConversationMessageCreateResponse
+}
diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts
new file mode 100644
index 0000000..c7e875a
--- /dev/null
+++ b/banban-mini/src/services/device.ts
@@ -0,0 +1,62 @@
+import { request } from './api'
+import { loadCurrentChildBindingContext } from './binding'
+
+export interface DeviceStatus {
+ device_id: string
+ child_id?: number | null
+ child_name?: string | null
+ power?: number | null
+ volume?: number | null
+ signal?: number | null
+ version?: string | null
+ settings_updated_at?: string | null
+ coord_type?: string | null
+ lat?: number | null
+ lng?: number | null
+ accuracy_m?: number | null
+ altitude_m?: number | null
+ speed_mps?: number | null
+ heading_deg?: number | null
+ source?: number | null
+ battery_pct?: number | null
+ device_time?: string | null
+ server_time?: string | null
+ location_updated_at?: string | null
+}
+
+export interface DeviceVolumeUpdateResponse {
+ device_id: string
+ level: number
+ msg_id: string
+}
+
+async function resolveDeviceId(deviceId?: string): Promise {
+ const normalizedDeviceId = String(deviceId || '').trim()
+ if (normalizedDeviceId) return normalizedDeviceId
+ const context = await loadCurrentChildBindingContext()
+ return context.currentBinding?.device_id || ''
+}
+
+export async function getDeviceStatus(deviceId?: string): Promise {
+ const resolvedDeviceId = await resolveDeviceId(deviceId)
+ if (!resolvedDeviceId) return null
+
+ try {
+ return await request(`/banban/devices/${resolvedDeviceId}/status`)
+ } catch (error: any) {
+ if (error?.status === 404) return null
+ throw error
+ }
+}
+
+export async function setDeviceVolume(level: number, deviceId?: string): Promise {
+ const resolvedDeviceId = await resolveDeviceId(deviceId)
+ if (!resolvedDeviceId) {
+ throw new Error('当前没有可用设备')
+ }
+
+ return request(`/banban/devices/${resolvedDeviceId}/volume`, {
+ method: 'POST',
+ data: { level },
+ })
+}