reportEngine:完善报告导出
This commit is contained in:
parent
86b85f2327
commit
ae37f21d6d
@ -7,13 +7,13 @@ networks:
|
||||
|
||||
services:
|
||||
minio:
|
||||
image: minio/minio:latest
|
||||
image: quay.io/minio/minio:latest
|
||||
container_name: minio
|
||||
restart: always
|
||||
# privileged: true 它几乎拥有与宿主机 root 相同的所有权限;MinIO 是纯用户空间的对象存储服务;无需用到特权模式,故而安全性与最小权限原则
|
||||
networks:
|
||||
network:
|
||||
ipv4_address: 177.7.0.6
|
||||
ipv4_address: 176.6.0.6
|
||||
ports:
|
||||
- "9000:9000"
|
||||
- "9001:9001"
|
||||
|
||||
@ -4,7 +4,7 @@
|
||||
>
|
||||
> **本 Skill 目标**:基于已落库的 IoT 物模型监测数据,为单个监测设备按「设备 + 时间范围」生成专业分析报告(Word `.docx`),支持历史报表下载,**保留期 1 个月**。
|
||||
>
|
||||
> **前端入口(2 处)**:① 大屏分析模式;② 数据中心分析页。两处均通过「选设备 + 选时间」即可生成并导出报表;另有历史报表列表可下载。
|
||||
> **前端入口(2 处)**:① 大屏分析模式;② 数据中心分析页。两处均通过「选设备 + 选时间」即可生成并导出分析报告;另有历史报表列表可下载。
|
||||
>
|
||||
> **存储决策**:**已确认未购买云 OSS 服务 → 统一使用自建 MinIO**(S3 协议兼容,复用现有 `fastbee-oss` 插件,不改上传链路)。
|
||||
>
|
||||
@ -43,7 +43,7 @@
|
||||
4. 结构化报告阈值:告警阈值藏在 LiteFlow `iot_scene` 脚本里,报告不可直接读 → 新增 `iot_things_threshold`。
|
||||
5. Java 端图表(JFreeChart)与 Word 模板引擎(poi-tl)。
|
||||
6. MinIO 默认存储配置。
|
||||
7. 前端两处「生成并导出报表」按钮 + 历史报表页(列表/下载/过期)。
|
||||
7. 前端两处「生成并导出分析报告」按钮 + 历史报表页(列表/下载/过期)。
|
||||
|
||||
## 1.4 前端现状
|
||||
|
||||
@ -262,7 +262,7 @@ UploadResult r = OssFactory.instance(redis)
|
||||
|
||||
## 5.1 入口一:大屏分析模式(`bigScreen/home.vue`)
|
||||
|
||||
- 在「分析条件」卡片“查询数据和曲线”旁新增按钮 **「生成并导出报表」**。
|
||||
- 在「分析条件」卡片“查询数据和曲线”旁新增按钮 **「生成并导出分析报告」**。
|
||||
- 参数直接取已有 `analysisQuery.deviceId` + `analysisQuery.timeRange`(`[startTime, endTime]`)。
|
||||
- 交互:点击 → `POST /iot/report/generate`(loading 防重复)→ 成功拿 `reportNo/fileUrl` 直接触发下载,并提示可在历史报表查看。
|
||||
- 未选设备或时间时按钮禁用。
|
||||
@ -288,7 +288,7 @@ async handleGenerateReport() {
|
||||
|
||||
## 5.2 入口二:数据中心(`dataCenter/analysis.vue`)
|
||||
|
||||
- 在筛选栏“查询/重置”旁新增 **「生成并导出报表」**。
|
||||
- 在筛选栏“查询/重置”旁新增 **「生成并导出分析报告」**。
|
||||
- 参数取已有 `queryParams.deviceId` + `queryParams.dayDaterange`。
|
||||
- 交互与 5.1 一致,复用同一 API 封装。
|
||||
|
||||
@ -402,7 +402,7 @@ CREATE TABLE report_record (
|
||||
6. **Provider**:`ReportDataProvider`(委托 `DeviceLogMapper`)、`ThingsThresholdProvider`。
|
||||
7. **渲染**:`ChartGenerator`(JFreeChart)+ `WordRenderer`(poi-tl)+ 模板 `DEVICE_CRACK_METER_V1.docx`。
|
||||
8. **落库与下载**:`report_record` + `expire_time`;MinIO 生命周期规则;下载接口(校验过期)。
|
||||
9. **前端**:大屏分析模式 + 数据中心加「生成并导出报表」;历史报表页 + 下载。
|
||||
9. **前端**:大屏分析模式 + 数据中心加「生成并导出分析报告」;历史报表页 + 下载。
|
||||
10. **闭环验证**:真实设备 + 时间范围 → 生成 → 下载 → 列表可见 → 过期处理验证。
|
||||
|
||||
---
|
||||
@ -420,7 +420,7 @@ CREATE TABLE report_record (
|
||||
[ ] report_record 落库,expire_time = +30 天
|
||||
[ ] 列表接口只返回未过期记录
|
||||
[ ] 下载接口可下载;过期返回明确提示
|
||||
[ ] 大屏分析模式与数据中心均可「生成并导出报表」
|
||||
[ ] 大屏分析模式与数据中心均可「生成并导出分析报告」
|
||||
[ ] 历史报表页面可查看/下载
|
||||
[ ] 设备 304259363251 完整闭环验证通过
|
||||
```
|
||||
|
||||
@ -47,7 +47,7 @@ public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
@Override
|
||||
public void insertFill(MetaObject metaObject) {
|
||||
log.debug("mybatisPlus插入填充--------------");
|
||||
//log.debug("mybatisPlus插入填充--------------");
|
||||
Date date = new Date();
|
||||
|
||||
fillValIfNullByName("createTime", date, metaObject, false);
|
||||
@ -59,7 +59,7 @@ public class MybatisPlusMetaObjectHandler implements MetaObjectHandler {
|
||||
|
||||
@Override
|
||||
public void updateFill(MetaObject metaObject) {
|
||||
log.debug("mybatisPlus更新填充--------------");
|
||||
//log.debug("mybatisPlus更新填充--------------");
|
||||
fillValIfNullByName("updateTime", new Date(), metaObject, true);
|
||||
fillValIfNullByName("updateBy", getUserName(), metaObject, true);
|
||||
}
|
||||
|
||||
@ -285,6 +285,17 @@ public class DeviceController extends BaseController {
|
||||
return deviceService.updateDevice(device);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新设备基准点(仅写入 baseline_device_id)
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('iot:device:edit')")
|
||||
@Log(title = "批量更新设备基准点", businessType = BusinessType.UPDATE)
|
||||
@PostMapping("/baseline/batch")
|
||||
@ApiOperation("批量更新设备基准点")
|
||||
public AjaxResult updateBaselineBatch(@RequestBody List<Device> devices) {
|
||||
return AjaxResult.success(deviceService.updateDeviceBaselineBatch(devices));
|
||||
}
|
||||
|
||||
/**
|
||||
* 重置设备状态
|
||||
*/
|
||||
|
||||
@ -79,25 +79,31 @@ public class ThingsThresholdController extends BaseController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 新增阈值
|
||||
* 批量新增阈值
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('iot:device:list')")
|
||||
@Log(title = "监测阈值配置", businessType = BusinessType.INSERT)
|
||||
@PostMapping
|
||||
@ApiOperation("新增阈值")
|
||||
public AjaxResult add(@RequestBody ThingsThreshold threshold) {
|
||||
return toAjax(thingsThresholdService.insertThreshold(threshold));
|
||||
@ApiOperation("批量新增阈值")
|
||||
public AjaxResult add(@RequestBody List<ThingsThreshold> thresholds) {
|
||||
if (thresholds == null || thresholds.isEmpty()) {
|
||||
return AjaxResult.error("新增列表不能为空");
|
||||
}
|
||||
return toAjax(thingsThresholdService.insertThresholdBatch(thresholds));
|
||||
}
|
||||
|
||||
/**
|
||||
* 修改阈值
|
||||
* 批量修改阈值
|
||||
*/
|
||||
@PreAuthorize("@ss.hasPermi('iot:device:list')")
|
||||
@Log(title = "监测阈值配置", businessType = BusinessType.UPDATE)
|
||||
@PutMapping
|
||||
@ApiOperation("修改阈值")
|
||||
public AjaxResult edit(@RequestBody ThingsThreshold threshold) {
|
||||
return toAjax(thingsThresholdService.updateThreshold(threshold));
|
||||
@ApiOperation("批量修改阈值")
|
||||
public AjaxResult edit(@RequestBody List<ThingsThreshold> thresholds) {
|
||||
if (thresholds == null || thresholds.isEmpty()) {
|
||||
return AjaxResult.error("修改列表不能为空");
|
||||
}
|
||||
return toAjax(thingsThresholdService.updateThresholdBatch(thresholds));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -19,12 +19,16 @@ import com.fastbee.report.period.ReportPeriodResolver;
|
||||
import com.fastbee.report.period.TimeWindow;
|
||||
import com.fastbee.report.record.ReportRecord;
|
||||
import com.fastbee.report.record.ReportRecordService;
|
||||
import com.fastbee.report.data.MetricData;
|
||||
import com.fastbee.report.model.Statistics;
|
||||
import com.fastbee.report.renderer.ReportRenderer;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;
|
||||
import org.springframework.stereotype.Component;
|
||||
|
||||
import javax.annotation.Resource;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.time.LocalDate;
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZoneOffset;
|
||||
@ -111,6 +115,15 @@ public class ReportAsyncExecutor {
|
||||
|
||||
ReportAnalyzer analyzer = analyzerFactory.get(context.getCategory());
|
||||
AnalysisResult analysis = analyzer.analyze(context, request);
|
||||
|
||||
// 无有效监测数据时终止生成,避免导出空白文档
|
||||
if (isNoEffectiveData(context, analysis)) {
|
||||
log.warn("报告生成中止:时间范围内无有效监测数据 reportNo={} deviceId={}",
|
||||
reportNo, request.getDeviceId());
|
||||
reportRecordService.markFailed(record, "所选时间范围内无有效监测数据,无法生成报告");
|
||||
return;
|
||||
}
|
||||
|
||||
ReportModel model = reportAssembler.assemble(context, analysis);
|
||||
byte[] docBytes = reportRenderer.render(model);
|
||||
|
||||
@ -129,6 +142,24 @@ public class ReportAsyncExecutor {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断本次报告是否无任何有效监测数据,避免导出空白文档。
|
||||
*
|
||||
* <p>判定口径:① 时序数据列表为空(设备完全未上报);② 所有物模型统计 count 均为 0
|
||||
* (上报数据均无效或未落在统计窗口内);③ 未配置任何监测物模型(无统计数据)。</p>
|
||||
*/
|
||||
private boolean isNoEffectiveData(ReportContext context, AnalysisResult analysis) {
|
||||
List<MetricData> metricDataList = context.getMetricDataList();
|
||||
if (metricDataList == null || metricDataList.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
Map<String, Statistics> statisticsMap = analysis.getStatistics();
|
||||
if (statisticsMap == null || statisticsMap.isEmpty()) {
|
||||
return true;
|
||||
}
|
||||
return statisticsMap.values().stream().noneMatch(s -> s.getCount() > 0);
|
||||
}
|
||||
|
||||
private void validate(ReportRequest request) {
|
||||
if (request == null || request.getDeviceId() == null) {
|
||||
throw new ReportException("请选择设备");
|
||||
|
||||
@ -53,4 +53,20 @@ public interface IThingsThresholdService extends IService<ThingsThreshold> {
|
||||
* @return 影响行数
|
||||
*/
|
||||
int deleteThresholdByIds(Long[] ids);
|
||||
|
||||
/**
|
||||
* 批量新增阈值
|
||||
*
|
||||
* @param thresholds 阈值列表
|
||||
* @return 影响行数
|
||||
*/
|
||||
int insertThresholdBatch(List<ThingsThreshold> thresholds);
|
||||
|
||||
/**
|
||||
* 批量修改阈值
|
||||
*
|
||||
* @param thresholds 阈值列表
|
||||
* @return 影响行数
|
||||
*/
|
||||
int updateThresholdBatch(List<ThingsThreshold> thresholds);
|
||||
}
|
||||
@ -8,6 +8,7 @@ import com.fastbee.report.mapper.ThingsThresholdMapper;
|
||||
import com.fastbee.report.service.IThingsThresholdService;
|
||||
import com.fastbee.report.threshold.ThingsThreshold;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.util.Arrays;
|
||||
import java.util.List;
|
||||
@ -63,4 +64,24 @@ public class ThingsThresholdServiceImpl extends ServiceImpl<ThingsThresholdMappe
|
||||
public int deleteThresholdByIds(Long[] ids) {
|
||||
return baseMapper.deleteBatchIds(Arrays.asList(ids));
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int insertThresholdBatch(List<ThingsThreshold> thresholds) {
|
||||
if (thresholds == null || thresholds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
boolean ok = saveBatch(thresholds);
|
||||
return ok ? thresholds.size() : 0;
|
||||
}
|
||||
|
||||
@Override
|
||||
@Transactional(rollbackFor = Exception.class)
|
||||
public int updateThresholdBatch(List<ThingsThreshold> thresholds) {
|
||||
if (thresholds == null || thresholds.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
boolean ok = updateBatchById(thresholds);
|
||||
return ok ? thresholds.size() : 0;
|
||||
}
|
||||
}
|
||||
@ -1,5 +1,7 @@
|
||||
package com.fastbee.report.threshold;
|
||||
|
||||
import com.baomidou.mybatisplus.annotation.FieldFill;
|
||||
import com.baomidou.mybatisplus.annotation.TableField;
|
||||
import com.baomidou.mybatisplus.annotation.TableName;
|
||||
import com.fasterxml.jackson.annotation.JsonFormat;
|
||||
import lombok.Data;
|
||||
@ -48,13 +50,17 @@ public class ThingsThreshold {
|
||||
|
||||
private String unit;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private String createBy;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@TableField(fill = FieldFill.INSERT)
|
||||
private Date createTime;
|
||||
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private String updateBy;
|
||||
|
||||
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
|
||||
@TableField(fill = FieldFill.INSERT_UPDATE)
|
||||
private Date updateTime;
|
||||
}
|
||||
|
||||
@ -57,4 +57,10 @@ public class SubDeviceListVO {
|
||||
|
||||
@ApiModelProperty("在线状态")
|
||||
private Integer status;
|
||||
|
||||
/**
|
||||
* 基准点设备id(iot_device.baseline_device_id):同一子产品下作为相对测量基准的设备;0/null 表示绝对量测量
|
||||
*/
|
||||
@ApiModelProperty("基准点设备id")
|
||||
private Long baselineDeviceId;
|
||||
}
|
||||
|
||||
@ -202,6 +202,15 @@ public interface IDeviceService {
|
||||
*/
|
||||
public AjaxResult updateDevice(Device device);
|
||||
|
||||
/**
|
||||
* 批量更新设备基准点(仅写入 baseline_device_id)
|
||||
* <p>同一子产品下通过单选确定一个基准点设备,组内其余设备指向该基准点;基准点设备自身写 0。</p>
|
||||
*
|
||||
* @param devices 仅包含 deviceId 与 baselineDeviceId 的设备列表
|
||||
* @return 影响行数
|
||||
*/
|
||||
public int updateDeviceBaselineBatch(List<Device> devices);
|
||||
|
||||
/**
|
||||
* 更新设备状态和定位
|
||||
*
|
||||
|
||||
@ -325,6 +325,26 @@ public class DeviceServiceImpl implements IDeviceService {
|
||||
return AjaxResult.success("修改成功", 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* 批量更新设备基准点(仅写入 baseline_device_id)
|
||||
* <p>组内其余设备指向基准点设备,基准点设备自身写 0。循环复用 updateDevice 的 baselineDeviceId 分支。</p>
|
||||
*/
|
||||
@Override
|
||||
public int updateDeviceBaselineBatch(List<Device> devices) {
|
||||
if (devices == null || devices.isEmpty()) {
|
||||
return 0;
|
||||
}
|
||||
int rows = 0;
|
||||
for (Device device : devices) {
|
||||
if (device.getDeviceId() == null) {
|
||||
continue;
|
||||
}
|
||||
device.setUpdateTime(DateUtils.getNowDate());
|
||||
rows += deviceMapper.updateDevice(device);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
@Caching(evict = {
|
||||
@CacheEvict(cacheNames = "Device", key = "'selectDeviceBySerialNumber:' + #device.getSerialNumber()"),
|
||||
// @CacheEvict(cacheNames = "Device", key = "'selectDeviceByDeviceId:' +
|
||||
@ -1024,7 +1044,10 @@ public class DeviceServiceImpl implements IDeviceService {
|
||||
}
|
||||
}
|
||||
fillShortListLatestReportTime(list, device.getEntrance());
|
||||
log.warn("当前selectDeviceShortList查询接口耗时: {}", System.currentTimeMillis() - startTime);
|
||||
long took = System.currentTimeMillis() - startTime;
|
||||
if (took > 1000L) {
|
||||
log.warn("selectDeviceShortList took: {}", took);
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
|
||||
@ -32,6 +32,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<result property="subProductId" column="sub_product_id"/>
|
||||
<result property="subProductName" column="sub_product_name"/>
|
||||
<result property="status" column="status"/>
|
||||
<result property="baselineDeviceId" column="baseline_device_id"/>
|
||||
<result property="id" column="id" />
|
||||
</resultMap>
|
||||
|
||||
@ -51,6 +52,7 @@ PUBLIC "-//mybatis.org//DTD Mapper 3.0//EN"
|
||||
<select id="selectGatewayList" parameterType="com.fastbee.iot.domain.SubGateway" resultMap="GatewaySubDeviceListResult">
|
||||
select ga.id, ga.gw_device_id, ga.sub_device_id, ga.slave_id, ga.create_time,
|
||||
d.device_name, d.serial_number, d.product_id sub_product_id, d.status,
|
||||
d.baseline_device_id,
|
||||
p.product_name as sub_product_name
|
||||
from iot_sub_gateway ga
|
||||
inner join iot_device d on ga.sub_device_id = d.device_id
|
||||
|
||||
@ -167,7 +167,7 @@ INSERT IGNORE INTO `sys_menu`
|
||||
(menu_id, menu_name, parent_id, order_num, `path`, component, query, is_frame, is_cache, menu_type, visible, status, perms, icon, create_by, create_time, update_by, update_time, remark)
|
||||
VALUES
|
||||
(3610, '历史报表', 3343, 4, 'reportHistory', 'dataCenter/reportHistory', NULL, 1, 0, 'C', '0', '0', 'iot:report:list', 'documentation', 'admin', NOW(), 'admin', NOW(), '设备监测报告历史报表'),
|
||||
(3611, '报表生成', 3610, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'iot:report:generate', '#', 'admin', NOW(), 'admin', NOW(), '生成并导出报表');
|
||||
(3611, '报表生成', 3610, 1, '', NULL, NULL, 1, 0, 'F', '0', '0', 'iot:report:generate', '#', 'admin', NOW(), 'admin', NOW(), '生成并导出分析报告');
|
||||
|
||||
INSERT IGNORE INTO `sys_menu_translate` (id, zh_cn, en_us) VALUES
|
||||
(3610, '历史报表', 'Report history'),
|
||||
|
||||
@ -168,6 +168,15 @@ export function updateDevice(data) {
|
||||
});
|
||||
}
|
||||
|
||||
// 批量更新设备基准点(仅写入 baselineDeviceId)
|
||||
export function updateDeviceBaselineBatch(data) {
|
||||
return request({
|
||||
url: '/iot/device/baseline/batch',
|
||||
method: 'post',
|
||||
data: data,
|
||||
});
|
||||
}
|
||||
|
||||
// 删除设备
|
||||
export function delDevice(deviceId) {
|
||||
return request({
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
import request from '@/utils/request';
|
||||
|
||||
// 生成并导出报表(设备 + 时间范围)
|
||||
// 生成并导出分析报告(设备 + 时间范围)
|
||||
export function generateDeviceReport(data) {
|
||||
return request({
|
||||
url: '/iot/report/generate',
|
||||
|
||||
@ -17,7 +17,7 @@ export function getThreshold(id) {
|
||||
})
|
||||
}
|
||||
|
||||
// 新增阈值
|
||||
// 新增阈值(批量,data 为 ThingsThreshold 数组)
|
||||
export function addThreshold(data) {
|
||||
return request({
|
||||
url: '/iot/threshold',
|
||||
@ -26,7 +26,7 @@ export function addThreshold(data) {
|
||||
})
|
||||
}
|
||||
|
||||
// 修改阈值
|
||||
// 修改阈值(批量,data 为 ThingsThreshold 数组)
|
||||
export function updateThreshold(data) {
|
||||
return request({
|
||||
url: '/iot/threshold',
|
||||
|
||||
@ -321,7 +321,7 @@
|
||||
>
|
||||
<i v-if="!reportLoading" class="el-icon-document"></i>
|
||||
<i v-else class="el-icon-loading"></i>
|
||||
<span>生成并导出报表</span>
|
||||
<span>生成并导出分析报告</span>
|
||||
</button>
|
||||
</div>
|
||||
</el-form>
|
||||
@ -1553,7 +1553,7 @@ export default {
|
||||
format: 'WORD',
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
if (res && res.code === 200) {
|
||||
// 使用弹框提示用户
|
||||
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
|
||||
confirmButtonText: '查看历史报表',
|
||||
|
||||
@ -38,7 +38,7 @@
|
||||
size="mini"
|
||||
:loading="reportLoading"
|
||||
@click="handleGenerateReport"
|
||||
>生成并导出报表</el-button>
|
||||
>生成并导出分析报告</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
</el-card>
|
||||
@ -333,7 +333,7 @@ export default {
|
||||
format: 'WORD',
|
||||
})
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
if (res && res.code === 200) {
|
||||
// 使用弹框提示用户
|
||||
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
|
||||
confirmButtonText: '查看历史报表',
|
||||
|
||||
@ -29,6 +29,9 @@
|
||||
<el-button type="success" icon="el-icon-download" size="mini" :disabled="!canExportNormal" @click="handleExportNormal" v-loading="exportLoading">
|
||||
{{ $t('dataCenter.history.384934-24') }}
|
||||
</el-button>
|
||||
<el-button type="warning" icon="el-icon-document" size="mini" :disabled="!canExportNormal" @click="handleExportReport" v-loading="reportLoading">
|
||||
导出分析报告
|
||||
</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@ -78,6 +81,7 @@
|
||||
<el-button type="primary" icon="el-icon-search" size="mini" @click="handleGwQuery">查询</el-button>
|
||||
<el-button icon="el-icon-refresh" size="mini" @click="handleReset">重置</el-button>
|
||||
<el-button type="success" icon="el-icon-download" size="mini" :disabled="!gwHasExportableData" @click="handleGwExport" v-loading="exportLoading">导出Excel</el-button>
|
||||
<el-button type="warning" icon="el-icon-document" size="mini" :disabled="!gwHasExportableData" @click="handleExportReport" v-loading="reportLoading">导出分析报告</el-button>
|
||||
</el-form-item>
|
||||
</el-form>
|
||||
|
||||
@ -127,6 +131,7 @@ import {
|
||||
getSubDeviceComparativeHistory,
|
||||
exportSubDeviceComparativeHistory,
|
||||
} from '@/api/data/center.js';
|
||||
import { generateDeviceReport } from '@/api/iot/report';
|
||||
import { saveAs } from 'file-saver';
|
||||
import { blobValidate } from '@/utils/ruoyi';
|
||||
|
||||
@ -289,6 +294,7 @@ export default {
|
||||
},
|
||||
loading: false,
|
||||
exportLoading: false,
|
||||
reportLoading: false,
|
||||
hasQueried: false,
|
||||
resultMode: '',
|
||||
independentYAxis: true,
|
||||
@ -459,6 +465,52 @@ export default {
|
||||
this.disposeChart();
|
||||
},
|
||||
methods: {
|
||||
handleExportReport() {
|
||||
let reportDeviceId = this.device ? this.device.deviceId : null;
|
||||
if (!reportDeviceId) {
|
||||
this.$message.warning('请先选择设备');
|
||||
return;
|
||||
}
|
||||
const range = this.isGatewayLike ? this.gwQueryParams.dayDaterange : this.queryParams.dayDaterange;
|
||||
if (!range || range.length < 2) {
|
||||
this.$message.warning('请选择时间范围');
|
||||
return;
|
||||
}
|
||||
// 网关设备需选定单个子设备后才能生成报告(网关本身不可生成)
|
||||
if (this.isGatewayLike) {
|
||||
const subIds = this.gwQueryParams.subDeviceIds;
|
||||
if (Array.isArray(subIds) && subIds.length === 1) {
|
||||
reportDeviceId = subIds[0];
|
||||
} else {
|
||||
this.$message.warning('网关设备请选择单个子设备后生成报告');
|
||||
return;
|
||||
}
|
||||
}
|
||||
this.reportLoading = true;
|
||||
const payload = {
|
||||
deviceId: reportDeviceId,
|
||||
startTime: moment(range[0]).format('YYYY-MM-DD HH:mm:ss'),
|
||||
endTime: moment(range[1]).format('YYYY-MM-DD HH:mm:ss'),
|
||||
format: 'WORD',
|
||||
};
|
||||
generateDeviceReport(payload)
|
||||
.then((res) => {
|
||||
if (res.code === 200) {
|
||||
this.$alert('报表正在后台生成中,生成完成后可在历史报表中下载', '提交成功', {
|
||||
confirmButtonText: '查看历史报表',
|
||||
type: 'success',
|
||||
callback: action => {
|
||||
if (action === 'confirm') {
|
||||
this.$router.push({ path: '/dataCenter/reportHistory' }).catch(() => {});
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
this.reportLoading = false;
|
||||
});
|
||||
},
|
||||
disposeChart() {
|
||||
if (this.chartInstance) {
|
||||
this.chartInstance.dispose();
|
||||
|
||||
@ -47,15 +47,30 @@
|
||||
<el-tag :type="statusType(scope.row.status)" size="mini">{{ statusText(scope.row.status) }}</el-tag>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="操作" width="100" align="center" fixed="right">
|
||||
<el-table-column label="操作" width="120" align="center" fixed="right">
|
||||
<template slot-scope="scope">
|
||||
<el-button
|
||||
v-if="scope.row.status === 'SUCCESS'"
|
||||
type="success"
|
||||
icon="el-icon-download"
|
||||
size="mini"
|
||||
:disabled="scope.row.status !== 'SUCCESS'"
|
||||
@click="handleDownload(scope.row)"
|
||||
>下载</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'FAILED'"
|
||||
type="danger"
|
||||
plain
|
||||
icon="el-icon-warning-outline"
|
||||
size="mini"
|
||||
@click="handleShowError(scope.row)"
|
||||
>失败原因</el-button>
|
||||
<el-button
|
||||
v-if="scope.row.status === 'CREATING'"
|
||||
plain
|
||||
icon="el-icon-refresh"
|
||||
size="mini"
|
||||
@click="getList()"
|
||||
>刷新</el-button>
|
||||
</template>
|
||||
</el-table-column>
|
||||
</el-table>
|
||||
@ -68,14 +83,40 @@
|
||||
@pagination="getList"
|
||||
/>
|
||||
</el-card>
|
||||
|
||||
<!-- 失败原因 -->
|
||||
<el-dialog title="报告生成失败原因" :visible.sync="errorVisible" width="520px" append-to-body>
|
||||
<div class="report-error-detail">
|
||||
<div class="detail-item">
|
||||
<span class="label">报告编号:</span>{{ errorRow.reportNo || '-' }}
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">设备名称:</span>{{ errorRow.deviceName || '-' }}
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">时间范围:</span>{{ errorRow.startTime || '-' }} ~ {{ errorRow.endTime || '-' }}
|
||||
</div>
|
||||
<div class="detail-item">
|
||||
<span class="label">失败原因:</span>
|
||||
</div>
|
||||
<pre class="error-message">{{ errorRow.errorMessage || '未记录失败原因,请联系管理员查看服务端日志' }}</pre>
|
||||
</div>
|
||||
<span slot="footer">
|
||||
<el-button size="mini" type="primary" @click="errorVisible = false">关闭</el-button>
|
||||
</span>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
import { saveAs } from 'file-saver';
|
||||
import moment from 'moment';
|
||||
import { listReportRecords, downloadReportRecord } from '@/api/iot/report';
|
||||
import { blobValidate } from '@/utils/ruoyi';
|
||||
|
||||
/** 报告保留天数,与后端 LogCleanTask#cleanLogs 默认保留天数一致 */
|
||||
const REPORT_RETENTION_DAYS = 15;
|
||||
|
||||
export default {
|
||||
name: 'ReportHistory',
|
||||
data() {
|
||||
@ -83,6 +124,10 @@ export default {
|
||||
loading: false,
|
||||
reportList: [],
|
||||
total: 0,
|
||||
/** 生成中记录轮询定时器 */
|
||||
pollTimer: null,
|
||||
errorVisible: false,
|
||||
errorRow: {},
|
||||
queryParams: {
|
||||
pageNum: 1,
|
||||
pageSize: 10,
|
||||
@ -107,18 +152,44 @@ export default {
|
||||
mounted() {
|
||||
this.getList();
|
||||
},
|
||||
beforeDestroy() {
|
||||
this.stopPolling();
|
||||
},
|
||||
methods: {
|
||||
getList() {
|
||||
/** silent=true 时不展示 loading,用于轮询刷新 */
|
||||
getList(silent) {
|
||||
if (!silent) {
|
||||
this.loading = true;
|
||||
}
|
||||
listReportRecords(this.queryParams)
|
||||
.then((res) => {
|
||||
this.reportList = res.rows || [];
|
||||
this.total = res.total || 0;
|
||||
})
|
||||
.finally(() => {
|
||||
if (!silent) {
|
||||
this.loading = false;
|
||||
}
|
||||
this.schedulePolling();
|
||||
});
|
||||
},
|
||||
/** 存在生成中记录时每 10s 轮询,全部转为终态后自动停止 */
|
||||
schedulePolling() {
|
||||
this.stopPolling();
|
||||
const hasCreating = this.reportList.some((item) => item.status === 'CREATING');
|
||||
if (!hasCreating) {
|
||||
return;
|
||||
}
|
||||
this.pollTimer = setTimeout(() => {
|
||||
this.getList(true);
|
||||
}, 10000);
|
||||
},
|
||||
stopPolling() {
|
||||
if (this.pollTimer) {
|
||||
clearTimeout(this.pollTimer);
|
||||
this.pollTimer = null;
|
||||
}
|
||||
},
|
||||
handleQuery() {
|
||||
this.queryParams.pageNum = 1;
|
||||
this.getList();
|
||||
@ -127,6 +198,17 @@ export default {
|
||||
this.resetForm('queryForm');
|
||||
this.handleQuery();
|
||||
},
|
||||
/** 报告无 expire_time 字段,按 create_time + 保留天数判断是否已过期 */
|
||||
isExpired(row) {
|
||||
if (!row || !row.createTime) {
|
||||
return false;
|
||||
}
|
||||
const created = moment(row.createTime, 'YYYY-MM-DD HH:mm:ss', true);
|
||||
if (!created.isValid()) {
|
||||
return false;
|
||||
}
|
||||
return moment().diff(created, 'days') >= REPORT_RETENTION_DAYS;
|
||||
},
|
||||
handleDownload(row) {
|
||||
if (this.isExpired(row)) {
|
||||
this.$message.warning('报告已过期');
|
||||
@ -148,6 +230,10 @@ export default {
|
||||
this.$message.error('下载失败,请稍后重试');
|
||||
});
|
||||
},
|
||||
handleShowError(row) {
|
||||
this.errorRow = row || {};
|
||||
this.errorVisible = true;
|
||||
},
|
||||
categoryText(code) {
|
||||
const map = {
|
||||
CRACK_METER: '裂缝计',
|
||||
@ -179,4 +265,24 @@ export default {
|
||||
.data-center-report-history .el-card {
|
||||
min-height: calc(100vh - 110px);
|
||||
}
|
||||
.report-error-detail .detail-item {
|
||||
margin-bottom: 6px;
|
||||
font-size: 13px;
|
||||
}
|
||||
.report-error-detail .label {
|
||||
color: #909399;
|
||||
}
|
||||
.report-error-detail .error-message {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
background: #f5f7fa;
|
||||
border-radius: 4px;
|
||||
color: #f56c6c;
|
||||
font-size: 13px;
|
||||
font-family: inherit;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
max-height: 240px;
|
||||
overflow: auto;
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -257,7 +257,9 @@ export default {
|
||||
this.$modal.msgError('请先保存产品信息');
|
||||
return;
|
||||
}
|
||||
const tasks = [];
|
||||
const toAdd = [];
|
||||
const toUpdate = [];
|
||||
const toDelete = [];
|
||||
this.rows.forEach((row) => {
|
||||
const payload = {
|
||||
productId: this.toNum(this.productId),
|
||||
@ -274,14 +276,18 @@ export default {
|
||||
};
|
||||
if (this.hasAnyValue(row)) {
|
||||
if (row.id) {
|
||||
tasks.push(updateThreshold(Object.assign({ id: row.id }, payload)));
|
||||
toUpdate.push(Object.assign({ id: row.id }, payload));
|
||||
} else {
|
||||
tasks.push(addThreshold(payload));
|
||||
toAdd.push(payload);
|
||||
}
|
||||
} else if (row.id) {
|
||||
tasks.push(delThreshold(row.id));
|
||||
toDelete.push(row.id);
|
||||
}
|
||||
});
|
||||
const tasks = [];
|
||||
if (toAdd.length) tasks.push(addThreshold(toAdd));
|
||||
if (toUpdate.length) tasks.push(updateThreshold(toUpdate));
|
||||
if (toDelete.length) tasks.push(delThreshold(toDelete.join(',')));
|
||||
if (!tasks.length) {
|
||||
this.$modal.msgWarning('没有需要保存的阈值');
|
||||
return;
|
||||
|
||||
@ -54,8 +54,8 @@
|
||||
</el-button>
|
||||
</el-input>
|
||||
</el-form-item>
|
||||
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量 -->
|
||||
<el-form-item v-if="form.deviceType != 2" prop="baselineDeviceId">
|
||||
<!-- 基准点设备:相对测量类设备(静力水准等)需指定基准点,留空表示绝对量测量。不做 deviceType 限制,任意子设备均可配置 -->
|
||||
<el-form-item prop="baselineDeviceId">
|
||||
<template slot="label">
|
||||
<el-tooltip placement="top">
|
||||
<div slot="content">
|
||||
@ -255,7 +255,7 @@
|
||||
|
||||
<!-- 设备阈值(优先级高于产品阈值,留空则继承产品阈值) -->
|
||||
<el-tab-pane name="deviceThreshold" :disabled="form.deviceId == 0" v-if="form.deviceType !== 3" lazy>
|
||||
<span slot="label">{{ $t('device.device-edit.148398-85') }}</span>
|
||||
<span slot="label"><span style="color: red">* </span>{{ $t('device.device-edit.148398-85') }}</span>
|
||||
<threshold-config
|
||||
ref="deviceThreshold"
|
||||
:product-id="form.productId"
|
||||
|
||||
@ -39,6 +39,13 @@
|
||||
<el-input style="width: 120px; text-align: center" :disabled="!isSet" v-model="scope.row.slaveId" size="small" :placeholder="$t('device.sub.083943-2')"></el-input>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column label="基准点" align="center" width="90px" :render-header="renderBaselineHeader">
|
||||
<template slot-scope="scope">
|
||||
<el-radio v-model="baselineMap[scope.row.subProductId]" :label="scope.row.subDeviceId" :disabled="!isSet">
|
||||
<span></span>
|
||||
</el-radio>
|
||||
</template>
|
||||
</el-table-column>
|
||||
<el-table-column :label="$t('device.sub.onlineStatus')" align="center" prop="status">
|
||||
<template slot-scope="scope">
|
||||
<dict-tag :options="dict.type.iot_device_status" :value="scope.row.status" />
|
||||
@ -69,6 +76,7 @@
|
||||
|
||||
<script>
|
||||
import { listGateway, delGateway, editGatewayBatch } from '@/api/iot/gateway';
|
||||
import { updateDeviceBaselineBatch } from '@/api/iot/device';
|
||||
|
||||
import subDeviceList from './sub-device-list';
|
||||
import subDeviceCreate from './sub-device-create';
|
||||
@ -119,6 +127,8 @@ export default {
|
||||
isSet: false,
|
||||
// 子产品行合并信息
|
||||
productSpans: [],
|
||||
// 各子产品的基准点设备id(同子产品单选),key=subProductId,value=subDeviceId
|
||||
baselineMap: {},
|
||||
};
|
||||
},
|
||||
|
||||
@ -143,8 +153,54 @@ export default {
|
||||
this.total = response.total;
|
||||
this.loading = false;
|
||||
this.computeProductSpans();
|
||||
this.computeBaselineMap();
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 由后端回查数据推导各子产品的当前基准点。
|
||||
* 约定:基准点设备 baselineDeviceId=0;同组其余设备 baselineDeviceId=基准点设备id。
|
||||
* 因此某子产品下被多数设备引用的 deviceId 即为基准点。
|
||||
*/
|
||||
computeBaselineMap() {
|
||||
const map = {};
|
||||
const list = this.deviceList || [];
|
||||
const groups = {};
|
||||
list.forEach((d) => {
|
||||
const pid = d.subProductId;
|
||||
if (pid == null) return;
|
||||
(groups[pid] = groups[pid] || []).push(d);
|
||||
});
|
||||
Object.keys(groups).forEach((pid) => {
|
||||
const refCount = {};
|
||||
groups[pid].forEach((d) => {
|
||||
const base = d.baselineDeviceId;
|
||||
if (base && base !== 0) {
|
||||
refCount[base] = (refCount[base] || 0) + 1;
|
||||
}
|
||||
});
|
||||
let baseline = null;
|
||||
let max = 0;
|
||||
Object.keys(refCount).forEach((devId) => {
|
||||
if (refCount[devId] > max) {
|
||||
max = refCount[devId];
|
||||
baseline = Number(devId);
|
||||
}
|
||||
});
|
||||
map[pid] = baseline;
|
||||
});
|
||||
this.baselineMap = map;
|
||||
},
|
||||
/** 基准点列头带说明 */
|
||||
renderBaselineHeader(h) {
|
||||
return h(
|
||||
'span',
|
||||
{},
|
||||
[
|
||||
'基准点 ',
|
||||
h('i', { class: 'el-icon-question', style: 'cursor:help;color:#909399;' }),
|
||||
]
|
||||
);
|
||||
},
|
||||
computeProductSpans() {
|
||||
const spans = [];
|
||||
const list = this.deviceList || [];
|
||||
@ -238,11 +294,39 @@ export default {
|
||||
/**保存子设备地址设置*/
|
||||
saveSetting() {
|
||||
this.isSet = !this.isSet;
|
||||
editGatewayBatch(this.deviceList).then((response) => {
|
||||
const baselinePayload = this.buildBaselinePayload();
|
||||
const tasks = [editGatewayBatch(this.deviceList)];
|
||||
if (baselinePayload.length > 0) {
|
||||
tasks.push(updateDeviceBaselineBatch(baselinePayload));
|
||||
}
|
||||
Promise.all(tasks).then(() => {
|
||||
this.getList();
|
||||
this.$modal.msgSuccess(this.$t('saveSuccess'));
|
||||
});
|
||||
},
|
||||
/**
|
||||
* 依据 baselineMap 组装基准点回写数据。
|
||||
* 同一子产品下:基准点设备 baselineDeviceId=0;其余设备 baselineDeviceId=基准点设备id。
|
||||
*/
|
||||
buildBaselinePayload() {
|
||||
const groups = {};
|
||||
(this.deviceList || []).forEach((d) => {
|
||||
const pid = d.subProductId;
|
||||
if (pid == null) return;
|
||||
(groups[pid] = groups[pid] || []).push(d);
|
||||
});
|
||||
const payload = [];
|
||||
Object.keys(groups).forEach((pid) => {
|
||||
const baseline = this.baselineMap[pid];
|
||||
groups[pid].forEach((d) => {
|
||||
payload.push({
|
||||
deviceId: d.subDeviceId,
|
||||
baselineDeviceId: d.subDeviceId === baseline ? 0 : (baseline || 0),
|
||||
});
|
||||
});
|
||||
});
|
||||
return payload;
|
||||
},
|
||||
cancelSetting() {
|
||||
this.isSet = !this.isSet;
|
||||
},
|
||||
|
||||
Loading…
Reference in New Issue
Block a user