合入小程序修改内容
This commit is contained in:
@@ -27,19 +27,39 @@
|
|||||||
|
|
||||||
.bind-header {
|
.bind-header {
|
||||||
padding: 80px 32px 32px;
|
padding: 80px 32px 32px;
|
||||||
|
position: relative;
|
||||||
text-align: center;
|
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 {
|
.title {
|
||||||
font-size: 44px;
|
font-size: 44px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 12px;
|
padding: 0 120px;
|
||||||
}
|
|
||||||
|
|
||||||
.subtitle {
|
|
||||||
font-size: 28px;
|
|
||||||
color: #666666;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,14 @@ export default function Bind() {
|
|||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
if (getCurrentPages().length > 1) {
|
||||||
|
Taro.navigateBack()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.switchTab({ url: '/pages/device/index' })
|
||||||
|
}
|
||||||
|
|
||||||
const stopPolling = () => {
|
const stopPolling = () => {
|
||||||
if (pollingRef.current) {
|
if (pollingRef.current) {
|
||||||
clearTimeout(pollingRef.current)
|
clearTimeout(pollingRef.current)
|
||||||
@@ -123,10 +131,10 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const schedulePoll = () => {
|
const schedulePoll = (nextBindToken?: string) => {
|
||||||
stopPolling()
|
stopPolling()
|
||||||
pollingRef.current = setTimeout(() => {
|
pollingRef.current = setTimeout(() => {
|
||||||
void pollBindSession()
|
void pollBindSession(nextBindToken)
|
||||||
}, 1500)
|
}, 1500)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,17 +146,18 @@ export default function Bind() {
|
|||||||
setBindHint('')
|
setBindHint('')
|
||||||
}
|
}
|
||||||
|
|
||||||
const pollBindSession = async () => {
|
const pollBindSession = async (nextBindToken?: string) => {
|
||||||
if (!bindToken) return
|
const activeBindToken = (nextBindToken || bindToken).trim()
|
||||||
|
if (!activeBindToken) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const session = await getNFCBindSession(bindToken)
|
const session = await getNFCBindSession(activeBindToken)
|
||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setCardUUID(session.card_uuid || '')
|
setCardUUID(session.card_uuid || '')
|
||||||
|
|
||||||
if (session.status === SESSION_STATUS_PENDING) {
|
if (session.status === SESSION_STATUS_PENDING) {
|
||||||
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
setBindHint('已发送绑卡指令,请拿自己的卡去设备上贴一下')
|
||||||
schedulePoll()
|
schedulePoll(activeBindToken)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,7 +312,7 @@ export default function Bind() {
|
|||||||
setBindToken(session.bind_token)
|
setBindToken(session.bind_token)
|
||||||
setBindStatus(session.status)
|
setBindStatus(session.status)
|
||||||
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
setBindHint('已发送绑卡指令,请去设备上贴自己的卡')
|
||||||
schedulePoll()
|
schedulePoll(session.bind_token)
|
||||||
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
Taro.showToast({ title: '请去设备上贴卡', icon: 'none' })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[bind] submit failed:', error)
|
console.error('[bind] submit failed:', error)
|
||||||
@@ -331,12 +340,11 @@ export default function Bind() {
|
|||||||
return (
|
return (
|
||||||
<View className='bind-page'>
|
<View className='bind-page'>
|
||||||
<View className='bind-header'>
|
<View className='bind-header'>
|
||||||
|
<View className='back-btn' onClick={handleBack}>
|
||||||
|
<Image className='back-icon' src={require('../../assets/tab-icons/arrow-left.png')} mode='aspectFit' />
|
||||||
|
<Text className='back-text'>返回</Text>
|
||||||
|
</View>
|
||||||
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
<Text className='title'>{isPendingBinding ? '补全绑定' : '扫码贴卡绑定设备'}</Text>
|
||||||
<Text className='subtitle'>
|
|
||||||
{isPendingBinding
|
|
||||||
? '当前设备已存在待补全关系,请先补充儿童资料'
|
|
||||||
: '先选择儿童,再扫码设备,系统会下发绑卡指令,随后去设备上贴卡确认'}
|
|
||||||
</Text>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='scan-area'>
|
<View className='scan-area'>
|
||||||
|
|||||||
@@ -218,36 +218,41 @@
|
|||||||
border-top: 1px solid #EEEEEE;
|
border-top: 1px solid #EEEEEE;
|
||||||
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
|
padding: 18px 20px calc(18px + env(safe-area-inset-bottom));
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
gap: 16px;
|
gap: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-input {
|
.composer-tip {
|
||||||
flex: 1;
|
text-align: center;
|
||||||
min-height: 76px;
|
|
||||||
background: #F5F7FA;
|
text {
|
||||||
border-radius: 38px;
|
font-size: 24px;
|
||||||
padding: 0 24px;
|
color: #999999;
|
||||||
font-size: 28px;
|
}
|
||||||
color: #1A1A1A;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-send {
|
.composer-record {
|
||||||
min-width: 132px;
|
width: 100%;
|
||||||
height: 76px;
|
min-height: 88px;
|
||||||
border-radius: 38px;
|
border-radius: 44px;
|
||||||
background: #FF8C42;
|
background: #FF8C42;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
box-shadow: 0 10px 24px rgba(255, 140, 66, 0.2);
|
||||||
|
|
||||||
|
&.recording {
|
||||||
|
background: #FF6A3D;
|
||||||
|
}
|
||||||
|
|
||||||
&.disabled {
|
&.disabled {
|
||||||
opacity: 0.55;
|
opacity: 0.55;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.composer-send-text {
|
.composer-record-text {
|
||||||
font-size: 28px;
|
font-size: 30px;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
letter-spacing: 2px;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 { useEffect, useRef, useState } from 'react'
|
||||||
import Taro, { useRouter } from '@tarojs/taro'
|
import Taro, { useRouter } from '@tarojs/taro'
|
||||||
import {
|
import {
|
||||||
getMessages,
|
getMessages,
|
||||||
getCharacter,
|
getCharacter,
|
||||||
sendParentMessage,
|
sendParentVoiceMessage,
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
Character,
|
Character,
|
||||||
ConversationSource,
|
ConversationSource,
|
||||||
@@ -88,10 +88,15 @@ export default function ChatDetail() {
|
|||||||
})
|
})
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [inputText, setInputText] = useState('')
|
|
||||||
const [sending, setSending] = useState(false)
|
const [sending, setSending] = useState(false)
|
||||||
|
const [recording, setRecording] = useState(false)
|
||||||
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
const [playingMessageId, setPlayingMessageId] = useState<number | null>(null)
|
||||||
|
const [recordHint, setRecordHint] = useState('长按开始留言')
|
||||||
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
const audioContextRef = useRef<Taro.InnerAudioContext | null>(null)
|
||||||
|
const recorderManagerRef = useRef<Taro.RecorderManager | null>(null)
|
||||||
|
const activeConversationIdRef = useRef(initialConversationId)
|
||||||
|
const latestLoadChatDataRef = useRef<(conversationId: number) => Promise<void>>(async () => {})
|
||||||
|
const recordingChildIdRef = useRef<number | null>(null)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const audioContext = Taro.createInnerAudioContext()
|
const audioContext = Taro.createInnerAudioContext()
|
||||||
@@ -114,6 +119,10 @@ export default function ChatDetail() {
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
activeConversationIdRef.current = activeConversationId
|
||||||
|
}, [activeConversationId])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
void loadChatData(activeConversationId)
|
void loadChatData(activeConversationId)
|
||||||
}, [activeConversationId, conversationSource, conversationPeerKind])
|
}, [activeConversationId, conversationSource, conversationPeerKind])
|
||||||
@@ -152,37 +161,137 @@ export default function ChatDetail() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleBack = () => {
|
latestLoadChatDataRef.current = loadChatData
|
||||||
Taro.navigateBack()
|
|
||||||
|
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 handleSend = async () => {
|
const durationMs = Math.max(0, Number(result.duration || 0))
|
||||||
const normalizedContent = inputText.trim()
|
if (durationMs < 800) {
|
||||||
if (!normalizedContent || sending) return
|
Taro.showToast({ title: '留言太短,请重试', icon: 'none' })
|
||||||
if (!character.canSend || !character.childId) {
|
|
||||||
Taro.showToast({ title: '当前会话不支持发送', icon: 'none' })
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
setSending(true)
|
setSending(true)
|
||||||
try {
|
try {
|
||||||
const response = await sendParentMessage(character.childId, normalizedContent)
|
const response = await sendParentVoiceMessage(currentChildId, {
|
||||||
setInputText('')
|
filePath: result.tempFilePath,
|
||||||
if (response.conversation_id && response.conversation_id !== activeConversationId) {
|
durationMs,
|
||||||
|
})
|
||||||
|
if (response.conversation_id && response.conversation_id !== activeConversationIdRef.current) {
|
||||||
setActiveConversationId(response.conversation_id)
|
setActiveConversationId(response.conversation_id)
|
||||||
} else {
|
} else {
|
||||||
await loadChatData(activeConversationId)
|
await latestLoadChatDataRef.current(activeConversationIdRef.current)
|
||||||
}
|
}
|
||||||
Taro.showToast({ title: '已发送', icon: 'success' })
|
Taro.showToast({ title: '留言已发送', icon: 'success' })
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[chat-detail] send failed:', error)
|
console.error('[chat-detail] voice send failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: error?.message || '发送失败,请重试',
|
title: error?.message || '留言发送失败,请重试',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
} finally {
|
} finally {
|
||||||
setSending(false)
|
setSending(false)
|
||||||
}
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
recorderManagerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBack = () => {
|
||||||
|
Taro.navigateBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
const ensureRecordPermission = async (): Promise<boolean> => {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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 {
|
||||||
|
recorderManager.start({
|
||||||
|
duration: 60000,
|
||||||
|
format: 'mp3',
|
||||||
|
numberOfChannels: 1,
|
||||||
|
sampleRate: 16000,
|
||||||
|
encodeBitRate: 96000,
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
recordingChildIdRef.current = null
|
||||||
|
setRecording(false)
|
||||||
|
setRecordHint('长按开始留言')
|
||||||
|
console.error('[chat-detail] recorder start failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: '无法开始录音,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleRecordStop = () => {
|
||||||
|
if (!recording) return
|
||||||
|
const recorderManager = recorderManagerRef.current
|
||||||
|
if (!recorderManager) return
|
||||||
|
|
||||||
|
setRecordHint('正在处理留言...')
|
||||||
|
recorderManager.stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
const handlePlayAudio = (message: ChatMessage) => {
|
const handlePlayAudio = (message: ChatMessage) => {
|
||||||
@@ -213,10 +322,13 @@ export default function ChatDetail() {
|
|||||||
const renderMessageBody = (msg: ChatMessage) => {
|
const renderMessageBody = (msg: ChatMessage) => {
|
||||||
if (msg.contentType === 2 && msg.mediaUrl) {
|
if (msg.contentType === 2 && msg.mediaUrl) {
|
||||||
const isPlaying = playingMessageId === msg.id
|
const isPlaying = playingMessageId === msg.id
|
||||||
|
const isLeaveMessage = msg.extJson?.message_kind === 'leave_message'
|
||||||
return (
|
return (
|
||||||
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
|
<View className={`audio-bubble ${isPlaying ? 'playing' : ''}`} onClick={() => handlePlayAudio(msg)}>
|
||||||
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
|
<Text className='audio-bubble-icon'>{isPlaying ? '[]' : '>'}</Text>
|
||||||
<Text className='audio-bubble-text'>{msg.mediaTranscriptText || '点击播放语音'}</Text>
|
<Text className='audio-bubble-text'>
|
||||||
|
{msg.mediaTranscriptText || (isLeaveMessage ? '点击收听留言' : '点击播放语音')}
|
||||||
|
</Text>
|
||||||
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -250,7 +362,7 @@ export default function ChatDetail() {
|
|||||||
) : messages.length === 0 ? (
|
) : messages.length === 0 ? (
|
||||||
<View className='empty-chat'>
|
<View className='empty-chat'>
|
||||||
<Text className='empty-chat-text'>
|
<Text className='empty-chat-text'>
|
||||||
{character.canSend ? '还没有消息,发送第一条吧' : '这个会话还没有消息'}
|
{character.canSend ? '还没有留言,长按发送第一条吧' : '这个会话还没有消息'}
|
||||||
</Text>
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -294,17 +406,18 @@ export default function ChatDetail() {
|
|||||||
|
|
||||||
{character.canSend && character.childId && (
|
{character.canSend && character.childId && (
|
||||||
<View className='composer'>
|
<View className='composer'>
|
||||||
<Input
|
<View className='composer-tip'>
|
||||||
className='composer-input'
|
<Text>{sending ? '正在发送留言...' : recordHint}</Text>
|
||||||
value={inputText}
|
</View>
|
||||||
placeholder='输入要发给孩子的消息'
|
<View
|
||||||
maxlength={500}
|
className={`composer-record ${recording ? 'recording' : ''} ${sending ? 'disabled' : ''}`}
|
||||||
confirmType='send'
|
onLongPress={handleRecordStart}
|
||||||
onInput={(event) => setInputText(event.detail.value)}
|
onTouchEnd={handleRecordStop}
|
||||||
onConfirm={handleSend}
|
onTouchCancel={handleRecordStop}
|
||||||
/>
|
>
|
||||||
<View className={`composer-send ${sending ? 'disabled' : ''}`} onClick={handleSend}>
|
<Text className='composer-record-text'>
|
||||||
<Text className='composer-send-text'>{sending ? '发送中' : '发送'}</Text>
|
{sending ? '发送中...' : recording ? '松开发送' : '按住留言'}
|
||||||
|
</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -10,71 +10,74 @@
|
|||||||
padding: 80px 32px 24px;
|
padding: 80px 32px 24px;
|
||||||
|
|
||||||
.page-title {
|
.page-title {
|
||||||
|
display: block;
|
||||||
font-size: 48px;
|
font-size: 48px;
|
||||||
|
line-height: 1.2;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-card {
|
.status-card {
|
||||||
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
|
||||||
border-radius: 28px;
|
|
||||||
padding: 40px 36px;
|
|
||||||
margin: 0 24px 32px;
|
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0 24px 32px;
|
||||||
|
padding: 40px 36px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #FF8C42 0%, #FF6B35 100%);
|
||||||
|
|
||||||
&::before {
|
&::before {
|
||||||
content: '';
|
content: '';
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: -20px;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
right: -20px;
|
||||||
width: 200px;
|
width: 200px;
|
||||||
height: 200px;
|
height: 200px;
|
||||||
background: rgba(255, 255, 255, 0.1);
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-badge {
|
.status-badge {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(255, 255, 255, 0.25);
|
|
||||||
border-radius: 20px;
|
|
||||||
padding: 10px 20px;
|
|
||||||
margin-bottom: 24px;
|
margin-bottom: 24px;
|
||||||
|
padding: 10px 20px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: rgba(255, 255, 255, 0.25);
|
||||||
|
|
||||||
.status-dot {
|
.status-dot {
|
||||||
width: 12px;
|
width: 12px;
|
||||||
height: 12px;
|
height: 12px;
|
||||||
background: #4CD964;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin-right: 8px;
|
margin-right: 8px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #4CD964;
|
||||||
}
|
}
|
||||||
|
|
||||||
.status-text {
|
.status-text {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
color: #FFFFFF;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
|
color: #FFFFFF;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.battery-section {
|
.battery-section {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
|
||||||
.battery-percent {
|
.battery-percent {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
font-size: 72px;
|
font-size: 72px;
|
||||||
|
line-height: 1;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #FFFFFF;
|
color: #FFFFFF;
|
||||||
line-height: 1;
|
|
||||||
margin-bottom: 12px;
|
|
||||||
display: block;
|
|
||||||
|
|
||||||
.percent {
|
.percent {
|
||||||
|
margin-left: 4px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 600;
|
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 {
|
.device-avatar {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 36px;
|
|
||||||
top: 50%;
|
top: 50%;
|
||||||
transform: translateY(-50%);
|
right: 36px;
|
||||||
width: 120px;
|
|
||||||
height: 120px;
|
|
||||||
background: rgba(255, 255, 255, 0.2);
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
border: 3px solid rgba(255, 255, 255, 0.3);
|
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-icon {
|
||||||
.robot-img {
|
.robot-img {
|
||||||
width: 56px;
|
width: 56px;
|
||||||
height: 56px;
|
height: 56px;
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
|
background: #FFFFFF;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.section-title {
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
margin: 0 24px 20px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 700;
|
font-weight: 700;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
margin: 0 24px 20px;
|
|
||||||
display: block;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-card {
|
.control-card {
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 16px;
|
|
||||||
margin: 0 24px 24px;
|
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.08);
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-item {
|
.control-item {
|
||||||
@@ -135,30 +169,23 @@
|
|||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.volume-header {
|
.control-left {
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
margin-bottom: 16px;
|
|
||||||
|
|
||||||
.control-left {
|
|
||||||
flex: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.control-left {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
flex: 1;
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.icon-bg {
|
.icon-bg {
|
||||||
width: 72px;
|
|
||||||
height: 72px;
|
|
||||||
border-radius: 50%;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 72px;
|
||||||
|
height: 72px;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
|
||||||
&.purple {
|
&.purple {
|
||||||
background: #F0E6FF;
|
background: #F0E6FF;
|
||||||
@@ -172,25 +199,40 @@
|
|||||||
width: 36px;
|
width: 36px;
|
||||||
height: 36px;
|
height: 36px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-info {
|
.control-info {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.control-name {
|
.control-name {
|
||||||
|
margin-bottom: 6px;
|
||||||
font-size: 32px;
|
font-size: 32px;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
margin-bottom: 6px;
|
}
|
||||||
}
|
|
||||||
|
|
||||||
.control-detail {
|
.control-detail {
|
||||||
font-size: 26px;
|
font-size: 26px;
|
||||||
color: #FF8C42;
|
|
||||||
font-weight: 500;
|
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 {
|
||||||
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -200,9 +242,9 @@
|
|||||||
padding: 0 8px;
|
padding: 0 8px;
|
||||||
|
|
||||||
.volume-icon {
|
.volume-icon {
|
||||||
|
flex-shrink: 0;
|
||||||
width: 24px;
|
width: 24px;
|
||||||
height: 24px;
|
height: 24px;
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.slider {
|
.slider {
|
||||||
@@ -211,90 +253,64 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.divider {
|
.fold-card {
|
||||||
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);
|
|
||||||
overflow: hidden;
|
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;
|
margin: 0 24px 24px;
|
||||||
padding: 10px 24px;
|
border-radius: 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
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 {
|
.detail-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
padding: 20px 0;
|
padding: 20px 0;
|
||||||
border-bottom: 1px solid #F3F3F3;
|
border-bottom: 1px solid #F3F3F3;
|
||||||
|
|
||||||
@@ -311,29 +327,41 @@
|
|||||||
.detail-value {
|
.detail-value {
|
||||||
max-width: 60%;
|
max-width: 60%;
|
||||||
font-size: 28px;
|
font-size: 28px;
|
||||||
color: #1A1A1A;
|
|
||||||
text-align: right;
|
text-align: right;
|
||||||
word-break: break-all;
|
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 {
|
.empty-card {
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin: 0 24px;
|
margin: 0 24px;
|
||||||
padding: 48px 32px;
|
padding: 48px 32px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
text-align: center;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-robot {
|
.empty-robot {
|
||||||
width: 132px;
|
|
||||||
height: 132px;
|
|
||||||
background: #FEF3E8;
|
|
||||||
border-radius: 50%;
|
|
||||||
margin: 0 auto 24px;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 132px;
|
||||||
|
height: 132px;
|
||||||
|
margin: 0 auto 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FEF3E8;
|
||||||
|
|
||||||
.robot-img {
|
.robot-img {
|
||||||
width: 68px;
|
width: 68px;
|
||||||
@@ -357,55 +385,40 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn {
|
.empty-btn {
|
||||||
margin-top: 28px;
|
|
||||||
height: 92px;
|
|
||||||
border-radius: 16px;
|
|
||||||
background: #FF8C42;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
height: 92px;
|
||||||
|
margin-top: 28px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #FF8C42;
|
||||||
}
|
}
|
||||||
|
|
||||||
.empty-btn-text {
|
.empty-btn-text {
|
||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
color: #FFFFFF;
|
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 {
|
.child-focus-card {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: #FFFFFF;
|
|
||||||
border-radius: 24px;
|
|
||||||
margin: 0 24px 24px;
|
margin: 0 24px 24px;
|
||||||
padding: 28px 24px;
|
padding: 28px 24px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: #FFFFFF;
|
||||||
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-focus-avatar {
|
.child-focus-avatar {
|
||||||
width: 96px;
|
|
||||||
height: 96px;
|
|
||||||
border-radius: 50%;
|
|
||||||
background: #FEF3E8;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
width: 96px;
|
||||||
|
height: 96px;
|
||||||
margin-right: 20px;
|
margin-right: 20px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: #FEF3E8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.child-focus-icon {
|
.child-focus-icon {
|
||||||
|
|||||||
@@ -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 { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
import { Binding, loadCurrentChildBindingContext } from '@/services/binding'
|
||||||
import { Child } from '@/services/child'
|
import { Child } from '@/services/child'
|
||||||
|
import { DeviceStatus, getDeviceStatus, setDeviceVolume } from '@/services/device'
|
||||||
import './index.scss'
|
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() {
|
export default function Device() {
|
||||||
const [binding, setBinding] = useState<Binding | null>(null)
|
const [binding, setBinding] = useState<Binding | null>(null)
|
||||||
const [child, setChild] = useState<Child | null>(null)
|
const [child, setChild] = useState<Child | null>(null)
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [sleepEnabled, setSleepEnabled] = 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(() => {
|
useDidShow(() => {
|
||||||
void loadDeviceInfo()
|
void loadDeviceInfo()
|
||||||
@@ -28,6 +64,15 @@ export default function Device() {
|
|||||||
const context = await loadCurrentChildBindingContext()
|
const context = await loadCurrentChildBindingContext()
|
||||||
setChild(context.currentChild)
|
setChild(context.currentChild)
|
||||||
setBinding(context.currentBinding)
|
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) {
|
} catch (error: any) {
|
||||||
console.error('[device] load failed:', error)
|
console.error('[device] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -40,15 +85,45 @@ export default function Device() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const handleSleepToggle = (e: any) => {
|
const handleSleepToggle = (e: any) => {
|
||||||
setSleepEnabled(e.detail.value)
|
const nextValue = Boolean(e.detail?.value)
|
||||||
|
setSleepEnabled(nextValue)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: e.detail.value ? '休眠展示已开启' : '休眠展示已关闭',
|
title: nextValue ? '休眠展示已开启' : '休眠展示已关闭',
|
||||||
icon: 'none',
|
icon: 'none',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleVolumeChange = (e: any) => {
|
const handleToggleDetail = () => {
|
||||||
setVolume(e.detail.value)
|
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) {
|
if (isLoading) {
|
||||||
@@ -115,8 +190,12 @@ export default function Device() {
|
|||||||
|
|
||||||
const childName = child.child_name || '未设置'
|
const childName = child.child_name || '未设置'
|
||||||
const pageTitle = `${childName} 的伴伴`
|
const pageTitle = `${childName} 的伴伴`
|
||||||
const battery = 82
|
const batteryValue = deviceStatus?.power ?? deviceStatus?.battery_pct ?? null
|
||||||
const daysLeft = 4
|
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 (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
@@ -131,10 +210,20 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
<View className='battery-section'>
|
<View className='battery-section'>
|
||||||
<Text className='battery-percent'>
|
<Text className='battery-percent'>
|
||||||
{battery}
|
{batteryDisplay}
|
||||||
<Text className='percent'>%</Text>
|
{batteryDisplay !== '--' && <Text className='percent'>%</Text>}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='battery-label'>模拟电量展示 (预计续航 {daysLeft} 天)</Text>
|
<Text className='battery-label'>当前电量</Text>
|
||||||
|
</View>
|
||||||
|
<View className='status-meta'>
|
||||||
|
<View className='status-pill'>
|
||||||
|
<Text className='status-pill-label'>信号</Text>
|
||||||
|
<Text className='status-pill-value'>{signalLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='status-pill'>
|
||||||
|
<Text className='status-pill-label'>版本</Text>
|
||||||
|
<Text className='status-pill-value'>{versionLabel}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='device-avatar'>
|
<View className='device-avatar'>
|
||||||
<View className='robot-icon'>
|
<View className='robot-icon'>
|
||||||
@@ -143,31 +232,12 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='detail-card'>
|
<View className='section-title'>设备控制</View>
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>当前儿童</Text>
|
|
||||||
<Text className='detail-value'>{childName}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>当前设备</Text>
|
|
||||||
<Text className='detail-value'>{binding.device_id}</Text>
|
|
||||||
</View>
|
|
||||||
<View className='detail-row'>
|
|
||||||
<Text className='detail-label'>绑定时间</Text>
|
|
||||||
<Text className='detail-value'>{binding.bound_at || '--'}</Text>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View className='section-title'>基础控制</View>
|
|
||||||
<View className='control-card'>
|
<View className='control-card'>
|
||||||
<View className='control-item'>
|
<View className='control-item'>
|
||||||
<View className='control-left'>
|
<View className='control-left'>
|
||||||
<View className='icon-bg purple'>
|
<View className='icon-bg purple'>
|
||||||
<Image
|
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
|
||||||
className='control-icon-img'
|
|
||||||
src={require('../../assets/tab-icons/moon.png')}
|
|
||||||
mode='aspectFit'
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='control-info'>
|
<View className='control-info'>
|
||||||
<Text className='control-name'>定时休眠</Text>
|
<Text className='control-name'>定时休眠</Text>
|
||||||
@@ -189,7 +259,10 @@ export default function Device() {
|
|||||||
mode='aspectFit'
|
mode='aspectFit'
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
|
<View className='control-info'>
|
||||||
<Text className='control-name'>外放音量</Text>
|
<Text className='control-name'>外放音量</Text>
|
||||||
|
<Text className='control-detail'>{isSavingVolume ? '发送中...' : `当前 ${volumeLabel}`}</Text>
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<View className='volume-slider'>
|
<View className='volume-slider'>
|
||||||
@@ -202,8 +275,9 @@ export default function Device() {
|
|||||||
className='slider'
|
className='slider'
|
||||||
min={0}
|
min={0}
|
||||||
max={100}
|
max={100}
|
||||||
value={volume}
|
value={volumeValue}
|
||||||
onChange={handleVolumeChange}
|
onChanging={handleVolumeChanging}
|
||||||
|
onChange={handleVolumeCommit}
|
||||||
activeColor='#FF8C42'
|
activeColor='#FF8C42'
|
||||||
backgroundColor='#E5E5E5'
|
backgroundColor='#E5E5E5'
|
||||||
blockColor='#FF8C42'
|
blockColor='#FF8C42'
|
||||||
@@ -216,6 +290,45 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='fold-card'>
|
||||||
|
<View className='fold-header' onClick={handleToggleDetail}>
|
||||||
|
<View className='fold-heading'>
|
||||||
|
<Text className='fold-title'>设备详情</Text>
|
||||||
|
<Text className='fold-subtitle'>{binding.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='fold-action'>{isDetailExpanded ? '收起' : '展开'}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{isDetailExpanded && (
|
||||||
|
<View className='detail-body'>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前儿童</Text>
|
||||||
|
<Text className='detail-value'>{childName}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前设备</Text>
|
||||||
|
<Text className='detail-value'>{binding.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>绑定时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(binding.bound_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>当前坐标</Text>
|
||||||
|
<Text className='detail-value'>{coordinateLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>状态更新时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(deviceStatus?.settings_updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='detail-row'>
|
||||||
|
<Text className='detail-label'>定位更新时间</Text>
|
||||||
|
<Text className='detail-value'>{formatTime(deviceStatus?.location_updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.location-stack {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
@@ -187,3 +198,19 @@
|
|||||||
color: #666666;
|
color: #666666;
|
||||||
line-height: 1.5;
|
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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { View, Text, Map } from '@tarojs/components'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { getToken } from '@/services/auth'
|
import { getToken } from '@/services/auth'
|
||||||
|
import { loadCurrentChildBindingContext } from '@/services/binding'
|
||||||
import {
|
import {
|
||||||
getCurrentDeviceLocation,
|
getCurrentDeviceLocation,
|
||||||
getDeviceTrajectory,
|
getDeviceTrajectory,
|
||||||
@@ -26,8 +27,8 @@ const DEFAULT_COORDINATES = {
|
|||||||
|
|
||||||
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
|
const MODE_OPTIONS: Array<{ key: TrajectoryMode; label: string }> = [
|
||||||
{ key: 'current', label: '当前位置' },
|
{ key: 'current', label: '当前位置' },
|
||||||
// { key: 'today', label: '今日轨迹' },
|
{ key: 'today', label: '今日轨迹' },
|
||||||
// { key: 'recent', label: '最近点位' },
|
{ key: 'recent', label: '最近点位' },
|
||||||
]
|
]
|
||||||
|
|
||||||
function formatTime(value?: string | null): string {
|
function formatTime(value?: string | null): string {
|
||||||
@@ -46,6 +47,56 @@ function formatAccuracy(value?: number | null): string {
|
|||||||
return `${value} 米`
|
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 {
|
function getTodayStartISOString(): string {
|
||||||
const now = new Date()
|
const now = new Date()
|
||||||
now.setHours(0, 0, 0, 0)
|
now.setHours(0, 0, 0, 0)
|
||||||
@@ -72,6 +123,9 @@ export default function Location() {
|
|||||||
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
|
const [selectedPoint, setSelectedPoint] = useState<DeviceTrajectoryPoint | null>(null)
|
||||||
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
|
const [trajectoryMode, setTrajectoryMode] = useState<TrajectoryMode>('current')
|
||||||
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
|
const [coordinates, setCoordinates] = useState(DEFAULT_COORDINATES)
|
||||||
|
const [emptyState, setEmptyState] = useState<{ title: string; desc: string; actionText: string; actionUrl: string } | null>(
|
||||||
|
null
|
||||||
|
)
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
void loadLocation()
|
void loadLocation()
|
||||||
@@ -90,13 +144,44 @@ export default function Location() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true)
|
setLoading(true)
|
||||||
|
setEmptyState(null)
|
||||||
if (nextMode) {
|
if (nextMode) {
|
||||||
setTrajectoryMode(nextMode)
|
setTrajectoryMode(nextMode)
|
||||||
setSelectedPoint(null)
|
setSelectedPoint(null)
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
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)
|
setDeviceLocation(currentLocation)
|
||||||
|
|
||||||
const nextCoordinates = currentLocation
|
const nextCoordinates = currentLocation
|
||||||
@@ -109,12 +194,14 @@ export default function Location() {
|
|||||||
let points: DeviceTrajectoryPoint[] = []
|
let points: DeviceTrajectoryPoint[] = []
|
||||||
if (targetMode === 'today') {
|
if (targetMode === 'today') {
|
||||||
const response = await getDeviceTrajectory({
|
const response = await getDeviceTrajectory({
|
||||||
|
deviceId: resolvedDeviceId,
|
||||||
startAt: getTodayStartISOString(),
|
startAt: getTodayStartISOString(),
|
||||||
limit: 200,
|
limit: 200,
|
||||||
})
|
})
|
||||||
points = response?.items || []
|
points = response?.items || []
|
||||||
} else if (targetMode === 'recent') {
|
} else if (targetMode === 'recent') {
|
||||||
const response = await getDeviceTrajectory({
|
const response = await getDeviceTrajectory({
|
||||||
|
deviceId: resolvedDeviceId,
|
||||||
startAt: getRecentStartISOString(72),
|
startAt: getRecentStartISOString(72),
|
||||||
limit: 100,
|
limit: 100,
|
||||||
})
|
})
|
||||||
@@ -267,6 +354,27 @@ export default function Location() {
|
|||||||
<Text className='page-title'>设备定位</Text>
|
<Text className='page-title'>设备定位</Text>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
{emptyState ? (
|
||||||
|
<View className='location-empty-shell'>
|
||||||
|
<View className='location-empty-card'>
|
||||||
|
<Text className='location-empty-title'>{emptyState.title}</Text>
|
||||||
|
<Text className='location-empty-desc'>{emptyState.desc}</Text>
|
||||||
|
<View
|
||||||
|
className='location-empty-action'
|
||||||
|
onClick={() => {
|
||||||
|
if (emptyState.actionUrl === '/pages/sleep/index') {
|
||||||
|
Taro.switchTab({ url: emptyState.actionUrl })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
Taro.navigateTo({ url: emptyState.actionUrl })
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Text className='location-empty-action-text'>{emptyState.actionText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
<View className='mode-switch'>
|
<View className='mode-switch'>
|
||||||
{MODE_OPTIONS.map((item) => (
|
{MODE_OPTIONS.map((item) => (
|
||||||
<View
|
<View
|
||||||
@@ -325,16 +433,11 @@ export default function Location() {
|
|||||||
<Text className='location-coordinate'>
|
<Text className='location-coordinate'>
|
||||||
{deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
|
{deviceLocation.lat.toFixed(6)}, {deviceLocation.lng.toFixed(6)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='location-desc'>
|
{buildLocationDetailLines(deviceLocation, { includeIdentity: true }).map((line, index) => (
|
||||||
设备 {deviceLocation.device_id} · {deviceLocation.child_name || '未命名儿童'}
|
<Text key={`current-${index}`} className='location-desc'>
|
||||||
|
{line}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='location-desc'>上报时间 {formatTime(deviceLocation.device_time)}</Text>
|
))}
|
||||||
<Text className='location-desc'>
|
|
||||||
坐标系 {deviceLocation.coord_type} · 精度 {formatAccuracy(deviceLocation.accuracy_m)}
|
|
||||||
</Text>
|
|
||||||
{deviceLocation.battery_pct !== null && deviceLocation.battery_pct !== undefined && (
|
|
||||||
<Text className='location-desc'>设备电量 {deviceLocation.battery_pct}%</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
) : (
|
) : (
|
||||||
@@ -353,13 +456,11 @@ export default function Location() {
|
|||||||
<Text className='location-coordinate'>
|
<Text className='location-coordinate'>
|
||||||
{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
|
{selectedPoint.lat.toFixed(6)}, {selectedPoint.lng.toFixed(6)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text className='location-desc'>上报时间 {formatTime(selectedPoint.device_time)}</Text>
|
{buildLocationDetailLines(selectedPoint).map((line, index) => (
|
||||||
<Text className='location-desc'>
|
<Text key={`selected-${index}`} className='location-desc'>
|
||||||
坐标系 {selectedPoint.coord_type} · 精度 {formatAccuracy(selectedPoint.accuracy_m)}
|
{line}
|
||||||
</Text>
|
</Text>
|
||||||
{selectedPoint.battery_pct !== null && selectedPoint.battery_pct !== undefined && (
|
))}
|
||||||
<Text className='location-desc'>设备电量 {selectedPoint.battery_pct}%</Text>
|
|
||||||
)}
|
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
@@ -389,6 +490,8 @@ export default function Location() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
.login-btn-wrapper {
|
||||||
margin-bottom: 32px;
|
margin-bottom: 32px;
|
||||||
|
|
||||||
|
|||||||
@@ -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 { useEffect, useState } from 'react'
|
||||||
import Taro from '@tarojs/taro'
|
import Taro from '@tarojs/taro'
|
||||||
import { getToken, wechatLogin } from '@/services/auth'
|
import { getToken, wechatLogin } from '@/services/auth'
|
||||||
@@ -6,16 +6,31 @@ import './index.scss'
|
|||||||
|
|
||||||
export default function Login() {
|
export default function Login() {
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
|
const [nickname, setNickname] = useState('')
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (getToken()) {
|
if (getToken()) {
|
||||||
Taro.reLaunch({ url: '/pages/device/index' })
|
Taro.reLaunch({ url: '/pages/device/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
|
||||||
|
nickname?: string
|
||||||
|
}
|
||||||
|
if (cachedUserInfo.nickname) {
|
||||||
|
setNickname(cachedUserInfo.nickname)
|
||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const handleLogin = async () => {
|
const handleLogin = async () => {
|
||||||
if (submitting) return
|
if (submitting) return
|
||||||
|
|
||||||
|
const cachedUserInfo = (Taro.getStorageSync('userInfo') || {}) as {
|
||||||
|
nickname?: string
|
||||||
|
avatar_url?: string
|
||||||
|
}
|
||||||
|
const normalizedNickname = nickname.trim() || cachedUserInfo.nickname?.trim() || ''
|
||||||
|
|
||||||
setSubmitting(true)
|
setSubmitting(true)
|
||||||
Taro.showLoading({ title: '登录中...' })
|
Taro.showLoading({ title: '登录中...' })
|
||||||
|
|
||||||
@@ -25,35 +40,26 @@ export default function Login() {
|
|||||||
throw new Error('未获取到微信登录凭证')
|
throw new Error('未获取到微信登录凭证')
|
||||||
}
|
}
|
||||||
|
|
||||||
let userInfo:
|
const session = await wechatLogin({
|
||||||
| {
|
|
||||||
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({
|
|
||||||
code: loginRes.code,
|
code: loginRes.code,
|
||||||
nickname: userInfo?.nickName,
|
nickname: normalizedNickname || undefined,
|
||||||
avatar_url: userInfo?.avatarUrl,
|
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', {
|
Taro.setStorageSync('userInfo', {
|
||||||
nickname: userInfo.nickName,
|
nickname: nextUserInfo.nickname,
|
||||||
avatar_url: userInfo.avatarUrl,
|
avatar_url: nextUserInfo.avatar_url,
|
||||||
})
|
})
|
||||||
|
setNickname(nextUserInfo.nickname || '')
|
||||||
} else {
|
} else {
|
||||||
Taro.removeStorageSync('userInfo')
|
Taro.removeStorageSync('userInfo')
|
||||||
|
setNickname('')
|
||||||
}
|
}
|
||||||
|
|
||||||
Taro.showToast({ title: '登录成功', icon: 'success' })
|
Taro.showToast({ title: '登录成功', icon: 'success' })
|
||||||
@@ -100,6 +106,18 @@ export default function Login() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='nickname-card'>
|
||||||
|
<Text className='nickname-label'>家长昵称</Text>
|
||||||
|
<Input
|
||||||
|
className='nickname-input'
|
||||||
|
type='nickname'
|
||||||
|
maxlength={64}
|
||||||
|
placeholder='请输入家长昵称'
|
||||||
|
value={nickname}
|
||||||
|
onInput={(event) => setNickname(event.detail.value)}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='login-btn-wrapper'>
|
<View className='login-btn-wrapper'>
|
||||||
<Button className='login-btn' loading={submitting} disabled={submitting} onClick={handleLogin}>
|
<Button className='login-btn' loading={submitting} disabled={submitting} onClick={handleLogin}>
|
||||||
<Text className='btn-icon'>🌐</Text>
|
<Text className='btn-icon'>🌐</Text>
|
||||||
|
|||||||
@@ -50,12 +50,6 @@
|
|||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
color: #1A1A1A;
|
color: #1A1A1A;
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 8px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.user-role {
|
|
||||||
font-size: 26px;
|
|
||||||
color: #666666;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -300,6 +294,22 @@
|
|||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.device-switch-footer {
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.device-switch-add {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #FFF2E7;
|
||||||
|
font-size: 28px;
|
||||||
|
color: #FF8C42;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.device-switch-item {
|
.device-switch-item {
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
border-radius: 18px;
|
border-radius: 18px;
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { View, Text, Image, Input } from '@tarojs/components'
|
import { View, Text, Image, Input } from '@tarojs/components'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import Taro, { useDidShow } from '@tarojs/taro'
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
import { clearToken, getCurrentUserId, getToken } from '@/services/auth'
|
import { clearToken, getToken } from '@/services/auth'
|
||||||
import {
|
import {
|
||||||
BindingListItem,
|
BindingListItem,
|
||||||
clearSelectedBindingDeviceId,
|
clearSelectedBindingDeviceId,
|
||||||
@@ -95,6 +95,11 @@ export default function Sleep() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleOpenAddChildFromSwitch = () => {
|
||||||
|
setShowChildModal(false)
|
||||||
|
handleOpenModal('add')
|
||||||
|
}
|
||||||
|
|
||||||
const handleSubmitModal = async () => {
|
const handleSubmitModal = async () => {
|
||||||
const normalizedName = childName.trim()
|
const normalizedName = childName.trim()
|
||||||
if (!normalizedName) {
|
if (!normalizedName) {
|
||||||
@@ -181,6 +186,11 @@ export default function Sleep() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (item.name === '新增孩子') {
|
||||||
|
handleOpenModal('add')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (item.name === '绑定设备') {
|
if (item.name === '绑定设备') {
|
||||||
if (!currentChild) {
|
if (!currentChild) {
|
||||||
handleOpenModal('add')
|
handleOpenModal('add')
|
||||||
@@ -209,7 +219,7 @@ export default function Sleep() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const userId = getCurrentUserId()
|
const parentDisplayName = parentInfo.nickname?.trim() || '家长'
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
@@ -218,6 +228,13 @@ export default function Sleep() {
|
|||||||
value: currentChildName,
|
value: currentChildName,
|
||||||
arrow: true,
|
arrow: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
|
iconBgClass: 'orange',
|
||||||
|
name: '新增孩子',
|
||||||
|
value: '',
|
||||||
|
arrow: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
iconBgClass: 'orange',
|
iconBgClass: 'orange',
|
||||||
@@ -257,8 +274,7 @@ export default function Sleep() {
|
|||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
<View className='user-info'>
|
<View className='user-info'>
|
||||||
<Text className='user-name'>{parentInfo.nickname || '家长用户'}</Text>
|
<Text className='user-name'>{parentDisplayName}</Text>
|
||||||
<Text className='user-role'>用户 ID: {userId || '--'}</Text>
|
|
||||||
</View>
|
</View>
|
||||||
<View className='verified-badge'>
|
<View className='verified-badge'>
|
||||||
<Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
|
<Text>{currentChild ? '已选择当前孩子' : '未选择孩子'}</Text>
|
||||||
@@ -342,6 +358,11 @@ export default function Sleep() {
|
|||||||
})}
|
})}
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<View className='device-switch-footer'>
|
||||||
|
<Text className='device-switch-add' onClick={handleOpenAddChildFromSwitch}>
|
||||||
|
+ 新增孩子
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
<View className='modal-actions'>
|
<View className='modal-actions'>
|
||||||
<Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
|
<Text className='modal-action cancel' onClick={() => setShowChildModal(false)}>
|
||||||
关闭
|
关闭
|
||||||
|
|||||||
@@ -4,6 +4,10 @@ import { getCurrentUserId, getToken, handleUnauthorized } from './session'
|
|||||||
|
|
||||||
const BASE_URL = API_BASE_URL
|
const BASE_URL = API_BASE_URL
|
||||||
const REQUEST_TIMEOUT_MS = 10000
|
const REQUEST_TIMEOUT_MS = 10000
|
||||||
|
const ERROR_DETAIL_MAP: Record<string, string> = {
|
||||||
|
'Request failed': '请求失败',
|
||||||
|
'device is already bound, unbind it before binding again': '设备已绑定,请先解绑后再重新绑定',
|
||||||
|
}
|
||||||
|
|
||||||
class ApiError extends Error {
|
class ApiError extends Error {
|
||||||
constructor(public status: number, message: string) {
|
constructor(public status: number, message: string) {
|
||||||
@@ -12,14 +16,14 @@ class ApiError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function getErrorDetail(data: any): string {
|
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.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)) {
|
if (Array.isArray(data.detail)) {
|
||||||
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
|
return data.detail.map((item: any) => String(item?.msg || item)).join('; ')
|
||||||
}
|
}
|
||||||
if (typeof data.message === 'string') return data.message
|
if (typeof data.message === 'string') return data.message
|
||||||
return 'Request failed'
|
return ERROR_DETAIL_MAP['Request failed']
|
||||||
}
|
}
|
||||||
|
|
||||||
async function request<T>(
|
async function request<T>(
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ export interface LoginSession {
|
|||||||
token_type: string
|
token_type: string
|
||||||
expires_in: number
|
expires_in: number
|
||||||
user_id: number
|
user_id: number
|
||||||
|
nickname?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Parent {
|
export interface Parent {
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
import { getCurrentUserId } from './auth'
|
import { getCurrentUserId } from './auth'
|
||||||
import { request } from './api'
|
import { ApiError, BASE_URL, getToken, request } from './api'
|
||||||
import { loadCurrentChildBindingContext } from './binding'
|
import { loadCurrentChildBindingContext } from './binding'
|
||||||
|
import { handleUnauthorized } from './session'
|
||||||
|
|
||||||
export type ConversationSource = 'im' | 'ai'
|
export type ConversationSource = 'im' | 'ai'
|
||||||
export type PeerKind = 'child' | 'parent' | 'ai'
|
export type PeerKind = 'child' | 'parent' | 'ai'
|
||||||
@@ -49,6 +51,7 @@ export interface ChatMessage {
|
|||||||
senderName?: string
|
senderName?: string
|
||||||
receiverName?: string
|
receiverName?: string
|
||||||
channelLabel?: string
|
channelLabel?: string
|
||||||
|
extJson?: Record<string, any> | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Character {
|
export interface Character {
|
||||||
@@ -201,10 +204,18 @@ function formatDetailTime(value?: string | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function formatImPreview(
|
function formatImPreview(
|
||||||
item: Pick<ChildConversationMessageItem, 'content_type' | 'content_text' | 'media_transcript_text' | 'content_json'>
|
item: Pick<
|
||||||
|
ChildConversationMessageItem,
|
||||||
|
'content_type' | 'content_text' | 'media_transcript_text' | 'content_json' | 'ext_json'
|
||||||
|
>
|
||||||
): string {
|
): string {
|
||||||
if (item.content_type === 1) return item.content_text || '文本消息'
|
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 === 3) return '[图片]'
|
||||||
if (item.content_type === 4) {
|
if (item.content_type === 4) {
|
||||||
const title = String(item.content_json?.title || item.content_json?.type || '').trim()
|
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,
|
avatar: PEER_META.parent.icon,
|
||||||
typeLabel: '家长沟通',
|
typeLabel: '家长沟通',
|
||||||
description: '家长通过微信小程序和孩子直接沟通',
|
description: '家长通过微信小程序和孩子直接沟通',
|
||||||
lastMessage: '还没有消息,点进去发送第一条',
|
lastMessage: '还没有留言,点进去发送第一条',
|
||||||
time: '',
|
time: '',
|
||||||
sortAt: '',
|
sortAt: '',
|
||||||
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
|
conversationTypeName: PARENT_CHILD_CONVERSATION_TYPE_NAME,
|
||||||
@@ -471,6 +482,7 @@ export async function getMessages(
|
|||||||
senderId: item.sender_id,
|
senderId: item.sender_id,
|
||||||
receiverType: item.receiver_type,
|
receiverType: item.receiver_type,
|
||||||
receiverId: item.receiver_id,
|
receiverId: item.receiver_id,
|
||||||
|
extJson: item.ext_json || null,
|
||||||
senderName:
|
senderName:
|
||||||
item.sender_type === 'parent' && item.sender_id === String(context.currentUserId)
|
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<ConversationMessageCreateResponse> {
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|||||||
62
banban-mini/src/services/device.ts
Normal file
62
banban-mini/src/services/device.ts
Normal file
@@ -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<string> {
|
||||||
|
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<DeviceStatus | null> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/status`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeviceVolume(level: number, deviceId?: string): Promise<DeviceVolumeUpdateResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceVolumeUpdateResponse>(`/banban/devices/${resolvedDeviceId}/volume`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { level },
|
||||||
|
})
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user