20251213:数据中心-历史记录-设备-导出历史数据

This commit is contained in:
George 2025-12-13 10:08:21 +08:00
parent 1338393ca4
commit d965eab5d6
10 changed files with 18251 additions and 20 deletions

View File

@ -128,7 +128,7 @@ public class DeviceDebugLogAspect {
Long productId = bo.getProductId();
saveErrorLog(serialNumber, sourceData, errorMsg, protocolCode, bo.getTopicName(),
DeviceLogTypeEnum.DATA_PARSE_ERROR.getType(), "PARSE_ERROR", productId);
DeviceLogTypeEnum.DATA_PARSE_ERROR.getType(), "DEBUG_ERROR", productId);
}
} catch (Exception ex) {
log.error("记录数据解析错误日志失败: {}", ex.getMessage(), ex);
@ -372,9 +372,13 @@ public class DeviceDebugLogAspect {
PushMessageBo messageBo = new PushMessageBo();
messageBo.setTopic(topicsUtils.buildTopic(productId, serialNumber.toUpperCase(), TopicType.WS_SERVICE_INVOKE));
// 将hex转换为ASCII
String sourceAscii = hexToAscii(sourceData);
JSONObject pushObj = new JSONObject();
pushObj.put("message", errorItems);
pushObj.put("sources", StringUtils.isNotEmpty(sourceData) ? sourceData : "");
pushObj.put("sources", StringUtils.isNotEmpty(sourceData) ? sourceData : ""); // HEX格式
pushObj.put("sourcesAscii", sourceAscii); // ASCII格式
pushObj.put("errorType", identity); // 错误类型:PARSE_ERROR 或 SEND_ERROR
pushObj.put("isError", true); // 标记为错误消息
@ -382,10 +386,62 @@ public class DeviceDebugLogAspect {
// 推送消息到MQTT
mqttRemoteManager.pushCommon(messageBo);
log.info("已推送错误信息到前端 - 设备: {}, 产品ID: {}, 错误类型: {}",
serialNumber, productId, identity);
log.info("已推送错误信息到前端 - 设备: {}, 产品ID: {}, 错误类型: {}, HEX: {}, ASCII: {}",
serialNumber, productId, identity,
StringUtils.isNotEmpty(sourceData) ? StringUtils.substring(sourceData, 0, 20) + "..." : "",
StringUtils.isNotEmpty(sourceAscii) ? StringUtils.substring(sourceAscii, 0, 20) + "..." : "");
} catch (Exception ex) {
log.error("推送错误信息到前端失败: {}", ex.getMessage(), ex);
}
}
/**
* 将16进制字符串转换为ASCII字符串
* @param hexStr 16进制字符串(可能包含空格)
* @return ASCII字符串,保留换行符和回车符,其他不可打印字符显示为点号
*/
private String hexToAscii(String hexStr) {
if (StringUtils.isEmpty(hexStr)) {
return "";
}
try {
// 移除空格
String cleanHex = hexStr.replaceAll("\\s+", "");
// 如果长度不是偶数,补0
if (cleanHex.length() % 2 != 0) {
cleanHex = "0" + cleanHex;
}
StringBuilder ascii = new StringBuilder();
for (int i = 0; i < cleanHex.length(); i += 2) {
String hexByte = cleanHex.substring(i, Math.min(i + 2, cleanHex.length()));
try {
int charCode = Integer.parseInt(hexByte, 16);
// 处理特殊字符:换行符(0x0A=10)和回车符(0x0D=13)
if (charCode == 10) {
// 换行符 \n
ascii.append("\n");
} else if (charCode == 13) {
// 回车符 \r
ascii.append("\r");
} else if (charCode == 9) {
// 制表符 \t
ascii.append("\t");
} else if (charCode >= 32 && charCode <= 126) {
// 可打印字符(32-126)
ascii.append((char) charCode);
} else {
// 其他不可打印字符显示为点号
ascii.append(".");
}
} catch (NumberFormatException e) {
ascii.append(".");
}
}
return ascii.toString();
} catch (Exception e) {
log.warn("HEX转ASCII失败: {}, HEX字符串: {}", e.getMessage(),
StringUtils.isNotEmpty(hexStr) ? StringUtils.substring(hexStr, 0, 50) : "");
return "";
}
}
}

View File

