Merge remote-tracking branch 'origin/main'

main
suixy 2 months ago
commit a8c8cd6434

@ -4,12 +4,7 @@
- 成员项目: 基于 vben5(ant-design-vue) 的前端项目 [ruoyi-plus-vben5](https://github.com/imdap/ruoyi-plus-vben5)
- 成员项目: 基于soybean 的前端项目 [ruoyi-plus-soybean](https://gitee.com/xlsea/ruoyi-plus-soybean)
## 配套后端代码仓库地址
| 介绍 | 项目名 | 项目地址 |
|------------|:-----------------|--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| 🔥 分布式集群框架 | RuoYi-Vue-Plus | - [Gitee](https://gitee.com/dromara/RuoYi-Vue-Plus)<br> - [GitHub](https://github.com/dromara/RuoYi-Vue-Plus)<br> - [GitCode](https://gitcode.com/dromara/RuoYi-Vue-Plus) |
| 🔥 微服务框架 | RuoYi-Cloud-Plus | - [Gitee](https://gitee.com/dromara/RuoYi-Cloud-Plus)<br>- [GitHub](https://github.com/dromara/RuoYi-Cloud-Plus)<br> - [GitCode](https://gitcode.com/dromara/RuoYi-Cloud-Plus) |
## 分支说明
@ -57,27 +52,3 @@ npm run build:prod
| 缓存监控 | 对系统的缓存信息查询,命令统计等。 | 支持 | 支持 |
| 在线构建器 | 拖动表单元素生成相应的HTML代码。 | 支持 | 支持 |
| 使用案例 | 系统的一些功能案例 | 支持 | 不支持 |
## 演示图例
| | |
| ---------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
| ![输入图片说明](https://foruda.gitee.com/images/1680077524361362822/270bb429_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680077619939771291/989bf9b6_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680077681751513929/1c27c5bd_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680077721559267315/74d63e23_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680077765638904515/1b75d4a6_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078026375951297/eded7a4b_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078237104531207/0eb1b6a7_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078254306078709/5931e22f_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078287971528493/0b9af60a_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078308138770249/8d3b6696_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078352553634393/db5ef880_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078378238393374/601e4357_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078414983206024/2aae27c1_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078446738419874/ecce7d59_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078475971341775/149e8634_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078491666717143/3fadece7_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078558863188826/fb8ced2a_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078574561685461/ae68a0b2_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078594932772013/9d8bfec6_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078626493093532/fcfe4ff6_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078643608812515/0295bd4f_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078685196286463/d7612c81_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078703877318597/56fce0bc_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078716586545643/b6dbd68f_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078734103217688/eb1e6aa6_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078759131415480/73c525d8_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078779416197879/75e3ed02_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078802329118061/77e10915_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078893627848351/34a1c342_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078928175016986/f126ec4a_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078941718318363/b68a0f72_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680078963175518631/3bb769a1_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680078982294090567/b31c343d_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680079000642440444/77ca82a9_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680079020995074177/03b7d52e_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680079039367822173/76811806_1766278.png '屏幕截图') |
| ![输入图片说明](https://foruda.gitee.com/images/1680079274333484664/4dfdc7c0_1766278.png '屏幕截图') | ![输入图片说明](https://foruda.gitee.com/images/1680079290467458224/d6715fcf_1766278.png '屏幕截图') |

@ -0,0 +1,60 @@
import request from '@/utils/request';
import type {
EfficiencyAnalysisVO,
EnergyAbnormalAlertReportVO,
EmsActionResponse,
InstrumentConditionReportVO,
MeterBalanceReportVO,
RecordIotenvInstantReportQuery
} from '../types';
export interface MeterBalanceReportQuery extends RecordIotenvInstantReportQuery {
rootMonitorId?: string;
aggregationType?: 'LAST' | 'AVG' | 'SUM' | string;
imbalanceThreshold?: number | string;
}
export interface EfficiencyAnalysisQuery extends RecordIotenvInstantReportQuery {
metricCodes?: string[];
}
export interface EnergyAbnormalAlertReportQuery extends RecordIotenvInstantReportQuery {
alarmStatus?: number;
pushStatus?: string;
}
// 查询仪表工况分析
export function getInstrumentConditionReport(data: RecordIotenvInstantReportQuery): Promise<EmsActionResponse<InstrumentConditionReportVO>> {
return request({
url: '/ems/report/iotAdvanced/instrumentCondition',
method: 'post',
data
});
}
// 查询计量平衡报表
export function getMeterBalanceReport(data: MeterBalanceReportQuery): Promise<EmsActionResponse<MeterBalanceReportVO>> {
return request({
url: '/ems/report/iotAdvanced/meterBalance',
method: 'post',
data
});
}
// 查询综合运行能效分析
export function getEfficiencyAnalysisReport(data: EfficiencyAnalysisQuery): Promise<EmsActionResponse<EfficiencyAnalysisVO>> {
return request({
url: '/ems/report/iotAdvanced/efficiencyAnalysis',
method: 'post',
data
});
}
// 查询用能异常报警报表
export function getEnergyAbnormalAlertReport(data: EnergyAbnormalAlertReportQuery): Promise<EmsActionResponse<EnergyAbnormalAlertReportVO>> {
return request({
url: '/ems/report/iotAdvanced/energyAbnormalAlert',
method: 'post',
data
});
}

@ -0,0 +1,334 @@
import request from '@/utils/request'
import type { AxiosPromise } from 'axios'
// ==================== TS 类型定义 ====================
/** 温度看板通用查询参数 */
export interface TempBoardQuery {
/** 开始时间 yyyy-MM-dd HH:mm:ss */
startTime?: string
/** 结束时间 yyyy-MM-dd HH:mm:ss */
endTime?: string
/** 测点ID列表 */
monitorIds?: string[]
/** 高温阈值默认35 */
highTempThreshold?: number
/** 低温阈值默认10 */
lowTempThreshold?: number
/** 温升过快阈值 ℃/分钟默认1.0 */
rapidRiseThreshold?: number
/** 温度抖动标准差阈值默认2.0 */
stddevThreshold?: number
/** 采样间隔异常阈值 秒默认300 */
gapThresholdSeconds?: number
/** 长时间未更新阈值 秒默认600 */
staleThresholdSeconds?: number
/** TopN 数量默认10 */
topN?: number
/** 单测点趋势查询 */
monitorId?: string
/** 预期采样数(完整率计算用) */
expectedSampleCount?: number
}
/** 测点温度排行项 */
export interface MonitorTempRank {
monitorId: string
monitorName: string
temperature: number
collectTime: string
}
/** 数据新鲜度项 */
export interface FreshnessItem {
monitorId: string
monitorName: string
temperature: number
collectTime: string
ageSeconds: number
}
/** 温度总览 VO */
export interface TempBoardOverviewVO {
monitorCount: number
avgLatestTemp: number
maxLatestTemp: number
maxTempMonitorId: string
minLatestTemp: number
minTempMonitorId: string
highTempTopN: MonitorTempRank[]
lowTempTopN: MonitorTempRank[]
freshnessList: FreshnessItem[]
}
/** 实时监控 VO */
export interface TempBoardRealtimeVO {
monitorId: string
monitorName: string
temperature: number
collectTime: string
recodeTime: string
delaySeconds: number
staleSeconds: number
}
/** 趋势分析 VO */
export interface TempBoardTrendVO {
statTime: string
monitorId: string
monitorName: string
avgTemp: number
maxTemp: number
minTemp: number
changeRate: number
sampleCount: number
peakTemp: number
peakTime: string
valleyTemp: number
valleyTime: string
prevTemp: number
prevTime: string
}
/** 分布分析 VO */
export interface TempBoardDistributionVO {
tempBucket: string
tempBin: number
sampleCount: number
statDate: string
statHour: number
avgTemp: number
monitorId: string
monitorName: string
temperature: number
}
/** 异常预警 VO */
export interface TempBoardAnomalyVO {
monitorId: string
monitorName: string
temperature: number
collectTime: string
anomalyType: string
risePerMin: number
tempStddev: number
startTime: string
endTime: string
maxTemp: number
sampleCount: number
prevTemp: number
prevTime: string
statTime: string
}
/** 对比分析 VO */
export interface TempBoardComparisonVO {
monitorId: string
monitorName: string
avgTemp: number
maxTemp: number
minTemp: number
tempRange: number
tempStddev: number
statDate: string
todayAvg: number
yesterdayAvg: number
diffValue: number
}
/** 数据质量 VO */
export interface TempBoardQualityVO {
monitorId: string
monitorName: string
delayBucket: string
sampleCount: number
collectTime: string
recodeTime: string
delaySeconds: number
prevTime: string
gapSeconds: number
actualCount: number
expectedCount: number
completenessRate: number
firstTime: string
lastTime: string
temperature: number
}
/** 高级分析 VO */
export interface TempBoardAdvancedVO {
fromNode: string
toNode: string
flowCount: number
statTime: string
monitorId: string
monitorName: string
avgTemp: number
sampleCount: number
tempBucket: string
maxTemp: number
minTemp: number
tempStddev: number
avgDelay: number
}
// ==================== API 函数 ====================
const BASE_URL = '/ems/report/tempBoard'
// --- A. 温度总览 ---
/** 温度总览 */
export const getTempOverview = (query: TempBoardQuery): AxiosPromise<TempBoardOverviewVO> =>
request({ url: `${BASE_URL}/overview`, method: 'get', params: query })
// --- B. 实时监控 ---
/** 实时温度明细 */
export const getRealtimeDetail = (query: TempBoardQuery): AxiosPromise<TempBoardRealtimeVO[]> =>
request({ url: `${BASE_URL}/realtime/detail`, method: 'get', params: query })
/** 高温测点 */
export const getHighTempMonitors = (query: TempBoardQuery): AxiosPromise<TempBoardRealtimeVO[]> =>
request({ url: `${BASE_URL}/realtime/highTemp`, method: 'get', params: query })
/** 低温测点 */
export const getLowTempMonitors = (query: TempBoardQuery): AxiosPromise<TempBoardRealtimeVO[]> =>
request({ url: `${BASE_URL}/realtime/lowTemp`, method: 'get', params: query })
/** 长时间未更新测点 */
export const getStaleMonitors = (query: TempBoardQuery): AxiosPromise<TempBoardRealtimeVO[]> =>
request({ url: `${BASE_URL}/realtime/stale`, method: 'get', params: query })
/** 入库延迟排行 */
export const getDelayRanking = (query: TempBoardQuery): AxiosPromise<TempBoardRealtimeVO[]> =>
request({ url: `${BASE_URL}/realtime/delay`, method: 'get', params: query })
// --- C. 趋势分析 ---
/** 单测点分钟趋势 */
export const getMinuteTrend = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/minute`, method: 'get', params: query })
/** 单测点小时趋势 */
export const getHourlyTrend = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/hourly`, method: 'get', params: query })
/** 多测点对比趋势 */
export const getMultiCompareTrend = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/multiCompare`, method: 'get', params: query })
/** 日均温趋势 */
export const getDailyTrend = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/daily`, method: 'get', params: query })
/** 温度变化率趋势 */
export const getChangeRateTrend = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/changeRate`, method: 'get', params: query })
/** 峰谷时刻表 */
export const getPeakValley = (query: TempBoardQuery): AxiosPromise<TempBoardTrendVO[]> =>
request({ url: `${BASE_URL}/trend/peakValley`, method: 'get', params: query })
// --- D. 分布分析 ---
/** 温度区间分布 */
export const getIntervalDistribution = (query: TempBoardQuery): AxiosPromise<TempBoardDistributionVO[]> =>
request({ url: `${BASE_URL}/distribution/interval`, method: 'get', params: query })
/** 温度直方图 */
export const getHistogram = (query: TempBoardQuery): AxiosPromise<TempBoardDistributionVO[]> =>
request({ url: `${BASE_URL}/distribution/histogram`, method: 'get', params: query })
/** 温度箱线图数据 */
export const getBoxplotData = (query: TempBoardQuery): AxiosPromise<TempBoardDistributionVO[]> =>
request({ url: `${BASE_URL}/distribution/boxplot`, method: 'get', params: query })
/** 日历热力图 */
export const getCalendarHeatmap = (query: TempBoardQuery): AxiosPromise<TempBoardDistributionVO[]> =>
request({ url: `${BASE_URL}/distribution/calendarHeatmap`, method: 'get', params: query })
/** 小时热力图 */
export const getHourlyHeatmap = (query: TempBoardQuery): AxiosPromise<TempBoardDistributionVO[]> =>
request({ url: `${BASE_URL}/distribution/hourlyHeatmap`, method: 'get', params: query })
// --- E. 异常预警 ---
/** 高温事件 */
export const getHighTempEvents = (query: TempBoardQuery): AxiosPromise<TempBoardAnomalyVO[]> =>
request({ url: `${BASE_URL}/anomaly/highTemp`, method: 'get', params: query })
/** 低温事件 */
export const getLowTempEvents = (query: TempBoardQuery): AxiosPromise<TempBoardAnomalyVO[]> =>
request({ url: `${BASE_URL}/anomaly/lowTemp`, method: 'get', params: query })
/** 连续高温时段 */
export const getContinuousHighTemp = (query: TempBoardQuery): AxiosPromise<TempBoardAnomalyVO[]> =>
request({ url: `${BASE_URL}/anomaly/continuousHighTemp`, method: 'get', params: query })
/** 温升过快事件 */
export const getRapidRiseEvents = (query: TempBoardQuery): AxiosPromise<TempBoardAnomalyVO[]> =>
request({ url: `${BASE_URL}/anomaly/rapidRise`, method: 'get', params: query })
/** 温度抖动异常 */
export const getJitterAnomalies = (query: TempBoardQuery): AxiosPromise<TempBoardAnomalyVO[]> =>
request({ url: `${BASE_URL}/anomaly/jitter`, method: 'get', params: query })
// --- F. 对比分析 ---
/** 测点平均温度排行 */
export const getAvgTempRanking = (query: TempBoardQuery): AxiosPromise<TempBoardComparisonVO[]> =>
request({ url: `${BASE_URL}/comparison/avgRanking`, method: 'get', params: query })
/** 测点稳定性排行 */
export const getStabilityRanking = (query: TempBoardQuery): AxiosPromise<TempBoardComparisonVO[]> =>
request({ url: `${BASE_URL}/comparison/stabilityRanking`, method: 'get', params: query })
/** 今日vs昨日对比 */
export const getDailyDiff = (query: TempBoardQuery): AxiosPromise<TempBoardComparisonVO[]> =>
request({ url: `${BASE_URL}/comparison/dailyDiff`, method: 'get', params: query })
/** 峰值对比 */
export const getPeakCompare = (query: TempBoardQuery): AxiosPromise<TempBoardComparisonVO[]> =>
request({ url: `${BASE_URL}/comparison/peak`, method: 'get', params: query })
/** 波动幅度对比 */
export const getFluctuationCompare = (query: TempBoardQuery): AxiosPromise<TempBoardComparisonVO[]> =>
request({ url: `${BASE_URL}/comparison/fluctuation`, method: 'get', params: query })
// --- G. 数据质量 ---
/** 入库延迟分布 */
export const getDelayDistribution = (query: TempBoardQuery): AxiosPromise<TempBoardQualityVO[]> =>
request({ url: `${BASE_URL}/quality/delayDistribution`, method: 'get', params: query })
/** 时间逆序可疑数据 */
export const getTimeReversal = (query: TempBoardQuery): AxiosPromise<TempBoardQualityVO[]> =>
request({ url: `${BASE_URL}/quality/timeReversal`, method: 'get', params: query })
/** 采样间隔异常 */
export const getSamplingGapAnomalies = (query: TempBoardQuery): AxiosPromise<TempBoardQualityVO[]> =>
request({ url: `${BASE_URL}/quality/samplingGap`, method: 'get', params: query })
/** 数据完整率 */
export const getCompletenessRate = (query: TempBoardQuery): AxiosPromise<TempBoardQualityVO[]> =>
request({ url: `${BASE_URL}/quality/completeness`, method: 'get', params: query })
/** 测点活跃度 */
export const getMonitorActivity = (query: TempBoardQuery): AxiosPromise<TempBoardQualityVO[]> =>
request({ url: `${BASE_URL}/quality/activity`, method: 'get', params: query })
// --- H. 高级分析 ---
/** 桑基图数据 */
export const getSankeyData = (query: TempBoardQuery): AxiosPromise<TempBoardAdvancedVO[]> =>
request({ url: `${BASE_URL}/advanced/sankey`, method: 'get', params: query })
/** 主题河流图数据 */
export const getThemeRiverData = (query: TempBoardQuery): AxiosPromise<TempBoardAdvancedVO[]> =>
request({ url: `${BASE_URL}/advanced/themeRiver`, method: 'get', params: query })
/** 矩形树图数据 */
export const getTreemapData = (query: TempBoardQuery): AxiosPromise<TempBoardAdvancedVO[]> =>
request({ url: `${BASE_URL}/advanced/treemap`, method: 'get', params: query })
/** 旭日图数据 */
export const getSunburstData = (query: TempBoardQuery): AxiosPromise<TempBoardAdvancedVO[]> =>
request({ url: `${BASE_URL}/advanced/sunburst`, method: 'get', params: query })
/** 平行坐标图数据 */
export const getParallelData = (query: TempBoardQuery): AxiosPromise<TempBoardAdvancedVO[]> =>
request({ url: `${BASE_URL}/advanced/parallel`, method: 'get', params: query })

@ -4,6 +4,7 @@ export interface OperLogQuery extends PageQuery {
operName: string;
businessType: string;
status: string;
operRemark: string;
orderByColumn: string;
isAsc: string;
}
@ -28,6 +29,7 @@ export interface OperLogVO extends BaseEntity {
errorMsg: string;
operTime: string;
costTime: number;
operRemark: string;
}
export interface OperLogForm {
@ -50,4 +52,5 @@ export interface OperLogForm {
errorMsg: string;
operTime: string;
costTime: number;
operRemark: string;
}

@ -37,7 +37,7 @@
<el-col :span="1.5">
<el-button type="info" plain icon="el-icon-sort" size="mini" @click="toggleExpandAll">/</el-button>
</el-col>
<right-toolbar v-model:show-search="showSearch" @query-table="getList" :columns="columns"></right-toolbar>
<right-toolbar v-model:show-search="showSearch" @query-table="getList"></right-toolbar>
</el-row>
<el-table
@ -47,44 +47,31 @@
row-key="objId"
:default-expand-all="isExpandAll"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
border
stripe
>
<el-table-column label="父级编号" prop="parentId" v-if="columns[1].visible" />
<el-table-column label="计量设备编号" align="center" prop="monitorCode" v-if="columns[2].visible" />
<el-table-column label="计量设备名称" align="center" prop="monitorName" v-if="columns[3].visible" />
<el-table-column label="计量设备位置" align="center" prop="monitorAddr" v-if="columns[4].visible" />
<el-table-column label="采集设备名称" align="center" prop="collectDeviceName" v-if="columns[7].visible" />
<el-table-column label="等级" align="center" prop="grade" v-if="columns[9].visible" />
<el-table-column label="传感器仪表" align="center" prop="meterTypeId" v-if="columns[10].visible" />
<el-table-column label="修正值" align="center" prop="correctValue" v-if="columns[11].visible" />
<el-table-column label="PT值" align="center" prop="pt" v-if="columns[12].visible" width="60" />
<el-table-column label="CT值" align="center" prop="ct" v-if="columns[13].visible" width="60" />
<el-table-column label="是否虚拟" align="center" prop="isAmmeter" v-if="columns[14].visible" width="100">
<!-- 设备名称放第一列树形展开箭头会自动出现在此列 -->
<el-table-column label="计量设备名称" prop="monitorName" min-width="200" show-overflow-tooltip>
<template #default="scope">
<span>{{ scope.row.monitorName }}</span>
</template>
</el-table-column>
<el-table-column label="设备编号" align="center" prop="monitorCode" min-width="130" show-overflow-tooltip />
<el-table-column label="设备位置" align="center" prop="monitorAddr" min-width="120" show-overflow-tooltip />
<el-table-column label="采集设备" align="center" prop="collectDeviceName" min-width="120" show-overflow-tooltip />
<el-table-column label="PT值" align="center" prop="pt" width="70" />
<el-table-column label="CT值" align="center" prop="ct" width="70" />
<el-table-column label="是否虚拟" align="center" prop="isAmmeter" width="90">
<template #default="scope">
<dict-tag :options="dict.type.is_ammeter" :value="scope.row.isAmmeter" />
</template>
</el-table-column>
<el-table-column label="通断复位" align="center" prop="isKeyMonitor" v-if="columns[15].visible" />
<el-table-column label="是否断路" align="center" prop="isCircuit" v-if="columns[16].visible" />
<el-table-column label="计量设备状态" align="center" prop="monitorStatus" v-if="columns[6].visible" width="100">
<el-table-column label="设备状态" align="center" prop="monitorStatus" width="100">
<template #default="scope">
<dict-tag :options="dict.type.monitor_status" :value="scope.row.monitorStatus" />
</template>
</el-table-column>
<el-table-column label="创建人" align="center" prop="createBy" v-if="columns[17].visible" />
<el-table-column label="创建时间" align="center" prop="createTime" width="180" v-if="columns[18].visible">
<template #default="scope">
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="更新人" align="center" prop="updateBy" v-if="columns[19].visible" />
<el-table-column label="更新时间" align="center" prop="updateTime" width="180" v-if="columns[20].visible">
<template #default="scope">
<span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d}') }}</span>
</template>
</el-table-column>
<el-table-column label="公摊表类型" align="center" prop="publicShareType" v-if="columns[21].visible" />
<el-table-column label="表具层级" align="center" prop="monitorHierarchy" v-if="columns[22].visible" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="180" fixed="right">
<template #default="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['ems/base:baseMonitorInfo:edit']"
>修改</el-button
@ -100,67 +87,78 @@
</el-table>
<!-- 添加或修改计量设备信息对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px">
<el-form-item label="父级编号" prop="parentId">
<EmsTreeSelect v-model="form.parentId" :options="baseMonitorInfoOptions" :normalizer="normalizer" placeholder="请选择父级编号" />
</el-form-item>
<el-form-item label="计量设备编号" prop="monitorCode">
<el-input v-model="form.monitorCode" placeholder="请输入计量设备编号" />
</el-form-item>
<el-form-item label="计量设备名称" prop="monitorName">
<el-input v-model="form.monitorName" placeholder="请输入计量设备名称" />
</el-form-item>
<el-form-item label="计量设备位置" prop="monitorAddr">
<el-input v-model="form.monitorAddr" placeholder="请输入计量设备位置" />
</el-form-item>
<el-form-item label="计量设备状态" prop="monitorStatus">
<el-radio-group v-model="form.monitorStatus">
<el-radio v-for="dict in dict.type.monitor_status" :key="dict.value" :label="parseInt(dict.value)">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="采集设备" prop="collectDeviceId">
<el-select v-model="form.collectDeviceId" placeholder="请选择采集设备">
<el-option
v-for="item in collectDeviceList"
:key="item.collectDeviceId"
:label="item.collectDeviceName"
:value="item.collectDeviceId"
></el-option>
</el-select>
</el-form-item>
<!-- <el-form-item label="等级" prop="grade">-->
<!-- <el-input v-model="form.grade" placeholder="请输入等级" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="传感器仪表" prop="meterTypeId">-->
<!-- <el-input v-model="form.meterTypeId" placeholder="请输入传感器仪表" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="修正值" prop="correctValue">-->
<!-- <el-input v-model="form.correctValue" placeholder="请输入修正值" />-->
<!-- </el-form-item>-->
<el-form-item label="PT值" prop="pt">
<el-input-number v-model="form.pt" placeholder="请输入PT值" />
</el-form-item>
<el-form-item label="CT值" prop="ct">
<el-input-number v-model="form.ct" placeholder="请输入CT值" />
</el-form-item>
<el-form-item label="是否虚拟" prop="isAmmeter">
<el-radio-group v-model="form.isAmmeter">
<el-radio v-for="dict in dict.type.is_ammeter" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<!-- <el-form-item label="通断复位" prop="isKeyMonitor">-->
<!-- <el-input v-model="form.isKeyMonitor" placeholder="请输入通断复位" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="是否断路" prop="isCircuit">-->
<!-- <el-input v-model="form.isCircuit" placeholder="请输入是否断路" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="公摊表类型" prop="publicShareType">-->
<!-- <el-input v-model="form.publicShareType" placeholder="请输入公摊表类型" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="表具层级" prop="monitorHierarchy">-->
<!-- <el-input v-model="form.monitorHierarchy" placeholder="请输入表具层级" />-->
<!-- </el-form-item>-->
<el-dialog :title="title" v-model="open" width="960px" append-to-body>
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="上级节点" prop="parentId">
<EmsTreeSelect v-model="form.parentId" :options="baseMonitorInfoOptions" :normalizer="normalizer" placeholder="请选择上级节点" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备编号" prop="monitorCode">
<el-input v-model="form.monitorCode" placeholder="请输入计量设备编号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="设备名称" prop="monitorName">
<el-input v-model="form.monitorName" placeholder="请输入计量设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备位置" prop="monitorAddr">
<el-input v-model="form.monitorAddr" placeholder="请输入计量设备位置" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 设备配置 ===== -->
<el-divider content-position="left">设备配置</el-divider>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="采集设备" prop="collectDeviceId">
<el-select v-model="form.collectDeviceId" placeholder="请选择采集设备" style="width: 100%">
<el-option
v-for="item in collectDeviceList"
:key="item.collectDeviceId"
:label="item.collectDeviceName"
:value="item.collectDeviceId"
/>
</el-select>
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备状态" prop="monitorStatus">
<el-radio-group v-model="form.monitorStatus">
<el-radio v-for="dict in dict.type.monitor_status" :key="dict.value" :label="parseInt(dict.value)">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<!-- ===== 电气参数 ===== -->
<el-divider content-position="left">电气参数</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="PT值" prop="pt">
<el-input-number v-model="form.pt" placeholder="PT" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="CT值" prop="ct">
<el-input-number v-model="form.ct" placeholder="CT" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否虚拟" prop="isAmmeter">
<el-radio-group v-model="form.isAmmeter">
<el-radio v-for="dict in dict.type.is_ammeter" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -182,7 +180,6 @@ import {
updateBaseMonitorInfo
} from '@/api/ems/base/baseMonitorInfo';
import EmsTreeSelect from '@/views/ems/components/EmsTreeSelect.vue';
import { getBaseEnergyTypeList } from '@/api/ems/base/baseEnergyType';
import { getCollectDeviceInfo } from '@/api/ems/base/baseCollectDeviceInfo';
import { handleTree } from '@/utils/ruoyi';
@ -345,124 +342,7 @@ const state = reactive({
}
]
},
columns: [
{
key: 0,
label: `自增标识`,
visible: false
},
{
key: 1,
label: `父级编号`,
visible: false
},
{
key: 2,
label: `计量设备编号`,
visible: true
},
{
key: 3,
label: `计量设备名称`,
visible: true
},
{
key: 4,
label: `计量设备位置`,
visible: true
},
{
key: 5,
label: `计量设备类型`,
visible: true
},
{
key: 6,
label: `计量设备状态`,
visible: true
},
{
key: 7,
label: `采集设备名称`,
visible: true
},
{
key: 8,
label: `祖级列表`,
visible: true
},
{
key: 9,
label: `等级`,
visible: false
},
{
key: 10,
label: `传感器仪表`,
visible: false
},
{
key: 11,
label: `修正值`,
visible: false
},
{
key: 12,
label: `PT值`,
visible: true
},
{
key: 13,
label: `CT值`,
visible: true
},
{
key: 14,
label: `是否虚拟`,
visible: true
},
{
key: 15,
label: `通断复位`,
visible: false
},
{
key: 16,
label: `是否断路`,
visible: false
},
{
key: 17,
label: `创建人`,
visible: false
},
{
key: 18,
label: `创建时间`,
visible: false
},
{
key: 19,
label: `更新人`,
visible: false
},
{
key: 20,
label: `更新时间`,
visible: false
},
{
key: 21,
label: `公摊表类型`,
visible: false
},
{
key: 22,
label: `表具层级`,
visible: false
}
],
//
//
collectDeviceList: []
} as any);
@ -470,7 +350,6 @@ const {
baseMonitorInfoList,
baseMonitorInfoOptions,
collectDeviceList,
columns,
form,
isExpandAll,
loading,

@ -85,7 +85,7 @@
<el-col :span="1.5">
<el-button type="info" plain icon="el-icon-sort" size="mini" @click="toggleExpandAll">/</el-button>
</el-col>
<right-toolbar v-model:show-search="showSearch" @query-table="getList" :columns="columns"></right-toolbar>
<right-toolbar v-model:show-search="showSearch" @query-table="getList"></right-toolbar>
</el-row>
<el-table
@ -96,57 +96,33 @@
:default-expand-all="isExpandAll"
:tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
class="data-table"
border
stripe
>
<el-table-column label="序号" type="index" width="60" align="center" v-if="columns[0].visible" />
<el-table-column label="计量设备编号" align="center" prop="monitorCode" v-if="columns[1].visible" />
<el-table-column label="计量设备名称" align="center" prop="monitorName" v-if="columns[2].visible" />
<!-- <el-table-column label="能源类型" align="center" prop="monitorType" v-if="columns[4].visible">
<template #default="scope">
{{ dict.type.monitor_type.find(e=>e.value == scope.row.monitorType).label}}
</template>
</el-table-column> -->
<el-table-column label="能源类型" align="center" prop="energyName" v-if="columns[3].visible"> </el-table-column>
<el-table-column label="计量设备位置" align="center" prop="monitorAddr" v-if="columns[4].visible" />
<el-table-column label="计量设备状态" align="center" prop="monitorStatus" v-if="columns[5].visible">
<template #default="scope">
<dict-tag :options="dict.type.monitor_status" :value="scope.row.monitorStatus" />
</template>
</el-table-column>
<el-table-column label="采集设备名称" align="center" prop="collectDeviceName" v-if="columns[6].visible" />
<el-table-column label="点位类型" align="center" prop="pointType" v-if="columns[19].visible" />
<el-table-column label="单位" align="center" prop="unitName" v-if="columns[20].visible" />
<el-table-column label="安装位置" align="center" prop="locationName" v-if="columns[21].visible" />
<el-table-column label="传感器类型" align="center" prop="sensorType" v-if="columns[22].visible" />
<el-table-column label="启用报警" align="center" prop="alarmEnable" v-if="columns[23].visible">
<template #default="scope">
<dict-tag :options="dict.type.is_flag" :value="scope.row.alarmEnable" />
</template>
</el-table-column>
<el-table-column label="等级" align="center" prop="grade" v-if="columns[7].visible" />
<el-table-column label="传感器仪表" align="center" prop="meterTypeId" v-if="columns[8].visible" />
<el-table-column label="修正值" align="center" prop="correctValue" v-if="columns[9].visible" />
<el-table-column label="是否虚拟" align="center" prop="isAmmeter" v-if="columns[10].visible">
<!-- 设备名称放第一列树形展开箭头会自动出现在此列 -->
<el-table-column label="设备名称" prop="monitorName" min-width="200" show-overflow-tooltip />
<el-table-column label="设备编号" align="center" prop="monitorCode" min-width="120" show-overflow-tooltip />
<el-table-column label="能源类型" align="center" prop="energyName" min-width="90" />
<el-table-column label="点位类型" align="center" prop="pointType" min-width="90" />
<el-table-column label="单位" align="center" prop="unitName" width="70" />
<el-table-column label="安装位置" align="center" prop="locationName" min-width="110" show-overflow-tooltip />
<el-table-column label="采集设备" align="center" prop="collectDeviceName" min-width="110" show-overflow-tooltip />
<el-table-column label="是否虚拟" align="center" prop="isAmmeter" width="80">
<template #default="scope">
<dict-tag :options="dict.type.is_ammeter" :value="scope.row.isAmmeter" />
</template>
</el-table-column>
<el-table-column label="通断复位" align="center" prop="isKeyMonitor" v-if="columns[11].visible" />
<el-table-column label="是否断路" align="center" prop="isCircuit" v-if="columns[12].visible" />
<el-table-column label="创建人" align="center" prop="createBy" v-if="columns[13].visible" />
<el-table-column label="创建时间" align="center" prop="createTime" width="180" v-if="columns[14].visible">
<el-table-column label="设备状态" align="center" prop="monitorStatus" width="90">
<template #default="scope">
<span>{{ parseTime(scope.row.createTime, '{y}-{m}-{d}') }}</span>
<dict-tag :options="dict.type.monitor_status" :value="scope.row.monitorStatus" />
</template>
</el-table-column>
<el-table-column label="更新人" align="center" prop="updateBy" v-if="columns[15].visible" />
<el-table-column label="更新时间" align="center" prop="updateTime" width="180" v-if="columns[16].visible">
<el-table-column label="报警" align="center" prop="alarmEnable" width="70">
<template #default="scope">
<span>{{ parseTime(scope.row.updateTime, '{y}-{m}-{d}') }}</span>
<dict-tag :options="dict.type.is_flag" :value="scope.row.alarmEnable" />
</template>
</el-table-column>
<el-table-column label="公摊表类型" align="center" prop="publicShareType" v-if="columns[17].visible" />
<el-table-column label="表具层级" align="center" prop="monitorHierarchy" v-if="columns[18].visible" />
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="240">
<el-table-column label="操作" align="center" class-name="small-padding fixed-width" width="240" fixed="right">
<template #default="scope">
<el-button size="mini" type="text" icon="el-icon-edit" @click="handleUpdate(scope.row)" v-hasPermi="['ems/base:baseMonitorInfo:edit']"
>修改</el-button
@ -172,116 +148,158 @@
</section>
<!-- 添加或修改计量设备信息对话框 -->
<el-dialog :title="title" v-model="open" width="500px" append-to-body class="themed-dialog">
<div class="dialog-tip">
<span class="tip-dot"></span>
设备档案字段较多这里按业务主数据录入隐藏技术字段继续保留在提交链路中避免影响 EMS 现有流程
</div>
<el-form ref="formRef" :model="form" :rules="rules" label-width="120px" class="dialog-form">
<el-form-item label="父级编号" prop="parentId">
<EmsTreeSelect v-model="form.parentId" :options="baseMonitorInfoOptions" :normalizer="normalizer" placeholder="请选择父级编号" />
</el-form-item>
<el-form-item label="计量设备编号" prop="monitorCode">
<el-input v-model="form.monitorCode" placeholder="请输入计量设备编号" />
</el-form-item>
<el-form-item label="计量设备名称" prop="monitorName">
<el-input v-model="form.monitorName" placeholder="请输入计量设备名称" />
</el-form-item>
<el-form-item label="能源类型" prop="monitorType">
<el-select v-model="form.monitorType" placeholder="请选择能源类型">
<el-option v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.energyName" :value="item.energyTypeId"></el-option>
</el-select>
</el-form-item>
<el-form-item label="计量设备位置" prop="monitorAddr">
<el-input v-model="form.monitorAddr" placeholder="请输入计量设备位置" />
</el-form-item>
<el-form-item label="计量设备状态" prop="monitorStatus">
<el-radio-group v-model="form.monitorStatus">
<el-radio v-for="dict in dict.type.monitor_status" :key="dict.value" :label="parseInt(dict.value)">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="采集设备" prop="collectDeviceId">
<el-select v-model="form.collectDeviceId" placeholder="请选择采集设备">
<el-option
v-for="item in collectDeviceList"
:key="item.collectDeviceId"
:label="item.collectDeviceName"
:value="item.collectDeviceId"
></el-option>
</el-select>
</el-form-item>
<el-form-item label="点位类型" prop="pointType">
<el-input v-model="form.pointType" placeholder="请输入点位类型" />
</el-form-item>
<el-form-item label="单位" prop="unitName">
<el-input v-model="form.unitName" placeholder="请输入单位" />
</el-form-item>
<el-form-item label="设备序列号" prop="deviceSn">
<el-input v-model="form.deviceSn" placeholder="请输入设备序列号" />
</el-form-item>
<el-form-item label="安装位置" prop="locationName">
<el-input v-model="form.locationName" placeholder="请输入安装位置" />
</el-form-item>
<el-form-item label="所属柜体" prop="cabinetName">
<el-input v-model="form.cabinetName" placeholder="请输入所属柜体" />
</el-form-item>
<el-form-item label="所属回路" prop="circuitName">
<el-input v-model="form.circuitName" placeholder="请输入所属回路" />
</el-form-item>
<el-form-item label="相别" prop="phaseNo">
<el-input v-model="form.phaseNo" placeholder="请输入相别" />
</el-form-item>
<el-form-item label="传感器类型" prop="sensorType">
<el-input v-model="form.sensorType" placeholder="请输入传感器类型" />
</el-form-item>
<el-form-item label="测量精度" prop="measurePrecision">
<el-input-number v-model="form.measurePrecision" placeholder="请输入测量精度" :precision="4" />
</el-form-item>
<el-form-item label="量程下限" prop="rangeMin">
<el-input-number v-model="form.rangeMin" placeholder="请输入量程下限" :precision="4" />
</el-form-item>
<el-form-item label="量程上限" prop="rangeMax">
<el-input-number v-model="form.rangeMax" placeholder="请输入量程上限" :precision="4" />
</el-form-item>
<el-form-item label="启用报警" prop="alarmEnable">
<el-radio-group v-model="form.alarmEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="启用曲线" prop="curveEnable">
<el-radio-group v-model="form.curveEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<el-form-item label="展示排序" prop="displaySort">
<el-input-number v-model="form.displaySort" placeholder="请输入展示排序" :precision="0" />
</el-form-item>
<!-- <el-form-item label="等级" prop="grade">-->
<!-- <el-input v-model="form.grade" placeholder="请输入等级" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="传感器仪表" prop="meterTypeId">-->
<!-- <el-input v-model="form.meterTypeId" placeholder="请输入传感器仪表" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="修正值" prop="correctValue">-->
<!-- <el-input v-model="form.correctValue" placeholder="请输入修正值" />-->
<!-- </el-form-item>-->
<el-form-item label="是否虚拟" prop="isAmmeter">
<el-radio-group v-model="form.isAmmeter">
<el-radio v-for="dict in dict.type.is_ammeter" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
<!-- <el-form-item label="通断复位" prop="isKeyMonitor">-->
<!-- <el-input v-model="form.isKeyMonitor" placeholder="请输入通断复位" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="是否断路" prop="isCircuit">-->
<!-- <el-input v-model="form.isCircuit" placeholder="请输入是否断路" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="公摊表类型" prop="publicShareType">-->
<!-- <el-input v-model="form.publicShareType" placeholder="请输入公摊表类型" />-->
<!-- </el-form-item>-->
<!-- <el-form-item label="表具层级" prop="monitorHierarchy">-->
<!-- <el-input v-model="form.monitorHierarchy" placeholder="请输入表具层级" />-->
<!-- </el-form-item>-->
<el-dialog :title="title" v-model="open" width="960px" append-to-body class="themed-dialog">
<el-form ref="formRef" :model="form" :rules="rules" label-width="110px" class="dialog-form">
<!-- ===== 基本信息 ===== -->
<el-divider content-position="left">基本信息</el-divider>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="上级节点" prop="parentId">
<EmsTreeSelect v-model="form.parentId" :options="baseMonitorInfoOptions" :normalizer="normalizer" placeholder="请选择上级节点" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备编号" prop="monitorCode">
<el-input v-model="form.monitorCode" placeholder="请输入计量设备编号" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="设备名称" prop="monitorName">
<el-input v-model="form.monitorName" placeholder="请输入计量设备名称" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="能源类型" prop="monitorType">
<el-select v-model="form.monitorType" placeholder="请选择能源类型" style="width: 100%">
<el-option v-for="item in energyTypeList" :key="item.energyTypeId" :label="item.energyName" :value="item.energyTypeId" />
</el-select>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="设备位置" prop="monitorAddr">
<el-input v-model="form.monitorAddr" placeholder="请输入计量设备位置" />
</el-form-item>
</el-col>
<el-col :span="12">
<el-form-item label="设备状态" prop="monitorStatus">
<el-radio-group v-model="form.monitorStatus">
<el-radio v-for="dict in dict.type.monitor_status" :key="dict.value" :label="parseInt(dict.value)">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<!-- ===== 采集配置 ===== -->
<el-divider content-position="left">采集配置</el-divider>
<el-row :gutter="24">
<el-col :span="12">
<el-form-item label="采集设备" prop="collectDeviceId">
<el-select v-model="form.collectDeviceId" placeholder="请选择采集设备" style="width: 100%">
<el-option v-for="item in collectDeviceList" :key="item.collectDeviceId" :label="item.collectDeviceName" :value="item.collectDeviceId" />
</el-select>
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="点位类型" prop="pointType">
<el-input v-model="form.pointType" placeholder="点位类型" />
</el-form-item>
</el-col>
<el-col :span="6">
<el-form-item label="单位" prop="unitName">
<el-input v-model="form.unitName" placeholder="单位" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 安装信息 ===== -->
<el-divider content-position="left">安装信息</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="安装位置" prop="locationName">
<el-input v-model="form.locationName" placeholder="安装位置" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="所属柜体" prop="cabinetName">
<el-input v-model="form.cabinetName" placeholder="所属柜体" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="所属回路" prop="circuitName">
<el-input v-model="form.circuitName" placeholder="所属回路" />
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="相别" prop="phaseNo">
<el-input v-model="form.phaseNo" placeholder="相别" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="设备序列号" prop="deviceSn">
<el-input v-model="form.deviceSn" placeholder="设备序列号" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="展示排序" prop="displaySort">
<el-input-number v-model="form.displaySort" placeholder="排序" :precision="0" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 传感器参数 ===== -->
<el-divider content-position="left">传感器参数</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="传感器类型" prop="sensorType">
<el-input v-model="form.sensorType" placeholder="传感器类型" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="测量精度" prop="measurePrecision">
<el-input-number v-model="form.measurePrecision" placeholder="精度" :precision="4" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="是否虚拟" prop="isAmmeter">
<el-radio-group v-model="form.isAmmeter">
<el-radio v-for="dict in dict.type.is_ammeter" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="量程下限" prop="rangeMin">
<el-input-number v-model="form.rangeMin" placeholder="下限" :precision="4" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="量程上限" prop="rangeMax">
<el-input-number v-model="form.rangeMax" placeholder="上限" :precision="4" :controls="false" style="width: 100%" />
</el-form-item>
</el-col>
</el-row>
<!-- ===== 功能开关 ===== -->
<el-divider content-position="left">功能开关</el-divider>
<el-row :gutter="24">
<el-col :span="8">
<el-form-item label="启用报警" prop="alarmEnable">
<el-radio-group v-model="form.alarmEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
<el-col :span="8">
<el-form-item label="启用曲线" prop="curveEnable">
<el-radio-group v-model="form.curveEnable">
<el-radio v-for="dict in dict.type.is_flag" :key="dict.value" :label="dict.value">{{ dict.label }}</el-radio>
</el-radio-group>
</el-form-item>
</el-col>
</el-row>
</el-form>
<template #footer>
<div class="dialog-footer">
@ -629,132 +647,6 @@ const state = reactive({
}
]
},
columns: [
// { key: 0, label: ``, visible: false },
{
key: 0,
label: `序号`,
visible: true
},
{
key: 1,
label: `计量设备编号`,
visible: true
},
{
key: 2,
label: `计量设备名称`,
visible: true
},
{
key: 3,
label: `能源类型`,
visible: true
},
{
key: 4,
label: `计量设备位置`,
visible: true
},
// { key: 5, label: ``, visible: true },
{
key: 5,
label: `计量设备状态`,
visible: true
},
{
key: 6,
label: `采集设备名称`,
visible: false
},
{
key: 7,
label: `等级`,
visible: false
},
{
key: 8,
label: `传感器仪表`,
visible: false
},
{
key: 9,
label: `修正值`,
visible: false
},
// { key: 12, label: `PT`, visible: false },
// { key: 13, label: `CT`, visible: false },
{
key: 10,
label: `是否虚拟`,
visible: true
},
{
key: 11,
label: `通断复位`,
visible: false
},
{
key: 12,
label: `是否断路`,
visible: false
},
{
key: 13,
label: `创建人`,
visible: false
},
{
key: 14,
label: `创建时间`,
visible: false
},
{
key: 15,
label: `更新人`,
visible: false
},
{
key: 16,
label: `更新时间`,
visible: false
},
{
key: 17,
label: `公摊表类型`,
visible: false
},
{
key: 18,
label: `表具层级`,
visible: false
},
{
key: 19,
label: `点位类型`,
visible: true
},
{
key: 20,
label: `单位`,
visible: true
},
{
key: 21,
label: `安装位置`,
visible: true
},
{
key: 22,
label: `传感器类型`,
visible: true
},
{
key: 23,
label: `启用报警`,
visible: true
}
],
//
energyTypeList: [],
// ID
@ -797,7 +689,6 @@ const state = reactive({
const {
baseMonitorInfoList,
baseMonitorInfoOptions,
columns,
currentDevice,
energyTypeList,
collectDeviceList,

@ -108,7 +108,7 @@ const initFormData: EmsBaseLocationJsonForm = {
id: undefined,
json: undefined,
remark: undefined,
deptId: undefined
deptId: undefined,
}
const data = reactive<PageData<EmsBaseLocationJsonForm, EmsBaseLocationJsonQuery>>({
form: {...initFormData},

@ -0,0 +1,502 @@
import type { RecordIotenvInstantVO } from '@/api/ems/types';
import * as echarts from 'echarts';
export interface CurveChartExpose {
chart?: unknown;
setData?: (option: echarts.EChartsOption) => void;
}
export interface IotCurveMetricHint {
key?: string;
label?: string;
unit?: string;
aliases?: string[];
}
export interface IotCurveMetricDefinition {
key: string;
label: string;
unit: string;
aliases: string[];
color: string;
areaStartColor: string;
areaEndColor: string;
}
export interface NormalizedIotCurveRecord extends RecordIotenvInstantVO {
__timeKey: string;
__sortValue: number;
}
const BASE_METRIC_CATALOG: IotCurveMetricDefinition[] = [
{
key: 'temperature',
label: '温度',
unit: '℃',
aliases: ['temperature', 'tempreture'],
color: '#ff7a45',
areaStartColor: 'rgba(255, 122, 69, 0.28)',
areaEndColor: 'rgba(255, 122, 69, 0.02)'
},
{
key: 'humidity',
label: '湿度',
unit: '%',
aliases: ['humidity'],
color: '#14b8a6',
areaStartColor: 'rgba(20, 184, 166, 0.26)',
areaEndColor: 'rgba(20, 184, 166, 0.02)'
},
{
key: 'noise',
label: '噪声',
unit: 'dB',
aliases: ['noise'],
color: '#8b5cf6',
areaStartColor: 'rgba(139, 92, 246, 0.24)',
areaEndColor: 'rgba(139, 92, 246, 0.02)'
},
{
key: 'illuminance',
label: '照度',
unit: 'Lux',
aliases: ['illuminance'],
color: '#f59e0b',
areaStartColor: 'rgba(245, 158, 11, 0.24)',
areaEndColor: 'rgba(245, 158, 11, 0.02)'
},
{
key: 'concentration',
label: '浓度',
unit: 'ppm',
aliases: ['concentration'],
color: '#ef4444',
areaStartColor: 'rgba(239, 68, 68, 0.24)',
areaEndColor: 'rgba(239, 68, 68, 0.02)'
},
{
key: 'vibrationSpeed',
label: '振动速度',
unit: 'mm/s',
aliases: ['vibrationSpeed', 'speed'],
color: '#2563eb',
areaStartColor: 'rgba(37, 99, 235, 0.24)',
areaEndColor: 'rgba(37, 99, 235, 0.02)'
},
{
key: 'vibrationDisplacement',
label: '振动位移',
unit: 'um',
aliases: ['vibrationDisplacement', 'displacement'],
color: '#0f766e',
areaStartColor: 'rgba(15, 118, 110, 0.24)',
areaEndColor: 'rgba(15, 118, 110, 0.02)'
},
{
key: 'vibrationAcceleration',
label: '振动加速度',
unit: 'g',
aliases: ['vibrationAcceleration', 'acceleration'],
color: '#db2777',
areaStartColor: 'rgba(219, 39, 119, 0.24)',
areaEndColor: 'rgba(219, 39, 119, 0.02)'
},
{
key: 'vibrationTemp',
label: '振动温度',
unit: '℃',
aliases: ['vibrationTemp'],
color: '#f97316',
areaStartColor: 'rgba(249, 115, 22, 0.24)',
areaEndColor: 'rgba(249, 115, 22, 0.02)'
}
];
const toNumberOrNull = (value: unknown) => {
if (value === null || value === undefined || value === '') {
return null;
}
const nextValue = Number(value);
return Number.isFinite(nextValue) ? nextValue : null;
};
const uniqueStrings = (values: Array<string | undefined | null>) => {
return Array.from(
new Set(
values
.map((item) => String(item || '').trim())
.filter((item) => item.length > 0)
)
);
};
const createFallbackMetricDefinition = (hint: IotCurveMetricHint, index: number): IotCurveMetricDefinition => {
const colorPalette = [
['#2563eb', 'rgba(37, 99, 235, 0.24)', 'rgba(37, 99, 235, 0.02)'],
['#14b8a6', 'rgba(20, 184, 166, 0.24)', 'rgba(20, 184, 166, 0.02)'],
['#f97316', 'rgba(249, 115, 22, 0.24)', 'rgba(249, 115, 22, 0.02)'],
['#8b5cf6', 'rgba(139, 92, 246, 0.24)', 'rgba(139, 92, 246, 0.02)'],
['#ef4444', 'rgba(239, 68, 68, 0.24)', 'rgba(239, 68, 68, 0.02)']
];
const [color, areaStartColor, areaEndColor] = colorPalette[index % colorPalette.length];
const fallbackKey = String(hint.key || hint.aliases?.[0] || `metric_${index + 1}`);
return {
key: fallbackKey,
label: String(hint.label || fallbackKey),
unit: String(hint.unit || ''),
aliases: uniqueStrings([fallbackKey, ...(hint.aliases || [])]),
color,
areaStartColor,
areaEndColor
};
};
const mergeMetricCatalog = (metricHints: IotCurveMetricHint[] = []) => {
const catalogMap = new Map<string, IotCurveMetricDefinition>();
BASE_METRIC_CATALOG.forEach((item) => {
catalogMap.set(item.key, item);
});
metricHints.forEach((hint, index) => {
const key = String(hint.key || hint.aliases?.[0] || '').trim();
if (!key) {
return;
}
const previous = catalogMap.get(key);
if (previous) {
catalogMap.set(key, {
...previous,
label: hint.label || previous.label,
unit: hint.unit || previous.unit,
aliases: uniqueStrings([key, ...(previous.aliases || []), ...(hint.aliases || [])])
});
return;
}
catalogMap.set(key, createFallbackMetricDefinition(hint, index));
});
return Array.from(catalogMap.values());
};
const resolveTimeKey = (record: RecordIotenvInstantVO) => {
return String(record?.recodeTime || record?.collectTime || '');
};
const resolveSortValue = (timeKey: string) => {
const timestamp = new Date(timeKey).getTime();
return Number.isFinite(timestamp) ? timestamp : 0;
};
export const normalizeIotCurveRecord = (record: RecordIotenvInstantVO): NormalizedIotCurveRecord => {
const timeKey = resolveTimeKey(record);
return {
...record,
__timeKey: timeKey,
__sortValue: resolveSortValue(timeKey)
};
};
export const readMetricValue = (record: Record<string, unknown>, metric: IotCurveMetricDefinition) => {
for (const alias of metric.aliases) {
const metricValue = toNumberOrNull(record[alias]);
if (metricValue !== null) {
return metricValue;
}
}
return null;
};
export const buildMetricHintsByNode = (node?: Record<string, unknown> | null) => {
if (!node) {
return [] as IotCurveMetricHint[];
}
const metricCode = String(node.metricCode || '').trim();
const metricName = String(node.metricName || node.pointType || '').trim();
const unitName = String(node.unitName || '').trim();
if (!metricCode) {
return [];
}
// 这里优先把树节点元数据也转成候选指标,后续即使后端新增类型,只要字段名与 metricCode 对上就能直接出图。
return [
{
key: metricCode,
label: metricName || String(node.label || metricCode),
unit: unitName,
aliases: [metricCode]
}
];
};
export const resolveIotCurveMetrics = (
records: Array<Record<string, unknown>>,
metricHints: IotCurveMetricHint[] = []
) => {
const catalog = mergeMetricCatalog(metricHints);
const preferredKeys = uniqueStrings(metricHints.map((item) => item.key));
const detectedMetrics = catalog.filter((item) => records.some((record) => readMetricValue(record, item) !== null));
if (!records.length) {
return catalog.filter((item) => preferredKeys.includes(item.key));
}
const resultMap = new Map<string, IotCurveMetricDefinition>();
preferredKeys.forEach((key) => {
const metric = catalog.find((item) => item.key === key);
if (metric && detectedMetrics.some((item) => item.key === key)) {
resultMap.set(key, metric);
}
});
detectedMetrics.forEach((item) => {
resultMap.set(item.key, item);
});
return Array.from(resultMap.values());
};
export const extractRealtimeIotRecords = (payload: unknown): RecordIotenvInstantVO[] => {
if (!payload) {
return [];
}
if (Array.isArray(payload)) {
return payload as RecordIotenvInstantVO[];
}
if (typeof payload !== 'object') {
return [];
}
const source = payload as Record<string, unknown>;
const candidateKeys = ['data', 'rows', 'records', 'list', 'payload'];
for (const key of candidateKeys) {
if (Array.isArray(source[key])) {
return source[key] as RecordIotenvInstantVO[];
}
}
if (resolveTimeKey(source as RecordIotenvInstantVO)) {
return [source as RecordIotenvInstantVO];
}
return [];
};
export const mergeRealtimeWindow = (
currentRecords: NormalizedIotCurveRecord[],
incomingRecords: RecordIotenvInstantVO[],
windowSize: number
) => {
const mergedMap = new Map<string, NormalizedIotCurveRecord>();
[...currentRecords, ...incomingRecords.map(normalizeIotCurveRecord)].forEach((item) => {
if (!item.__timeKey) {
return;
}
const uniqueKey = `${item.monitorId || item.monitorCode || ''}_${item.__timeKey}`;
const previous = mergedMap.get(uniqueKey);
mergedMap.set(uniqueKey, {
...previous,
...item
});
});
const normalizedWindowSize = Math.max(1, Number(windowSize) || 1);
return Array.from(mergedMap.values())
.sort((left, right) => {
if (left.__sortValue !== right.__sortValue) {
return left.__sortValue - right.__sortValue;
}
return left.__timeKey.localeCompare(right.__timeKey);
})
.slice(-normalizedWindowSize);
};
export const buildIotCurveOption = ({
metric,
monitorName,
records,
premium = false,
subtitle = ''
}: {
metric: IotCurveMetricDefinition;
monitorName: string;
records: Array<Record<string, unknown>>;
premium?: boolean;
subtitle?: string;
}): echarts.EChartsOption => {
const titleText = `${monitorName || '设备'} ${metric.label}曲线`;
const xData = records.map((item) => String(item.__timeKey || item.recodeTime || item.collectTime || ''));
const seriesData = records.map((item) => readMetricValue(item, metric));
return {
backgroundColor: 'transparent',
animation: premium,
animationDuration: premium ? 420 : 0,
animationDurationUpdate: premium ? 320 : 0,
title: {
text: titleText,
subtext: subtitle,
left: 'center',
top: 10,
textStyle: {
color: premium ? '#0f172a' : '#0f172a',
fontSize: premium ? 18 : 16,
fontWeight: 600
},
subtextStyle: {
color: premium ? '#64748b' : '#64748b',
fontSize: 12
}
},
grid: {
top: premium ? '18%' : '16%',
bottom: 56,
left: 68,
right: 28
},
tooltip: {
trigger: 'axis',
backgroundColor: premium ? 'rgba(15, 23, 42, 0.92)' : 'rgba(15, 23, 42, 0.88)',
borderWidth: 0,
textStyle: {
color: '#ffffff'
},
axisPointer: {
type: 'line',
label: {
show: true
}
}
},
legend: {
right: 20,
top: 16,
itemWidth: 14,
itemHeight: 8,
textStyle: {
color: '#475569'
}
},
dataZoom: [
{
type: 'inside',
filterMode: 'none'
},
{
type: 'slider',
height: 22,
bottom: 14,
borderColor: 'transparent',
backgroundColor: premium ? 'rgba(148, 163, 184, 0.16)' : 'rgba(148, 163, 184, 0.14)',
fillerColor: premium ? 'rgba(59, 130, 246, 0.18)' : 'rgba(59, 130, 246, 0.14)',
handleStyle: {
color: metric.color
}
}
],
xAxis: {
type: 'category',
boundaryGap: false,
data: xData,
axisLine: {
show: true,
lineStyle: {
color: '#cbd5e1'
}
},
axisTick: {
show: false
},
axisLabel: {
color: '#64748b',
hideOverlap: true
}
},
yAxis: {
type: 'value',
name: metric.unit ? `${metric.label}(${metric.unit})` : metric.label,
nameTextStyle: {
color: '#475569'
},
splitLine: {
lineStyle: {
color: 'rgba(148, 163, 184, 0.18)'
}
},
axisTick: {
show: false
},
axisLine: {
show: true,
lineStyle: {
color: '#cbd5e1'
}
},
axisLabel: {
color: '#64748b'
}
},
series: [
{
name: metric.unit ? `${metric.label}(${metric.unit})` : metric.label,
type: 'line',
smooth: true,
connectNulls: true,
showSymbol: records.length <= 80,
symbol: 'circle',
symbolSize: premium ? 7 : 6,
lineStyle: {
width: premium ? 3 : 2,
color: metric.color
},
itemStyle: {
color: metric.color,
borderColor: '#ffffff',
borderWidth: premium ? 2 : 1
},
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: metric.areaStartColor },
{ offset: 1, color: metric.areaEndColor }
])
},
emphasis: {
focus: 'series'
},
data: seriesData
}
]
};
};
export const connectCurveCharts = (
charts: Array<
| {
chart?: unknown;
}
| null
| undefined
>,
groupId: string
) => {
const chartInstances = charts
.map((item) => item?.chart)
.filter((item): item is echarts.ECharts => Boolean(item) && typeof (item as echarts.ECharts).setOption === 'function');
if (chartInstances.length < 2) {
return () => undefined;
}
chartInstances.forEach((item) => {
item.group = groupId;
});
echarts.connect(groupId);
return () => {
echarts.disconnect(groupId);
chartInstances.forEach((item) => {
if (item.group === groupId) {
item.group = '';
}
});
};
};

@ -0,0 +1,638 @@
import { computed, nextTick, reactive, ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { ElMessage } from 'element-plus';
import { parseTime } from '@/utils/ruoyi';
import { getMonitorInfoTree } from '@/api/ems/base/baseMonitorInfo';
import { getRecordIotenvInstantReportData } from '@/api/ems/record/recordIotenvInstant';
import type { RecordIotenvInstantVO } from '@/api/ems/types';
import {
buildMetricHintsByNode,
normalizeIotCurveRecord,
readMetricValue,
resolveIotCurveMetrics,
type IotCurveMetricDefinition,
type IotCurveMetricHint,
type NormalizedIotCurveRecord
} from './iotCurveShared';
export interface IotReportMetricStats {
latest: number | null;
avg: number | null;
max: number | null;
p75: number | null;
p90: number | null;
}
export interface IotReportDeviceStat {
monitorId: string;
monitorName: string;
latest: number;
avg: number;
max: number;
count: number;
}
export interface IotReportScopeOption {
key: string;
label: string;
deviceCount: number;
}
interface IotTreeLeafNode extends Record<string, unknown> {
code?: string;
label?: string;
type?: unknown;
__rootKey?: string;
}
interface IotReportSelection {
compareScope: string;
monitorId: string;
monitorIds: string[];
monitorName: string;
selectionLabel: string;
nodeId: string;
}
const createDefaultTimeRange = () => {
const now = new Date();
const start = new Date(now.getTime() - 2 * 60 * 60 * 1000);
return [parseTime(start, '{y}-{m}-{d} {h}:{i}:{s}'), parseTime(now, '{y}-{m}-{d} {h}:{i}:{s}')];
};
const resolveNodeKey = (node: Record<string, unknown>) => {
return String(node.id || node.code || node.label || Math.random());
};
const cloneTreeByRootKeys = (nodes: Array<Record<string, unknown>>, selectedKeys: string[]) => {
return (nodes || []).filter((item) => selectedKeys.includes(resolveNodeKey(item)));
};
const getLeafNodes = (nodes: Array<Record<string, unknown>>, rootKey?: string): IotTreeLeafNode[] => {
let result: IotTreeLeafNode[] = [];
(nodes || []).forEach((item) => {
const currentRootKey = rootKey || resolveNodeKey(item);
if (Array.isArray(item.children) && item.children.length > 0) {
result = result.concat(getLeafNodes(item.children as Array<Record<string, unknown>>, currentRootKey));
return;
}
if (item.code) {
result.push({
...item,
__rootKey: currentRootKey
});
}
});
return result;
};
const buildDeviceDisplayMap = (nodes: Array<Record<string, unknown>>) => {
return getLeafNodes(nodes).reduce((accumulator, item) => {
const key = String(item.code || '');
if (!key) {
return accumulator;
}
accumulator[key] = {
label: String(item.label || key),
type: item.type
};
return accumulator;
}, {} as Record<string, { label: string; type: unknown }>);
};
const findRootKeyByNodeId = (nodes: Array<Record<string, unknown>>, targetId: string, rootKey = ''): string => {
for (const item of nodes || []) {
const currentRootKey = rootKey || resolveNodeKey(item);
if (String(item.id || '') === String(targetId || '')) {
return currentRootKey;
}
if (Array.isArray(item.children) && item.children.length > 0) {
const matched = findRootKeyByNodeId(item.children as Array<Record<string, unknown>>, targetId, currentRootKey);
if (matched) {
return matched;
}
}
}
return '';
};
const buildSelectionByLeafNodes = (leafNodes: IotTreeLeafNode[], selectionLabel: string, compareScope = ''): IotReportSelection => {
const monitorIds = leafNodes.map((item) => String(item.code || '')).filter((item) => item);
const firstNode = leafNodes[0];
return {
compareScope: compareScope || (monitorIds.length > 1 ? 'group' : 'single'),
monitorId: monitorIds.length === 1 ? monitorIds[0] : '',
monitorIds,
monitorName: monitorIds.length === 1 ? String(firstNode?.label || selectionLabel) : selectionLabel,
selectionLabel,
nodeId: String(firstNode?.id || '')
};
};
export const isValidNumber = (value: unknown) => value !== null && value !== undefined && !Number.isNaN(Number(value));
export const average = (values: number[]) => {
if (!values.length) {
return 0;
}
return values.reduce((sum, item) => sum + item, 0) / values.length;
};
export const percentile = (sortedValues: number[], ratio: number) => {
if (!sortedValues.length) {
return 0;
}
const index = Math.min(sortedValues.length - 1, Math.max(0, Math.ceil(sortedValues.length * ratio) - 1));
return sortedValues[index];
};
export const getBands = (values: number[]) => {
const sortedValues = [...values].sort((left, right) => left - right);
return {
low: percentile(sortedValues, 0.33),
high: percentile(sortedValues, 0.66),
avg: average(sortedValues),
p75: percentile(sortedValues, 0.75),
p90: percentile(sortedValues, 0.9)
};
};
export const formatValue = (value: unknown) => {
if (!isValidNumber(value)) {
return '--';
}
return Number(value).toFixed(2);
};
export const formatPercent = (value: unknown) => {
return `${(Number(value || 0) * 100).toFixed(1)}%`;
};
export const normalizePercent = (value: number | null, max: number | null) => {
if (!isValidNumber(value) || !isValidNumber(max) || Number(max) <= 0) {
return 0;
}
return Number((((value || 0) / (max || 1)) * 100).toFixed(2));
};
export const ceilNumber = (value: number) => Math.ceil(value * 10) / 10;
export const useIotReportWorkbench = () => {
const route = useRoute();
const loading = ref(false);
const treeLoading = ref(false);
const reportReady = ref(false);
const monitorTreeOptions = ref<Array<Record<string, unknown>>>([]);
const deviceDisplayMap = ref<Record<string, { label: string; type: unknown }>>({});
const selectedScopeKeys = ref<string[]>([]);
const currentTreeNodeId = ref('');
const rawRecords = ref<NormalizedIotCurveRecord[]>([]);
const daterangeRecordTime = ref<string[]>(createDefaultTimeRange());
const queryForm = reactive({
compareScope: 'single',
monitorId: '',
monitorIds: [] as string[],
monitorName: '',
selectionLabel: '当前分析范围',
samplingInterval: 5,
primaryMetricField: ''
});
const leafNodeMap = computed(() => {
return getLeafNodes(monitorTreeOptions.value).reduce((accumulator, item) => {
const key = String(item.code || '');
if (key) {
accumulator[key] = item;
}
return accumulator;
}, {} as Record<string, IotTreeLeafNode>);
});
const selectedLeafNodes = computed(() => {
const currentIds = queryForm.monitorIds.length ? queryForm.monitorIds : queryForm.monitorId ? [queryForm.monitorId] : [];
return currentIds.map((item) => leafNodeMap.value[item]).filter(Boolean);
});
const metricHints = computed<IotCurveMetricHint[]>(() => {
return selectedLeafNodes.value.flatMap((item) => buildMetricHintsByNode(item));
});
const metricConfigs = computed<IotCurveMetricDefinition[]>(() => {
return resolveIotCurveMetrics(rawRecords.value, metricHints.value);
});
const primaryMetric = computed<IotCurveMetricDefinition | null>(() => {
if (!metricConfigs.value.length) {
return null;
}
const matched = metricConfigs.value.find((item) => item.key === queryForm.primaryMetricField);
return matched || metricConfigs.value[0];
});
const availablePrimaryMetricOptions = computed(() => metricConfigs.value);
const deviceCount = computed(() => {
if (queryForm.monitorIds.length > 0) {
return queryForm.monitorIds.length;
}
return queryForm.monitorId ? 1 : 0;
});
const hasMultiDevice = computed(() => {
return queryForm.monitorIds.length > 1 || ['group', 'all'].includes(queryForm.compareScope);
});
const availableScopeOptions = computed<IotReportScopeOption[]>(() => {
return (monitorTreeOptions.value || []).map((item) => ({
key: resolveNodeKey(item),
label: String(item.label || '未命名分组'),
deviceCount: getLeafNodes([item]).length
}));
});
const filteredMonitorTreeOptions = computed(() => {
if (!selectedScopeKeys.value.length) {
return monitorTreeOptions.value;
}
return cloneTreeByRootKeys(monitorTreeOptions.value, selectedScopeKeys.value);
});
const monitorTypeLabel = computed(() => {
return metricConfigs.value.length ? metricConfigs.value.map((item) => item.label).join(' / ') : '待识别';
});
const selectionSummary = computed(() => {
const summaryMap = {
all: '多分组联合分析',
group: '设备组分析',
single: '单设备分析'
};
return summaryMap[queryForm.compareScope] || '分析中';
});
const energySelectionSummary = computed(() => {
if (!selectedScopeKeys.value.length) {
return '当前未限定分组,默认展示全部范围';
}
const labels = availableScopeOptions.value.filter((item) => selectedScopeKeys.value.includes(item.key)).map((item) => item.label);
return `当前分组:${labels.join(' / ')}`;
});
const deviceSelectionSummary = computed(() => {
return queryForm.selectionLabel ? `分析范围:${queryForm.selectionLabel}` : '分析范围待确认';
});
const analysisObjectValue = computed(() => {
if (hasMultiDevice.value) {
return `${deviceCount.value} 台设备`;
}
return queryForm.monitorName || queryForm.selectionLabel || '--';
});
const analysisObjectFoot = computed(() => {
return hasMultiDevice.value ? '当前报表按设备组/联合范围聚合分析' : '当前报表按单设备时间轨迹分析';
});
const metricDescription = computed(() => {
return metricConfigs.value.length ? metricConfigs.value.map((item) => item.label).join('、') : '等待识别有效指标';
});
const primaryMetricFoot = computed(() => {
return primaryMetric.value ? `当前主看指标用于排名、筛选与小时均值分析` : '请选择有效主看指标';
});
const getMonitorDisplayName = (monitorId: string) => {
const cachedItem = deviceDisplayMap.value[monitorId];
return cachedItem?.label || monitorId || '--';
};
const getShortDisplayName = (monitorId: string) => {
const displayName = getMonitorDisplayName(monitorId);
return displayName.length <= 10 ? displayName : `${displayName.slice(0, 9)}...`;
};
const buildSeriesTimeAxis = () => {
return Array.from(new Set(rawRecords.value.map((item) => item.__timeKey).filter((item) => item)));
};
const getMetricValues = (metric: IotCurveMetricDefinition, monitorId = '') => {
return rawRecords.value
.filter((item) => !monitorId || String(item.monitorId || item.monitorCode || '') === monitorId)
.map((item) => readMetricValue(item, metric))
.filter((item): item is number => item !== null);
};
const getMetricStats = (metric: IotCurveMetricDefinition, monitorId = ''): IotReportMetricStats => {
const values = getMetricValues(metric, monitorId);
if (!values.length) {
return {
latest: null,
avg: null,
max: null,
p75: null,
p90: null
};
}
const sortedValues = [...values].sort((left, right) => left - right);
return {
latest: values[values.length - 1],
avg: average(values),
max: sortedValues[sortedValues.length - 1],
p75: percentile(sortedValues, 0.75),
p90: percentile(sortedValues, 0.9)
};
};
const metricCards = computed(() => {
return metricConfigs.value.slice(0, 4).map((metric) => {
const stats = getMetricStats(metric);
return {
...metric,
latest: stats.latest,
avg: stats.avg,
max: stats.max
};
});
});
const deviceStats = computed<IotReportDeviceStat[]>(() => {
if (!primaryMetric.value) {
return [];
}
const deviceMap = {} as Record<string, number[]>;
rawRecords.value.forEach((item) => {
const monitorId = String(item.monitorId || item.monitorCode || '');
const metricValue = readMetricValue(item, primaryMetric.value as IotCurveMetricDefinition);
if (!monitorId || metricValue === null) {
return;
}
if (!deviceMap[monitorId]) {
deviceMap[monitorId] = [];
}
deviceMap[monitorId].push(metricValue);
});
return Object.keys(deviceMap)
.map((monitorId) => {
const values = deviceMap[monitorId];
const sortedValues = [...values].sort((left, right) => left - right);
return {
monitorId,
monitorName: getMonitorDisplayName(monitorId),
latest: values[values.length - 1],
avg: average(values),
max: sortedValues[sortedValues.length - 1],
count: values.length
};
})
.sort((left, right) => right.avg - left.avg);
});
const coverageRatio = computed(() => {
if (!rawRecords.value.length || !metricConfigs.value.length) {
return 0;
}
const totalPoints = rawRecords.value.length * metricConfigs.value.length;
const validPoints = metricConfigs.value.reduce((sum, metric) => sum + getMetricValues(metric).length, 0);
return totalPoints ? validPoints / totalPoints : 0;
});
const loadMonitorTree = async () => {
treeLoading.value = true;
try {
// 这里不再硬编码能源类型编号,直接读取当前项目可用树结构,后续新增/删减类型不需要改页面。
const response = await getMonitorInfoTree({});
monitorTreeOptions.value = response.data || [];
deviceDisplayMap.value = buildDeviceDisplayMap(monitorTreeOptions.value);
} finally {
treeLoading.value = false;
}
};
const buildSelectionByMonitorIds = (monitorIds: string[]) => {
const leafNodes = monitorIds.map((item) => leafNodeMap.value[item]).filter(Boolean);
if (!leafNodes.length) {
return {
compareScope: 'all',
monitorId: '',
monitorIds: [],
monitorName: '',
selectionLabel: '当前分析范围',
nodeId: ''
} as IotReportSelection;
}
const compareScope = leafNodes.length > 1 ? 'group' : 'single';
const selectionLabel = leafNodes.length > 1 ? `选中设备组(${leafNodes.length}台)` : String(leafNodes[0].label || '当前设备');
return buildSelectionByLeafNodes(leafNodes, selectionLabel, compareScope);
};
const applySelection = (selection: IotReportSelection, shouldQuery = true) => {
queryForm.compareScope = selection.compareScope;
queryForm.monitorId = selection.monitorId;
queryForm.monitorIds = selection.monitorIds;
queryForm.monitorName = selection.monitorName;
queryForm.selectionLabel = selection.selectionLabel;
currentTreeNodeId.value = selection.nodeId;
if (shouldQuery) {
void handleQuery();
}
};
const applySelectionByScopeKeys = (shouldQuery = true) => {
const selectedRoots = cloneTreeByRootKeys(monitorTreeOptions.value, selectedScopeKeys.value);
if (!selectedRoots.length) {
return;
}
const leafNodes = getLeafNodes(selectedRoots);
const selectionLabel =
selectedRoots.length === 1 ? `${String(selectedRoots[0].label || '当前分组')}设备组` : `${selectedRoots.map((item) => item.label).join(' / ')}联合分析`;
const compareScope = selectedRoots.length > 1 ? 'all' : '';
applySelection(buildSelectionByLeafNodes(leafNodes, selectionLabel, compareScope), shouldQuery);
};
const initQueryFromRoute = () => {
const { query } = route;
const selectionCacheKey = String(query.selectionKey || 'recordIotenvInstantDashboardSelection');
const selectionCacheText = window.sessionStorage.getItem(selectionCacheKey);
let selectionCache = {} as Record<string, unknown>;
try {
selectionCache = selectionCacheText ? JSON.parse(selectionCacheText) : {};
} catch (error) {
selectionCache = {};
}
const monitorIds = String(query.monitorIds || '')
.split(',')
.map((item) => item.trim())
.filter((item) => item);
queryForm.compareScope = String(query.compareScope || (monitorIds.length > 1 ? 'group' : 'single'));
queryForm.monitorId = String(query.monitorId || '');
queryForm.monitorIds = monitorIds;
queryForm.monitorName = String(query.monitorName || query.monitorId || '');
queryForm.selectionLabel = String(query.selectionLabel || selectionCache.selectionLabel || query.monitorName || '当前分析范围');
queryForm.samplingInterval = Number(query.samplingInterval || 5);
queryForm.primaryMetricField = String(query.primaryMetricField || '');
const beginRecordTime = String(query.beginRecordTime || '');
const endRecordTime = String(query.endRecordTime || '');
if (beginRecordTime && endRecordTime) {
daterangeRecordTime.value = [beginRecordTime, endRecordTime];
}
};
const applyInitialSelection = () => {
if (!monitorTreeOptions.value.length) {
return;
}
const routeIds = queryForm.monitorIds.length ? queryForm.monitorIds : queryForm.monitorId ? [queryForm.monitorId] : [];
if (routeIds.length) {
const selection = buildSelectionByMonitorIds(routeIds);
if (selection.monitorIds.length) {
selectedScopeKeys.value = Array.from(new Set(routeIds.map((item) => leafNodeMap.value[item]?.__rootKey).filter(Boolean) as string[]));
applySelection(selection, false);
return;
}
}
selectedScopeKeys.value = [resolveNodeKey(monitorTreeOptions.value[0])];
applySelectionByScopeKeys(false);
};
const buildRequestQuery = () => {
const query: Record<string, unknown> = {
samplingInterval: queryForm.samplingInterval,
params: {
beginRecordTime: daterangeRecordTime.value[0],
endRecordTime: daterangeRecordTime.value[1]
}
};
if (queryForm.monitorIds.length > 1) {
query.monitorIds = queryForm.monitorIds;
} else {
query.monitorId = queryForm.monitorIds[0] || queryForm.monitorId;
}
return query;
};
const handleQuery = async () => {
if (!deviceCount.value) {
ElMessage.warning('当前没有可分析的设备范围');
return;
}
if (!daterangeRecordTime.value || daterangeRecordTime.value.length !== 2) {
ElMessage.warning('请选择完整的记录时间范围');
return;
}
loading.value = true;
reportReady.value = false;
try {
const { data = [] } = await getRecordIotenvInstantReportData(buildRequestQuery());
rawRecords.value = (data as RecordIotenvInstantVO[]).map((item) => normalizeIotCurveRecord(item));
if (metricConfigs.value.length && !metricConfigs.value.some((item) => item.key === queryForm.primaryMetricField)) {
queryForm.primaryMetricField = metricConfigs.value[0].key;
}
reportReady.value = rawRecords.value.length > 0 && metricConfigs.value.length > 0;
} catch (error) {
ElMessage.error('报表加载失败,请稍后重试');
} finally {
loading.value = false;
}
};
const handleScopeChange = (value: string[]) => {
if (!value || !value.length) {
nextTick(() => {
selectedScopeKeys.value = availableScopeOptions.value.length ? [availableScopeOptions.value[0].key] : [];
});
ElMessage.warning('至少保留一个分组用于分析');
return;
}
applySelectionByScopeKeys();
};
const handleTreeNodeClick = (data: Record<string, unknown>) => {
if (!data) {
return;
}
const rootKey = findRootKeyByNodeId(monitorTreeOptions.value, String(data.id || ''));
if (rootKey) {
selectedScopeKeys.value = [rootKey];
}
if (Array.isArray(data.children) && data.children.length > 0) {
applySelection(buildSelectionByLeafNodes(getLeafNodes([data]), `${String(data.label || '当前节点')}设备组`, 'group'));
return;
}
const monitorId = String(data.code || '');
applySelection(buildSelectionByMonitorIds([monitorId]));
};
watch(
metricConfigs,
(value) => {
if (!value.length) {
queryForm.primaryMetricField = '';
return;
}
if (!value.some((item) => item.key === queryForm.primaryMetricField)) {
queryForm.primaryMetricField = value[0].key;
}
},
{
immediate: true
}
);
const initialize = async () => {
initQueryFromRoute();
await loadMonitorTree();
applyInitialSelection();
await handleQuery();
};
return {
availablePrimaryMetricOptions,
availableScopeOptions,
analysisObjectFoot,
analysisObjectValue,
buildSeriesTimeAxis,
coverageRatio,
currentTreeNodeId,
daterangeRecordTime,
deviceCount,
deviceDisplayMap,
deviceSelectionSummary,
deviceStats,
energySelectionSummary,
filteredMonitorTreeOptions,
formatPercent,
formatValue,
getBands,
getMetricStats,
getMetricValues,
getMonitorDisplayName,
getShortDisplayName,
handleQuery,
handleScopeChange,
handleTreeNodeClick,
hasMultiDevice,
initialize,
loading,
metricCards,
metricConfigs,
metricDescription,
monitorTreeOptions,
monitorTypeLabel,
primaryMetric,
primaryMetricFoot,
queryForm,
rawRecords,
reportReady,
selectedScopeKeys,
selectionSummary,
treeLoading,
availableMetricHints: metricHints
};
};

@ -0,0 +1,323 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 桑基图 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">温区流转桑基图</span>
<HelpButton title="桑基图Sankey Diagram" :content="ADVANCED_HELP" />
</div>
</template>
<div ref="sankeyChartRef" class="chart-container-lg" />
</el-card>
<!-- 主题河流图 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">温度主题河流图</span>
<HelpButton title="主题河流图ThemeRiver" :content="ADVANCED_HELP" />
</div>
</template>
<div ref="themeRiverChartRef" class="chart-container-lg" />
</el-card>
<!-- 矩形树图 + 旭日图 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">温度矩形树图按测点平均温度</span>
<HelpButton title="矩形树图Treemap" :content="ADVANCED_HELP" />
</div>
</template>
<div ref="treemapChartRef" class="chart-container-lg" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">温度旭日图温区测点</span>
<HelpButton title="旭日图Sunburst" :content="ADVANCED_HELP" />
</div>
</template>
<div ref="sunburstChartRef" class="chart-container-lg" />
</el-card>
</el-col>
</el-row>
<!-- 平行坐标图 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">测点多维温度画像平行坐标图</span>
<HelpButton title="平行坐标图Parallel Coordinates" :content="ADVANCED_HELP" />
</div>
</template>
<div ref="parallelChartRef" class="chart-container-lg" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getSankeyData, getThemeRiverData, getTreemapData, getSunburstData, getParallelData } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardAdvancedVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { ADVANCED_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardAdvanced' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({})
const loading = ref(false)
const sankeyChartRef = ref<HTMLElement>()
const themeRiverChartRef = ref<HTMLElement>()
const treemapChartRef = ref<HTMLElement>()
const sunburstChartRef = ref<HTMLElement>()
const parallelChartRef = ref<HTMLElement>()
const { getChart } = useChartResize(sankeyChartRef, themeRiverChartRef, treemapChartRef, sunburstChartRef, parallelChartRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) { queryForm.startTime = timeRange.value[0]; queryForm.endTime = timeRange.value[1] }
loading.value = true
try {
const [skRes, trRes, tmRes, sbRes, plRes] = await Promise.all([
getSankeyData(queryForm), getThemeRiverData(queryForm),
getTreemapData(queryForm), getSunburstData(queryForm), getParallelData(queryForm)
])
await nextTick()
renderSankey(skRes.data)
renderThemeRiver(trRes.data)
renderTreemap(tmRes.data)
renderSunburst(sbRes.data)
renderParallel(plRes.data)
} finally { loading.value = false }
}
/** 桑基图 */
function renderSankey(data: TempBoardAdvancedVO[]) {
const chart = getChart(sankeyChartRef)
if (!chart || !data.length) return
const nodes = new Set<string>()
data.forEach(d => { nodes.add(d.fromNode!); nodes.add(d.toNode!) })
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (p: any) => p.dataType === 'edge'
? `${p.data.source}${p.data.target}<br/>流量: <b>${p.data.value}</b>`
: `${p.name}`
},
series: [{
type: 'sankey',
layout: 'none',
emphasis: { focus: 'adjacency' },
data: [...nodes].map(n => ({ name: n })),
links: data.map(d => ({ source: d.fromNode, target: d.toNode, value: d.flowCount })),
lineStyle: { color: 'gradient', curveness: 0.5 },
label: { color: '#333', fontSize: 12 },
itemStyle: { borderWidth: 0 }
}]
})
}
/** 主题河流图 */
function renderThemeRiver(data: TempBoardAdvancedVO[]) {
const chart = getChart(themeRiverChartRef)
if (!chart || !data.length) return
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const p = params[0]
return `${p.data[2]}<br/>时间: ${p.data[0]}<br/>温度: <b>${p.data[1]}℃</b>`
}
},
legend: { top: 0, textStyle: { color: '#666' } },
singleAxis: { type: 'time', axisLabel: { color: '#666' } },
series: [{
type: 'themeRiver',
data: data.map(d => [d.statTime, d.avgTemp, d.monitorName || d.monitorId]),
emphasis: { itemStyle: { shadowBlur: 20, shadowColor: 'rgba(0,0,0,0.8)' } },
label: { show: false }
}]
})
}
/** 矩形树图 */
function renderTreemap(data: TempBoardAdvancedVO[]) {
const chart = getChart(treemapChartRef)
if (!chart || !data.length) return
chart.setOption({
tooltip: {
formatter: (p: any) => {
const item = data.find(d => d.monitorName === p.name || d.monitorId === p.name)
return `${p.name}<br/>平均温度: <b>${p.value?.toFixed(2)}℃</b>${item ? `<br/>样本数: ${item.sampleCount}` : ''}`
}
},
series: [{
type: 'treemap',
data: data.map(d => ({
name: d.monitorName || d.monitorId,
value: d.avgTemp,
itemStyle: {
color: d.avgTemp >= 30 ? '#f56c6c' : d.avgTemp >= 20 ? '#e6a23c' : d.avgTemp >= 10 ? '#67c23a' : '#409eff'
}
})),
leafDepth: 1,
roam: false,
label: { show: true, formatter: '{b}\n{c}℃', fontSize: 12, color: '#fff' },
upperLabel: { show: true, height: 24, color: '#fff' },
itemStyle: { borderColor: '#fff', borderWidth: 2, gapWidth: 2 },
levels: [
{ itemStyle: { borderColor: '#333', borderWidth: 2, gapWidth: 2 } },
{ itemStyle: { borderColor: '#fff', borderWidth: 1, gapWidth: 1 } }
]
}]
})
}
/** 旭日图 */
function renderSunburst(data: TempBoardAdvancedVO[]) {
const chart = getChart(sunburstChartRef)
if (!chart || !data.length) return
//
const bucketMap = new Map<string, { name: string; value: number; children: any[] }>()
const colorMap: Record<string, string> = {
'低温': '#409eff', '偏低': '#67c23a', '正常': '#95de64',
'偏高': '#e6a23c', '高温': '#f56c6c'
}
data.forEach(d => {
if (!bucketMap.has(d.tempBucket!)) {
bucketMap.set(d.tempBucket!, { name: d.tempBucket!, value: 0, children: [] })
}
const bucket = bucketMap.get(d.tempBucket!)!
bucket.children.push({
name: d.monitorName || d.monitorId,
value: d.sampleCount,
itemStyle: { color: colorMap[d.tempBucket!] || undefined }
})
bucket.value += d.sampleCount ?? 0
})
chart.setOption({
tooltip: { formatter: '{b}: {c}样本' },
series: [{
type: 'sunburst',
data: [...bucketMap.values()].map(b => ({
...b,
itemStyle: { color: colorMap[b.name] || undefined }
})),
radius: ['15%', '90%'],
label: { rotate: 'radial', fontSize: 11, color: '#333' },
itemStyle: { borderWidth: 2, borderColor: '#fff' },
emphasis: { focus: 'ancestor' }
}]
})
}
/** 平行坐标图 */
function renderParallel(data: TempBoardAdvancedVO[]) {
const chart = getChart(parallelChartRef)
if (!chart || !data.length) return
const dimNames = ['测点', '平均温度', '最高温度', '最低温度', '标准差', '平均延迟(s)']
const fields = ['avgTemp', 'maxTemp', 'minTemp', 'tempStddev', 'avgDelay'] as const
const colors = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#9b59b6', '#1abc9c', '#e74c3c', '#2ecc71']
// IQR线
function calcAxisRange(field: string): { min: number; max: number } {
const values = data.map(d => (d as any)[field] as number).filter(v => v != null).sort((a, b) => a - b)
if (values.length === 0) return { min: 0, max: 100 }
if (values.length <= 2) {
//
const range = values[values.length - 1] - values[0]
const pad = Math.max(range * 0.1, 1)
return { min: Math.floor(values[0] - pad), max: Math.ceil(values[values.length - 1] + pad) }
}
const q1 = values[Math.floor(values.length * 0.25)]
const q3 = values[Math.floor(values.length * 0.75)]
const iqr = q3 - q1
// Q1 - 1.5*IQR ~ Q3 + 1.5*IQR
const lower = Math.min(q1 - 1.5 * iqr, values[0])
const upper = Math.max(q3 + 1.5 * iqr, values[values.length - 1])
const pad = Math.max((upper - lower) * 0.05, 0.5)
return { min: Math.floor(lower - pad), max: Math.ceil(upper + pad) }
}
//
const axisRanges = fields.map(f => calcAxisRange(f))
chart.setOption({
tooltip: { trigger: 'item' },
legend: {
data: data.map(d => d.monitorName || d.monitorId),
top: 0,
type: 'scroll',
textStyle: { color: '#666', fontSize: 11 }
},
parallelAxis: [
{ type: 'category', data: data.map(d => d.monitorName || d.monitorId), dim: 0, name: dimNames[0], axisLabel: { rotate: 30, color: '#666' } },
...fields.map((f, i) => ({
type: 'value',
dim: i + 1,
name: dimNames[i + 1],
min: axisRanges[i].min,
max: axisRanges[i].max,
nameLocation: 'end',
nameTextStyle: { color: '#333', fontSize: 12 },
axisLabel: { color: '#666', fontSize: 11 },
splitLine: { show: true, lineStyle: { color: '#eee' } },
axisTick: { show: true }
}))
],
parallel: { left: 100, right: 50, top: 50, bottom: 30 },
series: data.map((d, idx) => ({
name: d.monitorName || d.monitorId,
type: 'parallel',
data: [[d.monitorName || d.monitorId, d.avgTemp, d.maxTemp, d.minTemp, d.tempStddev, d.avgDelay]],
lineStyle: { width: 2, opacity: 0.7, color: colors[idx % colors.length] },
smooth: true
}))
})
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container-lg {
width: 100%;
min-height: 400px;
}
</style>

@ -0,0 +1,210 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item label="高温阈值">
<el-input-number v-model="queryForm.highTempThreshold" :min="0" :max="100" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="低温阈值">
<el-input-number v-model="queryForm.lowTempThreshold" :min="-50" :max="50" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="温升阈值">
<el-input-number v-model="queryForm.rapidRiseThreshold" :min="0.1" :max="10" :precision="1" :step="0.1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400">/min</span>
</el-form-item>
<el-form-item label="抖动阈值">
<el-input-number v-model="queryForm.stddevThreshold" :min="0.1" :max="10" :precision="1" :step="0.1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400">σ</span>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 异常统计卡片 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-danger">
<el-statistic title="高温事件" :value="highTempList.length" />
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-primary">
<el-statistic title="低温事件" :value="lowTempList.length" />
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-warning">
<el-statistic title="温升过快" :value="rapidRiseList.length" />
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-purple">
<el-statistic title="温度抖动" :value="jitterList.length" />
</el-card>
</el-col>
</el-row>
<!-- 高温事件 + 低温事件 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="danger" size="small">高温事件明细</el-tag>
<HelpButton title="异常分析 — 高温事件" :content="ANOMALY_HELP" />
</div>
</template>
<el-table :data="highTempList" stripe size="small" max-height="320">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }"><span class="text-red-500 font-bold">{{ row.temperature?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="collectTime" label="时间" width="160" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="primary" size="small">低温事件明细</el-tag>
<HelpButton title="异常分析 — 低温事件" :content="ANOMALY_HELP" />
</div>
</template>
<el-table :data="lowTempList" stripe size="small" max-height="320">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }"><span class="text-blue-500 font-bold">{{ row.temperature?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="collectTime" label="时间" width="160" />
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 连续高温时段 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">连续高温时段</span>
<HelpButton title="连续高温时段" :content="ANOMALY_HELP" />
</div>
</template>
<el-table :data="continuousList" stripe size="small" max-height="300">
<el-table-column prop="monitorId" label="测点ID" width="150" />
<el-table-column prop="monitorName" label="测点名称" width="180" />
<el-table-column prop="startTime" label="开始时间" width="200" />
<el-table-column prop="endTime" label="结束时间" width="200" />
<el-table-column prop="maxTemp" label="最高温" width="100" align="right">
<template #default="{ row }"><span class="text-red-500 font-bold">{{ row.maxTemp?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="sampleCount" label="持续样本数" width="100" align="right" />
</el-table>
</el-card>
<!-- 温升过快 + 温度抖动 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="warning" size="small">温升过快事件</el-tag>
<HelpButton title="温升过快事件" :content="ANOMALY_HELP" />
</div>
</template>
<el-table :data="rapidRiseList" stripe size="small" max-height="320">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="risePerMin" label="温升速率" width="90" align="right">
<template #default="{ row }"><span class="text-orange-500 font-bold">{{ row.risePerMin?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="collectTime" label="时间" width="160" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="info" size="small">温度抖动异常按小时</el-tag>
<HelpButton title="温度抖动异常" :content="ANOMALY_HELP" />
</div>
</template>
<el-table :data="jitterList" stripe size="small" max-height="320">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="tempStddev" label="标准差" width="80" align="right">
<template #default="{ row }"><span class="text-purple-500 font-bold">{{ row.tempStddev?.toFixed(4) }}</span></template>
</el-table-column>
<el-table-column prop="statTime" label="时段" width="160" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getHighTempEvents, getLowTempEvents, getContinuousHighTemp, getRapidRiseEvents, getJitterAnomalies } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardAnomalyVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { ANOMALY_HELP } from '../components/helpContent'
defineOptions({ name: 'TempBoardAnomaly' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({ highTempThreshold: 35, lowTempThreshold: 10, rapidRiseThreshold: 1.0, stddevThreshold: 2.0 })
const loading = ref(false)
const highTempList = ref<TempBoardAnomalyVO[]>([])
const lowTempList = ref<TempBoardAnomalyVO[]>([])
const continuousList = ref<TempBoardAnomalyVO[]>([])
const rapidRiseList = ref<TempBoardAnomalyVO[]>([])
const jitterList = ref<TempBoardAnomalyVO[]>([])
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) { queryForm.startTime = timeRange.value[0]; queryForm.endTime = timeRange.value[1] }
loading.value = true
try {
const [htRes, ltRes, cRes, rrRes, jRes] = await Promise.all([
getHighTempEvents(queryForm), getLowTempEvents(queryForm),
getContinuousHighTemp(queryForm), getRapidRiseEvents(queryForm), getJitterAnomalies(queryForm)
])
highTempList.value = htRes.data; lowTempList.value = ltRes.data
continuousList.value = cRes.data; rapidRiseList.value = rrRes.data; jitterList.value = jRes.data
} finally { loading.value = false }
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.kpi-card {
text-align: center;
}
.kpi-danger :deep(.el-statistic__number) { color: #f56c6c }
.kpi-primary :deep(.el-statistic__number) { color: #409eff }
.kpi-warning :deep(.el-statistic__number) { color: #e6a23c }
.kpi-purple :deep(.el-statistic__number) { color: #9b59b6 }
</style>

@ -0,0 +1,219 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 平均温度排行 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">测点平均温度排行</span>
<HelpButton title="测点平均温度排行" :content="COMPARISON_HELP" />
</div>
</template>
<div ref="avgRankChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">测点稳定性排行标准差升序</span>
<HelpButton title="测点稳定性排行" :content="COMPARISON_HELP" />
</div>
</template>
<div ref="stabilityChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
<!-- 今日vs昨日 + 波动幅度 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">今日 vs 昨日 温度对比</span>
<HelpButton title="今日vs昨日温度对比" :content="COMPARISON_HELP" />
</div>
</template>
<div ref="dailyDiffChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">波动幅度对比</span>
<HelpButton title="波动幅度对比" :content="COMPARISON_HELP" />
</div>
</template>
<div ref="fluctuationChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
<!-- 峰值对比表 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">峰值温度排行</span>
<HelpButton title="峰值温度排行" :content="COMPARISON_HELP" />
</div>
</template>
<el-table :data="peakList" stripe size="small" max-height="400">
<el-table-column prop="monitorId" label="测点ID" width="150" />
<el-table-column prop="monitorName" label="测点名称" width="200" />
<el-table-column prop="maxTemp" label="峰值温度" width="120" align="right">
<template #default="{ row }"><span class="font-bold">{{ row.maxTemp?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="avgTemp" label="平均温度" width="120" align="right">
<template #default="{ row }">{{ row.avgTemp?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="tempRange" label="波动幅度" width="120" align="right">
<template #default="{ row }">{{ row.tempRange?.toFixed(2) }}</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, type Ref } from 'vue'
import * as echarts from 'echarts'
import { getAvgTempRanking, getStabilityRanking, getDailyDiff, getPeakCompare, getFluctuationCompare } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardComparisonVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { COMPARISON_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardComparison' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({})
const loading = ref(false)
const peakList = ref<TempBoardComparisonVO[]>([])
const avgRankChartRef = ref<HTMLElement>()
const stabilityChartRef = ref<HTMLElement>()
const dailyDiffChartRef = ref<HTMLElement>()
const fluctuationChartRef = ref<HTMLElement>()
const { getChart } = useChartResize(avgRankChartRef, stabilityChartRef, dailyDiffChartRef, fluctuationChartRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
function renderBarH(el: Ref<HTMLElement | undefined>, data: TempBoardComparisonVO[], field: string, colorStart: string, colorEnd: string, label: string) {
const chart = getChart(el)
if (!chart || !data.length) return
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
const p = params[0]
return `${p.name}<br/>${p.seriesName}: <b>${p.value}</b> ${label === '℃' ? '℃' : label === 'σ' ? 'σ' : ''}`
}
},
legend: { show: false },
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: label, axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: data.map(d => d.monitorName || d.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [{
type: 'bar',
name: label,
data: data.map(d => d[field as keyof TempBoardComparisonVO]).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: colorStart },
{ offset: 1, color: colorEnd }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}', color: '#666', fontSize: 12 },
barMaxWidth: 24
}]
})
}
async function handleQuery() {
if (timeRange.value?.[0]) { queryForm.startTime = timeRange.value[0]; queryForm.endTime = timeRange.value[1] }
loading.value = true
try {
const [avgRes, stabRes, diffRes, peakRes, fluctRes] = await Promise.all([
getAvgTempRanking(queryForm), getStabilityRanking(queryForm),
getDailyDiff(queryForm), getPeakCompare(queryForm), getFluctuationCompare(queryForm)
])
peakList.value = peakRes.data
await nextTick()
renderBarH(avgRankChartRef, avgRes.data, 'avgTemp', '#f5a623', '#f7c948', '℃')
renderBarH(stabilityChartRef, stabRes.data, 'tempStddev', '#36cfc9', '#5ad8a6', 'σ')
renderBarH(fluctuationChartRef, fluctRes.data, 'tempRange', '#409eff', '#69c0ff', '℃')
// vs
const chart = getChart(dailyDiffChartRef)
if (chart && diffRes.data.length) {
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => {
let html = `<b>${params[0].name}</b><br/>`
params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: <b>${p.value}℃</b><br/>` })
return html
}
},
legend: { data: ['今日', '昨日'], top: 0, textStyle: { color: '#666' } },
grid: { left: 120, right: 30, top: 40, bottom: 20 },
yAxis: { type: 'category', data: diffRes.data.map(d => d.monitorName || d.monitorId), axisLabel: { color: '#333' } },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
series: [
{
name: '今日', type: 'bar',
data: diffRes.data.map(d => d.todayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#409eff' }, { offset: 1, color: '#69c0ff' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
},
{
name: '昨日', type: 'bar',
data: diffRes.data.map(d => d.yesterdayAvg),
itemStyle: { color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [{ offset: 0, color: '#909399' }, { offset: 1, color: '#b1b3b8' }]), borderRadius: [0, 4, 4, 0] },
barMaxWidth: 20
}
]
})
}
} finally { loading.value = false }
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container {
width: 100%;
min-height: 360px;
}
</style>

