提交小程序合入代码
This commit is contained in:
@@ -7,6 +7,7 @@ export default defineAppConfig({
|
|||||||
"pages/chat/detail/index",
|
"pages/chat/detail/index",
|
||||||
"pages/location/index",
|
"pages/location/index",
|
||||||
"pages/sleep/index",
|
"pages/sleep/index",
|
||||||
|
"pages/sleep-schedule/index",
|
||||||
],
|
],
|
||||||
window: {
|
window: {
|
||||||
backgroundTextStyle: "light",
|
backgroundTextStyle: "light",
|
||||||
|
|||||||
119
banban-mini/src/components/system-banner/index.scss
Normal file
119
banban-mini/src/components/system-banner/index.scss
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
.system-banner {
|
||||||
|
position: fixed;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
z-index: 9999;
|
||||||
|
box-sizing: border-box;
|
||||||
|
opacity: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
transform: translateY(-140%);
|
||||||
|
transition: transform 0.28s ease, opacity 0.28s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-card,
|
||||||
|
.system-banner-icon,
|
||||||
|
.system-banner-icon-text,
|
||||||
|
.system-banner-body,
|
||||||
|
.system-banner-header,
|
||||||
|
.system-banner-title,
|
||||||
|
.system-banner-time,
|
||||||
|
.system-banner-summary,
|
||||||
|
.system-banner-detail,
|
||||||
|
.system-banner-close,
|
||||||
|
.system-banner-close-text {
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner.is-visible {
|
||||||
|
opacity: 1;
|
||||||
|
pointer-events: auto;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 18px;
|
||||||
|
padding: 22px 22px 20px;
|
||||||
|
border-radius: 24px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 250, 244, 0.98) 0%, rgba(255, 243, 226, 0.98) 100%);
|
||||||
|
border: 1px solid rgba(255, 166, 77, 0.36);
|
||||||
|
box-shadow: 0 18px 36px rgba(102, 70, 24, 0.18);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-icon {
|
||||||
|
width: 64px;
|
||||||
|
height: 64px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, #ff9d3f 0%, #ff7e33 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-icon-text {
|
||||||
|
font-size: 34px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #fff;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-body {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #2f2213;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-time {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 22px;
|
||||||
|
color: #8e6b43;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-summary {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #3f2f1c;
|
||||||
|
line-height: 1.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-detail {
|
||||||
|
margin-top: 6px;
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #7a6143;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-close {
|
||||||
|
width: 40px;
|
||||||
|
height: 40px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
border-radius: 20px;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: rgba(255, 255, 255, 0.52);
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-banner-close-text {
|
||||||
|
font-size: 30px;
|
||||||
|
line-height: 1;
|
||||||
|
color: #8a6b48;
|
||||||
|
}
|
||||||
44
banban-mini/src/components/system-banner/index.tsx
Normal file
44
banban-mini/src/components/system-banner/index.tsx
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
import { View, Text } from '@tarojs/components'
|
||||||
|
import { SystemBannerNotification } from '@/services/system-banner'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
interface SystemBannerProps {
|
||||||
|
banner: SystemBannerNotification | null
|
||||||
|
visible: boolean
|
||||||
|
top: number
|
||||||
|
onPress: () => void
|
||||||
|
onClose: (event?: { stopPropagation?: () => void }) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SystemBanner(props: SystemBannerProps) {
|
||||||
|
const { banner, visible, top, onPress, onClose } = props
|
||||||
|
|
||||||
|
if (!banner) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View
|
||||||
|
className={`system-banner ${visible ? 'is-visible' : ''}`}
|
||||||
|
style={{ top: `${top}px` }}
|
||||||
|
onClick={onPress}
|
||||||
|
>
|
||||||
|
<View className='system-banner-card'>
|
||||||
|
<View className='system-banner-icon'>
|
||||||
|
<Text className='system-banner-icon-text'>!</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='system-banner-body'>
|
||||||
|
<View className='system-banner-header'>
|
||||||
|
<Text className='system-banner-title'>{banner.title}</Text>
|
||||||
|
<Text className='system-banner-time'>{banner.timeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='system-banner-summary'>{banner.summary}</Text>
|
||||||
|
<Text className='system-banner-detail'>{banner.detail}</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='system-banner-close' onClick={onClose}>
|
||||||
|
<Text className='system-banner-close-text'>×</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
191
banban-mini/src/components/system-banner/use-system-banner.tsx
Normal file
191
banban-mini/src/components/system-banner/use-system-banner.tsx
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import Taro, { useDidHide, useDidShow } from '@tarojs/taro'
|
||||||
|
import { getToken } from '@/services/session'
|
||||||
|
import { pollSystemBannerNotifications, SystemBannerNotification } from '@/services/system-banner'
|
||||||
|
import SystemBanner from './index'
|
||||||
|
|
||||||
|
const BANNER_AUTO_HIDE_MS = 4500
|
||||||
|
const BANNER_TRANSITION_MS = 280
|
||||||
|
const POLL_INTERVAL_MS = 5000
|
||||||
|
|
||||||
|
export function useSystemBanner() {
|
||||||
|
const [activeBanner, setActiveBanner] = useState<SystemBannerNotification | null>(null)
|
||||||
|
const [bannerQueue, setBannerQueue] = useState<SystemBannerNotification[]>([])
|
||||||
|
const [bannerVisible, setBannerVisible] = useState(false)
|
||||||
|
const [bannerTop, setBannerTop] = useState(24)
|
||||||
|
const pollTimerRef = useRef<ReturnType<typeof setInterval> | null>(null)
|
||||||
|
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const clearBannerTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
|
||||||
|
const isPollingRef = useRef(false)
|
||||||
|
const currentBannerRef = useRef<SystemBannerNotification | null>(null)
|
||||||
|
const queuedBannerKeysRef = useRef<Set<string>>(new Set())
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
currentBannerRef.current = activeBanner
|
||||||
|
}, [activeBanner])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
const systemInfo = Taro.getSystemInfoSync()
|
||||||
|
const statusBarHeight = Number(systemInfo.statusBarHeight || 0)
|
||||||
|
setBannerTop(Math.max(24, statusBarHeight + 12))
|
||||||
|
} catch {
|
||||||
|
setBannerTop(24)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const clearHideTimer = () => {
|
||||||
|
if (hideTimerRef.current) {
|
||||||
|
clearTimeout(hideTimerRef.current)
|
||||||
|
hideTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearBannerTimer = () => {
|
||||||
|
if (clearBannerTimerRef.current) {
|
||||||
|
clearTimeout(clearBannerTimerRef.current)
|
||||||
|
clearBannerTimerRef.current = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const finishBannerDismiss = () => {
|
||||||
|
clearBannerTimer()
|
||||||
|
clearBannerTimerRef.current = setTimeout(() => {
|
||||||
|
setActiveBanner(null)
|
||||||
|
}, BANNER_TRANSITION_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const dismissActiveBanner = () => {
|
||||||
|
if (!currentBannerRef.current) return
|
||||||
|
clearHideTimer()
|
||||||
|
setBannerVisible(false)
|
||||||
|
finishBannerDismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetBannerState = () => {
|
||||||
|
clearHideTimer()
|
||||||
|
clearBannerTimer()
|
||||||
|
queuedBannerKeysRef.current.clear()
|
||||||
|
setBannerVisible(false)
|
||||||
|
setActiveBanner(null)
|
||||||
|
setBannerQueue([])
|
||||||
|
}
|
||||||
|
|
||||||
|
const enqueueNotifications = (items: SystemBannerNotification[]) => {
|
||||||
|
if (items.length === 0) return
|
||||||
|
|
||||||
|
setBannerQueue((currentQueue) => {
|
||||||
|
const nextQueue = [...currentQueue]
|
||||||
|
const currentBannerKey = currentBannerRef.current?.key
|
||||||
|
|
||||||
|
items.forEach((item) => {
|
||||||
|
if (item.key === currentBannerKey) return
|
||||||
|
if (queuedBannerKeysRef.current.has(item.key)) return
|
||||||
|
queuedBannerKeysRef.current.add(item.key)
|
||||||
|
nextQueue.push(item)
|
||||||
|
})
|
||||||
|
|
||||||
|
return nextQueue
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const runNotificationPoll = async () => {
|
||||||
|
if (isPollingRef.current) return
|
||||||
|
|
||||||
|
if (!getToken()) {
|
||||||
|
resetBannerState()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
isPollingRef.current = true
|
||||||
|
try {
|
||||||
|
const notifications = await pollSystemBannerNotifications()
|
||||||
|
enqueueNotifications(notifications)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('[system-banner] poll failed:', error)
|
||||||
|
} finally {
|
||||||
|
isPollingRef.current = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const startPolling = () => {
|
||||||
|
if (pollTimerRef.current) return
|
||||||
|
|
||||||
|
void runNotificationPoll()
|
||||||
|
pollTimerRef.current = setInterval(() => {
|
||||||
|
void runNotificationPoll()
|
||||||
|
}, POLL_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
|
||||||
|
const stopPolling = () => {
|
||||||
|
if (!pollTimerRef.current) return
|
||||||
|
clearInterval(pollTimerRef.current)
|
||||||
|
pollTimerRef.current = null
|
||||||
|
}
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
startPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
useDidHide(() => {
|
||||||
|
stopPolling()
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeBanner || bannerQueue.length === 0) return
|
||||||
|
|
||||||
|
const [nextBanner, ...restQueue] = bannerQueue
|
||||||
|
queuedBannerKeysRef.current.delete(nextBanner.key)
|
||||||
|
setBannerQueue(restQueue)
|
||||||
|
setActiveBanner(nextBanner)
|
||||||
|
setBannerVisible(true)
|
||||||
|
}, [activeBanner, bannerQueue])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!activeBanner) return
|
||||||
|
|
||||||
|
clearHideTimer()
|
||||||
|
clearBannerTimer()
|
||||||
|
setBannerVisible(true)
|
||||||
|
|
||||||
|
hideTimerRef.current = setTimeout(() => {
|
||||||
|
setBannerVisible(false)
|
||||||
|
finishBannerDismiss()
|
||||||
|
}, BANNER_AUTO_HIDE_MS)
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
clearHideTimer()
|
||||||
|
}
|
||||||
|
}, [activeBanner])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
stopPolling()
|
||||||
|
clearHideTimer()
|
||||||
|
clearBannerTimer()
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleBannerClick = () => {
|
||||||
|
const currentBanner = currentBannerRef.current
|
||||||
|
if (!currentBanner) return
|
||||||
|
|
||||||
|
dismissActiveBanner()
|
||||||
|
void Taro.switchTab({ url: currentBanner.route })
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleBannerClose = (event?: { stopPropagation?: () => void }) => {
|
||||||
|
event?.stopPropagation?.()
|
||||||
|
dismissActiveBanner()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SystemBanner
|
||||||
|
banner={activeBanner}
|
||||||
|
visible={bannerVisible}
|
||||||
|
top={bannerTop}
|
||||||
|
onClose={handleBannerClose}
|
||||||
|
onPress={handleBannerClick}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
resolveChildSelection,
|
resolveChildSelection,
|
||||||
setSelectedChildId as setStoredSelectedChildId,
|
setSelectedChildId as setStoredSelectedChildId,
|
||||||
} from '@/services/child'
|
} from '@/services/child'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
|
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
@@ -82,6 +83,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Bind() {
|
export default function Bind() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [submitting, setSubmitting] = useState(false)
|
const [submitting, setSubmitting] = useState(false)
|
||||||
const [isScanning, setIsScanning] = useState(false)
|
const [isScanning, setIsScanning] = useState(false)
|
||||||
@@ -333,6 +335,7 @@ export default function Bind() {
|
|||||||
<View className='loading'>
|
<View className='loading'>
|
||||||
<Text>加载中...</Text>
|
<Text>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -480,6 +483,7 @@ export default function Bind() {
|
|||||||
<Text className='tip-text'>点击发送绑卡指令,然后去设备上贴自己的卡完成确认</Text>
|
<Text className='tip-text'>点击发送绑卡指令,然后去设备上贴自己的卡完成确认</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -175,7 +175,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.audio-bubble {
|
.audio-bubble {
|
||||||
min-width: 220px;
|
min-width: 144px;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 14px;
|
gap: 14px;
|
||||||
@@ -191,11 +191,6 @@
|
|||||||
line-height: 1;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.audio-bubble-text {
|
|
||||||
flex: 1;
|
|
||||||
font-size: 28px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.audio-bubble-duration {
|
.audio-bubble-duration {
|
||||||
font-size: 24px;
|
font-size: 24px;
|
||||||
opacity: 0.8;
|
opacity: 0.8;
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import {
|
|||||||
ConversationSource,
|
ConversationSource,
|
||||||
PeerKind,
|
PeerKind,
|
||||||
} from '../../../services/chat'
|
} from '../../../services/chat'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
function normalizePeerKind(value?: string): PeerKind {
|
function normalizePeerKind(value?: string): PeerKind {
|
||||||
@@ -53,6 +54,7 @@ function formatAudioDuration(durationMs?: number | null): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function ChatDetail() {
|
export default function ChatDetail() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const params = router.params
|
const params = router.params
|
||||||
|
|
||||||
@@ -322,13 +324,9 @@ 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 || (isLeaveMessage ? '点击收听留言' : '点击播放语音')}
|
|
||||||
</Text>
|
|
||||||
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
<Text className='audio-bubble-duration'>{formatAudioDuration(msg.mediaDurationMs)}</Text>
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
@@ -421,6 +419,7 @@ export default function ChatDetail() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,9 +3,11 @@ 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 { getConversations, ChatConversation } from '../../services/chat'
|
import { getConversations, ChatConversation } from '../../services/chat'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
export default function Chat() {
|
export default function Chat() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
const [conversations, setConversations] = useState<ChatConversation[]>([])
|
const [conversations, setConversations] = useState<ChatConversation[]>([])
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
|
|
||||||
@@ -97,6 +99,7 @@ export default function Chat() {
|
|||||||
))}
|
))}
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
)}
|
)}
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -164,11 +164,21 @@
|
|||||||
justify-content: space-between;
|
justify-content: space-between;
|
||||||
padding: 24px;
|
padding: 24px;
|
||||||
|
|
||||||
|
&.sleep-item:active {
|
||||||
|
background: #F8F9FA;
|
||||||
|
}
|
||||||
|
|
||||||
&.volume-item {
|
&.volume-item {
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: stretch;
|
align-items: stretch;
|
||||||
padding: 20px 24px 24px;
|
padding: 20px 24px 24px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
&.quick-sleep-item {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: stretch;
|
||||||
|
padding: 20px 24px 24px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.control-left {
|
.control-left {
|
||||||
@@ -220,6 +230,14 @@
|
|||||||
color: #FF8C42;
|
color: #FF8C42;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.control-link {
|
||||||
|
flex-shrink: 0;
|
||||||
|
margin-left: 16px;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
.divider {
|
.divider {
|
||||||
height: 1px;
|
height: 1px;
|
||||||
margin: 0 24px;
|
margin: 0 24px;
|
||||||
@@ -236,6 +254,195 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quick-sleep-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-sleep-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-sleep-btn {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 82px;
|
||||||
|
border-radius: 18px;
|
||||||
|
|
||||||
|
&.sleep {
|
||||||
|
background: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.wake {
|
||||||
|
background: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.65;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-sleep-btn-text {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-card {
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-header {
|
||||||
|
padding: 24px 24px 18px;
|
||||||
|
background: linear-gradient(135deg, #FFF1F2 0%, #FFE4E6 100%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #9F1239;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #BE123C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-body {
|
||||||
|
padding: 6px 24px 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 1px solid #F7E4E8;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-label {
|
||||||
|
font-size: 28px;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-value {
|
||||||
|
max-width: 60%;
|
||||||
|
font-size: 28px;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-empty {
|
||||||
|
padding: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-empty-text {
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history {
|
||||||
|
padding: 0 24px 20px;
|
||||||
|
border-top: 1px solid #F7E4E8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 22px 0 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-title {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-count {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-item {
|
||||||
|
padding: 18px 0;
|
||||||
|
border-top: 1px solid #F5F5F5;
|
||||||
|
|
||||||
|
&:first-of-type {
|
||||||
|
border-top: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-main {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-time {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1F2937;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-type {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #BE123C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-child,
|
||||||
|
.alarm-history-device {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 24px;
|
||||||
|
color: #6B7280;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-device {
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-empty {
|
||||||
|
padding: 8px 0 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alarm-history-empty-text {
|
||||||
|
font-size: 24px;
|
||||||
|
color: #9CA3AF;
|
||||||
|
}
|
||||||
|
|
||||||
.volume-slider {
|
.volume-slider {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { View, Text, Slider, Image, Switch } from '@tarojs/components'
|
import { View, Text, Slider, Image } 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 { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
function formatTime(value?: string | null): string {
|
function formatTime(value?: string | null): string {
|
||||||
@@ -39,14 +40,53 @@ function getSignalLabel(value?: number | null): string {
|
|||||||
return '弱'
|
return '弱'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatSleepRange(status: DeviceStatus | null): string {
|
||||||
|
const start = status?.disable_time_start?.trim()
|
||||||
|
const end = status?.disable_time_end?.trim()
|
||||||
|
if (!start || !end) return '未设置'
|
||||||
|
return `${start}-${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatAlarmTime(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')
|
||||||
|
const second = `${date.getSeconds()}`.padStart(2, '0')
|
||||||
|
return `${month}-${day} ${hour}:${minute}:${second}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlarmTypeLabel(value?: string | null): string {
|
||||||
|
const normalized = String(value || '').trim()
|
||||||
|
if (!normalized) return '--'
|
||||||
|
if (normalized === '010') return '紧急告警'
|
||||||
|
return normalized
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlarmChildLabel(alarm: DeviceAlarmItem | null, fallbackChildName?: string | null): string {
|
||||||
|
const alarmChildName = String(alarm?.child_name || '').trim()
|
||||||
|
if (alarmChildName) return alarmChildName
|
||||||
|
|
||||||
|
const fallbackName = String(fallbackChildName || '').trim()
|
||||||
|
if (fallbackName) return fallbackName
|
||||||
|
|
||||||
|
if (alarm?.child_id) return `儿童 ${alarm.child_id}`
|
||||||
|
return '未关联儿童'
|
||||||
|
}
|
||||||
|
|
||||||
export default function Device() {
|
export default function Device() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
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 [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
|
||||||
|
const [deviceAlarms, setDeviceAlarms] = useState<DeviceAlarmItem[]>([])
|
||||||
const [isLoading, setIsLoading] = useState(true)
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
const [sleepEnabled, setSleepEnabled] = useState(true)
|
|
||||||
const [isDetailExpanded, setIsDetailExpanded] = useState(false)
|
const [isDetailExpanded, setIsDetailExpanded] = useState(false)
|
||||||
const [isSavingVolume, setIsSavingVolume] = useState(false)
|
const [isSavingVolume, setIsSavingVolume] = useState(false)
|
||||||
|
const [sleepWakePending, setSleepWakePending] = useState<'on' | 'off' | null>(null)
|
||||||
const [volumeValue, setVolumeValue] = useState(0)
|
const [volumeValue, setVolumeValue] = useState(0)
|
||||||
|
|
||||||
useDidShow(() => {
|
useDidShow(() => {
|
||||||
@@ -66,11 +106,16 @@ export default function Device() {
|
|||||||
setBinding(context.currentBinding)
|
setBinding(context.currentBinding)
|
||||||
|
|
||||||
if (context.currentBinding?.device_id) {
|
if (context.currentBinding?.device_id) {
|
||||||
const nextStatus = await getDeviceStatus(context.currentBinding.device_id)
|
const [nextStatus, nextAlarms] = await Promise.all([
|
||||||
|
getDeviceStatus(context.currentBinding.device_id),
|
||||||
|
getDeviceAlarms(context.currentBinding.device_id, 20),
|
||||||
|
])
|
||||||
setDeviceStatus(nextStatus)
|
setDeviceStatus(nextStatus)
|
||||||
|
setDeviceAlarms(nextAlarms.items || [])
|
||||||
setVolumeValue(nextStatus?.volume ?? 0)
|
setVolumeValue(nextStatus?.volume ?? 0)
|
||||||
} else {
|
} else {
|
||||||
setDeviceStatus(null)
|
setDeviceStatus(null)
|
||||||
|
setDeviceAlarms([])
|
||||||
setVolumeValue(0)
|
setVolumeValue(0)
|
||||||
}
|
}
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
@@ -84,13 +129,8 @@ export default function Device() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleSleepToggle = (e: any) => {
|
const handleOpenSleepSchedule = () => {
|
||||||
const nextValue = Boolean(e.detail?.value)
|
Taro.navigateTo({ url: '/pages/sleep-schedule/index' })
|
||||||
setSleepEnabled(nextValue)
|
|
||||||
Taro.showToast({
|
|
||||||
title: nextValue ? '休眠已开启' : '休眠已关闭',
|
|
||||||
icon: 'none',
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleToggleDetail = () => {
|
const handleToggleDetail = () => {
|
||||||
@@ -126,12 +166,33 @@ export default function Device() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRemoteSleepWake = async (switchValue: 'on' | 'off') => {
|
||||||
|
if (!binding?.device_id || sleepWakePending) return
|
||||||
|
|
||||||
|
setSleepWakePending(switchValue)
|
||||||
|
try {
|
||||||
|
await setDeviceRemoteSleepWake(switchValue, binding.device_id)
|
||||||
|
Taro.showToast({
|
||||||
|
title: switchValue === 'off' ? '休眠指令已发送' : '唤醒指令已发送',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '操作失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setSleepWakePending(null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
<View className='loading'>
|
<View className='loading'>
|
||||||
<Text>加载中...</Text>
|
<Text>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -153,6 +214,7 @@ export default function Device() {
|
|||||||
<Text className='empty-btn-text'>去添加孩子</Text>
|
<Text className='empty-btn-text'>去添加孩子</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -184,6 +246,7 @@ export default function Device() {
|
|||||||
<Text className='empty-btn-text'>去绑定设备</Text>
|
<Text className='empty-btn-text'>去绑定设备</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -196,6 +259,10 @@ export default function Device() {
|
|||||||
const versionLabel = deviceStatus?.version || '--'
|
const versionLabel = deviceStatus?.version || '--'
|
||||||
const coordinateLabel = formatCoordinates(deviceStatus)
|
const coordinateLabel = formatCoordinates(deviceStatus)
|
||||||
const volumeLabel = `${volumeValue}%`
|
const volumeLabel = `${volumeValue}%`
|
||||||
|
const sleepRangeLabel = formatSleepRange(deviceStatus)
|
||||||
|
const latestAlarm = deviceAlarms[0] || null
|
||||||
|
const historyAlarms = latestAlarm ? deviceAlarms.slice(1) : []
|
||||||
|
const latestAlarmChildName = getAlarmChildLabel(latestAlarm, childName)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View className='device-page'>
|
<View className='device-page'>
|
||||||
@@ -234,17 +301,53 @@ export default function Device() {
|
|||||||
|
|
||||||
<View className='section-title'>设备控制</View>
|
<View className='section-title'>设备控制</View>
|
||||||
<View className='control-card'>
|
<View className='control-card'>
|
||||||
<View className='control-item'>
|
<View className='control-item sleep-item' onClick={handleOpenSleepSchedule}>
|
||||||
<View className='control-left'>
|
<View className='control-left'>
|
||||||
<View className='icon-bg purple'>
|
<View className='icon-bg purple'>
|
||||||
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
|
<Image 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>
|
||||||
<Text className='control-detail'>开启后进入休眠状态</Text>
|
<Text className='control-detail'>{sleepRangeLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<Text className='control-link'>设置</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='divider'></View>
|
||||||
|
|
||||||
|
<View className='control-item quick-sleep-item'>
|
||||||
|
<View className='quick-sleep-header'>
|
||||||
|
<View className='control-left'>
|
||||||
|
<View className='icon-bg purple'>
|
||||||
|
<Image className='control-icon-img' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
|
||||||
|
</View>
|
||||||
|
<View className='control-info'>
|
||||||
|
<Text className='control-name'>立即休眠/唤醒</Text>
|
||||||
|
<Text className='control-detail'>
|
||||||
|
{sleepWakePending === 'off'
|
||||||
|
? '正在发送休眠指令...'
|
||||||
|
: sleepWakePending === 'on'
|
||||||
|
? '正在发送唤醒指令...'
|
||||||
|
: '立即控制当前设备'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='quick-sleep-actions'>
|
||||||
|
<View
|
||||||
|
className={`quick-sleep-btn sleep ${sleepWakePending ? 'disabled' : ''}`}
|
||||||
|
onClick={sleepWakePending ? undefined : () => handleRemoteSleepWake('off')}
|
||||||
|
>
|
||||||
|
<Text className='quick-sleep-btn-text'>立即休眠</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`quick-sleep-btn wake ${sleepWakePending ? 'disabled' : ''}`}
|
||||||
|
onClick={sleepWakePending ? undefined : () => handleRemoteSleepWake('on')}
|
||||||
|
>
|
||||||
|
<Text className='quick-sleep-btn-text'>立即唤醒</Text>
|
||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
<Switch checked={sleepEnabled} onChange={handleSleepToggle} color='#FF8C42' />
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<View className='divider'></View>
|
<View className='divider'></View>
|
||||||
@@ -291,6 +394,70 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className='alarm-card'>
|
||||||
|
<View className='alarm-header'>
|
||||||
|
<Text className='alarm-title'>最近告警</Text>
|
||||||
|
<Text className='alarm-subtitle'>
|
||||||
|
{latestAlarm ? '已收到设备主动上报的告警,可继续查看以往记录' : '暂时没有收到新的设备告警'}
|
||||||
|
</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{latestAlarm ? (
|
||||||
|
<View>
|
||||||
|
<View className='alarm-body'>
|
||||||
|
<View className='alarm-row'>
|
||||||
|
<Text className='alarm-label'>告警时间</Text>
|
||||||
|
<Text className='alarm-value'>{formatAlarmTime(latestAlarm.created_at)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='alarm-row'>
|
||||||
|
<Text className='alarm-label'>来源儿童</Text>
|
||||||
|
<Text className='alarm-value'>{latestAlarmChildName}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='alarm-row'>
|
||||||
|
<Text className='alarm-label'>来源设备</Text>
|
||||||
|
<Text className='alarm-value'>{latestAlarm.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='alarm-row'>
|
||||||
|
<Text className='alarm-label'>上报类型</Text>
|
||||||
|
<Text className='alarm-value'>{getAlarmTypeLabel(latestAlarm.source_msg_id)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='alarm-history'>
|
||||||
|
<View className='alarm-history-header'>
|
||||||
|
<Text className='alarm-history-title'>以往告警</Text>
|
||||||
|
<Text className='alarm-history-count'>最近 {historyAlarms.length} 条</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{historyAlarms.length > 0 ? (
|
||||||
|
historyAlarms.map((alarm) => (
|
||||||
|
<View className='alarm-history-item' key={alarm.alarm_id}>
|
||||||
|
<View className='alarm-history-main'>
|
||||||
|
<Text className='alarm-history-time'>{formatAlarmTime(alarm.created_at)}</Text>
|
||||||
|
<Text className='alarm-history-type'>{getAlarmTypeLabel(alarm.source_msg_id)}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='alarm-history-meta'>
|
||||||
|
<Text className='alarm-history-child'>
|
||||||
|
来源儿童:{getAlarmChildLabel(alarm, childName)}
|
||||||
|
</Text>
|
||||||
|
<Text className='alarm-history-device'>{alarm.device_id}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<View className='alarm-history-empty'>
|
||||||
|
<Text className='alarm-history-empty-text'>还没有更早的告警记录。</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
) : (
|
||||||
|
<View className='alarm-empty'>
|
||||||
|
<Text className='alarm-empty-text'>设备触发长按告警后,会在这里显示最近一条记录。</Text>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='fold-card'>
|
<View className='fold-card'>
|
||||||
<View className='fold-header' onClick={handleToggleDetail}>
|
<View className='fold-header' onClick={handleToggleDetail}>
|
||||||
<View className='fold-heading'>
|
<View className='fold-heading'>
|
||||||
@@ -329,6 +496,7 @@ export default function Device() {
|
|||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
DeviceLocation,
|
DeviceLocation,
|
||||||
DeviceTrajectoryPoint,
|
DeviceTrajectoryPoint,
|
||||||
} from '@/services/location'
|
} from '@/services/location'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
type TrajectoryMode = 'current' | 'today' | 'recent'
|
type TrajectoryMode = 'current' | 'today' | 'recent'
|
||||||
@@ -117,6 +118,7 @@ function getTrajectoryMarkerId(index: number): number {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Location() {
|
export default function Location() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
|
const [deviceLocation, setDeviceLocation] = useState<DeviceLocation | null>(null)
|
||||||
const [trajectory, setTrajectory] = useState<DeviceTrajectoryPoint[]>([])
|
const [trajectory, setTrajectory] = useState<DeviceTrajectoryPoint[]>([])
|
||||||
@@ -492,6 +494,7 @@ export default function Location() {
|
|||||||
</View>
|
</View>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
5
banban-mini/src/pages/sleep-schedule/index.config.ts
Normal file
5
banban-mini/src/pages/sleep-schedule/index.config.ts
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
export default definePageConfig({
|
||||||
|
navigationBarTitleText: '定时休眠',
|
||||||
|
navigationBarBackgroundColor: '#F5F7FA',
|
||||||
|
navigationBarTextStyle: 'black',
|
||||||
|
})
|
||||||
252
banban-mini/src/pages/sleep-schedule/index.scss
Normal file
252
banban-mini/src/pages/sleep-schedule/index.scss
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
.sleep-page {
|
||||||
|
min-height: 100%;
|
||||||
|
background: #F5F7FA;
|
||||||
|
padding-bottom: 160px;
|
||||||
|
padding-bottom: calc(160px + constant(safe-area-inset-bottom));
|
||||||
|
padding-bottom: calc(160px + env(safe-area-inset-bottom));
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
padding: 80px 32px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 48px;
|
||||||
|
line-height: 1.2;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 14px;
|
||||||
|
font-size: 28px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-card {
|
||||||
|
margin: 0 24px 28px;
|
||||||
|
padding: 32px 28px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: linear-gradient(135deg, #1F2937 0%, #334155 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-copy {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 24px;
|
||||||
|
color: rgba(255, 255, 255, 0.68);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-value {
|
||||||
|
display: block;
|
||||||
|
margin-top: 10px;
|
||||||
|
font-size: 34px;
|
||||||
|
line-height: 1.3;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
word-break: break-all;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-detail {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: rgba(255, 255, 255, 0.88);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon-wrap {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: 104px;
|
||||||
|
height: 104px;
|
||||||
|
margin-left: 20px;
|
||||||
|
border-radius: 28px;
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-icon {
|
||||||
|
width: 52px;
|
||||||
|
height: 52px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
display: block;
|
||||||
|
margin: 0 24px 18px;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card,
|
||||||
|
.summary-card,
|
||||||
|
.tips-card,
|
||||||
|
.empty-card {
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-card,
|
||||||
|
.summary-card,
|
||||||
|
.tips-card {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row,
|
||||||
|
.summary-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
padding: 28px 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-row:active {
|
||||||
|
background: #F8F9FA;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-label,
|
||||||
|
.summary-label {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-hint {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: #7A7A7A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-value,
|
||||||
|
.summary-value {
|
||||||
|
max-width: 42%;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
text-align: right;
|
||||||
|
color: #FF8C42;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-card .summary-value {
|
||||||
|
color: #1A1A1A;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.divider {
|
||||||
|
height: 1px;
|
||||||
|
margin: 0 24px;
|
||||||
|
background: #F0F0F0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-card {
|
||||||
|
padding: 28px 24px;
|
||||||
|
background: #FFF7ED;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-title {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 12px;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #C2410C;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-text {
|
||||||
|
display: block;
|
||||||
|
font-size: 26px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #9A3412;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tips-text + .tips-text {
|
||||||
|
margin-top: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn {
|
||||||
|
position: fixed;
|
||||||
|
left: 24px;
|
||||||
|
right: 24px;
|
||||||
|
bottom: 32px;
|
||||||
|
bottom: calc(32px + constant(safe-area-inset-bottom));
|
||||||
|
bottom: calc(32px + env(safe-area-inset-bottom));
|
||||||
|
height: 92px;
|
||||||
|
border-radius: 22px;
|
||||||
|
background: #FF8C42;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
box-shadow: 0 10px 24px rgba(255, 140, 66, 0.28);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn.disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-btn-text {
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-card {
|
||||||
|
padding: 32px 28px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 32px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #1A1A1A;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-desc {
|
||||||
|
display: block;
|
||||||
|
margin-top: 12px;
|
||||||
|
font-size: 27px;
|
||||||
|
line-height: 1.7;
|
||||||
|
color: #666666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn {
|
||||||
|
margin-top: 28px;
|
||||||
|
height: 84px;
|
||||||
|
border-radius: 18px;
|
||||||
|
background: #FF8C42;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-btn-text {
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
min-height: 100%;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading text {
|
||||||
|
font-size: 32px;
|
||||||
|
color: #999999;
|
||||||
|
}
|
||||||
222
banban-mini/src/pages/sleep-schedule/index.tsx
Normal file
222
banban-mini/src/pages/sleep-schedule/index.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
import { View, Text, Picker, Image } from '@tarojs/components'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import Taro, { useDidShow } from '@tarojs/taro'
|
||||||
|
import { getToken } from '@/services/auth'
|
||||||
|
import { loadCurrentChildBindingContext, Binding } from '@/services/binding'
|
||||||
|
import { Child } from '@/services/child'
|
||||||
|
import { DeviceStatus, getDeviceStatus, setDeviceSleepSchedule } from '@/services/device'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
|
import './index.scss'
|
||||||
|
|
||||||
|
function formatSleepRange(status: DeviceStatus | null): string {
|
||||||
|
const start = status?.disable_time_start?.trim()
|
||||||
|
const end = status?.disable_time_end?.trim()
|
||||||
|
if (!start || !end) return '未设置'
|
||||||
|
return `${start}-${end}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUpdatedAt(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}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SleepSchedule() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
|
const [isLoading, setIsLoading] = useState(true)
|
||||||
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
const [binding, setBinding] = useState<Binding | null>(null)
|
||||||
|
const [child, setChild] = useState<Child | null>(null)
|
||||||
|
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
|
||||||
|
const [startTime, setStartTime] = useState('22:00')
|
||||||
|
const [endTime, setEndTime] = useState('07:00')
|
||||||
|
|
||||||
|
useDidShow(() => {
|
||||||
|
void loadData()
|
||||||
|
})
|
||||||
|
|
||||||
|
const loadData = async () => {
|
||||||
|
if (!getToken()) {
|
||||||
|
Taro.reLaunch({ url: '/pages/login/index' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
setStartTime(nextStatus?.disable_time_start || '22:00')
|
||||||
|
setEndTime(nextStatus?.disable_time_end || '07:00')
|
||||||
|
} else {
|
||||||
|
setDeviceStatus(null)
|
||||||
|
setStartTime('22:00')
|
||||||
|
setEndTime('07:00')
|
||||||
|
}
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[sleep-schedule] load failed:', error)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '加载失败,请稍后重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSave = async () => {
|
||||||
|
if (!binding?.device_id) {
|
||||||
|
Taro.showToast({ title: '当前还没有绑定设备', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (startTime === endTime) {
|
||||||
|
Taro.showToast({ title: '开始和结束时间不能相同', icon: 'none' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsSaving(true)
|
||||||
|
try {
|
||||||
|
const result = await setDeviceSleepSchedule(startTime, endTime, deviceStatus?.timezone || 'Asia/Shanghai', binding.device_id)
|
||||||
|
setDeviceStatus((current) => ({
|
||||||
|
...(current || { device_id: binding.device_id }),
|
||||||
|
...current,
|
||||||
|
sleep_mode: result.sleep_mode,
|
||||||
|
disable_time_start: result.start || startTime,
|
||||||
|
disable_time_end: result.end || endTime,
|
||||||
|
timezone: result.timezone,
|
||||||
|
}))
|
||||||
|
Taro.showToast({ title: '休眠时间已发送', icon: 'success' })
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '设置失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<View className='sleep-page'>
|
||||||
|
<View className='loading'>
|
||||||
|
<Text>加载中...</Text>
|
||||||
|
</View>
|
||||||
|
{systemBanner}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!child) {
|
||||||
|
return (
|
||||||
|
<View className='sleep-page'>
|
||||||
|
<View className='page-header'>
|
||||||
|
<Text className='page-title'>定时休眠</Text>
|
||||||
|
<Text className='page-subtitle'>先选择一个孩子,再为对应设备设置休眠时间</Text>
|
||||||
|
</View>
|
||||||
|
<View className='empty-card'>
|
||||||
|
<Text className='empty-title'>还没有当前孩子</Text>
|
||||||
|
<Text className='empty-desc'>请先到管理页添加并选择孩子。</Text>
|
||||||
|
</View>
|
||||||
|
{systemBanner}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!binding) {
|
||||||
|
return (
|
||||||
|
<View className='sleep-page'>
|
||||||
|
<View className='page-header'>
|
||||||
|
<Text className='page-title'>定时休眠</Text>
|
||||||
|
<Text className='page-subtitle'>{child.child_name} 还没有绑定设备</Text>
|
||||||
|
</View>
|
||||||
|
<View className='empty-card'>
|
||||||
|
<Text className='empty-title'>当前没有可设置的设备</Text>
|
||||||
|
<Text className='empty-desc'>先去首页绑定设备,再回来设置休眠时间段。</Text>
|
||||||
|
<View className='primary-btn' onClick={() => Taro.navigateTo({ url: '/pages/bind/index' })}>
|
||||||
|
<Text className='primary-btn-text'>去绑定设备</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
{systemBanner}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentRange = formatSleepRange(deviceStatus)
|
||||||
|
const timezoneText = deviceStatus?.timezone || 'Asia/Shanghai'
|
||||||
|
const childName = child.child_name || '当前孩子'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View className='sleep-page'>
|
||||||
|
<View className='page-header'>
|
||||||
|
<Text className='page-title'>定时休眠</Text>
|
||||||
|
<Text className='page-subtitle'>每天在设定时段内,设备将进入不可启动状态</Text>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='hero-card'>
|
||||||
|
<View className='hero-copy'>
|
||||||
|
<Text className='hero-label'>当前设备</Text>
|
||||||
|
<Text className='hero-value'>{binding.device_id}</Text>
|
||||||
|
<Text className='hero-detail'>{childName} 的当前休眠时段:{currentRange}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='hero-icon-wrap'>
|
||||||
|
<Image className='hero-icon' src={require('../../assets/tab-icons/moon.png')} mode='aspectFit' />
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='section-title'>时间设置</View>
|
||||||
|
<View className='settings-card'>
|
||||||
|
<Picker mode='time' value={startTime} onChange={(event) => setStartTime(event.detail.value)}>
|
||||||
|
<View className='setting-row'>
|
||||||
|
<View>
|
||||||
|
<Text className='setting-label'>开始时间</Text>
|
||||||
|
<Text className='setting-hint'>设备从这个时刻开始进入休眠</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='setting-value'>{startTime}</Text>
|
||||||
|
</View>
|
||||||
|
</Picker>
|
||||||
|
|
||||||
|
<View className='divider'></View>
|
||||||
|
|
||||||
|
<Picker mode='time' value={endTime} onChange={(event) => setEndTime(event.detail.value)}>
|
||||||
|
<View className='setting-row'>
|
||||||
|
<View>
|
||||||
|
<Text className='setting-label'>结束时间</Text>
|
||||||
|
<Text className='setting-hint'>到这个时刻后恢复正常使用</Text>
|
||||||
|
</View>
|
||||||
|
<Text className='setting-value'>{endTime}</Text>
|
||||||
|
</View>
|
||||||
|
</Picker>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className='summary-card'>
|
||||||
|
<View className='summary-row'>
|
||||||
|
<Text className='summary-label'>生效时区</Text>
|
||||||
|
<Text className='summary-value'>{timezoneText}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-row'>
|
||||||
|
<Text className='summary-label'>当前配置</Text>
|
||||||
|
<Text className='summary-value'>{currentRange}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='summary-row'>
|
||||||
|
<Text className='summary-label'>最近更新时间</Text>
|
||||||
|
<Text className='summary-value'>{formatUpdatedAt(deviceStatus?.settings_updated_at)}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<View className={`save-btn ${isSaving ? 'disabled' : ''}`} onClick={isSaving ? undefined : handleSave}>
|
||||||
|
<Text className='save-btn-text'>{isSaving ? '发送中...' : '保存休眠时间'}</Text>
|
||||||
|
</View>
|
||||||
|
{systemBanner}
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -194,6 +194,104 @@
|
|||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.system-update-card {
|
||||||
|
margin: 0 24px 24px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: #FFFFFF;
|
||||||
|
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.04);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
opacity: 0.72;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 20px;
|
||||||
|
padding: 24px;
|
||||||
|
background: #F0FDF4;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-heading {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-title {
|
||||||
|
display: block;
|
||||||
|
font-size: 30px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #14532D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-subtitle {
|
||||||
|
display: block;
|
||||||
|
margin-top: 8px;
|
||||||
|
font-size: 24px;
|
||||||
|
line-height: 1.5;
|
||||||
|
color: #15803D;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-action {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
flex-shrink: 0;
|
||||||
|
min-width: 132px;
|
||||||
|
height: 64px;
|
||||||
|
padding: 0 24px;
|
||||||
|
border-radius: 16px;
|
||||||
|
background: #16A34A;
|
||||||
|
|
||||||
|
&.disabled {
|
||||||
|
background: #CBD5E1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-action-text {
|
||||||
|
font-size: 26px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: #FFFFFF;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-body {
|
||||||
|
padding: 6px 24px 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 24px;
|
||||||
|
padding: 18px 0;
|
||||||
|
border-bottom: 1px solid #F3F4F6;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-label {
|
||||||
|
flex-shrink: 0;
|
||||||
|
font-size: 26px;
|
||||||
|
color: #6B7280;
|
||||||
|
}
|
||||||
|
|
||||||
|
.system-update-value {
|
||||||
|
min-width: 0;
|
||||||
|
font-size: 28px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: right;
|
||||||
|
word-break: break-all;
|
||||||
|
color: #1F2937;
|
||||||
|
|
||||||
|
&.highlight {
|
||||||
|
color: #15803D;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.menu-item.disabled {
|
.menu-item.disabled {
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,8 @@ import {
|
|||||||
unbindDevice,
|
unbindDevice,
|
||||||
} from '@/services/binding'
|
} from '@/services/binding'
|
||||||
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
|
||||||
|
import { DeviceFirmwareStatus, getDeviceFirmwareStatus, startDeviceFirmwareUpdate } from '@/services/device'
|
||||||
|
import { useSystemBanner } from '@/components/system-banner/use-system-banner'
|
||||||
import './index.scss'
|
import './index.scss'
|
||||||
|
|
||||||
interface MenuItem {
|
interface MenuItem {
|
||||||
@@ -22,11 +24,15 @@ interface MenuItem {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export default function Sleep() {
|
export default function Sleep() {
|
||||||
|
const systemBanner = useSystemBanner()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [children, setChildren] = useState<Child[]>([])
|
const [children, setChildren] = useState<Child[]>([])
|
||||||
const [bindings, setBindings] = useState<BindingListItem[]>([])
|
const [bindings, setBindings] = useState<BindingListItem[]>([])
|
||||||
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
const [binding, setBinding] = useState<BindingListItem | null>(null)
|
||||||
const [currentChild, setCurrentChild] = useState<Child | null>(null)
|
const [currentChild, setCurrentChild] = useState<Child | null>(null)
|
||||||
|
const [firmwareStatus, setFirmwareStatus] = useState<DeviceFirmwareStatus | null>(null)
|
||||||
|
const [isLoadingFirmware, setIsLoadingFirmware] = useState(false)
|
||||||
|
const [isUpdatingFirmware, setIsUpdatingFirmware] = useState(false)
|
||||||
const [showModal, setShowModal] = useState(false)
|
const [showModal, setShowModal] = useState(false)
|
||||||
const [showChildModal, setShowChildModal] = useState(false)
|
const [showChildModal, setShowChildModal] = useState(false)
|
||||||
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
const [modalType, setModalType] = useState<'add' | 'edit'>('add')
|
||||||
@@ -47,11 +53,13 @@ export default function Sleep() {
|
|||||||
setLoading(true)
|
setLoading(true)
|
||||||
try {
|
try {
|
||||||
const context = await loadCurrentChildBindingContext()
|
const context = await loadCurrentChildBindingContext()
|
||||||
|
const nextBinding = (context.currentBinding as BindingListItem | null) || null
|
||||||
setChildren(context.children)
|
setChildren(context.children)
|
||||||
setBindings(context.bindings as BindingListItem[])
|
setBindings(context.bindings as BindingListItem[])
|
||||||
setCurrentChild(context.currentChild)
|
setCurrentChild(context.currentChild)
|
||||||
setBinding((context.currentBinding as BindingListItem | null) || null)
|
setBinding(nextBinding)
|
||||||
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
setParentInfo(Taro.getStorageSync('userInfo') || {})
|
||||||
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
console.error('[manage] load failed:', error)
|
console.error('[manage] load failed:', error)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
@@ -65,6 +73,30 @@ export default function Sleep() {
|
|||||||
|
|
||||||
const currentChildName = currentChild?.child_name || '未设置'
|
const currentChildName = currentChild?.child_name || '未设置'
|
||||||
|
|
||||||
|
const loadFirmwareStatus = async (deviceId?: string | null) => {
|
||||||
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
|
if (!normalizedDeviceId) {
|
||||||
|
setFirmwareStatus(null)
|
||||||
|
setIsLoadingFirmware(false)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setIsLoadingFirmware(true)
|
||||||
|
try {
|
||||||
|
const nextFirmwareStatus = await getDeviceFirmwareStatus(normalizedDeviceId)
|
||||||
|
setFirmwareStatus(nextFirmwareStatus)
|
||||||
|
} catch (error: any) {
|
||||||
|
console.error('[manage] firmware load failed:', error)
|
||||||
|
setFirmwareStatus(null)
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '系统更新状态加载失败',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsLoadingFirmware(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
const handleOpenModal = (type: 'add' | 'edit', child?: Child) => {
|
||||||
setModalType(type)
|
setModalType(type)
|
||||||
setChildName(child?.child_name || '')
|
setChildName(child?.child_name || '')
|
||||||
@@ -88,6 +120,7 @@ export default function Sleep() {
|
|||||||
}
|
}
|
||||||
setCurrentChild(child)
|
setCurrentChild(child)
|
||||||
setBinding(nextBinding)
|
setBinding(nextBinding)
|
||||||
|
void loadFirmwareStatus(nextBinding?.device_id)
|
||||||
setShowChildModal(false)
|
setShowChildModal(false)
|
||||||
Taro.showToast({
|
Taro.showToast({
|
||||||
title: '已切换当前孩子',
|
title: '已切换当前孩子',
|
||||||
@@ -128,6 +161,37 @@ export default function Sleep() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleStartFirmwareUpdate = () => {
|
||||||
|
if (!binding?.device_id || !firmwareStatus?.can_update || isLoadingFirmware || isUpdatingFirmware) return
|
||||||
|
|
||||||
|
Taro.showModal({
|
||||||
|
title: '系统更新',
|
||||||
|
content: `确定要更新当前设备 ${binding.device_id} 吗?`,
|
||||||
|
confirmText: '更新',
|
||||||
|
confirmColor: '#16A34A',
|
||||||
|
success: async (result) => {
|
||||||
|
if (!result.confirm || !binding?.device_id) return
|
||||||
|
|
||||||
|
setIsUpdatingFirmware(true)
|
||||||
|
try {
|
||||||
|
const nextFirmwareStatus = await startDeviceFirmwareUpdate(binding.device_id)
|
||||||
|
setFirmwareStatus(nextFirmwareStatus)
|
||||||
|
Taro.showToast({
|
||||||
|
title: '更新指令已发送',
|
||||||
|
icon: 'success',
|
||||||
|
})
|
||||||
|
} catch (error: any) {
|
||||||
|
Taro.showToast({
|
||||||
|
title: error?.message || '更新失败,请重试',
|
||||||
|
icon: 'none',
|
||||||
|
})
|
||||||
|
} finally {
|
||||||
|
setIsUpdatingFirmware(false)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
const handleUnbind = () => {
|
const handleUnbind = () => {
|
||||||
if (!binding) return
|
if (!binding) return
|
||||||
|
|
||||||
@@ -215,11 +279,23 @@ export default function Sleep() {
|
|||||||
<View className='loading'>
|
<View className='loading'>
|
||||||
<Text>加载中...</Text>
|
<Text>加载中...</Text>
|
||||||
</View>
|
</View>
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
const parentDisplayName = parentInfo.nickname?.trim() || '家长'
|
const parentDisplayName = parentInfo.nickname?.trim() || '家长'
|
||||||
|
const currentFirmwareLabel = firmwareStatus?.current_version || '--'
|
||||||
|
const latestFirmwareLabel = firmwareStatus?.latest_version || '--'
|
||||||
|
const canUpdateFirmware = Boolean(binding?.device_id && firmwareStatus?.can_update && !isLoadingFirmware && !isUpdatingFirmware)
|
||||||
|
const firmwareActionText = isLoadingFirmware ? '查询中' : isUpdatingFirmware ? '发送中' : '更新'
|
||||||
|
const firmwareSubtitle = !binding?.device_id
|
||||||
|
? '绑定设备后可查看系统版本'
|
||||||
|
: isLoadingFirmware
|
||||||
|
? '正在查询当前设备版本'
|
||||||
|
: firmwareStatus?.update_available
|
||||||
|
? '发现可更新版本'
|
||||||
|
: '当前设备版本状态'
|
||||||
const menuItems: MenuItem[] = [
|
const menuItems: MenuItem[] = [
|
||||||
{
|
{
|
||||||
icon: require('../../assets/tab-icons/orange-robot.png'),
|
icon: require('../../assets/tab-icons/orange-robot.png'),
|
||||||
@@ -296,6 +372,35 @@ export default function Sleep() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
|
<View className={`system-update-card ${binding?.device_id ? '' : 'disabled'}`}>
|
||||||
|
<View className='system-update-header'>
|
||||||
|
<View className='system-update-heading'>
|
||||||
|
<Text className='system-update-title'>系统更新</Text>
|
||||||
|
<Text className='system-update-subtitle'>{firmwareSubtitle}</Text>
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
className={`system-update-action ${canUpdateFirmware ? '' : 'disabled'}`}
|
||||||
|
onClick={canUpdateFirmware ? handleStartFirmwareUpdate : undefined}
|
||||||
|
>
|
||||||
|
<Text className='system-update-action-text'>{firmwareActionText}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
<View className='system-update-body'>
|
||||||
|
<View className='system-update-row'>
|
||||||
|
<Text className='system-update-label'>当前设备</Text>
|
||||||
|
<Text className='system-update-value'>{binding?.device_id || '未绑定'}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='system-update-row'>
|
||||||
|
<Text className='system-update-label'>当前版本</Text>
|
||||||
|
<Text className='system-update-value'>{currentFirmwareLabel}</Text>
|
||||||
|
</View>
|
||||||
|
<View className='system-update-row'>
|
||||||
|
<Text className='system-update-label'>最新版本</Text>
|
||||||
|
<Text className='system-update-value highlight'>{latestFirmwareLabel}</Text>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
|
|
||||||
<View className='menu-card'>
|
<View className='menu-card'>
|
||||||
{menuItems.map((item, index) => (
|
{menuItems.map((item, index) => (
|
||||||
<View key={index}>
|
<View key={index}>
|
||||||
@@ -396,6 +501,7 @@ export default function Sleep() {
|
|||||||
</View>
|
</View>
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
{systemBanner}
|
||||||
</View>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -211,10 +211,7 @@ function formatImPreview(
|
|||||||
): 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) {
|
if (item.content_type === 2) {
|
||||||
if (item.ext_json?.message_kind === 'leave_message') {
|
return ''
|
||||||
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) {
|
||||||
|
|||||||
@@ -5,6 +5,10 @@ export interface DeviceStatus {
|
|||||||
device_id: string
|
device_id: string
|
||||||
child_id?: number | null
|
child_id?: number | null
|
||||||
child_name?: string | null
|
child_name?: string | null
|
||||||
|
sleep_mode?: number | null
|
||||||
|
disable_time_start?: string | null
|
||||||
|
disable_time_end?: string | null
|
||||||
|
timezone?: string | null
|
||||||
power?: number | null
|
power?: number | null
|
||||||
volume?: number | null
|
volume?: number | null
|
||||||
signal?: number | null
|
signal?: number | null
|
||||||
@@ -24,12 +28,77 @@ export interface DeviceStatus {
|
|||||||
location_updated_at?: string | null
|
location_updated_at?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceAlarmItem {
|
||||||
|
alarm_id: number
|
||||||
|
device_id: string
|
||||||
|
child_id?: number | null
|
||||||
|
child_name?: string | null
|
||||||
|
source_msg_id: string
|
||||||
|
created_at: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceAlarmListResponse {
|
||||||
|
items: DeviceAlarmItem[]
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeviceVolumeUpdateResponse {
|
export interface DeviceVolumeUpdateResponse {
|
||||||
device_id: string
|
device_id: string
|
||||||
level: number
|
level: number
|
||||||
msg_id: string
|
msg_id: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface DeviceSleepScheduleUpdateResponse {
|
||||||
|
device_id: string
|
||||||
|
sleep_mode: number
|
||||||
|
start?: string | null
|
||||||
|
end?: string | null
|
||||||
|
timezone: string
|
||||||
|
msg_id?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRemoteSleepWakeResponse {
|
||||||
|
device_id: string
|
||||||
|
switch: 'on' | 'off'
|
||||||
|
msg_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceFirmwareStatus {
|
||||||
|
device_id: string
|
||||||
|
current_version?: string | null
|
||||||
|
latest_version?: string | null
|
||||||
|
update_available: boolean
|
||||||
|
can_update: boolean
|
||||||
|
update_status: string
|
||||||
|
progress: number
|
||||||
|
target_version?: string | null
|
||||||
|
updated_at?: string | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceFirmwareUpdateResponse extends DeviceFirmwareStatus {
|
||||||
|
msg_id: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeTimeValue(value?: string | null): string | null {
|
||||||
|
if (value === null || value === undefined) return null
|
||||||
|
const trimmed = String(value).trim()
|
||||||
|
if (!trimmed) return null
|
||||||
|
|
||||||
|
const matched = trimmed.match(/^(\d{1,2}):(\d{2})(?::\d{2})?:?$/)
|
||||||
|
if (!matched) return trimmed.replace(/:+$/, '')
|
||||||
|
|
||||||
|
const hour = matched[1].padStart(2, '0')
|
||||||
|
return `${hour}:${matched[2]}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeDeviceStatus(status: DeviceStatus): DeviceStatus {
|
||||||
|
return {
|
||||||
|
...status,
|
||||||
|
disable_time_start: normalizeTimeValue(status.disable_time_start),
|
||||||
|
disable_time_end: normalizeTimeValue(status.disable_time_end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function resolveDeviceId(deviceId?: string): Promise<string> {
|
async function resolveDeviceId(deviceId?: string): Promise<string> {
|
||||||
const normalizedDeviceId = String(deviceId || '').trim()
|
const normalizedDeviceId = String(deviceId || '').trim()
|
||||||
if (normalizedDeviceId) return normalizedDeviceId
|
if (normalizedDeviceId) return normalizedDeviceId
|
||||||
@@ -42,7 +111,8 @@ export async function getDeviceStatus(deviceId?: string): Promise<DeviceStatus |
|
|||||||
if (!resolvedDeviceId) return null
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
try {
|
try {
|
||||||
return await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/status`)
|
const result = await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/status`)
|
||||||
|
return normalizeDeviceStatus(result)
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
if (error?.status === 404) return null
|
if (error?.status === 404) return null
|
||||||
throw error
|
throw error
|
||||||
@@ -60,3 +130,76 @@ export async function setDeviceVolume(level: number, deviceId?: string): Promise
|
|||||||
data: { level },
|
data: { level },
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function setDeviceSleepSchedule(
|
||||||
|
start: string,
|
||||||
|
end: string,
|
||||||
|
timezone = 'Asia/Shanghai',
|
||||||
|
deviceId?: string
|
||||||
|
): Promise<DeviceSleepScheduleUpdateResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await request<DeviceSleepScheduleUpdateResponse>(`/banban/devices/${resolvedDeviceId}/sleep-schedule`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { start, end, timezone },
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
start: normalizeTimeValue(result.start),
|
||||||
|
end: normalizeTimeValue(result.end),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setDeviceRemoteSleepWake(
|
||||||
|
switchValue: 'on' | 'off',
|
||||||
|
deviceId?: string
|
||||||
|
): Promise<DeviceRemoteSleepWakeResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceRemoteSleepWakeResponse>(`/banban/devices/${resolvedDeviceId}/sleep-wake`, {
|
||||||
|
method: 'POST',
|
||||||
|
data: { switch: switchValue },
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceFirmwareStatus(deviceId?: string): Promise<DeviceFirmwareStatus | null> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) return null
|
||||||
|
|
||||||
|
try {
|
||||||
|
return await request<DeviceFirmwareStatus>(`/banban/devices/${resolvedDeviceId}/firmware`)
|
||||||
|
} catch (error: any) {
|
||||||
|
if (error?.status === 404) return null
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function startDeviceFirmwareUpdate(deviceId?: string): Promise<DeviceFirmwareUpdateResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
throw new Error('当前没有可用设备')
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceFirmwareUpdateResponse>(`/banban/devices/${resolvedDeviceId}/firmware/update`, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getDeviceAlarms(
|
||||||
|
deviceId?: string,
|
||||||
|
limit = 20
|
||||||
|
): Promise<DeviceAlarmListResponse> {
|
||||||
|
const resolvedDeviceId = await resolveDeviceId(deviceId)
|
||||||
|
if (!resolvedDeviceId) {
|
||||||
|
return { items: [], total: 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
return request<DeviceAlarmListResponse>(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`)
|
||||||
|
}
|
||||||
|
|||||||
175
banban-mini/src/services/system-banner.ts
Normal file
175
banban-mini/src/services/system-banner.ts
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import Taro from '@tarojs/taro'
|
||||||
|
import { Binding, getBindings } from './binding'
|
||||||
|
import { DeviceAlarmItem, getDeviceAlarms } from './device'
|
||||||
|
|
||||||
|
const LAST_SEEN_ALARM_MAP_KEY = 'systemBannerLastSeenAlarmMap'
|
||||||
|
const DEVICE_ALARM_FETCH_LIMIT = 20
|
||||||
|
const LAST_POLL_TIME_KEY = 'systemBannerLastPollTime'
|
||||||
|
|
||||||
|
type LastSeenAlarmMap = Record<string, number>
|
||||||
|
|
||||||
|
export interface SystemBannerNotification {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
summary: string
|
||||||
|
detail: string
|
||||||
|
timeLabel: string
|
||||||
|
route: string
|
||||||
|
alarmId: number
|
||||||
|
deviceId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastSeenAlarmMap(): LastSeenAlarmMap {
|
||||||
|
const storedValue = Taro.getStorageSync(LAST_SEEN_ALARM_MAP_KEY)
|
||||||
|
if (!storedValue || typeof storedValue !== 'object') return {}
|
||||||
|
return storedValue as LastSeenAlarmMap
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLastSeenAlarmMap(map: LastSeenAlarmMap) {
|
||||||
|
Taro.setStorageSync(LAST_SEEN_ALARM_MAP_KEY, map)
|
||||||
|
}
|
||||||
|
|
||||||
|
function readLastPollTime(): number {
|
||||||
|
const value = Number(Taro.getStorageSync(LAST_POLL_TIME_KEY) || 0)
|
||||||
|
return Number.isFinite(value) ? value : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function writeLastPollTime(value: number) {
|
||||||
|
Taro.setStorageSync(LAST_POLL_TIME_KEY, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function setDeviceAlarmSeen(deviceId: string, alarmId: number, map: LastSeenAlarmMap) {
|
||||||
|
map[deviceId] = alarmId
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasDeviceAlarmBaseline(deviceId: string, map: LastSeenAlarmMap): boolean {
|
||||||
|
return Object.prototype.hasOwnProperty.call(map, deviceId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlarmChildLabel(alarm: DeviceAlarmItem, fallbackChildName?: string | null): string {
|
||||||
|
const childName = String(alarm.child_name || '').trim()
|
||||||
|
if (childName) return childName
|
||||||
|
|
||||||
|
const fallbackName = String(fallbackChildName || '').trim()
|
||||||
|
if (fallbackName) return fallbackName
|
||||||
|
|
||||||
|
if (alarm.child_id) return `儿童 ${alarm.child_id}`
|
||||||
|
return '当前孩子'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getAlarmTypeLabel(sourceMsgId?: string | null): string {
|
||||||
|
const normalizedValue = String(sourceMsgId || '').trim()
|
||||||
|
if (normalizedValue === '010') return '紧急告警'
|
||||||
|
if (!normalizedValue) return '设备提醒'
|
||||||
|
return normalizedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBannerTime(value?: string | null): string {
|
||||||
|
if (!value) return ''
|
||||||
|
const date = new Date(value)
|
||||||
|
if (Number.isNaN(date.getTime())) return ''
|
||||||
|
const hour = `${date.getHours()}`.padStart(2, '0')
|
||||||
|
const minute = `${date.getMinutes()}`.padStart(2, '0')
|
||||||
|
return `${hour}:${minute}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildDeviceAlarmNotification(
|
||||||
|
alarm: DeviceAlarmItem,
|
||||||
|
fallbackChildName?: string | null
|
||||||
|
): SystemBannerNotification {
|
||||||
|
const childLabel = getAlarmChildLabel(alarm, fallbackChildName)
|
||||||
|
const alarmTypeLabel = getAlarmTypeLabel(alarm.source_msg_id)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: `device-alarm-${alarm.device_id}-${alarm.alarm_id}`,
|
||||||
|
title: '系统消息',
|
||||||
|
summary: `${childLabel} 的伴伴触发了${alarmTypeLabel}`,
|
||||||
|
detail: `设备 ${alarm.device_id}`,
|
||||||
|
timeLabel: formatBannerTime(alarm.created_at),
|
||||||
|
route: '/pages/device/index',
|
||||||
|
alarmId: alarm.alarm_id,
|
||||||
|
deviceId: alarm.device_id,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortAlarmsByCreatedAt(left: DeviceAlarmItem, right: DeviceAlarmItem): number {
|
||||||
|
const leftTime = new Date(left.created_at).getTime()
|
||||||
|
const rightTime = new Date(right.created_at).getTime()
|
||||||
|
return leftTime - rightTime
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function pollSystemBannerNotifications(): Promise<SystemBannerNotification[]> {
|
||||||
|
const bindingResponse = await getBindings(undefined, 100)
|
||||||
|
const bindings = bindingResponse.items || []
|
||||||
|
|
||||||
|
if (bindings.length === 0) return []
|
||||||
|
|
||||||
|
const lastSeenAlarmMap = readLastSeenAlarmMap()
|
||||||
|
const previousPollTime = readLastPollTime()
|
||||||
|
const currentPollTime = Date.now()
|
||||||
|
let hasMapChanged = false
|
||||||
|
|
||||||
|
const alarmResults = await Promise.allSettled(
|
||||||
|
bindings.map(async (binding) => ({
|
||||||
|
binding,
|
||||||
|
response: await getDeviceAlarms(binding.device_id, DEVICE_ALARM_FETCH_LIMIT),
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
|
||||||
|
const nextNotifications: Array<{ alarm: DeviceAlarmItem; binding: Binding }> = []
|
||||||
|
|
||||||
|
alarmResults.forEach((result) => {
|
||||||
|
if (result.status !== 'fulfilled') return
|
||||||
|
|
||||||
|
const { binding, response } = result.value
|
||||||
|
const deviceId = String(binding.device_id || '').trim()
|
||||||
|
if (!deviceId) return
|
||||||
|
|
||||||
|
const alarms = response.items || []
|
||||||
|
const latestAlarmId = alarms[0]?.alarm_id || 0
|
||||||
|
const hasBaseline = hasDeviceAlarmBaseline(deviceId, lastSeenAlarmMap)
|
||||||
|
const knownAlarmId = Number(lastSeenAlarmMap[deviceId] || 0)
|
||||||
|
|
||||||
|
// Establish a baseline on first poll so old history does not flood the user.
|
||||||
|
if (!hasBaseline) {
|
||||||
|
setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap)
|
||||||
|
hasMapChanged = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestAlarmId < knownAlarmId) {
|
||||||
|
setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap)
|
||||||
|
hasMapChanged = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (latestAlarmId === knownAlarmId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setDeviceAlarmSeen(deviceId, latestAlarmId, lastSeenAlarmMap)
|
||||||
|
hasMapChanged = true
|
||||||
|
|
||||||
|
alarms
|
||||||
|
.filter((alarm) => {
|
||||||
|
if (alarm.alarm_id <= knownAlarmId) return false
|
||||||
|
if (previousPollTime <= 0) return true
|
||||||
|
|
||||||
|
const createdAt = new Date(alarm.created_at).getTime()
|
||||||
|
if (Number.isNaN(createdAt)) return true
|
||||||
|
return createdAt >= previousPollTime
|
||||||
|
})
|
||||||
|
.forEach((alarm) => {
|
||||||
|
nextNotifications.push({ alarm, binding })
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
if (hasMapChanged) {
|
||||||
|
writeLastSeenAlarmMap(lastSeenAlarmMap)
|
||||||
|
}
|
||||||
|
writeLastPollTime(currentPollTime)
|
||||||
|
|
||||||
|
return nextNotifications
|
||||||
|
.sort((left, right) => sortAlarmsByCreatedAt(left.alarm, right.alarm))
|
||||||
|
.map(({ alarm, binding }) => buildDeviceAlarmNotification(alarm, binding.child_name))
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user