feat: 移动式检修车间系统前端完成

- 完成系统日志页面,优化表格滚动和样式
- 完成报警记录页面,优化表格滚动和报警级别显示
- 完成环境参数页面,优化参数显示和监控画面
- 完成参数记录页面,优化图表样式和简洁设计
- 集成MQTT配置,支持实时数据对接
- 统一UI设计风格,采用现代化卡片式布局
- 添加响应式设计,适配不同屏幕尺寸
- 预留MQTT数据接口,支持AC空调和WSD温湿度设备
This commit is contained in:
吉浩茹
2025-09-26 10:34:00 +08:00
commit 8f6dcca19f
35 changed files with 17474 additions and 0 deletions

View File

@ -0,0 +1,841 @@
<template>
<view class="page-container alarm-record-container">
<!-- 页面头部 -->
<view class="page-header">
<view class="header-left">
<text class="page-title">报警记录</text>
</view>
<view class="header-right">
<view class="system-title">
<view class="system-title-icon">
<text class="icon">📋</text>
</view>
<text class="system-title-text">移动式检修车间系统</text>
</view>
</view>
</view>
<!-- 报警表格 -->
<view class="page-content">
<view class="alarm-content">
<view class="alarm-table">
<!-- 表格头部 -->
<view class="table-header">
<view class="table-cell header-cell content-column">内容</view>
<view class="table-cell header-cell type-column">种类</view>
<view class="table-cell header-cell time-column">时间</view>
<view class="table-cell header-cell level-column">级别</view>
<view class="table-cell header-cell action-column">处置</view>
<view class="table-cell header-cell action-time-column">时间</view>
</view>
<!-- 表格内容 -->
<scroll-view
class="table-body"
scroll-y="true"
:scroll-with-animation="true"
:scroll-top="scrollTop"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
>
<!-- 加载状态 -->
<view class="table-loading-container" v-if="isLoading">
<view class="table-loading-spinner"></view>
<text class="table-loading-text">正在加载报警记录...</text>
</view>
<!-- 表格数据 -->
<template v-else>
<view
v-for="(alarm, index) in alarmList"
:key="index"
class="table-row"
:class="{ 'even-row': index % 2 === 0 }"
>
<view class="table-cell content-column">{{ alarm.content }}</view>
<view class="table-cell type-column">{{ alarm.type }}</view>
<view class="table-cell time-column">{{ alarm.time }}</view>
<view class="table-cell level-column" :class="getLevelClass(alarm.level)">
{{ alarm.level }}
</view>
<view class="table-cell action-column">{{ alarm.action }}</view>
<view class="table-cell action-time-column">{{ alarm.actionTime }}</view>
</view>
</template>
<!-- 空数据提示 -->
<view class="table-empty-container" v-if="!isLoading && alarmList.length === 0 && hasInitialized">
<text class="table-empty-text">暂无报警记录</text>
</view>
<!-- 初始状态提示 -->
<view class="table-empty-container" v-if="!isLoading && alarmList.length === 0 && !hasInitialized">
<text class="table-empty-text">暂无数据</text>
</view>
<!-- 底部间距确保最后一条记录完全显示 -->
<view class="table-bottom-spacing"></view>
</scroll-view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
// 报警数据
const alarmList = ref([]);
const isLoading = ref(false);
const isConnected = ref(false);
const hasInitialized = ref(false);
// 滚动相关
const scrollTop = ref(0);
const isScrolling = ref(false);
// 移除空行占位逻辑,没有数据时只显示"暂无数据"
// MQTT报警服务接口预留
const mqttAlarmService = {
// 连接MQTT服务器
connect: async () => {
console.log('MQTT报警服务连接中...');
try {
// 模拟连接延迟
await new Promise(resolve => setTimeout(resolve, 1000));
isConnected.value = true;
console.log('MQTT报警服务连接成功');
return Promise.resolve();
} catch (error) {
console.error('MQTT报警连接失败:', error);
isConnected.value = false;
return Promise.reject(error);
}
},
// 订阅报警数据
subscribeAlarmData: () => {
console.log('订阅系统报警数据');
// 这里后期会实现真实的MQTT报警订阅
return Promise.resolve();
},
// 获取历史报警记录
getHistoryAlarms: async (limit = 50) => {
console.log(`获取历史报警记录,限制${limit}`);
try {
isLoading.value = true;
hasInitialized.value = true;
// 模拟请求延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟报警数据
const mockAlarms = [
{
content: '湿度45%超标',
type: '参数超标',
time: '2025-9-3-12:01',
level: 'A',
action: '恢复',
actionTime: '2025-9-3-13:11'
},
{
content: '温度28℃过高',
type: '参数超标',
time: '2025-9-3-11:45',
level: 'B',
action: '调整',
actionTime: '2025-9-3-12:30'
},
{
content: '洁净度异常',
type: '环境异常',
time: '2025-9-3-11:20',
level: 'A',
action: '清理',
actionTime: '2025-9-3-11:50'
},
{
content: '设备通讯中断',
type: '设备故障',
time: '2025-9-3-10:55',
level: 'C',
action: '重启',
actionTime: '2025-9-3-11:05'
},
{
content: '压力值偏低',
type: '参数异常',
time: '2025-9-3-10:30',
level: 'B',
action: '检查',
actionTime: '2025-9-3-10:45'
},
{
content: '电源电压不稳',
type: '电气故障',
time: '2025-9-3-10:10',
level: 'A',
action: '更换',
actionTime: '2025-9-3-10:25'
},
{
content: '传感器数据异常',
type: '设备异常',
time: '2025-9-3-09:50',
level: 'B',
action: '校准',
actionTime: '2025-9-3-10:00'
},
{
content: '网络连接超时',
type: '通讯故障',
time: '2025-9-3-09:30',
level: 'C',
action: '重连',
actionTime: '2025-9-3-09:35'
},
{
content: '内存使用率过高',
type: '系统异常',
time: '2025-9-3-09:15',
level: 'B',
action: '清理',
actionTime: '2025-9-3-09:20'
},
{
content: '磁盘空间不足',
type: '存储异常',
time: '2025-9-3-09:00',
level: 'A',
action: '扩容',
actionTime: '2025-9-3-09:10'
},
{
content: 'CPU温度过高',
type: '硬件故障',
time: '2025-9-3-08:45',
level: 'A',
action: '散热',
actionTime: '2025-9-3-08:50'
},
{
content: '数据库连接失败',
type: '数据异常',
time: '2025-9-3-08:30',
level: 'B',
action: '修复',
actionTime: '2025-9-3-08:35'
},
{
content: '配置文件损坏',
type: '配置异常',
time: '2025-9-3-08:15',
level: 'C',
action: '恢复',
actionTime: '2025-9-3-08:20'
},
{
content: '服务进程异常',
type: '进程故障',
time: '2025-9-3-08:00',
level: 'B',
action: '重启',
actionTime: '2025-9-3-08:05'
},
{
content: '日志文件过大',
type: '存储异常',
time: '2025-9-3-07:45',
level: 'C',
action: '压缩',
actionTime: '2025-9-3-07:50'
}
];
alarmList.value = mockAlarms;
isLoading.value = false;
return Promise.resolve(mockAlarms);
} catch (error) {
console.error('获取历史报警记录失败:', error);
isLoading.value = false;
return Promise.reject(error);
}
},
// 获取实时报警
getRealtimeAlarms: async () => {
console.log('获取实时报警');
try {
// 模拟实时报警数据
const contents = ['温度超标', '湿度异常', '压力偏高', '洁净度超标', '设备故障', '通讯中断'];
const types = ['参数超标', '环境异常', '设备故障', '电气故障', '通讯故障'];
const levels = ['A', 'B', 'C'];
const actions = ['处理中', '已恢复', '待处理', '检查中'];
const newAlarm = {
content: contents[Math.floor(Math.random() * contents.length)],
type: types[Math.floor(Math.random() * types.length)],
time: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).replace(/\//g, '-').replace(', ', '-'),
level: levels[Math.floor(Math.random() * levels.length)],
action: actions[Math.floor(Math.random() * actions.length)],
actionTime: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).replace(/\//g, '-').replace(', ', '-')
};
// 添加到报警列表顶部
alarmList.value.unshift(newAlarm);
// 限制报警数量保持最新的50条
if (alarmList.value.length > 50) {
alarmList.value = alarmList.value.slice(0, 50);
}
// 自动滚动到顶部显示最新报警
setTimeout(() => {
scrollToTop();
}, 100);
return Promise.resolve(newAlarm);
} catch (error) {
console.error('获取实时报警失败:', error);
return Promise.reject(error);
}
},
// 确认报警
confirmAlarm: async (alarmId) => {
console.log('确认报警:', alarmId);
try {
// 模拟确认操作
await new Promise(resolve => setTimeout(resolve, 300));
uni.showToast({
title: '报警已确认',
icon: 'success'
});
return Promise.resolve();
} catch (error) {
console.error('确认报警失败:', error);
uni.showToast({
title: '确认失败',
icon: 'error'
});
return Promise.reject(error);
}
},
// 清空报警记录
clearAlarms: async () => {
console.log('清空报警记录');
try {
// 模拟清空操作
await new Promise(resolve => setTimeout(resolve, 300));
alarmList.value = [];
uni.showToast({
title: '报警记录已清空',
icon: 'success'
});
return Promise.resolve();
} catch (error) {
console.error('清空报警记录失败:', error);
uni.showToast({
title: '清空失败',
icon: 'error'
});
return Promise.reject(error);
}
},
// 断开连接
disconnect: () => {
console.log('MQTT报警服务断开连接');
isConnected.value = false;
// 这里后期会实现真实的MQTT断开
}
};
// 获取级别样式类
const getLevelClass = (level) => {
switch (level) {
case 'A':
return 'level-a';
case 'B':
return 'level-b';
case 'C':
return 'level-c';
default:
return '';
}
};
// 滚动事件处理
let scrollTimer = null;
const onScroll = (e) => {
isScrolling.value = true;
// 防抖处理,避免频繁触发
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 150);
// 可以在这里添加滚动时的逻辑
};
const onScrollToLower = () => {
console.log('滚动到底部');
// 可以在这里添加加载更多数据的逻辑
};
// 滚动到顶部
const scrollToTop = () => {
scrollTop.value = scrollTop.value === 0 ? 1 : 0;
};
// 滚动到底部
const scrollToBottom = () => {
// 使用nextTick确保DOM更新完成
nextTick(() => {
// 计算滚动到底部的位置
const scrollHeight = alarmList.value.length * 80; // 假设每行80rpx
scrollTop.value = scrollHeight + 100; // 额外100rpx确保完全滚动到底部
});
};
// 定时获取实时报警
let realtimeTimer = null;
const startRealtimeAlarm = () => {
if (realtimeTimer) return;
realtimeTimer = setInterval(() => {
if (isConnected.value && !isLoading.value) {
// 20%概率生成新报警
if (Math.random() < 0.2) {
mqttAlarmService.getRealtimeAlarms();
}
}
}, 8000); // 每8秒检查一次
};
const stopRealtimeAlarm = () => {
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
};
// 组件生命周期
onMounted(async () => {
try {
// 连接MQTT并初始化
await mqttAlarmService.connect();
await mqttAlarmService.subscribeAlarmData();
await mqttAlarmService.getHistoryAlarms();
// 开始实时报警获取
startRealtimeAlarm();
} catch (error) {
console.error('报警系统初始化失败:', error);
uni.showToast({
title: '连接失败',
icon: 'error'
});
}
});
onUnmounted(() => {
stopRealtimeAlarm();
mqttAlarmService.disconnect();
// 清理滚动定时器
if (scrollTimer) {
clearTimeout(scrollTimer);
scrollTimer = null;
}
});
</script>
<style lang="scss">
@import '@/styles/common.scss';
.alarm-record-container {
// 继承通用页面容器样式
display: flex;
flex-direction: column;
height: 100%;
}
.alarm-content {
// 继承通用内容样式
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.alarm-table {
// 继承通用表格样式
@extend .common-table;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-header {
display: flex;
background: #f8f9fa;
color: #3c4043;
border-bottom: 1rpx solid #e8eaed;
}
.header-cell {
font-weight: 500;
font-size: 26rpx;
padding: 20rpx 16rpx;
text-align: center;
letter-spacing: 0.2rpx;
}
.table-body {
flex: 1;
height: 0; /* 重要配合flex: 1使用确保正确计算高度 */
overflow-y: auto;
overflow-x: hidden;
/* 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #dadce0 #f1f3f4;
/* 平滑滚动 */
scroll-behavior: smooth;
}
/* Webkit浏览器滚动条样式 */
.table-body::-webkit-scrollbar {
width: 8rpx;
}
.table-body::-webkit-scrollbar-track {
background: rgba(241, 243, 244, 0.5);
border-radius: 4rpx;
margin: 4rpx 0;
}
.table-body::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #dadce0 0%, #bdc1c6 100%);
border-radius: 4rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.table-body::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #bdc1c6 0%, #9aa0a6 100%);
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.table-body::-webkit-scrollbar-thumb:active {
background: linear-gradient(180deg, #9aa0a6 0%, #5f6368 100%);
}
.table-row {
display: flex;
border-bottom: 1rpx solid #e8eaed;
transition: all 0.2s ease;
min-height: 80rpx;
}
.table-row.even-row {
background-color: rgba(248, 249, 250, 0.5);
}
.table-row:hover {
background-color: rgba(241, 243, 244, 0.8);
transform: translateX(2rpx);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.table-row:last-child {
border-bottom: none;
}
.table-cell {
padding: 16rpx 16rpx;
font-size: 24rpx;
color: #3c4043;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 80rpx;
line-height: 1.4;
word-wrap: break-word;
word-break: break-all;
transition: color 0.2s ease;
}
.content-column {
flex: 3;
justify-content: flex-start;
text-align: left;
padding-left: 30rpx;
}
.type-column {
flex: 2;
}
.time-column {
flex: 2.5;
}
.level-column {
flex: 1;
font-weight: bold;
}
.action-column {
flex: 1.5;
}
.action-time-column {
flex: 2.5;
}
.level-a {
color: #ea4335;
font-weight: 700;
position: relative;
background: linear-gradient(135deg, rgba(234, 67, 53, 0.1) 0%, rgba(234, 67, 53, 0.05) 100%);
border-radius: 8rpx;
padding: 8rpx 12rpx;
border: 1rpx solid rgba(234, 67, 53, 0.2);
}
.level-a::before {
content: '🔴';
margin-right: 6rpx;
font-size: 18rpx;
}
.level-b {
color: #fbbc04;
font-weight: 700;
position: relative;
background: linear-gradient(135deg, rgba(251, 188, 4, 0.1) 0%, rgba(251, 188, 4, 0.05) 100%);
border-radius: 8rpx;
padding: 8rpx 12rpx;
border: 1rpx solid rgba(251, 188, 4, 0.2);
}
.level-b::before {
content: '🟡';
margin-right: 6rpx;
font-size: 18rpx;
}
.level-c {
color: #34a853;
font-weight: 700;
position: relative;
background: linear-gradient(135deg, rgba(52, 168, 83, 0.1) 0%, rgba(52, 168, 83, 0.05) 100%);
border-radius: 8rpx;
padding: 8rpx 12rpx;
border: 1rpx solid rgba(52, 168, 83, 0.2);
}
.level-c::before {
content: '🟢';
margin-right: 6rpx;
font-size: 18rpx;
}
/* 移除空行样式,不再需要 */
.table-bottom-spacing {
height: 40rpx;
background-color: transparent;
}
.table-loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 20rpx;
gap: 20rpx;
background-color: #ffffff;
}
.table-loading-spinner {
width: 48rpx;
height: 48rpx;
border: 3rpx solid #e8eaed;
border-top: 3rpx solid #5f6368;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.table-loading-text {
font-size: 24rpx;
color: #5f6368;
font-weight: 400;
}
.table-empty-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80rpx;
gap: 16rpx;
}
.table-empty-text {
font-size: 26rpx;
color: #9aa0a6;
font-weight: 400;
}
.table-loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #e9ecef;
border-top: 4rpx solid #6c757d;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.table-loading-text {
font-size: 28rpx;
color: #6c757d;
font-weight: 500;
}
.table-empty-container {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 20rpx;
background-color: #ffffff;
}
.table-empty-text {
font-size: 32rpx;
color: #999;
font-weight: 500;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
gap: 20rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 152, 0, 0.3);
border-top: 4rpx solid #ff9800;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: #666;
}
.empty-container {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx;
}
.empty-text {
font-size: 32rpx;
color: #adb5bd;
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.header-cell {
font-size: 28rpx;
padding: 24rpx 20rpx;
}
.table-cell {
font-size: 26rpx;
padding: 20rpx 16rpx;
}
.empty-text {
font-size: 28rpx;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.header-cell {
font-size: 22rpx;
padding: 16rpx 12rpx;
}
.table-cell {
font-size: 20rpx;
padding: 12rpx 8rpx;
}
.empty-text {
font-size: 28rpx;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.header-cell {
font-size: 30rpx;
padding: 28rpx 24rpx;
}
.table-cell {
font-size: 28rpx;
padding: 24rpx 20rpx;
}
.empty-text {
font-size: 30rpx;
}
}
</style>

View File

@ -0,0 +1,916 @@
<template>
<view class="page-container environment-container">
<!-- 页面头部 -->
<view class="page-header">
<view class="header-left">
<text class="page-title">现场环境参数</text>
</view>
<view class="header-right">
<view class="system-title">
<view class="system-title-icon">
<text class="icon">📋</text>
</view>
<text class="system-title-text">移动式检修车间系统</text>
</view>
</view>
</view>
<view class="page-content">
<view class="content-wrapper">
<view class="left-panel">
<!-- 参数显示区域 -->
<view class="parameters">
<!-- 当前状态概览 -->
<view class="status-overview">
<view class="status-card">
<view class="status-icon">🌡</view>
<view class="status-info">
<text class="status-label">温度</text>
<text class="status-value">{{ temperature }}°C</text>
</view>
<view class="status-indicator" :class="getTemperatureStatus()"></view>
</view>
<view class="status-card">
<view class="status-icon">💧</view>
<view class="status-info">
<text class="status-label">湿度</text>
<text class="status-value">{{ humidity }}%</text>
</view>
<view class="status-indicator" :class="getHumidityStatus()"></view>
</view>
<view class="status-card">
<view class="status-icon"></view>
<view class="status-info">
<text class="status-label">洁净度</text>
<text class="status-value">{{ cleanliness }}%</text>
</view>
<view class="status-indicator" :class="getCleanlinessStatus()"></view>
</view>
</view>
<!-- 详细参数进度条 -->
<view class="detailed-params">
<view class="param-item">
<view class="param-header">
<text class="param-name">温度</text>
<text class="param-current">{{ temperature }}°C</text>
</view>
<view class="param-progress">
<view class="progress-bar">
<view class="progress-fill temperature-fill" :style="{ width: temperaturePercent + '%' }"></view>
</view>
<text class="param-range">15°C - 35°C</text>
</view>
</view>
<view class="param-item">
<view class="param-header">
<text class="param-name">湿度</text>
<text class="param-current">{{ humidity }}%</text>
</view>
<view class="param-progress">
<view class="progress-bar">
<view class="progress-fill humidity-fill" :style="{ width: humidityPercent + '%' }"></view>
</view>
<text class="param-range">30% - 70%</text>
</view>
</view>
<view class="param-item">
<view class="param-header">
<text class="param-name">洁净度</text>
<text class="param-current">{{ cleanliness }}%</text>
</view>
<view class="param-progress">
<view class="progress-bar">
<view class="progress-fill cleanliness-fill" :style="{ width: cleanlinessPercent + '%' }"></view>
</view>
<text class="param-range">60% - 100%</text>
</view>
</view>
</view>
</view>
<!-- 设置面板 -->
<view class="settings-panel">
<view class="settings-header">
<text class="settings-title">环境控制</text>
<view class="settings-status">
<view class="status-dot" :class="isConnected ? getOverallStatus() : 'status-disconnected'"></view>
<text class="status-text">{{ getOverallStatusText() }}</text>
</view>
</view>
<!-- MQTT连接状态 -->
<view class="mqtt-status" v-if="lastUpdateTime">
<text class="mqtt-label">最后更新:</text>
<text class="mqtt-time">{{ lastUpdateTime }}</text>
</view>
<view class="settings-content">
<view class="control-item">
<text class="control-label">温度控制</text>
<view class="control-value">{{ temperature }}°C</view>
</view>
<view class="control-item">
<text class="control-label">湿度控制</text>
<view class="control-value">{{ humidity }}%</view>
</view>
</view>
<view class="settings-actions">
<button class="settings-button" @click="openSettings">
<text class="button-icon"></text>
<text class="button-text">参数设定</text>
</button>
</view>
</view>
</view>
<!-- 右侧监控画面 -->
<view class="right-panel">
<view class="camera-feed">
<view class="camera-header">
<text class="camera-title">实时监控</text>
<view class="camera-status" :class="isConnected ? getOverallStatus() : 'status-disconnected'">
<view class="status-indicator-dot"></view>
<text class="status-text">{{ getOverallStatusText() }}</text>
</view>
</view>
<view class="camera-container">
<image class="camera-image" src="/static/inspection-room.jpg" mode="aspectFill"></image>
<view class="camera-overlay">
<view class="overlay-info">
<text class="overlay-time">{{ getCurrentTime() }}</text>
<text class="overlay-location">检修车间</text>
</view>
<view class="camera-controls">
<button class="control-btn">
<text class="control-icon">📷</text>
</button>
<button class="control-btn">
<text class="control-icon">🔍</text>
</button>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, defineEmits, onMounted, onUnmounted } from 'vue';
// import mqttClient from '@/utils/mqttClient';
import { MQTT_CONFIG, DataParser } from '@/config/mqtt';
const emit = defineEmits(['openSettings']);
// 环境参数数据
const temperature = ref(25);
const humidity = ref(45);
const cleanliness = ref(80);
// MQTT连接状态
const isConnected = ref(false);
const lastUpdateTime = ref('');
// 计算百分比值用于进度条显示
const temperaturePercent = ref(50);
const humidityPercent = ref(60);
const cleanlinessPercent = ref(40);
// MQTT数据处理
const handleDeviceData = (data) => {
console.log('收到设备数据:', data);
// 更新最后更新时间
lastUpdateTime.value = DataParser.formatTimestamp(data.timestamp);
// 根据设备类型处理数据
switch (data.Device) {
case 'AC': // 空调设备
if (data.Data && data.Data.BSQWD !== undefined) {
temperature.value = parseFloat(data.Data.BSQWD);
temperaturePercent.value = ((temperature.value - 15) / 20) * 100; // 15-35°C范围
console.log('更新空调温度:', temperature.value);
}
break;
case 'WSD': // 温湿度传感器
if (data.Data) {
if (data.Data.WD !== undefined) {
temperature.value = parseFloat(data.Data.WD);
temperaturePercent.value = ((temperature.value - 15) / 20) * 100;
console.log('更新温湿度传感器温度:', temperature.value);
}
if (data.Data.SD !== undefined) {
humidity.value = parseFloat(data.Data.SD);
humidityPercent.value = (humidity.value / 80) * 100; // 0-80%范围
console.log('更新湿度:', humidity.value);
}
}
break;
case 'PM': // PM2.5传感器
if (data.Data && data.Data.PM25 !== undefined) {
// 将PM2.5值转换为洁净度百分比 (PM2.5越低,洁净度越高)
const pm25Value = parseFloat(data.Data.PM25);
cleanliness.value = Math.max(0, Math.min(100, 100 - (pm25Value / 2))); // 简单转换
cleanlinessPercent.value = cleanliness.value;
console.log('更新PM2.5/洁净度:', pm25Value, cleanliness.value);
}
break;
default:
console.log('未知设备类型:', data.Device);
}
};
// // 连接MQTT并订阅数据
// const connectMQTT = async () => {
// try {
// await mqttClient.connect();
// isConnected.value = true;
// // 订阅设备数据主题
// const subscribeSuccess = mqttClient.subscribe(MQTT_CONFIG.topics.deviceData, handleDeviceData);
// if (subscribeSuccess) {
// console.log('MQTT订阅成功等待设备数据...');
// } else {
// console.error('MQTT订阅失败');
// }
// } catch (error) {
// console.error('MQTT连接失败:', error);
// isConnected.value = false;
// // 显示连接失败提示
// uni.showToast({
// title: 'MQTT连接失败',
// icon: 'error',
// duration: 3000
// });
// }
// };
// 打开设置弹窗
const openSettings = () => {
emit('openSettings');
};
// 获取参数状态
const getTemperatureStatus = () => {
if (temperature.value < 20 || temperature.value > 30) return 'status-warning';
return 'status-normal';
};
const getHumidityStatus = () => {
if (humidity.value < 40 || humidity.value > 60) return 'status-warning';
return 'status-normal';
};
const getCleanlinessStatus = () => {
if (cleanliness.value < 70) return 'status-warning';
return 'status-normal';
};
// 获取整体状态
const getOverallStatus = () => {
const tempStatus = getTemperatureStatus();
const humidityStatus = getHumidityStatus();
const cleanlinessStatus = getCleanlinessStatus();
if (tempStatus === 'status-warning' || humidityStatus === 'status-warning' || cleanlinessStatus === 'status-warning') {
return 'status-warning';
}
return 'status-normal';
};
const getOverallStatusText = () => {
if (!isConnected.value) {
return '未连接';
}
return getOverallStatus() === 'status-normal' ? '正常' : '异常';
};
// 获取当前时间
const getCurrentTime = () => {
const now = new Date();
return now.toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
};
// 组件挂载时连接MQTT
onMounted(() => {
// connectMQTT();
});
// 组件卸载时断开连接
onUnmounted(() => {
// mqttClient.unsubscribe(MQTT_CONFIG.topics.deviceData);
// mqttClient.disconnect();
});
// 移除模拟数据变化使用真实MQTT数据
// setInterval(() => {
// // 温度在22-28之间随机变化
// temperature.value = Math.floor(Math.random() * 6) + 22;
// temperaturePercent.value = Math.floor(Math.random() * 30) + 40;
//
// // 湿度在40-60之间随机变化
// humidity.value = Math.floor(Math.random() * 20) + 40;
// humidityPercent.value = Math.floor(Math.random() * 30) + 40;
//
// // 洁净度百分比随机变化
// cleanlinessPercent.value = Math.floor(Math.random() * 30) + 40;
// }, 5000);
</script>
<style lang="scss">
@import '@/styles/common.scss';
.environment-container {
// 继承通用页面容器样式
display: flex;
flex-direction: column;
height: 100%;
}
.content-wrapper {
display: flex;
flex: 1;
height: calc(100% - 120rpx);
padding: 0;
// margin: 0 30rpx;
box-sizing: border-box;
}
.left-panel {
flex: 3;
display: flex;
flex-direction: column;
margin-right: 20rpx;
height: 100%;
}
/* 参数显示区域 */
.parameters {
flex: 1;
display: flex;
flex-direction: column;
gap: 20rpx;
margin-bottom: 20rpx;
overflow-y: auto;
}
/* 状态概览卡片 */
.status-overview {
display: flex;
gap: 12rpx;
margin-bottom: 20rpx;
}
.status-card {
flex: 1;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12rpx;
padding: 16rpx;
display: flex;
align-items: center;
gap: 12rpx;
border: 1rpx solid #e8eaed;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.status-card:hover {
transform: translateY(-2rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.status-icon {
font-size: 32rpx;
width: 40rpx;
text-align: center;
}
.status-info {
flex: 1;
display: flex;
flex-direction: column;
gap: 4rpx;
}
.status-label {
font-size: 22rpx;
color: #5f6368;
font-weight: 500;
}
.status-value {
font-size: 28rpx;
color: #1a73e8;
font-weight: 700;
}
.status-indicator {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
transition: all 0.3s ease;
}
.status-indicator.status-normal {
background: linear-gradient(135deg, #34a853 0%, #2e7d32 100%);
box-shadow: 0 0 8rpx rgba(52, 168, 83, 0.3);
}
.status-indicator.status-warning {
background: linear-gradient(135deg, #ea4335 0%, #d32f2f 100%);
box-shadow: 0 0 8rpx rgba(234, 67, 53, 0.3);
}
/* 详细参数进度条 */
.detailed-params {
display: flex;
flex-direction: column;
gap: 16rpx;
}
.param-item {
background: #ffffff;
border-radius: 12rpx;
padding: 20rpx;
border: 1rpx solid #e8eaed;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
transition: all 0.3s ease;
}
.param-item:hover {
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.param-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12rpx;
}
.param-name {
font-size: 26rpx;
font-weight: 600;
color: #3c4043;
}
.param-current {
font-size: 28rpx;
font-weight: 700;
color: #1a73e8;
}
.param-progress {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.progress-bar {
height: 8rpx;
background: #f1f3f4;
border-radius: 4rpx;
overflow: hidden;
position: relative;
}
.progress-fill {
height: 100%;
border-radius: 4rpx;
transition: width 0.5s ease;
position: relative;
}
.progress-fill::after {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, transparent 0%, rgba(255, 255, 255, 0.3) 50%, transparent 100%);
animation: shimmer 2s infinite;
}
@keyframes shimmer {
0% { transform: translateX(-100%); }
100% { transform: translateX(100%); }
}
.temperature-fill {
background: linear-gradient(90deg, #ff6b35 0%, #f7931e 100%);
}
.humidity-fill {
background: linear-gradient(90deg, #4285f4 0%, #34a853 100%);
}
.cleanliness-fill {
background: linear-gradient(90deg, #9c27b0 0%, #673ab7 100%);
}
.param-range {
font-size: 20rpx;
color: #9aa0a6;
text-align: right;
}
/* 右侧面板 */
.right-panel {
flex: 2;
display: flex;
flex-direction: column;
height: 100%;
}
.camera-feed {
flex: 1;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12rpx;
overflow: hidden;
position: relative;
height: 100%;
border: 1rpx solid #e8eaed;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
}
.camera-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16rpx 20rpx;
background: rgba(255, 255, 255, 0.95);
border-bottom: 1rpx solid #e8eaed;
}
.camera-title {
font-size: 28rpx;
font-weight: 700;
color: #3c4043;
}
.camera-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 6rpx 12rpx;
border-radius: 20rpx;
transition: all 0.3s ease;
}
.camera-status.status-normal {
background: rgba(52, 168, 83, 0.1);
border: 1rpx solid rgba(52, 168, 83, 0.2);
}
.camera-status.status-warning {
background: rgba(234, 67, 53, 0.1);
border: 1rpx solid rgba(234, 67, 53, 0.2);
}
.camera-status.status-disconnected {
background: rgba(154, 160, 166, 0.1);
border: 1rpx solid rgba(154, 160, 166, 0.2);
}
.status-indicator-dot {
width: 8rpx;
height: 8rpx;
border-radius: 50%;
transition: all 0.3s ease;
}
.camera-status.status-normal .status-indicator-dot {
background: #34a853;
box-shadow: 0 0 6rpx rgba(52, 168, 83, 0.4);
}
.camera-status.status-warning .status-indicator-dot {
background: #ea4335;
box-shadow: 0 0 6rpx rgba(234, 67, 53, 0.4);
}
.camera-status.status-disconnected .status-indicator-dot {
background: #9aa0a6;
box-shadow: 0 0 6rpx rgba(154, 160, 166, 0.4);
}
.camera-container {
flex: 1;
position: relative;
overflow: hidden;
}
.camera-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.camera-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(180deg, rgba(0, 0, 0, 0.1) 0%, transparent 30%, transparent 70%, rgba(0, 0, 0, 0.2) 100%);
display: flex;
flex-direction: column;
justify-content: space-between;
padding: 16rpx;
}
.overlay-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.overlay-time {
font-size: 22rpx;
color: rgba(255, 255, 255, 0.9);
font-weight: 600;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.5);
}
.overlay-location {
font-size: 20rpx;
color: rgba(255, 255, 255, 0.7);
font-weight: 500;
text-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.5);
}
.camera-controls {
display: flex;
gap: 12rpx;
justify-content: flex-end;
}
.control-btn {
width: 48rpx;
height: 48rpx;
border-radius: 50%;
background: rgba(255, 255, 255, 0.9);
border: none;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s ease;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.2);
}
.control-btn:hover {
transform: scale(1.1);
background: rgba(255, 255, 255, 1);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.3);
}
.control-icon {
font-size: 20rpx;
}
.settings-panel {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12rpx;
padding: 20rpx;
border: 1rpx solid #e8eaed;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
display: flex;
flex-direction: column;
gap: 16rpx;
// height: 200rpx;
}
.settings-header {
display: flex;
justify-content: space-between;
align-items: center;
padding-bottom: 12rpx;
border-bottom: 1rpx solid #e8eaed;
}
.settings-title {
font-size: 28rpx;
font-weight: 700;
color: #3c4043;
}
.settings-status {
display: flex;
align-items: center;
gap: 8rpx;
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
transition: all 0.3s ease;
}
.status-dot.status-normal {
background: linear-gradient(135deg, #34a853 0%, #2e7d32 100%);
box-shadow: 0 0 8rpx rgba(52, 168, 83, 0.3);
}
.status-dot.status-warning {
background: linear-gradient(135deg, #ea4335 0%, #d32f2f 100%);
box-shadow: 0 0 8rpx rgba(234, 67, 53, 0.3);
}
.status-dot.status-disconnected {
background: linear-gradient(135deg, #9aa0a6 0%, #5f6368 100%);
box-shadow: 0 0 8rpx rgba(154, 160, 166, 0.3);
}
.status-text {
font-size: 22rpx;
font-weight: 600;
color: #5f6368;
}
/* MQTT状态显示 */
.mqtt-status {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8rpx 0;
border-bottom: 1rpx solid #e8eaed;
margin-bottom: 12rpx;
}
.mqtt-label {
font-size: 20rpx;
color: #9aa0a6;
font-weight: 500;
}
.mqtt-time {
font-size: 20rpx;
color: #1a73e8;
font-weight: 600;
}
.settings-content {
display: flex;
flex-direction: column;
gap: 12rpx;
}
.control-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8rpx 0;
}
.control-label {
font-size: 24rpx;
font-weight: 500;
color: #5f6368;
}
.control-value {
font-size: 26rpx;
font-weight: 700;
color: #1a73e8;
}
.settings-actions {
display: flex;
justify-content: center;
}
.settings-button {
display: flex;
align-items: center;
gap: 8rpx;
padding: 12rpx 24rpx;
background: linear-gradient(135deg, #1a73e8 0%, #1557b0 100%);
border: none;
color: white;
font-size: 24rpx;
font-weight: 600;
border-radius: 8rpx;
transition: all 0.3s ease;
box-shadow: 0 2rpx 8rpx rgba(26, 115, 232, 0.3);
}
.settings-button:hover {
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(26, 115, 232, 0.4);
}
.button-icon {
font-size: 20rpx;
}
.button-text {
font-size: 24rpx;
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.parameter-label {
font-size: 32rpx;
}
.parameter-value {
font-size: 36rpx;
}
.param-icon {
width: 48rpx;
height: 48rpx;
}
.parameter-row {
height: 120rpx;
}
.settings-label {
font-size: 36rpx;
}
.settings-value {
font-size: 36rpx;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.parameter-label {
font-size: 24rpx;
}
.parameter-value {
font-size: 28rpx;
}
.param-icon {
width: 32rpx;
height: 32rpx;
}
.parameter-row {
height: 80rpx;
}
.settings-label {
font-size: 28rpx;
}
.settings-value {
font-size: 28rpx;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.parameter-label {
font-size: 36rpx;
}
.parameter-value {
font-size: 40rpx;
}
.param-icon {
width: 56rpx;
height: 56rpx;
}
.parameter-row {
height: 140rpx;
}
.settings-label {
font-size: 40rpx;
}
.settings-value {
font-size: 40rpx;
}
}
</style>

View File

@ -0,0 +1,675 @@
<template>
<view class="page-container parameter-record-container">
<!-- 页面头部 -->
<view class="page-header">
<view class="header-left">
<text class="page-title">参数记录</text>
<view class="date-selector" @click="showDatePicker">
<text class="date-text">{{ currentDate }}</text>
<text class="date-icon"></text>
</view>
</view>
<view class="header-right">
<view class="system-title">
<view class="system-title-icon">
<text class="icon">📋</text>
</view>
<text class="system-title-text">移动式检修车间系统</text>
</view>
</view>
</view>
<!-- 图表区域 -->
<view class="page-content">
<scroll-view
class="charts-scroll-container"
scroll-y="true"
:scroll-with-animation="true"
:scroll-top="scrollTop"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
>
<view class="charts-container">
<!-- 温度图表 -->
<view class="chart-card">
<view class="chart-header">
<view class="chart-info">
<text class="chart-title">温度趋势</text>
<text class="chart-subtitle">24小时数据</text>
</view>
<view class="chart-status">
<view class="status-dot temperature-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-content">
<canvas
id="temperatureChart"
canvas-id="temperatureChart"
class="chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
@touchend="onChartTouch"
></canvas>
</view>
</view>
<!-- 湿度图表 -->
<view class="chart-card">
<view class="chart-header">
<view class="chart-info">
<text class="chart-title">湿度趋势</text>
<text class="chart-subtitle">24小时数据</text>
</view>
<view class="chart-status">
<view class="status-dot humidity-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-content">
<canvas
id="humidityChart"
canvas-id="humidityChart"
class="chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
@touchend="onChartTouch"
></canvas>
</view>
</view>
<!-- PM图表 -->
<view class="chart-card">
<view class="chart-header">
<view class="chart-info">
<text class="chart-title">PM2.5趋势</text>
<text class="chart-subtitle">24小时数据</text>
</view>
<view class="chart-status">
<view class="status-dot pm-dot"></view>
<text class="status-text">正常</text>
</view>
</view>
<view class="chart-content">
<canvas
id="pmChart"
canvas-id="pmChart"
class="chart-canvas"
@touchstart="onChartTouch"
@touchmove="onChartTouch"
@touchend="onChartTouch"
></canvas>
</view>
</view>
</view>
<!-- 底部间距 -->
<view class="content-bottom-spacing"></view>
</scroll-view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, nextTick } from 'vue';
// 当前日期
const currentDate = ref('2025年9月1日');
// 滚动相关
const scrollTop = ref(0);
const isScrolling = ref(false);
// 模拟数据
const temperatureData = ref([]);
const humidityData = ref([]);
const pmData = ref([]);
const pressureData = ref([]);
const windSpeedData = ref([]);
const lightData = ref([]);
// MQTT数据请求接口预留
const mqttService = {
// 连接MQTT服务器
connect: () => {
console.log('MQTT连接中...');
// 这里后期会实现真实的MQTT连接
return Promise.resolve();
},
// 订阅参数数据
subscribeParameterData: (date) => {
console.log(`订阅${date}的参数数据`);
// 这里后期会实现真实的MQTT订阅
return Promise.resolve();
},
// 获取历史数据
getHistoricalData: (date, parameter) => {
console.log(`获取${date}${parameter}历史数据`);
// 这里后期会实现真实的MQTT数据请求
return Promise.resolve();
},
// 断开连接
disconnect: () => {
console.log('MQTT断开连接');
// 这里后期会实现真实的MQTT断开
}
};
// 初始化数据
const initData = () => {
// 清空现有数据
temperatureData.value = [];
humidityData.value = [];
pmData.value = [];
pressureData.value = [];
windSpeedData.value = [];
lightData.value = [];
// 生成24小时的模拟数据
for (let i = 0; i < 24; i++) {
temperatureData.value.push({
time: i,
value: 20 + Math.sin(i * Math.PI / 12) * 5 + Math.random() * 2
});
humidityData.value.push({
time: i,
value: 50 + Math.sin(i * Math.PI / 8) * 10 + Math.random() * 3
});
pmData.value.push({
time: i,
value: 30 + Math.sin(i * Math.PI / 6) * 8 + Math.random() * 4
});
pressureData.value.push({
time: i,
value: 1013 + Math.sin(i * Math.PI / 10) * 5 + Math.random() * 2
});
windSpeedData.value.push({
time: i,
value: 5 + Math.sin(i * Math.PI / 6) * 3 + Math.random() * 2
});
lightData.value.push({
time: i,
value: 200 + Math.sin(i * Math.PI / 12) * 100 + Math.random() * 50
});
}
};
// 绘制图表
const drawChart = (canvasId, data, color = '#000000') => {
const ctx = uni.createCanvasContext(canvasId);
// 获取实际canvas尺寸
const query = uni.createSelectorQuery();
query.select(`#${canvasId}`).boundingClientRect((rect) => {
if (rect) {
const canvasWidth = rect.width;
const canvasHeight = rect.height;
drawChartContent(ctx, canvasId, data, color, canvasWidth, canvasHeight);
}
}).exec();
};
// 绘制图表内容
const drawChartContent = (ctx, canvasId, data, color, canvasWidth, canvasHeight) => {
// 清空画布
ctx.clearRect(0, 0, canvasWidth, canvasHeight);
// 设置背景 - 透明背景
ctx.setFillStyle('transparent');
ctx.fillRect(0, 0, canvasWidth, canvasHeight - 40);
// 绘制网格线
ctx.setStrokeStyle('#f1f3f4');
ctx.setLineWidth(1);
// 绘制水平网格线
for (let i = 0; i <= 4; i++) {
const y = (canvasHeight - 40) * (i / 4);
ctx.beginPath();
ctx.moveTo(0, y);
ctx.lineTo(canvasWidth, y);
ctx.stroke();
}
// 绘制垂直网格线
for (let i = 0; i <= 6; i++) {
const x = (canvasWidth / 6) * i;
ctx.beginPath();
ctx.moveTo(x, 0);
ctx.lineTo(x, canvasHeight - 40);
ctx.stroke();
}
// 绘制数据线和填充区域
if (data.length > 0) {
const points = [];
// 计算所有点的坐标
data.forEach((point, index) => {
const x = (canvasWidth / 24) * (point.time + 0.5);
let y;
if (canvasId === 'temperatureChart') {
y = (canvasHeight - 40) - ((point.value - 15) / 15) * (canvasHeight - 40);
} else if (canvasId === 'humidityChart') {
y = (canvasHeight - 40) - (point.value / 80) * (canvasHeight - 40);
} else {
y = (canvasHeight - 40) - (point.value / 60) * (canvasHeight - 40);
}
points.push({ x, y, value: point.value });
});
// 绘制填充区域 - 透明色
ctx.setFillStyle('transparent');
ctx.beginPath();
ctx.moveTo(points[0].x, canvasHeight - 40);
points.forEach(point => {
ctx.lineTo(point.x, point.y);
});
ctx.lineTo(points[points.length - 1].x, canvasHeight - 40);
ctx.closePath();
ctx.fill();
// 绘制数据线
ctx.setStrokeStyle(color);
ctx.setLineWidth(3);
ctx.beginPath();
points.forEach((point, index) => {
if (index === 0) {
ctx.moveTo(point.x, point.y);
} else {
ctx.lineTo(point.x, point.y);
}
});
ctx.stroke();
// 绘制数据点
ctx.setFillStyle(color);
points.forEach((point, index) => {
if (index % 3 === 0) { // 每3个点显示一个数据点
ctx.beginPath();
ctx.arc(point.x, point.y, 4, 0, 2 * Math.PI);
ctx.fill();
// 绘制数据点外圈
ctx.setStrokeStyle('#ffffff');
ctx.setLineWidth(2);
ctx.stroke();
}
});
}
// 绘制时间轴背景
ctx.setFillStyle('rgba(248, 249, 250, 0.8)');
ctx.fillRect(0, canvasHeight - 40, canvasWidth, 40);
// 绘制时间轴标签 - 显示0-23所有小时
ctx.setFillStyle('#5f6368');
ctx.setFontSize(20);
ctx.setTextAlign('center');
for (let i = 0; i <= 23; i += 2) { // 每2小时显示一个标签
const x = (canvasWidth / 24) * (i + 0.5);
ctx.fillText(i.toString(), x, canvasHeight - 12);
}
ctx.draw();
};
// 绘制所有图表
const drawAllCharts = () => {
nextTick(() => {
drawChart('temperatureChart', temperatureData.value, '#ff6b35');
drawChart('humidityChart', humidityData.value, '#4285f4');
drawChart('pmChart', pmData.value, '#9c27b0');
drawChart('pressureChart', pressureData.value, '#34a853');
drawChart('windSpeedChart', windSpeedData.value, '#fbbc04');
drawChart('lightChart', lightData.value, '#ea4335');
});
};
// 显示日期选择器
const showDatePicker = () => {
uni.showActionSheet({
itemList: ['2025年9月1日', '2025年8月31日', '2025年8月30日'],
success: (res) => {
const dates = ['2025年9月1日', '2025年8月31日', '2025年8月30日'];
currentDate.value = dates[res.tapIndex];
// 通过MQTT获取新日期的数据
loadDataByDate(currentDate.value);
}
});
};
// 根据日期加载数据
const loadDataByDate = async (date) => {
try {
// 连接MQTT并获取数据
await mqttService.connect();
await mqttService.subscribeParameterData(date);
// 获取各参数的历史数据
await Promise.all([
mqttService.getHistoricalData(date, 'temperature'),
mqttService.getHistoricalData(date, 'humidity'),
mqttService.getHistoricalData(date, 'pm')
]);
// 重新生成模拟数据并绘制图表
initData();
drawAllCharts();
} catch (error) {
console.error('加载数据失败:', error);
// 如果MQTT失败使用模拟数据
initData();
drawAllCharts();
}
};
// 图表触摸事件
const onChartTouch = (e) => {
// 可以在这里添加图表交互功能
console.log('Chart touched:', e);
};
// 滚动事件处理
let scrollTimer = null;
const onScroll = (e) => {
isScrolling.value = true;
// 防抖处理,避免频繁触发
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 150);
};
const onScrollToLower = () => {
console.log('滚动到底部');
// 可以在这里添加加载更多数据的逻辑
};
// 组件挂载后初始化
onMounted(() => {
loadDataByDate(currentDate.value);
});
</script>
<style lang="scss">
@import '@/styles/common.scss';
.parameter-record-container {
// 继承通用页面容器样式
display: flex;
flex-direction: column;
height: 100%;
}
.header-left {
display: flex;
align-items: center;
gap: 24rpx;
}
.date-selector {
display: flex;
align-items: center;
padding: 16rpx 24rpx;
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 12rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.06);
border: 1rpx solid #e8eaed;
transition: all 0.3s ease;
}
.date-selector:hover {
transform: translateY(-1rpx);
box-shadow: 0 4rpx 12rpx rgba(0, 0, 0, 0.1);
}
.date-text {
font-size: 26rpx;
color: #3c4043;
margin-right: 12rpx;
font-weight: 600;
}
.date-icon {
font-size: 20rpx;
color: #1a73e8;
font-weight: 600;
transition: transform 0.3s ease;
}
.date-selector:hover .date-icon {
transform: rotate(180deg);
}
.charts-scroll-container {
flex: 1;
height: 0; /* 重要配合flex: 1使用确保正确计算高度 */
overflow-y: auto;
overflow-x: hidden;
/* 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #dadce0 #f1f3f4;
/* 平滑滚动 */
scroll-behavior: smooth;
}
/* Webkit浏览器滚动条样式 */
.charts-scroll-container::-webkit-scrollbar {
width: 8rpx;
}
.charts-scroll-container::-webkit-scrollbar-track {
background: rgba(241, 243, 244, 0.5);
border-radius: 4rpx;
margin: 4rpx 0;
}
.charts-scroll-container::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #dadce0 0%, #bdc1c6 100%);
border-radius: 4rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.charts-scroll-container::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #bdc1c6 0%, #9aa0a6 100%);
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.charts-scroll-container::-webkit-scrollbar-thumb:active {
background: linear-gradient(180deg, #9aa0a6 0%, #5f6368 100%);
}
.charts-container {
display: flex;
flex-direction: column;
gap: 20rpx;
// padding: 20rpx;
}
.chart-card {
background: linear-gradient(135deg, #ffffff 0%, #f8f9fa 100%);
border-radius: 16rpx;
padding: 24rpx;
box-shadow: 0 4rpx 16rpx rgba(0, 0, 0, 0.08);
border: 1rpx solid #e8eaed;
transition: all 0.3s ease;
}
.chart-card:hover {
transform: translateY(-2rpx);
box-shadow: 0 8rpx 24rpx rgba(0, 0, 0, 0.12);
}
.chart-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20rpx;
padding-bottom: 16rpx;
border-bottom: 1rpx solid #e8eaed;
}
.chart-info {
display: flex;
flex-direction: column;
gap: 4rpx;
}
.chart-title {
font-size: 32rpx;
font-weight: 700;
color: #3c4043;
}
.chart-subtitle {
font-size: 22rpx;
color: #9aa0a6;
font-weight: 500;
}
.chart-status {
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
border-radius: 20rpx;
background: rgba(52, 168, 83, 0.1);
border: 1rpx solid rgba(52, 168, 83, 0.2);
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
transition: all 0.3s ease;
}
.temperature-dot {
background: linear-gradient(135deg, #ff6b35 0%, #f7931e 100%);
box-shadow: 0 0 8rpx rgba(255, 107, 53, 0.3);
}
.humidity-dot {
background: linear-gradient(135deg, #4285f4 0%, #34a853 100%);
box-shadow: 0 0 8rpx rgba(66, 133, 244, 0.3);
}
.pm-dot {
background: linear-gradient(135deg, #9c27b0 0%, #673ab7 100%);
box-shadow: 0 0 8rpx rgba(156, 39, 176, 0.3);
}
.status-text {
font-size: 22rpx;
color: #34a853;
font-weight: 600;
}
.chart-content {
display: flex;
justify-content: center;
align-items: center;
padding: 16rpx;
background: rgba(255, 255, 255, 0.5);
border-radius: 12rpx;
}
.chart-canvas {
width: 100%;
height: 320rpx;
background-color: transparent;
border-radius: 8rpx;
}
.footer {
display: flex;
justify-content: flex-end;
margin-top: 30rpx;
padding: 20rpx 10rpx 10rpx 0;
border-top: 1px solid #e0e0e0;
}
.footer-text {
font-size: 28rpx;
color: #666;
font-weight: 500;
}
.content-bottom-spacing {
height: 40rpx;
background-color: transparent;
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.date-text {
font-size: 36rpx;
}
.chart-title {
font-size: 36rpx;
}
.chart-canvas {
height: 360rpx;
}
.footer-text {
font-size: 32rpx;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.date-text {
font-size: 28rpx;
}
.chart-title {
font-size: 28rpx;
}
.chart-canvas {
height: 240rpx;
}
.footer-text {
font-size: 24rpx;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.date-text {
font-size: 40rpx;
}
.chart-title {
font-size: 40rpx;
}
.chart-canvas {
height: 420rpx;
}
.footer-text {
font-size: 36rpx;
}
}
</style>

View File

@ -0,0 +1,31 @@
<template>
<view class="placeholder-content">
<text>{{ title }} 页面内容</text>
</view>
</template>
<script setup>
import { defineProps } from 'vue';
const props = defineProps({
title: {
type: String,
required: true
}
});
</script>
<style lang="scss">
.placeholder-content {
flex: 1;
display: flex;
align-items: center;
justify-content: center;
background-color: #ffffff;
border-radius: 10rpx;
padding: 30rpx;
font-size: 32rpx;
color: #666;
height: 100%;
}
</style>

View File

@ -0,0 +1,172 @@
<template>
<view class="modal" v-if="visible">
<view class="popup-content">
<view class="popup-header">
<text class="popup-title">环境参数设置</text>
<text class="close-button" @click="closeModal">×</text>
</view>
<view class="popup-body">
<view class="setting-group">
<text class="setting-label">温度阈值 (°C)</text>
<input type="number" class="setting-input" v-model="tempThreshold" />
</view>
<view class="setting-group">
<text class="setting-label">湿度阈值 (%)</text>
<input type="number" class="setting-input" v-model="humidityThreshold" />
</view>
</view>
<view class="popup-footer">
<button class="popup-button cancel-button" @click="closeModal">取消</button>
<button class="popup-button save-button" @click="saveSettings">保存</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
visible: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['update:visible', 'save']);
// 设置参数
const tempThreshold = ref(25);
const humidityThreshold = ref(45);
const cleanlinessThreshold = ref(100);
// 报警选项
const alarmOptions = ref(['全部启用', '仅温度', '仅湿度', '仅洁净度', '全部禁用']);
const alarmIndex = ref(0);
// 报警选项变更
const onAlarmChange = (e) => {
alarmIndex.value = e.detail.value;
};
// 关闭弹窗
const closeModal = () => {
emit('update:visible', false);
};
// 保存设置
const saveSettings = () => {
// 创建设置对象
const settings = {
tempThreshold: tempThreshold.value,
humidityThreshold: humidityThreshold.value,
cleanlinessThreshold: cleanlinessThreshold.value,
alarmOption: alarmOptions.value[alarmIndex.value]
};
// 发送保存事件
emit('save', settings);
// 关闭弹窗
closeModal();
// 显示保存成功提示
uni.showToast({
title: '设置已保存',
icon: 'success'
});
};
</script>
<style lang="scss">
/* 弹窗样式 */
.modal {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
z-index: 999;
}
.popup-content {
background-color: white;
padding: 30rpx;
border-radius: 20rpx;
width: 600rpx;
}
.popup-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
}
.popup-title {
font-size: 32rpx;
font-weight: bold;
}
.close-button {
font-size: 40rpx;
cursor: pointer;
}
.popup-body {
margin-bottom: 30rpx;
}
.setting-group {
margin-bottom: 20rpx;
}
.setting-label {
display: block;
margin-bottom: 10rpx;
font-weight: bold;
font-size: 28rpx;
}
.setting-input {
// width: 100%;
padding: 15rpx;
border: 1px solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
}
.picker-value {
padding: 15rpx;
border: 1px solid #ddd;
border-radius: 10rpx;
font-size: 28rpx;
}
.popup-footer {
display: flex;
justify-content: flex-end;
gap: 20rpx;
}
.popup-button {
padding: 15rpx 30rpx;
border: none;
border-radius: 10rpx;
font-weight: bold;
font-size: 28rpx;
}
.cancel-button {
background-color: #f0f0f0;
}
.save-button {
background-color: #3f51b5;
color: white;
}
</style>

174
src/components/SideMenu.vue Normal file
View File

@ -0,0 +1,174 @@
<template>
<view class="sidebar">
<view class="logo-container">
<!-- <image class="logo" src="/static/logo.png" mode="aspectFit"></image> -->
<text class="logo-text">检修系统</text>
</view>
<view
v-for="(item, index) in menuList"
:key="index"
class="menu-item"
:class="{ active: activeMenuIndex === index }"
@click="switchMenu(index)"
>
<text>{{ item.name }}</text>
</view>
<view class="menu-item alert" @click="showAlert">
<text>当前报警项目</text>
</view>
</view>
</template>
<script setup>
import { ref, defineProps, defineEmits } from 'vue';
const props = defineProps({
activeMenuIndex: {
type: Number,
default: 0
}
});
const emit = defineEmits(['update:activeMenuIndex', 'showAlert']);
// 菜单列表
const menuList = ref([
{ name: '现场环境参数' },
{ name: '参数记录' },
{ name: '视觉监控' },
{ name: '日志' },
{ name: '报警' }
]);
// 切换菜单
const switchMenu = (index) => {
emit('update:activeMenuIndex', index);
};
// 显示报警信息
const showAlert = () => {
emit('showAlert');
};
</script>
<style lang="scss">
/* 左侧菜单栏样式 - 后台管理系统风格 */
.sidebar {
width: 200rpx;
background-color: #ffffff;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
border-right: 1rpx solid #e8eaed;
box-shadow: 1rpx 0 3rpx rgba(0, 0, 0, 0.1);
}
.logo-container {
display: flex;
align-items: center;
justify-content: center;
padding: 24rpx 0;
background-color: #f8f9fa;
color: #3c4043;
flex-direction: column;
border-bottom: 1rpx solid #e8eaed;
}
.logo {
width: 60rpx;
height: 60rpx;
background-color: transparent;
border-radius: 50%;
}
.logo-text {
font-size: 24rpx;
font-weight: 500;
margin-top: 8rpx;
color: #3c4043;
}
.menu-item {
padding: 20rpx 0;
display: flex;
align-items: center;
justify-content: center;
position: relative;
background-color: transparent;
color: #5f6368;
border-bottom: 1rpx solid #f1f3f4;
text-align: center;
font-size: 24rpx;
font-weight: 400;
transition: all 0.2s ease;
}
.menu-item:hover {
background-color: #f8f9fa;
color: #3c4043;
}
.menu-item.active {
background-color: #e8f0fe;
color: #1a73e8;
border-right: 3rpx solid #1a73e8;
font-weight: 500;
}
.menu-item.alert {
background-color: #fef7e0;
color: #ea8600;
margin-top: auto;
border: 1rpx solid #fdd663;
font-weight: 500;
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.sidebar {
width: 220rpx;
}
.logo-text {
font-size: 26rpx;
}
.menu-item {
font-size: 26rpx;
padding: 24rpx 0;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.sidebar {
width: 160rpx;
}
.logo-text {
font-size: 20rpx;
}
.menu-item {
font-size: 20rpx;
padding: 16rpx 0;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.sidebar {
width: 240rpx;
}
.logo-text {
font-size: 28rpx;
}
.menu-item {
font-size: 28rpx;
padding: 28rpx 0;
}
}
</style>

View File

@ -0,0 +1,762 @@
<template>
<view class="page-container system-log-container">
<!-- 页面头部 -->
<view class="page-header">
<view class="header-left">
<text class="page-title">日志</text>
</view>
<view class="header-right">
<view class="system-title">
<view class="system-title-icon">
<text class="icon">📋</text>
</view>
<text class="system-title-text">移动式检修车间系统</text>
</view>
</view>
</view>
<!-- 日志表格 -->
<view class="page-content">
<view class="log-content">
<view class="log-table">
<!-- 表格头部 -->
<view class="table-header">
<view class="table-cell header-cell event-column">事件</view>
<view class="table-cell header-cell time-column">时间</view>
<view class="table-cell header-cell status-column">状态</view>
</view>
<!-- 表格内容 -->
<scroll-view
class="table-body"
scroll-y="true"
:scroll-with-animation="true"
:scroll-top="scrollTop"
@scrolltolower="onScrollToLower"
@scroll="onScroll"
>
<!-- 加载状态 -->
<view class="table-loading-container" v-if="isLoading">
<view class="table-loading-spinner"></view>
<text class="table-loading-text">正在加载日志数据...</text>
</view>
<!-- 表格数据 -->
<template v-else>
<view
v-for="(log, index) in logList"
:key="index"
class="table-row"
:class="{ 'even-row': index % 2 === 0 }"
>
<view class="table-cell event-column">{{ log.event }}</view>
<view class="table-cell time-column">{{ log.time }}</view>
<view class="table-cell status-column" :class="getStatusClass(log.status)">
{{ log.status }}
</view>
</view>
</template>
<!-- 空数据提示 -->
<view class="table-empty-container" v-if="!isLoading && logList.length === 0 && hasInitialized">
<text class="table-empty-text">暂无日志数据</text>
</view>
<!-- 初始状态提示 -->
<view class="table-empty-container" v-if="!isLoading && logList.length === 0 && !hasInitialized">
<text class="table-empty-text">暂无数据</text>
</view>
<!-- 底部间距确保最后一条记录完全显示 -->
<view class="table-bottom-spacing"></view>
</scroll-view>
</view>
</view>
</view>
</view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, nextTick } from 'vue';
// 日志数据
const logList = ref([]);
const isLoading = ref(false);
const isConnected = ref(false);
const hasInitialized = ref(false);
// 滚动相关
const scrollTop = ref(0);
const isScrolling = ref(false);
// 移除空行占位逻辑,没有数据时只显示"暂无数据"
// MQTT日志服务接口预留
const mqttLogService = {
// 连接MQTT服务器
connect: async () => {
console.log('MQTT日志服务连接中...');
try {
// 模拟连接延迟
await new Promise(resolve => setTimeout(resolve, 1000));
isConnected.value = true;
console.log('MQTT日志服务连接成功');
return Promise.resolve();
} catch (error) {
console.error('MQTT日志连接失败:', error);
isConnected.value = false;
return Promise.reject(error);
}
},
// 订阅日志数据
subscribeLogData: () => {
console.log('订阅系统日志数据');
// 这里后期会实现真实的MQTT日志订阅
return Promise.resolve();
},
// 获取历史日志
getHistoryLogs: async (limit = 50) => {
console.log(`获取历史日志,限制${limit}`);
try {
isLoading.value = true;
hasInitialized.value = true;
// 模拟请求延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 模拟日志数据
const mockLogs = [
{
event: '开机',
time: '2025-9-3-12:01',
status: '正常'
},
{
event: '报警',
time: '2025-9-3-12:01',
status: '异常'
},
{
event: '系统启动',
time: '2025-9-3-11:58',
status: '正常'
},
{
event: '温度检测',
time: '2025-9-3-11:55',
status: '正常'
},
{
event: '湿度异常',
time: '2025-9-3-11:52',
status: '异常'
},
{
event: '设备自检',
time: '2025-9-3-11:50',
status: '正常'
},
{
event: '网络连接',
time: '2025-9-3-11:48',
status: '正常'
},
{
event: '参数校准',
time: '2025-9-3-11:45',
status: '正常'
},
{
event: '数据备份',
time: '2025-9-3-11:40',
status: '正常'
},
{
event: '系统更新',
time: '2025-9-3-11:35',
status: '正常'
},
{
event: '安全检查',
time: '2025-9-3-11:30',
status: '正常'
},
{
event: '性能监控',
time: '2025-9-3-11:25',
status: '正常'
},
{
event: '日志清理',
time: '2025-9-3-11:20',
status: '正常'
},
{
event: '配置更新',
time: '2025-9-3-11:15',
status: '正常'
},
{
event: '服务重启',
time: '2025-9-3-11:10',
status: '正常'
},
{
event: '数据库连接',
time: '2025-9-3-11:05',
status: '正常'
},
{
event: '缓存清理',
time: '2025-9-3-11:00',
status: '正常'
},
{
event: '定时任务',
time: '2025-9-3-10:55',
status: '正常'
},
{
event: '内存检查',
time: '2025-9-3-10:50',
status: '正常'
},
{
event: '磁盘检查',
time: '2025-9-3-10:45',
status: '正常'
}
];
logList.value = mockLogs;
isLoading.value = false;
return Promise.resolve(mockLogs);
} catch (error) {
console.error('获取历史日志失败:', error);
isLoading.value = false;
return Promise.reject(error);
}
},
// 获取实时日志
getRealtimeLogs: async () => {
console.log('获取实时日志');
try {
// 模拟实时日志数据
const events = ['温度监测', '湿度检测', '设备巡检', '数据同步', '状态上报'];
const statuses = ['正常', '异常', '警告'];
const newLog = {
event: events[Math.floor(Math.random() * events.length)],
time: new Date().toLocaleString('zh-CN', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
}).replace(/\//g, '-').replace(', ', '-'),
status: statuses[Math.floor(Math.random() * statuses.length)]
};
// 添加到日志列表顶部
logList.value.unshift(newLog);
// 限制日志数量保持最新的50条
if (logList.value.length > 50) {
logList.value = logList.value.slice(0, 50);
}
// 自动滚动到顶部显示最新日志
setTimeout(() => {
scrollToTop();
}, 100);
return Promise.resolve(newLog);
} catch (error) {
console.error('获取实时日志失败:', error);
return Promise.reject(error);
}
},
// 清空日志
clearLogs: async () => {
console.log('清空日志');
try {
// 模拟清空操作
await new Promise(resolve => setTimeout(resolve, 300));
logList.value = [];
uni.showToast({
title: '日志已清空',
icon: 'success'
});
return Promise.resolve();
} catch (error) {
console.error('清空日志失败:', error);
uni.showToast({
title: '清空失败',
icon: 'error'
});
return Promise.reject(error);
}
},
// 断开连接
disconnect: () => {
console.log('MQTT日志服务断开连接');
isConnected.value = false;
// 这里后期会实现真实的MQTT断开
}
};
// 获取状态样式类
const getStatusClass = (status) => {
switch (status) {
case '正常':
return 'status-normal';
case '异常':
return 'status-error';
case '警告':
return 'status-warning';
default:
return '';
}
};
// 滚动事件处理
let scrollTimer = null;
const onScroll = (e) => {
isScrolling.value = true;
// 防抖处理,避免频繁触发
if (scrollTimer) {
clearTimeout(scrollTimer);
}
scrollTimer = setTimeout(() => {
isScrolling.value = false;
}, 150);
// 可以在这里添加滚动时的逻辑
};
const onScrollToLower = () => {
console.log('滚动到底部');
// 可以在这里添加加载更多数据的逻辑
};
// 滚动到顶部
const scrollToTop = () => {
scrollTop.value = scrollTop.value === 0 ? 1 : 0;
};
// 滚动到底部
const scrollToBottom = () => {
// 使用nextTick确保DOM更新完成
nextTick(() => {
// 计算滚动到底部的位置
const scrollHeight = logList.value.length * 80; // 假设每行80rpx
scrollTop.value = scrollHeight + 100; // 额外100rpx确保完全滚动到底部
});
};
// 定时获取实时日志
let realtimeTimer = null;
const startRealtimeLog = () => {
if (realtimeTimer) return;
realtimeTimer = setInterval(() => {
if (isConnected.value && !isLoading.value) {
// 30%概率生成新日志
if (Math.random() < 0.3) {
mqttLogService.getRealtimeLogs();
}
}
}, 5000); // 每5秒检查一次
};
const stopRealtimeLog = () => {
if (realtimeTimer) {
clearInterval(realtimeTimer);
realtimeTimer = null;
}
};
// 组件生命周期
onMounted(async () => {
try {
// 连接MQTT并初始化
await mqttLogService.connect();
await mqttLogService.subscribeLogData();
await mqttLogService.getHistoryLogs();
// 开始实时日志获取
startRealtimeLog();
} catch (error) {
console.error('日志系统初始化失败:', error);
uni.showToast({
title: '连接失败',
icon: 'error'
});
}
});
onUnmounted(() => {
stopRealtimeLog();
mqttLogService.disconnect();
// 清理滚动定时器
if (scrollTimer) {
clearTimeout(scrollTimer);
scrollTimer = null;
}
});
</script>
<style lang="scss">
@import '@/styles/common.scss';
.system-log-container {
// 继承通用页面容器样式
display: flex;
flex-direction: column;
height: 100%;
}
.log-content {
// 继承通用内容样式
flex: 1;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.log-table {
// 继承通用表格样式
@extend .common-table;
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.table-header {
display: flex;
background: #f8f9fa;
color: #3c4043;
border-bottom: 1rpx solid #e8eaed;
}
.header-cell {
font-weight: 500;
font-size: 26rpx;
padding: 20rpx 16rpx;
text-align: center;
letter-spacing: 0.2rpx;
}
.table-body {
flex: 1;
height: 0; /* 重要配合flex: 1使用确保正确计算高度 */
overflow-y: auto;
overflow-x: hidden;
/* 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #dadce0 #f1f3f4;
/* 平滑滚动 */
scroll-behavior: smooth;
}
/* Webkit浏览器滚动条样式 */
.table-body::-webkit-scrollbar {
width: 8rpx;
}
.table-body::-webkit-scrollbar-track {
background: rgba(241, 243, 244, 0.5);
border-radius: 4rpx;
margin: 4rpx 0;
}
.table-body::-webkit-scrollbar-thumb {
background: linear-gradient(180deg, #dadce0 0%, #bdc1c6 100%);
border-radius: 4rpx;
border: 1rpx solid rgba(255, 255, 255, 0.2);
transition: all 0.3s ease;
}
.table-body::-webkit-scrollbar-thumb:hover {
background: linear-gradient(180deg, #bdc1c6 0%, #9aa0a6 100%);
box-shadow: 0 2rpx 4rpx rgba(0, 0, 0, 0.1);
}
.table-body::-webkit-scrollbar-thumb:active {
background: linear-gradient(180deg, #9aa0a6 0%, #5f6368 100%);
}
.table-row {
display: flex;
border-bottom: 1rpx solid #e8eaed;
transition: all 0.2s ease;
min-height: 80rpx;
}
.table-row.even-row {
background-color: rgba(248, 249, 250, 0.5);
}
.table-row:hover {
background-color: rgba(241, 243, 244, 0.8);
transform: translateX(2rpx);
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.05);
}
.table-row:last-child {
border-bottom: none;
}
.table-cell {
padding: 16rpx 16rpx;
font-size: 24rpx;
color: #3c4043;
display: flex;
align-items: center;
justify-content: center;
text-align: center;
min-height: 80rpx;
line-height: 1.4;
word-wrap: break-word;
word-break: break-all;
transition: color 0.2s ease;
}
.event-column {
flex: 2;
justify-content: flex-start;
text-align: left;
padding-left: 30rpx;
}
.time-column {
flex: 2;
}
.status-column {
flex: 1;
font-weight: bold;
}
.status-normal {
color: #34a853;
font-weight: 600;
position: relative;
}
.status-normal::before {
content: '●';
color: #34a853;
margin-right: 8rpx;
font-size: 20rpx;
}
.status-error {
color: #ea4335;
font-weight: 600;
position: relative;
}
.status-error::before {
content: '●';
color: #ea4335;
margin-right: 8rpx;
font-size: 20rpx;
}
.status-warning {
color: #fbbc04;
font-weight: 600;
position: relative;
}
.status-warning::before {
content: '●';
color: #fbbc04;
margin-right: 8rpx;
font-size: 20rpx;
}
/* 移除空行样式,不再需要 */
.table-bottom-spacing {
height: 40rpx;
background-color: transparent;
}
.table-loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx 20rpx;
gap: 20rpx;
background-color: #ffffff;
}
.table-loading-spinner {
width: 48rpx;
height: 48rpx;
border: 3rpx solid #e8eaed;
border-top: 3rpx solid #5f6368;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.table-loading-text {
font-size: 24rpx;
color: #5f6368;
font-weight: 400;
}
.table-empty-container {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
padding: 80rpx;
gap: 16rpx;
}
.table-empty-text {
font-size: 26rpx;
color: #9aa0a6;
font-weight: 400;
}
.table-loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid #e9ecef;
border-top: 4rpx solid #6c757d;
border-radius: 50%;
animation: spin 1s linear infinite;
}
.table-loading-text {
font-size: 28rpx;
color: #6c757d;
font-weight: 500;
}
.table-empty-container {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx 20rpx;
background-color: #ffffff;
}
.table-empty-text {
font-size: 32rpx;
color: #999;
font-weight: 500;
}
.loading-container {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 60rpx;
gap: 20rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 152, 0, 0.3);
border-top: 4rpx solid #ff9800;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
font-size: 28rpx;
color: #666;
}
.empty-container {
display: flex;
justify-content: center;
align-items: center;
padding: 100rpx;
}
.empty-text {
font-size: 32rpx;
color: #adb5bd;
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.header-cell {
font-size: 28rpx;
padding: 24rpx 20rpx;
}
.table-cell {
font-size: 26rpx;
padding: 20rpx 16rpx;
}
.empty-text {
font-size: 28rpx;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.header-cell {
font-size: 22rpx;
padding: 16rpx 12rpx;
}
.table-cell {
font-size: 20rpx;
padding: 12rpx 8rpx;
}
.empty-text {
font-size: 24rpx;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.header-cell {
font-size: 30rpx;
padding: 28rpx 24rpx;
}
.table-cell {
font-size: 28rpx;
padding: 24rpx 20rpx;
}
.empty-text {
font-size: 30rpx;
}
}
</style>

View File

@ -0,0 +1,488 @@
<template>
<view class="page-container visual-monitoring-container">
<!-- 页面头部 -->
<view class="page-header">
<view class="header-left">
<text class="page-title">视觉监控</text>
</view>
<view class="header-right">
<view class="system-title">
<view class="system-title-icon">
<text class="icon">📋</text>
</view>
<text class="system-title-text">移动式检修车间系统</text>
</view>
</view>
</view>
<!-- 监控画面区域 -->
<view class="page-content">
<view class="monitoring-content">
<view class="c">
<image
class="camera-image"
:src="currentImage"
mode="aspectFill"
@load="onImageLoad"
@error="onImageError"
/>
<!-- 加载状态 -->
<view class="loading-overlay" v-if="isLoading">
<view class="loading-spinner"></view>
<text class="loading-text">加载中...</text>
</view>
<!-- 连接状态指示器 -->
<view class="connection-status" :class="{ connected: isConnected }">
<view class="status-dot"></view>
<text class="status-text">{{ isConnected ? '已连接' : '未连接' }}</text>
</view>
</view>
</view>
<!-- 底部控制按钮 -->
<view class="control-panel">
<button
class="control-button on-button"
:class="{ active: isDeviceOn }"
@click="toggleDevice(true)"
:disabled="isControlling"
>
</button>
<button
class="control-button off-button"
:class="{ active: !isDeviceOn }"
@click="toggleDevice(false)"
:disabled="isControlling"
>
</button>
</view>
</view>
</view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
// 页面状态
const currentImage = ref('/static/inspection-room.jpg');
const isLoading = ref(false);
const isConnected = ref(false);
const isDeviceOn = ref(false);
const isControlling = ref(false);
// MQTT服务接口预留
const mqttService = {
// 连接MQTT服务器
connect: async () => {
console.log('MQTT视觉监控连接中...');
try {
// 模拟连接延迟
await new Promise(resolve => setTimeout(resolve, 1000));
isConnected.value = true;
console.log('MQTT视觉监控连接成功');
return Promise.resolve();
} catch (error) {
console.error('MQTT连接失败:', error);
isConnected.value = false;
return Promise.reject(error);
}
},
// 订阅图像数据
subscribeImageData: () => {
console.log('订阅视觉监控图像数据');
// 这里后期会实现真实的MQTT图像订阅
return Promise.resolve();
},
// 获取实时图像
getRealtimeImage: async () => {
console.log('获取实时监控图像');
try {
// 模拟图像请求
isLoading.value = true;
await new Promise(resolve => setTimeout(resolve, 500));
// 模拟返回不同的图像
const images = [
'/static/inspection-room.jpg',
'/static/camera-placeholder.jpg'
];
const randomImage = images[Math.floor(Math.random() * images.length)];
currentImage.value = randomImage;
isLoading.value = false;
return Promise.resolve(randomImage);
} catch (error) {
console.error('获取图像失败:', error);
isLoading.value = false;
return Promise.reject(error);
}
},
// 发送设备控制指令
sendDeviceControl: async (command) => {
console.log(`发送设备控制指令: ${command}`);
try {
isControlling.value = true;
// 模拟控制指令发送延迟
await new Promise(resolve => setTimeout(resolve, 800));
// 更新设备状态
isDeviceOn.value = command === 'on';
isControlling.value = false;
uni.showToast({
title: `设备已${command === 'on' ? '开启' : '关闭'}`,
icon: 'success'
});
return Promise.resolve();
} catch (error) {
console.error('设备控制失败:', error);
isControlling.value = false;
uni.showToast({
title: '控制失败',
icon: 'error'
});
return Promise.reject(error);
}
},
// 获取设备状态
getDeviceStatus: async () => {
console.log('获取设备状态');
try {
// 模拟获取设备状态
await new Promise(resolve => setTimeout(resolve, 300));
const status = Math.random() > 0.5; // 随机状态
isDeviceOn.value = status;
return Promise.resolve(status);
} catch (error) {
console.error('获取设备状态失败:', error);
return Promise.reject(error);
}
},
// 断开连接
disconnect: () => {
console.log('MQTT视觉监控断开连接');
isConnected.value = false;
// 这里后期会实现真实的MQTT断开
}
};
// 设备控制
const toggleDevice = async (turnOn) => {
if (isControlling.value || !isConnected.value) return;
try {
await mqttService.sendDeviceControl(turnOn ? 'on' : 'off');
// 控制成功后刷新图像
setTimeout(() => {
mqttService.getRealtimeImage();
}, 1000);
} catch (error) {
console.error('设备控制失败:', error);
}
};
// 图像加载事件
const onImageLoad = () => {
console.log('图像加载成功');
isLoading.value = false;
};
const onImageError = () => {
console.error('图像加载失败');
isLoading.value = false;
uni.showToast({
title: '图像加载失败',
icon: 'error'
});
};
// 定时刷新图像
let imageRefreshTimer = null;
const startImageRefresh = () => {
if (imageRefreshTimer) return;
imageRefreshTimer = setInterval(() => {
if (isConnected.value && !isLoading.value) {
mqttService.getRealtimeImage();
}
}, 5000); // 每5秒刷新一次图像
};
const stopImageRefresh = () => {
if (imageRefreshTimer) {
clearInterval(imageRefreshTimer);
imageRefreshTimer = null;
}
};
// 组件生命周期
onMounted(async () => {
try {
// 连接MQTT并初始化
await mqttService.connect();
await mqttService.subscribeImageData();
await mqttService.getDeviceStatus();
await mqttService.getRealtimeImage();
// 开始定时刷新图像
startImageRefresh();
} catch (error) {
console.error('视觉监控初始化失败:', error);
uni.showToast({
title: '连接失败',
icon: 'error'
});
}
});
onUnmounted(() => {
stopImageRefresh();
mqttService.disconnect();
});
</script>
<style lang="scss">
@import '@/styles/common.scss';
.visual-monitoring-container {
// 继承通用页面容器样式
position: relative;
}
.monitoring-content {
flex: 1;
display: flex;
flex-direction: column;
position: relative;
overflow: hidden;
min-height: 500rpx;
/* 滚动条样式 */
scrollbar-width: thin;
scrollbar-color: #c1c1c1 #f1f1f1;
}
/* Webkit浏览器滚动条样式 */
.monitoring-content::-webkit-scrollbar {
width: 8rpx;
}
.monitoring-content::-webkit-scrollbar-track {
background: #f1f1f1;
border-radius: 4rpx;
}
.monitoring-content::-webkit-scrollbar-thumb {
background: #c1c1c1;
border-radius: 4rpx;
}
.monitoring-content::-webkit-scrollbar-thumb:hover {
background: #a8a8a8;
}
.camera-container {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
// margin: 0 30rpx;
background-color: #000000;
overflow: hidden;
border-radius: 16rpx;
}
.camera-image {
width: 100%;
height: 100%;
object-fit: cover;
}
.loading-overlay {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.7);
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
gap: 20rpx;
}
.loading-spinner {
width: 60rpx;
height: 60rpx;
border: 4rpx solid rgba(255, 255, 255, 0.3);
border-top: 4rpx solid #ffffff;
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading-text {
color: white;
font-size: 28rpx;
}
.connection-status {
position: absolute;
top: 20rpx;
right: 20rpx;
display: flex;
align-items: center;
gap: 8rpx;
padding: 8rpx 16rpx;
background-color: rgba(0, 0, 0, 0.6);
border-radius: 20rpx;
backdrop-filter: blur(10rpx);
}
.status-dot {
width: 12rpx;
height: 12rpx;
border-radius: 50%;
background-color: #ff4444;
transition: background-color 0.3s;
}
.connection-status.connected .status-dot {
background-color: #44ff44;
}
.status-text {
color: white;
font-size: 24rpx;
font-weight: 500;
}
.control-panel {
position: absolute;
bottom: 24rpx;
left: 50%;
transform: translateX(-50%);
display: flex;
justify-content: center;
gap: 24rpx;
padding: 16rpx 24rpx;
background-color: rgba(255, 255, 255, 0.95);
border-radius: 8rpx;
box-shadow: 0 2rpx 8rpx rgba(0, 0, 0, 0.1);
backdrop-filter: blur(10rpx);
z-index: 20;
}
.control-button {
width: 120rpx;
height: 60rpx;
border: none;
border-radius: 6rpx;
font-size: 24rpx;
font-weight: 500;
color: white;
position: relative;
overflow: hidden;
transition: all 0.2s ease;
box-shadow: 0 1rpx 3rpx rgba(0, 0, 0, 0.1);
}
.control-button::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(135deg, rgba(255, 255, 255, 0.1), transparent);
opacity: 0;
transition: opacity 0.2s;
}
.control-button:hover::before {
opacity: 1;
}
.on-button {
background: #1a73e8;
}
.on-button.active {
background: #1557b0;
box-shadow: 0 2rpx 6rpx rgba(26, 115, 232, 0.3);
}
.off-button {
background: #5f6368;
}
.off-button.active {
background: #3c4043;
box-shadow: 0 2rpx 6rpx rgba(95, 99, 104, 0.3);
}
.control-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.control-button:not(:disabled):active {
transform: translateY(1rpx);
box-shadow: 0 1rpx 2rpx rgba(0, 0, 0, 0.1);
}
// 响应式设计 - 平板设备适配
@media (min-width: 768px) and (max-width: 1024px) {
.camera-image {
min-height: 500rpx;
}
.control-button {
font-size: 26rpx;
width: 140rpx;
height: 70rpx;
}
}
// 响应式设计 - 手机设备适配
@media (max-width: 750rpx) {
.camera-image {
min-height: 400rpx;
}
.control-button {
font-size: 20rpx;
width: 100rpx;
height: 50rpx;
}
}
// 响应式设计 - 大屏设备适配
@media (min-width: 1200px) {
.camera-image {
min-height: 600rpx;
}
.control-button {
font-size: 28rpx;
width: 160rpx;
height: 80rpx;
}
}
</style>