@ -0,0 +1,42 @@
<template>
<el-button :icon="InfoFilled" circle size="small" text type="info" @click="visible = true" />
<el-dialog v-model="visible" :title="title" width="640px" append-to-body destroy-on-close>
<div class="help-content" v-html="content" />
</el-dialog>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { InfoFilled } from '@element-plus/icons-vue'
defineProps<{
title: string
content: string
}>()
const visible = ref(false)
</script>
<style scoped>
.help-content {
line-height: 1.8;
font-size: 14px;
color: #333;
}
.help-content :deep(h3) {
margin: 16px 0 8px;
font-size: 15px;
color: #303133;
}
.help-content :deep(p) {
margin: 6px 0;
text-indent: 2em;
}
.help-content :deep(ul) {
padding-left: 2em;
margin: 6px 0;
}
.help-content :deep(li) {
margin: 4px 0;
}
</style>

@ -0,0 +1,211 @@
<template>
<div class="app-container">
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 380px"
/>
</el-form-item>
<el-form-item label="高温阈值">
<el-input-number v-model="queryForm.highTempThreshold" :min="0" :max="100" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="低温阈值">
<el-input-number v-model="queryForm.lowTempThreshold" :min="-50" :max="50" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="TopN">
<el-input-number v-model="queryForm.topN" :min="3" :max="50" style="width: 100px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
<el-button icon="Refresh" @click="handleReset"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- KPI 卡片 -->
<el-row :gutter="16" class="mt-4">
<el-col :span="6">
<el-card shadow="hover" class="kpi-card">
<el-statistic title="活跃测点数" :value="overview.monitorCount ?? '-'" suffix="个" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card">
<el-statistic title="平均温度" :value="overview.avgLatestTemp ?? '-'" :precision="2" suffix="℃" />
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card kpi-high">
<el-statistic title="最高温度" :value="overview.maxLatestTemp ?? '-'" :precision="2" suffix="℃">
<template #suffix>
<span class="text-xs"></span>
<span v-if="overview.maxTempMonitorId" class="text-xs text-gray-400 ml-2">{{ overview.maxTempMonitorId }}</span>
</template>
</el-statistic>
</el-card>
</el-col>
<el-col :span="6">
<el-card shadow="hover" class="kpi-card kpi-low">
<el-statistic title="最低温度" :value="overview.minLatestTemp ?? '-'" :precision="2" suffix="℃">
<template #suffix>
<span class="text-xs"></span>
<span v-if="overview.minTempMonitorId" class="text-xs text-gray-400 ml-2">{{ overview.minTempMonitorId }}</span>
</template>
</el-statistic>
</el-card>
</el-col>
</el-row>
<!-- TopN 排行 -->
<el-row :gutter="16" class="mt-4">
<el-col :span="12">
<el-card shadow="never">
<template #header><span class="font-bold">高温 Top{{ queryForm.topN }}</span></template>
<div ref="highTopNChartRef" style="height: 320px" />
</el-card>
</el-col>
<el-col :span="12">
<el-card shadow="never">
<template #header><span class="font-bold">低温 Top{{ queryForm.topN }}</span></template>
<div ref="lowTopNChartRef" style="height: 320px" />
</el-card>
</el-col>
</el-row>
<!-- 数据新鲜度 -->
<el-card shadow="never" class="mt-4">
<template #header><span class="font-bold">数据新鲜度概览</span></template>
<el-table :data="overview.freshnessList ?? []" stripe size="small" max-height="400">
<el-table-column prop="monitorId" label="测点ID" width="160" />
<el-table-column prop="monitorName" label="测点名称" width="200" />
<el-table-column prop="temperature" label="当前温度" width="120" align="right">
<template #default="{ row }">{{ row.temperature?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="collectTime" label="采集时间" width="200" />
<el-table-column prop="ageSeconds" label="延迟(秒)" width="120" align="right">
<template #default="{ row }">
<el-tag :type="row.ageSeconds < 60 ? 'success' : row.ageSeconds < 300 ? 'warning' : 'danger'" size="small">
{{ row.ageSeconds }}s
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getTempOverview, type TempBoardOverviewVO, type TempBoardQuery } from '@/api/ems/report/tempBoard'
defineOptions({ name: 'TempBoardOverview' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({
highTempThreshold: 35,
lowTempThreshold: 10,
topN: 10
})
const overview = ref<Partial<TempBoardOverviewVO>>({})
const loading = ref(false)
const highTopNChartRef = ref<HTMLElement>()
const lowTopNChartRef = ref<HTMLElement>()
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initDefaultTimeRange() {
const now = new Date()
// 8:30:00
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
// 8:30:00
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const fmt = (d: Date) => {
const pad = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())} ${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) {
queryForm.startTime = timeRange.value[0]
queryForm.endTime = timeRange.value[1]
}
loading.value = true
try {
const { data } = await getTempOverview(queryForm)
overview.value = data
await nextTick()
renderTopNCharts()
} finally {
loading.value = false
}
}
function handleReset() {
queryForm.highTempThreshold = 35
queryForm.lowTempThreshold = 10
queryForm.topN = 10
initDefaultTimeRange()
handleQuery()
}
function renderTopNCharts() {
// TopN
if (highTopNChartRef.value && overview.value.highTempTopN) {
const chart = echarts.init(highTopNChartRef.value)
const data = overview.value.highTempTopN
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 120, right: 40, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃' },
yAxis: { type: 'category', data: data.map(d => d.monitorName || d.monitorId).reverse() },
series: [{
type: 'bar',
data: data.map(d => d.temperature).reverse(),
itemStyle: { color: '#f56c6c' },
label: { show: true, position: 'right', formatter: '{c}℃' }
}]
})
}
// TopN
if (lowTopNChartRef.value && overview.value.lowTempTopN) {
const chart = echarts.init(lowTopNChartRef.value)
const data = overview.value.lowTempTopN
chart.setOption({
tooltip: { trigger: 'axis' },
grid: { left: 120, right: 40, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃' },
yAxis: { type: 'category', data: data.map(d => d.monitorName || d.monitorId).reverse() },
series: [{
type: 'bar',
data: data.map(d => d.temperature).reverse(),
itemStyle: { color: '#409eff' },
label: { show: true, position: 'right', formatter: '{c}℃' }
}]
})
}
}
onMounted(() => {
initDefaultTimeRange()
handleQuery()
})
</script>
<style scoped>
.kpi-card { text-align: center; }
.kpi-high :deep(.el-statistic__number) { color: #f56c6c; }
.kpi-low :deep(.el-statistic__number) { color: #409eff; }
</style>

@ -0,0 +1,243 @@
/**
*
*/
/** 总览模块 */
export const OVERVIEW_HELP = `
<h3></h3>
<p>"驾驶舱"</p>
<h3></h3>
<ul>
<li><b></b></li>
<li><b></b></li>
<li><b></b>便</li>
<li><b></b></li>
</ul>
<h3>TopN </h3>
<p> TopN TopN </p>
<h3></h3>
<p></p>
<ul>
<li><span style="color:#67c23a">绿</span> &lt; 60</li>
<li><span style="color:#e6a23c"></span> 60~300</li>
<li><span style="color:#f56c6c"></span> &gt; 300</li>
</ul>
<h3>使</h3>
<p> 80% 线线</p>
`
/** 实时温度模块 */
export const REALTIME_HELP = `
<h3></h3>
<p></p>
<h3></h3>
<ul>
<li><b></b></li>
<li><b></b></li>
<li><b></b></li>
</ul>
<h3></h3>
<p>便</p>
<h3></h3>
<ul>
<li><b></b></li>
<li><b></b></li>
<li><b></b></li>
</ul>
<h3></h3>
<p></p>
`
/** 趋势分析模块 */
export const TREND_HELP = `
<h3></h3>
<p>线</p>
<h3></h3>
<p>线</p>
<h3>/</h3>
<p>线线"温带"</p>
<h3></h3>
<p></p>
<h3></h3>
<p></p>
<ul>
<li></li>
<li></li>
<li>/</li>
</ul>
<h3>使</h3>
<p>"从宏观到微观"</p>
`
/** 分布分析模块 */
export const DISTRIBUTION_HELP = `
<h3></h3>
<p></p>
<h3></h3>
<p>/ 0~1010~20 </p>
<p>"大部分时间温度处于什么范围?极端温度出现的频率有多高?"</p>
<h3></h3>
<p> 1 1</p>
<p>线</p>
<h3></h3>
<p> GitHub </p>
<ul>
<li></li>
<li>/</li>
<li></li>
</ul>
<h3></h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
`
/** 异常分析模块 */
export const ANOMALY_HELP = `
<h3></h3>
<p></p>
<h3></h3>
<ul>
<li><b></b></li>
<li><b></b></li>
<li><b></b></li>
<li><b></b></li>
</ul>
<h3></h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
<h3></h3>
<ul>
<li><b></b> 1~2/min</li>
<li><b></b> 1~3σ</li>
</ul>
`
/** 对比分析模块 */
export const COMPARISON_HELP = `
<h3></h3>
<p></p>
<h3></h3>
<p>/</p>
<h3></h3>
<p></p>
<h3> vs </h3>
<p>便</p>
<ul>
<li></li>
<li></li>
</ul>
<h3></h3>
<p> - </p>
<h3></h3>
<p></p>
`
/** 数据质量模块 */
export const QUALITY_HELP = `
<h3></h3>
<p></p>
<h3></h3>
<p>collectTimerecodeTime</p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
<h3></h3>
<p> 90% </p>
<ul>
<li></li>
<li>线</li>
<li></li>
</ul>
<h3></h3>
<p></p>
<h3></h3>
<p></p>
<ul>
<li></li>
<li></li>
<li></li>
</ul>
<h3></h3>
<p></p>
`
/** 高级分析模块 */
export const ADVANCED_HELP = `
<h3>Sankey Diagram</h3>
<p></p>
<p><b></b></p>
<ul>
<li></li>
<li>线</li>
<li></li>
</ul>
<p><b></b>"主要路径""正常温区""高温区"</p>
<h3>ThemeRiver</h3>
<p>"河流"</p>
<p><b></b></p>
<ul>
<li></li>
<li></li>
<li></li>
<li></li>
</ul>
<h3>Treemap</h3>
<p></p>
<p><b></b>"温度热点"</p>
<h3>Sunburst</h3>
<p></p>
<p><b></b></p>
<h3>Parallel Coordinates</h3>
<p>线</p>
<p><b></b></p>
<ul>
<li>线</li>
<li>线</li>
<li>线线</li>
<li>"高温高波动""低温高稳定"</li>
</ul>
`

@ -0,0 +1,63 @@
import { onBeforeUnmount, onMounted, type Ref } from 'vue'
import * as echarts from 'echarts'
/**
* ECharts + resize
* @param chartRefs ref
* @returns chartInstances setOption
*/
export function useChartResize(...chartRefs: Ref<HTMLElement | undefined>[]) {
const chartInstances = new Map<HTMLElement, echarts.ECharts>()
/** 获取或创建图表实例 */
function getChart(ref: Ref<HTMLElement | undefined>): echarts.ECharts | undefined {
const el = ref.value
if (!el) return undefined
if (!chartInstances.has(el)) {
chartInstances.set(el, echarts.init(el))
}
return chartInstances.get(el)
}
/** 释放所有图表实例 */
function disposeAll() {
chartInstances.forEach(chart => chart.dispose())
chartInstances.clear()
}
let resizeObserver: ResizeObserver | null = null
onMounted(() => {
resizeObserver = new ResizeObserver(() => {
chartInstances.forEach(chart => {
if (!chart.isDisposed()) {
chart.resize()
}
})
})
// 观察所有图表容器
chartRefs.forEach(r => {
if (r.value) {
resizeObserver!.observe(r.value)
}
})
// 同时监听窗口 resize
window.addEventListener('resize', handleResize)
})
function handleResize() {
chartInstances.forEach(chart => {
if (!chart.isDisposed()) {
chart.resize()
}
})
}
onBeforeUnmount(() => {
window.removeEventListener('resize', handleResize)
resizeObserver?.disconnect()
disposeAll()
})
return { getChart, disposeAll }
}

@ -0,0 +1,254 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 温度区间分布 + 直方图 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">温度区间分布</span>
<HelpButton title="温度区间分布" :content="DISTRIBUTION_HELP" />
</div>
</template>
<div ref="intervalChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">温度直方图1分箱</span>
<HelpButton title="温度直方图" :content="DISTRIBUTION_HELP" />
</div>
</template>
<div ref="histogramChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
<!-- 日历热力图 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">日历热力图</span>
<HelpButton title="日历热力图" :content="DISTRIBUTION_HELP" />
</div>
</template>
<div ref="calendarHeatmapRef" class="chart-container" />
</el-card>
<!-- 小时热力图 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">小时热力图</span>
<HelpButton title="小时热力图" :content="DISTRIBUTION_HELP" />
</div>
</template>
<div ref="hourlyHeatmapRef" class="chart-container-lg" />
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getIntervalDistribution, getHistogram, getCalendarHeatmap, getHourlyHeatmap } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardDistributionVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { DISTRIBUTION_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardDistribution' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({})
const loading = ref(false)
const intervalChartRef = ref<HTMLElement>()
const histogramChartRef = ref<HTMLElement>()
const calendarHeatmapRef = ref<HTMLElement>()
const hourlyHeatmapRef = ref<HTMLElement>()
const { getChart } = useChartResize(intervalChartRef, histogramChartRef, calendarHeatmapRef, hourlyHeatmapRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) {
queryForm.startTime = timeRange.value[0]
queryForm.endTime = timeRange.value[1]
}
loading.value = true
try {
const [intervalRes, histRes, calRes, hourRes] = await Promise.all([
getIntervalDistribution(queryForm), getHistogram(queryForm),
getCalendarHeatmap(queryForm), getHourlyHeatmap(queryForm)
])
await nextTick()
renderIntervalChart(intervalRes.data)
renderHistogramChart(histRes.data)
renderCalendarHeatmap(calRes.data)
renderHourlyHeatmap(hourRes.data)
} finally { loading.value = false }
}
/** 温度区间分布环形图 */
function renderIntervalChart(data: TempBoardDistributionVO[]) {
const chart = getChart(intervalChartRef)
if (!chart || !data.length) return
const colorPalette = ['#409eff', '#67c23a', '#e6a23c', '#f56c6c', '#909399', '#9b59b6', '#1abc9c']
chart.setOption({
tooltip: {
trigger: 'item',
formatter: (p: any) => `${p.name}<br/>样本数: <b>${p.value}</b><br/>占比: <b>${p.percent}%</b>`
},
legend: {
orient: 'vertical', left: 'left', top: 'middle',
textStyle: { color: '#666' },
formatter: (name: string) => name
},
color: colorPalette,
series: [{
type: 'pie',
radius: ['40%', '70%'],
center: ['60%', '50%'],
data: data.map(d => ({ name: d.tempBucket, value: d.sampleCount })),
emphasis: { itemStyle: { shadowBlur: 10, shadowOffsetX: 0, shadowColor: 'rgba(0,0,0,0.5)' } },
label: { formatter: '{b}\n{d}%', fontSize: 12 },
itemStyle: { borderRadius: 6, borderColor: '#fff', borderWidth: 2 }
}]
})
}
/** 温度直方图 */
function renderHistogramChart(data: TempBoardDistributionVO[]) {
const chart = getChart(histogramChartRef)
if (!chart || !data.length) return
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>样本数: <b>${params[0].value}</b>`
},
grid: { left: 60, right: 30, top: 30, bottom: 40 },
xAxis: {
type: 'category', data: data.map(d => `${d.tempBin}`), name: '温度',
axisLabel: { color: '#666', rotate: 30 }
},
yAxis: { type: 'value', name: '样本数', axisLabel: { color: '#666' }, splitLine: { lineStyle: { type: 'dashed' } } },
series: [{
type: 'bar',
data: data.map(d => d.sampleCount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#67c23a' },
{ offset: 1, color: '#b3e19d' }
]),
borderRadius: [4, 4, 0, 0]
},
label: { show: true, position: 'top', fontSize: 10, color: '#666' },
barMaxWidth: 30
}]
})
}
/** 日历热力图 */
function renderCalendarHeatmap(data: TempBoardDistributionVO[]) {
const chart = getChart(calendarHeatmapRef)
if (!chart || !data.length) return
const dates = data.map(d => d.statDate!)
const range = dates.length ? [dates[0], dates[dates.length - 1]] : []
chart.setOption({
tooltip: {
formatter: (p: any) => `${p.value[0]}<br/>平均温度: <b>${p.value[1]?.toFixed(2)}℃</b>`
},
visualMap: {
min: 10, max: 30, calculable: true,
orient: 'horizontal', left: 'center', bottom: 0,
inRange: { color: ['#313695', '#4575b4', '#74add1', '#abd9e9', '#fee090', '#fdae61', '#f46d43', '#d73027'] },
textStyle: { color: '#666' }
},
calendar: {
range,
cellSize: ['auto', 30],
itemStyle: { borderWidth: 3, borderColor: '#fff' },
yearLabel: { show: false },
dayLabel: { color: '#666' },
monthLabel: { color: '#333' }
},
series: [{
type: 'heatmap',
coordinateSystem: 'calendar',
data: data.map(d => [d.statDate, d.avgTemp]),
itemStyle: { borderRadius: 2 }
}]
})
}
/** 小时热力图 */
function renderHourlyHeatmap(data: TempBoardDistributionVO[]) {
const chart = getChart(hourlyHeatmapRef)
if (!chart || !data.length) return
const hours = Array.from({ length: 24 }, (_, i) => `${i}:00`)
const dates = [...new Set(data.map(d => d.statDate!))]
chart.setOption({
tooltip: {
formatter: (p: any) => `${p.value[0]} ${hours[p.value[1]]}<br/>平均温度: <b>${p.value[2]?.toFixed(2)}℃</b>`
},
grid: { left: 100, right: 60, top: 10, bottom: 60 },
xAxis: { type: 'category', data: hours, splitArea: { show: true }, axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: dates, splitArea: { show: true }, axisLabel: { color: '#666' } },
visualMap: {
min: 10, max: 30, calculable: true,
orient: 'horizontal', left: 'center', bottom: 0,
inRange: { color: ['#313695', '#4575b4', '#74add1', '#abd9e9', '#fee090', '#fdae61', '#f46d43', '#d73027'] },
textStyle: { color: '#666' }
},
series: [{
type: 'heatmap',
data: data.map(d => [d.statHour, d.statDate, d.avgTemp]),
label: { show: false },
emphasis: { itemStyle: { shadowBlur: 10, shadowColor: 'rgba(0,0,0,0.5)' } },
itemStyle: { borderRadius: 2, borderColor: '#fff', borderWidth: 1 }
}]
})
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container {
width: 100%;
min-height: 360px;
}
.chart-container-lg {
width: 100%;
min-height: 400px;
}
</style>

@ -0,0 +1,241 @@
<template>
<div class="app-container">
<!-- 筛选区 -->
<el-card shadow="never" class="filter-card">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker
v-model="timeRange"
type="datetimerange"
value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至"
start-placeholder="开始时间"
end-placeholder="结束时间"
style="width: 380px"
/>
</el-form-item>
<el-form-item label="高温阈值">
<el-input-number v-model="queryForm.highTempThreshold" :min="0" :max="100" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="低温阈值">
<el-input-number v-model="queryForm.lowTempThreshold" :min="-50" :max="50" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="TopN">
<el-input-number v-model="queryForm.topN" :min="3" :max="50" style="width: 100px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
<el-button icon="Refresh" @click="handleReset"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- KPI 卡片 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card">
<el-statistic title="活跃测点数" :value="overview.monitorCount ?? '-'" suffix="个" />
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card">
<el-statistic title="平均温度" :value="overview.avgLatestTemp ?? '-'" :precision="2" suffix="℃" />
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-high">
<el-statistic title="最高温度" :value="overview.maxLatestTemp ?? '-'" :precision="2" />
<div v-if="overview.maxTempMonitorId" class="text-xs text-gray-400 mt-1">{{ overview.maxTempMonitorId }}</div>
</el-card>
</el-col>
<el-col :xs="12" :sm="6">
<el-card shadow="hover" class="kpi-card kpi-low">
<el-statistic title="最低温度" :value="overview.minLatestTemp ?? '-'" :precision="2" />
<div v-if="overview.minTempMonitorId" class="text-xs text-gray-400 mt-1">{{ overview.minTempMonitorId }}</div>
</el-card>
</el-col>
</el-row>
<!-- TopN 排行 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">高温 Top{{ queryForm.topN }}</span>
<HelpButton title="总览 — 高温 TopN 排行" :content="OVERVIEW_HELP" />
</div>
</template>
<div ref="highTopNChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">低温 Top{{ queryForm.topN }}</span>
<HelpButton title="总览 — 低温 TopN 排行" :content="OVERVIEW_HELP" />
</div>
</template>
<div ref="lowTopNChartRef" class="chart-container" />
</el-card>
</el-col>
</el-row>
<!-- 数据新鲜度 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">数据新鲜度概览</span>
<HelpButton title="数据新鲜度概览" :content="OVERVIEW_HELP" />
</div>
</template>
<el-table :data="overview.freshnessList ?? []" stripe size="small" max-height="400">
<el-table-column prop="monitorId" label="测点ID" width="160" />
<el-table-column prop="monitorName" label="测点名称" width="200" />
<el-table-column prop="temperature" label="当前温度" width="120" align="right">
<template #default="{ row }">{{ row.temperature?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="collectTime" label="采集时间" width="200" />
<el-table-column prop="ageSeconds" label="延迟" width="120" align="right">
<template #default="{ row }">
<el-tag :type="row.ageSeconds < 60 ? 'success' : row.ageSeconds < 300 ? 'warning' : 'danger'" size="small">
{{ row.ageSeconds }}s
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getTempOverview } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardOverviewVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { OVERVIEW_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardOverview' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({ highTempThreshold: 35, lowTempThreshold: 10, topN: 10 })
const overview = ref<Partial<TempBoardOverviewVO>>({})
const loading = ref(false)
const highTopNChartRef = ref<HTMLElement>()
const lowTopNChartRef = ref<HTMLElement>()
const { getChart } = useChartResize(highTopNChartRef, lowTopNChartRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initDefaultTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const fmt = (d: Date) => {
const p = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) {
queryForm.startTime = timeRange.value[0]
queryForm.endTime = timeRange.value[1]
}
loading.value = true
try {
const { data } = await getTempOverview(queryForm)
overview.value = data
await nextTick()
renderTopNCharts()
} finally { loading.value = false }
}
function handleReset() {
Object.assign(queryForm, { highTempThreshold: 35, lowTempThreshold: 10, topN: 10 })
initDefaultTimeRange()
handleQuery()
}
function renderTopNCharts() {
// TopN
const highChart = getChart(highTopNChartRef)
if (highChart && overview.value.highTempTopN) {
const d = overview.value.highTempTopN
highChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#f56c6c">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map(i => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [{
type: 'bar',
data: d.map(i => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#f56c6c' },
{ offset: 1, color: '#fab6b6' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#f56c6c', fontSize: 12 },
barMaxWidth: 24
}]
})
}
// TopN
const lowChart = getChart(lowTopNChartRef)
if (lowChart && overview.value.lowTempTopN) {
const d = overview.value.lowTempTopN
lowChart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>温度: <b style="color:#409eff">${params[0].value}℃</b>`
},
grid: { left: 120, right: 50, top: 10, bottom: 20 },
xAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' } },
yAxis: { type: 'category', data: d.map(i => i.monitorName || i.monitorId).reverse(), axisLabel: { color: '#333' } },
series: [{
type: 'bar',
data: d.map(i => i.temperature).reverse(),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 1, 0, [
{ offset: 0, color: '#409eff' },
{ offset: 1, color: '#a0cfff' }
]),
borderRadius: [0, 4, 4, 0]
},
label: { show: true, position: 'right', formatter: '{c}℃', color: '#409eff', fontSize: 12 },
barMaxWidth: 24
}]
})
}
}
onMounted(() => { initDefaultTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container {
width: 100%;
min-height: 320px;
}
.kpi-card { text-align: center }
.kpi-high :deep(.el-statistic__number) { color: #f56c6c }
.kpi-low :deep(.el-statistic__number) { color: #409eff }
</style>

@ -0,0 +1,207 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item label="采样间隔阈值">
<el-input-number v-model="queryForm.gapThresholdSeconds" :min="60" :max="86400" :step="60" style="width: 140px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="预期采样数">
<el-input-number v-model="queryForm.expectedSampleCount" :min="1" :max="100000" style="width: 140px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 入库延迟分布 + 数据完整率 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">入库延迟分布</span>
<HelpButton title="入库延迟分布" :content="QUALITY_HELP" />
</div>
</template>
<div ref="delayDistChartRef" class="chart-container" />
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">数据完整率</span>
<HelpButton title="数据完整率" :content="QUALITY_HELP" />
</div>
</template>
<el-table :data="completenessList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="actualCount" label="实际数" width="80" align="right" />
<el-table-column prop="completenessRate" label="完整率" width="120" align="right">
<template #default="{ row }">
<el-progress :percentage="Math.round((row.completenessRate ?? 0) * 100)" :stroke-width="12"
:color="row.completenessRate >= 0.9 ? '#67c23a' : row.completenessRate >= 0.7 ? '#e6a23c' : '#f56c6c'" />
</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
<!-- 时间逆序可疑数据 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">时间逆序可疑数据recodeTime &lt; collectTime</span>
<div class="flex items-center gap-2">
<el-tag type="danger" size="small">{{ timeReversalList.length }} </el-tag>
<HelpButton title="时间逆序可疑数据" :content="QUALITY_HELP" />
</div>
</div>
</template>
<el-table :data="timeReversalList" stripe size="small" max-height="300">
<el-table-column prop="monitorId" label="测点ID" width="150" />
<el-table-column prop="monitorName" label="测点名称" width="180" />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }">{{ row.temperature?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="collectTime" label="采集时间" width="200" />
<el-table-column prop="recodeTime" label="入库时间" width="200" />
<el-table-column prop="delaySeconds" label="延迟(秒)" width="100" align="right">
<template #default="{ row }"><el-tag type="danger" size="small">{{ row.delaySeconds }}s</el-tag></template>
</el-table-column>
</el-table>
</el-card>
<!-- 采样间隔异常 + 测点活跃度 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">采样间隔异常</span>
<HelpButton title="采样间隔异常" :content="QUALITY_HELP" />
</div>
</template>
<el-table :data="samplingGapList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="gapSeconds" label="间隔(秒)" width="100" align="right">
<template #default="{ row }"><el-tag type="warning" size="small">{{ row.gapSeconds }}s</el-tag></template>
</el-table-column>
<el-table-column prop="prevTime" label="前次时间" width="160" />
<el-table-column prop="collectTime" label="本次时间" width="160" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="12">
<el-card shadow="never">
<template #header>
<div class="card-header">
<span class="font-bold">测点活跃度</span>
<HelpButton title="测点活跃度" :content="QUALITY_HELP" />
</div>
</template>
<el-table :data="activityList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="actualCount" label="样本数" width="80" align="right" />
<el-table-column prop="firstTime" label="首次" width="160" />
<el-table-column prop="lastTime" label="末次" width="160" />
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick } from 'vue'
import * as echarts from 'echarts'
import { getDelayDistribution, getTimeReversal, getSamplingGapAnomalies, getCompletenessRate, getMonitorActivity } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardQualityVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { QUALITY_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardQuality' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({ gapThresholdSeconds: 300, expectedSampleCount: 1440 })
const loading = ref(false)
const delayDistChartRef = ref<HTMLElement>()
const timeReversalList = ref<TempBoardQualityVO[]>([])
const samplingGapList = ref<TempBoardQualityVO[]>([])
const completenessList = ref<TempBoardQualityVO[]>([])
const activityList = ref<TempBoardQualityVO[]>([])
const { getChart } = useChartResize(delayDistChartRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
async function handleQuery() {
if (timeRange.value?.[0]) { queryForm.startTime = timeRange.value[0]; queryForm.endTime = timeRange.value[1] }
loading.value = true
try {
const [ddRes, trRes, sgRes, crRes, actRes] = await Promise.all([
getDelayDistribution(queryForm), getTimeReversal(queryForm),
getSamplingGapAnomalies(queryForm), getCompletenessRate(queryForm), getMonitorActivity(queryForm)
])
timeReversalList.value = trRes.data; samplingGapList.value = sgRes.data
completenessList.value = crRes.data; activityList.value = actRes.data
await nextTick()
const chart = getChart(delayDistChartRef)
if (chart && ddRes.data.length) {
chart.setOption({
tooltip: {
trigger: 'axis',
axisPointer: { type: 'shadow' },
formatter: (params: any) => `${params[0].name}<br/>样本数: <b>${params[0].value}</b>`
},
grid: { left: 60, right: 30, top: 30, bottom: 30 },
xAxis: { type: 'category', data: ddRes.data.map(d => d.delayBucket), axisLabel: { color: '#666' } },
yAxis: { type: 'value', name: '样本数', axisLabel: { color: '#666' }, splitLine: { lineStyle: { type: 'dashed' } } },
series: [{
type: 'bar',
data: ddRes.data.map(d => d.sampleCount),
itemStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#e6a23c' },
{ offset: 1, color: '#f5deb3' }
]),
borderRadius: [4, 4, 0, 0]
},
label: { show: true, position: 'top', fontSize: 10, color: '#666' },
barMaxWidth: 30
}]
})
}
} finally { loading.value = false }
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container {
width: 100%;
min-height: 300px;
}
</style>

@ -0,0 +1,190 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item label="高温阈值">
<el-input-number v-model="queryForm.highTempThreshold" :min="0" :max="100" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="低温阈值">
<el-input-number v-model="queryForm.lowTempThreshold" :min="-50" :max="50" :precision="1" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item label="未更新阈值">
<el-input-number v-model="queryForm.staleThresholdSeconds" :min="60" :max="86400" :step="60" style="width: 120px" />
<span class="ml-1 text-xs text-gray-400"></span>
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 实时温度明细表 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">实时温度明细</span>
<div class="flex items-center gap-2">
<el-tag size="small"> {{ detailList.length }} </el-tag>
<HelpButton title="实时温度明细" :content="REALTIME_HELP" />
</div>
</div>
</template>
<el-table v-loading="loading" :data="detailList" stripe size="small" max-height="400">
<el-table-column prop="monitorId" label="测点ID" width="150" />
<el-table-column prop="monitorName" label="测点名称" width="180" />
<el-table-column prop="temperature" label="温度" width="100" align="right">
<template #default="{ row }">
<span :class="row.temperature >= (queryForm.highTempThreshold ?? 35) ? 'text-red-500 font-bold' : row.temperature <= (queryForm.lowTempThreshold ?? 10) ? 'text-blue-500 font-bold' : ''">
{{ row.temperature?.toFixed(2) }}
</span>
</template>
</el-table-column>
<el-table-column prop="collectTime" label="采集时间" width="200" />
<el-table-column prop="recodeTime" label="入库时间" width="200" />
<el-table-column prop="delaySeconds" label="入库延迟" width="100" align="right">
<template #default="{ row }">
<el-tag :type="row.delaySeconds < 10 ? 'success' : row.delaySeconds < 30 ? 'warning' : 'danger'" size="small">
{{ row.delaySeconds }}s
</el-tag>
</template>
</el-table-column>
<el-table-column prop="staleSeconds" label="数据新鲜度" width="100" align="right">
<template #default="{ row }">
<el-tag :type="row.staleSeconds < 60 ? 'success' : row.staleSeconds < 300 ? 'warning' : 'danger'" size="small">
{{ formatDuration(row.staleSeconds) }}
</el-tag>
</template>
</el-table-column>
</el-table>
</el-card>
<!-- 高温/低温/未更新 三列布局 -->
<el-row :gutter="16" class="mt-4">
<el-col :xs="24" :sm="8">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="danger" size="small">高温测点</el-tag>
<HelpButton title="高温测点" :content="REALTIME_HELP" />
</div>
</template>
<el-table :data="highTempList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }"><span class="text-red-500 font-bold">{{ row.temperature?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="collectTime" label="时间" width="160" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="primary" size="small">低温测点</el-tag>
<HelpButton title="低温测点" :content="REALTIME_HELP" />
</div>
</template>
<el-table :data="lowTempList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }"><span class="text-blue-500 font-bold">{{ row.temperature?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="collectTime" label="时间" width="160" />
</el-table>
</el-card>
</el-col>
<el-col :xs="24" :sm="8">
<el-card shadow="never">
<template #header>
<div class="card-header">
<el-tag type="warning" size="small">长时间未更新</el-tag>
<HelpButton title="长时间未更新" :content="REALTIME_HELP" />
</div>
</template>
<el-table :data="staleList" stripe size="small" max-height="300">
<el-table-column prop="monitorName" label="测点" show-overflow-tooltip />
<el-table-column prop="temperature" label="温度" width="80" align="right">
<template #default="{ row }">{{ row.temperature?.toFixed(2) }}</template>
</el-table-column>
<el-table-column prop="staleSeconds" label="未更新" width="80" align="right">
<template #default="{ row }">{{ formatDuration(row.staleSeconds) }}</template>
</el-table-column>
</el-table>
</el-card>
</el-col>
</el-row>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted } from 'vue'
import { getRealtimeDetail, getHighTempMonitors, getLowTempMonitors, getStaleMonitors } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardRealtimeVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { REALTIME_HELP } from '../components/helpContent'
defineOptions({ name: 'TempBoardRealtime' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({ highTempThreshold: 35, lowTempThreshold: 10, staleThresholdSeconds: 600 })
const loading = ref(false)
const detailList = ref<TempBoardRealtimeVO[]>([])
const highTempList = ref<TempBoardRealtimeVO[]>([])
const lowTempList = ref<TempBoardRealtimeVO[]>([])
const staleList = ref<TempBoardRealtimeVO[]>([])
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const fmt = (d: Date) => {
const p = (n: number) => n.toString().padStart(2, '0')
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
}
timeRange.value = [fmt(start), fmt(end)]
}
function formatDuration(seconds: number): string {
if (seconds < 60) return `${seconds}s`
if (seconds < 3600) return `${Math.floor(seconds / 60)}m${seconds % 60}s`
return `${Math.floor(seconds / 3600)}h${Math.floor((seconds % 3600) / 60)}m`
}
async function handleQuery() {
if (timeRange.value?.[0]) {
queryForm.startTime = timeRange.value[0]
queryForm.endTime = timeRange.value[1]
}
loading.value = true
try {
const [detailRes, highRes, lowRes, staleRes] = await Promise.all([
getRealtimeDetail(queryForm),
getHighTempMonitors(queryForm),
getLowTempMonitors(queryForm),
getStaleMonitors(queryForm)
])
detailList.value = detailRes.data
highTempList.value = highRes.data
lowTempList.value = lowRes.data
staleList.value = staleRes.data
} finally { loading.value = false }
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
</style>

@ -0,0 +1,199 @@
<template>
<div class="app-container">
<el-card shadow="never">
<el-form :inline="true" :model="queryForm" size="small">
<el-form-item label="时间范围">
<el-date-picker v-model="timeRange" type="datetimerange" value-format="YYYY-MM-DD HH:mm:ss"
range-separator="至" start-placeholder="开始时间" end-placeholder="结束时间" style="width: 380px" />
</el-form-item>
<el-form-item label="测点">
<el-input v-model="queryForm.monitorId" placeholder="输入测点ID" clearable style="width: 200px" />
</el-form-item>
<el-form-item>
<el-button type="primary" icon="Search" @click="handleQuery"></el-button>
</el-form-item>
</el-form>
</el-card>
<!-- 分钟趋势 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">分钟趋势</span>
<HelpButton title="分钟趋势" :content="TREND_HELP" />
</div>
</template>
<div ref="minuteChartRef" class="chart-container-lg" />
</el-card>
<!-- 小时趋势 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">小时趋势含最高/最低温带</span>
<HelpButton title="小时趋势" :content="TREND_HELP" />
</div>
</template>
<div ref="hourlyChartRef" class="chart-container-lg" />
</el-card>
<!-- 日均温趋势 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">日均温趋势</span>
<HelpButton title="日均温趋势" :content="TREND_HELP" />
</div>
</template>
<div ref="dailyChartRef" class="chart-container" />
</el-card>
<!-- 峰谷时刻表 -->
<el-card shadow="never" class="mt-4">
<template #header>
<div class="card-header">
<span class="font-bold">峰谷时刻表</span>
<HelpButton title="峰谷时刻表" :content="TREND_HELP" />
</div>
</template>
<el-table :data="peakValleyList" stripe size="small">
<el-table-column prop="monitorId" label="测点ID" width="150" />
<el-table-column prop="monitorName" label="测点名称" width="180" />
<el-table-column prop="peakTemp" label="峰值温度" width="100" align="right">
<template #default="{ row }"><span class="text-red-500 font-bold">{{ row.peakTemp?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="peakTime" label="峰值时间" width="200" />
<el-table-column prop="valleyTemp" label="谷值温度" width="100" align="right">
<template #default="{ row }"><span class="text-blue-500 font-bold">{{ row.valleyTemp?.toFixed(2) }}</span></template>
</el-table-column>
<el-table-column prop="valleyTime" label="谷值时间" width="200" />
</el-table>
</el-card>
</div>
</template>
<script setup lang="ts">
import { ref, reactive, onMounted, nextTick, type Ref } from 'vue'
import * as echarts from 'echarts'
import { getMinuteTrend, getHourlyTrend, getDailyTrend, getPeakValley } from '@/api/ems/report/tempBoard'
import type { TempBoardQuery, TempBoardTrendVO } from '@/api/ems/report/tempBoard'
import HelpButton from '../components/HelpButton.vue'
import { TREND_HELP } from '../components/helpContent'
import { useChartResize } from '../components/useChartResize'
defineOptions({ name: 'TempBoardTrend' })
const timeRange = ref<[string, string]>(['', ''])
const queryForm = reactive<TempBoardQuery>({ monitorId: '' })
const loading = ref(false)
const minuteChartRef = ref<HTMLElement>()
const hourlyChartRef = ref<HTMLElement>()
const dailyChartRef = ref<HTMLElement>()
const peakValleyList = ref<TempBoardTrendVO[]>([])
const { getChart } = useChartResize(minuteChartRef, hourlyChartRef, dailyChartRef)
/** 初始化默认时间范围昨天8:30 ~ 今天8:30 */
function initTimeRange() {
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 8, 30, 0)
const start = new Date(end.getTime() - 24 * 3600 * 1000)
const p = (n: number) => n.toString().padStart(2, '0')
const fmt = (d: Date) => `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}:${p(d.getSeconds())}`
timeRange.value = [fmt(start), fmt(end)]
}
function renderLineChart(el: Ref<HTMLElement | undefined>, data: TempBoardTrendVO[], options?: { showBand?: boolean }) {
const chart = getChart(el)
if (!chart || !data.length) return
const times = data.map(d => d.statTime!)
const series: any[] = [{
name: '平均温度',
type: 'line',
data: data.map(d => d.avgTemp),
smooth: true,
lineStyle: { width: 2, color: '#409eff' },
itemStyle: { color: '#409eff' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64,158,255,0.3)' },
{ offset: 1, color: 'rgba(64,158,255,0.02)' }
])
}
}]
if (options?.showBand) {
series.push({
name: '最高温度', type: 'line', data: data.map(d => d.maxTemp),
lineStyle: { width: 1, type: 'dashed', color: '#f56c6c' },
itemStyle: { color: '#f56c6c', opacity: 0.6 },
symbol: 'none'
})
series.push({
name: '最低温度', type: 'line', data: data.map(d => d.minTemp),
lineStyle: { width: 1, type: 'dashed', color: '#67c23a' },
itemStyle: { color: '#67c23a', opacity: 0.6 },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: 'rgba(64,158,255,0.15)' },
{ offset: 1, color: 'rgba(64,158,255,0.05)' }
])
},
symbol: 'none'
})
}
chart.setOption({
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
let html = `<b>${params[0].axisValue}</b><br/>`
params.forEach((p: any) => { html += `${p.marker} ${p.seriesName}: <b>${p.value}℃</b><br/>` })
return html
}
},
legend: { data: series.map(s => s.name), top: 0, textStyle: { color: '#666' } },
grid: { left: 60, right: 30, top: 40, bottom: 30 },
xAxis: { type: 'category', data: times, axisLabel: { color: '#999' }, boundaryGap: false },
yAxis: { type: 'value', name: '℃', axisLabel: { color: '#666' }, splitLine: { lineStyle: { type: 'dashed' } } },
series
})
}
async function handleQuery() {
if (timeRange.value?.[0]) {
queryForm.startTime = timeRange.value[0]
queryForm.endTime = timeRange.value[1]
}
loading.value = true
try {
const [minRes, hourRes, dayRes, pvRes] = await Promise.all([
getMinuteTrend(queryForm),
getHourlyTrend(queryForm),
getDailyTrend(queryForm),
getPeakValley(queryForm)
])
await nextTick()
renderLineChart(minuteChartRef, minRes.data)
renderLineChart(hourlyChartRef, hourRes.data, { showBand: true })
renderLineChart(dailyChartRef, dayRes.data, { showBand: true })
peakValleyList.value = pvRes.data
} finally { loading.value = false }
}
onMounted(() => { initTimeRange(); handleQuery() })
</script>
<style scoped>
.card-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.chart-container {
width: 100%;
min-height: 320px;
}
.chart-container-lg {
width: 100%;
min-height: 360px;
}
</style>

