From 7518455e2eacc17dbbb3fe55014c2ff1a7320ebc Mon Sep 17 00:00:00 2001 From: stu2not Date: Thu, 7 May 2026 16:05:50 +0800 Subject: [PATCH] =?UTF-8?q?=E5=AE=8C=E5=96=84=E5=B0=8F=E7=A8=8B=E5=BA=8F?= =?UTF-8?q?=E8=AE=BE=E5=A4=87=E9=A1=B5=E4=B8=8E=E7=B3=BB=E7=BB=9F=E6=A8=AA?= =?UTF-8?q?=E5=B9=85=E6=8F=90=E9=86=92?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/app.config.ts | 1 + .../src/components/system-banner/index.scss | 119 +++++++++ .../src/components/system-banner/index.tsx | 44 +++ .../system-banner/use-system-banner.tsx | 191 +++++++++++++ banban-mini/src/pages/bind/index.tsx | 4 + banban-mini/src/pages/chat/detail/index.tsx | 3 + banban-mini/src/pages/chat/index.tsx | 3 + banban-mini/src/pages/device/index.scss | 207 ++++++++++++++ banban-mini/src/pages/device/index.tsx | 196 +++++++++++++- banban-mini/src/pages/location/index.tsx | 3 + .../src/pages/sleep-schedule/index.config.ts | 5 + .../src/pages/sleep-schedule/index.scss | 252 ++++++++++++++++++ .../src/pages/sleep-schedule/index.tsx | 222 +++++++++++++++ banban-mini/src/pages/sleep/index.tsx | 4 + banban-mini/src/services/device.ts | 106 +++++++- banban-mini/src/services/system-banner.ts | 175 ++++++++++++ 16 files changed, 1520 insertions(+), 15 deletions(-) create mode 100644 banban-mini/src/components/system-banner/index.scss create mode 100644 banban-mini/src/components/system-banner/index.tsx create mode 100644 banban-mini/src/components/system-banner/use-system-banner.tsx create mode 100644 banban-mini/src/pages/sleep-schedule/index.config.ts create mode 100644 banban-mini/src/pages/sleep-schedule/index.scss create mode 100644 banban-mini/src/pages/sleep-schedule/index.tsx create mode 100644 banban-mini/src/services/system-banner.ts 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)) +}