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} - - ))} - - - - { - console.error('[location] map error:', event.detail) - }} - /> - - - - - - {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 ? ( - - - 📍 + + { + console.error('[location] map error:', event.detail) + }} + /> + + + + + + {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)} + /> + +