smart-home/smart-home-app/pages/kitchen/history.vue
2026-02-26 09:16:34 +08:00

793 lines
20 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<template>
<view class="container">
<!-- 顶部状态栏 -->
<!-- <view class="status-bar"></view> -->
<!-- 头部 -->
<view class="header">
<view class="header-left" @click="goBack">
<text class="back-icon"></text>
<text class="title">报警历史</text>
</view>
<view class="header-right">
<text class="refresh-text" @click="loadAlarmHistory" :class="{loading: isLoading}">
{{isLoading ? '刷新中...' : '刷新'}}
</text>
<text class="clear-text" @click="clearHistory">清空</text>
</view>
</view>
<!-- 筛选选项 -->
<view class="filter-bar">
<scroll-view scroll-x="true" class="filter-scroll">
<view class="filter-list">
<text
:class="['filter-item', selectedFilter === 'all' ? 'active' : '']"
@click="setFilter('all')"
>全部</text>
<text
:class="['filter-item', selectedFilter === 'danger' ? 'active' : '']"
@click="setFilter('danger')"
>高温危险</text>
<text
:class="['filter-item', selectedFilter === 'warning' ? 'active' : '']"
@click="setFilter('warning')"
>异常升温</text>
<text
:class="['filter-item', selectedFilter === 'unattended' ? 'active' : '']"
@click="setFilter('unattended')"
>无人值守</text>
<text
:class="['filter-item', selectedFilter === 'expanding' ? 'active' : '']"
@click="setFilter('expanding')"
>高温扩散</text>
</view>
</scroll-view>
</view>
<!-- 统计信息 -->
<view class="stats-card">
<view class="stats-grid">
<view class="stats-item">
<text class="stats-num">{{totalAlarms}}</text>
<text class="stats-label">总报警</text>
</view>
<view class="stats-item">
<text class="stats-num danger">{{dangerAlarms}}</text>
<text class="stats-label">高危</text>
</view>
<view class="stats-item">
<text class="stats-num warning">{{warningAlarms}}</text>
<text class="stats-label">警告</text>
</view>
<view class="stats-item">
<text class="stats-num today">{{todayAlarms}}</text>
<text class="stats-label">今日</text>
</view>
</view>
</view>
<!-- 报警列表 -->
<view class="history-list">
<view class="date-group" v-for="group in filteredHistory" :key="group.date">
<view class="date-header">
<text class="date-text">{{group.date}}</text>
<text class="count-text">{{group.alarms.length}}条</text>
</view>
<view class="alarm-card" v-for="alarm in group.alarms" :key="alarm.id" @click="showAlarmDetail(alarm)">
<view class="alarm-header">
<view class="alarm-icon-wrapper" :class="alarm.level">
<text class="alarm-icon">{{alarm.icon}}</text>
</view>
<view class="alarm-info">
<text class="alarm-title">{{alarm.title}}</text>
<text class="alarm-time">{{alarm.time}}</text>
</view>
<view class="alarm-temp">
<text class="temp-value">{{alarm.temp}}°C</text>
</view>
</view>
<view class="alarm-content">
<text class="alarm-desc">{{alarm.desc}}</text>
<text class="alarm-location">热点区域: {{alarm.location}}</text>
</view>
<view class="alarm-actions">
<text class="action-btn" @click.stop="markAsRead(alarm)">标记已读</text>
<text class="action-btn" @click.stop="deleteAlarm(alarm)">删除</text>
</view>
</view>
</view>
</view>
<!-- 空状态 -->
<view class="empty-state" v-if="filteredHistory.length === 0">
<text class="empty-icon">📋</text>
<text class="empty-text">暂无报警记录</text>
<text class="empty-desc">系统将自动记录所有报警事件</text>
</view>
</view>
</template>
<script>
import { getAlarmHistoryUrl } from '@/utils/esp32Client.js'
import { requestEsp32Api } from '@/utils/networkHelper.js'
import { syncEsp32AlarmHistoryToCloud } from '@/utils/alarmSync.js'
import { listCloudAlarms, markCloudAlarmRead } from '@/utils/alarmCloudApi.js'
import { getAllDevices } from '@/utils/deviceStore.js'
import { getToken } from '@/utils/authStore.js'
export default {
data() {
return {
selectedFilter: 'all',
isLoading: false,
refreshTimer: null,
alarmHistory: []
}
},
computed: {
filteredHistory() {
if (this.selectedFilter === 'all') {
return this.alarmHistory
}
return this.alarmHistory.map(group => ({
...group,
alarms: group.alarms.filter(alarm => {
switch (this.selectedFilter) {
case 'danger':
return alarm.level === 'danger'
case 'warning':
return alarm.level === 'warning'
case 'unattended':
return alarm.title.includes('无人值守')
case 'expanding':
return alarm.title.includes('扩散')
default:
return true
}
})
})).filter(group => group.alarms.length > 0)
},
totalAlarms() {
return this.alarmHistory.reduce((total, group) => total + group.alarms.length, 0)
},
dangerAlarms() {
return this.alarmHistory.reduce((total, group) =>
total + group.alarms.filter(alarm => alarm.level === 'danger').length, 0)
},
warningAlarms() {
return this.alarmHistory.reduce((total, group) =>
total + group.alarms.filter(alarm => alarm.level === 'warning').length, 0)
},
todayAlarms() {
const todayGroup = this.alarmHistory.find(group => group.date === '今天')
return todayGroup ? todayGroup.alarms.length : 0
}
},
onLoad() {
this.loadAlarmHistory()
// 设置定时刷新每30秒自动刷新一次
this.refreshTimer = setInterval(() => {
this.loadAlarmHistory()
}, 30000)
},
onUnload() {
// 页面卸载时清除定时器
if (this.refreshTimer) {
clearInterval(this.refreshTimer)
this.refreshTimer = null
}
},
methods: {
parseAlarmTimestamp(alarm) {
// 尝试多种时间格式解析
let timestamp = null
// 0. 优先使用timestampMs字段设备端新增字段
if (alarm.timestampMs && typeof alarm.timestampMs === 'number') {
timestamp = alarm.timestampMs
}
// 1. 尝试timestamp_ms字段
else if (alarm.timestamp_ms && typeof alarm.timestamp_ms === 'number') {
timestamp = alarm.timestamp_ms
}
// 2. 尝试timestamp字段
else if (alarm.timestamp) {
if (typeof alarm.timestamp === 'number') {
// 如果是秒级时间戳,转换为毫秒
timestamp = alarm.timestamp < 1000000000000 ? alarm.timestamp * 1000 : alarm.timestamp
} else if (typeof alarm.timestamp === 'string') {
// 尝试解析字符串时间
const parsed = Date.parse(alarm.timestamp)
if (!isNaN(parsed)) {
timestamp = parsed
}
}
}
// 3. 尝试ESP32的time+date字段组合 (如: time="15:44:57", date="01-13")
else if (alarm.time && alarm.date && typeof alarm.time === 'string' && typeof alarm.date === 'string') {
try {
// 解析日期和时间
const currentYear = new Date().getFullYear()
const [month, day] = alarm.date.split('-').map(Number)
const [hour, minute, second] = alarm.time.split(':').map(Number)
// 构造完整日期
const alarmDate = new Date(currentYear, month - 1, day, hour, minute, second)
timestamp = alarmDate.getTime()
} catch (error) {
console.error('解析ESP32时间失败:', error, alarm)
}
}
// 4. 尝试单独time字段
else if (alarm.time && typeof alarm.time === 'string') {
const parsed = Date.parse(alarm.time)
if (!isNaN(parsed)) {
timestamp = parsed
}
}
// 5. 尝试created_at或类似字段
else if (alarm.created_at) {
const parsed = Date.parse(alarm.created_at)
if (!isNaN(parsed)) {
timestamp = parsed
}
}
// 如果都解析失败,使用当前时间但记录警告
if (!timestamp) {
console.warn('⚠️ 报警历史: 无法解析报警时间,使用当前时间:', alarm)
timestamp = Date.now()
}
return timestamp
},
goBack() {
uni.navigateBack()
},
getCloudDeviceUid() {
// 你可以按自己的实际绑定逻辑改:这里先允许从多个地方取值
try {
const dcRaw = uni.getStorageSync('cloud_device_config')
const dc = dcRaw ? (typeof dcRaw === 'string' ? JSON.parse(dcRaw) : dcRaw) : null
if (dc && dc.deviceUid) return String(dc.deviceUid)
} catch (e) {}
try {
const v = uni.getStorageSync('cloud_device_uid')
if (v) return String(v)
} catch (e) {}
try {
const list = getAllDevices() || []
const kitchen = list.find(d => d && d.type === 'host' && d.id === 'dev_host_2')
if (kitchen && kitchen.id) return String(kitchen.id)
const host = list.find(d => d && d.type === 'host' && d.id)
if (host && host.id) return String(host.id)
} catch (e) {}
return ''
},
getEsp32AlarmHistoryUrlFallback() {
try {
const list = getAllDevices() || []
const host = list.find(d => d && d.type === 'host' && d.ip)
if (host && host.ip) return 'http://' + String(host.ip) + '/api/alarm/history'
} catch (e) {}
return ''
},
async loadAlarmHistory() {
if (this.isLoading) return
this.isLoading = true
try {
const token = getToken()
if (token) {
// 1) 同步ESP32->云端(失败不阻断)
try {
const deviceUid = this.getCloudDeviceUid()
if (deviceUid) {
await syncEsp32AlarmHistoryToCloud({ deviceUid })
}
} catch (e) {
console.warn('⚠️ 同步云端报警历史失败:', e)
}
// 2) 从云端加载(优先)
try {
const deviceUid = this.getCloudDeviceUid()
const cloudRes = await listCloudAlarms({ deviceUid, page: 0, size: 200 })
const ok = cloudRes && cloudRes.statusCode === 200 && cloudRes.data && cloudRes.data.code === 200
if (ok && Array.isArray(cloudRes.data.data)) {
const cloudAlarms = cloudRes.data.data.map(a => {
let occurredAtMs = 0
try {
occurredAtMs = a && a.occurredAt ? Date.parse(a.occurredAt) : 0
} catch (e) {
occurredAtMs = 0
}
return {
id: a.id,
timestampMs: occurredAtMs || 0,
title: a.title || (a.alarmType ? String(a.alarmType) : '报警'),
desc: a.message || '',
temp: typeof a.temp === 'number' ? a.temp : 0,
level: a.level || 'warning',
isRead: !!a.isRead,
location: '厨房区域'
}
})
this.parseAlarmHistory(cloudAlarms)
return
}
} catch (e) {
console.warn('⚠️ 云端报警历史加载失败回退ESP32:', e)
}
}
// 3) 回退展示ESP32本地历史
console.log('🔄 获取ESP32报警历史数据...')
const url = getAlarmHistoryUrl() || this.getEsp32AlarmHistoryUrlFallback()
if (!url) {
throw new Error('ESP32 baseUrl not configured')
}
const response = await requestEsp32Api({ url, method: 'GET' })
if (response.statusCode === 200 && response.data && response.data.alarms) {
this.parseAlarmHistory(response.data.alarms)
}
} catch (error) {
console.error('❌ 获取报警历史失败:', error)
uni.showToast({ title: '获取历史数据失败', icon: 'none' })
} finally {
this.isLoading = false
}
},
parseAlarmHistory(alarms) {
// 按日期分组报警记录
const groupedAlarms = {}
const today = new Date()
const yesterday = new Date(today)
yesterday.setDate(yesterday.getDate() - 1)
console.log('解析报警历史数据:', alarms.length, '条记录')
alarms.forEach(alarm => {
const timestamp = this.parseAlarmTimestamp(alarm)
const alarmTime = new Date(timestamp)
const alarmDate = this.formatDate(alarmTime)
let dateKey = alarmDate
if (this.formatDate(alarmTime) === this.formatDate(today)) {
dateKey = '今天'
} else if (this.formatDate(alarmTime) === this.formatDate(yesterday)) {
dateKey = '昨天'
}
if (!groupedAlarms[dateKey]) {
groupedAlarms[dateKey] = {
date: dateKey,
alarms: []
}
}
const iconMap = {
'高温危险': '🔥',
'异常升温': '⚠️',
'高温移动': '🏃',
'燃烧报警': '🔥',
'高温扩散': '🌡️',
'无人值守': '👤'
}
let level = alarm.level || 'warning'
if (alarm.title && (alarm.title.includes('高温危险') || alarm.title.includes('燃烧'))) {
level = 'danger'
}
const title = alarm.title || '报警'
const formattedAlarm = {
id: alarm.id,
icon: iconMap[String(title).replace('报警', '')] || '⚠️',
title: title,
time: alarmTime.getHours() + ':' + String(alarmTime.getMinutes()).padStart(2, '0'),
temp: alarm.temp || 0,
level: level,
desc: alarm.desc || alarm.message || '温度异常',
location: alarm.location || '厨房区域',
isRead: alarm.isRead || false
}
groupedAlarms[dateKey].alarms.push(formattedAlarm)
})
// 对每个日期组内的报警按时间倒序排列(最新的在前)
Object.values(groupedAlarms).forEach(group => {
group.alarms.sort((a, b) => {
// 根据时间字符串排序,格式为 "HH:MM"
const timeA = a.time.split(':').map(Number)
const timeB = b.time.split(':').map(Number)
const minutesA = timeA[0] * 60 + timeA[1]
const minutesB = timeB[0] * 60 + timeB[1]
return minutesB - minutesA // 倒序,最新的在前
})
})
// 按日期排序:今天 > 昨天 > 其他日期(按日期倒序)
this.alarmHistory = Object.values(groupedAlarms).sort((a, b) => {
const order = { '今天': 0, '昨天': 1 }
const aOrder = order[a.date]
const bOrder = order[b.date]
// 如果都是特殊日期(今天、昨天),按预定义顺序
if (aOrder !== undefined && bOrder !== undefined) {
return aOrder - bOrder
}
// 如果一个是特殊日期,一个不是,特殊日期优先
if (aOrder !== undefined) return -1
if (bOrder !== undefined) return 1
// 如果都不是特殊日期,按日期字符串倒序(最新的在前)
return b.date.localeCompare(a.date)
})
if (this.alarmHistory.length === 0) {
console.log('没有报警历史数据')
}
},
formatDate(date) {
const month = String(date.getMonth() + 1).padStart(2, '0')
const day = String(date.getDate()).padStart(2, '0')
return `${month}-${day}`
},
setFilter(filter) {
this.selectedFilter = filter
},
clearHistory() {
uni.showModal({
title: '确认清空',
content: '确定要清空所有报警历史记录吗?',
success: (res) => {
if (res.confirm) {
this.alarmHistory = []
uni.showToast({
title: '已清空历史记录',
icon: 'success'
})
}
}
})
},
showAlarmDetail(alarm) {
// 显示报警详情
uni.showModal({
title: alarm.title,
content: `时间: ${alarm.time}\n温度: ${alarm.temp}°C\n位置: ${alarm.location}\n描述: ${alarm.desc}`,
showCancel: false
})
},
markAsRead(alarm) {
alarm.isRead = true
uni.showToast({
title: '已标记为已读',
icon: 'success'
})
},
deleteAlarm(alarm) {
uni.showModal({
title: '确认删除',
content: '确定要删除这条报警记录吗?',
success: (res) => {
if (res.confirm) {
// 找到并删除报警记录
this.alarmHistory.forEach(group => {
const index = group.alarms.findIndex(a => a.id === alarm.id)
if (index !== -1) {
group.alarms.splice(index, 1)
}
})
// 删除空的日期组
this.alarmHistory = this.alarmHistory.filter(group => group.alarms.length > 0)
uni.showToast({
title: '已删除',
icon: 'success'
})
}
}
})
}
}
}
</script>
<style>
.container {
min-height: 100vh;
background: #f5f5f5;
padding-bottom: 40rpx;
}
.status-bar {
background: #3498DB;
height: 0;
padding-top: constant(safe-area-inset-top);
padding-top: env(safe-area-inset-top);
}
.header {
background: #3498DB;
padding: 20rpx 30rpx 40rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.header-left {
display: flex;
align-items: center;
}
.back-icon {
font-size: 36rpx;
color: #fff;
margin-right: 16rpx;
}
.title {
font-size: 36rpx;
font-weight: bold;
color: #fff;
}
.header-right {
display: flex;
align-items: center;
gap: 20rpx;
}
.refresh-text {
font-size: 28rpx;
color: #fff;
padding: 8rpx 16rpx;
border-radius: 16rpx;
background: rgba(255, 255, 255, 0.2);
}
.refresh-text.loading {
opacity: 0.6;
}
.clear-text {
font-size: 28rpx;
color: #fff;
}
/* 筛选栏 */
.filter-bar {
background: #fff;
padding: 20rpx 0;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.filter-scroll {
white-space: nowrap;
}
.filter-list {
display: flex;
padding: 0 20rpx;
}
.filter-item {
padding: 12rpx 24rpx;
margin-right: 16rpx;
background: #f5f5f5;
border-radius: 24rpx;
font-size: 24rpx;
color: #666;
white-space: nowrap;
}
.filter-item.active {
background: #9B59B6;
color: #fff;
}
/* 统计卡片 */
.stats-card {
background: #fff;
margin: 20rpx;
border-radius: 16rpx;
padding: 30rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.stats-grid {
display: flex;
justify-content: space-around;
}
.stats-item {
text-align: center;
}
.stats-num {
font-size: 48rpx;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.stats-num.danger {
color: #E74C3C;
}
.stats-num.warning {
color: #F39C12;
}
.stats-num.today {
color: #3498DB;
}
.stats-label {
font-size: 24rpx;
color: #999;
display: block;
}
/* 历史列表 */
.history-list {
margin: 20rpx;
}
.date-group {
margin-bottom: 32rpx;
}
.date-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 0;
margin-bottom: 16rpx;
}
.date-text {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.count-text {
font-size: 24rpx;
color: #999;
}
.alarm-card {
background: #fff;
border-radius: 16rpx;
padding: 24rpx;
margin-bottom: 16rpx;
box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.05);
}
.alarm-header {
display: flex;
align-items: center;
margin-bottom: 16rpx;
}
.alarm-icon-wrapper {
width: 80rpx;
height: 80rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-right: 20rpx;
}
.alarm-icon-wrapper.danger {
background: rgba(231, 76, 60, 0.1);
}
.alarm-icon-wrapper.warning {
background: rgba(243, 156, 18, 0.1);
}
.alarm-icon {
font-size: 36rpx;
}
.alarm-info {
flex: 1;
}
.alarm-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
display: block;
margin-bottom: 6rpx;
}
.alarm-time {
font-size: 24rpx;
color: #999;
display: block;
}
.alarm-temp {
text-align: right;
}
.temp-value {
font-size: 32rpx;
font-weight: bold;
color: #E74C3C;
}
.alarm-content {
margin-bottom: 16rpx;
padding: 16rpx;
background: #f8f9fa;
border-radius: 8rpx;
}
.alarm-desc {
font-size: 26rpx;
color: #333;
display: block;
margin-bottom: 8rpx;
}
.alarm-location {
font-size: 24rpx;
color: #666;
display: block;
}
.alarm-actions {
display: flex;
justify-content: flex-end;
gap: 16rpx;
}
.action-btn {
padding: 8rpx 16rpx;
font-size: 24rpx;
color: #3498DB;
background: rgba(52, 152, 219, 0.1);
border-radius: 8rpx;
}
/* 空状态 */
.empty-state {
text-align: center;
padding: 120rpx 40rpx;
}
.empty-icon {
font-size: 120rpx;
display: block;
margin-bottom: 32rpx;
opacity: 0.3;
}
.empty-text {
font-size: 32rpx;
color: #999;
display: block;
margin-bottom: 16rpx;
}
.empty-desc {
font-size: 24rpx;
color: #ccc;
display: block;
}
</style>