@ -1,26 +1,31 @@
package com.fastbee.controller.datacenter;
import com.alibaba.excel.EasyExcel;
import com.alibaba.excel.ExcelWriter;
import com.alibaba.excel.write.metadata.WriteSheet;
import com.alibaba.fastjson2.JSONObject;
import com.fastbee.common.annotation.Anonymous;
import com.fastbee.common.core.domain.AjaxResult;
import com.fastbee.common.utils.DateUtils;
import com.fastbee.common.utils.StringUtils;
import com.fastbee.iot.domain.DeviceLog;
import com.fastbee.iot.model.AlertCountVO;
import com.fastbee.iot.model.DeviceHistoryParam;
import com.fastbee.iot.model.HistoryModel;
import com.fastbee.iot.model.ThingsModelLogCountVO;
import com.fastbee.iot.model.dto.ThingsModelDTO;
import com.fastbee.iot.model.param.DataCenterParam;
import com.fastbee.iot.model.scenemodel.SceneHistoryParam;
import com.fastbee.iot.service.DataCenterService;
import com.fastbee.iot.service.IDeviceService;
import io.swagger.annotations.Api;
import io.swagger.annotations.ApiOperation;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import javax.annotation.Resource;
import java.util.Date;
import java.util.List;
import java.util.Map;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.net.URLEncoder;
import java.util.*;
import java.util.stream.Collectors;
import static com.fastbee.common.utils.SecurityUtils.getLoginUser;
@ -37,6 +42,9 @@ public class DataCenterController {
@Resource
private DataCenterService dataCenterService;
@Resource
private IDeviceService deviceService;
/**
* 查询设备物模型的历史数据
@ -122,4 +130,154 @@ public class DataCenterController {
return AjaxResult.success(list);
}
/**
* 导出设备历史数据Excel
* @param deviceHistoryParam 查询参数
* @param response HTTP响应
*/
@ApiOperation("导出设备历史数据Excel")
@PreAuthorize("@ss.hasPermi('dataCenter:history:list')")
@PostMapping("/deviceHistory/export")
public void exportDeviceHistory(@RequestBody DeviceHistoryParam deviceHistoryParam, HttpServletResponse response) throws IOException
{
final String serialNumber = deviceHistoryParam.getSerialNumber();
if (StringUtils.isEmpty(serialNumber)) {
response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
response.getWriter().write("请选择设备");
return;
}
if (StringUtils.isNotEmpty(deviceHistoryParam.getSerialNumber())) {
deviceHistoryParam.setSerialNumber(deviceHistoryParam.getSerialNumber().toUpperCase());
}
// 获取历史数据
List<JSONObject> dataList = dataCenterService.deviceHistory(deviceHistoryParam);
if (dataList == null || dataList.isEmpty()) {
response.setStatus(HttpServletResponse.SC_NO_CONTENT);
response.getWriter().write("没有数据可导出");
return;
}
// 获取变量名称映射(identifier -> modelName)
Map<String, String> identifierNameMap = new LinkedHashMap<>();
List<String> headerList = new ArrayList<>();
if (deviceHistoryParam.getIdentifierList() != null && !deviceHistoryParam.getIdentifierList().isEmpty()) {
// 从参数中获取变量列表,查询物模型获取中文名称
List<String> identifierList = deviceHistoryParam.getIdentifierList().stream()
.map(DeviceHistoryParam.IdentifierVO::getIdentifier)
.collect(Collectors.toList());
// 查询物模型获取中文名称
if (deviceHistoryParam.getDeviceId() != null) {
List<ThingsModelDTO> thingsModelList = deviceService.listThingsModel(deviceHistoryParam.getDeviceId());
Map<String, ThingsModelDTO> thingsModelMap = thingsModelList.stream()
.collect(Collectors.toMap(ThingsModelDTO::getIdentifier, t -> t, (o, n) -> n));
for (String identifier : identifierList) {
ThingsModelDTO thingsModel = thingsModelMap.get(identifier);
String headerName = thingsModel != null && StringUtils.isNotEmpty(thingsModel.getModelName())
? thingsModel.getModelName() : identifier;
identifierNameMap.put(identifier, headerName);
headerList.add(headerName);
}
} else {
// 如果没有设备ID,直接使用identifier作为表头
headerList.addAll(identifierList);
for (String identifier : identifierList) {
identifierNameMap.put(identifier, identifier);
}
}
} else if (!dataList.isEmpty()) {
// 从第一条数据中提取所有变量名
JSONObject firstData = dataList.get(0);
Set<String> identifierSet = new LinkedHashSet<>();
for (String key : firstData.keySet()) {
Object value = firstData.get(key);
if (value instanceof List) {
@SuppressWarnings("unchecked")
List<JSONObject> identifierList = (List<JSONObject>) value;
for (JSONObject identifierObj : identifierList) {
identifierSet.addAll(identifierObj.keySet());
}
}
}
// 查询物模型获取中文名称
if (deviceHistoryParam.getDeviceId() != null) {
List<ThingsModelDTO> thingsModelList = deviceService.listThingsModel(deviceHistoryParam.getDeviceId());
Map<String, ThingsModelDTO> thingsModelMap = thingsModelList.stream()
.collect(Collectors.toMap(ThingsModelDTO::getIdentifier, t -> t, (o, n) -> n));
for (String identifier : identifierSet) {
ThingsModelDTO thingsModel = thingsModelMap.get(identifier);
String headerName = thingsModel != null && StringUtils.isNotEmpty(thingsModel.getModelName())
? thingsModel.getModelName() : identifier;
identifierNameMap.put(identifier, headerName);
headerList.add(headerName);
}
} else {
// 如果没有设备ID,直接使用identifier作为表头
headerList.addAll(identifierSet);
for (String identifier : identifierSet) {
identifierNameMap.put(identifier, identifier);
}
}
}
// 构建导出数据
List<List<Object>> exportDataList = new ArrayList<>();
for (JSONObject jsonObject : dataList) {
List<Object> rowData = new ArrayList<>();
// 第一列是时间
String time = jsonObject.keySet().iterator().next();
rowData.add(time);
// 获取该时间点的数据
Object dataValue = jsonObject.get(time);
if (dataValue instanceof List) {
@SuppressWarnings("unchecked")
List<JSONObject> identifierDataList = (List<JSONObject>) dataValue;
Map<String, Object> valueMap = new HashMap<>();
for (JSONObject identifierObj : identifierDataList) {
for (Map.Entry<String, Object> entry : identifierObj.entrySet()) {
valueMap.put(entry.getKey(), entry.getValue());
}
}
// 按照identifierNameMap的顺序添加数据
for (Map.Entry<String, String> entry : identifierNameMap.entrySet()) {
String identifier = entry.getKey();
Object value = valueMap.get(identifier);
rowData.add(value != null ? value : "");
}
}
exportDataList.add(rowData);
}
// 构建表头 - EasyExcel格式:每个List<String>代表一列,List中的元素代表该列在不同层级的表头
List<List<String>> headList = new ArrayList<>();
// 第一列:时间
headList.add(Collections.singletonList("时间"));
// 其他列:每个变量一列
for (String header : headerList) {
headList.add(Collections.singletonList(header));
}
// 设置响应头
response.setContentType("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet");
response.setCharacterEncoding("utf-8");
String fileName = URLEncoder.encode(serialNumber + "_" + DateUtils.parseDateToStr(DateUtils.YYYYMMDDHHMMSS, new Date()), "UTF-8").replaceAll("\\+", "%20");
response.setHeader("Content-disposition", "attachment;filename*=utf-8''" + fileName + ".xlsx");
// 使用EasyExcel写入数据
ExcelWriter excelWriter = EasyExcel.write(response.getOutputStream())
.head(headList)
.build();
WriteSheet writeSheet = EasyExcel.writerSheet(0, "设备历史数据").build();
excelWriter.write(exportDataList, writeSheet);
excelWriter.finish();
}
}

