diff --git a/banban-mini/src/app.config.ts b/banban-mini/src/app.config.ts
index 0a712f8..2d74c7b 100644
--- a/banban-mini/src/app.config.ts
+++ b/banban-mini/src/app.config.ts
@@ -7,6 +7,7 @@ export default defineAppConfig({
"pages/chat/detail/index",
"pages/location/index",
"pages/sleep/index",
+ "pages/sleep-schedule/index",
],
window: {
backgroundTextStyle: "light",
diff --git a/banban-mini/src/components/system-banner/index.scss b/banban-mini/src/components/system-banner/index.scss
new file mode 100644
index 0000000..1faa6da
--- /dev/null
+++ b/banban-mini/src/components/system-banner/index.scss
@@ -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;
+}
diff --git a/banban-mini/src/components/system-banner/index.tsx b/banban-mini/src/components/system-banner/index.tsx
new file mode 100644
index 0000000..f570bb9
--- /dev/null
+++ b/banban-mini/src/components/system-banner/index.tsx
@@ -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 (
+
+
+
+ !
+
+
+
+
+ {banner.title}
+ {banner.timeLabel}
+
+ {banner.summary}
+ {banner.detail}
+
+
+
+ ×
+
+
+
+ )
+}
diff --git a/banban-mini/src/components/system-banner/use-system-banner.tsx b/banban-mini/src/components/system-banner/use-system-banner.tsx
new file mode 100644
index 0000000..01c99dd
--- /dev/null
+++ b/banban-mini/src/components/system-banner/use-system-banner.tsx
@@ -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(null)
+ const [bannerQueue, setBannerQueue] = useState([])
+ const [bannerVisible, setBannerVisible] = useState(false)
+ const [bannerTop, setBannerTop] = useState(24)
+ const pollTimerRef = useRef | null>(null)
+ const hideTimerRef = useRef | null>(null)
+ const clearBannerTimerRef = useRef | null>(null)
+ const isPollingRef = useRef(false)
+ const currentBannerRef = useRef(null)
+ const queuedBannerKeysRef = useRef>(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 (
+
+ )
+}
diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx
index 003246a..3c36ad9 100644
--- a/banban-mini/src/pages/bind/index.tsx
+++ b/banban-mini/src/pages/bind/index.tsx
@@ -17,6 +17,7 @@ import {
resolveChildSelection,
setSelectedChildId as setStoredSelectedChildId,
} from '@/services/child'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
@@ -82,6 +83,7 @@ function parseBindingPayload(rawValue: string): { deviceId: string; serialNumber
}
export default function Bind() {
+ const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true)
const [submitting, setSubmitting] = useState(false)
const [isScanning, setIsScanning] = useState(false)
@@ -333,6 +335,7 @@ export default function Bind() {
加载中...
+ {systemBanner}
)
}
@@ -480,6 +483,7 @@ export default function Bind() {
点击发送绑卡指令,然后去设备上贴自己的卡完成确认
+ {systemBanner}
)
}
diff --git a/banban-mini/src/pages/chat/detail/index.tsx b/banban-mini/src/pages/chat/detail/index.tsx
index 60ac3b5..251735c 100644
--- a/banban-mini/src/pages/chat/detail/index.tsx
+++ b/banban-mini/src/pages/chat/detail/index.tsx
@@ -10,6 +10,7 @@ import {
ConversationSource,
PeerKind,
} from '../../../services/chat'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
function normalizePeerKind(value?: string): PeerKind {
@@ -53,6 +54,7 @@ function formatAudioDuration(durationMs?: number | null): string {
}
export default function ChatDetail() {
+ const systemBanner = useSystemBanner()
const router = useRouter()
const params = router.params
@@ -421,6 +423,7 @@ export default function ChatDetail() {
)}
+ {systemBanner}
)
}
diff --git a/banban-mini/src/pages/chat/index.tsx b/banban-mini/src/pages/chat/index.tsx
index d6f1a11..3e08ae7 100644
--- a/banban-mini/src/pages/chat/index.tsx
+++ b/banban-mini/src/pages/chat/index.tsx
@@ -3,9 +3,11 @@ import { useState } from 'react'
import Taro, { useDidShow } from '@tarojs/taro'
import { getToken } from '@/services/auth'
import { getConversations, ChatConversation } from '../../services/chat'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
export default function Chat() {
+ const systemBanner = useSystemBanner()
const [conversations, setConversations] = useState([])
const [loading, setLoading] = useState(true)
@@ -97,6 +99,7 @@ export default function Chat() {
))}
)}
+ {systemBanner}
)
}
diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss
index 80464f7..0545c35 100644
--- a/banban-mini/src/pages/device/index.scss
+++ b/banban-mini/src/pages/device/index.scss
@@ -164,11 +164,21 @@
justify-content: space-between;
padding: 24px;
+ &.sleep-item:active {
+ background: #F8F9FA;
+ }
+
&.volume-item {
flex-direction: column;
align-items: stretch;
padding: 20px 24px 24px;
}
+
+ &.quick-sleep-item {
+ flex-direction: column;
+ align-items: stretch;
+ padding: 20px 24px 24px;
+ }
}
.control-left {
@@ -220,6 +230,14 @@
color: #FF8C42;
}
+.control-link {
+ flex-shrink: 0;
+ margin-left: 16px;
+ font-size: 26px;
+ font-weight: 700;
+ color: #FF8C42;
+}
+
.divider {
height: 1px;
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 {
display: flex;
align-items: center;
diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx
index 287b175..20bdeb8 100644
--- a/banban-mini/src/pages/device/index.tsx
+++ b/banban-mini/src/pages/device/index.tsx
@@ -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 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 { DeviceAlarmItem, DeviceStatus, getDeviceAlarms, getDeviceStatus, setDeviceRemoteSleepWake, setDeviceVolume } from '@/services/device'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
function formatTime(value?: string | null): string {
@@ -39,14 +40,53 @@ function getSignalLabel(value?: number | null): string {
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() {
+ const systemBanner = useSystemBanner()
const [binding, setBinding] = useState(null)
const [child, setChild] = useState(null)
const [deviceStatus, setDeviceStatus] = useState(null)
+ const [deviceAlarms, setDeviceAlarms] = useState([])
const [isLoading, setIsLoading] = useState(true)
- const [sleepEnabled, setSleepEnabled] = useState(true)
const [isDetailExpanded, setIsDetailExpanded] = useState(false)
const [isSavingVolume, setIsSavingVolume] = useState(false)
+ const [sleepWakePending, setSleepWakePending] = useState<'on' | 'off' | null>(null)
const [volumeValue, setVolumeValue] = useState(0)
useDidShow(() => {
@@ -66,11 +106,16 @@ export default function Device() {
setBinding(context.currentBinding)
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)
+ setDeviceAlarms(nextAlarms.items || [])
setVolumeValue(nextStatus?.volume ?? 0)
} else {
setDeviceStatus(null)
+ setDeviceAlarms([])
setVolumeValue(0)
}
} catch (error: any) {
@@ -84,13 +129,8 @@ export default function Device() {
}
}
- const handleSleepToggle = (e: any) => {
- const nextValue = Boolean(e.detail?.value)
- setSleepEnabled(nextValue)
- Taro.showToast({
- title: nextValue ? '休眠已开启' : '休眠已关闭',
- icon: 'none',
- })
+ const handleOpenSleepSchedule = () => {
+ Taro.navigateTo({ url: '/pages/sleep-schedule/index' })
}
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) {
return (
加载中...
+ {systemBanner}
)
}
@@ -153,6 +214,7 @@ export default function Device() {
去添加孩子
+ {systemBanner}
)
}
@@ -184,6 +246,7 @@ export default function Device() {
去绑定设备
+ {systemBanner}
)
}
@@ -196,6 +259,10 @@ export default function Device() {
const versionLabel = deviceStatus?.version || '--'
const coordinateLabel = formatCoordinates(deviceStatus)
const volumeLabel = `${volumeValue}%`
+ const sleepRangeLabel = formatSleepRange(deviceStatus)
+ const latestAlarm = deviceAlarms[0] || null
+ const historyAlarms = latestAlarm ? deviceAlarms.slice(1) : []
+ const latestAlarmChildName = getAlarmChildLabel(latestAlarm, childName)
return (
@@ -234,17 +301,53 @@ export default function Device() {
设备控制
-
+
定时休眠
- 开启后进入休眠状态
+ {sleepRangeLabel}
+
+
+ 设置
+
+
+
+
+
+
+
+
+
+
+
+ 立即休眠/唤醒
+
+ {sleepWakePending === 'off'
+ ? '正在发送休眠指令...'
+ : sleepWakePending === 'on'
+ ? '正在发送唤醒指令...'
+ : '立即控制当前设备'}
+
+
+
+
+
+ handleRemoteSleepWake('off')}
+ >
+ 立即休眠
+
+ handleRemoteSleepWake('on')}
+ >
+ 立即唤醒
-
@@ -291,6 +394,70 @@ export default function Device() {
+
+
+ 最近告警
+
+ {latestAlarm ? '已收到设备主动上报的告警,可继续查看以往记录' : '暂时没有收到新的设备告警'}
+
+
+
+ {latestAlarm ? (
+
+
+
+ 告警时间
+ {formatAlarmTime(latestAlarm.created_at)}
+
+
+ 来源儿童
+ {latestAlarmChildName}
+
+
+ 来源设备
+ {latestAlarm.device_id}
+
+
+ 上报类型
+ {getAlarmTypeLabel(latestAlarm.source_msg_id)}
+
+
+
+
+
+ 以往告警
+ 最近 {historyAlarms.length} 条
+
+
+ {historyAlarms.length > 0 ? (
+ historyAlarms.map((alarm) => (
+
+
+ {formatAlarmTime(alarm.created_at)}
+ {getAlarmTypeLabel(alarm.source_msg_id)}
+
+
+
+ 来源儿童:{getAlarmChildLabel(alarm, childName)}
+
+ {alarm.device_id}
+
+
+ ))
+ ) : (
+
+ 还没有更早的告警记录。
+
+ )}
+
+
+ ) : (
+
+ 设备触发长按告警后,会在这里显示最近一条记录。
+
+ )}
+
+
@@ -329,6 +496,7 @@ export default function Device() {
)}
+ {systemBanner}
)
}
diff --git a/banban-mini/src/pages/location/index.tsx b/banban-mini/src/pages/location/index.tsx
index 6f46f7f..fa4a36c 100644
--- a/banban-mini/src/pages/location/index.tsx
+++ b/banban-mini/src/pages/location/index.tsx
@@ -9,6 +9,7 @@ import {
DeviceLocation,
DeviceTrajectoryPoint,
} from '@/services/location'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
type TrajectoryMode = 'current' | 'today' | 'recent'
@@ -117,6 +118,7 @@ function getTrajectoryMarkerId(index: number): number {
}
export default function Location() {
+ const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true)
const [deviceLocation, setDeviceLocation] = useState(null)
const [trajectory, setTrajectory] = useState([])
@@ -492,6 +494,7 @@ export default function Location() {
>
)}
+ {systemBanner}
)
}
diff --git a/banban-mini/src/pages/sleep-schedule/index.config.ts b/banban-mini/src/pages/sleep-schedule/index.config.ts
new file mode 100644
index 0000000..f914a9e
--- /dev/null
+++ b/banban-mini/src/pages/sleep-schedule/index.config.ts
@@ -0,0 +1,5 @@
+export default definePageConfig({
+ navigationBarTitleText: '定时休眠',
+ navigationBarBackgroundColor: '#F5F7FA',
+ navigationBarTextStyle: 'black',
+})
diff --git a/banban-mini/src/pages/sleep-schedule/index.scss b/banban-mini/src/pages/sleep-schedule/index.scss
new file mode 100644
index 0000000..505be44
--- /dev/null
+++ b/banban-mini/src/pages/sleep-schedule/index.scss
@@ -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;
+}
diff --git a/banban-mini/src/pages/sleep-schedule/index.tsx b/banban-mini/src/pages/sleep-schedule/index.tsx
new file mode 100644
index 0000000..ef4b5b3
--- /dev/null
+++ b/banban-mini/src/pages/sleep-schedule/index.tsx
@@ -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(null)
+ const [child, setChild] = useState(null)
+ const [deviceStatus, setDeviceStatus] = useState(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 (
+
+
+ 加载中...
+
+ {systemBanner}
+
+ )
+ }
+
+ if (!child) {
+ return (
+
+
+ 定时休眠
+ 先选择一个孩子,再为对应设备设置休眠时间
+
+
+ 还没有当前孩子
+ 请先到管理页添加并选择孩子。
+
+ {systemBanner}
+
+ )
+ }
+
+ if (!binding) {
+ return (
+
+
+ 定时休眠
+ {child.child_name} 还没有绑定设备
+
+
+ 当前没有可设置的设备
+ 先去首页绑定设备,再回来设置休眠时间段。
+ Taro.navigateTo({ url: '/pages/bind/index' })}>
+ 去绑定设备
+
+
+ {systemBanner}
+
+ )
+ }
+
+ const currentRange = formatSleepRange(deviceStatus)
+ const timezoneText = deviceStatus?.timezone || 'Asia/Shanghai'
+ const childName = child.child_name || '当前孩子'
+
+ return (
+
+
+ 定时休眠
+ 每天在设定时段内,设备将进入不可启动状态
+
+
+
+
+ 当前设备
+ {binding.device_id}
+ {childName} 的当前休眠时段:{currentRange}
+
+
+
+
+
+
+ 时间设置
+
+ setStartTime(event.detail.value)}>
+
+
+ 开始时间
+ 设备从这个时刻开始进入休眠
+
+ {startTime}
+
+
+
+
+
+ setEndTime(event.detail.value)}>
+
+
+ 结束时间
+ 到这个时刻后恢复正常使用
+
+ {endTime}
+
+
+
+
+
+
+ 生效时区
+ {timezoneText}
+
+
+ 当前配置
+ {currentRange}
+
+
+ 最近更新时间
+ {formatUpdatedAt(deviceStatus?.settings_updated_at)}
+
+
+
+
+ {isSaving ? '发送中...' : '保存休眠时间'}
+
+ {systemBanner}
+
+ )
+}
diff --git a/banban-mini/src/pages/sleep/index.tsx b/banban-mini/src/pages/sleep/index.tsx
index 7918355..a40d7b6 100644
--- a/banban-mini/src/pages/sleep/index.tsx
+++ b/banban-mini/src/pages/sleep/index.tsx
@@ -10,6 +10,7 @@ import {
unbindDevice,
} from '@/services/binding'
import { Child, clearSelectedChildId, createChild, setSelectedChildId, updateChild } from '@/services/child'
+import { useSystemBanner } from '@/components/system-banner/use-system-banner'
import './index.scss'
interface MenuItem {
@@ -22,6 +23,7 @@ interface MenuItem {
}
export default function Sleep() {
+ const systemBanner = useSystemBanner()
const [loading, setLoading] = useState(true)
const [children, setChildren] = useState([])
const [bindings, setBindings] = useState([])
@@ -215,6 +217,7 @@ export default function Sleep() {
加载中...
+ {systemBanner}
)
}
@@ -396,6 +399,7 @@ export default function Sleep() {
)}
+ {systemBanner}
)
}
diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts
index c7e875a..e4cb29c 100644
--- a/banban-mini/src/services/device.ts
+++ b/banban-mini/src/services/device.ts
@@ -5,6 +5,10 @@ export interface DeviceStatus {
device_id: string
child_id?: number | 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
volume?: number | null
signal?: number | null
@@ -24,12 +28,61 @@ export interface DeviceStatus {
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 {
device_id: string
level: number
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
+}
+
+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 {
const normalizedDeviceId = String(deviceId || '').trim()
if (normalizedDeviceId) return normalizedDeviceId
@@ -42,7 +95,8 @@ export async function getDeviceStatus(deviceId?: string): Promise(`/banban/devices/${resolvedDeviceId}/status`)
+ const result = await request(`/banban/devices/${resolvedDeviceId}/status`)
+ return normalizeDeviceStatus(result)
} catch (error: any) {
if (error?.status === 404) return null
throw error
@@ -60,3 +114,53 @@ export async function setDeviceVolume(level: number, deviceId?: string): Promise
data: { level },
})
}
+
+export async function setDeviceSleepSchedule(
+ start: string,
+ end: string,
+ timezone = 'Asia/Shanghai',
+ deviceId?: string
+): Promise {
+ const resolvedDeviceId = await resolveDeviceId(deviceId)
+ if (!resolvedDeviceId) {
+ throw new Error('当前没有可用设备')
+ }
+
+ const result = await request(`/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 {
+ const resolvedDeviceId = await resolveDeviceId(deviceId)
+ if (!resolvedDeviceId) {
+ throw new Error('当前没有可用设备')
+ }
+
+ return request(`/banban/devices/${resolvedDeviceId}/sleep-wake`, {
+ method: 'POST',
+ data: { switch: switchValue },
+ })
+}
+
+export async function getDeviceAlarms(
+ deviceId?: string,
+ limit = 20
+): Promise {
+ const resolvedDeviceId = await resolveDeviceId(deviceId)
+ if (!resolvedDeviceId) {
+ return { items: [], total: 0 }
+ }
+
+ return request(`/banban/devices/${resolvedDeviceId}/alarms?limit=${limit}`)
+}
diff --git a/banban-mini/src/services/system-banner.ts b/banban-mini/src/services/system-banner.ts
new file mode 100644
index 0000000..1d45b0f
--- /dev/null
+++ b/banban-mini/src/services/system-banner.ts
@@ -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
+
+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 {
+ 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))
+}