完善小程序设备页与系统横幅提醒

This commit is contained in:
stu2not
2026-05-07 16:05:50 +08:00
parent f825c60176
commit 7518455e2e
16 changed files with 1520 additions and 15 deletions

View File

@@ -7,6 +7,7 @@ export default defineAppConfig({
"pages/chat/detail/index",
"pages/location/index",
"pages/sleep/index",
"pages/sleep-schedule/index",
],
window: {
backgroundTextStyle: "light",

View 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;
}

View 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>
)
}

View 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}
/>
)
}

View File

@@ -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() {
<View className='loading'>
<Text>...</Text>
</View>
{systemBanner}
</View>
)
}
@@ -480,6 +483,7 @@ export default function Bind() {
<Text className='tip-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}

View File

@@ -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() {
</View>
</View>
)}
{systemBanner}
</View>
)
}

View File

@@ -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<ChatConversation[]>([])
const [loading, setLoading] = useState(true)
@@ -97,6 +99,7 @@ export default function Chat() {
))}
</ScrollView>
)}
{systemBanner}
</View>
)
}

View File

@@ -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;

View File

@@ -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<Binding | null>(null)
const [child, setChild] = useState<Child | null>(null)
const [deviceStatus, setDeviceStatus] = useState<DeviceStatus | null>(null)
const [deviceAlarms, setDeviceAlarms] = useState<DeviceAlarmItem[]>([])
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 (
<View className='device-page'>
<View className='loading'>
<Text>...</Text>
</View>
{systemBanner}
</View>
)
}
@@ -153,6 +214,7 @@ export default function Device() {
<Text className='empty-btn-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}
@@ -184,6 +246,7 @@ export default function Device() {
<Text className='empty-btn-text'></Text>
</View>
</View>
{systemBanner}
</View>
)
}
@@ -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 (
<View className='device-page'>
@@ -234,17 +301,53 @@ export default function Device() {
<View className='section-title'></View>
<View className='control-card'>
<View className='control-item'>
<View className='control-item sleep-item' onClick={handleOpenSleepSchedule}>
<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'></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>
<Switch checked={sleepEnabled} onChange={handleSleepToggle} color='#FF8C42' />
</View>
<View className='divider'></View>
@@ -291,6 +394,70 @@ export default function Device() {
</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-header' onClick={handleToggleDetail}>
<View className='fold-heading'>
@@ -329,6 +496,7 @@ export default function Device() {
</View>
)}
</View>
{systemBanner}
</View>
)
}

View File

@@ -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<DeviceLocation | null>(null)
const [trajectory, setTrajectory] = useState<DeviceTrajectoryPoint[]>([])
@@ -492,6 +494,7 @@ export default function Location() {
</View>
</>
)}
{systemBanner}
</View>
)
}

View File

@@ -0,0 +1,5 @@
export default definePageConfig({
navigationBarTitleText: '定时休眠',
navigationBarBackgroundColor: '#F5F7FA',
navigationBarTextStyle: 'black',
})

View 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;
}

View 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>
)
}

View File

@@ -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<Child[]>([])
const [bindings, setBindings] = useState<BindingListItem[]>([])
@@ -215,6 +217,7 @@ export default function Sleep() {
<View className='loading'>
<Text>...</Text>
</View>
{systemBanner}
</View>
)
}
@@ -396,6 +399,7 @@ export default function Sleep() {
</View>
</View>
)}
{systemBanner}
</View>
)
}

View File

@@ -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<string> {
const normalizedDeviceId = String(deviceId || '').trim()
if (normalizedDeviceId) return normalizedDeviceId
@@ -42,7 +95,8 @@ export async function getDeviceStatus(deviceId?: string): Promise<DeviceStatus |
if (!resolvedDeviceId) return null
try {
return await request<DeviceStatus>(`/banban/devices/${resolvedDeviceId}/status`)
const result = await request<DeviceStatus>(`/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<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 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}`)
}

View 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))
}