@ -7,6 +7,9 @@
<el-form-item label="操作地址" prop="operIp">
<el-input v-model="queryParams.operIp" placeholder="请输入操作地址" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="备注" prop="operRemark">
<el-input v-model="queryParams.operRemark" placeholder="请输入备注" clearable @keyup.enter="handleQuery" />
</el-form-item>
<el-form-item label="系统模块" prop="title">
<el-input v-model="queryParams.title" placeholder="请输入系统模块" clearable @keyup.enter="handleQuery" />
</el-form-item>
@ -73,6 +76,7 @@
<el-table-column type="selection" width="50" align="center" />
<el-table-column label="日志编号" align="center" prop="operId" />
<el-table-column label="系统模块" align="center" prop="title" :show-overflow-tooltip="true" />
<el-table-column label="备注" align="center" prop="operRemark" :show-overflow-tooltip="true" />
<el-table-column label="操作类型" align="center" prop="businessType">
<template #default="scope">
<dict-tag :options="sys_oper_type" :value="scope.row.businessType" />
@ -178,6 +182,7 @@ const data = reactive<PageData<OperLogForm, OperLogQuery>>({
operName: '',
businessType: '',
status: '',
operRemark: '',
orderByColumn: defaultSort.value.prop,
isAsc: defaultSort.value.order
},

@ -16,6 +16,11 @@
<el-descriptions-item label="操作模块">
<template #default> {{ info.title }} / {{ typeFormat(info) }} </template>
</el-descriptions-item>
<el-descriptions-item v-if="info.operRemark" label="操作备注">
<template #default>
<span>{{ info.operRemark }}</span>
</template>
</el-descriptions-item>
<el-descriptions-item label="操作方法">
<template #default>
{{ info.method }}

Loading…
Cancel
Save