View File

@ -0,0 +1,42 @@
package com.fastbee.iot.model;
import lombok.Data;
import java.util.LinkedHashMap;
import java.util.Map;
/**
* @author fastb
* @version 1.0
* @description: 设备历史数据导出VO(用于EasyExcel动态表头导出)
* @date 2024-12-12
*/
@Data
public class DeviceHistoryExportVO {
/**
* 时间
*/
private String time;
/**
* 动态字段存储,key为变量名称,value为变量值
* 使用LinkedHashMap保持顺序
*/
private Map<String, Object> dynamicFields = new LinkedHashMap<>();
/**
* 获取动态字段值
*/
public Object getFieldValue(String fieldName) {
return dynamicFields.get(fieldName);
}
/**
* 设置动态字段值
*/
public void setFieldValue(String fieldName, Object value) {
dynamicFields.put(fieldName, value);
}
}

17843
vue/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -44,3 +44,13 @@ export function getDataCenterCountThingsModelInvoke(query) {
params: query,
});
}
// 导出设备历史数据Excel
export function exportDeviceHistory(data) {
return request({
url: '/data/center/deviceHistory/export',
method: 'post',
data: data,
responseType: 'blob',
});
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 25 KiB

View File

@ -43,5 +43,6 @@
"dataCenter.history.384934-20": "Last day",
"dataCenter.history.384934-21": "In the past seven days",
"dataCenter.history.384934-22": "Last 30 days",
"dataCenter.history.384934-23": "total"
"dataCenter.history.384934-23": "total",
"dataCenter.history.384934-24": "Export Excel"
}

