对接MQTT、APP样式

This commit is contained in:
吉浩茹
2025-09-29 23:53:09 +08:00
parent e4ea3312b7
commit 1040f6aca1
33 changed files with 5245 additions and 2023 deletions

623
src/pages/alarm/index.vue Normal file
View File

@ -0,0 +1,623 @@
<template>
<view class="alarm-record-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="tabbar-content">
<!-- 报警统计 -->
<view class="alarm-statistics">
<view class="stat-item">
<text class="stat-label">总报警数</text>
<text class="stat-value total">{{ totalAlarms }}</text>
</view>
<view class="stat-item">
<text class="stat-label">未处理</text>
<text class="stat-value pending">{{ pendingAlarms }}</text>
</view>
<view class="stat-item">
<text class="stat-label">已处理</text>
<text class="stat-value resolved">{{ resolvedAlarms }}</text>
</view>
<view class="stat-item">
<text class="stat-label">紧急报警</text>
<text class="stat-value urgent">{{ urgentAlarms }}</text>
</view>
</view>
<!-- 筛选区域 -->
<view class="filter-section">
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">报警级别</text>
<picker :value="levelIndex" :range="levelOptions" @change="onLevelChange">
<view class="picker-view">
<text>{{ levelOptions[levelIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="filter-item">
<text class="filter-label">处理状态</text>
<picker :value="statusIndex" :range="statusOptions" @change="onStatusChange">
<view class="picker-view">
<text>{{ statusOptions[statusIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">时间范围</text>
<picker mode="date" :value="alarmDate" @change="onDateChange">
<view class="picker-view">
<text>{{ alarmDate }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<button class="search-button" @click="searchAlarms">搜索</button>
</view>
</view>
<!-- 报警列表 -->
<view class="alarms-container">
<view class="alarms-header">
<text class="alarms-title">报警详情</text>
<view class="header-actions">
<button class="action-button" @click="refreshAlarms">刷新</button>
<button class="action-button" @click="exportAlarms">导出</button>
<button class="action-button clear" @click="clearAlarms">清空</button>
</view>
</view>
<scroll-view class="alarms-list" scroll-y="true">
<view class="alarm-item" v-for="(alarm, index) in alarms" :key="index" :class="alarm.level">
<view class="alarm-header">
<view class="alarm-level" :class="alarm.level">
<text>{{ alarm.levelText }}</text>
</view>
<view class="alarm-status" :class="alarm.status">
<text>{{ alarm.statusText }}</text>
</view>
<text class="alarm-time">{{ alarm.time }}</text>
</view>
<view class="alarm-content">
<text class="alarm-title">{{ alarm.title }}</text>
<text class="alarm-description">{{ alarm.description }}</text>
</view>
<view class="alarm-details">
<view class="detail-item">
<text class="detail-label">报警源:</text>
<text class="detail-value">{{ alarm.source }}</text>
</view>
<view class="detail-item">
<text class="detail-label">当前值:</text>
<text class="detail-value">{{ alarm.currentValue }}</text>
</view>
<view class="detail-item">
<text class="detail-label">阈值:</text>
<text class="detail-value">{{ alarm.threshold }}</text>
</view>
</view>
<view class="alarm-actions" v-if="alarm.status === 'pending'">
<button class="action-button resolve" @click="resolveAlarm(alarm)">处理</button>
<button class="action-button ignore" @click="ignoreAlarm(alarm)">忽略</button>
</view>
<view class="alarm-resolution" v-if="alarm.status === 'resolved'">
<text class="resolution-text">处理人: {{ alarm.resolver }}</text>
<text class="resolution-time">处理时间: {{ alarm.resolveTime }}</text>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
totalAlarms: 28,
pendingAlarms: 5,
resolvedAlarms: 20,
urgentAlarms: 3,
levelIndex: 0,
levelOptions: ['全部级别', '紧急', '高', '中', '低'],
statusIndex: 0,
statusOptions: ['全部状态', '未处理', '已处理', '已忽略'],
alarmDate: '2025-09-29',
alarms: [
{
level: 'urgent',
levelText: '紧急',
status: 'pending',
statusText: '未处理',
time: '2025-09-29 15:45:33',
title: '温度超限报警',
description: '温度传感器读数超过安全阈值,存在设备损坏风险',
source: '温度传感器01',
currentValue: '85.2°C',
threshold: '80.0°C',
resolver: '',
resolveTime: ''
},
{
level: 'high',
levelText: '高',
status: 'pending',
statusText: '未处理',
time: '2025-09-29 15:42:15',
title: '湿度异常报警',
description: '湿度传感器读数异常,可能影响设备正常运行',
source: '湿度传感器02',
currentValue: '95%',
threshold: '90%',
resolver: '',
resolveTime: ''
},
{
level: 'medium',
levelText: '中',
status: 'resolved',
statusText: '已处理',
time: '2025-09-29 15:38:22',
title: '网络连接异常',
description: 'MQTT连接中断数据同步失败',
source: '网络模块',
currentValue: '离线',
threshold: '在线',
resolver: '系统管理员',
resolveTime: '2025-09-29 15:40:15'
},
{
level: 'low',
levelText: '低',
status: 'resolved',
statusText: '已处理',
time: '2025-09-29 15:35:10',
title: '存储空间不足',
description: '系统存储空间使用率超过80%',
source: '存储模块',
currentValue: '85%',
threshold: '80%',
resolver: '系统管理员',
resolveTime: '2025-09-29 15:36:45'
},
{
level: 'urgent',
levelText: '紧急',
status: 'pending',
statusText: '未处理',
time: '2025-09-29 15:30:55',
title: '设备离线报警',
description: '关键设备离线,可能影响生产安全',
source: '设备监控',
currentValue: '离线',
threshold: '在线',
resolver: '',
resolveTime: ''
}
]
}
},
onLoad() {
console.log('报警记录页面加载')
},
methods: {
onLevelChange(e) {
this.levelIndex = e.detail.value
},
onStatusChange(e) {
this.statusIndex = e.detail.value
},
onDateChange(e) {
this.alarmDate = e.detail.value
},
searchAlarms() {
console.log('搜索报警', {
level: this.levelOptions[this.levelIndex],
status: this.statusOptions[this.statusIndex],
date: this.alarmDate
})
uni.showToast({
title: '搜索中...',
icon: 'loading'
})
},
refreshAlarms() {
uni.showToast({
title: '刷新报警记录',
icon: 'success'
})
},
exportAlarms() {
uni.showToast({
title: '导出功能开发中',
icon: 'none'
})
},
clearAlarms() {
uni.showModal({
title: '确认清空',
content: '确定要清空所有报警记录吗?此操作不可恢复。',
success: (res) => {
if (res.confirm) {
this.alarms = []
this.totalAlarms = 0
this.pendingAlarms = 0
this.resolvedAlarms = 0
this.urgentAlarms = 0
uni.showToast({
title: '清空成功',
icon: 'success'
})
}
}
})
},
resolveAlarm(alarm) {
uni.showModal({
title: '处理报警',
content: `确定要处理 "${alarm.title}" 吗?`,
success: (res) => {
if (res.confirm) {
alarm.status = 'resolved'
alarm.statusText = '已处理'
alarm.resolver = '当前用户'
alarm.resolveTime = new Date().toLocaleString()
this.pendingAlarms--
this.resolvedAlarms++
uni.showToast({
title: '处理成功',
icon: 'success'
})
}
}
})
},
ignoreAlarm(alarm) {
uni.showModal({
title: '忽略报警',
content: `确定要忽略 "${alarm.title}" 吗?`,
success: (res) => {
if (res.confirm) {
alarm.status = 'ignored'
alarm.statusText = '已忽略'
this.pendingAlarms--
uni.showToast({
title: '已忽略',
icon: 'success'
})
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
.alarm-record-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.alarm-statistics {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
display: flex;
justify-content: space-around;
}
.stat-item {
text-align: center;
}
.stat-label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 10rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: bold;
&.total {
color: #333;
}
&.pending {
color: #ff4444;
}
&.resolved {
color: #4caf50;
}
&.urgent {
color: #ff9800;
}
}
.filter-section {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.filter-row {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.filter-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.filter-label {
font-size: 24rpx;
color: #666;
}
.picker-view {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background-color: #f8f8f8;
border-radius: 10rpx;
border: 2rpx solid #e0e0e0;
}
.picker-arrow {
color: #999;
font-size: 24rpx;
}
.search-button {
background-color: #3f51b5;
color: white;
padding: 20rpx 30rpx;
border-radius: 10rpx;
font-size: 28rpx;
align-self: flex-end;
}
.alarms-container {
background: white;
border-radius: 15rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
overflow: hidden;
}
.alarms-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.alarms-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.header-actions {
display: flex;
gap: 15rpx;
}
.action-button {
background-color: #3f51b5;
color: white;
padding: 10rpx 20rpx;
border-radius: 6rpx;
font-size: 24rpx;
&.clear {
background-color: #ff4444;
}
&.resolve {
background-color: #4caf50;
}
&.ignore {
background-color: #ff9800;
}
}
.alarms-list {
height: 600rpx;
}
.alarm-item {
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.urgent {
border-left: 8rpx solid #ff4444;
}
&.high {
border-left: 8rpx solid #ff9800;
}
&.medium {
border-left: 8rpx solid #2196f3;
}
&.low {
border-left: 8rpx solid #4caf50;
}
}
.alarm-header {
display: flex;
align-items: center;
gap: 20rpx;
margin-bottom: 15rpx;
}
.alarm-level {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: bold;
&.urgent {
background-color: #ffebee;
color: #ff4444;
}
&.high {
background-color: #fff3e0;
color: #ff9800;
}
&.medium {
background-color: #e3f2fd;
color: #2196f3;
}
&.low {
background-color: #e8f5e8;
color: #4caf50;
}
}
.alarm-status {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: bold;
&.pending {
background-color: #fff3cd;
color: #ff9800;
}
&.resolved {
background-color: #d4edda;
color: #4caf50;
}
&.ignored {
background-color: #f8d7da;
color: #dc3545;
}
}
.alarm-time {
font-size: 24rpx;
color: #666;
margin-left: auto;
}
.alarm-content {
margin-bottom: 20rpx;
}
.alarm-title {
display: block;
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 10rpx;
}
.alarm-description {
font-size: 26rpx;
color: #666;
line-height: 1.5;
}
.alarm-details {
background-color: #f8f8f8;
padding: 20rpx;
border-radius: 10rpx;
margin-bottom: 20rpx;
}
.detail-item {
display: flex;
justify-content: space-between;
margin-bottom: 10rpx;
&:last-child {
margin-bottom: 0;
}
}
.detail-label {
font-size: 24rpx;
color: #666;
}
.detail-value {
font-size: 24rpx;
color: #333;
font-weight: bold;
}
.alarm-actions {
display: flex;
gap: 20rpx;
justify-content: flex-end;
}
.alarm-resolution {
background-color: #e8f5e8;
padding: 20rpx;
border-radius: 10rpx;
display: flex;
flex-direction: column;
gap: 5rpx;
}
.resolution-text {
font-size: 24rpx;
color: #4caf50;
}
.resolution-time {
font-size: 24rpx;
color: #666;
}
</style>

View File

@ -0,0 +1,885 @@
<template>
<view class="environment-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="tabbar-content">
<!-- 顶部参数卡片 -->
<view class="parameter-cards">
<view class="card-item">
<view class="card-icon temperature-icon">🌡</view>
<view class="card-content">
<text class="card-value">{{ temperature }}°C</text>
<text class="card-label">温度</text>
</view>
<view class="card-status" :class="temperature > 0 ? 'active' : 'inactive'"></view>
</view>
<view class="card-item">
<view class="card-icon humidity-icon">💧</view>
<view class="card-content">
<text class="card-value">{{ humidity }}%</text>
<text class="card-label">湿度</text>
</view>
<view class="card-status" :class="humidity > 0 ? 'active' : 'inactive'"></view>
</view>
<view class="card-item">
<view class="card-icon cleanliness-icon"></view>
<view class="card-content">
<text class="card-value">{{ cleanliness > 0 ? cleanliness + '%' : '-%' }}</text>
<text class="card-label">洁净度</text>
</view>
<view class="card-status" :class="cleanliness > 0 ? 'active' : 'inactive'"></view>
</view>
</view>
<!-- 环境参数详情 -->
<view class="parameter-details">
<view class="parameter-item">
<view class="parameter-header">
<text class="parameter-label">温度</text>
<text class="current-value">{{ temperature }}°C</text>
</view>
<view class="progress-container">
<view class="progress-bar">
<view class="progress-fill temperature-progress" :style="{ width: temperatureProgress + '%' }"></view>
</view>
</view>
<view class="progress-info">
<text class="range-value">{{ temperatureRange.min }}°C - {{ temperatureRange.max }}°C</text>
</view>
</view>
<view class="parameter-item">
<view class="parameter-header">
<text class="parameter-label">湿度</text>
<text class="current-value">{{ humidity }}%</text>
</view>
<view class="progress-container">
<view class="progress-bar">
<view class="progress-fill humidity-progress" :style="{ width: humidityProgress + '%' }"></view>
</view>
</view>
<view class="progress-info">
<text class="range-value">{{ humidityRange.min }}% - {{ humidityRange.max }}%</text>
</view>
</view>
<view class="parameter-item">
<view class="parameter-header">
<text class="parameter-label">洁净度</text>
<text class="current-value">{{ cleanliness > 0 ? cleanliness + '%' : '-%' }}</text>
</view>
<view class="progress-container">
<view class="progress-bar">
<view class="progress-fill cleanliness-progress" :style="{ width: cleanlinessProgress + '%' }"></view>
</view>
</view>
<view class="progress-info">
<text class="range-value">暂无数据</text>
</view>
</view>
</view>
<!-- 空调目标参数设置 -->
<view class="air-conditioner-settings">
<view class="settings-header">
<text class="settings-title">空调目标参数设置</text>
</view>
<view class="temperature-control">
<text class="control-label">目标温度</text>
<view class="temperature-display">
<button class="temp-btn decrease" @click="decreaseTemperature">-</button>
<text class="temperature-value">{{ targetTemperature }}°C</text>
<button class="temp-btn increase" @click="increaseTemperature">+</button>
</view>
</view>
</view>
<!-- 环境控制区域 -->
<view class="environment-control">
<view class="control-header">
<text class="control-title">环境控制</text>
<view class="status-indicator">
<view class="status-dot"></view>
<text class="status-text">异常</text>
</view>
</view>
<view class="control-info">
<text class="last-update">最后更新: {{ lastUpdate }}</text>
<view class="connection-info">
<text class="connection-status" :class="connectionStatus.isConnected ? 'connected' : 'disconnected'">
{{ connectionStatus.isConnected ? 'MQTT已连接' : 'MQTT未连接' }}
</text>
<button v-if="!connectionStatus.isConnected" class="reconnect-btn" @click="manualReconnect">
重连
</button>
</view>
<text class="temperature-range">温度控制: {{ temperatureRange.min }}°C - {{ temperatureRange.max }}°C</text>
<text class="humidity-range">湿度控制: {{ humidityRange.min }}% - {{ humidityRange.max }}%</text>
</view>
<button class="settings-button" @click="openSettingsModal">
<text class="settings-icon"></text>
<text class="settings-text">参数设定</text>
</button>
</view>
<!-- 参数设定弹窗 -->
<uni-popup ref="settingsPopup" type="center" :mask-click="false">
<view class="settings-modal">
<view class="modal-header">
<text class="modal-title">参数设定</text>
<text class="close-btn" @click="closeSettingsModal"></text>
</view>
<view class="modal-content">
<!-- 温度设定 -->
<view class="setting-item">
<text class="setting-label">温度控制范围</text>
<view class="range-inputs">
<input
class="range-input"
type="number"
v-model="tempSettings.min"
placeholder="最小值"
/>
<text class="range-separator">-</text>
<input
class="range-input"
type="number"
v-model="tempSettings.max"
placeholder="最大值"
/>
<text class="unit">°C</text>
</view>
</view>
<!-- 湿度设定 -->
<view class="setting-item">
<text class="setting-label">湿度控制范围</text>
<view class="range-inputs">
<input
class="range-input"
type="number"
v-model="humiditySettings.min"
placeholder="最小值"
/>
<text class="range-separator">-</text>
<input
class="range-input"
type="number"
v-model="humiditySettings.max"
placeholder="最大值"
/>
<text class="unit">%</text>
</view>
</view>
</view>
<view class="modal-actions">
<button class="cancel-btn" @click="closeSettingsModal">取消</button>
<button class="confirm-btn" @click="saveSettings">确定</button>
</view>
</view>
</uni-popup>
</view>
</view>
</template>
<script>
import mqttDataManager from '@/utils/mqttDataManager.js'
import { manualReconnect } from '@/utils/sendMqtt.js'
export default {
data() {
return {
temperature: 0,
humidity: 0,
cleanliness: 0,
temperatureProgress: 0,
humidityProgress: 0,
cleanlinessProgress: 0,
lastUpdate: '暂无数据',
temperatureRange: {
min: 0,
max: 100
},
humidityRange: {
min: 0,
max: 100
},
tempSettings: {
min: 25,
max: 35
},
humiditySettings: {
min: 0,
max: 100
},
connectionStatus: {
isConnected: false,
lastUpdate: null
},
targetTemperature: 40
}
},
onLoad() {
console.log('环境参数页面加载')
this.initMqttListener()
},
onUnload() {
console.log('🔌 环境参数页面卸载,清理资源...')
// 页面卸载时移除监听器
if (this.dataUpdateHandler) {
mqttDataManager.removeListener('dataUpdate', this.dataUpdateHandler)
console.log('✅ 数据更新监听器已移除')
}
if (this.statusUpdateHandler) {
mqttDataManager.removeListener('connectionStatus', this.statusUpdateHandler)
console.log('✅ 状态更新监听器已移除')
}
// 清理调试定时器
if (this.debugInterval) {
clearInterval(this.debugInterval)
console.log('✅ 调试定时器已清理')
}
console.log('✅ 环境参数页面资源清理完成')
},
methods: {
// 初始化MQTT监听
initMqttListener() {
console.log('🔧 环境参数页面开始初始化MQTT监听...')
// 监听数据更新
this.dataUpdateHandler = (data) => {
console.log('📨 环境参数页面收到MQTT数据:', data)
this.updateEnvironmentData(data)
}
mqttDataManager.addListener('dataUpdate', this.dataUpdateHandler)
// 监听连接状态
this.statusUpdateHandler = (status) => {
console.log('🔄 环境参数页面连接状态更新:', status)
const wasConnected = this.connectionStatus.isConnected
this.connectionStatus = status
// 只在状态发生变化时显示提示(避免重复提示)
if (wasConnected !== status.isConnected) {
if (status.isConnected) {
console.log('✅ MQTT连接状态: 已连接')
// 不显示Toast因为sendMqtt.js中已经显示了
} else {
console.log('❌ MQTT连接状态: 未连接')
// 不显示Toast因为sendMqtt.js中已经显示了
}
}
}
mqttDataManager.addListener('connectionStatus', this.statusUpdateHandler)
// 获取初始数据
const lastData = mqttDataManager.getLastData()
console.log('📊 获取初始数据:', lastData)
if (lastData.timestamp) {
this.updateEnvironmentData(lastData)
}
// 获取初始连接状态
this.connectionStatus = mqttDataManager.getConnectionStatus()
console.log('🔍 初始连接状态:', this.connectionStatus)
// 定期检查连接状态(用于调试)
this.debugInterval = setInterval(() => {
const currentStatus = mqttDataManager.getConnectionStatus()
console.log('🔍 定期检查连接状态:', currentStatus)
}, 10000) // 每10秒检查一次
console.log('✅ 环境参数页面MQTT监听初始化完成')
},
// 更新环境数据
updateEnvironmentData(data) {
console.log('🌡️ 环境参数页面更新数据:', data)
// 只处理WSD设备的数据
if (data.deviceType === 'WSD') {
if (data.temperature !== undefined) {
this.temperature = parseFloat(data.temperature.toFixed(1))
this.temperatureProgress = Math.min(Math.max(this.temperature, 0), 100)
console.log('✅ 温度已更新:', this.temperature)
}
if (data.humidity !== undefined) {
this.humidity = parseFloat(data.humidity.toFixed(1))
this.humidityProgress = Math.min(Math.max(this.humidity, 0), 100)
console.log('✅ 湿度已更新:', this.humidity)
}
this.lastUpdate = data.time || new Date().toLocaleString('zh-CN')
console.log('✅ 环境数据更新完成:', {
temperature: this.temperature,
humidity: this.humidity,
lastUpdate: this.lastUpdate
})
} else {
console.log('⚠️ 非WSD设备数据跳过更新:', data.deviceType)
}
},
// 降低目标温度
decreaseTemperature() {
if (this.targetTemperature > 16) {
this.targetTemperature--
console.log('目标温度降低至:', this.targetTemperature + '°C')
this.showTemperatureChangeToast()
} else {
uni.showToast({
title: '温度不能低于16°C',
icon: 'none'
})
}
},
// 提高目标温度
increaseTemperature() {
if (this.targetTemperature < 30) {
this.targetTemperature++
console.log('目标温度提高至:', this.targetTemperature + '°C')
this.showTemperatureChangeToast()
} else {
uni.showToast({
title: '温度不能高于30°C',
icon: 'none'
})
}
},
// 显示温度变化提示
showTemperatureChangeToast() {
uni.showToast({
title: `目标温度: ${this.targetTemperature}°C`,
icon: 'success',
duration: 1500
})
},
// 手动重连MQTT
manualReconnect() {
console.log('🔄 用户手动触发MQTT重连')
uni.showToast({
title: '正在重连...',
icon: 'loading',
duration: 2000
})
// 调用sendMqtt.js中的手动重连函数
manualReconnect()
},
openSettingsModal() {
// 打开弹窗前,将当前设置复制到临时变量
this.tempSettings = { ...this.temperatureRange }
this.humiditySettings = { ...this.humidityRange }
this.$refs.settingsPopup.open()
},
closeSettingsModal() {
this.$refs.settingsPopup.close()
},
saveSettings() {
// 保存设置
this.temperatureRange = { ...this.tempSettings }
this.humidityRange = { ...this.humiditySettings }
// 更新最后更新时间
this.lastUpdate = new Date().toLocaleString()
// 关闭弹窗
this.closeSettingsModal()
// 显示保存成功提示
uni.showToast({
title: '参数设置已保存',
icon: 'success'
})
console.log('保存的温度范围:', this.temperatureRange)
console.log('保存的湿度范围:', this.humidityRange)
}
}
}
</script>
<style lang="scss" scoped>
.environment-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
/* 顶部参数卡片样式 */
.parameter-cards {
display: flex;
gap: 20rpx;
margin-bottom: 30rpx;
}
.card-item {
flex: 1;
background: white;
border-radius: 12rpx;
padding: 20rpx;
display: flex;
flex-direction: column;
align-items: center;
position: relative;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.1);
min-height: 120rpx;
}
.card-icon {
font-size: 40rpx;
margin-bottom: 10rpx;
}
.card-content {
text-align: center;
flex: 1;
display: flex;
flex-direction: column;
justify-content: center;
}
.card-value {
font-size: 28rpx;
font-weight: bold;
color: #333;
margin-bottom: 5rpx;
}
.card-label {
font-size: 24rpx;
color: #666;
}
.card-status {
position: absolute;
top: 15rpx;
right: 15rpx;
width: 16rpx;
height: 16rpx;
border-radius: 50%;
}
.card-status.active {
background-color: #4caf50;
}
.card-status.inactive {
background-color: #ccc;
}
.card-label {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 10rpx;
}
.card-value {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.card-indicator {
position: absolute;
top: 20rpx;
right: 20rpx;
width: 20rpx;
height: 20rpx;
border-radius: 50%;
}
.temperature-indicator {
background-color: #ff4444;
}
.humidity-indicator {
background-color: #4488ff;
}
.cleanliness-indicator {
background-color: #999;
}
.parameter-details {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.parameter-item {
margin-bottom: 30rpx;
}
.parameter-item:last-child {
margin-bottom: 0;
}
.parameter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.parameter-label {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.current-value {
font-size: 28rpx;
color: #4488ff;
font-weight: bold;
}
.progress-container {
display: flex;
align-items: center;
gap: 20rpx;
}
.progress-bar {
flex: 1;
height: 16rpx;
background-color: #f0f0f0;
border-radius: 8rpx;
overflow: hidden;
}
.progress-fill {
height: 100%;
border-radius: 8rpx;
transition: width 0.3s ease;
}
.temperature-progress {
background: linear-gradient(90deg, #4488ff, #44ff88);
}
.humidity-progress {
background: linear-gradient(90deg, #4488ff, #44ff88);
}
.cleanliness-progress {
background: linear-gradient(90deg, #4488ff, #44ff88);
}
.progress-info {
display: flex;
flex-direction: column;
align-items: flex-end;
min-width: 200rpx;
}
.current-value {
font-size: 28rpx;
font-weight: bold;
color: #4488ff;
margin-bottom: 5rpx;
}
.range-value {
font-size: 24rpx;
color: #666;
}
/* 空调目标参数设置样式 */
.air-conditioner-settings {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.settings-header {
margin-bottom: 25rpx;
}
.settings-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.temperature-control {
display: flex;
justify-content: space-between;
align-items: center;
}
.control-label {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.temperature-display {
display: flex;
align-items: center;
gap: 20rpx;
}
.temp-btn {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
font-size: 36rpx;
font-weight: bold;
border: none;
color: white;
}
.temp-btn.decrease {
background-color: #ff4444;
}
.temp-btn.increase {
background-color: #4caf50;
}
.temperature-value {
font-size: 36rpx;
font-weight: bold;
color: #333;
min-width: 120rpx;
text-align: center;
}
.environment-control {
background: white;
border-radius: 12rpx;
padding: 25rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.control-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.control-title {
font-size: 32rpx;
color: #333;
font-weight: bold;
}
.status-indicator {
display: flex;
align-items: center;
gap: 10rpx;
}
.status-dot {
width: 20rpx;
height: 20rpx;
border-radius: 50%;
background-color: #ffaa00;
}
.status-text {
font-size: 28rpx;
color: #ffaa00;
}
.control-info {
display: flex;
flex-direction: column;
gap: 10rpx;
margin-bottom: 30rpx;
}
.last-update {
font-size: 28rpx;
color: #4488ff;
}
.connection-status {
font-size: 28rpx;
font-weight: bold;
}
.connection-status.connected {
color: #4caf50;
}
.connection-status.disconnected {
color: #ff4444;
}
.connection-info {
display: flex;
align-items: center;
gap: 20rpx;
}
.reconnect-btn {
background-color: #ff4444;
color: white;
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 24rpx;
border: none;
}
.temperature-range {
font-size: 28rpx;
color: #4488ff;
}
.humidity-range {
font-size: 28rpx;
color: #4488ff;
}
.settings-button {
background-color: #3f51b5;
color: white;
padding: 20rpx 40rpx;
border-radius: 10rpx;
font-size: 28rpx;
display: flex;
align-items: center;
justify-content: center;
gap: 10rpx;
width: 100%;
}
.settings-icon {
font-size: 24rpx;
}
.settings-text {
font-size: 28rpx;
}
/* 弹窗样式 */
.settings-modal {
background: white;
border-radius: 20rpx;
width: 600rpx;
max-width: 90vw;
overflow: hidden;
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
background-color: #f8f8f8;
border-bottom: 2rpx solid #e0e0e0;
}
.modal-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.close-btn {
font-size: 32rpx;
color: #666;
padding: 10rpx;
}
.modal-content {
padding: 30rpx;
}
.setting-item {
margin-bottom: 40rpx;
&:last-child {
margin-bottom: 0;
}
}
.setting-label {
display: block;
font-size: 28rpx;
color: #333;
margin-bottom: 20rpx;
font-weight: bold;
}
.range-inputs {
display: flex;
align-items: center;
gap: 15rpx;
}
.range-input {
flex: 1;
padding: 20rpx;
border: 2rpx solid #e0e0e0;
border-radius: 10rpx;
font-size: 28rpx;
text-align: center;
background-color: #f8f8f8;
}
.range-separator {
font-size: 28rpx;
color: #666;
font-weight: bold;
}
.unit {
font-size: 24rpx;
color: #666;
min-width: 60rpx;
}
.modal-actions {
display: flex;
gap: 20rpx;
padding: 30rpx;
background-color: #f8f8f8;
}
.cancel-btn {
flex: 1;
background-color: #666;
color: white;
padding: 20rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.confirm-btn {
flex: 1;
background-color: #3f51b5;
color: white;
padding: 20rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
</style>

View File

@ -1,10 +1,20 @@
<template>
<view class="content">
<image class="logo" src="/static/logo.png"></image>
<view class="text-area">
<text class="title">{{ title }}</text>
<view class="index-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="non-tabbar-content">
<view class="content">
<image class="logo" src="/static/logo.png"></image>
<view class="text-area">
<text class="title">{{ title }}</text>
</view>
<button class="nav-button" @click="navigateToTabBar">进入检修系统</button>
</view>
</view>
<button class="nav-button" @click="navigateToSystem">进入检修系统</button>
</view>
</template>
@ -17,14 +27,15 @@ export default {
},
onLoad() {},
methods: {
navigateToSystem() {
uni.navigateTo({
url: '../system/index',
navigateToTabBar() {
// 跳转到tabbar的第一个页面环境参数页面
uni.switchTab({
url: '/pages/environment/index',
fail: (err) => {
console.error('导航失败:', err);
// 尝试使用替代方法
console.error('跳转到tabbar失败:', err);
// 如果失败尝试使用redirectTo
uni.redirectTo({
url: '../system/index'
url: '/pages/environment/index'
});
}
});
@ -34,20 +45,26 @@ export default {
</script>
<style>
.index-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.content {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40rpx;
flex: 1;
overflow-y: auto;
}
.logo {
height: 200rpx;
width: 200rpx;
margin-top: 200rpx;
margin-left: auto;
margin-right: auto;
margin-bottom: 50rpx;
}

481
src/pages/log/index.vue Normal file
View File

@ -0,0 +1,481 @@
<template>
<view class="system-log-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="tabbar-content">
<!-- 筛选区域 -->
<view class="filter-section">
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">日志级别</text>
<picker :value="levelIndex" :range="levelOptions" @change="onLevelChange">
<view class="picker-view">
<text>{{ levelOptions[levelIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="filter-item">
<text class="filter-label">时间范围</text>
<picker mode="date" :value="logDate" @change="onDateChange">
<view class="picker-view">
<text>{{ logDate }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
</view>
<view class="filter-row">
<view class="filter-item">
<text class="filter-label">关键词</text>
<input class="search-input" v-model="searchKeyword" placeholder="输入关键词搜索" />
</view>
<button class="search-button" @click="searchLogs">搜索</button>
</view>
</view>
<!-- 统计信息 -->
<view class="statistics-section">
<view class="stat-item">
<text class="stat-label">总日志数</text>
<text class="stat-value">{{ totalLogs }}</text>
</view>
<view class="stat-item">
<text class="stat-label">错误日志</text>
<text class="stat-value error">{{ errorLogs }}</text>
</view>
<view class="stat-item">
<text class="stat-label">警告日志</text>
<text class="stat-value warning">{{ warningLogs }}</text>
</view>
<view class="stat-item">
<text class="stat-label">信息日志</text>
<text class="stat-value info">{{ infoLogs }}</text>
</view>
</view>
<!-- 日志列表 -->
<view class="logs-container">
<view class="logs-header">
<text class="logs-title">日志详情</text>
<view class="header-actions">
<button class="action-button" @click="refreshLogs">刷新</button>
<button class="action-button" @click="exportLogs">导出</button>
<button class="action-button clear" @click="clearLogs">清空</button>
</view>
</view>
<scroll-view class="logs-list" scroll-y="true" @scrolltolower="loadMoreLogs">
<view class="log-item" v-for="(log, index) in logs" :key="index" :class="log.level">
<view class="log-header">
<view class="log-level" :class="log.level">
<text>{{ log.levelText }}</text>
</view>
<text class="log-time">{{ log.time }}</text>
</view>
<view class="log-content">
<text class="log-message">{{ log.message }}</text>
</view>
<view class="log-details" v-if="log.details">
<text class="log-details-text">{{ log.details }}</text>
</view>
</view>
<view class="load-more" v-if="hasMore">
<text class="load-more-text">加载更多...</text>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
levelIndex: 0,
levelOptions: ['全部级别', '错误', '警告', '信息', '调试'],
logDate: '2025-09-29',
searchKeyword: '',
totalLogs: 1248,
errorLogs: 23,
warningLogs: 156,
infoLogs: 1069,
hasMore: true,
logs: [
{
level: 'error',
levelText: 'ERROR',
time: '2025-09-29 15:45:33',
message: 'MQTT连接失败',
details: 'Connection timeout after 5000ms'
},
{
level: 'warning',
levelText: 'WARN',
time: '2025-09-29 15:44:15',
message: '温度传感器读数异常',
details: 'Temperature reading: 999.9°C (超出正常范围)'
},
{
level: 'info',
levelText: 'INFO',
time: '2025-09-29 15:43:22',
message: '系统启动完成',
details: '所有服务已启动,系统运行正常'
},
{
level: 'error',
levelText: 'ERROR',
time: '2025-09-29 15:42:10',
message: '数据库连接失败',
details: 'Failed to connect to database: Connection refused'
},
{
level: 'info',
levelText: 'INFO',
time: '2025-09-29 15:41:55',
message: '用户登录成功',
details: '用户 admin 登录系统'
},
{
level: 'warning',
levelText: 'WARN',
time: '2025-09-29 15:40:33',
message: '内存使用率过高',
details: 'Memory usage: 85% (建议清理缓存)'
},
{
level: 'info',
levelText: 'INFO',
time: '2025-09-29 15:39:18',
message: '数据同步完成',
details: '同步了 156 条记录到云端'
},
{
level: 'error',
levelText: 'ERROR',
time: '2025-09-29 15:38:45',
message: '文件上传失败',
details: 'File size exceeds limit: 50MB > 10MB'
}
]
}
},
onLoad() {
console.log('系统日志页面加载')
},
methods: {
onLevelChange(e) {
this.levelIndex = e.detail.value
},
onDateChange(e) {
this.logDate = e.detail.value
},
searchLogs() {
console.log('搜索日志', {
level: this.levelOptions[this.levelIndex],
date: this.logDate,
keyword: this.searchKeyword
})
uni.showToast({
title: '搜索中...',
icon: 'loading'
})
},
refreshLogs() {
uni.showToast({
title: '刷新日志',
icon: 'success'
})
},
exportLogs() {
uni.showToast({
title: '导出功能开发中',
icon: 'none'
})
},
clearLogs() {
uni.showModal({
title: '确认清空',
content: '确定要清空所有日志吗?此操作不可恢复。',
success: (res) => {
if (res.confirm) {
this.logs = []
this.totalLogs = 0
this.errorLogs = 0
this.warningLogs = 0
this.infoLogs = 0
uni.showToast({
title: '清空成功',
icon: 'success'
})
}
}
})
},
loadMoreLogs() {
if (this.hasMore) {
uni.showToast({
title: '加载更多...',
icon: 'loading'
})
// 模拟加载更多数据
setTimeout(() => {
this.hasMore = false
uni.hideToast()
}, 1000)
}
}
}
}
</script>
<style lang="scss" scoped>
.system-log-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.filter-section {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
}
.filter-row {
display: flex;
gap: 20rpx;
margin-bottom: 20rpx;
&:last-child {
margin-bottom: 0;
}
}
.filter-item {
flex: 1;
display: flex;
flex-direction: column;
gap: 10rpx;
}
.filter-label {
font-size: 24rpx;
color: #666;
}
.picker-view {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20rpx;
background-color: #f8f8f8;
border-radius: 10rpx;
border: 2rpx solid #e0e0e0;
}
.picker-arrow {
color: #999;
font-size: 24rpx;
}
.search-input {
padding: 20rpx;
background-color: #f8f8f8;
border-radius: 10rpx;
border: 2rpx solid #e0e0e0;
font-size: 28rpx;
}
.search-button {
background-color: #3f51b5;
color: white;
padding: 20rpx 30rpx;
border-radius: 10rpx;
font-size: 28rpx;
align-self: flex-end;
}
.statistics-section {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
display: flex;
justify-content: space-around;
}
.stat-item {
text-align: center;
}
.stat-label {
display: block;
font-size: 24rpx;
color: #666;
margin-bottom: 10rpx;
}
.stat-value {
font-size: 32rpx;
font-weight: bold;
color: #333;
&.error {
color: #ff4444;
}
&.warning {
color: #ff9800;
}
&.info {
color: #2196f3;
}
}
.logs-container {
background: white;
border-radius: 15rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
overflow: hidden;
}
.logs-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.logs-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.header-actions {
display: flex;
gap: 15rpx;
}
.action-button {
background-color: #3f51b5;
color: white;
padding: 10rpx 20rpx;
border-radius: 6rpx;
font-size: 24rpx;
&.clear {
background-color: #ff4444;
}
}
.logs-list {
height: 600rpx;
}
.log-item {
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
&.error {
border-left: 8rpx solid #ff4444;
}
&.warning {
border-left: 8rpx solid #ff9800;
}
&.info {
border-left: 8rpx solid #2196f3;
}
}
.log-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 15rpx;
}
.log-level {
padding: 8rpx 16rpx;
border-radius: 20rpx;
font-size: 22rpx;
font-weight: bold;
&.error {
background-color: #ffebee;
color: #ff4444;
}
&.warning {
background-color: #fff3e0;
color: #ff9800;
}
&.info {
background-color: #e3f2fd;
color: #2196f3;
}
}
.log-time {
font-size: 24rpx;
color: #666;
}
.log-content {
margin-bottom: 10rpx;
}
.log-message {
font-size: 28rpx;
color: #333;
line-height: 1.5;
}
.log-details {
background-color: #f8f8f8;
padding: 15rpx;
border-radius: 8rpx;
margin-top: 10rpx;
}
.log-details-text {
font-size: 24rpx;
color: #666;
line-height: 1.4;
}
.load-more {
padding: 30rpx;
text-align: center;
}
.load-more-text {
font-size: 28rpx;
color: #666;
}
</style>

View File

@ -0,0 +1,383 @@
<template>
<view class="parameter-record-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="tabbar-content">
<!-- 日期选择器 -->
<view class="date-selector">
<picker mode="date" :value="selectedDate" @change="onDateChange">
<view class="date-picker">
<text class="date-text">{{ selectedDate }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
<view class="connection-status" :class="connectionStatus.isConnected ? 'connected' : 'disconnected'">
{{ connectionStatus.isConnected ? 'MQTT已连接' : 'MQTT未连接' }}
</view>
</view>
<!-- 温度趋势图表 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">温度趋势</text>
<view class="status-indicator">
<view class="status-dot temperature-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-container">
<canvas canvas-id="temperatureChart" class="chart-canvas"></canvas>
</view>
</view>
<!-- 湿度趋势图表 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">湿度趋势</text>
<view class="status-indicator">
<view class="status-dot humidity-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-container">
<canvas canvas-id="humidityChart" class="chart-canvas"></canvas>
</view>
</view>
<!-- PM2.5趋势图表 -->
<view class="chart-card">
<view class="chart-header">
<text class="chart-title">PM2.5趋势</text>
<view class="status-indicator">
<view class="status-dot pm25-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-container">
<canvas canvas-id="pm25Chart" class="chart-canvas"></canvas>
</view>
</view>
</view>
</view>
</template>
<script>
import mqttDataManager from '@/utils/mqttDataManager.js'
export default {
data() {
return {
selectedDate: '2025-09-01',
// 24小时数据 (0-23点)
temperatureData: [22, 25, 28, 32, 35, 38, 40, 38, 35, 32, 28, 25, 22, 20, 18, 20, 22, 25, 28, 30, 32, 30, 28, 25],
humidityData: [45, 50, 55, 60, 65, 70, 75, 70, 65, 60, 55, 50, 45, 40, 35, 40, 45, 50, 55, 60, 65, 60, 55, 50],
pm25Data: [15, 18, 22, 25, 28, 32, 35, 32, 28, 25, 22, 18, 15, 12, 10, 12, 15, 18, 22, 25, 28, 25, 22, 18],
connectionStatus: {
isConnected: false,
lastUpdate: null
}
}
},
onLoad() {
console.log('参数记录页面加载')
this.$nextTick(() => {
this.drawCharts()
})
this.initMqttListener()
},
onUnload() {
// 页面卸载时移除监听器
if (this.dataUpdateHandler) {
mqttDataManager.removeListener('dataUpdate', this.dataUpdateHandler)
}
if (this.statusUpdateHandler) {
mqttDataManager.removeListener('connectionStatus', this.statusUpdateHandler)
}
},
methods: {
// 初始化MQTT监听
initMqttListener() {
// 监听数据更新
this.dataUpdateHandler = (data) => {
console.log('参数记录页面收到MQTT数据:', data)
this.updateChartData(data)
}
mqttDataManager.addListener('dataUpdate', this.dataUpdateHandler)
// 监听连接状态
this.statusUpdateHandler = (status) => {
this.connectionStatus = status
console.log('参数记录页面连接状态更新:', status)
}
mqttDataManager.addListener('connectionStatus', this.statusUpdateHandler)
// 获取初始连接状态
this.connectionStatus = mqttDataManager.getConnectionStatus()
},
// 更新图表数据
updateChartData(data) {
console.log('📊 参数记录页面更新数据:', data)
// 只处理WSD设备的数据
if (data.deviceType === 'WSD') {
const now = new Date()
const currentHour = now.getHours()
// 更新对应小时的数据
if (data.temperature !== undefined) {
this.temperatureData[currentHour] = Math.round(data.temperature)
console.log(`✅ 温度数据已更新 - 小时${currentHour}:`, this.temperatureData[currentHour])
}
if (data.humidity !== undefined) {
this.humidityData[currentHour] = Math.round(data.humidity)
console.log(`✅ 湿度数据已更新 - 小时${currentHour}:`, this.humidityData[currentHour])
}
// 重新绘制图表
this.$nextTick(() => {
this.drawCharts()
})
console.log('✅ 图表数据更新完成:', {
temperature: this.temperatureData[currentHour],
humidity: this.humidityData[currentHour],
hour: currentHour
})
} else {
console.log('⚠️ 非WSD设备数据跳过图表更新:', data.deviceType)
}
},
onDateChange(e) {
this.selectedDate = e.detail.value
this.$nextTick(() => {
this.drawCharts()
})
},
drawCharts() {
this.drawTemperatureChart()
this.drawHumidityChart()
this.drawPM25Chart()
},
drawTemperatureChart() {
const ctx = uni.createCanvasContext('temperatureChart', this)
this.drawLineChart(ctx, this.temperatureData, '#ff6b35', '°C')
},
drawHumidityChart() {
const ctx = uni.createCanvasContext('humidityChart', this)
this.drawLineChart(ctx, this.humidityData, '#4a90e2', '%')
},
drawPM25Chart() {
const ctx = uni.createCanvasContext('pm25Chart', this)
this.drawLineChart(ctx, this.pm25Data, '#7ed321', 'μg/m³')
},
drawLineChart(ctx, data, color, unit) {
const canvasWidth = 300
const canvasHeight = 200
const padding = 40
const chartWidth = canvasWidth - padding * 2
const chartHeight = canvasHeight - padding * 2
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight)
// 绘制网格
ctx.setStrokeStyle('#f0f0f0')
ctx.setLineWidth(1)
// 水平网格线
for (let i = 0; i <= 5; i++) {
const y = padding + (chartHeight / 5) * i
ctx.beginPath()
ctx.moveTo(padding, y)
ctx.lineTo(padding + chartWidth, y)
ctx.stroke()
}
// 垂直网格线
for (let i = 0; i <= 6; i++) {
const x = padding + (chartWidth / 6) * i
ctx.beginPath()
ctx.moveTo(x, padding)
ctx.lineTo(x, padding + chartHeight)
ctx.stroke()
}
// 绘制数据线
ctx.setStrokeStyle(color)
ctx.setLineWidth(3)
ctx.beginPath()
data.forEach((value, index) => {
const x = padding + (chartWidth / 23) * index
const y = padding + chartHeight - (value / 100) * chartHeight
if (index === 0) {
ctx.moveTo(x, y)
} else {
ctx.lineTo(x, y)
}
})
ctx.stroke()
// 绘制数据点
ctx.setFillStyle(color)
data.forEach((value, index) => {
const x = padding + (chartWidth / 23) * index
const y = padding + chartHeight - (value / 100) * chartHeight
ctx.beginPath()
ctx.arc(x, y, 4, 0, 2 * Math.PI)
ctx.fill()
})
// 绘制X轴标签
ctx.setFillStyle('#666')
ctx.setFontSize(12)
ctx.setTextAlign('center')
const timeLabels = ['00', '04', '08', '12', '16', '20', '23']
timeLabels.forEach((label, index) => {
const x = padding + (chartWidth / 6) * index
ctx.fillText(label, x, canvasHeight - 10)
})
// 绘制Y轴标签
ctx.setTextAlign('right')
for (let i = 0; i <= 5; i++) {
const y = padding + chartHeight - (chartHeight / 5) * i + 5
const value = (100 / 5) * i
ctx.fillText(value.toString(), padding - 10, y)
}
ctx.draw()
}
}
}
</script>
<style lang="scss" scoped>
.parameter-record-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.date-selector {
background: white;
border-radius: 8rpx;
padding: 15rpx;
margin-bottom: 15rpx;
display: flex;
justify-content: space-between;
align-items: center;
}
.connection-status {
font-size: 24rpx;
font-weight: bold;
padding: 8rpx 16rpx;
border-radius: 20rpx;
}
.connection-status.connected {
background-color: #e8f5e8;
color: #4caf50;
}
.connection-status.disconnected {
background-color: #ffebee;
color: #ff4444;
}
.date-picker {
display: flex;
align-items: center;
gap: 8rpx;
padding: 15rpx 20rpx;
background-color: #f8f8f8;
border-radius: 6rpx;
}
.date-text {
font-size: 28rpx;
color: #333;
}
.picker-arrow {
color: #999;
font-size: 20rpx;
}
.chart-card {
background: white;
border-radius: 8rpx;
padding: 20rpx;
margin-bottom: 15rpx;
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
}
.chart-title {
font-size: 28rpx;
font-weight: bold;
color: #333;
}
.chart-subtitle {
font-size: 22rpx;
color: #666;
}
.status-indicator {
display: flex;
align-items: center;
gap: 6rpx;
background-color: #e8f5e8;
padding: 6rpx 12rpx;
border-radius: 15rpx;
}
.status-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
}
.temperature-dot {
background-color: #ff6b35;
}
.humidity-dot {
background-color: #4a90e2;
}
.pm25-dot {
background-color: #7ed321;
}
.status-text {
font-size: 20rpx;
color: #4caf50;
}
.chart-container {
display: flex;
justify-content: center;
}
.chart-canvas {
width: 280px;
height: 180px;
}
</style>

504
src/pages/visual/index.vue Normal file
View File

@ -0,0 +1,504 @@
<template>
<view class="visual-monitoring-page">
<!-- 固定头部 -->
<view class="fixed-header">
<text class="header-title">移动式检修车间</text>
</view>
<!-- 内容区域 -->
<view class="tabbar-content">
<!-- 摄像头状态 -->
<view class="camera-status">
<view class="status-item">
<view class="status-icon camera-icon">📹</view>
<view class="status-info">
<text class="status-label">摄像头状态</text>
<text class="status-value" :class="cameraStatus.class">{{ cameraStatus.text }}</text>
</view>
</view>
<view class="status-item">
<view class="status-icon recording-icon">🔴</view>
<view class="status-info">
<text class="status-label">录制状态</text>
<text class="status-value" :class="recordingStatus.class">{{ recordingStatus.text }}</text>
</view>
</view>
</view>
<!-- 视频区域 -->
<view class="video-container">
<view class="video-placeholder" v-if="!videoLoaded">
<image class="placeholder-image" src="/static/camera-placeholder.jpg" mode="aspectFit"></image>
<text class="placeholder-text">摄像头未连接</text>
<button class="connect-button" @click="connectCamera">连接摄像头</button>
</view>
<view class="video-player" v-else>
<text class="video-text">实时视频流</text>
<view class="video-controls">
<button class="control-button" @click="toggleRecording">
{{ isRecording ? '停止录制' : '开始录制' }}
</button>
<button class="control-button" @click="takeSnapshot">拍照</button>
<button class="control-button" @click="toggleFullscreen">全屏</button>
</view>
</view>
</view>
<!-- 监控设置 -->
<view class="monitoring-settings">
<view class="settings-header">
<text class="settings-title">监控设置</text>
</view>
<view class="setting-item">
<text class="setting-label">录制质量</text>
<picker :value="qualityIndex" :range="qualityOptions" @change="onQualityChange">
<view class="picker-view">
<text>{{ qualityOptions[qualityIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="setting-item">
<text class="setting-label">录制时长</text>
<picker :value="durationIndex" :range="durationOptions" @change="onDurationChange">
<view class="picker-view">
<text>{{ durationOptions[durationIndex] }}</text>
<text class="picker-arrow"></text>
</view>
</picker>
</view>
<view class="setting-item">
<text class="setting-label">自动保存</text>
<switch :checked="autoSave" @change="onAutoSaveChange" color="#3f51b5"/>
</view>
</view>
<!-- 录制历史 -->
<view class="recording-history">
<view class="history-header">
<text class="history-title">录制历史</text>
<button class="clear-button" @click="clearHistory">清空</button>
</view>
<scroll-view class="history-list" scroll-y="true">
<view class="history-item" v-for="(item, index) in historyList" :key="index">
<view class="history-info">
<text class="history-time">{{ item.time }}</text>
<text class="history-duration">{{ item.duration }}</text>
</view>
<view class="history-actions">
<button class="action-button" @click="playVideo(item)">播放</button>
<button class="action-button" @click="downloadVideo(item)">下载</button>
<button class="action-button delete" @click="deleteVideo(item)">删除</button>
</view>
</view>
</scroll-view>
</view>
</view>
</view>
</template>
<script>
export default {
data() {
return {
videoLoaded: false,
isRecording: false,
cameraStatus: {
text: '离线',
class: 'offline'
},
recordingStatus: {
text: '未录制',
class: 'inactive'
},
qualityIndex: 1,
qualityOptions: ['低', '中', '高', '超高清'],
durationIndex: 2,
durationOptions: ['5分钟', '10分钟', '30分钟', '1小时', '持续录制'],
autoSave: true,
historyList: [
{
time: '2025-09-29 15:45:33',
duration: '10:30',
size: '125MB'
},
{
time: '2025-09-29 14:20:15',
duration: '5:45',
size: '68MB'
},
{
time: '2025-09-29 13:10:22',
duration: '15:20',
size: '189MB'
}
]
}
},
onLoad() {
console.log('视觉监控页面加载')
},
methods: {
connectCamera() {
uni.showLoading({
title: '连接中...'
})
setTimeout(() => {
uni.hideLoading()
this.videoLoaded = true
this.cameraStatus = {
text: '在线',
class: 'online'
}
uni.showToast({
title: '摄像头连接成功',
icon: 'success'
})
}, 2000)
},
toggleRecording() {
this.isRecording = !this.isRecording
this.recordingStatus = {
text: this.isRecording ? '录制中' : '未录制',
class: this.isRecording ? 'recording' : 'inactive'
}
uni.showToast({
title: this.isRecording ? '开始录制' : '停止录制',
icon: 'success'
})
},
takeSnapshot() {
uni.showToast({
title: '拍照成功',
icon: 'success'
})
},
toggleFullscreen() {
uni.showToast({
title: '全屏功能开发中',
icon: 'none'
})
},
onQualityChange(e) {
this.qualityIndex = e.detail.value
},
onDurationChange(e) {
this.durationIndex = e.detail.value
},
onAutoSaveChange(e) {
this.autoSave = e.detail.value
},
playVideo(item) {
uni.showToast({
title: `播放 ${item.time}`,
icon: 'none'
})
},
downloadVideo(item) {
uni.showToast({
title: `下载 ${item.time}`,
icon: 'success'
})
},
deleteVideo(item) {
uni.showModal({
title: '确认删除',
content: `确定要删除 ${item.time} 的录制文件吗?`,
success: (res) => {
if (res.confirm) {
const index = this.historyList.indexOf(item)
this.historyList.splice(index, 1)
uni.showToast({
title: '删除成功',
icon: 'success'
})
}
}
})
},
clearHistory() {
uni.showModal({
title: '确认清空',
content: '确定要清空所有录制历史吗?',
success: (res) => {
if (res.confirm) {
this.historyList = []
uni.showToast({
title: '清空成功',
icon: 'success'
})
}
}
})
}
}
}
</script>
<style lang="scss" scoped>
.visual-monitoring-page {
height: 100vh;
display: flex;
flex-direction: column;
overflow: hidden;
}
.camera-status {
background: white;
border-radius: 12rpx;
padding: 25rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
display: flex;
gap: 30rpx;
}
.status-item {
flex: 1;
display: flex;
align-items: center;
gap: 20rpx;
}
.status-icon {
font-size: 48rpx;
}
.camera-icon {
color: #666;
}
.recording-icon {
color: #ff4444;
}
.status-info {
display: flex;
flex-direction: column;
gap: 5rpx;
}
.status-label {
font-size: 24rpx;
color: #666;
}
.status-value {
font-size: 28rpx;
font-weight: bold;
&.online {
color: #4caf50;
}
&.offline {
color: #999;
}
&.recording {
color: #ff4444;
}
&.inactive {
color: #666;
}
}
.video-container {
background: white;
border-radius: 12rpx;
margin-bottom: 20rpx;
box-shadow: 0 2rpx 8rpx rgba(0,0,0,0.05);
overflow: hidden;
}
.video-placeholder {
padding: 60rpx;
text-align: center;
}
.placeholder-image {
width: 300rpx;
height: 200rpx;
margin-bottom: 30rpx;
border-radius: 10rpx;
}
.placeholder-text {
display: block;
font-size: 28rpx;
color: #666;
margin-bottom: 30rpx;
}
.connect-button {
background-color: #3f51b5;
color: white;
padding: 20rpx 40rpx;
border-radius: 10rpx;
font-size: 28rpx;
}
.video-player {
padding: 30rpx;
text-align: center;
}
.video-text {
display: block;
font-size: 32rpx;
color: #333;
margin-bottom: 30rpx;
}
.video-controls {
display: flex;
gap: 20rpx;
justify-content: center;
}
.control-button {
background-color: #3f51b5;
color: white;
padding: 15rpx 30rpx;
border-radius: 8rpx;
font-size: 24rpx;
}
.monitoring-settings {
background: white;
border-radius: 15rpx;
padding: 30rpx;
margin-bottom: 20rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
}
.settings-header {
margin-bottom: 30rpx;
}
.settings-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.setting-item {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
&:last-child {
margin-bottom: 0;
}
}
.setting-label {
font-size: 28rpx;
color: #333;
}
.picker-view {
display: flex;
align-items: center;
gap: 10rpx;
padding: 15rpx 20rpx;
background-color: #f8f8f8;
border-radius: 8rpx;
border: 2rpx solid #e0e0e0;
}
.picker-arrow {
color: #999;
font-size: 20rpx;
}
.recording-history {
background: white;
border-radius: 15rpx;
box-shadow: 0 4rpx 12rpx rgba(0,0,0,0.1);
overflow: hidden;
}
.history-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
}
.history-title {
font-size: 32rpx;
font-weight: bold;
color: #333;
}
.clear-button {
background-color: #ff4444;
color: white;
padding: 10rpx 20rpx;
border-radius: 6rpx;
font-size: 24rpx;
}
.history-list {
height: 400rpx;
}
.history-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 30rpx;
border-bottom: 2rpx solid #f0f0f0;
&:last-child {
border-bottom: none;
}
}
.history-info {
display: flex;
flex-direction: column;
gap: 5rpx;
}
.history-time {
font-size: 28rpx;
color: #333;
font-weight: bold;
}
.history-duration {
font-size: 24rpx;
color: #666;
}
.history-actions {
display: flex;
gap: 15rpx;
}
.action-button {
background-color: #3f51b5;
color: white;
padding: 8rpx 16rpx;
border-radius: 6rpx;
font-size: 22rpx;
&.delete {
background-color: #ff4444;
}
}
</style>