View File

@ -43,5 +43,6 @@
"dataCenter.history.384934-20": "最近一天",
"dataCenter.history.384934-21": "最近七天",
"dataCenter.history.384934-22": "最近三十天",
"dataCenter.history.384934-23": "总量"
"dataCenter.history.384934-23": "总量",
"dataCenter.history.384934-24": "导出Excel"
}

View File

@ -30,6 +30,7 @@
<el-form-item style="float: right; margin-right: 0">
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleDevQuery">{{ $t('dataCenter.history.384934-8') }}</el-button>
<el-button icon="el-icon-refresh" size="mini" @click="handleDevResetQuery">{{ $t('dataCenter.history.384934-9') }}</el-button>
<el-button type="success" icon="el-icon-download" size="mini" :disabled="devDatas.length === 0" @click="handleDevExport" v-loading="exportLoading">{{ $t('dataCenter.history.384934-24') }}</el-button>
</el-form-item>
</el-form>
@ -142,7 +143,9 @@
import moment from 'moment';
import { listDeviceShort, listThingsModel } from '@/api/iot/device.js';
import { getSceneModelList, getSceneModelDetail, getSceneModelDataList } from '@/api/scene/list.js';
import { getDataCenterDeviceHistory, getDataCenterSceneHistory } from '@/api/data/center.js';
import { getDataCenterDeviceHistory, getDataCenterSceneHistory, exportDeviceHistory } from '@/api/data/center.js';
import { saveAs } from 'file-saver';
import { blobValidate } from '@/utils/ruoyi';
export default {
name: 'dataCenterHistory',
@ -220,6 +223,8 @@ export default {
scenePageNum: 1,
scenePageSize: 10,
sceneTotal: 0,
// 导出loading
exportLoading: false,
};
},
mounted() {
@ -534,6 +539,65 @@ export default {
value: item,
}));
},
// 导出设备历史数据
async handleDevExport() {
const isField = this.areAllFields(this.devQueryParams);
if (!isField) {
this.$message.warning('请先选择设备、变量和时间范围');
return;
}
if (this.devDatas.length === 0) {
this.$message.warning('没有数据可导出');
return;
}
try {
this.exportLoading = true;
const devices = this.devDeviceList.find((item) => item.deviceId === this.devQueryParams.deviceId);
const identifierList = this.devQueryParams.identifiers.map((item) => {
const identifiers = this.devIdentifierList.find((chil) => chil.identifier === item);
return { identifier: identifiers.identifier, type: identifiers.type };
});
const params = {
deviceId: devices.deviceId,
serialNumber: devices.serialNumber,
identifierList: identifierList,
beginTime: moment(this.devQueryParams.dayDaterange[0]).format('YYYY-MM-DD HH:mm:ss'),
endTime: moment(this.devQueryParams.dayDaterange[1]).format('YYYY-MM-DD HH:mm:ss'),
};
const res = await exportDeviceHistory(params);
// 检查是否为Blob对象
if (res instanceof Blob) {
// 验证是否为有效的blob(不是错误JSON)
const clonedBlob = res.slice();
const isValidBlob = await blobValidate(clonedBlob);
if (!isValidBlob) {
// 可能是错误响应,尝试解析
try {
const text = await res.text();
const errorObj = JSON.parse(text);
this.$message.error(errorObj.msg || '导出失败');
return;
} catch (e) {
this.$message.error('导出失败,请稍后重试');
return;
}
}
// 有效的Excel文件,直接下载
const fileName = `设备历史数据_${moment().format('YYYYMMDDHHmmss')}.xlsx`;
saveAs(res, fileName);
this.$message.success('导出成功');
} else {
// 非Blob响应,可能是错误
this.$message.error('导出失败:服务器返回格式错误');
}
} catch (err) {
console.error('导出失败:', err);
this.$message.error(err.message || '导出失败');
} finally {
this.exportLoading = false;
}
},
drawSceneLine() {
// 初始化折线图
this.charts = this.$echarts.init(this.$refs.sceneLineChart);

View File

@ -1,7 +1,7 @@
<template>
<div class="instruction-parsing">
<el-row>
<el-col :span="16" class="left">
<el-col :span="20" class="left">
<div class="head">
<span style="color: #909399">{{ $t('device.instruction-parsing.830424-0') }}</span>
<span>{{ device.serialNumber }}</span>
@ -30,7 +30,18 @@
</div>
</div>
<div class="item-content">
<div class="content-value">{{ item.value }}</div>
<div class="content-value">
<!-- 显示HEX格式 -->
<div v-if="item.value" class="data-format-item">
<span class="format-label">HEX:</span>
<span class="hex-value">{{ item.value }}</span>
</div>
<!-- 显示ASCII格式 -->
<div v-if="item.valueAscii" class="data-format-item ascii-item">
<span class="format-label">ASCII:</span>
<pre class="ascii-value">{{ item.valueAscii }}</pre>
</div>
</div>
</div>
<div class="analysis-data" v-if="item.type != 0 && item.analysis">
<div class="data-col left-col">
@ -61,7 +72,7 @@
<el-button round type="primary" class="send-btn" size="mini" @click.stop="sendMessage">{{ $t('device.instruction-parsing.830424-14') }}</el-button>
</div>
</el-col>
<el-col :span="8" class="right">
<el-col :span="4" class="right">
<div class="head right-head">{{ $t('device.instruction-parsing.830424-15') }}</div>
<div class="content beautify-scroll-def">
<div v-for="(item, index) in quickParsing" :key="index" class="quick-item" @click.stop="quickClick(item)" @contextmenu.prevent="onContextmenu($event, item)">
@ -329,7 +340,7 @@
<el-input v-model="editNameForm.name" :placeholder="$t('device.instruction-parsing.830424-30')" style="width: 60%"></el-input>
</el-form-item>
<el-form-item :label="$t('device.instruction-parsing.830424-31')">
<el-input v-model="editNameForm.command" :disabled="true" style="width: 60%"></el-input>
<el-input v-model="editNameForm.command" style="width: 60%"></el-input>
</el-form-item>
</el-form>
</div>
@ -843,7 +854,8 @@ export default {
//0是下发,1是回复,2是上报
type: 2,
time: formatDate(new Date()),
value: data.sources,
value: data.sources, // HEX格式
valueAscii: data.sourcesAscii || '', // ASCII格式
loading: false,
analysis: false,
//解析的数据数组
@ -957,7 +969,7 @@ $right-btn-color: #1890ff;
}
.content {
height: 600px;
height: 700px;
width: 100%;
overflow: auto;
padding: 20px;
@ -1031,11 +1043,55 @@ $right-btn-color: #1890ff;
font-size: 14px;
line-height: 20px;
display: flex;
align-items: center;
align-items: flex-start;
.content-value {
max-width: 100%;
word-wrap: break-word;
width: 100%;
.data-format-item {
margin-bottom: 12px;
line-height: 24px;
&:last-child {
margin-bottom: 0;
}
&.ascii-item {
margin-top: 8px;
}
.format-label {
font-weight: 600;
color: #606266;
margin-right: 8px;
display: inline-block;
min-width: 60px;
vertical-align: top;
}
.hex-value {
word-break: break-all;
font-family: 'Courier New', monospace;
}
.ascii-value {
margin: 0;
padding: 8px;
background-color: #f5f7fa;
border-radius: 4px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: 'Courier New', monospace;
font-size: 13px;
line-height: 1.6;
color: #303133;
display: block;
max-width: 100%;
overflow-x: auto;
}
}
}
}