feat(steady): 完善稳态数据视图功能
- 更新纵坐标刻度算法,优化小数趋势图范围显示 - 添加稳态趋势图全屏模式和共享工具组件 - 实现多图联动的鼠标悬停竖线同步功能 - 调整主线线宽分档策略,降低最大线宽限制 - 重构稳态趋势工具栏,优化谐波次数选择逻辑 - 添加周时间周期搜索支持和自定义时间范围选择 - 完善稳态数据表格和指示器浮动面板功能 - 优化稳态趋势图性能,添加LTB采样和动画控制 - 修复数据表格打开前的趋势数据验证问题 - 统一时间轴标签格式化和网格对齐处理
This commit is contained in:
@@ -81,7 +81,8 @@ PR 应包含:
|
|||||||
|
|
||||||
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。
|
- 必须显示纵坐标最大值和最小值,图表配置中应显式保留 `showMaxLabel` 与 `showMinLabel`。
|
||||||
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
|
- 纵坐标刻度值采用均分方式生成,不再使用会改变刻度间隔的“友好刻度”取整逻辑。
|
||||||
- 纵坐标最大值和最小值基于图形内真实最大值、最小值按 `1.2` 倍扩展;正数下边界使用 `0.8` 倍向下留白,负数上边界使用 `0.8` 倍向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
|
- 纵坐标最大值和最小值基于图形内真实最大值、最小值向外留白;当边界值绝对值 `> 1` 时,外扩边界使用 `1.05` 倍,内收边界使用 `0.95` 倍;当边界值绝对值 `<= 1` 时,继续使用 `1.15` / `0.85`,避免小数趋势图范围过窄或坐标轴退化。正数下边界使用内收倍数向下留白,负数上边界使用内收倍数向上留白,避免正数最小值或负数最大值被扩展到数据内侧。
|
||||||
|
- 当数据整体绝对值 `> 1` 且真实波动范围较窄时,纵坐标可启用紧凑刻度候选:以 `1.015` / `0.985` 作为最小可接受留白边界,允许增加少量均分段数并使用更小的可读步长,优先减少上下空白;紧凑候选的额外分段惩罚应低于普通候选,避免 `200-240` 这类大空白方案因为分段更少而胜出。例如 220V 附近窄幅波动不应被可读步长放大到 `200-240`,更合理时可收敛到类似 `205-235` 的范围。
|
||||||
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。
|
- 当数据同时包含正负值且正负幅值接近时,纵坐标最大值和最小值应尽量对称,按较大绝对值向外取整后取 `±同一边界`,例如最大值 `178`、最小值 `-177` 时显示为 `180` 与 `-180`。
|
||||||
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
- 当最大值、最小值相同或数据接近 `0` 时,需要补充兜底范围,避免坐标轴退化为一条线;小于 `1` 的小数范围按实际小数精度保留,不强制取整。
|
||||||
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
- 当纵坐标区间较小且均分后出现冗长小数时,应优先使用 `1`、`2`、`2.5`、`5` 等可读步长归一化刻度;必要时可少量增加分段,但必须继续保证刻度均分、最大最小值显示、真实数据完整落在坐标范围内。
|
||||||
@@ -94,6 +95,7 @@ PR 应包含:
|
|||||||
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
- 多图不得让 ECharts 按各自纵坐标标签宽度自动改变绘图区起点;应使用统一的 `grid.left`,并显式配置 `grid.containLabel: false` 或等效方案,避免 `150`、`2`、`-100` 等标签宽度差异导致曲线区域错位。
|
||||||
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
- 纵坐标标签宽度预留应按同组图中最长标签统一评估,必要时增加统一的左侧 `grid.left`,不能为单张图单独调整左边距。
|
||||||
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
|
- 横坐标首尾标签、单位文字或底部留白只能影响底部显示空间,不应改变绘图区左边界;调整 `grid.bottom`、`axisLabel.margin`、`nameGap` 时,需要同步检查多图 x=0 起点是否仍然对齐。
|
||||||
|
- `waveform` 与 `steady` 多图联动时必须保留鼠标悬停竖线,趋势图 tooltip 应使用 `axisPointer.type: 'line'`,同一联动组图表应通过 ECharts `group` 同步 tooltip / axisPointer;T1/T2 等固定标记线属于 `markLine`,不得与悬停联动竖线混淆。
|
||||||
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
|
- 验证多图趋势图时,至少检查单通道拆分图和全部通道列表图两种场景;判断标准是多张图左侧坐标轴竖线形成同一条垂直线,底部横坐标标签不遮挡、不贴线。
|
||||||
|
|
||||||
|
|
||||||
@@ -103,5 +105,5 @@ PR 应包含:
|
|||||||
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。
|
- 当前可见点数按横向缩放范围计算:`ceil(seriesDataLength * ((dataZoom.end - dataZoom.start) / 100))`。
|
||||||
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
- 初始全量展示时,点数越多线越细;横向放大后可见点数减少,线宽可逐步变粗;横向缩小或重置后线宽恢复到对应细线档位。
|
||||||
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
- Y 轴缩放、测量模式、峰值显示不改变主线线宽,避免状态切换造成额外视觉跳动。
|
||||||
- 主线最大线宽不得超过 `1.6`。
|
- 主线最大线宽不得超过 `1.3`。
|
||||||
- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.2`,`200 - 799` 使用 `1.4`,`< 200` 使用 `1.6`。
|
- 线宽分档统一为:`>= 200000` 使用 `0.35`,`100000 - 199999` 使用 `0.45`,`50000 - 99999` 使用 `0.55`,`20000 - 49999` 使用 `0.65`,`10000 - 19999` 使用 `0.75`,`5000 - 9999` 使用 `0.9`,`2000 - 4999` 使用 `1`,`800 - 1999` 使用 `1.1`,`200 - 799` 使用 `1.2`,`< 200` 使用 `1.3`。
|
||||||
|
|||||||
@@ -37,6 +37,7 @@
|
|||||||
"pinia-plugin-persistedstate": "^3.2.1",
|
"pinia-plugin-persistedstate": "^3.2.1",
|
||||||
"print-js": "^1.6.0",
|
"print-js": "^1.6.0",
|
||||||
"qs": "^6.11.2",
|
"qs": "^6.11.2",
|
||||||
|
"scichart": "^5.2.28",
|
||||||
"screenfull": "^6.0.2",
|
"screenfull": "^6.0.2",
|
||||||
"semver": "^7.3.5",
|
"semver": "^7.3.5",
|
||||||
"sortablejs": "^1.15.0",
|
"sortablejs": "^1.15.0",
|
||||||
|
|||||||
@@ -16,3 +16,9 @@ export const querySteadyTrend = (params: SteadyDataView.SteadyTrendQueryParams)
|
|||||||
export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => {
|
export const querySteadyTrendDay = (params: SteadyDataView.SteadyTrendQueryParams) => {
|
||||||
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/day', params, { loading: false })
|
return http.post<SteadyDataView.SteadyTrendQueryResult>('/steady/data-view/trend/day', params, { loading: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const querySteadyChecksquare = (params: SteadyDataView.SteadyChecksquareQueryParams) => {
|
||||||
|
return http.post<SteadyDataView.SteadyChecksquareQueryResult>('/steady/data-view/checksquare/query', params, {
|
||||||
|
loading: false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export namespace SteadyDataView {
|
|||||||
indicatorName?: string
|
indicatorName?: string
|
||||||
seriesName?: string
|
seriesName?: string
|
||||||
phase?: string
|
phase?: string
|
||||||
|
harmonicOrder?: number
|
||||||
statType?: SteadyTrendStatType
|
statType?: SteadyTrendStatType
|
||||||
unit?: string
|
unit?: string
|
||||||
points: SteadyTrendPoint[]
|
points: SteadyTrendPoint[]
|
||||||
@@ -70,7 +71,69 @@ export namespace SteadyDataView {
|
|||||||
sourcePointCount?: number
|
sourcePointCount?: number
|
||||||
displayPointCount?: number
|
displayPointCount?: number
|
||||||
loadableDays?: string[]
|
loadableDays?: string[]
|
||||||
|
queryTimeStart?: string
|
||||||
|
queryTimeEnd?: string
|
||||||
|
queryCompleted?: boolean
|
||||||
series: SteadyTrendSeries[]
|
series: SteadyTrendSeries[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareQueryParams {
|
||||||
|
lineId: string
|
||||||
|
indicatorCodes: string[]
|
||||||
|
timeStart: string
|
||||||
|
timeEnd: string
|
||||||
|
harmonicOrders?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareSegment {
|
||||||
|
startTime: string
|
||||||
|
endTime: string
|
||||||
|
status: 'NORMAL' | 'MISSING' | string
|
||||||
|
missingPointCount?: number
|
||||||
|
durationMinutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareStatSummary {
|
||||||
|
statType: SteadyTrendStatType
|
||||||
|
supported: boolean
|
||||||
|
hasData?: boolean
|
||||||
|
expectedPointCount?: number
|
||||||
|
actualPointCount?: number
|
||||||
|
missingPointCount?: number
|
||||||
|
missingRate?: number | null
|
||||||
|
missingRateText?: string | null
|
||||||
|
maxContinuousMissingMinutes?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareStatDetail {
|
||||||
|
statType: SteadyTrendStatType
|
||||||
|
supported: boolean
|
||||||
|
segments: SteadyChecksquareSegment[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareItem {
|
||||||
|
itemKey: string
|
||||||
|
indicatorCode: string
|
||||||
|
indicatorName?: string
|
||||||
|
harmonicOrder?: number | null
|
||||||
|
hasData?: boolean
|
||||||
|
expectedPointCount?: number
|
||||||
|
actualPointCount?: number
|
||||||
|
missingPointCount?: number
|
||||||
|
missingRate?: number | null
|
||||||
|
missingRateText?: string | null
|
||||||
|
maxContinuousMissingMinutes?: number
|
||||||
|
statSummaries: SteadyChecksquareStatSummary[]
|
||||||
|
statDetails: SteadyChecksquareStatDetail[]
|
||||||
|
children?: SteadyChecksquareItem[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyChecksquareQueryResult {
|
||||||
|
lineId: string
|
||||||
|
lineName?: string
|
||||||
|
timeStart: string
|
||||||
|
timeEnd: string
|
||||||
|
intervalMinutes?: number
|
||||||
|
items: SteadyChecksquareItem[]
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
frontend/src/api/steady/steadyTrend/index.ts
Normal file
18
frontend/src/api/steady/steadyTrend/index.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import http from '@/api'
|
||||||
|
import type { SteadyTrend } from './interface'
|
||||||
|
|
||||||
|
export const getSteadyTrendLedgerTree = (params?: { keyword?: string }) => {
|
||||||
|
return http.get<SteadyTrend.SteadyLedgerNode[]>('/steady/data-view/ledger-tree', params, { loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getSteadyTrendIndicatorTree = () => {
|
||||||
|
return http.get<SteadyTrend.SteadyIndicatorNode[]>('/steady/data-view/indicator-tree', {}, { loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const querySteadyTrend = (params: SteadyTrend.SteadyTrendQueryParams) => {
|
||||||
|
return http.post<SteadyTrend.SteadyTrendQueryResult>('/steady/data-view/trend/query', params, { loading: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
export const querySteadyTrendDay = (params: SteadyTrend.SteadyTrendQueryParams) => {
|
||||||
|
return http.post<SteadyTrend.SteadyTrendQueryResult>('/steady/data-view/trend/day', params, { loading: false })
|
||||||
|
}
|
||||||
77
frontend/src/api/steady/steadyTrend/interface/index.ts
Normal file
77
frontend/src/api/steady/steadyTrend/interface/index.ts
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
export namespace SteadyTrend {
|
||||||
|
export interface SteadyLedgerNode {
|
||||||
|
id: string
|
||||||
|
parentId?: string
|
||||||
|
name: string
|
||||||
|
level: 0 | 1 | 2 | 3
|
||||||
|
sort?: number
|
||||||
|
deviceCount?: number
|
||||||
|
lineCount?: number
|
||||||
|
selectable?: boolean
|
||||||
|
children?: SteadyLedgerNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyIndicatorSeriesField {
|
||||||
|
field: string
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyIndicatorNode {
|
||||||
|
id?: string
|
||||||
|
treeKey?: string
|
||||||
|
indicatorCode?: string
|
||||||
|
name: string
|
||||||
|
groupCode?: string
|
||||||
|
tableName?: string
|
||||||
|
baseFields?: string[]
|
||||||
|
phaseCodes?: string[]
|
||||||
|
seriesFields?: SteadyIndicatorSeriesField[]
|
||||||
|
supportStats?: SteadyTrendStatType[]
|
||||||
|
harmonic?: boolean
|
||||||
|
harmonicOrderStart?: number | null
|
||||||
|
harmonicOrderEnd?: number | null
|
||||||
|
unit?: string
|
||||||
|
children?: SteadyIndicatorNode[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SteadyTrendStatType = 'AVG' | 'MAX' | 'MIN' | 'CP95'
|
||||||
|
|
||||||
|
export interface SteadyTrendQueryParams {
|
||||||
|
lineIds: string[]
|
||||||
|
indicatorCodes: string[]
|
||||||
|
statTypes: SteadyTrendStatType[]
|
||||||
|
timeStart: string
|
||||||
|
timeEnd: string
|
||||||
|
qualityFlag?: number
|
||||||
|
harmonicOrders?: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendPoint {
|
||||||
|
time: string
|
||||||
|
value: number | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendSeries {
|
||||||
|
seriesKey: string
|
||||||
|
lineId: string
|
||||||
|
lineName?: string
|
||||||
|
indicatorCode: string
|
||||||
|
indicatorName?: string
|
||||||
|
seriesName?: string
|
||||||
|
phase?: string
|
||||||
|
harmonicOrder?: number
|
||||||
|
statType?: SteadyTrendStatType
|
||||||
|
unit?: string
|
||||||
|
points: SteadyTrendPoint[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendQueryResult {
|
||||||
|
sampled?: boolean
|
||||||
|
bucket?: string
|
||||||
|
sourcePointCount?: number
|
||||||
|
displayPointCount?: number
|
||||||
|
loadableDays?: string[]
|
||||||
|
series: SteadyTrendSeries[]
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
@@ -117,6 +117,57 @@ const resolveZoomRangeFromAxisValues = (startValue: unknown, endValue: unknown)
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveTimeValue = (value: unknown): number | undefined => {
|
||||||
|
if (Array.isArray(value)) return resolveTimeValue(value[0])
|
||||||
|
|
||||||
|
const numberValue = getFiniteNumber(value)
|
||||||
|
if (numberValue !== undefined) return numberValue
|
||||||
|
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeriesTimeRange = () => {
|
||||||
|
const seriesList = Array.isArray(props.options?.series) ? props.options.series : []
|
||||||
|
let minTime = Number.POSITIVE_INFINITY
|
||||||
|
let maxTime = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
seriesList.forEach((series: { data?: unknown[] }) => {
|
||||||
|
;(series.data || []).forEach(point => {
|
||||||
|
const timestamp = resolveTimeValue(point)
|
||||||
|
|
||||||
|
if (timestamp === undefined) return
|
||||||
|
|
||||||
|
minTime = Math.min(minTime, timestamp)
|
||||||
|
maxTime = Math.max(maxTime, timestamp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime
|
||||||
|
? { minTime, maxTime }
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveZoomRangeFromTimeAxisValues = (startValue: unknown, endValue: unknown) => {
|
||||||
|
const startTime = resolveTimeValue(startValue)
|
||||||
|
const endTime = resolveTimeValue(endValue)
|
||||||
|
const timeRange = getSeriesTimeRange()
|
||||||
|
|
||||||
|
if (startTime === undefined || endTime === undefined || !timeRange) return null
|
||||||
|
|
||||||
|
const rangeSize = timeRange.maxTime - timeRange.minTime
|
||||||
|
|
||||||
|
return normalizeZoomPercentRange(
|
||||||
|
((Math.min(startTime, endTime) - timeRange.minTime) / rangeSize) * 100,
|
||||||
|
((Math.max(startTime, endTime) - timeRange.minTime) / rangeSize) * 100
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
const resolveCurrentDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
const resolveCurrentDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
||||||
const dataZoomOptions = chart?.getOption?.()?.dataZoom
|
const dataZoomOptions = chart?.getOption?.()?.dataZoom
|
||||||
const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : []
|
const dataZoomList = Array.isArray(dataZoomOptions) ? dataZoomOptions : dataZoomOptions ? [dataZoomOptions] : []
|
||||||
@@ -140,6 +191,9 @@ const resolveChartDataZoomRange = (zoomPayload: ChartDataZoomPayload) => {
|
|||||||
const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
const valueRange = resolveZoomRangeFromAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||||
if (valueRange) return valueRange
|
if (valueRange) return valueRange
|
||||||
|
|
||||||
|
const timeValueRange = resolveZoomRangeFromTimeAxisValues(zoomPayload?.startValue, zoomPayload?.endValue)
|
||||||
|
if (timeValueRange) return timeValueRange
|
||||||
|
|
||||||
return resolveCurrentDataZoomRange(zoomPayload)
|
return resolveCurrentDataZoomRange(zoomPayload)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -199,9 +253,18 @@ const resetChartCursor = () => {
|
|||||||
isPanPointerDown = false
|
isPanPointerDown = false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isSliderDataZoomResizeHandle = (target: any) => {
|
||||||
|
return target?.type === 'path' && target?.draggable === true && target?.parent?.parent?.type === 'group'
|
||||||
|
}
|
||||||
|
|
||||||
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
const updatePanCursor = (event: { offsetX: number; offsetY: number }) => {
|
||||||
const viewportRoot = getChartViewportRoot()
|
const viewportRoot = getChartViewportRoot()
|
||||||
|
|
||||||
|
if (viewportRoot && isSliderDataZoomResizeHandle((event as any)?.target)) {
|
||||||
|
viewportRoot.style.cursor = 'pointer'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
if (!viewportRoot || (props.options?.activeTool !== 'pan' && props.options?.activeTool !== 'mark')) {
|
||||||
resetChartCursor()
|
resetChartCursor()
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -21,7 +21,11 @@ const COMPONENT_PATH_ALIASES: Record<string, string> = {
|
|||||||
'/event/event-list/index': '/event/eventList/index',
|
'/event/event-list/index': '/event/eventList/index',
|
||||||
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
|
// 后端菜单可能使用短横线模块名,前端页面目录统一为 steadyDataView。
|
||||||
'/steady/steady-data-view': '/steady/steadyDataView',
|
'/steady/steady-data-view': '/steady/steadyDataView',
|
||||||
'/steady/steady-data-view/index': '/steady/steadyDataView/index'
|
'/steady/steady-data-view/index': '/steady/steadyDataView/index',
|
||||||
|
'/steady/steady-trend': '/steady/steadyTrend',
|
||||||
|
'/steady/steady-trend/index': '/steady/steadyTrend/index',
|
||||||
|
'/steady/check-square': '/steady/checksquare',
|
||||||
|
'/steady/check-square/index': '/steady/checksquare/index'
|
||||||
}
|
}
|
||||||
const STATIC_ROUTE_NAMES = new Set([
|
const STATIC_ROUTE_NAMES = new Set([
|
||||||
'layout',
|
'layout',
|
||||||
@@ -34,6 +38,8 @@ const STATIC_ROUTE_NAMES = new Set([
|
|||||||
'toolAddLedger',
|
'toolAddLedger',
|
||||||
'eventList',
|
'eventList',
|
||||||
'steadyDataView',
|
'steadyDataView',
|
||||||
|
'steadyTrend',
|
||||||
|
'checksquare',
|
||||||
'systemMonitor',
|
'systemMonitor',
|
||||||
'diskMonitor',
|
'diskMonitor',
|
||||||
'403',
|
'403',
|
||||||
|
|||||||
@@ -117,6 +117,41 @@ export const staticRouter: RouteRecordRaw[] = [
|
|||||||
title: '稳态数据'
|
title: '稳态数据'
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: '/steadyTrend/index',
|
||||||
|
name: 'steadyTrend',
|
||||||
|
alias: [
|
||||||
|
'/steadyTrend',
|
||||||
|
'/steadytrend',
|
||||||
|
'/steadytrend/index',
|
||||||
|
'/steady/steadyTrend',
|
||||||
|
'/steady/steadyTrend/index',
|
||||||
|
'/steady/steady-trend',
|
||||||
|
'/steady/steady-trend/index'
|
||||||
|
],
|
||||||
|
component: () => import('@/views/steady/steadyTrend/index.vue'),
|
||||||
|
meta: {
|
||||||
|
cacheName: 'SteadyTrend',
|
||||||
|
title: '\u7a33\u6001\u8d8b\u52bf'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/checksquare/index',
|
||||||
|
name: 'checksquare',
|
||||||
|
alias: [
|
||||||
|
'/checksquare',
|
||||||
|
'/checksquare/index',
|
||||||
|
'/steady/checksquare',
|
||||||
|
'/steady/checksquare/index',
|
||||||
|
'/steady/check-square',
|
||||||
|
'/steady/check-square/index'
|
||||||
|
],
|
||||||
|
component: () => import('@/views/steady/checksquare/index.vue'),
|
||||||
|
meta: {
|
||||||
|
cacheName: 'ChecksquareView',
|
||||||
|
title: '数据验证入库'
|
||||||
|
}
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: '/403',
|
path: '/403',
|
||||||
name: '403',
|
name: '403',
|
||||||
|
|||||||
@@ -144,6 +144,18 @@ function normalizeBusinessMenu(menu: any): any {
|
|||||||
menu.component = '@/views/steady/steadyDataView/index.vue'
|
menu.component = '@/views/steady/steadyDataView/index.vue'
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSteadyTrendMenu(menu)) {
|
||||||
|
menu.path = '/steadyTrend/index'
|
||||||
|
menu.name = 'steadyTrend'
|
||||||
|
menu.component = '@/views/steady/steadyTrend/index.vue'
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isChecksquareMenu(menu)) {
|
||||||
|
menu.path = '/checksquare/index'
|
||||||
|
menu.name = 'checksquare'
|
||||||
|
menu.component = '@/views/steady/checksquare/index.vue'
|
||||||
|
}
|
||||||
|
|
||||||
return menu
|
return menu
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -173,8 +185,36 @@ function isSteadyDataViewMenu(menu: any): boolean {
|
|||||||
return title.includes('稳态数据')
|
return title.includes('稳态数据')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isSteadyTrendMenu(menu: any): boolean {
|
||||||
|
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const title = String(menu?.meta?.title ?? menu?.title ?? '')
|
||||||
|
|
||||||
|
if (normalizedName === 'steadytrend') return true
|
||||||
|
if (normalizedPath.includes('steadytrend')) return true
|
||||||
|
if (normalizedComponent.includes('steadytrend')) return true
|
||||||
|
|
||||||
|
return title.includes('\u7a33\u6001\u8d8b\u52bf')
|
||||||
|
}
|
||||||
|
|
||||||
|
function isChecksquareMenu(menu: any): boolean {
|
||||||
|
const normalizedName = String(menu?.name ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const normalizedPath = String(menu?.path ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const normalizedComponent = String(menu?.component ?? '').toLowerCase().replace(/[-_]/g, '')
|
||||||
|
const title = String(menu?.meta?.title ?? menu?.title ?? '')
|
||||||
|
|
||||||
|
if (normalizedName === 'checksquare') return true
|
||||||
|
if (normalizedPath.includes('checksquare')) return true
|
||||||
|
if (normalizedComponent.includes('checksquare')) return true
|
||||||
|
|
||||||
|
return title.includes('数据验证')
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string {
|
export function resolveBusinessMenuPath(menu: Menu.MenuOptions): string {
|
||||||
if (isEventListMenu(menu)) return '/eventList/index'
|
if (isEventListMenu(menu)) return '/eventList/index'
|
||||||
|
if (isChecksquareMenu(menu)) return '/checksquare/index'
|
||||||
|
if (isSteadyTrendMenu(menu)) return '/steadyTrend/index'
|
||||||
return isSteadyDataViewMenu(menu) ? '/steadyDataView/index' : menu.path
|
return isSteadyDataViewMenu(menu) ? '/steadyDataView/index' : menu.path
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,11 @@ assert.deepEqual(buildTimePeriodRange('day', new Date(2026, 4, 13)), [
|
|||||||
'2026-05-13 23:59:59.999'
|
'2026-05-13 23:59:59.999'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(buildTimePeriodRange('week', new Date(2026, 4, 13)), [
|
||||||
|
'2026-05-11 00:00:00.000',
|
||||||
|
'2026-05-17 23:59:59.999'
|
||||||
|
])
|
||||||
|
|
||||||
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
|
assert.deepEqual(buildTimePeriodRange('month', new Date(2026, 4, 13)), [
|
||||||
'2026-05-01 00:00:00.000',
|
'2026-05-01 00:00:00.000',
|
||||||
'2026-05-31 23:59:59.999'
|
'2026-05-31 23:59:59.999'
|
||||||
@@ -60,17 +65,25 @@ assert.deepEqual(buildTimePeriodRange('month', shiftTimePeriod('month', new Date
|
|||||||
'2026-04-30 23:59:59.999'
|
'2026-04-30 23:59:59.999'
|
||||||
])
|
])
|
||||||
|
|
||||||
|
assert.deepEqual(buildTimePeriodRange('week', shiftTimePeriod('week', new Date(2026, 4, 13), -1)), [
|
||||||
|
'2026-05-04 00:00:00.000',
|
||||||
|
'2026-05-10 23:59:59.999'
|
||||||
|
])
|
||||||
|
|
||||||
assert.equal(formatTimePeriodDateTime(new Date(2026, 4, 13, 8, 9, 10, 11)), '2026-05-13 08:09:10.011')
|
assert.equal(formatTimePeriodDateTime(new Date(2026, 4, 13, 8, 9, 10, 11)), '2026-05-13 08:09:10.011')
|
||||||
assert.equal(getTimePeriodPickerType('day'), 'date')
|
assert.equal(getTimePeriodPickerType('day'), 'date')
|
||||||
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
|
assert.equal(getTimePeriodPickerFormat('month'), 'YYYY-MM')
|
||||||
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
|
assert.equal(resolveTimePeriodUnitLabel('year'), '年')
|
||||||
|
assert.equal(resolveTimePeriodUnitLabel('custom'), '自定义')
|
||||||
|
|
||||||
const componentExpectations = [
|
const componentExpectations = [
|
||||||
['component renders unit selector', /time-period-search__unit[\s\S]*timePeriodUnitOptions/],
|
['component renders unit selector', /time-period-search__unit[\s\S]*visibleTimePeriodUnitOptions/],
|
||||||
['component renders previous period button', /ArrowLeft[\s\S]*上一个/],
|
['component renders previous period button', /ArrowLeft[\s\S]*上一个/],
|
||||||
['component renders current period button', /Clock[\s\S]*当前/],
|
['component renders current period button', /Clock[\s\S]*当前/],
|
||||||
['component renders next period button', /ArrowRight[\s\S]*下一个/],
|
['component renders next period button', /ArrowRight[\s\S]*下一个/],
|
||||||
['component renders date picker by selected unit', /getTimePeriodPickerType\(props\.unit\)/],
|
['component renders date picker by selected unit', /getTimePeriodPickerType\(props\.unit\)/],
|
||||||
|
['component supports custom datetime range picker', /type="datetimerange"[\s\S]*handleRangeChange/],
|
||||||
|
['component can limit visible units by props', /visibleUnits\?:\s*TimePeriodUnit\[\][\s\S]*visibleTimePeriodUnitOptions/],
|
||||||
['component uses fixed eventList-compatible picker width', /time-period-search__picker[\s\S]*width:\s*112px;[\s\S]*flex:\s*0 0 112px;/]
|
['component uses fixed eventList-compatible picker width', /time-period-search__picker[\s\S]*width:\s*112px;[\s\S]*flex:\s*0 0 112px;/]
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="time-period-search">
|
<div class="time-period-search">
|
||||||
<el-select class="time-period-search__unit" :model-value="unit" @update:model-value="handleUnitChange">
|
<el-select class="time-period-search__unit" :model-value="unit" @update:model-value="handleUnitChange">
|
||||||
<el-option v-for="item in timePeriodUnitOptions" :key="item.value" :label="item.label" :value="item.value" />
|
<el-option
|
||||||
|
v-for="item in visibleTimePeriodUnitOptions"
|
||||||
|
:key="item.value"
|
||||||
|
:label="item.label"
|
||||||
|
:value="item.value"
|
||||||
|
/>
|
||||||
</el-select>
|
</el-select>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="ArrowLeft"
|
:icon="ArrowLeft"
|
||||||
:title="`上一个${unitLabel}`"
|
:title="`上一个${unitLabel}`"
|
||||||
@@ -12,6 +18,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<el-date-picker
|
<el-date-picker
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__picker"
|
class="time-period-search__picker"
|
||||||
:model-value="baseDate"
|
:model-value="baseDate"
|
||||||
:type="getTimePeriodPickerType(props.unit)"
|
:type="getTimePeriodPickerType(props.unit)"
|
||||||
@@ -22,7 +29,23 @@
|
|||||||
@update:model-value="handleDateChange"
|
@update:model-value="handleDateChange"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<el-date-picker
|
||||||
|
v-else
|
||||||
|
class="time-period-search__range-picker"
|
||||||
|
:model-value="rangeValue"
|
||||||
|
type="datetimerange"
|
||||||
|
format="YYYY-MM-DD HH:mm:ss"
|
||||||
|
value-format="YYYY-MM-DD HH:mm:ss.SSS"
|
||||||
|
range-separator="至"
|
||||||
|
start-placeholder="开始时间"
|
||||||
|
end-placeholder="结束时间"
|
||||||
|
:clearable="false"
|
||||||
|
:editable="false"
|
||||||
|
@update:model-value="handleRangeChange"
|
||||||
|
/>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="ArrowRight"
|
:icon="ArrowRight"
|
||||||
:title="`下一个${unitLabel}`"
|
:title="`下一个${unitLabel}`"
|
||||||
@@ -30,6 +53,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<el-button
|
<el-button
|
||||||
|
v-if="!isCustomUnit"
|
||||||
class="time-period-search__button"
|
class="time-period-search__button"
|
||||||
:icon="Clock"
|
:icon="Clock"
|
||||||
:title="`当前${unitLabel}`"
|
:title="`当前${unitLabel}`"
|
||||||
@@ -57,15 +81,25 @@ defineOptions({
|
|||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
unit: TimePeriodUnit
|
unit: TimePeriodUnit
|
||||||
modelValue: Date | string | number
|
modelValue: Date | string | number
|
||||||
|
rangeValue?: string[]
|
||||||
|
visibleUnits?: TimePeriodUnit[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:unit': [value: TimePeriodUnit]
|
'update:unit': [value: TimePeriodUnit]
|
||||||
'update:modelValue': [value: Date]
|
'update:modelValue': [value: Date]
|
||||||
|
'update:rangeValue': [value: string[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const baseDate = computed(() => new Date(props.modelValue))
|
const baseDate = computed(() => new Date(props.modelValue))
|
||||||
const unitLabel = computed(() => resolveTimePeriodUnitLabel(props.unit))
|
const unitLabel = computed(() => resolveTimePeriodUnitLabel(props.unit))
|
||||||
|
const isCustomUnit = computed(() => props.unit === 'custom')
|
||||||
|
const visibleTimePeriodUnitOptions = computed(() => {
|
||||||
|
if (!props.visibleUnits?.length) return timePeriodUnitOptions
|
||||||
|
|
||||||
|
return timePeriodUnitOptions.filter(item => props.visibleUnits?.includes(item.value))
|
||||||
|
})
|
||||||
|
const rangeValue = computed(() => props.rangeValue || [])
|
||||||
|
|
||||||
const handleUnitChange = (value: TimePeriodUnit) => {
|
const handleUnitChange = (value: TimePeriodUnit) => {
|
||||||
emit('update:unit', value)
|
emit('update:unit', value)
|
||||||
@@ -76,6 +110,11 @@ const handleDateChange = (value: Date | string | number | null) => {
|
|||||||
emit('update:modelValue', new Date(value))
|
emit('update:modelValue', new Date(value))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleRangeChange = (value: string[] | null) => {
|
||||||
|
if (!value?.length) return
|
||||||
|
emit('update:rangeValue', value)
|
||||||
|
}
|
||||||
|
|
||||||
const shiftPeriod = (offset: number) => {
|
const shiftPeriod = (offset: number) => {
|
||||||
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
|
emit('update:modelValue', shiftTimePeriod(props.unit, baseDate.value, offset))
|
||||||
}
|
}
|
||||||
@@ -103,6 +142,12 @@ const setCurrentPeriod = () => {
|
|||||||
flex: 0 0 112px;
|
flex: 0 0 112px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.time-period-search__range-picker {
|
||||||
|
width: 360px;
|
||||||
|
flex: 1 1 360px;
|
||||||
|
min-width: 280px;
|
||||||
|
}
|
||||||
|
|
||||||
.time-period-search__button {
|
.time-period-search__button {
|
||||||
width: 28px;
|
width: 28px;
|
||||||
flex: 0 0 28px;
|
flex: 0 0 28px;
|
||||||
|
|||||||
@@ -1,21 +1,27 @@
|
|||||||
export type TimePeriodUnit = 'day' | 'month' | 'year'
|
export type TimePeriodUnit = 'day' | 'week' | 'month' | 'year' | 'custom'
|
||||||
|
|
||||||
export const timePeriodUnitOptions: { label: string; value: TimePeriodUnit }[] = [
|
export const timePeriodUnitOptions: { label: string; value: TimePeriodUnit }[] = [
|
||||||
{ label: '日', value: 'day' },
|
{ label: '日', value: 'day' },
|
||||||
|
{ label: '周', value: 'week' },
|
||||||
{ label: '月', value: 'month' },
|
{ label: '月', value: 'month' },
|
||||||
{ label: '年', value: 'year' }
|
{ label: '年', value: 'year' },
|
||||||
|
{ label: '自定义', value: 'custom' }
|
||||||
]
|
]
|
||||||
|
|
||||||
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
|
const datePickerTypeMap: Record<TimePeriodUnit, 'date' | 'month' | 'year'> = {
|
||||||
day: 'date',
|
day: 'date',
|
||||||
|
week: 'date',
|
||||||
month: 'month',
|
month: 'month',
|
||||||
year: 'year'
|
year: 'year',
|
||||||
|
custom: 'date'
|
||||||
}
|
}
|
||||||
|
|
||||||
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
|
const datePickerFormatMap: Record<TimePeriodUnit, string> = {
|
||||||
day: 'YYYY-MM-DD',
|
day: 'YYYY-MM-DD',
|
||||||
|
week: 'YYYY-MM-DD',
|
||||||
month: 'YYYY-MM',
|
month: 'YYYY-MM',
|
||||||
year: 'YYYY'
|
year: 'YYYY',
|
||||||
|
custom: 'YYYY-MM-DD'
|
||||||
}
|
}
|
||||||
|
|
||||||
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
|
const padTimeValue = (value: number, length = 2) => String(value).padStart(length, '0')
|
||||||
@@ -52,6 +58,23 @@ export const buildTimePeriodRange = (unit: TimePeriodUnit, date: Date): string[]
|
|||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unit === 'week') {
|
||||||
|
const dayOfWeek = date.getDay()
|
||||||
|
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek
|
||||||
|
const weekStart = new Date(year, month, day + mondayOffset, 0, 0, 0, 0)
|
||||||
|
const weekEnd = new Date(
|
||||||
|
weekStart.getFullYear(),
|
||||||
|
weekStart.getMonth(),
|
||||||
|
weekStart.getDate() + 6,
|
||||||
|
23,
|
||||||
|
59,
|
||||||
|
59,
|
||||||
|
999
|
||||||
|
)
|
||||||
|
|
||||||
|
return [formatTimePeriodDateTime(weekStart), formatTimePeriodDateTime(weekEnd)]
|
||||||
|
}
|
||||||
|
|
||||||
if (unit === 'year') {
|
if (unit === 'year') {
|
||||||
return [
|
return [
|
||||||
formatTimePeriodDateTime(new Date(year, 0, 1, 0, 0, 0, 0)),
|
formatTimePeriodDateTime(new Date(year, 0, 1, 0, 0, 0, 0)),
|
||||||
@@ -78,6 +101,11 @@ export const shiftTimePeriod = (unit: TimePeriodUnit, date: Date, offset: number
|
|||||||
return nextDate
|
return nextDate
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (unit === 'week') {
|
||||||
|
nextDate.setDate(nextDate.getDate() + offset * 7)
|
||||||
|
return nextDate
|
||||||
|
}
|
||||||
|
|
||||||
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
|
// 月份切换以 1 日为锚点,避免 31 日切到短月份时发生日期溢出。
|
||||||
nextDate.setDate(1)
|
nextDate.setDate(1)
|
||||||
nextDate.setMonth(nextDate.getMonth() + offset)
|
nextDate.setMonth(nextDate.getMonth() + offset)
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card checksquare-detail">
|
||||||
|
<div class="detail-header">
|
||||||
|
<div>
|
||||||
|
<div class="section-title">连续性详情</div>
|
||||||
|
<div class="section-description">
|
||||||
|
{{ selectedItem ? resolveChecksquareRowName(selectedItem) : '请选择总览表中的指标' }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-empty v-if="!selectedItem" description="请选择指标查看缺失区间" />
|
||||||
|
|
||||||
|
<template v-else>
|
||||||
|
<div class="stat-grid">
|
||||||
|
<div v-for="statType in CHECKSQUARE_STAT_TYPES" :key="statType" class="stat-card">
|
||||||
|
<span class="stat-name">{{ formatChecksquareStatType(statType) }}</span>
|
||||||
|
<span class="stat-value">{{ formatStatMissingRate(selectedItem, statType) }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table class="segment-table" :data="segments" size="small" max-height="220" empty-text="暂无缺失区间">
|
||||||
|
<el-table-column prop="statType" label="统计类型" width="96">
|
||||||
|
<template #default="{ row }">{{ formatChecksquareStatType(row.statType) }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="startTime" label="开始时间" min-width="160" />
|
||||||
|
<el-table-column prop="endTime" label="结束时间" min-width="160" />
|
||||||
|
<el-table-column prop="missingPointCount" label="缺失点数" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ row.missingPointCount ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="durationMinutes" label="持续分钟" width="100" align="right">
|
||||||
|
<template #default="{ row }">{{ row.durationMinutes ?? '-' }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</template>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
CHECKSQUARE_STAT_TYPES,
|
||||||
|
collectMissingSegments,
|
||||||
|
formatChecksquareStatType,
|
||||||
|
formatStatMissingRate,
|
||||||
|
resolveChecksquareRowName
|
||||||
|
} from '../utils/checksquareTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareDetailPanel'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
selectedItem: SteadyDataView.SteadyChecksquareItem | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const segments = computed(() => collectMissingSegments(props.selectedItem))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-detail {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 12px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.detail-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-description {
|
||||||
|
margin-top: 4px;
|
||||||
|
font-size: 13px;
|
||||||
|
color: var(--el-text-color-regular);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-name {
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1200px) {
|
||||||
|
.stat-grid {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
<template>
|
||||||
|
<section class="table-main card checksquare-summary">
|
||||||
|
<div class="table-header">
|
||||||
|
<div class="header-button-lf">
|
||||||
|
<span class="section-title">指标校验结果</span>
|
||||||
|
<span v-if="result" class="summary-meta">
|
||||||
|
<el-tag size="small" effect="plain">{{ result.lineName || result.lineId || '未返回监测点' }}</el-tag>
|
||||||
|
<el-tag v-if="result.intervalMinutes" size="small" effect="plain">
|
||||||
|
{{ result.intervalMinutes }} 分钟间隔
|
||||||
|
</el-tag>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="header-button-ri">
|
||||||
|
<el-button type="primary" plain :icon="Refresh" :loading="loading" @click="emit('refresh')">
|
||||||
|
刷新
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table
|
||||||
|
class="summary-table"
|
||||||
|
height="100%"
|
||||||
|
:data="items"
|
||||||
|
:loading="loading"
|
||||||
|
row-key="itemKey"
|
||||||
|
:tree-props="{ children: 'children' }"
|
||||||
|
highlight-current-row
|
||||||
|
empty-text="暂无校验结果"
|
||||||
|
>
|
||||||
|
<el-table-column prop="indicatorName" label="指标名称" min-width="208">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<span class="indicator-name" :title="resolveChecksquareRowName(row)">
|
||||||
|
{{ resolveChecksquareRowName(row) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="hasData" label="是否有数据" min-width="120" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-tag v-if="row.hasData !== undefined" :type="row.hasData ? 'success' : 'danger'" effect="plain">
|
||||||
|
{{ formatBooleanText(row.hasData) }}
|
||||||
|
</el-tag>
|
||||||
|
<span v-else>-</span>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="missingRate" label="总缺失率" min-width="130" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ formatMissingRate(row.missingRate, row.missingRateText) }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="平均值缺失率" min-width="130" align="center">
|
||||||
|
<template #default="{ row }">{{ formatStatMissingRate(row, 'AVG') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最大值缺失率" min-width="130" align="center">
|
||||||
|
<template #default="{ row }">{{ formatStatMissingRate(row, 'MAX') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="最小值缺失率" min-width="130" align="center">
|
||||||
|
<template #default="{ row }">{{ formatStatMissingRate(row, 'MIN') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="CP95缺失率" min-width="140" align="center">
|
||||||
|
<template #default="{ row }">{{ formatStatMissingRate(row, 'CP95') }}</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column prop="maxContinuousMissingMinutes" label="最大连续缺失" min-width="150" align="center">
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row.maxContinuousMissingMinutes ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
<el-table-column label="操作" width="96" align="center" fixed="right">
|
||||||
|
<template #default="{ row }">
|
||||||
|
<el-button type="primary" link :disabled="!hasChecksquareDetail(row)" @click="emit('detail', row)">
|
||||||
|
详情
|
||||||
|
</el-button>
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Refresh } from '@element-plus/icons-vue'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
formatBooleanText,
|
||||||
|
formatMissingRate,
|
||||||
|
formatStatMissingRate,
|
||||||
|
hasChecksquareDetail,
|
||||||
|
resolveChecksquareRowName
|
||||||
|
} from '../utils/checksquareTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareSummaryTable'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
result: SteadyDataView.SteadyChecksquareQueryResult | null
|
||||||
|
items: SteadyDataView.SteadyChecksquareItem[]
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: []
|
||||||
|
detail: [row: SteadyDataView.SteadyChecksquareItem]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-summary {
|
||||||
|
display: flex;
|
||||||
|
min-height: 0;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-table {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-title {
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.summary-meta {
|
||||||
|
display: inline-flex;
|
||||||
|
margin-left: 15px;
|
||||||
|
gap: 6px;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-name {
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
vertical-align: middle;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,392 @@
|
|||||||
|
<template>
|
||||||
|
<div class="checksquare-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsed }">
|
||||||
|
<aside class="selector-column">
|
||||||
|
<div class="ledger-panel-body">
|
||||||
|
<SteadyLedgerTree
|
||||||
|
:key="selectorResetKey"
|
||||||
|
:tree-data="ledgerTree"
|
||||||
|
:loading="loading.ledger"
|
||||||
|
:keyword="ledgerKeyword"
|
||||||
|
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||||
|
:collapsed="ledgerPanelCollapsed"
|
||||||
|
@refresh="emit('refreshLedger')"
|
||||||
|
@search="emit('ledgerSearch', $event)"
|
||||||
|
@change="emit('ledgerChange', $event)"
|
||||||
|
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsed)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="checksquare-main">
|
||||||
|
<section class="card query-card">
|
||||||
|
<div class="toolbar-field toolbar-field--time">
|
||||||
|
<span class="toolbar-field__label">时间:</span>
|
||||||
|
<TimePeriodSearch
|
||||||
|
class="checksquare-time"
|
||||||
|
:unit="form.timeUnit"
|
||||||
|
:model-value="form.timeBaseDate"
|
||||||
|
:range-value="form.timeRange"
|
||||||
|
:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"
|
||||||
|
@update:unit="handleTimeUnitChange"
|
||||||
|
@update:model-value="handleTimeBaseDateChange"
|
||||||
|
@update:range-value="handleTimeRangeChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="toolbar-field indicator-form-item">
|
||||||
|
<span class="toolbar-field__label">稳态指标:</span>
|
||||||
|
<div class="indicator-select-row">
|
||||||
|
<el-tree-select
|
||||||
|
v-model="selectedIndicatorKeys"
|
||||||
|
class="indicator-tree-select"
|
||||||
|
:data="indicatorSelectTree"
|
||||||
|
multiple
|
||||||
|
show-checkbox
|
||||||
|
collapse-tags
|
||||||
|
collapse-tags-tooltip
|
||||||
|
filterable
|
||||||
|
clearable
|
||||||
|
default-expand-all
|
||||||
|
node-key="treeKey"
|
||||||
|
value-key="treeKey"
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
placeholder="请选择指标"
|
||||||
|
@change="handleIndicatorSelectChange"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="indicator-select-node">
|
||||||
|
<span class="indicator-select-node__name">{{ data.name }}</span>
|
||||||
|
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree-select>
|
||||||
|
<el-button type="primary" plain @click="handleSelectAllIndicators">全选</el-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="query-actions">
|
||||||
|
<el-button type="primary" :icon="Search" :loading="loading.query" @click="emit('query')">
|
||||||
|
查询
|
||||||
|
</el-button>
|
||||||
|
<el-button type="primary" plain :icon="RefreshLeft" @click="emit('reset')">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div class="checksquare-content">
|
||||||
|
<ChecksquareSummaryTable
|
||||||
|
class="content-summary"
|
||||||
|
:result="result"
|
||||||
|
:items="result?.items || []"
|
||||||
|
:loading="loading.query"
|
||||||
|
@refresh="emit('query')"
|
||||||
|
@detail="openDetailDialog"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<el-dialog v-model="detailDialogVisible" title="连续性详情" width="760px" append-to-body>
|
||||||
|
<ChecksquareDetailPanel :selected-item="selectedItem" />
|
||||||
|
</el-dialog>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref, watch } from 'vue'
|
||||||
|
import { RefreshLeft, Search } from '@element-plus/icons-vue'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||||
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
import SteadyLedgerTree from '@/views/steady/steadyDataView/components/SteadyLedgerTree.vue'
|
||||||
|
import { collectLeafIndicators } from '@/views/steady/steadyDataView/utils/selectionRules'
|
||||||
|
import type { ChecksquareFormState } from '../utils/checksquarePayload'
|
||||||
|
import ChecksquareDetailPanel from './ChecksquareDetailPanel.vue'
|
||||||
|
import ChecksquareSummaryTable from './ChecksquareSummaryTable.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareWorkbench'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
form: ChecksquareFormState
|
||||||
|
ledgerTree: SteadyDataView.SteadyLedgerNode[]
|
||||||
|
indicatorTree: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
result: SteadyDataView.SteadyChecksquareQueryResult | null
|
||||||
|
selectedItem: SteadyDataView.SteadyChecksquareItem | null
|
||||||
|
loading: {
|
||||||
|
ledger: boolean
|
||||||
|
indicator: boolean
|
||||||
|
query: boolean
|
||||||
|
}
|
||||||
|
ledgerKeyword: string
|
||||||
|
defaultLedgerCheckedKeys: string[]
|
||||||
|
defaultIndicatorCheckedKeys: string[]
|
||||||
|
ledgerPanelCollapsed: boolean
|
||||||
|
selectorResetKey: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:form': [value: ChecksquareFormState]
|
||||||
|
'update:ledgerPanelCollapsed': [value: boolean]
|
||||||
|
refreshLedger: []
|
||||||
|
ledgerSearch: [value: string]
|
||||||
|
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||||
|
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
|
query: []
|
||||||
|
reset: []
|
||||||
|
selectItem: [item: SteadyDataView.SteadyChecksquareItem]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const selectedIndicatorKeys = ref<string[]>([])
|
||||||
|
const detailDialogVisible = ref(false)
|
||||||
|
const CHECKSQUARE_TIME_PERIOD_UNITS: TimePeriodUnit[] = ['day', 'week', 'month', 'year', 'custom']
|
||||||
|
|
||||||
|
const normalizeIndicatorSelectTree = (
|
||||||
|
nodes: SteadyDataView.SteadyIndicatorNode[],
|
||||||
|
parentKey = ''
|
||||||
|
): SteadyDataView.SteadyIndicatorNode[] => {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
treeKey,
|
||||||
|
children: node.children?.length ? normalizeIndicatorSelectTree(node.children, `${treeKey}-`) : undefined
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorSelectTree = computed(() => normalizeIndicatorSelectTree(props.indicatorTree))
|
||||||
|
|
||||||
|
const indicatorNodeMap = computed(() => {
|
||||||
|
const nodeMap = new Map<string, SteadyDataView.SteadyIndicatorNode>()
|
||||||
|
|
||||||
|
const collect = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
if (node.treeKey) nodeMap.set(node.treeKey, node)
|
||||||
|
if (node.children?.length) collect(node.children)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
collect(indicatorSelectTree.value)
|
||||||
|
|
||||||
|
return nodeMap
|
||||||
|
})
|
||||||
|
|
||||||
|
const collectAllIndicatorKeys = () => {
|
||||||
|
return collectLeafIndicators(indicatorSelectTree.value).map(node => node.treeKey).filter(Boolean) as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const emitSelectedIndicators = () => {
|
||||||
|
const selectedNodes = selectedIndicatorKeys.value
|
||||||
|
.map(key => indicatorNodeMap.value.get(key))
|
||||||
|
.filter(Boolean) as SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
|
||||||
|
emit('indicatorChange', collectLeafIndicators(selectedNodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIndicatorSelectChange = () => {
|
||||||
|
emitSelectedIndicators()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectAllIndicators = () => {
|
||||||
|
selectedIndicatorKeys.value = collectAllIndicatorKeys()
|
||||||
|
emitSelectedIndicators()
|
||||||
|
}
|
||||||
|
|
||||||
|
const openDetailDialog = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
emit('selectItem', item)
|
||||||
|
detailDialogVisible.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||||
|
const timeRange = unit === 'custom' ? props.form.timeRange : buildTimePeriodRange(unit, baseDate)
|
||||||
|
|
||||||
|
emit('update:form', {
|
||||||
|
...props.form,
|
||||||
|
timeUnit: unit,
|
||||||
|
timeBaseDate: baseDate,
|
||||||
|
timeRange
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeUnitChange = (value: TimePeriodUnit) => {
|
||||||
|
updateTimeRange(value, props.form.timeBaseDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeBaseDateChange = (value: Date) => {
|
||||||
|
updateTimeRange(props.form.timeUnit, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeRangeChange = (value: string[]) => {
|
||||||
|
emit('update:form', {
|
||||||
|
...props.form,
|
||||||
|
timeUnit: 'custom',
|
||||||
|
timeRange: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.defaultIndicatorCheckedKeys, indicatorSelectTree.value, props.selectorResetKey],
|
||||||
|
() => {
|
||||||
|
selectedIndicatorKeys.value = props.defaultIndicatorCheckedKeys.filter(key => indicatorNodeMap.value.has(key))
|
||||||
|
emitSelectedIndicators()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-layout.is-ledger-collapsed {
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-column {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-layout.is-ledger-collapsed .selector-column {
|
||||||
|
z-index: 4;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-panel-body {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-layout.is-ledger-collapsed .ledger-panel-body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-panel-body :deep(.steady-tree-card) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-card {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(430px, 1.35fr) minmax(0, 1fr) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field--time {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field__label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-time {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-time :deep(.time-period-search__unit) {
|
||||||
|
width: 88px;
|
||||||
|
flex: 0 0 88px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-time :deep(.time-period-search__picker) {
|
||||||
|
width: 136px;
|
||||||
|
flex: 0 0 136px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-form-item {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-select-row {
|
||||||
|
display: flex;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-tree-select {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-select-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-select-node__name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checksquare-content {
|
||||||
|
position: relative;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.content-summary {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.checksquare-layout:not(.is-ledger-collapsed) {
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.query-card {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.query-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,286 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(currentDir, '../../../..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
api: path.resolve(rootDir, 'api/steady/steadyDataView/index.ts'),
|
||||||
|
apiTypes: path.resolve(rootDir, 'api/steady/steadyDataView/interface/index.ts'),
|
||||||
|
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
|
||||||
|
workbench: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareWorkbench.vue'),
|
||||||
|
summaryTable: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareSummaryTable.vue'),
|
||||||
|
detailPanel: path.resolve(rootDir, 'views/steady/checksquare/components/ChecksquareDetailPanel.vue'),
|
||||||
|
payload: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquarePayload.ts'),
|
||||||
|
table: path.resolve(rootDir, 'views/steady/checksquare/utils/checksquareTable.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => (exists(file) ? fs.readFileSync(file, 'utf8') : '')
|
||||||
|
const exists = file => fs.existsSync(file)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['checksquare query api exists', () => /querySteadyChecksquare/.test(read(files.api))],
|
||||||
|
[
|
||||||
|
'checksquare api posts to expected endpoint',
|
||||||
|
() => /\/steady\/data-view\/checksquare\/query/.test(read(files.api))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare request type uses single lineId',
|
||||||
|
() => /interface SteadyChecksquareQueryParams[\s\S]*lineId: string/.test(read(files.apiTypes))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare request type supports per-order harmonic query only',
|
||||||
|
() => {
|
||||||
|
const typeBlock =
|
||||||
|
read(files.apiTypes).match(/interface SteadyChecksquareQueryParams\s*\{[\s\S]*?\n {4}\}/)?.[0] || ''
|
||||||
|
return /harmonicOrders\?: number\[\]/.test(typeBlock) && !/qualityFlag|statTypes|phases|lineIds/.test(typeBlock)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
['workbench component exists', () => exists(files.workbench)],
|
||||||
|
['summary table component exists', () => exists(files.summaryTable)],
|
||||||
|
['detail panel component exists', () => exists(files.detailPanel)],
|
||||||
|
['payload utility exists', () => exists(files.payload)],
|
||||||
|
['table utility exists', () => exists(files.table)],
|
||||||
|
['page reuses steady ledger tree', () => /SteadyLedgerTree/.test(read(files.workbench))],
|
||||||
|
['page reuses shared time period search', () => /TimePeriodSearch/.test(read(files.workbench))],
|
||||||
|
['payload keeps shared time period unit state', () => /timeUnit:\s*TimePeriodUnit/.test(read(files.payload))],
|
||||||
|
[
|
||||||
|
'checksquare time search exposes day week month year custom units',
|
||||||
|
() =>
|
||||||
|
/CHECKSQUARE_TIME_PERIOD_UNITS\s*:\s*TimePeriodUnit\[\]\s*=\s*\['day',\s*'week',\s*'month',\s*'year',\s*'custom'\]/.test(
|
||||||
|
read(files.workbench)
|
||||||
|
) && /:visible-units="CHECKSQUARE_TIME_PERIOD_UNITS"/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare defaults to day range',
|
||||||
|
() =>
|
||||||
|
/timeRange:\s*buildTimePeriodRange\('day',\s*baseDate\)/.test(read(files.payload)) &&
|
||||||
|
/timeUnit:\s*'day'/.test(read(files.payload))
|
||||||
|
],
|
||||||
|
['page no longer tracks floating indicator panel state', () => !/indicatorPanelCollapsed|indicator-panel-collapsed/.test(read(files.page))],
|
||||||
|
[
|
||||||
|
'query form uses tree select for steady indicators',
|
||||||
|
() =>
|
||||||
|
/<el-tree-select/.test(read(files.workbench)) &&
|
||||||
|
/v-model="selectedIndicatorKeys"/.test(read(files.workbench)) &&
|
||||||
|
/multiple/.test(read(files.workbench)) &&
|
||||||
|
/show-checkbox/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'query form keeps steady indicator immediately after time selector',
|
||||||
|
() => /class="toolbar-field toolbar-field--time"[\s\S]*class="toolbar-field indicator-form-item"/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'query form supports selecting all steady indicators',
|
||||||
|
() =>
|
||||||
|
/@click="handleSelectAllIndicators"/.test(read(files.workbench)) &&
|
||||||
|
/collectAllIndicatorKeys/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare no longer renders floating indicator panel',
|
||||||
|
() => !/SteadyIndicatorFloatingPanel|indicatorPanelCollapsedProxy|is-indicator-expanded/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
['summary table renders unsupported stats as dash', () => /formatStatMissingRate[\s\S]*'-'/.test(read(files.table))],
|
||||||
|
[
|
||||||
|
'summary table has localized AVG MAX MIN CP95 columns',
|
||||||
|
() => /平均值缺失率[\s\S]*最大值缺失率[\s\S]*最小值缺失率[\s\S]*CP95缺失率/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table utility localizes checksquare stat type names',
|
||||||
|
() => /AVG:\s*'平均值'[\s\S]*MAX:\s*'最大值'[\s\S]*MIN:\s*'最小值'/.test(read(files.table))
|
||||||
|
],
|
||||||
|
['detail panel renders missing segments', () => /segments/.test(read(files.detailPanel))]
|
||||||
|
,
|
||||||
|
[
|
||||||
|
'summary table title changed to check result',
|
||||||
|
() => /指标校验结果/.test(read(files.summaryTable)) && !/指标校验总览/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table shows monitor fallback and keeps meta 15px from title',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
return (
|
||||||
|
/class="summary-meta"/.test(summaryTable) &&
|
||||||
|
/result\.lineName\s*\|\|\s*result\.lineId\s*\|\|\s*'未返回监测点'/.test(summaryTable) &&
|
||||||
|
/\.summary-meta\s*\{[\s\S]*margin-left:\s*15px/.test(summaryTable)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table uses tree rows for harmonic results',
|
||||||
|
() =>
|
||||||
|
/row-key="itemKey"/.test(read(files.summaryTable)) &&
|
||||||
|
/tree-props/.test(read(files.summaryTable)) &&
|
||||||
|
/children/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table keeps harmonic tree rows collapsed by default',
|
||||||
|
() => !/default-expand-all/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table removes harmonic order column',
|
||||||
|
() => !/<el-table-column[^>]*prop="harmonicOrder"/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table uses balanced column widths for check result',
|
||||||
|
() => {
|
||||||
|
const summaryTable = read(files.summaryTable)
|
||||||
|
const indicatorColumn = summaryTable.match(/<el-table-column[^>]*prop="indicatorName"[^>]*>/)?.[0] || ''
|
||||||
|
const hasDataColumn = summaryTable.match(/<el-table-column[^>]*prop="hasData"[^>]*>/)?.[0] || ''
|
||||||
|
const missingRateColumn = summaryTable.match(/<el-table-column[^>]*prop="missingRate"[^>]*>/)?.[0] || ''
|
||||||
|
const avgColumn = summaryTable.match(/<el-table-column[^>]*label="平均值缺失率"[^>]*>/)?.[0] || ''
|
||||||
|
const maxColumn = summaryTable.match(/<el-table-column[^>]*label="最大值缺失率"[^>]*>/)?.[0] || ''
|
||||||
|
const minColumn = summaryTable.match(/<el-table-column[^>]*label="最小值缺失率"[^>]*>/)?.[0] || ''
|
||||||
|
const cp95Column = summaryTable.match(/<el-table-column[^>]*label="CP95缺失率"[^>]*>/)?.[0] || ''
|
||||||
|
const maxMissingColumn =
|
||||||
|
summaryTable.match(/<el-table-column[^>]*prop="maxContinuousMissingMinutes"[^>]*>/)?.[0] || ''
|
||||||
|
const operationColumn = summaryTable.match(/<el-table-column[^>]*label="操作"[^>]*>/)?.[0] || ''
|
||||||
|
const stretchColumns = [
|
||||||
|
hasDataColumn,
|
||||||
|
missingRateColumn,
|
||||||
|
avgColumn,
|
||||||
|
maxColumn,
|
||||||
|
minColumn,
|
||||||
|
cp95Column,
|
||||||
|
maxMissingColumn
|
||||||
|
]
|
||||||
|
|
||||||
|
return (
|
||||||
|
/min-width="208"/.test(indicatorColumn) &&
|
||||||
|
/min-width="120"/.test(hasDataColumn) &&
|
||||||
|
/min-width="130"/.test(missingRateColumn) &&
|
||||||
|
/min-width="130"/.test(avgColumn) &&
|
||||||
|
/min-width="130"/.test(maxColumn) &&
|
||||||
|
/min-width="130"/.test(minColumn) &&
|
||||||
|
/min-width="140"/.test(cp95Column) &&
|
||||||
|
/min-width="150"/.test(maxMissingColumn) &&
|
||||||
|
/width="96"/.test(operationColumn) &&
|
||||||
|
stretchColumns.every(column => /min-width=/.test(column) && !/\swidth=/.test(column)) &&
|
||||||
|
stretchColumns.every(column => /align="center"/.test(column)) &&
|
||||||
|
/align="center"/.test(operationColumn) &&
|
||||||
|
!/align=/.test(indicatorColumn)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'workbench query card follows steady data view toolbar sizing',
|
||||||
|
() => {
|
||||||
|
const workbench = read(files.workbench)
|
||||||
|
return (
|
||||||
|
/\.query-card\s*\{[\s\S]*display:\s*grid[\s\S]*grid-template-columns:\s*minmax\(430px,\s*1\.35fr\)\s+minmax\(0,\s*1fr\)\s+auto[\s\S]*gap:\s*10px[\s\S]*align-items:\s*center[\s\S]*padding:\s*12px/.test(
|
||||||
|
workbench
|
||||||
|
) &&
|
||||||
|
/\.checksquare-time\s*\{[\s\S]*flex:\s*1\s+1\s+0[\s\S]*min-width:\s*0/.test(workbench) &&
|
||||||
|
/\.checksquare-time\s*:deep\(\.time-period-search__unit\)\s*\{[\s\S]*width:\s*88px[\s\S]*flex:\s*0\s+0\s+88px/.test(
|
||||||
|
workbench
|
||||||
|
) &&
|
||||||
|
/\.checksquare-time\s*:deep\(\.time-period-search__picker\)\s*\{[\s\S]*width:\s*136px[\s\S]*flex:\s*0\s+0\s+136px/.test(
|
||||||
|
workbench
|
||||||
|
) &&
|
||||||
|
/\.query-actions\s*\{[\s\S]*display:\s*flex[\s\S]*justify-content:\s*flex-end[\s\S]*gap:\s*8px/.test(workbench)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'summary table exposes detail action',
|
||||||
|
() => /详情/.test(read(files.summaryTable)) && /emit\('detail'/.test(read(files.summaryTable))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'workbench shows detail in dialog instead of inline panel',
|
||||||
|
() =>
|
||||||
|
/<el-dialog/.test(read(files.workbench)) &&
|
||||||
|
/ChecksquareDetailPanel/.test(read(files.workbench)) &&
|
||||||
|
!/class="content-detail"/.test(read(files.workbench))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page builds pending rows from selected indicators',
|
||||||
|
() => /buildPendingChecksquareResult/.test(read(files.page)) && /refreshPendingResult/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page queries indicators sequentially',
|
||||||
|
() => /for \(const indicator of queryIndicators\)/.test(read(files.page)) && /mergeChecksquareIndicatorResult/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page queries harmonic orders with controlled concurrency',
|
||||||
|
() =>
|
||||||
|
/CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY\s*=\s*6/.test(read(files.page)) &&
|
||||||
|
/runChecksquareHarmonicQuery/.test(read(files.page)) &&
|
||||||
|
/workers = Array\.from\(\{[\s\S]*length: Math\.min\(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY/.test(read(files.page)) &&
|
||||||
|
/await Promise\.all\(workers\)/.test(read(files.page)) &&
|
||||||
|
/const harmonicOrders = \[\.\.\.CHECKSQUARE_HARMONIC_ORDERS\]/.test(read(files.page)) &&
|
||||||
|
/if \(orderIndex >= harmonicOrders\.length\) return/.test(read(files.page))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table pre-creates harmonic rows from second to fiftieth order',
|
||||||
|
() =>
|
||||||
|
/CHECKSQUARE_HARMONIC_ORDER_MIN\s*=\s*2/.test(read(files.table)) &&
|
||||||
|
/CHECKSQUARE_HARMONIC_ORDER_MAX\s*=\s*50/.test(read(files.table)) &&
|
||||||
|
/CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN \+ 1/.test(read(files.table)) &&
|
||||||
|
/children: isChecksquareHarmonicIndicator\(indicator\)\s*\?\s*buildPendingChecksquareHarmonicItems/.test(
|
||||||
|
read(files.table)
|
||||||
|
)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table only merges indicators whose harmonic order range intersects second to fiftieth order',
|
||||||
|
() => {
|
||||||
|
const table = read(files.table)
|
||||||
|
return (
|
||||||
|
/CHECKSQUARE_HARMONIC_ORDER_MIN/.test(table) &&
|
||||||
|
/CHECKSQUARE_HARMONIC_ORDER_MAX/.test(table) &&
|
||||||
|
/hasChecksquareHarmonicOrderRange/.test(table) &&
|
||||||
|
/isChecksquareHarmonicIndicator[\s\S]*hasChecksquareHarmonicOrderRange\(indicator\)/.test(table) &&
|
||||||
|
/const shouldMergeHarmonicItems\s*=\s*isChecksquareHarmonicIndicator\(indicator\)/.test(table) &&
|
||||||
|
/const normalItems\s*=\s*shouldMergeHarmonicItems[\s\S]*resultItems/.test(table) &&
|
||||||
|
/const harmonicItems\s*=\s*shouldMergeHarmonicItems[\s\S]*\[\]/.test(table)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table summarizes harmonic parent after all orders finish',
|
||||||
|
() =>
|
||||||
|
/buildHarmonicParentSummary/.test(read(files.table)) &&
|
||||||
|
/every\(item => isResolvedChecksquareItem\(item\)\)/.test(read(files.table)) &&
|
||||||
|
/missingPointCount \/ expectedPointCount/.test(read(files.table))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table marks harmonic parent valid when every order child has data',
|
||||||
|
() =>
|
||||||
|
/hasData:\s*children\.every\(item => item\.hasData === true\),/.test(read(files.table)) &&
|
||||||
|
!/children\.every\(item => \(item\.missingPointCount \|\| 0\) === 0\)/.test(read(files.table))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table keeps harmonic row keys stable while merging returned order results',
|
||||||
|
() =>
|
||||||
|
/normalizeChecksquareResultItemKey/.test(read(files.table)) &&
|
||||||
|
/normalizeChecksquareResultItemKey\([\s\S]*child\.itemKey/.test(read(files.table)) &&
|
||||||
|
/resolveChecksquareHarmonicOrder/.test(read(files.table)) &&
|
||||||
|
/resolveChecksquareHarmonicOrder\(item\) === child\.harmonicOrder/.test(read(files.table))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'table formats harmonic parent progress before final summary is ready',
|
||||||
|
() =>
|
||||||
|
/resolveChecksquareRowName[\s\S]*getHarmonicProgressText/.test(read(files.table)) &&
|
||||||
|
/已完成 \$\{resolvedCount\}\/\$\{totalCount\}/.test(read(files.table))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page keeps selected checksquare detail synced after async row replacement',
|
||||||
|
() =>
|
||||||
|
/syncSelectedItemWithLatestResult/.test(read(files.page)) &&
|
||||||
|
/selectedItem\.value\.itemKey/.test(read(files.page)) &&
|
||||||
|
/mergeChecksquareIndicatorResult\(queryResult\.value/.test(read(files.page))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('checksquare feature contract failed:')
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('checksquare feature contract passed')
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(currentDir, '../../../..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
page: path.resolve(rootDir, 'views/steady/checksquare/index.vue'),
|
||||||
|
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
|
||||||
|
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
|
||||||
|
authStore: path.resolve(rootDir, 'stores/modules/auth.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
const exists = file => fs.existsSync(file)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['checksquare page exists', () => exists(files.page)],
|
||||||
|
['static router registers /checksquare/index', () => /path:\s*'\/checksquare\/index'/.test(read(files.staticRouter))],
|
||||||
|
['static route name is checksquare', () => /name:\s*'checksquare'/.test(read(files.staticRouter))],
|
||||||
|
[
|
||||||
|
'static router imports checksquare page',
|
||||||
|
() => /@\/views\/steady\/checksquare\/index\.vue/.test(read(files.staticRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'static router aliases steady check-square to checksquare',
|
||||||
|
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.staticRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dynamic router aliases check-square to checksquare',
|
||||||
|
() => /\/steady\/check-square[\s\S]*\/steady\/checksquare/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dynamic router keeps checksquare static route from being overwritten',
|
||||||
|
() => /STATIC_ROUTE_NAMES[\s\S]*'checksquare'/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'auth normalizes backend checksquare menu to static entry',
|
||||||
|
() => /isChecksquareMenu[\s\S]*menu\.path\s*=\s*'\/checksquare\/index'/.test(read(files.authStore))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'auth treats 数据验证 menu title as checksquare',
|
||||||
|
() => /isChecksquareMenu[\s\S]*title\.includes\('数据验证'\)/.test(read(files.authStore))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'business menu path resolver handles checksquare',
|
||||||
|
() => /isChecksquareMenu\(menu\)[\s\S]*return\s+'\/checksquare\/index'/.test(read(files.authStore))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('checksquare route contract failed:')
|
||||||
|
for (const failure of failures) {
|
||||||
|
console.error(`- ${failure}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('checksquare route contract passed')
|
||||||
263
frontend/src/views/steady/checksquare/index.vue
Normal file
263
frontend/src/views/steady/checksquare/index.vue
Normal file
@@ -0,0 +1,263 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-box checksquare-page">
|
||||||
|
<ChecksquareWorkbench
|
||||||
|
v-model:form="formState"
|
||||||
|
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||||
|
:ledger-tree="ledgerTree"
|
||||||
|
:indicator-tree="indicatorTree"
|
||||||
|
:result="queryResult"
|
||||||
|
:selected-item="selectedItem"
|
||||||
|
:loading="loading"
|
||||||
|
:ledger-keyword="ledgerKeyword"
|
||||||
|
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
|
||||||
|
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
|
||||||
|
:selector-reset-key="selectorResetKey"
|
||||||
|
@refresh-ledger="loadLedgerTree"
|
||||||
|
@ledger-search="handleLedgerSearch"
|
||||||
|
@ledger-change="handleLedgerChange"
|
||||||
|
@indicator-change="handleIndicatorChange"
|
||||||
|
@query="handleQuery"
|
||||||
|
@reset="handleReset"
|
||||||
|
@select-item="handleSelectItem"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import {
|
||||||
|
getSteadyTrendIndicatorTree,
|
||||||
|
getSteadyTrendLedgerTree,
|
||||||
|
querySteadyChecksquare
|
||||||
|
} from '@/api/steady/steadyDataView'
|
||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import {
|
||||||
|
collectLeafIndicators,
|
||||||
|
collectSelectedLineIds,
|
||||||
|
findFirstLeafIndicator,
|
||||||
|
findFirstSelectableLedgerNode,
|
||||||
|
sortSteadyIndicatorTree
|
||||||
|
} from '@/views/steady/steadyDataView/utils/selectionRules'
|
||||||
|
import { normalizeSteadyLedgerTree } from '@/views/steady/steadyDataView/utils/ledgerTree'
|
||||||
|
import ChecksquareWorkbench from './components/ChecksquareWorkbench.vue'
|
||||||
|
import {
|
||||||
|
buildSteadyChecksquarePayload,
|
||||||
|
defaultChecksquareFormState,
|
||||||
|
validateChecksquareSelection
|
||||||
|
} from './utils/checksquarePayload'
|
||||||
|
import {
|
||||||
|
CHECKSQUARE_HARMONIC_ORDERS,
|
||||||
|
buildPendingChecksquareResult,
|
||||||
|
isChecksquareHarmonicIndicator,
|
||||||
|
mergeChecksquareIndicatorResult
|
||||||
|
} from './utils/checksquareTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'ChecksquareView'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||||
|
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||||
|
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
|
||||||
|
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
|
||||||
|
const queryResult = ref<SteadyDataView.SteadyChecksquareQueryResult | null>(null)
|
||||||
|
const selectedItem = ref<SteadyDataView.SteadyChecksquareItem | null>(null)
|
||||||
|
const formState = ref(defaultChecksquareFormState())
|
||||||
|
const ledgerKeyword = ref('')
|
||||||
|
const ledgerPanelCollapsed = ref(false)
|
||||||
|
const selectorResetKey = ref(0)
|
||||||
|
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||||
|
const defaultIndicatorCheckedKeys = ref<string[]>([])
|
||||||
|
const loading = reactive({
|
||||||
|
ledger: false,
|
||||||
|
indicator: false,
|
||||||
|
query: false
|
||||||
|
})
|
||||||
|
let querySerial = 0
|
||||||
|
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY = 6
|
||||||
|
|
||||||
|
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||||
|
|
||||||
|
const refreshPendingResult = () => {
|
||||||
|
queryResult.value = buildPendingChecksquareResult(selectedIndicators.value, formState.value)
|
||||||
|
selectedItem.value = null
|
||||||
|
}
|
||||||
|
|
||||||
|
const unwrapData = <T,>(response: { data: T } | T): T => {
|
||||||
|
if (response && typeof response === 'object' && 'data' in response) {
|
||||||
|
return (response as { data: T }).data
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const cancelCurrentQuery = () => {
|
||||||
|
querySerial += 1
|
||||||
|
loading.query = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
||||||
|
loading.ledger = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
|
||||||
|
// 数据校验沿用稳态趋势台账树,搜索返回扁平节点时仍需恢复固定层级。
|
||||||
|
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
|
||||||
|
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
||||||
|
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
||||||
|
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||||
|
} finally {
|
||||||
|
loading.ledger = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadIndicatorTree = async () => {
|
||||||
|
loading.indicator = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyTrendIndicatorTree()
|
||||||
|
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
|
||||||
|
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||||
|
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||||
|
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||||
|
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||||
|
} finally {
|
||||||
|
loading.indicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedgerSearch = (value: string) => {
|
||||||
|
ledgerKeyword.value = value
|
||||||
|
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
|
||||||
|
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
||||||
|
cancelCurrentQuery()
|
||||||
|
selectedLedgerNodes.value = nodes
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
|
cancelCurrentQuery()
|
||||||
|
selectedIndicators.value = collectLeafIndicators(nodes)
|
||||||
|
refreshPendingResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSelectItem = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
selectedItem.value = item
|
||||||
|
}
|
||||||
|
|
||||||
|
const findChecksquareItemByKey = (
|
||||||
|
items: SteadyDataView.SteadyChecksquareItem[],
|
||||||
|
itemKey: string
|
||||||
|
): SteadyDataView.SteadyChecksquareItem | null => {
|
||||||
|
for (const item of items) {
|
||||||
|
if (item.itemKey === itemKey) return item
|
||||||
|
const childItem = findChecksquareItemByKey(item.children || [], itemKey)
|
||||||
|
if (childItem) return childItem
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncSelectedItemWithLatestResult = () => {
|
||||||
|
if (!selectedItem.value?.itemKey || !queryResult.value) return
|
||||||
|
|
||||||
|
selectedItem.value = findChecksquareItemByKey(queryResult.value.items || [], selectedItem.value.itemKey)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleReset = () => {
|
||||||
|
cancelCurrentQuery()
|
||||||
|
formState.value = defaultChecksquareFormState()
|
||||||
|
selectedLedgerNodes.value = []
|
||||||
|
selectedIndicators.value = []
|
||||||
|
defaultLedgerCheckedKeys.value = []
|
||||||
|
defaultIndicatorCheckedKeys.value = []
|
||||||
|
queryResult.value = null
|
||||||
|
selectedItem.value = null
|
||||||
|
selectorResetKey.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const runChecksquareHarmonicQuery = async (
|
||||||
|
indicator: SteadyDataView.SteadyIndicatorNode,
|
||||||
|
currentQuerySerial: number
|
||||||
|
) => {
|
||||||
|
const harmonicOrders = [...CHECKSQUARE_HARMONIC_ORDERS]
|
||||||
|
let nextOrderIndex = 0
|
||||||
|
const workers = Array.from({
|
||||||
|
length: Math.min(CHECKSQUARE_HARMONIC_QUERY_CONCURRENCY, harmonicOrders.length)
|
||||||
|
}).map(async () => {
|
||||||
|
while (currentQuerySerial === querySerial) {
|
||||||
|
const orderIndex = nextOrderIndex
|
||||||
|
nextOrderIndex += 1
|
||||||
|
if (orderIndex >= harmonicOrders.length) return
|
||||||
|
|
||||||
|
const harmonicOrder = harmonicOrders[orderIndex]
|
||||||
|
|
||||||
|
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value, harmonicOrder)
|
||||||
|
const response = await querySteadyChecksquare(payload)
|
||||||
|
if (currentQuerySerial !== querySerial) return
|
||||||
|
|
||||||
|
// 谐波 2-50 次请求耗时差异较大,单次返回后立即合并,避免等待全部次数完成才刷新表格。
|
||||||
|
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
|
||||||
|
syncSelectedItemWithLatestResult()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
await Promise.all(workers)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQuery = async () => {
|
||||||
|
const selectionError = validateChecksquareSelection({
|
||||||
|
lineIds: lineIds.value,
|
||||||
|
indicators: selectedIndicators.value,
|
||||||
|
timeRange: formState.value.timeRange
|
||||||
|
})
|
||||||
|
if (selectionError) {
|
||||||
|
ElMessage.warning(selectionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentQuerySerial = ++querySerial
|
||||||
|
const queryIndicators = [...selectedIndicators.value]
|
||||||
|
loading.query = true
|
||||||
|
refreshPendingResult()
|
||||||
|
selectedItem.value = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 按指标串行校验,保证结果列表能随单个指标完成逐步回填。
|
||||||
|
for (const indicator of queryIndicators) {
|
||||||
|
if (currentQuerySerial !== querySerial) return
|
||||||
|
|
||||||
|
if (isChecksquareHarmonicIndicator(indicator)) {
|
||||||
|
await runChecksquareHarmonicQuery(indicator, currentQuerySerial)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSteadyChecksquarePayload(lineIds.value[0], [indicator], formState.value)
|
||||||
|
const response = await querySteadyChecksquare(payload)
|
||||||
|
if (currentQuerySerial !== querySerial) return
|
||||||
|
|
||||||
|
queryResult.value = mergeChecksquareIndicatorResult(queryResult.value, indicator, unwrapData(response))
|
||||||
|
syncSelectedItemWithLatestResult()
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (currentQuerySerial === querySerial) {
|
||||||
|
loading.query = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLedgerTree()
|
||||||
|
loadIndicatorTree()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.checksquare-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
|
||||||
|
export interface ChecksquareFormState {
|
||||||
|
timeRange: string[]
|
||||||
|
timeUnit: TimePeriodUnit
|
||||||
|
timeBaseDate: Date
|
||||||
|
}
|
||||||
|
|
||||||
|
export const defaultChecksquareFormState = (): ChecksquareFormState => {
|
||||||
|
const baseDate = new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange: buildTimePeriodRange('day', baseDate),
|
||||||
|
timeUnit: 'day',
|
||||||
|
timeBaseDate: baseDate
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const padTimeValue = (value: number) => `${value}`.padStart(2, '0')
|
||||||
|
|
||||||
|
export const formatChecksquareTime = (date: Date) => {
|
||||||
|
return `${date.getFullYear()}-${padTimeValue(date.getMonth() + 1)}-${padTimeValue(date.getDate())} ${padTimeValue(
|
||||||
|
date.getHours()
|
||||||
|
)}:${padTimeValue(date.getMinutes())}:${padTimeValue(date.getSeconds())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectChecksquareIndicatorCodes = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
|
return Array.from(new Set(indicators.map(item => item.indicatorCode).filter(Boolean))) as string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyChecksquarePayload = (
|
||||||
|
lineId: string,
|
||||||
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
|
formState: ChecksquareFormState,
|
||||||
|
harmonicOrder?: number
|
||||||
|
): SteadyDataView.SteadyChecksquareQueryParams => {
|
||||||
|
const payload: SteadyDataView.SteadyChecksquareQueryParams = {
|
||||||
|
lineId,
|
||||||
|
indicatorCodes: collectChecksquareIndicatorCodes(indicators),
|
||||||
|
timeStart: (formState.timeRange[0] || '').replace(/\.[^.]+$/, ''),
|
||||||
|
timeEnd: (formState.timeRange[1] || '').replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (harmonicOrder) {
|
||||||
|
payload.harmonicOrders = [harmonicOrder]
|
||||||
|
}
|
||||||
|
|
||||||
|
return payload
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateChecksquareSelection = (params: {
|
||||||
|
lineIds: string[]
|
||||||
|
indicators: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
timeRange: string[]
|
||||||
|
}) => {
|
||||||
|
const { lineIds, indicators, timeRange } = params
|
||||||
|
|
||||||
|
if (!lineIds.length) return '请选择监测点'
|
||||||
|
if (lineIds.length > 1) return '数据校验一次只能选择一个监测点'
|
||||||
|
if (!indicators.length) return '请选择指标'
|
||||||
|
if (!timeRange[0]) return '请选择开始时间'
|
||||||
|
if (!timeRange[1]) return '请选择结束时间'
|
||||||
|
if (Date.parse(timeRange[0].replace(' ', 'T')) > Date.parse(timeRange[1].replace(' ', 'T'))) {
|
||||||
|
return '开始时间不能大于结束时间'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
316
frontend/src/views/steady/checksquare/utils/checksquareTable.ts
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
|
|
||||||
|
export const CHECKSQUARE_STAT_TYPES: SteadyDataView.SteadyTrendStatType[] = ['AVG', 'MAX', 'MIN', 'CP95']
|
||||||
|
export const CHECKSQUARE_HARMONIC_ORDER_MIN = 2
|
||||||
|
export const CHECKSQUARE_HARMONIC_ORDER_MAX = 50
|
||||||
|
export const CHECKSQUARE_HARMONIC_ORDERS = Array.from(
|
||||||
|
{ length: CHECKSQUARE_HARMONIC_ORDER_MAX - CHECKSQUARE_HARMONIC_ORDER_MIN + 1 },
|
||||||
|
(_item, index) => index + CHECKSQUARE_HARMONIC_ORDER_MIN
|
||||||
|
)
|
||||||
|
const CHECKSQUARE_STAT_LABEL_MAP: Record<SteadyDataView.SteadyTrendStatType, string> = {
|
||||||
|
AVG: '平均值',
|
||||||
|
MAX: '最大值',
|
||||||
|
MIN: '最小值',
|
||||||
|
CP95: 'CP95'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatChecksquareStatType = (statType: SteadyDataView.SteadyTrendStatType | string) => {
|
||||||
|
return CHECKSQUARE_STAT_LABEL_MAP[statType as SteadyDataView.SteadyTrendStatType] || statType
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatBooleanText = (value?: boolean | null) => {
|
||||||
|
if (value === null || value === undefined) return '-'
|
||||||
|
|
||||||
|
return value ? '是' : '否'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatMissingRate = (value?: number | null, text?: string | null) => {
|
||||||
|
if (text) return text
|
||||||
|
if (value === null || value === undefined || !Number.isFinite(Number(value))) return '-'
|
||||||
|
|
||||||
|
return `${(Number(value) * 100).toFixed(2)}%`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findStatSummary = (
|
||||||
|
item: SteadyDataView.SteadyChecksquareItem,
|
||||||
|
statType: SteadyDataView.SteadyTrendStatType
|
||||||
|
) => {
|
||||||
|
return item.statSummaries?.find(summary => summary.statType === statType)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const formatStatMissingRate = (
|
||||||
|
item: SteadyDataView.SteadyChecksquareItem,
|
||||||
|
statType: SteadyDataView.SteadyTrendStatType
|
||||||
|
) => {
|
||||||
|
const summary = findStatSummary(item, statType)
|
||||||
|
if (!summary || summary.supported === false) return '-'
|
||||||
|
|
||||||
|
return formatMissingRate(summary.missingRate, summary.missingRateText)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveChecksquareRowName = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
const progressText = getHarmonicProgressText(item)
|
||||||
|
if (progressText) return `${item.indicatorName || item.indicatorCode}(${progressText})`
|
||||||
|
if (!item.harmonicOrder) return item.indicatorName || item.indicatorCode
|
||||||
|
|
||||||
|
return `${item.harmonicOrder}次`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const getHarmonicProgressText = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
const children = item.children || []
|
||||||
|
if (!children.length || !children.some(child => child.harmonicOrder)) return ''
|
||||||
|
|
||||||
|
const totalCount = children.length
|
||||||
|
const resolvedCount = children.filter(child => isResolvedChecksquareItem(child)).length
|
||||||
|
if (resolvedCount >= totalCount) return ''
|
||||||
|
|
||||||
|
return `已完成 ${resolvedCount}/${totalCount}`
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectMissingSegments = (item: SteadyDataView.SteadyChecksquareItem | null) => {
|
||||||
|
if (!item) return []
|
||||||
|
|
||||||
|
return (item.statDetails || []).flatMap(detail =>
|
||||||
|
(detail.segments || [])
|
||||||
|
.filter(segment => segment.status === 'MISSING')
|
||||||
|
.map(segment => ({
|
||||||
|
...segment,
|
||||||
|
statType: detail.statType
|
||||||
|
}))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasChecksquareDetail = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
return (item.statDetails || []).some(detail => (detail.segments || []).length)
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasChecksquareHarmonicOrderRange = (indicator: SteadyDataView.SteadyIndicatorNode) => {
|
||||||
|
const start = Number(indicator.harmonicOrderStart)
|
||||||
|
const end = Number(indicator.harmonicOrderEnd)
|
||||||
|
if (!Number.isFinite(start) || !Number.isFinite(end)) return false
|
||||||
|
|
||||||
|
const rangeStart = Math.min(start, end)
|
||||||
|
const rangeEnd = Math.max(start, end)
|
||||||
|
|
||||||
|
return rangeStart <= CHECKSQUARE_HARMONIC_ORDER_MAX && rangeEnd >= CHECKSQUARE_HARMONIC_ORDER_MIN
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isChecksquareHarmonicIndicator = (indicator: SteadyDataView.SteadyIndicatorNode) => {
|
||||||
|
// 只有指标目录明确包含 2-50 次谐波范围时,才预建谐波子行并执行逐次合并。
|
||||||
|
return hasChecksquareHarmonicOrderRange(indicator)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPendingChecksquareItem = (
|
||||||
|
indicator: SteadyDataView.SteadyIndicatorNode
|
||||||
|
): SteadyDataView.SteadyChecksquareItem => {
|
||||||
|
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
|
||||||
|
|
||||||
|
return {
|
||||||
|
itemKey: `pending|${indicatorCode}`,
|
||||||
|
indicatorCode,
|
||||||
|
indicatorName: indicator.name || indicatorCode,
|
||||||
|
children: isChecksquareHarmonicIndicator(indicator) ? buildPendingChecksquareHarmonicItems(indicator) : undefined,
|
||||||
|
statSummaries: [],
|
||||||
|
statDetails: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPendingChecksquareHarmonicItems = (
|
||||||
|
indicator: SteadyDataView.SteadyIndicatorNode
|
||||||
|
): SteadyDataView.SteadyChecksquareItem[] => {
|
||||||
|
const indicatorCode = indicator.indicatorCode || indicator.id || indicator.treeKey || indicator.name
|
||||||
|
|
||||||
|
return CHECKSQUARE_HARMONIC_ORDERS.map(harmonicOrder => ({
|
||||||
|
itemKey: `pending|${indicatorCode}|${harmonicOrder}`,
|
||||||
|
indicatorCode,
|
||||||
|
indicatorName: indicator.name || indicatorCode,
|
||||||
|
harmonicOrder,
|
||||||
|
statSummaries: [],
|
||||||
|
statDetails: []
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildPendingChecksquareResult = (
|
||||||
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
|
formState: { timeRange: string[] }
|
||||||
|
): SteadyDataView.SteadyChecksquareQueryResult => {
|
||||||
|
return {
|
||||||
|
lineId: '',
|
||||||
|
timeStart: formState.timeRange[0] || '',
|
||||||
|
timeEnd: formState.timeRange[1] || '',
|
||||||
|
items: indicators.map(buildPendingChecksquareItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const isResolvedChecksquareItem = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
return item.hasData !== undefined || item.expectedPointCount !== undefined || item.actualPointCount !== undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeChecksquareResultItemKey = (
|
||||||
|
item: SteadyDataView.SteadyChecksquareItem,
|
||||||
|
itemKey: string
|
||||||
|
): SteadyDataView.SteadyChecksquareItem => ({
|
||||||
|
...item,
|
||||||
|
itemKey
|
||||||
|
})
|
||||||
|
|
||||||
|
const isValidChecksquareHarmonicOrder = (value: number) => {
|
||||||
|
return Number.isInteger(value) && CHECKSQUARE_HARMONIC_ORDERS.includes(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseChecksquareHarmonicOrder = (value?: string | number | null) => {
|
||||||
|
const order = Number(value)
|
||||||
|
|
||||||
|
return isValidChecksquareHarmonicOrder(order) ? order : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseChecksquareHarmonicOrderFromText = (value?: string | null) => {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const orderText = value.match(/(?:^|[^\d])([2-9]|[1-4]\d|50)(?:次|[^\d]|$)/)?.[1]
|
||||||
|
|
||||||
|
return parseChecksquareHarmonicOrder(orderText)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveChecksquareHarmonicOrder = (item: SteadyDataView.SteadyChecksquareItem) => {
|
||||||
|
return (
|
||||||
|
parseChecksquareHarmonicOrder(item.harmonicOrder) ||
|
||||||
|
parseChecksquareHarmonicOrderFromText(item.itemKey) ||
|
||||||
|
parseChecksquareHarmonicOrderFromText(item.indicatorName) ||
|
||||||
|
parseChecksquareHarmonicOrderFromText(item.indicatorCode)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const sumNumber = (
|
||||||
|
items: SteadyDataView.SteadyChecksquareItem[],
|
||||||
|
getter: (item: SteadyDataView.SteadyChecksquareItem) => unknown
|
||||||
|
) => {
|
||||||
|
return items.reduce((total, item) => {
|
||||||
|
const value = Number(getter(item))
|
||||||
|
|
||||||
|
return Number.isFinite(value) ? total + value : total
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const summarizeStatType = (
|
||||||
|
items: SteadyDataView.SteadyChecksquareItem[],
|
||||||
|
statType: SteadyDataView.SteadyTrendStatType
|
||||||
|
): SteadyDataView.SteadyChecksquareStatSummary => {
|
||||||
|
const summaries = items
|
||||||
|
.map(item => findStatSummary(item, statType))
|
||||||
|
.filter((summary): summary is SteadyDataView.SteadyChecksquareStatSummary => Boolean(summary))
|
||||||
|
const supportedSummaries = summaries.filter(summary => summary.supported !== false)
|
||||||
|
const expectedPointCount = supportedSummaries.reduce((total, summary) => total + (summary.expectedPointCount || 0), 0)
|
||||||
|
const actualPointCount = supportedSummaries.reduce((total, summary) => total + (summary.actualPointCount || 0), 0)
|
||||||
|
const missingPointCount = supportedSummaries.reduce((total, summary) => total + (summary.missingPointCount || 0), 0)
|
||||||
|
const maxContinuousMissingMinutes = Math.max(
|
||||||
|
0,
|
||||||
|
...supportedSummaries.map(summary => summary.maxContinuousMissingMinutes || 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
statType,
|
||||||
|
supported: supportedSummaries.length > 0,
|
||||||
|
hasData: supportedSummaries.length > 0 && supportedSummaries.every(summary => summary.hasData === true),
|
||||||
|
expectedPointCount,
|
||||||
|
actualPointCount,
|
||||||
|
missingPointCount,
|
||||||
|
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
|
||||||
|
maxContinuousMissingMinutes
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildHarmonicParentSummary = (
|
||||||
|
parentItem: SteadyDataView.SteadyChecksquareItem,
|
||||||
|
children: SteadyDataView.SteadyChecksquareItem[]
|
||||||
|
): SteadyDataView.SteadyChecksquareItem => {
|
||||||
|
if (!children.length || !children.every(item => isResolvedChecksquareItem(item))) {
|
||||||
|
return {
|
||||||
|
itemKey: parentItem.itemKey,
|
||||||
|
indicatorCode: parentItem.indicatorCode,
|
||||||
|
indicatorName: parentItem.indicatorName,
|
||||||
|
statSummaries: [],
|
||||||
|
statDetails: [],
|
||||||
|
children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectedPointCount = sumNumber(children, item => item.expectedPointCount)
|
||||||
|
const actualPointCount = sumNumber(children, item => item.actualPointCount)
|
||||||
|
const missingPointCount = sumNumber(children, item => item.missingPointCount)
|
||||||
|
const maxContinuousMissingMinutes = Math.max(0, ...children.map(item => item.maxContinuousMissingMinutes || 0))
|
||||||
|
const statSummaries = CHECKSQUARE_STAT_TYPES.map(statType => summarizeStatType(children, statType))
|
||||||
|
|
||||||
|
return {
|
||||||
|
...parentItem,
|
||||||
|
hasData: children.every(item => item.hasData === true),
|
||||||
|
expectedPointCount,
|
||||||
|
actualPointCount,
|
||||||
|
missingPointCount,
|
||||||
|
missingRate: expectedPointCount ? missingPointCount / expectedPointCount : null,
|
||||||
|
missingRateText: expectedPointCount ? undefined : '-',
|
||||||
|
maxContinuousMissingMinutes,
|
||||||
|
statSummaries,
|
||||||
|
statDetails: [],
|
||||||
|
children
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const mergeChecksquareIndicatorResult = (
|
||||||
|
currentResult: SteadyDataView.SteadyChecksquareQueryResult | null,
|
||||||
|
indicator: SteadyDataView.SteadyIndicatorNode,
|
||||||
|
indicatorResult: SteadyDataView.SteadyChecksquareQueryResult
|
||||||
|
): SteadyDataView.SteadyChecksquareQueryResult => {
|
||||||
|
const pendingResult = currentResult || {
|
||||||
|
lineId: indicatorResult.lineId || '',
|
||||||
|
lineName: indicatorResult.lineName,
|
||||||
|
timeStart: indicatorResult.timeStart || '',
|
||||||
|
timeEnd: indicatorResult.timeEnd || '',
|
||||||
|
intervalMinutes: indicatorResult.intervalMinutes,
|
||||||
|
items: [buildPendingChecksquareItem(indicator)]
|
||||||
|
}
|
||||||
|
const indicatorCode = indicator.indicatorCode
|
||||||
|
const resultItems = indicatorResult.items || []
|
||||||
|
const shouldMergeHarmonicItems = isChecksquareHarmonicIndicator(indicator)
|
||||||
|
const normalItems = shouldMergeHarmonicItems
|
||||||
|
? resultItems.filter(item => !resolveChecksquareHarmonicOrder(item))
|
||||||
|
: resultItems
|
||||||
|
const harmonicItems = shouldMergeHarmonicItems
|
||||||
|
? resultItems.filter(item => resolveChecksquareHarmonicOrder(item))
|
||||||
|
: []
|
||||||
|
const currentItem = pendingResult.items.find(item => item.indicatorCode === indicatorCode)
|
||||||
|
const mergedItem = normalItems[0] || currentItem || buildPendingChecksquareItem(indicator)
|
||||||
|
const currentChildren = currentItem?.children || mergedItem.children || []
|
||||||
|
const mergedChildren = currentChildren.length
|
||||||
|
? currentChildren.map(child => {
|
||||||
|
const replacement = harmonicItems.find(item => resolveChecksquareHarmonicOrder(item) === child.harmonicOrder)
|
||||||
|
|
||||||
|
return replacement
|
||||||
|
? normalizeChecksquareResultItemKey(
|
||||||
|
{
|
||||||
|
...replacement,
|
||||||
|
harmonicOrder: child.harmonicOrder
|
||||||
|
},
|
||||||
|
child.itemKey
|
||||||
|
)
|
||||||
|
: child
|
||||||
|
})
|
||||||
|
: harmonicItems
|
||||||
|
const replacement = {
|
||||||
|
...mergedItem,
|
||||||
|
itemKey: currentItem?.itemKey || mergedItem.itemKey,
|
||||||
|
indicatorName: indicator.name || mergedItem.indicatorName || mergedItem.indicatorCode,
|
||||||
|
children: mergedChildren.length ? mergedChildren : undefined
|
||||||
|
} as SteadyDataView.SteadyChecksquareItem
|
||||||
|
const finalReplacement = replacement.children?.length ? buildHarmonicParentSummary(replacement, replacement.children) : replacement
|
||||||
|
|
||||||
|
return {
|
||||||
|
...pendingResult,
|
||||||
|
lineId: indicatorResult.lineId || pendingResult.lineId,
|
||||||
|
lineName: indicatorResult.lineName || pendingResult.lineName,
|
||||||
|
timeStart: indicatorResult.timeStart || pendingResult.timeStart,
|
||||||
|
timeEnd: indicatorResult.timeEnd || pendingResult.timeEnd,
|
||||||
|
intervalMinutes: indicatorResult.intervalMinutes ?? pendingResult.intervalMinutes,
|
||||||
|
items: pendingResult.items.map(item => (item.indicatorCode === indicatorCode ? finalReplacement : item))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const steadyDir = path.resolve(currentDir, '..')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
checksquarePage: path.join(steadyDir, 'checksquare/index.vue'),
|
||||||
|
dataViewPage: path.join(steadyDir, 'steadyDataView/index.vue'),
|
||||||
|
dataViewSelectionRules: path.join(steadyDir, 'steadyDataView/utils/selectionRules.ts'),
|
||||||
|
trendPage: path.join(steadyDir, 'steadyTrend/index.vue'),
|
||||||
|
trendSelectionRules: path.join(steadyDir, 'steadyTrend/utils/selectionRules.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'shared steadyDataView selection rules define voltage trend before current trend',
|
||||||
|
() => /STEADY_INDICATOR_GROUP_ORDER\s*=\s*\[[\s\S]*电压趋势[\s\S]*电流趋势/.test(read(files.dataViewSelectionRules))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steadyTrend selection rules define voltage trend before current trend',
|
||||||
|
() => /STEADY_INDICATOR_GROUP_ORDER\s*=\s*\[[\s\S]*电压趋势[\s\S]*电流趋势/.test(read(files.trendSelectionRules))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steadyDataView sorts indicator tree after loading',
|
||||||
|
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.dataViewPage))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steadyTrend sorts indicator tree after loading',
|
||||||
|
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.trendPage))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'checksquare sorts indicator tree after loading',
|
||||||
|
() => /indicatorTree\.value\s*=\s*sortSteadyIndicatorTree\(unwrapData\(response\)\s*\|\|\s*\[\]\)/.test(read(files.checksquarePage))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steady indicator tree order contract failed:')
|
||||||
|
failures.forEach(failure => console.error(`- ${failure}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steady indicator tree order contract passed')
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
<template>
|
<template>
|
||||||
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': collapsed }">
|
<aside class="indicator-floating-panel" :class="[`is-${mode}`, { 'is-collapsed': collapsed }]">
|
||||||
<el-button
|
<el-button
|
||||||
class="indicator-toggle"
|
class="indicator-toggle"
|
||||||
type="primary"
|
type="primary"
|
||||||
@@ -11,9 +11,7 @@
|
|||||||
<SteadyIndicatorTree
|
<SteadyIndicatorTree
|
||||||
:key="selectorResetKey"
|
:key="selectorResetKey"
|
||||||
:tree-data="treeData"
|
:tree-data="treeData"
|
||||||
:loading="loading"
|
|
||||||
:default-checked-keys="defaultCheckedKeys"
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
@refresh="emit('refresh')"
|
|
||||||
@change="emit('change', $event)"
|
@change="emit('change', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -29,17 +27,21 @@ defineOptions({
|
|||||||
name: 'SteadyIndicatorFloatingPanel'
|
name: 'SteadyIndicatorFloatingPanel'
|
||||||
})
|
})
|
||||||
|
|
||||||
|
withDefaults(
|
||||||
defineProps<{
|
defineProps<{
|
||||||
collapsed: boolean
|
collapsed: boolean
|
||||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||||
loading: boolean
|
|
||||||
defaultCheckedKeys: string[]
|
defaultCheckedKeys: string[]
|
||||||
selectorResetKey: number
|
selectorResetKey: number
|
||||||
}>()
|
mode?: 'floating' | 'inline'
|
||||||
|
}>(),
|
||||||
|
{
|
||||||
|
mode: 'floating'
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:collapsed': [value: boolean]
|
'update:collapsed': [value: boolean]
|
||||||
refresh: []
|
|
||||||
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
}>()
|
}>()
|
||||||
</script>
|
</script>
|
||||||
@@ -59,6 +61,19 @@ const emit = defineEmits<{
|
|||||||
width: 0;
|
width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-inline {
|
||||||
|
position: relative;
|
||||||
|
top: auto;
|
||||||
|
right: auto;
|
||||||
|
bottom: auto;
|
||||||
|
width: 300px;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-inline.is-collapsed {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.indicator-toggle {
|
.indicator-toggle {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 12px;
|
top: 12px;
|
||||||
@@ -84,5 +99,9 @@ const emit = defineEmits<{
|
|||||||
.indicator-floating-panel {
|
.indicator-floating-panel {
|
||||||
width: 280px;
|
width: 280px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-inline {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
<section class="card steady-tree-card indicator-tree">
|
<section class="card steady-tree-card indicator-tree">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span class="panel-title">稳态指标</span>
|
<span class="panel-title">稳态指标</span>
|
||||||
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<el-scrollbar class="tree-scrollbar">
|
<el-scrollbar class="tree-scrollbar">
|
||||||
@@ -31,7 +30,6 @@
|
|||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, nextTick, ref, watch } from 'vue'
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
import { Refresh } from '@element-plus/icons-vue'
|
|
||||||
import type { TreeInstance } from 'element-plus'
|
import type { TreeInstance } from 'element-plus'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import { collectLeafIndicators } from '../utils/selectionRules'
|
import { collectLeafIndicators } from '../utils/selectionRules'
|
||||||
@@ -42,12 +40,10 @@ defineOptions({
|
|||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
treeData: SteadyDataView.SteadyIndicatorNode[]
|
treeData: SteadyDataView.SteadyIndicatorNode[]
|
||||||
loading: boolean
|
|
||||||
defaultCheckedKeys: string[]
|
defaultCheckedKeys: string[]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
refresh: []
|
|
||||||
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
|
|||||||
@@ -1,40 +1,23 @@
|
|||||||
<template>
|
<template>
|
||||||
<section class="card trend-chart-panel" v-loading="loading">
|
<section class="card trend-chart-panel">
|
||||||
<div class="panel-header">
|
<div class="panel-header">
|
||||||
<span v-if="trendResult" class="panel-meta">{{ trendResult.displayPointCount || 0 }} 点</span>
|
<SteadyTrendChartTools
|
||||||
<span v-else class="panel-meta">趋势图</span>
|
:tool-groups="trendToolGroups"
|
||||||
|
:is-tool-active="isTrendToolActive"
|
||||||
<div class="trend-tool-groups">
|
:is-tool-disabled="isTrendToolDisabled"
|
||||||
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
|
:get-tool-tooltip="getTrendToolTooltip"
|
||||||
<el-tooltip
|
@tool-action="handleTrendToolAction"
|
||||||
v-for="item in group.items"
|
|
||||||
:key="item.action"
|
|
||||||
:content="getTrendToolTooltip(item)"
|
|
||||||
placement="top"
|
|
||||||
>
|
|
||||||
<el-button
|
|
||||||
:type="isTrendToolActive(item.action) ? 'primary' : 'default'"
|
|
||||||
:icon="item.icon"
|
|
||||||
:disabled="isTrendToolDisabled(item.action)"
|
|
||||||
circle
|
|
||||||
@click="handleTrendToolAction(item.action)"
|
|
||||||
/>
|
/>
|
||||||
</el-tooltip>
|
<span v-if="trendResult" class="panel-meta">总点数:{{ trendResult.displayPointCount || 0 }}</span>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="hasSeries" ref="chartExportTargetRef" class="chart-list steady-trend-export-target">
|
<div class="chart-panel-body" v-loading="loading">
|
||||||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
<div
|
||||||
<div class="chart-body">
|
v-if="hasChartFrame"
|
||||||
<LineChart :options="group.options" :group="group.group" @chart-data-zoom="handleChartDataZoom" />
|
ref="chartExportTargetRef"
|
||||||
</div>
|
class="chart-list steady-trend-export-target"
|
||||||
</div>
|
:style="{ '--steady-trend-visible-chart-count': normalVisibleChartCount }"
|
||||||
</div>
|
>
|
||||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
|
||||||
|
|
||||||
<el-dialog v-model="fullscreenVisible" title="趋势图全屏展示" fullscreen append-to-body destroy-on-close>
|
|
||||||
<div v-if="hasSeries" class="fullscreen-chart-body">
|
|
||||||
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||||||
<div class="chart-body">
|
<div class="chart-body">
|
||||||
<LineChart
|
<LineChart
|
||||||
@@ -45,8 +28,21 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<el-empty v-else-if="hasQueriedWithoutData" class="chart-empty" description="暂无数据" />
|
||||||
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||||
</el-dialog>
|
</div>
|
||||||
|
|
||||||
|
<SteadyTrendFullscreen
|
||||||
|
v-model="fullscreenVisible"
|
||||||
|
:chart-groups="chartGroups"
|
||||||
|
:visible-chart-count="fullscreenVisibleChartCount"
|
||||||
|
:tool-groups="fullscreenToolGroups"
|
||||||
|
:is-tool-active="isTrendToolActive"
|
||||||
|
:is-tool-disabled="isTrendToolDisabled"
|
||||||
|
:get-tool-tooltip="getTrendToolTooltip"
|
||||||
|
@chart-data-zoom="handleChartDataZoom"
|
||||||
|
@tool-action="handleTrendToolAction"
|
||||||
|
/>
|
||||||
|
|
||||||
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
||||||
</section>
|
</section>
|
||||||
@@ -60,6 +56,7 @@ import {
|
|||||||
ArrowUpBold,
|
ArrowUpBold,
|
||||||
Crop,
|
Crop,
|
||||||
DataAnalysis,
|
DataAnalysis,
|
||||||
|
DataLine,
|
||||||
FullScreen,
|
FullScreen,
|
||||||
Mouse,
|
Mouse,
|
||||||
Picture,
|
Picture,
|
||||||
@@ -72,8 +69,10 @@ import { computed, nextTick, ref, watch } from 'vue'
|
|||||||
import LineChart from '@/components/echarts/line/index.vue'
|
import LineChart from '@/components/echarts/line/index.vue'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||||
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
||||||
import type { Component } from 'vue'
|
import SteadyTrendFullscreen from './SteadyTrendFullscreen.vue'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SteadyTrendChartPanel'
|
name: 'SteadyTrendChartPanel'
|
||||||
@@ -85,43 +84,24 @@ const props = defineProps<{
|
|||||||
}>()
|
}>()
|
||||||
|
|
||||||
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
||||||
type SteadyTrendToolAction =
|
const trendToolGroups: SteadyTrendToolGroup[] = [
|
||||||
| 'x-zoom-in'
|
|
||||||
| 'x-zoom-out'
|
|
||||||
| 'y-zoom-in'
|
|
||||||
| 'y-zoom-out'
|
|
||||||
| 'box-zoom'
|
|
||||||
| 'wheel-zoom'
|
|
||||||
| 'reset'
|
|
||||||
| 'pan'
|
|
||||||
| 'fullscreen'
|
|
||||||
| 'download-image'
|
|
||||||
| 'query-data'
|
|
||||||
|
|
||||||
const trendToolGroups: Array<{
|
|
||||||
key: string
|
|
||||||
items: Array<{
|
|
||||||
action: SteadyTrendToolAction
|
|
||||||
label: string
|
|
||||||
icon: Component
|
|
||||||
}>
|
|
||||||
}> = [
|
|
||||||
{
|
{
|
||||||
key: 'viewport',
|
key: 'viewport',
|
||||||
items: [
|
items: [
|
||||||
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse },
|
|
||||||
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||||
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||||
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||||
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||||
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||||
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||||
{ action: 'pan', label: '平移', icon: Pointer }
|
{ action: 'pan', label: '平移', icon: Pointer },
|
||||||
|
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse }
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'view',
|
key: 'view',
|
||||||
items: [
|
items: [
|
||||||
|
{ action: 'missing-data', label: '缺失数据', icon: DataLine },
|
||||||
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||||
{ action: 'download-image', label: '下载图片', icon: Picture },
|
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||||
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
||||||
@@ -130,22 +110,51 @@ const trendToolGroups: Array<{
|
|||||||
]
|
]
|
||||||
|
|
||||||
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
||||||
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 10 }
|
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 100 }
|
||||||
|
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const STEADY_TREND_HALF_RANGE_DAYS = 20
|
||||||
|
const STEADY_TREND_QUARTER_RANGE_DAYS = 30
|
||||||
|
const STEADY_TREND_TENTH_RANGE_DAYS = 60
|
||||||
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||||
|
const defaultTrendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||||
const trendYZoomScale = ref(1)
|
const trendYZoomScale = ref(1)
|
||||||
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
||||||
const wheelZoomEnabled = ref(false)
|
const wheelZoomEnabled = ref(false)
|
||||||
|
const missingDataEnabled = ref(true)
|
||||||
const fullscreenVisible = ref(false)
|
const fullscreenVisible = ref(false)
|
||||||
const dataTableVisible = ref(false)
|
const dataTableVisible = ref(false)
|
||||||
const chartExportTargetRef = ref<HTMLElement>()
|
const chartExportTargetRef = ref<HTMLElement>()
|
||||||
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
||||||
|
const hasQueryTimeRange = computed(() => Boolean(props.trendResult?.queryTimeStart && props.trendResult?.queryTimeEnd))
|
||||||
|
const hasDataPoints = computed(() =>
|
||||||
|
Boolean(
|
||||||
|
props.trendResult?.series?.some(series =>
|
||||||
|
(series.points || []).some(point => typeof point.value === 'number' && Number.isFinite(point.value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
const hasChartFrame = computed(() => hasSeries.value || (hasQueryTimeRange.value && !props.trendResult?.queryCompleted))
|
||||||
|
const hasQueriedWithoutData = computed(() => Boolean(props.trendResult?.queryCompleted && !hasDataPoints.value))
|
||||||
const chartGroups = computed(() =>
|
const chartGroups = computed(() =>
|
||||||
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
||||||
activeTool: activeTrendInteractionMode.value,
|
activeTool: activeTrendInteractionMode.value,
|
||||||
wheelZoomEnabled: wheelZoomEnabled.value,
|
wheelZoomEnabled: wheelZoomEnabled.value,
|
||||||
yZoomScale: trendYZoomScale.value
|
showMissingData: missingDataEnabled.value,
|
||||||
|
yZoomScale: trendYZoomScale.value,
|
||||||
|
queryTimeStart: props.trendResult?.queryTimeStart,
|
||||||
|
queryTimeEnd: props.trendResult?.queryTimeEnd
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
|
const normalVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 3), 1))
|
||||||
|
const fullscreenVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 6), 1))
|
||||||
|
const fullscreenToolGroups = computed(() =>
|
||||||
|
trendToolGroups
|
||||||
|
.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter(item => item.action !== 'fullscreen')
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length)
|
||||||
|
)
|
||||||
const canPanTrendChart = computed(() => {
|
const canPanTrendChart = computed(() => {
|
||||||
const { start, end } = trendXZoomRange.value
|
const { start, end } = trendXZoomRange.value
|
||||||
|
|
||||||
@@ -153,19 +162,60 @@ const canPanTrendChart = computed(() => {
|
|||||||
})
|
})
|
||||||
const isDefaultTrendXZoomRange = computed(() => {
|
const isDefaultTrendXZoomRange = computed(() => {
|
||||||
const { start, end } = trendXZoomRange.value
|
const { start, end } = trendXZoomRange.value
|
||||||
|
const defaultRange = defaultTrendXZoomRange.value
|
||||||
|
|
||||||
return start === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.start && end === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.end
|
return start === defaultRange.start && end === defaultRange.end
|
||||||
})
|
})
|
||||||
const canResetTrendChart = computed(() => {
|
const canResetTrendChart = computed(() => {
|
||||||
const changedYZoom = trendYZoomScale.value !== 1
|
const changedYZoom = trendYZoomScale.value !== 1
|
||||||
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
||||||
const changedWheelZoom = wheelZoomEnabled.value
|
const changedWheelZoom = wheelZoomEnabled.value
|
||||||
|
|
||||||
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
return (
|
||||||
|
hasSeries.value &&
|
||||||
|
(!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const parseSteadyTrendTime = (value?: string) => {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendTimeRangeMs = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
|
||||||
|
let minTime = Number.POSITIVE_INFINITY
|
||||||
|
let maxTime = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
trendResult?.series?.forEach(series => {
|
||||||
|
series.points?.forEach(point => {
|
||||||
|
const timestamp = parseSteadyTrendTime(point.time)
|
||||||
|
|
||||||
|
if (timestamp === null) return
|
||||||
|
|
||||||
|
minTime = Math.min(minTime, timestamp)
|
||||||
|
maxTime = Math.max(maxTime, timestamp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime ? maxTime - minTime : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendDefaultZoomRange = (trendResult: SteadyDataView.SteadyTrendQueryResult | null) => {
|
||||||
|
const timeRangeMs = resolveSteadyTrendTimeRangeMs(trendResult)
|
||||||
|
const timeRangeDays = timeRangeMs / STEADY_TREND_DAY_MS
|
||||||
|
|
||||||
|
if (timeRangeDays > STEADY_TREND_TENTH_RANGE_DAYS) return { start: 0, end: 10 }
|
||||||
|
if (timeRangeDays > STEADY_TREND_QUARTER_RANGE_DAYS) return { start: 0, end: 25 }
|
||||||
|
if (timeRangeDays > STEADY_TREND_HALF_RANGE_DAYS) return { start: 0, end: 50 }
|
||||||
|
|
||||||
|
return { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
||||||
|
}
|
||||||
|
|
||||||
const resetTrendToolState = () => {
|
const resetTrendToolState = () => {
|
||||||
trendXZoomRange.value = { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
trendXZoomRange.value = { ...defaultTrendXZoomRange.value }
|
||||||
trendYZoomScale.value = 1
|
trendYZoomScale.value = 1
|
||||||
activeTrendInteractionMode.value = 'none'
|
activeTrendInteractionMode.value = 'none'
|
||||||
wheelZoomEnabled.value = false
|
wheelZoomEnabled.value = false
|
||||||
@@ -194,6 +244,7 @@ const zoomTrendXAxis = (ratio: number) => {
|
|||||||
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
||||||
if (action === 'fullscreen') return fullscreenVisible.value
|
if (action === 'fullscreen') return fullscreenVisible.value
|
||||||
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
||||||
|
if (action === 'missing-data') return missingDataEnabled.value
|
||||||
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
||||||
|
|
||||||
return false
|
return false
|
||||||
@@ -201,14 +252,14 @@ const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
|||||||
|
|
||||||
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
||||||
if (action === 'query-data') return false
|
if (action === 'query-data') return false
|
||||||
if (!hasSeries.value) return true
|
if (!hasChartFrame.value) return true
|
||||||
if (action === 'pan') return !canPanTrendChart.value
|
if (action === 'pan') return !canPanTrendChart.value
|
||||||
if (action === 'reset') return !canResetTrendChart.value
|
if (action === 'reset') return !canResetTrendChart.value
|
||||||
|
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: string }) => {
|
const getTrendToolTooltip = (item: SteadyTrendToolItem) => {
|
||||||
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
||||||
return '请先放大 X 轴或框选局部区域后再平移'
|
return '请先放大 X 轴或框选局部区域后再平移'
|
||||||
}
|
}
|
||||||
@@ -219,7 +270,9 @@ const getTrendToolTooltip = (item: { action: SteadyTrendToolAction; label: strin
|
|||||||
const downloadSteadyTrendImage = async () => {
|
const downloadSteadyTrendImage = async () => {
|
||||||
await nextTick()
|
await nextTick()
|
||||||
|
|
||||||
const targetElement = chartExportTargetRef.value
|
const targetElement = fullscreenVisible.value
|
||||||
|
? (document.querySelector('.steady-trend-fullscreen__chart-list') as HTMLElement | null)
|
||||||
|
: chartExportTargetRef.value
|
||||||
|
|
||||||
if (!targetElement) {
|
if (!targetElement) {
|
||||||
ElMessage.warning('暂无可下载的趋势图')
|
ElMessage.warning('暂无可下载的趋势图')
|
||||||
@@ -266,6 +319,9 @@ const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
|||||||
case 'wheel-zoom':
|
case 'wheel-zoom':
|
||||||
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
||||||
break
|
break
|
||||||
|
case 'missing-data':
|
||||||
|
missingDataEnabled.value = !missingDataEnabled.value
|
||||||
|
break
|
||||||
case 'pan':
|
case 'pan':
|
||||||
if (!canPanTrendChart.value) {
|
if (!canPanTrendChart.value) {
|
||||||
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
||||||
@@ -309,7 +365,8 @@ const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
|||||||
watch(
|
watch(
|
||||||
() => props.trendResult,
|
() => props.trendResult,
|
||||||
() => {
|
() => {
|
||||||
// 新查询结果应回到完整时间范围,避免沿用上一批数据的局部缩放窗口。
|
// 新查询结果按当前数据量重置默认窗口,避免沿用上一批数据的局部缩放范围。
|
||||||
|
defaultTrendXZoomRange.value = resolveSteadyTrendDefaultZoomRange(props.trendResult)
|
||||||
resetTrendToolState()
|
resetTrendToolState()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
@@ -317,6 +374,9 @@ watch(
|
|||||||
|
|
||||||
<style scoped lang="scss">
|
<style scoped lang="scss">
|
||||||
.trend-chart-panel {
|
.trend-chart-panel {
|
||||||
|
--steady-trend-chart-gap: 8px;
|
||||||
|
--steady-trend-visible-chart-count: 3;
|
||||||
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
@@ -330,63 +390,44 @@ watch(
|
|||||||
flex: none;
|
flex: none;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: flex-start;
|
justify-content: flex-start;
|
||||||
gap: 12px;
|
gap: 0;
|
||||||
margin-bottom: 10px;
|
margin-bottom: 10px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.panel-meta {
|
.panel-meta {
|
||||||
|
margin-left: 15px;
|
||||||
color: var(--el-text-color-secondary);
|
color: var(--el-text-color-secondary);
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.trend-tool-groups {
|
.chart-panel-body {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex: 1 1 auto;
|
||||||
justify-content: flex-end;
|
flex-direction: column;
|
||||||
gap: 8px;
|
min-height: 0;
|
||||||
min-width: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-tool-group {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-tool-group + .trend-tool-group {
|
|
||||||
padding-left: 8px;
|
|
||||||
border-left: 1px dashed var(--el-border-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
.trend-tool-group :deep(.el-button.is-circle) {
|
|
||||||
width: 28px;
|
|
||||||
height: 28px;
|
|
||||||
padding: 6px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-list {
|
.chart-list {
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1;
|
flex: 1 1 auto;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 8px;
|
gap: var(--steady-trend-chart-gap);
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
overflow: auto;
|
overflow-x: hidden;
|
||||||
}
|
overflow-y: auto;
|
||||||
|
|
||||||
.fullscreen-chart-body {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 8px;
|
|
||||||
height: calc(100vh - 96px);
|
|
||||||
min-height: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chart-group {
|
.chart-group {
|
||||||
|
box-sizing: border-box;
|
||||||
display: flex;
|
display: flex;
|
||||||
flex: 1 0 240px;
|
flex: 0 0
|
||||||
|
calc(
|
||||||
|
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||||
|
var(--steady-trend-visible-chart-count)
|
||||||
|
);
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
min-height: 220px;
|
min-height: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
border: 1px solid var(--el-border-color-lighter);
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="trend-tool-groups">
|
||||||
|
<div v-for="group in toolGroups" :key="group.key" class="trend-tool-group">
|
||||||
|
<el-tooltip v-for="item in group.items" :key="item.action" :content="getToolTooltip(item)" placement="top">
|
||||||
|
<el-button
|
||||||
|
:type="isToolActive(item.action) ? 'primary' : 'default'"
|
||||||
|
:icon="item.icon"
|
||||||
|
:disabled="isToolDisabled(item.action)"
|
||||||
|
circle
|
||||||
|
@click="emit('tool-action', item.action)"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendChartTools'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
toolGroups: SteadyTrendToolGroup[]
|
||||||
|
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||||
|
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||||
|
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'tool-action': [action: SteadyTrendToolAction]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.trend-tool-groups {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group + .trend-tool-group {
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px dashed var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group :deep(.el-button.is-circle) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,192 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="steady-trend-fullscreen">
|
||||||
|
<header class="steady-trend-fullscreen__header">
|
||||||
|
<span class="steady-trend-fullscreen__title">趋势图全屏展示</span>
|
||||||
|
<el-button class="steady-trend-fullscreen__close" :icon="Close" text circle @click="closeFullscreen" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="steady-trend-fullscreen__body">
|
||||||
|
<div class="steady-trend-fullscreen__tool-row">
|
||||||
|
<SteadyTrendChartTools
|
||||||
|
class="steady-trend-fullscreen__tools"
|
||||||
|
:tool-groups="toolGroups"
|
||||||
|
:is-tool-active="isToolActive"
|
||||||
|
:is-tool-disabled="isToolDisabled"
|
||||||
|
:get-tool-tooltip="getToolTooltip"
|
||||||
|
@tool-action="emit('tool-action', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="chartGroups.length"
|
||||||
|
class="steady-trend-fullscreen__chart-list"
|
||||||
|
:style="{ '--steady-trend-visible-chart-count': visibleChartCount }"
|
||||||
|
>
|
||||||
|
<div v-for="group in chartGroups" :key="group.key" class="steady-trend-fullscreen__chart-group">
|
||||||
|
<div class="steady-trend-fullscreen__chart-body">
|
||||||
|
<LineChart
|
||||||
|
:options="group.options"
|
||||||
|
:group="group.group"
|
||||||
|
@chart-data-zoom="handleChartDataZoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else class="steady-trend-fullscreen__empty" description="请选择监测点和指标后查询趋势" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Close } from '@element-plus/icons-vue'
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import LineChart from '@/components/echarts/line/index.vue'
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||||
|
import type { SteadyTrendChartGroup, SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendFullscreen'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
chartGroups: SteadyTrendChartGroup[]
|
||||||
|
visibleChartCount: number
|
||||||
|
toolGroups: SteadyTrendToolGroup[]
|
||||||
|
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||||
|
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||||
|
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'chart-data-zoom': [value: SteadyTrendZoomRange]
|
||||||
|
'tool-action': [action: SteadyTrendToolAction]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const closeFullscreen = () => {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!props.modelValue || event.key !== 'Escape') return
|
||||||
|
|
||||||
|
closeFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||||
|
emit('chart-data-zoom', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__header {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__close {
|
||||||
|
flex: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__body {
|
||||||
|
--steady-trend-chart-gap: 8px;
|
||||||
|
--steady-trend-visible-chart-count: 6;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__tool-row {
|
||||||
|
position: static;
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__tools {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--steady-trend-chart-gap);
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-group {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0
|
||||||
|
calc(
|
||||||
|
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||||
|
var(--steady-trend-visible-chart-count)
|
||||||
|
);
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--cn-color-canvas-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__empty {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -23,16 +23,18 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="toolbar-field">
|
<div class="toolbar-field">
|
||||||
<span class="toolbar-field__label">数据:</span>
|
<span class="toolbar-field__label">数据质量:</span>
|
||||||
<el-select
|
<el-switch
|
||||||
:model-value="modelValue.qualityFlag"
|
:model-value="modelValue.qualityFlag ?? 0"
|
||||||
clearable
|
class="quality-switch"
|
||||||
placeholder="选择数据质量"
|
width="72"
|
||||||
@update:model-value="updateField('qualityFlag', $event)"
|
inline-prompt
|
||||||
>
|
active-text="有效"
|
||||||
<el-option label="仅有效数据" :value="0" />
|
inactive-text="无效"
|
||||||
<el-option label="仅无效数据" :value="1" />
|
:active-value="0"
|
||||||
</el-select>
|
:inactive-value="1"
|
||||||
|
@update:model-value="handleQualityFlagChange"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
||||||
@@ -40,10 +42,8 @@
|
|||||||
<el-select
|
<el-select
|
||||||
:model-value="modelValue.harmonicOrders"
|
:model-value="modelValue.harmonicOrders"
|
||||||
multiple
|
multiple
|
||||||
collapse-tags
|
|
||||||
collapse-tags-tooltip
|
|
||||||
placeholder="选择谐波次数"
|
placeholder="选择谐波次数"
|
||||||
@update:model-value="updateField('harmonicOrders', $event)"
|
@update:model-value="handleHarmonicOrdersChange"
|
||||||
>
|
>
|
||||||
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||||
</el-select>
|
</el-select>
|
||||||
@@ -57,9 +57,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||||
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
import { MAX_HARMONIC_ORDER_COUNT } from '../utils/selectionRules'
|
||||||
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
@@ -94,6 +96,40 @@ const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: Stea
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleQualityFlagChange = (value: string | number | boolean) => {
|
||||||
|
updateField('qualityFlag', Number(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeHarmonicOrders = (orders: number[]) => {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
orders
|
||||||
|
.map(item => Number(item))
|
||||||
|
.filter(item => Number.isInteger(item) && item >= 2 && item <= 50)
|
||||||
|
)
|
||||||
|
).sort((left, right) => left - right)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHarmonicOrders = (orders: number[]) => {
|
||||||
|
const nextOrders = normalizeHarmonicOrders(orders)
|
||||||
|
|
||||||
|
if (nextOrders.length > MAX_HARMONIC_ORDER_COUNT) {
|
||||||
|
ElMessage.warning(`谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`)
|
||||||
|
const currentOrders = normalizeHarmonicOrders(props.modelValue.harmonicOrders)
|
||||||
|
updateField(
|
||||||
|
'harmonicOrders',
|
||||||
|
currentOrders.length ? currentOrders : nextOrders.slice(0, MAX_HARMONIC_ORDER_COUNT)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateField('harmonicOrders', nextOrders)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHarmonicOrdersChange = (value: number[]) => {
|
||||||
|
updateHarmonicOrders(value)
|
||||||
|
}
|
||||||
|
|
||||||
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||||
emit('update:modelValue', {
|
emit('update:modelValue', {
|
||||||
...props.modelValue,
|
...props.modelValue,
|
||||||
@@ -144,6 +180,10 @@ const handleTimeBaseDateChange = (value: Date) => {
|
|||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.quality-switch {
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
.trend-toolbar__time {
|
.trend-toolbar__time {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -34,9 +34,7 @@
|
|||||||
v-model:collapsed="indicatorPanelCollapsedProxy"
|
v-model:collapsed="indicatorPanelCollapsedProxy"
|
||||||
:selector-reset-key="selectorResetKey"
|
:selector-reset-key="selectorResetKey"
|
||||||
:tree-data="indicatorTree"
|
:tree-data="indicatorTree"
|
||||||
:loading="loading.indicator"
|
|
||||||
:default-checked-keys="defaultIndicatorCheckedKeys"
|
:default-checked-keys="defaultIndicatorCheckedKeys"
|
||||||
@refresh="emit('refreshIndicator')"
|
|
||||||
@change="emit('indicatorChange', $event)"
|
@change="emit('indicatorChange', $event)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -83,7 +81,6 @@ const emit = defineEmits<{
|
|||||||
refreshLedger: []
|
refreshLedger: []
|
||||||
ledgerSearch: [value: string]
|
ledgerSearch: [value: string]
|
||||||
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
ledgerChange: [nodes: SteadyDataView.SteadyLedgerNode[]]
|
||||||
refreshIndicator: []
|
|
||||||
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
indicatorChange: [nodes: SteadyDataView.SteadyIndicatorNode[]]
|
||||||
queryTrend: []
|
queryTrend: []
|
||||||
resetTrend: []
|
resetTrend: []
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
export type SteadyTrendToolAction =
|
||||||
|
| 'x-zoom-in'
|
||||||
|
| 'x-zoom-out'
|
||||||
|
| 'y-zoom-in'
|
||||||
|
| 'y-zoom-out'
|
||||||
|
| 'box-zoom'
|
||||||
|
| 'wheel-zoom'
|
||||||
|
| 'reset'
|
||||||
|
| 'pan'
|
||||||
|
| 'fullscreen'
|
||||||
|
| 'download-image'
|
||||||
|
| 'query-data'
|
||||||
|
| 'missing-data'
|
||||||
|
|
||||||
|
export interface SteadyTrendToolItem {
|
||||||
|
action: SteadyTrendToolAction
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendToolGroup {
|
||||||
|
key: string
|
||||||
|
items: SteadyTrendToolItem[]
|
||||||
|
}
|
||||||
@@ -5,10 +5,20 @@ import { fileURLToPath } from 'node:url'
|
|||||||
|
|
||||||
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
const viewDir = path.join(currentDir, '..')
|
const viewDir = path.join(currentDir, '..')
|
||||||
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
|
const read = file => {
|
||||||
|
const filePath = path.join(viewDir, file)
|
||||||
|
|
||||||
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
||||||
|
}
|
||||||
|
|
||||||
const toolbarSource = read('components/SteadyTrendToolbar.vue')
|
const toolbarSource = read('components/SteadyTrendToolbar.vue')
|
||||||
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||||
|
const fullscreenSource = read('components/SteadyTrendFullscreen.vue')
|
||||||
|
const chartToolsSource = read('components/SteadyTrendChartTools.vue')
|
||||||
|
const lineChartSource = fs.readFileSync(
|
||||||
|
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
const trendPayloadSource = read('utils/trendPayload.ts')
|
const trendPayloadSource = read('utils/trendPayload.ts')
|
||||||
const trendOptionsSource = read('utils/trendOptions.ts')
|
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||||
const selectionRulesSource = read('utils/selectionRules.ts')
|
const selectionRulesSource = read('utils/selectionRules.ts')
|
||||||
@@ -46,14 +56,89 @@ const expectations = [
|
|||||||
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
|
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'chart loading mask stays outside the fullscreen dialog tree',
|
||||||
|
/<section\s+class="card trend-chart-panel"(?![^>]*v-loading)[\s\S]*<div[^>]*class="chart-panel-body"[^>]*v-loading="loading"[\s\S]*<SteadyTrendFullscreen/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'normal chart list caps visible charts at three while smaller counts fill the viewport',
|
||||||
|
/(?=[\s\S]*normalVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*3\))(?=[\s\S]*:style="\{\s*'--steady-trend-visible-chart-count':\s*normalVisibleChartCount\s*\}")/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen chart list caps visible charts at six while smaller counts fill the viewport',
|
||||||
|
/(?=[\s\S]*fullscreenVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*6\))(?=[\s\S]*:visible-chart-count="fullscreenVisibleChartCount")/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart groups divide the current viewport by visible chart count',
|
||||||
|
/\.chart-group\s*\{[\s\S]*flex:\s*0 0\s*calc\(\s*\(100% - var\(--steady-trend-chart-gap\)\s*\*\s*\(var\(--steady-trend-visible-chart-count\)\s*-\s*1\)\)\s*\/\s*var\(--steady-trend-visible-chart-count\)\s*\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart groups include their border in the divided height to avoid false scrollbars',
|
||||||
|
/\.chart-group\s*\{[\s\S]*box-sizing:\s*border-box/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel delegates fullscreen rendering to a dedicated component',
|
||||||
|
/<SteadyTrendFullscreen[\s\S]*v-model="fullscreenVisible"[\s\S]*:chart-groups="chartGroups"[\s\S]*:visible-chart-count="fullscreenVisibleChartCount"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes shared trend tools into fullscreen',
|
||||||
|
/<SteadyTrendFullscreen[\s\S]*:tool-groups="fullscreenToolGroups"[\s\S]*:is-tool-active="isTrendToolActive"[\s\S]*:is-tool-disabled="isTrendToolDisabled"[\s\S]*:get-tool-tooltip="getTrendToolTooltip"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen tool groups omit nested fullscreen action',
|
||||||
|
/fullscreenToolGroups[\s\S]*item\.action\s*!==\s*'fullscreen'/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel does not use Element Plus dialog for steady fullscreen',
|
||||||
|
/^(?![\s\S]*<el-dialog[\s\S]*steady-trend-fullscreen)(?![\s\S]*fullscreen-chart-body)[\s\S]*$/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component renders through a fixed body teleport',
|
||||||
|
/<Teleport\s+to="body">[\s\S]*class="steady-trend-fullscreen"[\s\S]*position:\s*fixed[\s\S]*inset:\s*0/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component has an explicit close button and Esc handling',
|
||||||
|
/@click="closeFullscreen"[\s\S]*event\.key\s*!==\s*'Escape'[\s\S]*window\.addEventListener\('keydown',\s*handleKeydown\)/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component gives chart body a concrete flex viewport',
|
||||||
|
/\.steady-trend-fullscreen__body\s*\{[\s\S]*display:\s*flex[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*min-height:\s*0[\s\S]*\.steady-trend-fullscreen__chart-list\s*\{[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*overflow-y:\s*auto/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen keeps trend tool groups in a real workspace row below the title bar',
|
||||||
|
/<main class="steady-trend-fullscreen__body">[\s\S]*<div class="steady-trend-fullscreen__tool-row">[\s\S]*<SteadyTrendChartTools[\s\S]*<\/div>[\s\S]*class="steady-trend-fullscreen__chart-list"/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen title header does not contain trend tool groups',
|
||||||
|
/<header class="steady-trend-fullscreen__header">(?:(?!SteadyTrendChartTools)[\s\S])*<\/header>/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen tool row reserves layout space instead of floating over charts',
|
||||||
|
/\.steady-trend-fullscreen__tool-row\s*\{(?=[\s\S]*position:\s*static)(?=[\s\S]*display:\s*flex)(?=[\s\S]*flex:\s*none)[\s\S]*\}/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'chart panel syncs grouped charts through LineChart group and dataZoom events',
|
'chart panel syncs grouped charts through LineChart group and dataZoom events',
|
||||||
/<LineChart[\s\S]*:options="group\.options"[\s\S]*:group="group\.group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
/<LineChart[\s\S]*:options="group\.options"[\s\S]*:group="group\.group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel only shows trend point count without bucket prefix',
|
'chart panel shows total point count with explicit label',
|
||||||
/trendResult\.displayPointCount\s*\|\|\s*0[\s\S]*点/,
|
/class="panel-meta"[\s\S]*trendResult\.displayPointCount\s*\|\|\s*0/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -62,38 +147,50 @@ const expectations = [
|
|||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel renders waveform-style trend tool groups',
|
'chart panel renders shared trend tool groups',
|
||||||
/trend-tool-groups[\s\S]*trendToolGroups[\s\S]*handleTrendToolAction/,
|
/<SteadyTrendChartTools[\s\S]*:tool-groups="trendToolGroups"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel keeps trend toolbar next to point count on the left',
|
'chart panel keeps total point count on the right of the trend toolbar with 15px spacing',
|
||||||
/\.panel-header\s*\{[\s\S]*justify-content:\s*flex-start/,
|
/\.panel-meta\s*\{[\s\S]*margin-left:\s*15px/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'shared chart tools component renders the trend tool buttons',
|
||||||
|
/trend-tool-groups[\s\S]*v-for="group in toolGroups"[\s\S]*v-for="item in group\.items"[\s\S]*@click="emit\('tool-action', item\.action\)"/,
|
||||||
|
chartToolsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen renders shared trend tool groups',
|
||||||
|
/<SteadyTrendChartTools[\s\S]*:tool-groups="toolGroups"[\s\S]*@tool-action="emit\('tool-action', \$event\)"/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'chart panel exposes core trend toolbar actions except marker and data export',
|
'chart panel exposes core trend toolbar actions except marker and data export',
|
||||||
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
|
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
|
['chart panel exposes a wheel zoom toggle action', /wheel-zoom/, chartPanelSource],
|
||||||
[
|
[
|
||||||
'chart panel exposes a wheel zoom toggle action',
|
'chart panel places wheel zoom after pan in the viewport toolbar',
|
||||||
/wheel-zoom/,
|
/items:\s*\[[\s\S]*action:\s*'pan'[\s\S]*action:\s*'wheel-zoom'/,
|
||||||
chartPanelSource
|
|
||||||
],
|
|
||||||
[
|
|
||||||
'chart panel defaults wheel zoom disabled',
|
|
||||||
/const\s+wheelZoomEnabled\s*=\s*ref\(false\)/,
|
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
|
['chart panel defaults wheel zoom disabled', /const\s+wheelZoomEnabled\s*=\s*ref\(false\)/, chartPanelSource],
|
||||||
[
|
[
|
||||||
'chart panel passes wheel zoom state into grouped options',
|
'chart panel passes wheel zoom state into grouped options',
|
||||||
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
|
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel defaults to first tenth of the x range',
|
'chart panel keeps full range as fallback x zoom range',
|
||||||
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*10\s*\}/,
|
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*100\s*\}/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel defaults x zoom range by 20 30 60 day thresholds',
|
||||||
|
/STEADY_TREND_HALF_RANGE_DAYS\s*=\s*20[\s\S]*STEADY_TREND_QUARTER_RANGE_DAYS\s*=\s*30[\s\S]*STEADY_TREND_TENTH_RANGE_DAYS\s*=\s*60[\s\S]*resolveSteadyTrendDefaultZoomRange[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_TENTH_RANGE_DAYS[\s\S]*end:\s*10[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_QUARTER_RANGE_DAYS[\s\S]*end:\s*25[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_HALF_RANGE_DAYS[\s\S]*end:\s*50/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -102,8 +199,8 @@ const expectations = [
|
|||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel compares reset state against the default x zoom range',
|
'chart panel compares reset state against the current data-derived x zoom range',
|
||||||
/const isDefaultTrendXZoomRange[\s\S]*DEFAULT_STEADY_TREND_X_ZOOM_RANGE[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
|
/const defaultTrendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>[\s\S]*const isDefaultTrendXZoomRange[\s\S]*defaultTrendXZoomRange\.value[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -117,8 +214,8 @@ const expectations = [
|
|||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel resets x zoom range to the default first tenth when trend result changes',
|
'chart panel resets x zoom range to the current data-derived default when trend result changes',
|
||||||
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
|
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.defaultTrendXZoomRange\.value\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*defaultTrendXZoomRange\.value\s*=\s*resolveSteadyTrendDefaultZoomRange\(props\.trendResult\)[\s\S]*resetTrendToolState\(\)/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -162,6 +259,31 @@ const expectations = [
|
|||||||
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
|
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'chart options enlarge slider dataZoom handles for easier horizontal dragging',
|
||||||
|
/handleSize:\s*'300%'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options highlight slider dataZoom handles on hover',
|
||||||
|
/emphasis:\s*\{[\s\S]*handleStyle:\s*\{[\s\S]*borderColor:\s*'#409eff'[\s\S]*shadowBlur:\s*6/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options show pointer cursor on slider dataZoom handles',
|
||||||
|
/handleSize:\s*'300%'[\s\S]*cursor:\s*'pointer'[\s\S]*handleStyle:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart slider dataZoom only syncs after dragging settles for dense trend charts',
|
||||||
|
/handleSize:\s*'300%'[\s\S]*realtime:\s*false[\s\S]*cursor:\s*'pointer'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'line chart overrides slider dataZoom handle cursor to pointer on hover',
|
||||||
|
/isSliderDataZoomResizeHandle[\s\S]*target\?\.type\s*===\s*'path'[\s\S]*viewportRoot\.style\.cursor\s*=\s*'pointer'/,
|
||||||
|
lineChartSource
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'chart options accept wheel zoom option',
|
'chart options accept wheel zoom option',
|
||||||
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
|
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
|
||||||
@@ -177,6 +299,21 @@ const expectations = [
|
|||||||
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
|
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'chart options hide hover x-axis pointer labels to avoid overlapping time axis labels',
|
||||||
|
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'[\s\S]*label:\s*\{[\s\S]*show:\s*false/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart tooltip avoids heavy immediate transitions on dense trend charts',
|
||||||
|
/tooltip:\s*\{[\s\S]*showDelay:\s*80[\s\S]*hideDelay:\s*80[\s\S]*transitionDuration:\s*0/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options register hidden toolbox dataZoom for external box zoom',
|
||||||
|
/toolbox:\s*\{[\s\S]*show:\s*true[\s\S]*itemSize:\s*0[\s\S]*left:\s*-100[\s\S]*feature:\s*\{[\s\S]*dataZoom:\s*\{[\s\S]*yAxisIndex:\s*'none'[\s\S]*brushStyle:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'chart options calculate visible point count from shared zoom range',
|
'chart options calculate visible point count from shared zoom range',
|
||||||
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
|
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
|
||||||
@@ -184,7 +321,7 @@ const expectations = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart series line width uses visible point count after x zoom',
|
'chart series line width uses visible point count after x zoom',
|
||||||
/width:\s*resolveSteadyTrendLineWidth\(\s*resolveSteadyTrendVisiblePointCount\(series\.points\?\.length\s*\|\|\s*0,\s*zoomRange\)\s*\)/,
|
/const\s+pointCount\s*=\s*series\.points\?\.length\s*\|\|\s*0[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*width:\s*resolveSteadyTrendLineWidth\(visiblePointCount\)/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -233,13 +370,28 @@ const expectations = [
|
|||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart x axis only keeps first and last labels like waveform',
|
'chart x axis formats time axis labels by range span',
|
||||||
/buildSteadyTimeAxisLabelFormatter[\s\S]*index\s*!==\s*0\s*&&\s*index\s*!==\s*lastIndex[\s\S]*return\s*''/,
|
/STEADY_TREND_ONE_DAY_MS[\s\S]*buildSteadyTimeAxisLabelFormatter[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisDailyLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis keeps only the first year for same-year daily labels',
|
||||||
|
/formatSteadyTimeAxisDailyLabel[\s\S]*date\.getFullYear\(\)\s*===\s*firstYear[\s\S]*formatSteadyTimeAxisShortDateLabel[\s\S]*const\s+firstYear/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis first label uses rich padding to avoid touching the y axis line',
|
||||||
|
/formatSteadyTimeAxisFirstLabel[\s\S]*return `\{first\|\$\{label\}\}`[\s\S]*rich:\s*\{[\s\S]*first:\s*\{[\s\S]*padding:\s*\[0,\s*0,\s*0,\s*6\]/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart x axis label layout follows waveform except unit display',
|
'chart x axis label layout follows waveform except unit display',
|
||||||
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*false[\s\S]*interval:\s*0[\s\S]*margin:\s*showTimeAxis\s*\?\s*10\s*:\s*0[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeLabels\)/,
|
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*true[\s\S]*showMinLabel:\s*true[\s\S]*showMaxLabel:\s*false[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0[\s\S]*alignMinLabel:\s*'left'[\s\S]*alignMaxLabel:\s*'right'[\s\S]*width:\s*72[\s\S]*overflow:\s*'truncate'[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeRange\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis label moves down without increasing bottom grid space',
|
||||||
|
/bottom:\s*showTimeAxis\s*\?\s*40\s*:\s*8[\s\S]*axisLabel:\s*\{[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -264,8 +416,33 @@ const expectations = [
|
|||||||
],
|
],
|
||||||
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
|
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
|
||||||
[
|
[
|
||||||
'chart legend only uses phase name',
|
'chart legend combines harmonic order and phase when harmonic order exists',
|
||||||
/const formatSeriesName[\s\S]*return series\.phase \|\| series\.seriesKey/,
|
/const formatSeriesName[\s\S]*const harmonicOrder = resolveHarmonicOrder\(series\)[\s\S]*return harmonicOrder \? `\$\{harmonicOrder\}次_\$\{phaseLabel\}` : phaseLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart series are sorted by harmonic order before phase so same orders stay together in the legend',
|
||||||
|
/const sortSteadyTrendSeries[\s\S]*resolveHarmonicOrder\(left\.series\)[\s\S]*resolvePhaseOrder\(left\.series\)[\s\S]*const sortedSeriesList = sortSteadyTrendSeries\((?:seriesList|displaySeriesList)\)[\s\S]*series: sortedSeriesList\.map/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options keep phase colors while styling harmonic orders by line type',
|
||||||
|
/lineStyle:\s*\{[\s\S]*type:\s*resolveHarmonicLineType\(series,\s*pointCount\)[\s\S]*opacity:\s*resolveHarmonicLineOpacity\(series\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options force solid harmonic lines for large point counts',
|
||||||
|
/STEADY_TREND_LARGE_POINT_COUNT\s*=\s*20000[\s\S]*resolveHarmonicLineType[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT[\s\S]*return 'solid'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart series disable animation and use lttb sampling for large point counts',
|
||||||
|
/resolveSteadyTrendSampling[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT\s*\?\s*'lttb'[\s\S]*animation:\s*false[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart disables lttb sampling after x zoom reduces visible points',
|
||||||
|
/STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*resolveSteadyTrendSampling\s*=\s*\([^)]*visiblePointCount[\s\S]*visiblePointCount\s*<=\s*STEADY_TREND_RAW_VISIBLE_POINT_COUNT[\s\S]*return undefined[\s\S]*const\s+visiblePointCount\s*=\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount,\s*visiblePointCount\)/,
|
||||||
trendOptionsSource
|
trendOptionsSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,82 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageFile = path.resolve(currentDir, '../index.vue')
|
||||||
|
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
|
||||||
|
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
|
||||||
|
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
|
||||||
|
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyDataView/interface/index.ts')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
|
||||||
|
const pageSource = read(pageFile)
|
||||||
|
const chartPanelSource = read(chartPanelFile)
|
||||||
|
const trendOptionsSource = read(trendOptionsFile)
|
||||||
|
const payloadSource = read(payloadFile)
|
||||||
|
const interfaceSource = read(interfaceFile)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'steadyDataView imports the day query endpoint for chunked loading',
|
||||||
|
/import\s*\{[^}]*querySteadyTrendDay[^}]*\}\s*from\s*'@\/api\/steady\/steadyDataView'/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steadyDataView routes long ranges through chunked trend loading',
|
||||||
|
/isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)[\s\S]*querySteadyTrendInChunks/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steadyDataView prevents stale chunk responses from overwriting newer queries',
|
||||||
|
/trendQuerySerial[\s\S]*currentQuerySerial[\s\S]*currentQuerySerial\s*!==\s*trendQuerySerial/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
['chunk helper uses a three day maximum window', /STEADY_TREND_CHUNK_DAYS\s*=\s*3/, payloadSource],
|
||||||
|
[
|
||||||
|
'long range chunk query initializes a full time range result before data arrives',
|
||||||
|
/buildEmptySteadyTrendQueryResult\(payload\.timeStart,\s*payload\.timeEnd\)/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'long range chunk query avoids trend loading overlay',
|
||||||
|
/loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit\(payload\.timeStart,\s*payload\.timeEnd\)/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
['chunk helper exports a long range predicate', /export\s+const\s+isSteadyTrendRangeOverChunkLimit/, payloadSource],
|
||||||
|
['chunk helper exports query chunk builder', /export\s+const\s+buildSteadyTrendQueryChunks/, payloadSource],
|
||||||
|
['chunk helper exports incremental result merger', /export\s+const\s+mergeSteadyTrendQueryResult/, payloadSource],
|
||||||
|
[
|
||||||
|
'chunk helper exports empty range result builder',
|
||||||
|
/export\s+const\s+buildEmptySteadyTrendQueryResult/,
|
||||||
|
payloadSource
|
||||||
|
],
|
||||||
|
['chunk helper exports no data predicate', /export\s+const\s+hasSteadyTrendResultData/, payloadSource],
|
||||||
|
[
|
||||||
|
'trend result carries full query time range metadata',
|
||||||
|
/queryTimeStart\?:\s*string[\s\S]*queryTimeEnd\?:\s*string/,
|
||||||
|
interfaceSource
|
||||||
|
],
|
||||||
|
['chart panel enables missing data by default', /const\s+missingDataEnabled\s*=\s*ref\(true\)/, chartPanelSource],
|
||||||
|
[
|
||||||
|
'chart panel shows no data state after completed empty query',
|
||||||
|
/hasQueriedWithoutData[\s\S]*description="暂无数据"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options use full query time range for x axis min and max',
|
||||||
|
/queryTimeStart[\s\S]*queryTimeEnd[\s\S]*xAxis[\s\S]*min:[\s\S]*max:/,
|
||||||
|
trendOptionsSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([_name, pattern, source]) => !pattern.test(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyDataView chunked query contract failed:')
|
||||||
|
failures.forEach(([name]) => console.error(`- ${name}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView chunked query contract passed')
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
/* eslint-env node */
|
/* eslint-env node */
|
||||||
import fs from 'node:fs'
|
import fs from 'node:fs'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
import { fileURLToPath } from 'node:url'
|
import { fileURLToPath } from 'node:url'
|
||||||
@@ -28,7 +28,7 @@ const expectations = [
|
|||||||
],
|
],
|
||||||
[
|
[
|
||||||
'chart panel warns before opening data table without series data',
|
'chart panel warns before opening data table without series data',
|
||||||
/ElMessage\.warning\('请先查询趋势数据'\)/,
|
/case\s+'query-data'[\s\S]*if\s*\(!hasSeries\.value\)[\s\S]*ElMessage\.warning/,
|
||||||
chartPanelSource
|
chartPanelSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
@@ -61,7 +61,7 @@ const expectations = [
|
|||||||
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
|
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
|
||||||
[
|
[
|
||||||
'trend table utility carries indicator units into labels',
|
'trend table utility carries indicator units into labels',
|
||||||
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*(\$\{unit\})/,
|
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*unit/,
|
||||||
tableUtilSource
|
tableUtilSource
|
||||||
],
|
],
|
||||||
[
|
[
|
||||||
|
|||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||||
|
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||||
|
const harmonicSelectSource = toolbarSource.match(
|
||||||
|
/<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">[\s\S]*?<\/div>/
|
||||||
|
)?.[0] || ''
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['harmonic order select exists', /class="toolbar-field harmonic-select"/],
|
||||||
|
['harmonic order select keeps multiple mode', /<el-select[\s\S]*multiple/],
|
||||||
|
['harmonic order select shows every selected tag', /^(?![\s\S]*collapse-tags)[\s\S]*$/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, pattern]) => !pattern.test(harmonicSelectSource)).map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyDataView harmonic tags contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView harmonic tags contract passed')
|
||||||
@@ -55,8 +55,77 @@ const flatNodes = [
|
|||||||
{ id: 'line-2', parentId: 'device-1', name: '监测点_2', level: 3, lineCount: 1, selectable: true }
|
{ id: 'line-2', parentId: 'device-1', name: '监测点_2', level: 3, lineCount: 1, selectable: true }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const aliasedNodes = [
|
||||||
|
{ engineeringId: 'engineering-alias', engineeringName: '别名工程', level: 0, deviceCount: 1, lineCount: 1 },
|
||||||
|
{
|
||||||
|
projectId: 'project-alias',
|
||||||
|
parentId: 'engineering-alias',
|
||||||
|
projectName: '别名项目',
|
||||||
|
level: 1,
|
||||||
|
deviceCount: 1,
|
||||||
|
lineCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
equipmentId: 'device-alias',
|
||||||
|
parentId: 'project-alias',
|
||||||
|
equipmentName: '别名设备',
|
||||||
|
level: 2,
|
||||||
|
lineCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: 'line-alias',
|
||||||
|
deviceId: 'device-alias',
|
||||||
|
lineName: '别名监测点',
|
||||||
|
level: 3,
|
||||||
|
selectable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const snakeCaseNodes = [
|
||||||
|
{
|
||||||
|
engineering_id: 'engineering-snake',
|
||||||
|
engineering_name: 'Snake Engineering',
|
||||||
|
level: 0,
|
||||||
|
equipment_count: 1,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
project_id: 'project-snake',
|
||||||
|
parent_id: 'engineering-snake',
|
||||||
|
project_name: 'Snake Project',
|
||||||
|
level: 1,
|
||||||
|
equipment_count: 1,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
device_id: 'device-snake',
|
||||||
|
parent_id: 'project-snake',
|
||||||
|
device_name: 'Snake Device',
|
||||||
|
level: 2,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
line_id: 'line-snake',
|
||||||
|
device_id: 'device-snake',
|
||||||
|
line_name: 'Snake Line',
|
||||||
|
level: 3,
|
||||||
|
selectable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
const normalized = normalizeSteadyLedgerTree(flatNodes)
|
const normalized = normalizeSteadyLedgerTree(flatNodes)
|
||||||
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
|
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
|
||||||
|
const aliasedNormalized = normalizeSteadyLedgerTree(aliasedNodes)
|
||||||
|
const aliasedLine = aliasedNormalized[0]?.children?.[0]?.children?.[0]?.children?.[0]
|
||||||
|
const snakeCaseNormalized = normalizeSteadyLedgerTree(snakeCaseNodes)
|
||||||
|
const snakeCaseDevice = snakeCaseNormalized[0]?.children?.[0]?.children?.[0]
|
||||||
|
const snakeCaseLine = snakeCaseDevice?.children?.[0]
|
||||||
|
|
||||||
const expectations = [
|
const expectations = [
|
||||||
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
|
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
|
||||||
@@ -71,11 +140,38 @@ const expectations = [
|
|||||||
'unselectable line nodes are removed from steady query tree',
|
'unselectable line nodes are removed from steady query tree',
|
||||||
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
|
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
|
||||||
],
|
],
|
||||||
|
[
|
||||||
|
'backend alias fields keep monitor point nodes',
|
||||||
|
aliasedLine?.id === 'line-alias' && aliasedLine?.name === '别名监测点' && aliasedLine?.parentId === 'device-alias'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'snake case backend fields and childrenList keep the full ledger hierarchy',
|
||||||
|
snakeCaseNormalized[0]?.id === 'engineering-snake' &&
|
||||||
|
snakeCaseNormalized[0]?.deviceCount === 1 &&
|
||||||
|
snakeCaseNormalized[0]?.lineCount === 1 &&
|
||||||
|
snakeCaseDevice?.id === 'device-snake' &&
|
||||||
|
snakeCaseDevice?.lineCount === 1 &&
|
||||||
|
snakeCaseLine?.id === 'line-snake' &&
|
||||||
|
snakeCaseLine?.name === 'Snake Line' &&
|
||||||
|
snakeCaseLine?.parentId === 'device-snake'
|
||||||
|
],
|
||||||
[
|
[
|
||||||
'ledger tree component hides count text on monitor point leaves',
|
'ledger tree component hides count text on monitor point leaves',
|
||||||
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
|
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
|
||||||
],
|
],
|
||||||
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))]
|
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))],
|
||||||
|
[
|
||||||
|
'page assigns normalized ledger tree data outside comments',
|
||||||
|
read(pageFile)
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.some(line => /^\s*ledgerTree\.value\s*=\s*normalizeSteadyLedgerTree/.test(line))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'page assigns default selected ledger node outside comments',
|
||||||
|
read(pageFile)
|
||||||
|
.split(/\r?\n/)
|
||||||
|
.some(line => /^\s*selectedLedgerNodes\.value\s*=\s*firstLedgerNode/.test(line))
|
||||||
|
]
|
||||||
]
|
]
|
||||||
|
|
||||||
const failures = expectations.filter(([, matched]) => !matched)
|
const failures = expectations.filter(([, matched]) => !matched)
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
|
||||||
|
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
|
||||||
|
|
||||||
|
const chartPanelSource = fs.readFileSync(chartPanelFile, 'utf8')
|
||||||
|
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['chart panel defines missing data tool action', /'missing-data'/, chartPanelSource],
|
||||||
|
['chart panel labels missing data action', /action:\s*'missing-data'[\s\S]*label:/, chartPanelSource],
|
||||||
|
[
|
||||||
|
'chart panel defaults missing data enabled for every query',
|
||||||
|
/const\s+missingDataEnabled\s*=\s*ref\(true\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel marks missing data action active only when enabled',
|
||||||
|
/action\s*===\s*'missing-data'[\s\S]*return\s+missingDataEnabled\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes missing data state into chart options',
|
||||||
|
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*showMissingData:\s*missingDataEnabled\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
['chart options accept missing data option', /showMissingData\?:\s*boolean/, trendOptionsSource],
|
||||||
|
[
|
||||||
|
'chart options only fills missing data when enabled',
|
||||||
|
/chartOptions\.showMissingData\s*===\s*true\s*\?\s*fillSteadyTrendMissingPoints\(/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing data filler inserts null values',
|
||||||
|
/fillSteadyTrendMissingPoints[\s\S]*value:[\s\S]*:\s*null/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing data filler infers interval from existing time gaps',
|
||||||
|
/resolveSteadyTrendPointIntervalMs[\s\S]*gaps[\s\S]*Math\.min/,
|
||||||
|
trendOptionsSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyDataView missing data contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView missing data contract passed')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||||
|
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
|
||||||
|
|
||||||
|
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||||
|
const payloadSource = fs.readFileSync(payloadFile, 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['toolbar labels the filter as data quality', /toolbar-field__label">数据质量:<\/span>/],
|
||||||
|
['toolbar renders quality flag with switch', /<el-switch[\s\S]*:model-value="modelValue\.qualityFlag \?\? 0"/],
|
||||||
|
['quality switch maps valid data to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||||
|
['quality switch maps invalid data to one', /inactive-text="无效"[\s\S]*:inactive-value="1"/],
|
||||||
|
['quality switch updates qualityFlag', /@update:model-value="handleQualityFlagChange"/],
|
||||||
|
['quality switch reserves enough prompt width', /\.quality-switch\s*\{[\s\S]*min-width:\s*72px/],
|
||||||
|
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||||
|
['utilities send quality flag in trend query payload', /qualityFlag:\s*formState\.qualityFlag/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks
|
||||||
|
.filter(([, pattern], index) => !pattern.test(index < 6 ? toolbarSource : payloadSource))
|
||||||
|
.map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyDataView quality switch contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView quality switch contract passed')
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||||
|
|
||||||
|
const readFunctionBody = name => {
|
||||||
|
const match = pageSource.match(
|
||||||
|
new RegExp(`const\\s+${name}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]*?)\\n\\}`)
|
||||||
|
)
|
||||||
|
|
||||||
|
return match?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'ledger selection change keeps current trend result until next query',
|
||||||
|
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||||
|
readFunctionBody('handleLedgerChange')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'indicator selection change keeps current trend result until next query',
|
||||||
|
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||||
|
readFunctionBody('handleIndicatorChange')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'new trend query clears stale trend result before deciding loading mode',
|
||||||
|
source =>
|
||||||
|
/trendResult\.value\s*=\s*null[\s\S]*useChunkedQuery[\s\S]*loading\.trend\s*=\s*!isSteadyTrendRangeOverChunkLimit/.test(
|
||||||
|
source
|
||||||
|
),
|
||||||
|
readFunctionBody('handleQueryTrend')
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, check, source]) => !check(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyDataView query state contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView query state contract passed')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(currentDir, '../../../..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
|
||||||
|
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
|
||||||
|
authStore: path.resolve(rootDir, 'stores/modules/auth.ts'),
|
||||||
|
page: path.resolve(rootDir, 'views/steady/steadyTrend/index.vue'),
|
||||||
|
api: path.resolve(rootDir, 'api/steady/steadyTrend/index.ts'),
|
||||||
|
apiInterface: path.resolve(rootDir, 'api/steady/steadyTrend/interface/index.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
const exists = file => fs.existsSync(file)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['steadyTrend page exists', () => exists(files.page)],
|
||||||
|
['steadyTrend API exists', () => exists(files.api)],
|
||||||
|
['steadyTrend API interface exists', () => exists(files.apiInterface)],
|
||||||
|
['static router registers /steadyTrend/index', () => /path:\s*'\/steadyTrend\/index'/.test(read(files.staticRouter))],
|
||||||
|
['static route name is steadyTrend', () => /name:\s*'steadyTrend'/.test(read(files.staticRouter))],
|
||||||
|
['static router imports steadyTrend page', () => /@\/views\/steady\/steadyTrend\/index\.vue/.test(read(files.staticRouter))],
|
||||||
|
[
|
||||||
|
'dynamic router aliases steady-trend to steadyTrend',
|
||||||
|
() => /\/steady\/steady-trend[\s\S]*\/steady\/steadyTrend/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dynamic router keeps steadyTrend static route from being overwritten',
|
||||||
|
() => /STATIC_ROUTE_NAMES[\s\S]*'steadyTrend'/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'auth normalizes backend steadyTrend menu to static entry',
|
||||||
|
() => /isSteadyTrendMenu[\s\S]*menu\.path\s*=\s*'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'business menu path resolver handles steadyTrend',
|
||||||
|
() => /isSteadyTrendMenu\(menu\)[\s\S]*return\s+'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend route contract failed:')
|
||||||
|
failures.forEach(name => console.error(`- ${name}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend route contract passed')
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const viewDir = path.join(currentDir, '..')
|
||||||
|
const read = file => fs.readFileSync(path.join(viewDir, file), 'utf8')
|
||||||
|
|
||||||
|
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||||
|
const lineChartSource = fs.readFileSync(
|
||||||
|
path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'),
|
||||||
|
'utf8'
|
||||||
|
)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
[
|
||||||
|
'steady trend x axis uses ECharts time axis',
|
||||||
|
/xAxis:\s*\{[\s\S]*type:\s*'time'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady trend chart no longer builds global timeLabels for default rendering',
|
||||||
|
/^(?![\s\S]*const\s+timeLabels\s*=\s*Array\.from[\s\S]*new Set\(displaySeriesList\.flatMap)[\s\S]*$/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady trend series passes timestamp value pairs directly to ECharts',
|
||||||
|
/data:\s*buildSteadyTrendSeriesData\(series\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady trend default rendering no longer pads every series by shared time labels',
|
||||||
|
/^(?![\s\S]*data:\s*timeLabels\.map\(time\s*=>\s*pointMap\.get\(time\)\s*\?\?\s*null\))[\s\S]*$/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady trend series enables progressive rendering for dense data',
|
||||||
|
/progressive:\s*STEADY_TREND_PROGRESSIVE_CHUNK_SIZE[\s\S]*progressiveThreshold:\s*STEADY_TREND_PROGRESSIVE_THRESHOLD/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'time axis label formatter formats timestamp values without timeLabels',
|
||||||
|
/buildSteadyTimeAxisLabelFormatter\s*=\s*\([^)]*\)\s*=>[\s\S]*value:\s*string\s*\|\s*number/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'one-day time axis labels use compact hour-minute text to avoid truncating repeated dates',
|
||||||
|
/formatSteadyTimeAxisMinuteLabel[\s\S]*formatSteadyTimeAxisHourMinuteLabel[\s\S]*isRangeOverOneDay[\s\S]*formatSteadyTimeAxisHourMinuteLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'line chart can resolve dataZoom startValue and endValue without category xAxis data',
|
||||||
|
/resolveZoomRangeFromTimeAxisValues[\s\S]*getSeriesTimeRange[\s\S]*resolveChartDataZoomRange/,
|
||||||
|
lineChartSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyDataView time axis contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyDataView time axis contract passed')
|
||||||
@@ -41,6 +41,7 @@ const expectations = [
|
|||||||
['page does not import data table panel', /SteadyDataTablePanel/],
|
['page does not import data table panel', /SteadyDataTablePanel/],
|
||||||
['components render floating indicator panel', /indicator-floating-panel/],
|
['components render floating indicator panel', /indicator-floating-panel/],
|
||||||
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
||||||
|
['page restores default harmonic order when harmonic filter becomes visible', /DEFAULT_HARMONIC_ORDERS[\s\S]*showHarmonicOrders\.value[\s\S]*trendForm\.value\.harmonicOrders\.length[\s\S]*DEFAULT_HARMONIC_ORDERS/],
|
||||||
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
||||||
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
||||||
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
|
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
|
||||||
@@ -55,16 +56,27 @@ const expectations = [
|
|||||||
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||||
['components reuse LineChart', /<LineChart/],
|
['components reuse LineChart', /<LineChart/],
|
||||||
['toolbar uses shared time period search', /TimePeriodSearch/],
|
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||||
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据:/],
|
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据质量:/],
|
||||||
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
|
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
|
||||||
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
||||||
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
|
['toolbar renders quality flag with switch', /<el-switch[\s\S]*@update:model-value="handleQualityFlagChange"/],
|
||||||
['toolbar binds valid quality flag to zero', /<el-option\s+label="仅有效数据"\s+:value="0"\s*\/>/],
|
['toolbar maps valid quality flag to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||||
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||||
|
['utilities default harmonic order to second harmonic', /DEFAULT_HARMONIC_ORDERS\s*=\s*\[2\]/],
|
||||||
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||||
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||||
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
||||||
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
|
['utilities cap harmonic order count at three', /MAX_HARMONIC_ORDER_COUNT\s*=\s*3/],
|
||||||
|
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个/],
|
||||||
|
['utilities count harmonic orders as one indicator in selection estimates', /const harmonicMultiplier\s*=\s*1/],
|
||||||
|
[
|
||||||
|
'toolbar does not provide harmonic quick groups',
|
||||||
|
/HARMONIC_ORDER_QUICK_GROUPS|harmonic-select__quick|appendHarmonicQuickOrders/
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'toolbar warns when harmonic selection exceeds three instead of using silent multiple-limit',
|
||||||
|
/(?=[\s\S]*MAX_HARMONIC_ORDER_COUNT)(?=[\s\S]*ElMessage\.warning\(`谐波次数最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个`\))(?![\s\S]*multiple-limit)/
|
||||||
|
],
|
||||||
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||||
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
||||||
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
|
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
|
||||||
@@ -86,6 +98,7 @@ const sourceByExpectation = [
|
|||||||
pageSource,
|
pageSource,
|
||||||
componentSource,
|
componentSource,
|
||||||
pageSource,
|
pageSource,
|
||||||
|
pageSource,
|
||||||
apiSource,
|
apiSource,
|
||||||
apiSource,
|
apiSource,
|
||||||
apiSource,
|
apiSource,
|
||||||
@@ -113,9 +126,14 @@ const sourceByExpectation = [
|
|||||||
utilitySource,
|
utilitySource,
|
||||||
utilitySource,
|
utilitySource,
|
||||||
utilitySource,
|
utilitySource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
utilitySource,
|
utilitySource,
|
||||||
interfaceSource,
|
interfaceSource,
|
||||||
interfaceSource,
|
interfaceSource,
|
||||||
|
utilitySource,
|
||||||
utilitySource
|
utilitySource
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,11 @@ const forbiddenPatterns = [
|
|||||||
],
|
],
|
||||||
['chart panel title text is removed', /panel-title/, chartPanelSource],
|
['chart panel title text is removed', /panel-title/, chartPanelSource],
|
||||||
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
|
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
|
||||||
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource]
|
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource],
|
||||||
|
['indicator tree refresh button is removed', /@click="emit\('refresh'\)"|:icon="Refresh"/, indicatorTreeSource],
|
||||||
|
['floating indicator panel refresh passthrough is removed', /@refresh="emit\('refresh'\)"|refresh:\s*\[\]/, floatingPanelSource],
|
||||||
|
['workbench indicator refresh passthrough is removed', /@refresh="emit\('refreshIndicator'\)"|refreshIndicator:\s*\[\]/, workbenchSource],
|
||||||
|
['page indicator refresh binding is removed', /@refresh-indicator="loadIndicatorTree"/, source]
|
||||||
]
|
]
|
||||||
|
|
||||||
const requiredPatterns = [
|
const requiredPatterns = [
|
||||||
@@ -85,7 +89,6 @@ const requiredPatterns = [
|
|||||||
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
|
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
|
||||||
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
|
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
|
||||||
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
|
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
|
||||||
['indicator tree header separates title and refresh icon', /justify-content:\s*flex-start/, indicatorTreeSource],
|
|
||||||
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
|
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||||
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
|
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
|
||||||
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],
|
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],
|
||||||
|
|||||||
@@ -17,7 +17,6 @@
|
|||||||
@refresh-ledger="loadLedgerTree"
|
@refresh-ledger="loadLedgerTree"
|
||||||
@ledger-search="handleLedgerSearch"
|
@ledger-search="handleLedgerSearch"
|
||||||
@ledger-change="handleLedgerChange"
|
@ledger-change="handleLedgerChange"
|
||||||
@refresh-indicator="loadIndicatorTree"
|
|
||||||
@indicator-change="handleIndicatorChange"
|
@indicator-change="handleIndicatorChange"
|
||||||
@query-trend="handleQueryTrend"
|
@query-trend="handleQueryTrend"
|
||||||
@reset-trend="resetTrendState"
|
@reset-trend="resetTrendState"
|
||||||
@@ -28,7 +27,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
import { ElMessage } from 'element-plus'
|
import { ElMessage } from 'element-plus'
|
||||||
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
|
import {
|
||||||
|
getSteadyTrendIndicatorTree,
|
||||||
|
getSteadyTrendLedgerTree,
|
||||||
|
querySteadyTrend,
|
||||||
|
querySteadyTrendDay
|
||||||
|
} from '@/api/steady/steadyDataView'
|
||||||
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
||||||
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
||||||
import {
|
import {
|
||||||
@@ -37,10 +41,20 @@ import {
|
|||||||
findFirstSelectableLedgerNode,
|
findFirstSelectableLedgerNode,
|
||||||
hasHarmonicIndicator,
|
hasHarmonicIndicator,
|
||||||
resolveAvailableStats,
|
resolveAvailableStats,
|
||||||
|
sortSteadyIndicatorTree,
|
||||||
validateTrendSelection
|
validateTrendSelection
|
||||||
} from './utils/selectionRules'
|
} from './utils/selectionRules'
|
||||||
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
|
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
|
||||||
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
|
import {
|
||||||
|
DEFAULT_HARMONIC_ORDERS,
|
||||||
|
buildEmptySteadyTrendQueryResult,
|
||||||
|
buildSteadyTrendQueryChunks,
|
||||||
|
buildSteadyTrendQueryPayload,
|
||||||
|
defaultTrendFormState,
|
||||||
|
hasSteadyTrendResultData,
|
||||||
|
isSteadyTrendRangeOverChunkLimit,
|
||||||
|
mergeSteadyTrendQueryResult
|
||||||
|
} from './utils/trendPayload'
|
||||||
|
|
||||||
defineOptions({
|
defineOptions({
|
||||||
name: 'SteadyDataView'
|
name: 'SteadyDataView'
|
||||||
@@ -63,6 +77,7 @@ const loading = reactive({
|
|||||||
indicator: false,
|
indicator: false,
|
||||||
trend: false
|
trend: false
|
||||||
})
|
})
|
||||||
|
let trendQuerySerial = 0
|
||||||
|
|
||||||
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||||
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
||||||
@@ -86,7 +101,7 @@ const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
|||||||
// 台账树接口在搜索场景可能返回扁平节点,前端统一恢复工程、项目、设备、监测点层级。
|
// 台账树接口在搜索场景可能返回扁平节点,前端统一恢复工程、项目、设备、监测点层级。
|
||||||
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
|
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
|
||||||
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
||||||
// 台账树首次加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
|
// 台账树加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
|
||||||
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
||||||
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -98,10 +113,10 @@ const loadIndicatorTree = async () => {
|
|||||||
loading.indicator = true
|
loading.indicator = true
|
||||||
try {
|
try {
|
||||||
const response = await getSteadyTrendIndicatorTree()
|
const response = await getSteadyTrendIndicatorTree()
|
||||||
indicatorTree.value = unwrapData(response) || []
|
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
|
||||||
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||||
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||||
// 指标树首次加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
|
// 指标树加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
|
||||||
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||||
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||||
} finally {
|
} finally {
|
||||||
@@ -118,13 +133,16 @@ const handleLedgerSearch = (value: string) => {
|
|||||||
|
|
||||||
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
|
||||||
selectedLedgerNodes.value = nodes
|
selectedLedgerNodes.value = nodes
|
||||||
|
// 监测点切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
|
||||||
selectedIndicators.value = nodes
|
selectedIndicators.value = nodes
|
||||||
|
// 指标切换只更新待查询条件,趋势数据保留到用户再次点击查询时再清理。
|
||||||
}
|
}
|
||||||
|
|
||||||
const resetTrendState = () => {
|
const resetTrendState = () => {
|
||||||
|
trendQuerySerial += 1
|
||||||
trendForm.value = defaultTrendFormState()
|
trendForm.value = defaultTrendFormState()
|
||||||
selectedLedgerNodes.value = []
|
selectedLedgerNodes.value = []
|
||||||
selectedIndicators.value = []
|
selectedIndicators.value = []
|
||||||
@@ -134,6 +152,29 @@ const resetTrendState = () => {
|
|||||||
selectorResetKey.value += 1
|
selectorResetKey.value += 1
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const querySteadyTrendInChunks = async (payload: SteadyDataView.SteadyTrendQueryParams, currentQuerySerial: number) => {
|
||||||
|
const chunks = buildSteadyTrendQueryChunks(payload)
|
||||||
|
|
||||||
|
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd)
|
||||||
|
|
||||||
|
for (const chunkPayload of chunks) {
|
||||||
|
if (currentQuerySerial !== trendQuerySerial) return
|
||||||
|
|
||||||
|
const trendResponse = await querySteadyTrendDay(chunkPayload)
|
||||||
|
|
||||||
|
if (currentQuerySerial !== trendQuerySerial) return
|
||||||
|
|
||||||
|
trendResult.value = mergeSteadyTrendQueryResult(trendResult.value, unwrapData(trendResponse))
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentQuerySerial === trendQuerySerial && trendResult.value) {
|
||||||
|
trendResult.value = {
|
||||||
|
...trendResult.value,
|
||||||
|
queryCompleted: true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handleQueryTrend = async () => {
|
const handleQueryTrend = async () => {
|
||||||
indicatorPanelCollapsed.value = true
|
indicatorPanelCollapsed.value = true
|
||||||
|
|
||||||
@@ -153,16 +194,42 @@ const handleQueryTrend = async () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
|
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
|
||||||
|
trendResult.value = null
|
||||||
|
const currentQuerySerial = ++trendQuerySerial
|
||||||
|
const useChunkedQuery = isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
|
||||||
|
|
||||||
loading.trend = true
|
loading.trend = !isSteadyTrendRangeOverChunkLimit(payload.timeStart, payload.timeEnd)
|
||||||
try {
|
try {
|
||||||
|
if (useChunkedQuery) {
|
||||||
|
// 超过 3 天的趋势查询拆成小窗口逐段加载,先撑起完整横坐标框架再增量更新。
|
||||||
|
await querySteadyTrendInChunks(payload, currentQuerySerial)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (currentQuerySerial !== trendQuerySerial) return
|
||||||
|
|
||||||
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
|
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
|
||||||
const trendResponse = await querySteadyTrend(payload)
|
const trendResponse = await querySteadyTrend(payload)
|
||||||
trendResult.value = unwrapData(trendResponse)
|
|
||||||
|
if (currentQuerySerial !== trendQuerySerial) return
|
||||||
|
|
||||||
|
const nextResult = unwrapData(trendResponse)
|
||||||
|
trendResult.value = {
|
||||||
|
...nextResult,
|
||||||
|
queryTimeStart: payload.timeStart,
|
||||||
|
queryTimeEnd: payload.timeEnd,
|
||||||
|
queryCompleted: true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!hasSteadyTrendResultData(trendResult.value)) {
|
||||||
|
trendResult.value = buildEmptySteadyTrendQueryResult(payload.timeStart, payload.timeEnd, true)
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
|
if (currentQuerySerial === trendQuerySerial) {
|
||||||
loading.trend = false
|
loading.trend = false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
watch(
|
watch(
|
||||||
selectedIndicators,
|
selectedIndicators,
|
||||||
@@ -172,7 +239,11 @@ watch(
|
|||||||
trendForm.value = {
|
trendForm.value = {
|
||||||
...trendForm.value,
|
...trendForm.value,
|
||||||
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
|
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
|
||||||
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
|
harmonicOrders: showHarmonicOrders.value
|
||||||
|
? trendForm.value.harmonicOrders.length
|
||||||
|
? trendForm.value.harmonicOrders
|
||||||
|
: [...DEFAULT_HARMONIC_ORDERS]
|
||||||
|
: []
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{ deep: true }
|
{ deep: true }
|
||||||
|
|||||||
@@ -69,10 +69,37 @@ const flattenLedgerNodes = (
|
|||||||
) => {
|
) => {
|
||||||
nodes.forEach(node => {
|
nodes.forEach(node => {
|
||||||
const rawNode = node as RawLedgerNode
|
const rawNode = node as RawLedgerNode
|
||||||
const children = Array.isArray(node.children) ? node.children : []
|
const rawChildren = rawNode.children ?? rawNode.Children ?? rawNode.childrenList ?? rawNode.childList
|
||||||
const id = resolveText(rawNode, 'id', 'Id')
|
const children = Array.isArray(rawChildren) ? (rawChildren as SteadyDataView.SteadyLedgerNode[]) : []
|
||||||
const parentId = resolveText(rawNode, 'parentId', 'pid', 'Pid') || inheritedParentId
|
|
||||||
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
|
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
|
||||||
|
const id = resolveText(
|
||||||
|
rawNode,
|
||||||
|
'id',
|
||||||
|
'Id',
|
||||||
|
'lineId',
|
||||||
|
'line_id',
|
||||||
|
'equipmentId',
|
||||||
|
'equipment_id',
|
||||||
|
'deviceId',
|
||||||
|
'device_id',
|
||||||
|
'projectId',
|
||||||
|
'project_id',
|
||||||
|
'engineeringId',
|
||||||
|
'engineering_id'
|
||||||
|
)
|
||||||
|
const parentId =
|
||||||
|
resolveText(
|
||||||
|
rawNode,
|
||||||
|
'parentId',
|
||||||
|
'parent_id',
|
||||||
|
'pid',
|
||||||
|
'Pid',
|
||||||
|
level === 3 ? 'deviceId' : '',
|
||||||
|
level === 3 ? 'device_id' : '',
|
||||||
|
level === 2 ? 'projectId' : '',
|
||||||
|
level === 2 ? 'project_id' : ''
|
||||||
|
) ||
|
||||||
|
inheritedParentId
|
||||||
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
|
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
|
||||||
|
|
||||||
if (!id) return
|
if (!id) return
|
||||||
@@ -82,11 +109,27 @@ const flattenLedgerNodes = (
|
|||||||
id,
|
id,
|
||||||
parentId,
|
parentId,
|
||||||
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
|
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
|
||||||
name: resolveText(rawNode, 'name', 'Name') || id,
|
name:
|
||||||
|
resolveText(
|
||||||
|
rawNode,
|
||||||
|
'name',
|
||||||
|
'Name',
|
||||||
|
'lineName',
|
||||||
|
'line_name',
|
||||||
|
'equipmentName',
|
||||||
|
'equipment_name',
|
||||||
|
'deviceName',
|
||||||
|
'device_name',
|
||||||
|
'projectName',
|
||||||
|
'project_name',
|
||||||
|
'engineeringName',
|
||||||
|
'engineering_name'
|
||||||
|
) ||
|
||||||
|
id,
|
||||||
level,
|
level,
|
||||||
sort: resolveNumber(rawNode, 'sort', 'Sort'),
|
sort: resolveNumber(rawNode, 'sort', 'Sort'),
|
||||||
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount'),
|
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount', 'equipmentCount', 'equipment_count'),
|
||||||
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount'),
|
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount', 'monitorCount', 'monitor_count'),
|
||||||
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
|
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
|
||||||
children: [],
|
children: [],
|
||||||
__order: output.length
|
__order: output.length
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
|
|||||||
export const MAX_TREND_SERIES_COUNT = 24
|
export const MAX_TREND_SERIES_COUNT = 24
|
||||||
export const MAX_SELECTED_LINE_COUNT = 6
|
export const MAX_SELECTED_LINE_COUNT = 6
|
||||||
export const MAX_SELECTED_INDICATOR_COUNT = 6
|
export const MAX_SELECTED_INDICATOR_COUNT = 6
|
||||||
export const MAX_HARMONIC_ORDER_COUNT = 6
|
export const MAX_HARMONIC_ORDER_COUNT = 3
|
||||||
|
const STEADY_INDICATOR_GROUP_ORDER = ['电压趋势', '电流趋势']
|
||||||
|
|
||||||
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
|
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
|
||||||
return node.level === 3 && node.selectable !== false
|
return node.level === 3 && node.selectable !== false
|
||||||
@@ -47,6 +48,30 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
|
|||||||
return indicators
|
return indicators
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveSteadyIndicatorGroupOrder = (node: SteadyDataView.SteadyIndicatorNode) => {
|
||||||
|
const orderName = String(node.name || '').trim()
|
||||||
|
const orderIndex = STEADY_INDICATOR_GROUP_ORDER.findIndex(name => orderName === name)
|
||||||
|
|
||||||
|
return orderIndex === -1 ? STEADY_INDICATOR_GROUP_ORDER.length : orderIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortSteadyIndicatorTree = (
|
||||||
|
nodes: SteadyDataView.SteadyIndicatorNode[]
|
||||||
|
): SteadyDataView.SteadyIndicatorNode[] => {
|
||||||
|
return nodes
|
||||||
|
.map((node, index) => ({ node, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = resolveSteadyIndicatorGroupOrder(left.node)
|
||||||
|
const rightOrder = resolveSteadyIndicatorGroupOrder(right.node)
|
||||||
|
|
||||||
|
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder
|
||||||
|
})
|
||||||
|
.map(({ node }) => ({
|
||||||
|
...node,
|
||||||
|
children: node.children?.length ? sortSteadyIndicatorTree(node.children) : node.children
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
export const findFirstSelectableLedgerNode = (
|
export const findFirstSelectableLedgerNode = (
|
||||||
nodes: SteadyDataView.SteadyLedgerNode[]
|
nodes: SteadyDataView.SteadyLedgerNode[]
|
||||||
): SteadyDataView.SteadyLedgerNode | null => {
|
): SteadyDataView.SteadyLedgerNode | null => {
|
||||||
@@ -100,7 +125,8 @@ export const estimateTrendSeriesCount = (
|
|||||||
statType: SteadyDataView.SteadyTrendStatType,
|
statType: SteadyDataView.SteadyTrendStatType,
|
||||||
harmonicOrders: number[]
|
harmonicOrders: number[]
|
||||||
) => {
|
) => {
|
||||||
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
|
void harmonicOrders
|
||||||
|
const harmonicMultiplier = 1
|
||||||
|
|
||||||
return indicators.reduce((count, indicator) => {
|
return indicators.reduce((count, indicator) => {
|
||||||
const phaseCount = indicator.phaseCodes?.length || 1
|
const phaseCount = indicator.phaseCodes?.length || 1
|
||||||
@@ -113,7 +139,7 @@ export const estimateTrendSeriesCount = (
|
|||||||
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
|
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
|
||||||
if (!hasHarmonicIndicator(indicators)) return ''
|
if (!hasHarmonicIndicator(indicators)) return ''
|
||||||
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
|
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
|
||||||
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
|
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return `谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`
|
||||||
|
|
||||||
return ''
|
return ''
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,10 @@ export type SteadyTrendActiveTool = 'none' | 'box-zoom' | 'pan'
|
|||||||
export interface SteadyTrendChartBuildOptions {
|
export interface SteadyTrendChartBuildOptions {
|
||||||
activeTool?: SteadyTrendActiveTool
|
activeTool?: SteadyTrendActiveTool
|
||||||
wheelZoomEnabled?: boolean
|
wheelZoomEnabled?: boolean
|
||||||
|
showMissingData?: boolean
|
||||||
yZoomScale?: number
|
yZoomScale?: number
|
||||||
|
queryTimeStart?: string
|
||||||
|
queryTimeEnd?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
const STEADY_TREND_CHART_GROUP = 'steady-trend-chart-sync'
|
const STEADY_TREND_CHART_GROUP = 'steady-trend-chart-sync'
|
||||||
@@ -38,6 +41,11 @@ const STEADY_TREND_GRID_LEFT = '64px'
|
|||||||
const STEADY_TREND_GRID_RIGHT = '28px'
|
const STEADY_TREND_GRID_RIGHT = '28px'
|
||||||
const STEADY_TREND_GRID_TOP = 28
|
const STEADY_TREND_GRID_TOP = 28
|
||||||
const STEADY_TREND_LINE_MAX_WIDTH = 1.3
|
const STEADY_TREND_LINE_MAX_WIDTH = 1.3
|
||||||
|
const STEADY_TREND_LARGE_POINT_COUNT = 20000
|
||||||
|
const STEADY_TREND_RAW_VISIBLE_POINT_COUNT = 50000
|
||||||
|
const STEADY_TREND_PROGRESSIVE_CHUNK_SIZE = 5000
|
||||||
|
const STEADY_TREND_PROGRESSIVE_THRESHOLD = 10000
|
||||||
|
const STEADY_TREND_ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
const STEADY_AXIS_TEXT_COLOR = 'var(--el-text-color-regular)'
|
const STEADY_AXIS_TEXT_COLOR = 'var(--el-text-color-regular)'
|
||||||
const STEADY_AXIS_LINE_COLOR = 'var(--el-border-color)'
|
const STEADY_AXIS_LINE_COLOR = 'var(--el-border-color)'
|
||||||
|
|
||||||
@@ -51,6 +59,8 @@ const fallbackTrendColors = [
|
|||||||
'#14b8a6',
|
'#14b8a6',
|
||||||
'#f97316'
|
'#f97316'
|
||||||
]
|
]
|
||||||
|
const harmonicLineTypes = ['solid', 'dashed', 'dotted'] as const
|
||||||
|
const harmonicLineOpacities = [1, 0.86, 0.72, 0.58]
|
||||||
|
|
||||||
interface ReadableAxisRangeOptions {
|
interface ReadableAxisRangeOptions {
|
||||||
preferCompact?: boolean
|
preferCompact?: boolean
|
||||||
@@ -93,8 +103,83 @@ const resolveGroupTitle = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const isValidHarmonicOrder = (value: number) => Number.isInteger(value) && value >= 2 && value <= 50
|
||||||
|
|
||||||
|
const parseHarmonicOrderToken = (value: string) => {
|
||||||
|
const orderText =
|
||||||
|
value.match(/^([2-9]|[1-4]\d|50)$/)?.[1] ||
|
||||||
|
value.match(/^[a-z]+_([2-9]|[1-4]\d|50)$/i)?.[1] ||
|
||||||
|
value.match(/([2-9]|[1-4]\d|50)\s*次/)?.[1]
|
||||||
|
const order = Number(orderText)
|
||||||
|
|
||||||
|
return isValidHarmonicOrder(order) ? order : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicOrder = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
|
if (isValidHarmonicOrder(Number(series.harmonicOrder))) return Number(series.harmonicOrder)
|
||||||
|
|
||||||
|
const harmonicText = [series.indicatorCode, series.indicatorName, series.seriesName, series.seriesKey].join('|')
|
||||||
|
if (!/HARMONIC|谐波/i.test(harmonicText)) return null
|
||||||
|
|
||||||
|
for (const value of [series.seriesName, series.indicatorName].filter(Boolean) as string[]) {
|
||||||
|
const order = parseHarmonicOrderToken(value)
|
||||||
|
if (order) return order
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyTokens = String(series.seriesKey || '').split(/[|_\s-]+/)
|
||||||
|
for (const value of keyTokens.reverse()) {
|
||||||
|
const order = parseHarmonicOrderToken(value)
|
||||||
|
if (order) return order
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhaseLabel = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
|
const phase = String(series.phase || '').trim()
|
||||||
|
if (!phase) return series.seriesName || series.seriesKey
|
||||||
|
|
||||||
|
return phase.endsWith('相') ? phase : `${phase}相`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePhaseOrder = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
|
const phaseOrderMap: Record<string, number> = {
|
||||||
|
A: 1,
|
||||||
|
B: 2,
|
||||||
|
C: 3
|
||||||
|
}
|
||||||
|
const phase = String(series.phase || '')
|
||||||
|
.replace(/相$/, '')
|
||||||
|
.toUpperCase()
|
||||||
|
|
||||||
|
return phaseOrderMap[phase] || 99
|
||||||
|
}
|
||||||
|
|
||||||
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
|
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
return series.phase || series.seriesKey
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
const phaseLabel = formatPhaseLabel(series)
|
||||||
|
|
||||||
|
return harmonicOrder ? `${harmonicOrder}次_${phaseLabel}` : phaseLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortSteadyTrendSeries = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||||
|
return seriesList
|
||||||
|
.map((series, index) => ({ series, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = resolveHarmonicOrder(left.series)
|
||||||
|
const rightOrder = resolveHarmonicOrder(right.series)
|
||||||
|
|
||||||
|
if (!leftOrder && !rightOrder) return left.index - right.index
|
||||||
|
if (!leftOrder) return 1
|
||||||
|
if (!rightOrder) return -1
|
||||||
|
if (leftOrder !== rightOrder) return leftOrder - rightOrder
|
||||||
|
|
||||||
|
const leftPhaseOrder = resolvePhaseOrder(left.series)
|
||||||
|
const rightPhaseOrder = resolvePhaseOrder(right.series)
|
||||||
|
|
||||||
|
return leftPhaseOrder === rightPhaseOrder ? left.index - right.index : leftPhaseOrder - rightPhaseOrder
|
||||||
|
})
|
||||||
|
.map(item => item.series)
|
||||||
}
|
}
|
||||||
|
|
||||||
const getAxisPrecision = (step: number) => {
|
const getAxisPrecision = (step: number) => {
|
||||||
@@ -149,13 +234,151 @@ const formatAxisLabel = (value: number, precision: number) => {
|
|||||||
return `${normalizeAxisValue(value, precision)}`
|
return `${normalizeAxisValue(value, precision)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
const buildSteadyTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
const parseSteadyTrendPointTime = (value?: string) => {
|
||||||
const lastIndex = timeLabels.length - 1
|
if (!value) return null
|
||||||
|
|
||||||
return (value: string | number, index: number) => {
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
if (index !== 0 && index !== lastIndex) return ''
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const padSteadyTrendTimePart = (value: number) => `${value}`.padStart(2, '0')
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisDateLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
padSteadyTrendTimePart(date.getMonth() + 1),
|
||||||
|
padSteadyTrendTimePart(date.getDate())
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisShortDateLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return [padSteadyTrendTimePart(date.getMonth() + 1), padSteadyTrendTimePart(date.getDate())].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisMinuteLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return `${formatSteadyTimeAxisDateLabel(timestamp)} ${[
|
||||||
|
padSteadyTrendTimePart(date.getHours()),
|
||||||
|
padSteadyTrendTimePart(date.getMinutes())
|
||||||
|
].join(':')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisHourMinuteLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return [padSteadyTrendTimePart(date.getHours()), padSteadyTrendTimePart(date.getMinutes())].join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisFirstLabel = (label: string) => {
|
||||||
|
return `{first|${label}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisDailyLabel = (timestamp: number, firstYear?: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return date.getFullYear() === firstYear
|
||||||
|
? formatSteadyTimeAxisShortDateLabel(timestamp)
|
||||||
|
: formatSteadyTimeAxisDateLabel(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSteadyTimeAxisLabelFormatter = (timeRange: { min?: number; max?: number } = {}) => {
|
||||||
|
const firstYear = timeRange.min === undefined ? undefined : new Date(timeRange.min).getFullYear()
|
||||||
|
const isRangeOverOneDay =
|
||||||
|
timeRange.min !== undefined &&
|
||||||
|
timeRange.max !== undefined &&
|
||||||
|
timeRange.max - timeRange.min > STEADY_TREND_ONE_DAY_MS
|
||||||
|
|
||||||
|
return (value: string | number) => {
|
||||||
|
const numericValue = Number(value)
|
||||||
|
const timestamp = Number.isFinite(numericValue) ? numericValue : parseSteadyTrendPointTime(`${value}`)
|
||||||
|
|
||||||
|
if (timestamp === null) {
|
||||||
return `${value}`
|
return `${value}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return isRangeOverOneDay
|
||||||
|
? formatSteadyTimeAxisDailyLabel(timestamp, firstYear)
|
||||||
|
: formatSteadyTimeAxisHourMinuteLabel(timestamp)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTrendPointTime = (timestamp: number, sampleTime: string) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const separator = sampleTime.includes('T') ? 'T' : ' '
|
||||||
|
|
||||||
|
return (
|
||||||
|
[date.getFullYear(), padSteadyTrendTimePart(date.getMonth() + 1), padSteadyTrendTimePart(date.getDate())].join(
|
||||||
|
'-'
|
||||||
|
) +
|
||||||
|
separator +
|
||||||
|
[
|
||||||
|
padSteadyTrendTimePart(date.getHours()),
|
||||||
|
padSteadyTrendTimePart(date.getMinutes()),
|
||||||
|
padSteadyTrendTimePart(date.getSeconds())
|
||||||
|
].join(':')
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendPointIntervalMs = (points: SteadyDataView.SteadyTrendPoint[]) => {
|
||||||
|
const timestamps = Array.from(
|
||||||
|
new Set(
|
||||||
|
points
|
||||||
|
.map(point => parseSteadyTrendPointTime(point.time))
|
||||||
|
.filter((timestamp): timestamp is number => timestamp !== null)
|
||||||
|
)
|
||||||
|
).sort((left, right) => left - right)
|
||||||
|
const gaps = timestamps
|
||||||
|
.slice(1)
|
||||||
|
.map((timestamp, index) => timestamp - timestamps[index])
|
||||||
|
.filter(gap => gap > 0)
|
||||||
|
|
||||||
|
return gaps.length ? Math.min(...gaps) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillSteadyTrendMissingPoints = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||||
|
return seriesList.map(series => {
|
||||||
|
const points = series.points || []
|
||||||
|
const intervalMs = resolveSteadyTrendPointIntervalMs(points)
|
||||||
|
|
||||||
|
if (points.length < 2 || intervalMs <= 0) return series
|
||||||
|
|
||||||
|
const pointEntries = points
|
||||||
|
.map(point => ({
|
||||||
|
timestamp: parseSteadyTrendPointTime(point.time),
|
||||||
|
point
|
||||||
|
}))
|
||||||
|
.filter(
|
||||||
|
(item): item is { timestamp: number; point: SteadyDataView.SteadyTrendPoint } => item.timestamp !== null
|
||||||
|
)
|
||||||
|
.sort((left, right) => left.timestamp - right.timestamp)
|
||||||
|
|
||||||
|
if (pointEntries.length < 2) return series
|
||||||
|
|
||||||
|
const pointMap = new Map(pointEntries.map(item => [item.timestamp, item.point.value]))
|
||||||
|
const firstTimestamp = pointEntries[0].timestamp
|
||||||
|
const lastTimestamp = pointEntries[pointEntries.length - 1].timestamp
|
||||||
|
const sampleTime = pointEntries[0].point.time
|
||||||
|
const filledPoints: SteadyDataView.SteadyTrendPoint[] = []
|
||||||
|
|
||||||
|
// 缺失数据只在用户点亮按钮后补齐,默认不走该路径以避免放大点数和渲染成本。
|
||||||
|
for (let timestamp = firstTimestamp; timestamp <= lastTimestamp; timestamp += intervalMs) {
|
||||||
|
filledPoints.push({
|
||||||
|
time: formatSteadyTrendPointTime(timestamp, sampleTime),
|
||||||
|
value: pointMap.has(timestamp) ? pointMap.get(timestamp)! : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...series,
|
||||||
|
points: filledPoints
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const getReadableAxisIntervalCandidates = (value: number) => {
|
const getReadableAxisIntervalCandidates = (value: number) => {
|
||||||
@@ -504,6 +727,67 @@ const resolveSeriesColor = (series: SteadyDataView.SteadyTrendSeries, index: num
|
|||||||
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
|
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicLineType = (series: SteadyDataView.SteadyTrendSeries, pointCount: number) => {
|
||||||
|
if (pointCount >= STEADY_TREND_LARGE_POINT_COUNT) return 'solid'
|
||||||
|
|
||||||
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
if (!harmonicOrder) return 'solid'
|
||||||
|
|
||||||
|
return harmonicLineTypes[(harmonicOrder - 2) % harmonicLineTypes.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicLineOpacity = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
if (!harmonicOrder) return 1
|
||||||
|
|
||||||
|
return harmonicLineOpacities[
|
||||||
|
Math.floor((harmonicOrder - 2) / harmonicLineTypes.length) % harmonicLineOpacities.length
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendSampling = (pointCount: number, visiblePointCount: number) => {
|
||||||
|
if (visiblePointCount <= STEADY_TREND_RAW_VISIBLE_POINT_COUNT) return undefined
|
||||||
|
|
||||||
|
return pointCount >= STEADY_TREND_LARGE_POINT_COUNT ? 'lttb' : undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSteadyTrendSeriesData = (series: SteadyDataView.SteadyTrendSeries) => {
|
||||||
|
return (series.points || [])
|
||||||
|
.map(point => {
|
||||||
|
const timestamp = parseSteadyTrendPointTime(point.time)
|
||||||
|
|
||||||
|
return timestamp === null ? null : [timestamp, point.value]
|
||||||
|
})
|
||||||
|
.filter((point): point is [number, number | null] => point !== null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendTimeRange = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
|
||||||
|
let min = Number.POSITIVE_INFINITY
|
||||||
|
let max = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
seriesList.forEach(series => {
|
||||||
|
;(series.points || []).forEach(point => {
|
||||||
|
const timestamp = parseSteadyTrendPointTime(point.time)
|
||||||
|
|
||||||
|
if (timestamp === null) return
|
||||||
|
|
||||||
|
min = Math.min(min, timestamp)
|
||||||
|
max = Math.max(max, timestamp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Number.isFinite(min) && Number.isFinite(max) ? { min, max } : {}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendQueryTimeRange = (chartOptions: SteadyTrendChartBuildOptions = {}) => {
|
||||||
|
const queryTimeStart = parseSteadyTrendPointTime(chartOptions.queryTimeStart)
|
||||||
|
const queryTimeEnd = parseSteadyTrendPointTime(chartOptions.queryTimeEnd)
|
||||||
|
|
||||||
|
return queryTimeStart !== null && queryTimeEnd !== null && queryTimeEnd > queryTimeStart
|
||||||
|
? { min: queryTimeStart, max: queryTimeEnd }
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
|
||||||
export const buildSteadyTrendChartOptions = (
|
export const buildSteadyTrendChartOptions = (
|
||||||
seriesList: SteadyDataView.SteadyTrendSeries[],
|
seriesList: SteadyDataView.SteadyTrendSeries[],
|
||||||
zoomRange: SteadyTrendZoomRange,
|
zoomRange: SteadyTrendZoomRange,
|
||||||
@@ -511,15 +795,19 @@ export const buildSteadyTrendChartOptions = (
|
|||||||
chartOptions: SteadyTrendChartBuildOptions = {},
|
chartOptions: SteadyTrendChartBuildOptions = {},
|
||||||
chartTitle = ''
|
chartTitle = ''
|
||||||
): Record<string, unknown> => {
|
): Record<string, unknown> => {
|
||||||
const timeLabels = Array.from(
|
const displaySeriesList =
|
||||||
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
|
chartOptions.showMissingData === true ? fillSteadyTrendMissingPoints(seriesList) : seriesList
|
||||||
).sort()
|
const sortedSeriesList = sortSteadyTrendSeries(displaySeriesList)
|
||||||
const values = seriesList.flatMap(series =>
|
const values = displaySeriesList.flatMap(series =>
|
||||||
(series.points || [])
|
(series.points || [])
|
||||||
.map(point => point.value)
|
.map(point => point.value)
|
||||||
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||||
)
|
)
|
||||||
const unit = seriesList.find(series => series.unit)?.unit || ''
|
const unit = displaySeriesList.find(series => series.unit)?.unit || ''
|
||||||
|
const timeRange = {
|
||||||
|
...resolveSteadyTrendTimeRange(displaySeriesList),
|
||||||
|
...resolveSteadyTrendQueryTimeRange(chartOptions)
|
||||||
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
activeTool: chartOptions.activeTool || 'none',
|
activeTool: chartOptions.activeTool || 'none',
|
||||||
@@ -535,12 +823,18 @@ export const buildSteadyTrendChartOptions = (
|
|||||||
},
|
},
|
||||||
tooltip: {
|
tooltip: {
|
||||||
trigger: 'axis',
|
trigger: 'axis',
|
||||||
|
showDelay: 80,
|
||||||
|
hideDelay: 80,
|
||||||
|
transitionDuration: 0,
|
||||||
axisPointer: {
|
axisPointer: {
|
||||||
type: 'line',
|
type: 'line',
|
||||||
snap: true,
|
snap: true,
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
color: 'rgba(24, 144, 255, 0.55)',
|
color: 'rgba(24, 144, 255, 0.55)',
|
||||||
width: 1
|
width: 1
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -557,8 +851,9 @@ export const buildSteadyTrendChartOptions = (
|
|||||||
containLabel: false
|
containLabel: false
|
||||||
},
|
},
|
||||||
xAxis: {
|
xAxis: {
|
||||||
type: 'category',
|
type: 'time',
|
||||||
data: timeLabels,
|
min: timeRange.min,
|
||||||
|
max: timeRange.max,
|
||||||
boundaryGap: false,
|
boundaryGap: false,
|
||||||
axisLine: {
|
axisLine: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -569,11 +864,21 @@ export const buildSteadyTrendChartOptions = (
|
|||||||
},
|
},
|
||||||
axisLabel: {
|
axisLabel: {
|
||||||
show: showTimeAxis,
|
show: showTimeAxis,
|
||||||
hideOverlap: false,
|
hideOverlap: true,
|
||||||
interval: 0,
|
showMinLabel: true,
|
||||||
margin: showTimeAxis ? 10 : 0,
|
showMaxLabel: false,
|
||||||
|
margin: showTimeAxis ? 16 : 0,
|
||||||
|
alignMinLabel: 'left',
|
||||||
|
alignMaxLabel: 'right',
|
||||||
|
width: 72,
|
||||||
|
overflow: 'truncate',
|
||||||
color: STEADY_AXIS_TEXT_COLOR,
|
color: STEADY_AXIS_TEXT_COLOR,
|
||||||
formatter: buildSteadyTimeAxisLabelFormatter(timeLabels)
|
rich: {
|
||||||
|
first: {
|
||||||
|
padding: [0, 0, 0, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatter: buildSteadyTimeAxisLabelFormatter(timeRange)
|
||||||
},
|
},
|
||||||
axisTick: {
|
axisTick: {
|
||||||
show: false,
|
show: false,
|
||||||
@@ -594,24 +899,65 @@ export const buildSteadyTrendChartOptions = (
|
|||||||
{
|
{
|
||||||
start: zoomRange.start,
|
start: zoomRange.start,
|
||||||
height: 13,
|
height: 13,
|
||||||
|
handleSize: '300%',
|
||||||
|
realtime: false,
|
||||||
|
cursor: 'pointer',
|
||||||
|
handleStyle: {
|
||||||
|
color: '#f5f9ff',
|
||||||
|
borderColor: '#9bbcf3',
|
||||||
|
borderWidth: 1
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
handleStyle: {
|
||||||
|
color: '#e8f2ff',
|
||||||
|
borderColor: '#409eff',
|
||||||
|
borderWidth: 2,
|
||||||
|
shadowBlur: 6,
|
||||||
|
shadowColor: 'rgba(64, 158, 255, 0.35)'
|
||||||
|
}
|
||||||
|
},
|
||||||
bottom: '20px',
|
bottom: '20px',
|
||||||
end: zoomRange.end
|
end: zoomRange.end
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
color: seriesList.map(resolveSeriesColor),
|
toolbox: {
|
||||||
series: seriesList.map((series, index) => {
|
// 外部工具栏通过 takeGlobalCursor 激活框选放大,ECharts 仍需要注册 toolbox.dataZoom 才会创建框选控制器。
|
||||||
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
|
show: true,
|
||||||
|
showTitle: false,
|
||||||
|
itemSize: 0,
|
||||||
|
itemGap: 0,
|
||||||
|
left: -100,
|
||||||
|
top: -100,
|
||||||
|
feature: {
|
||||||
|
dataZoom: {
|
||||||
|
yAxisIndex: 'none',
|
||||||
|
brushStyle: {
|
||||||
|
color: 'rgba(64, 158, 255, 0.18)',
|
||||||
|
borderColor: 'rgba(64, 158, 255, 0.65)',
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: sortedSeriesList.map(resolveSeriesColor),
|
||||||
|
series: sortedSeriesList.map((series, index) => {
|
||||||
|
const pointCount = series.points?.length || 0
|
||||||
|
const visiblePointCount = resolveSteadyTrendVisiblePointCount(pointCount, zoomRange)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: formatSeriesName(series),
|
name: formatSeriesName(series),
|
||||||
type: 'line',
|
type: 'line',
|
||||||
|
animation: false,
|
||||||
|
sampling: resolveSteadyTrendSampling(pointCount, visiblePointCount),
|
||||||
|
progressive: STEADY_TREND_PROGRESSIVE_CHUNK_SIZE,
|
||||||
|
progressiveThreshold: STEADY_TREND_PROGRESSIVE_THRESHOLD,
|
||||||
showSymbol: false,
|
showSymbol: false,
|
||||||
connectNulls: false,
|
connectNulls: false,
|
||||||
data: timeLabels.map(time => pointMap.get(time) ?? null),
|
data: buildSteadyTrendSeriesData(series),
|
||||||
lineStyle: {
|
lineStyle: {
|
||||||
width: resolveSteadyTrendLineWidth(
|
width: resolveSteadyTrendLineWidth(visiblePointCount),
|
||||||
resolveSteadyTrendVisiblePointCount(series.points?.length || 0, zoomRange)
|
type: resolveHarmonicLineType(series, pointCount),
|
||||||
),
|
opacity: resolveHarmonicLineOpacity(series),
|
||||||
color: resolveSeriesColor(series, index)
|
color: resolveSeriesColor(series, index)
|
||||||
},
|
},
|
||||||
itemStyle: {
|
itemStyle: {
|
||||||
@@ -627,6 +973,17 @@ export const buildSteadyTrendChartGroups = (
|
|||||||
zoomRange: SteadyTrendZoomRange,
|
zoomRange: SteadyTrendZoomRange,
|
||||||
chartOptions: SteadyTrendChartBuildOptions = {}
|
chartOptions: SteadyTrendChartBuildOptions = {}
|
||||||
): SteadyTrendChartGroup[] => {
|
): SteadyTrendChartGroup[] => {
|
||||||
|
if (!seriesList.length && chartOptions.queryTimeStart && chartOptions.queryTimeEnd) {
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
key: 'steady-trend-empty',
|
||||||
|
title: '趋势图',
|
||||||
|
group: STEADY_TREND_CHART_GROUP,
|
||||||
|
options: buildSteadyTrendChartOptions([], zoomRange, true, chartOptions, '趋势图')
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
if (!seriesList.length) return []
|
if (!seriesList.length) return []
|
||||||
|
|
||||||
const groupMode = resolveGroupMode(seriesList)
|
const groupMode = resolveGroupMode(seriesList)
|
||||||
|
|||||||
@@ -10,6 +10,11 @@ export interface SteadyTrendFormState {
|
|||||||
harmonicOrders: number[]
|
harmonicOrders: number[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HARMONIC_ORDERS = [2]
|
||||||
|
const STEADY_TREND_CHUNK_DAYS = 3
|
||||||
|
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const STEADY_TREND_CHUNK_MS = STEADY_TREND_CHUNK_DAYS * STEADY_TREND_DAY_MS
|
||||||
|
|
||||||
export const defaultTrendFormState = (): SteadyTrendFormState => {
|
export const defaultTrendFormState = (): SteadyTrendFormState => {
|
||||||
const baseDate = new Date()
|
const baseDate = new Date()
|
||||||
|
|
||||||
@@ -19,15 +24,33 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
|
|||||||
timeBaseDate: baseDate,
|
timeBaseDate: baseDate,
|
||||||
statType: 'AVG',
|
statType: 'AVG',
|
||||||
qualityFlag: 0,
|
qualityFlag: 0,
|
||||||
harmonicOrders: []
|
harmonicOrders: [...DEFAULT_HARMONIC_ORDERS]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const formatSteadyTrendQueryTime = (value: string) => {
|
const formatSteadyTrendQueryTime = (value: string) => {
|
||||||
// 后端趋势接口只接受 yyyy-MM-dd HH:mm:ss,公共时间组件生成的毫秒需要在入参层收敛。
|
// 后端趋势接口只接收 yyyy-MM-dd HH:mm:ss,公共时间组件生成的毫秒需要在入参层收敛。
|
||||||
return value.replace(/\.[^.]+$/, '')
|
return value.replace(/\.[^.]+$/, '')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const parseSteadyTrendQueryTime = (value: string) => {
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const padSteadyTrendTimeValue = (value: number) => `${value}`.padStart(2, '0')
|
||||||
|
|
||||||
|
const formatSteadyTrendChunkTime = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return `${date.getFullYear()}-${padSteadyTrendTimeValue(date.getMonth() + 1)}-${padSteadyTrendTimeValue(
|
||||||
|
date.getDate()
|
||||||
|
)} ${padSteadyTrendTimeValue(date.getHours())}:${padSteadyTrendTimeValue(date.getMinutes())}:${padSteadyTrendTimeValue(
|
||||||
|
date.getSeconds()
|
||||||
|
)}`
|
||||||
|
}
|
||||||
|
|
||||||
export const buildSteadyTrendQueryPayload = (
|
export const buildSteadyTrendQueryPayload = (
|
||||||
lineIds: string[],
|
lineIds: string[],
|
||||||
indicators: SteadyDataView.SteadyIndicatorNode[],
|
indicators: SteadyDataView.SteadyIndicatorNode[],
|
||||||
@@ -43,3 +66,125 @@ export const buildSteadyTrendQueryPayload = (
|
|||||||
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const isSteadyTrendRangeOverChunkLimit = (timeStart: string, timeEnd: string) => {
|
||||||
|
const startTimestamp = parseSteadyTrendQueryTime(timeStart)
|
||||||
|
const endTimestamp = parseSteadyTrendQueryTime(timeEnd)
|
||||||
|
|
||||||
|
if (startTimestamp === null || endTimestamp === null) return false
|
||||||
|
|
||||||
|
return endTimestamp - startTimestamp > STEADY_TREND_CHUNK_MS
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendQueryChunks = (
|
||||||
|
payload: SteadyDataView.SteadyTrendQueryParams
|
||||||
|
): SteadyDataView.SteadyTrendQueryParams[] => {
|
||||||
|
const startTimestamp = parseSteadyTrendQueryTime(payload.timeStart)
|
||||||
|
const endTimestamp = parseSteadyTrendQueryTime(payload.timeEnd)
|
||||||
|
|
||||||
|
if (startTimestamp === null || endTimestamp === null || endTimestamp <= startTimestamp) return [payload]
|
||||||
|
|
||||||
|
const chunks: SteadyDataView.SteadyTrendQueryParams[] = []
|
||||||
|
let currentStart = startTimestamp
|
||||||
|
|
||||||
|
while (currentStart <= endTimestamp) {
|
||||||
|
const currentEnd = Math.min(currentStart + STEADY_TREND_CHUNK_MS - 1000, endTimestamp)
|
||||||
|
|
||||||
|
chunks.push({
|
||||||
|
...payload,
|
||||||
|
timeStart: formatSteadyTrendChunkTime(currentStart),
|
||||||
|
timeEnd: formatSteadyTrendChunkTime(currentEnd)
|
||||||
|
})
|
||||||
|
|
||||||
|
currentStart = currentEnd + 1000
|
||||||
|
}
|
||||||
|
|
||||||
|
return chunks
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildEmptySteadyTrendQueryResult = (
|
||||||
|
queryTimeStart: string,
|
||||||
|
queryTimeEnd: string,
|
||||||
|
queryCompleted = false
|
||||||
|
): SteadyDataView.SteadyTrendQueryResult => {
|
||||||
|
return {
|
||||||
|
sampled: false,
|
||||||
|
sourcePointCount: 0,
|
||||||
|
displayPointCount: 0,
|
||||||
|
loadableDays: [],
|
||||||
|
queryTimeStart,
|
||||||
|
queryTimeEnd,
|
||||||
|
queryCompleted,
|
||||||
|
series: []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasSteadyTrendResultData = (result: SteadyDataView.SteadyTrendQueryResult | null) => {
|
||||||
|
return Boolean(
|
||||||
|
result?.series?.some(series =>
|
||||||
|
(series.points || []).some(point => typeof point.value === 'number' && Number.isFinite(point.value))
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSteadyTrendPointTimestamp = (point: SteadyDataView.SteadyTrendPoint) => parseSteadyTrendQueryTime(point.time)
|
||||||
|
|
||||||
|
export const mergeSteadyTrendQueryResult = (
|
||||||
|
currentResult: SteadyDataView.SteadyTrendQueryResult | null,
|
||||||
|
nextResult: SteadyDataView.SteadyTrendQueryResult | null
|
||||||
|
): SteadyDataView.SteadyTrendQueryResult => {
|
||||||
|
const seriesMap = new Map<string, SteadyDataView.SteadyTrendSeries>()
|
||||||
|
const appendSeries = (seriesList: SteadyDataView.SteadyTrendSeries[] = []) => {
|
||||||
|
seriesList.forEach(series => {
|
||||||
|
const currentSeries = seriesMap.get(series.seriesKey)
|
||||||
|
|
||||||
|
if (!currentSeries) {
|
||||||
|
seriesMap.set(series.seriesKey, {
|
||||||
|
...series,
|
||||||
|
points: [...(series.points || [])]
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
currentSeries.points = [...(currentSeries.points || []), ...(series.points || [])]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
appendSeries(currentResult?.series)
|
||||||
|
appendSeries(nextResult?.series)
|
||||||
|
|
||||||
|
const series = Array.from(seriesMap.values()).map(item => {
|
||||||
|
const pointMap = new Map<string, SteadyDataView.SteadyTrendPoint>()
|
||||||
|
|
||||||
|
;(item.points || []).forEach(point => {
|
||||||
|
pointMap.set(point.time, point)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
...item,
|
||||||
|
points: Array.from(pointMap.values()).sort((left, right) => {
|
||||||
|
const leftTimestamp = getSteadyTrendPointTimestamp(left)
|
||||||
|
const rightTimestamp = getSteadyTrendPointTimestamp(right)
|
||||||
|
|
||||||
|
if (leftTimestamp === null || rightTimestamp === null) return left.time.localeCompare(right.time)
|
||||||
|
|
||||||
|
return leftTimestamp - rightTimestamp
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
const displayPointCount = series.reduce((total, item) => total + (item.points?.length || 0), 0)
|
||||||
|
|
||||||
|
return {
|
||||||
|
sampled: Boolean(currentResult?.sampled || nextResult?.sampled),
|
||||||
|
bucket: nextResult?.bucket || currentResult?.bucket,
|
||||||
|
sourcePointCount: (currentResult?.sourcePointCount || 0) + (nextResult?.sourcePointCount || 0),
|
||||||
|
displayPointCount,
|
||||||
|
loadableDays: Array.from(
|
||||||
|
new Set([...(currentResult?.loadableDays || []), ...(nextResult?.loadableDays || [])])
|
||||||
|
),
|
||||||
|
queryTimeStart: currentResult?.queryTimeStart || nextResult?.queryTimeStart,
|
||||||
|
queryTimeEnd: currentResult?.queryTimeEnd || nextResult?.queryTimeEnd,
|
||||||
|
queryCompleted: Boolean(currentResult?.queryCompleted || nextResult?.queryCompleted),
|
||||||
|
series
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
<template>
|
||||||
|
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': collapsed }">
|
||||||
|
<el-button
|
||||||
|
class="indicator-toggle"
|
||||||
|
type="primary"
|
||||||
|
:icon="collapsed ? ArrowLeft : ArrowRight"
|
||||||
|
circle
|
||||||
|
@click="emit('update:collapsed', !collapsed)"
|
||||||
|
/>
|
||||||
|
<div v-show="!collapsed" class="indicator-panel-body">
|
||||||
|
<SteadyIndicatorTree
|
||||||
|
:key="selectorResetKey"
|
||||||
|
:tree-data="treeData"
|
||||||
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
|
@change="emit('change', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import SteadyIndicatorTree from './SteadyIndicatorTree.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyIndicatorFloatingPanel'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
collapsed: boolean
|
||||||
|
treeData: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
defaultCheckedKeys: string[]
|
||||||
|
selectorResetKey: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:collapsed': [value: boolean]
|
||||||
|
change: [nodes: SteadyTrend.SteadyIndicatorNode[]]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.indicator-floating-panel {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
right: 12px;
|
||||||
|
bottom: 12px;
|
||||||
|
z-index: 2;
|
||||||
|
width: 300px;
|
||||||
|
transition: width 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-collapsed {
|
||||||
|
width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-toggle {
|
||||||
|
position: absolute;
|
||||||
|
top: 12px;
|
||||||
|
left: -28px;
|
||||||
|
z-index: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-panel-body {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-panel-body :deep(.steady-tree-card) {
|
||||||
|
height: 100%;
|
||||||
|
box-shadow: var(--el-box-shadow-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-floating-panel.is-collapsed .indicator-panel-body {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.indicator-floating-panel {
|
||||||
|
width: 280px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,134 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card steady-tree-card indicator-tree">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">稳态指标</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-scrollbar class="tree-scrollbar">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
class="indicator-tree"
|
||||||
|
:data="normalizedTreeData"
|
||||||
|
node-key="treeKey"
|
||||||
|
show-checkbox
|
||||||
|
default-expand-all
|
||||||
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
@check="handleCheck"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="tree-node">
|
||||||
|
<span class="node-name">{{ data.name }}</span>
|
||||||
|
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</el-scrollbar>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import type { TreeInstance } from 'element-plus'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import { collectLeafIndicators } from '../utils/selectionRules'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyIndicatorTree'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
treeData: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
defaultCheckedKeys: string[]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
change: [nodes: SteadyTrend.SteadyIndicatorNode[]]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const treeRef = ref<TreeInstance>()
|
||||||
|
const normalizedTreeData = computed(() => {
|
||||||
|
const normalize = (nodes: SteadyTrend.SteadyIndicatorNode[], parentKey = ''): SteadyTrend.SteadyIndicatorNode[] => {
|
||||||
|
return nodes.map((node, index) => {
|
||||||
|
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
treeKey,
|
||||||
|
children: node.children?.length ? normalize(node.children, `${treeKey}-`) : undefined
|
||||||
|
} as SteadyTrend.SteadyIndicatorNode
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalize(props.treeData)
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleCheck = () => {
|
||||||
|
const checkedNodes = (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
emit('change', collectLeafIndicators(checkedNodes))
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDefaultCheckedKeys = async () => {
|
||||||
|
await nextTick()
|
||||||
|
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [normalizedTreeData.value, props.defaultCheckedKeys],
|
||||||
|
() => {
|
||||||
|
applyDefaultCheckedKeys()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-tree-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.indicator-tree :deep(.el-tree-node__content) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,275 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card steady-tree-card" :class="{ 'is-collapsed': collapsed }">
|
||||||
|
<div v-show="collapsed" class="collapsed-panel">
|
||||||
|
<el-tooltip content="展开设备树" placement="right">
|
||||||
|
<el-button class="panel-toggle" type="primary" :icon="ArrowRight" circle @click="emit('toggle')" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-show="!collapsed" class="expanded-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<span class="panel-title">设备树</span>
|
||||||
|
<el-tooltip content="收缩设备树" placement="top">
|
||||||
|
<el-button class="panel-toggle" type="primary" :icon="ArrowLeft" circle @click="emit('toggle')" />
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="tree-search-row">
|
||||||
|
<el-input
|
||||||
|
:model-value="keyword"
|
||||||
|
clearable
|
||||||
|
placeholder="搜索工程、项目、设备、监测点"
|
||||||
|
@update:model-value="handleKeywordChange"
|
||||||
|
></el-input>
|
||||||
|
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-scrollbar class="tree-scrollbar">
|
||||||
|
<el-tree
|
||||||
|
ref="treeRef"
|
||||||
|
class="ledger-tree"
|
||||||
|
:data="treeData"
|
||||||
|
node-key="id"
|
||||||
|
show-checkbox
|
||||||
|
default-expand-all
|
||||||
|
:default-checked-keys="defaultCheckedKeys"
|
||||||
|
:expand-on-click-node="false"
|
||||||
|
:props="{ label: 'name', children: 'children' }"
|
||||||
|
@check="handleCheck"
|
||||||
|
>
|
||||||
|
<template #default="{ data }">
|
||||||
|
<div class="tree-node">
|
||||||
|
<span class="node-main">
|
||||||
|
<el-icon :class="['node-icon', `is-level-${normalizeLedgerLevel(data.level)}`]">
|
||||||
|
<component :is="resolveLedgerIcon(data.level)" />
|
||||||
|
</el-icon>
|
||||||
|
<span class="node-name">{{ data.name }}</span>
|
||||||
|
</span>
|
||||||
|
<span class="node-count">
|
||||||
|
<template v-if="shouldShowLedgerCount(data)">
|
||||||
|
{{ resolveLedgerCountText(data) }}
|
||||||
|
</template>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</el-tree>
|
||||||
|
</el-scrollbar>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { nextTick, ref, watch } from 'vue'
|
||||||
|
import type { Component } from 'vue'
|
||||||
|
import { ArrowLeft, ArrowRight, Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
|
||||||
|
import type { TreeInstance } from 'element-plus'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyLedgerTree'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
treeData: SteadyTrend.SteadyLedgerNode[]
|
||||||
|
loading: boolean
|
||||||
|
keyword: string
|
||||||
|
defaultCheckedKeys: string[]
|
||||||
|
collapsed: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
refresh: []
|
||||||
|
search: [value: string]
|
||||||
|
change: [nodes: SteadyTrend.SteadyLedgerNode[]]
|
||||||
|
toggle: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const treeRef = ref<TreeInstance>()
|
||||||
|
type LedgerLevel = SteadyTrend.SteadyLedgerNode['level']
|
||||||
|
const ledgerIcons: Record<LedgerLevel, Component> = {
|
||||||
|
0: OfficeBuilding,
|
||||||
|
1: Folder,
|
||||||
|
2: Monitor,
|
||||||
|
3: Location
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLedgerLevel = (value: unknown): LedgerLevel => {
|
||||||
|
const level = Number(value)
|
||||||
|
if (level === 0 || level === 1 || level === 2 || level === 3) {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLedgerIcon = (value: unknown) => {
|
||||||
|
return ledgerIcons[normalizeLedgerLevel(value)]
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldShowLedgerCount = (data: SteadyTrend.SteadyLedgerNode) => {
|
||||||
|
return Number(data.level) < 3 && (Number(data.deviceCount) > 0 || Number(data.lineCount) > 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLedgerCountText = (data: SteadyTrend.SteadyLedgerNode) => {
|
||||||
|
if (normalizeLedgerLevel(data.level) === 2) {
|
||||||
|
return String(Number(data.lineCount || 0))
|
||||||
|
}
|
||||||
|
|
||||||
|
return `${Number(data.deviceCount || 0)} / ${Number(data.lineCount || 0)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeywordChange = (value: string) => {
|
||||||
|
emit('search', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleCheck = () => {
|
||||||
|
emit('change', (treeRef.value?.getCheckedNodes(false, false) || []) as SteadyTrend.SteadyLedgerNode[])
|
||||||
|
}
|
||||||
|
|
||||||
|
const applyDefaultCheckedKeys = async () => {
|
||||||
|
await nextTick()
|
||||||
|
treeRef.value?.setCheckedKeys(props.defaultCheckedKeys, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.treeData, props.defaultCheckedKeys],
|
||||||
|
() => {
|
||||||
|
applyDefaultCheckedKeys()
|
||||||
|
},
|
||||||
|
{ immediate: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-tree-card {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-tree-card:not(.is-collapsed) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-tree-card.is-collapsed {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
z-index: 4;
|
||||||
|
align-items: center;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
min-height: 36px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: visible;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel,
|
||||||
|
.expanded-panel {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.collapsed-panel {
|
||||||
|
display: flex;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.expanded-panel {
|
||||||
|
display: flex;
|
||||||
|
flex: 1;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header,
|
||||||
|
.tree-node {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-title {
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--el-text-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-toggle {
|
||||||
|
flex: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-search-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-search-row :deep(.el-input) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-scrollbar {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tree-node {
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-main {
|
||||||
|
display: inline-flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon {
|
||||||
|
flex: none;
|
||||||
|
font-size: 15px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon.is-level-0 {
|
||||||
|
color: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon.is-level-1 {
|
||||||
|
color: var(--el-color-success);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon.is-level-2 {
|
||||||
|
color: var(--el-color-warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-icon.is-level-3 {
|
||||||
|
color: var(--el-color-info);
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-name {
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.node-count {
|
||||||
|
flex: none;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-tree :deep(.el-tree-node__content) {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,444 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card trend-chart-panel">
|
||||||
|
<div class="panel-header">
|
||||||
|
<SteadyTrendChartTools
|
||||||
|
:tool-groups="trendToolGroups"
|
||||||
|
:is-tool-active="isTrendToolActive"
|
||||||
|
:is-tool-disabled="isTrendToolDisabled"
|
||||||
|
:get-tool-tooltip="getTrendToolTooltip"
|
||||||
|
@tool-action="handleTrendToolAction"
|
||||||
|
/>
|
||||||
|
<span v-if="trendResult" class="panel-meta">总点数:{{ trendResult.displayPointCount || 0 }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chart-panel-body" v-loading="loading">
|
||||||
|
<div
|
||||||
|
v-if="hasSeries"
|
||||||
|
ref="chartExportTargetRef"
|
||||||
|
class="chart-list steady-trend-export-target"
|
||||||
|
:style="{ '--steady-trend-visible-chart-count': normalVisibleChartCount }"
|
||||||
|
>
|
||||||
|
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
|
||||||
|
<div class="chart-body">
|
||||||
|
<SteadyTrendChartRenderer
|
||||||
|
:group="group"
|
||||||
|
:zoom-range="trendXZoomRange"
|
||||||
|
:active-tool="activeTrendInteractionMode"
|
||||||
|
:wheel-zoom-enabled="wheelZoomEnabled"
|
||||||
|
:y-zoom-scale="trendYZoomScale"
|
||||||
|
:show-time-axis="chartGroups.indexOf(group) === chartGroups.length - 1"
|
||||||
|
@chart-data-zoom="handleChartDataZoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<SteadyTrendFullscreen
|
||||||
|
v-model="fullscreenVisible"
|
||||||
|
:chart-groups="chartGroups"
|
||||||
|
:visible-chart-count="fullscreenVisibleChartCount"
|
||||||
|
:tool-groups="fullscreenToolGroups"
|
||||||
|
:zoom-range="trendXZoomRange"
|
||||||
|
:active-tool="activeTrendInteractionMode"
|
||||||
|
:wheel-zoom-enabled="wheelZoomEnabled"
|
||||||
|
:y-zoom-scale="trendYZoomScale"
|
||||||
|
:is-tool-active="isTrendToolActive"
|
||||||
|
:is-tool-disabled="isTrendToolDisabled"
|
||||||
|
:get-tool-tooltip="getTrendToolTooltip"
|
||||||
|
@chart-data-zoom="handleChartDataZoom"
|
||||||
|
@tool-action="handleTrendToolAction"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
ArrowDownBold,
|
||||||
|
ArrowLeftBold,
|
||||||
|
ArrowRightBold,
|
||||||
|
ArrowUpBold,
|
||||||
|
Crop,
|
||||||
|
DataAnalysis,
|
||||||
|
DataLine,
|
||||||
|
FullScreen,
|
||||||
|
Mouse,
|
||||||
|
Picture,
|
||||||
|
Pointer,
|
||||||
|
RefreshLeft
|
||||||
|
} from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import html2canvas from 'html2canvas'
|
||||||
|
import { computed, nextTick, ref, watch } from 'vue'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import { isSteadyTrendLargeDataset } from '../utils/chartRenderer'
|
||||||
|
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
import SteadyTrendChartRenderer from './SteadyTrendChartRenderer.vue'
|
||||||
|
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||||
|
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
|
||||||
|
import SteadyTrendFullscreen from './SteadyTrendFullscreen.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendChartPanel'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
trendResult: SteadyTrend.SteadyTrendQueryResult | null
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
|
||||||
|
const trendToolGroups: SteadyTrendToolGroup[] = [
|
||||||
|
{
|
||||||
|
key: 'viewport',
|
||||||
|
items: [
|
||||||
|
{ action: 'x-zoom-in', label: 'X坐标放大', icon: ArrowRightBold },
|
||||||
|
{ action: 'x-zoom-out', label: 'X坐标缩小', icon: ArrowLeftBold },
|
||||||
|
{ action: 'y-zoom-in', label: 'Y坐标放大', icon: ArrowUpBold },
|
||||||
|
{ action: 'y-zoom-out', label: 'Y坐标缩小', icon: ArrowDownBold },
|
||||||
|
{ action: 'box-zoom', label: '框选放大', icon: Crop },
|
||||||
|
{ action: 'reset', label: '恢复', icon: RefreshLeft },
|
||||||
|
{ action: 'pan', label: '平移', icon: Pointer },
|
||||||
|
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
key: 'view',
|
||||||
|
items: [
|
||||||
|
{ action: 'missing-data', label: '缺失数据', icon: DataLine },
|
||||||
|
{ action: 'fullscreen', label: '全屏展示', icon: FullScreen },
|
||||||
|
{ action: 'download-image', label: '下载图片', icon: Picture },
|
||||||
|
{ action: 'query-data', label: '数据查询', icon: DataAnalysis }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const clampPercent = (value: number) => Math.min(Math.max(value, 0), 100)
|
||||||
|
const DEFAULT_STEADY_TREND_X_ZOOM_RANGE: SteadyTrendZoomRange = { start: 0, end: 100 }
|
||||||
|
const STEADY_TREND_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const STEADY_TREND_HALF_RANGE_DAYS = 20
|
||||||
|
const STEADY_TREND_QUARTER_RANGE_DAYS = 30
|
||||||
|
const STEADY_TREND_TENTH_RANGE_DAYS = 60
|
||||||
|
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||||
|
const defaultTrendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
|
||||||
|
const trendYZoomScale = ref(1)
|
||||||
|
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
|
||||||
|
const wheelZoomEnabled = ref(false)
|
||||||
|
const missingDataEnabled = ref(false)
|
||||||
|
const fullscreenVisible = ref(false)
|
||||||
|
const dataTableVisible = ref(false)
|
||||||
|
const chartExportTargetRef = ref<HTMLElement>()
|
||||||
|
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
|
||||||
|
const isLargeTrendDataset = computed(() => isSteadyTrendLargeDataset(props.trendResult?.series || []))
|
||||||
|
const chartGroups = computed(() =>
|
||||||
|
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
|
||||||
|
activeTool: activeTrendInteractionMode.value,
|
||||||
|
wheelZoomEnabled: wheelZoomEnabled.value,
|
||||||
|
showMissingData: missingDataEnabled.value,
|
||||||
|
yZoomScale: trendYZoomScale.value
|
||||||
|
})
|
||||||
|
)
|
||||||
|
const normalVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 3), 1))
|
||||||
|
const fullscreenVisibleChartCount = computed(() => Math.max(Math.min(chartGroups.value.length, 6), 1))
|
||||||
|
const fullscreenToolGroups = computed(() =>
|
||||||
|
trendToolGroups
|
||||||
|
.map(group => ({
|
||||||
|
...group,
|
||||||
|
items: group.items.filter(item => item.action !== 'fullscreen')
|
||||||
|
}))
|
||||||
|
.filter(group => group.items.length)
|
||||||
|
)
|
||||||
|
const canPanTrendChart = computed(() => {
|
||||||
|
const { start, end } = trendXZoomRange.value
|
||||||
|
|
||||||
|
return hasSeries.value && (start > 0 || end < 100)
|
||||||
|
})
|
||||||
|
const isDefaultTrendXZoomRange = computed(() => {
|
||||||
|
const { start, end } = trendXZoomRange.value
|
||||||
|
const defaultRange = defaultTrendXZoomRange.value
|
||||||
|
|
||||||
|
return start === defaultRange.start && end === defaultRange.end
|
||||||
|
})
|
||||||
|
const canResetTrendChart = computed(() => {
|
||||||
|
const changedYZoom = trendYZoomScale.value !== 1
|
||||||
|
const changedInteractionMode = activeTrendInteractionMode.value !== 'none'
|
||||||
|
const changedWheelZoom = wheelZoomEnabled.value
|
||||||
|
|
||||||
|
return hasSeries.value && (!isDefaultTrendXZoomRange.value || changedYZoom || changedInteractionMode || changedWheelZoom)
|
||||||
|
})
|
||||||
|
|
||||||
|
const parseSteadyTrendTime = (value?: string) => {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendTimeRangeMs = (trendResult: SteadyTrend.SteadyTrendQueryResult | null) => {
|
||||||
|
let minTime = Number.POSITIVE_INFINITY
|
||||||
|
let maxTime = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
trendResult?.series?.forEach(series => {
|
||||||
|
series.points?.forEach(point => {
|
||||||
|
const timestamp = parseSteadyTrendTime(point.time)
|
||||||
|
|
||||||
|
if (timestamp === null) return
|
||||||
|
|
||||||
|
minTime = Math.min(minTime, timestamp)
|
||||||
|
maxTime = Math.max(maxTime, timestamp)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return Number.isFinite(minTime) && Number.isFinite(maxTime) && maxTime > minTime ? maxTime - minTime : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendDefaultZoomRange = (trendResult: SteadyTrend.SteadyTrendQueryResult | null) => {
|
||||||
|
const timeRangeMs = resolveSteadyTrendTimeRangeMs(trendResult)
|
||||||
|
const timeRangeDays = timeRangeMs / STEADY_TREND_DAY_MS
|
||||||
|
|
||||||
|
if (timeRangeDays > STEADY_TREND_TENTH_RANGE_DAYS) return { start: 0, end: 10 }
|
||||||
|
if (timeRangeDays > STEADY_TREND_QUARTER_RANGE_DAYS) return { start: 0, end: 25 }
|
||||||
|
if (timeRangeDays > STEADY_TREND_HALF_RANGE_DAYS) return { start: 0, end: 50 }
|
||||||
|
|
||||||
|
return { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTrendToolState = () => {
|
||||||
|
trendXZoomRange.value = { ...defaultTrendXZoomRange.value }
|
||||||
|
trendYZoomScale.value = 1
|
||||||
|
activeTrendInteractionMode.value = 'none'
|
||||||
|
wheelZoomEnabled.value = false
|
||||||
|
}
|
||||||
|
|
||||||
|
const zoomTrendXAxis = (ratio: number) => {
|
||||||
|
const { start, end } = trendXZoomRange.value
|
||||||
|
const center = (start + end) / 2
|
||||||
|
const nextWidth = Math.min(Math.max((end - start) * ratio, 1), 100)
|
||||||
|
const nextStart = clampPercent(center - nextWidth / 2)
|
||||||
|
const nextEnd = clampPercent(center + nextWidth / 2)
|
||||||
|
|
||||||
|
if (nextStart === 0) {
|
||||||
|
trendXZoomRange.value = { start: 0, end: nextWidth }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextEnd === 100) {
|
||||||
|
trendXZoomRange.value = { start: 100 - nextWidth, end: 100 }
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
trendXZoomRange.value = { start: nextStart, end: nextEnd }
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTrendToolActive = (action: SteadyTrendToolAction) => {
|
||||||
|
if (action === 'fullscreen') return fullscreenVisible.value
|
||||||
|
if (action === 'wheel-zoom') return wheelZoomEnabled.value
|
||||||
|
if (action === 'missing-data') return missingDataEnabled.value
|
||||||
|
if (action === 'box-zoom' || action === 'pan') return activeTrendInteractionMode.value === action
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const isTrendToolDisabled = (action: SteadyTrendToolAction) => {
|
||||||
|
if (action === 'query-data') return false
|
||||||
|
if (!hasSeries.value) return true
|
||||||
|
if (action === 'pan') return !canPanTrendChart.value
|
||||||
|
if (action === 'reset') return !canResetTrendChart.value
|
||||||
|
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
const getTrendToolTooltip = (item: SteadyTrendToolItem) => {
|
||||||
|
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
|
||||||
|
return '请先放大 X 轴或框选局部区域后再平移'
|
||||||
|
}
|
||||||
|
|
||||||
|
return item.label
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadSteadyTrendImage = async () => {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const targetElement = fullscreenVisible.value
|
||||||
|
? (document.querySelector('.steady-trend-fullscreen__chart-list') as HTMLElement | null)
|
||||||
|
: chartExportTargetRef.value
|
||||||
|
|
||||||
|
if (!targetElement) {
|
||||||
|
ElMessage.warning('暂无可下载的趋势图')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const canvas = await html2canvas(targetElement, {
|
||||||
|
backgroundColor: '#ffffff',
|
||||||
|
scale: window.devicePixelRatio || 1,
|
||||||
|
useCORS: true
|
||||||
|
})
|
||||||
|
const imageUrl = canvas.toDataURL('image/png')
|
||||||
|
const exportFile = document.createElement('a')
|
||||||
|
|
||||||
|
exportFile.style.display = 'none'
|
||||||
|
exportFile.download = `steady-trend-${Date.now()}.png`
|
||||||
|
exportFile.href = imageUrl
|
||||||
|
document.body.appendChild(exportFile)
|
||||||
|
exportFile.click()
|
||||||
|
document.body.removeChild(exportFile)
|
||||||
|
|
||||||
|
ElMessage.success('趋势图图片下载成功')
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTrendToolAction = async (action: SteadyTrendToolAction) => {
|
||||||
|
if (isTrendToolDisabled(action)) return
|
||||||
|
|
||||||
|
switch (action) {
|
||||||
|
case 'x-zoom-in':
|
||||||
|
zoomTrendXAxis(0.8)
|
||||||
|
break
|
||||||
|
case 'x-zoom-out':
|
||||||
|
zoomTrendXAxis(1.25)
|
||||||
|
break
|
||||||
|
case 'y-zoom-in':
|
||||||
|
trendYZoomScale.value = Math.max(trendYZoomScale.value * 0.8, 0.1)
|
||||||
|
break
|
||||||
|
case 'y-zoom-out':
|
||||||
|
trendYZoomScale.value = Math.min(trendYZoomScale.value * 1.25, 10)
|
||||||
|
break
|
||||||
|
case 'box-zoom':
|
||||||
|
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'box-zoom' ? 'none' : 'box-zoom'
|
||||||
|
break
|
||||||
|
case 'wheel-zoom':
|
||||||
|
wheelZoomEnabled.value = !wheelZoomEnabled.value
|
||||||
|
break
|
||||||
|
case 'missing-data':
|
||||||
|
missingDataEnabled.value = !missingDataEnabled.value
|
||||||
|
break
|
||||||
|
case 'pan':
|
||||||
|
if (!canPanTrendChart.value) {
|
||||||
|
ElMessage.info('请先放大 X 轴或框选局部区域后再平移')
|
||||||
|
activeTrendInteractionMode.value = 'none'
|
||||||
|
break
|
||||||
|
}
|
||||||
|
activeTrendInteractionMode.value = activeTrendInteractionMode.value === 'pan' ? 'none' : 'pan'
|
||||||
|
break
|
||||||
|
case 'reset':
|
||||||
|
resetTrendToolState()
|
||||||
|
break
|
||||||
|
case 'fullscreen':
|
||||||
|
fullscreenVisible.value = true
|
||||||
|
break
|
||||||
|
case 'download-image':
|
||||||
|
await downloadSteadyTrendImage()
|
||||||
|
break
|
||||||
|
case 'query-data':
|
||||||
|
if (!hasSeries.value) {
|
||||||
|
ElMessage.warning('请先查询趋势数据')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if (isLargeTrendDataset.value) {
|
||||||
|
ElMessage.warning('当前趋势点数较大,请使用后台分页查询或导出接口查看明细数据')
|
||||||
|
break
|
||||||
|
}
|
||||||
|
dataTableVisible.value = true
|
||||||
|
break
|
||||||
|
default:
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||||
|
trendXZoomRange.value = {
|
||||||
|
start: clampPercent(value.start),
|
||||||
|
end: clampPercent(value.end)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!canPanTrendChart.value && activeTrendInteractionMode.value === 'pan') {
|
||||||
|
activeTrendInteractionMode.value = 'none'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.trendResult,
|
||||||
|
() => {
|
||||||
|
// 新查询结果按当前数据量重置默认窗口,避免沿用上一批数据的局部缩放范围。
|
||||||
|
defaultTrendXZoomRange.value = resolveSteadyTrendDefaultZoomRange(props.trendResult)
|
||||||
|
resetTrendToolState()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.trend-chart-panel {
|
||||||
|
--steady-trend-chart-gap: 8px;
|
||||||
|
--steady-trend-visible-chart-count: 3;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-header {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
gap: 0;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.panel-meta {
|
||||||
|
margin-left: 15px;
|
||||||
|
color: var(--el-text-color-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-panel-body {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--steady-trend-chart-gap);
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-group {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0
|
||||||
|
calc(
|
||||||
|
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||||
|
var(--steady-trend-visible-chart-count)
|
||||||
|
);
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--cn-color-canvas-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.chart-empty {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<template>
|
||||||
|
<SteadyTrendSciChart
|
||||||
|
:series-list="group.seriesList"
|
||||||
|
:zoom-range="zoomRange"
|
||||||
|
:active-tool="activeTool"
|
||||||
|
:wheel-zoom-enabled="wheelZoomEnabled"
|
||||||
|
:y-zoom-scale="yZoomScale"
|
||||||
|
:show-time-axis="showTimeAxis"
|
||||||
|
:title="group.title"
|
||||||
|
@chart-data-zoom="emit('chart-data-zoom', $event)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SteadyTrendActiveTool, SteadyTrendChartGroup, SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
import SteadyTrendSciChart from './SteadyTrendSciChart.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendChartRenderer'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
group: SteadyTrendChartGroup
|
||||||
|
zoomRange: SteadyTrendZoomRange
|
||||||
|
activeTool: SteadyTrendActiveTool
|
||||||
|
wheelZoomEnabled: boolean
|
||||||
|
yZoomScale: number
|
||||||
|
showTimeAxis: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'chart-data-zoom': [value: SteadyTrendZoomRange]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
<template>
|
||||||
|
<div class="trend-tool-groups">
|
||||||
|
<div v-for="group in toolGroups" :key="group.key" class="trend-tool-group">
|
||||||
|
<el-tooltip v-for="item in group.items" :key="item.action" :content="getToolTooltip(item)" placement="top">
|
||||||
|
<el-button
|
||||||
|
:type="isToolActive(item.action) ? 'primary' : 'default'"
|
||||||
|
:icon="item.icon"
|
||||||
|
:disabled="isToolDisabled(item.action)"
|
||||||
|
circle
|
||||||
|
@click="emit('tool-action', item.action)"
|
||||||
|
/>
|
||||||
|
</el-tooltip>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendChartTools'
|
||||||
|
})
|
||||||
|
|
||||||
|
defineProps<{
|
||||||
|
toolGroups: SteadyTrendToolGroup[]
|
||||||
|
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||||
|
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||||
|
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'tool-action': [action: SteadyTrendToolAction]
|
||||||
|
}>()
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.trend-tool-groups {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group + .trend-tool-group {
|
||||||
|
padding-left: 8px;
|
||||||
|
border-left: 1px dashed var(--el-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-tool-group :deep(.el-button.is-circle) {
|
||||||
|
width: 28px;
|
||||||
|
height: 28px;
|
||||||
|
padding: 6px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
<template>
|
||||||
|
<el-dialog
|
||||||
|
v-model="visibleProxy"
|
||||||
|
class="steady-trend-data-dialog"
|
||||||
|
title="数据查询"
|
||||||
|
width="86vw"
|
||||||
|
top="7vh"
|
||||||
|
append-to-body
|
||||||
|
destroy-on-close
|
||||||
|
>
|
||||||
|
<div class="table-main card steady-trend-data-table">
|
||||||
|
<div class="table-header">
|
||||||
|
<div class="header-button-lf">
|
||||||
|
<el-button
|
||||||
|
type="primary"
|
||||||
|
:icon="Download"
|
||||||
|
plain
|
||||||
|
:loading="downloading"
|
||||||
|
:disabled="!tableModel.timeValues.length"
|
||||||
|
@click="downloadSteadyTrendData"
|
||||||
|
>
|
||||||
|
下载数据
|
||||||
|
</el-button>
|
||||||
|
</div>
|
||||||
|
<div class="header-button-ri"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<el-table :data="pagedRows" border stripe height="100%">
|
||||||
|
<el-table-column prop="time" label="时间" min-width="170" fixed="left" align="center" />
|
||||||
|
<el-table-column
|
||||||
|
v-for="lineGroup in tableModel.lineGroups"
|
||||||
|
:key="lineGroup.key"
|
||||||
|
:label="lineGroup.label"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
v-for="indicatorGroup in lineGroup.indicatorGroups"
|
||||||
|
:key="indicatorGroup.key"
|
||||||
|
:label="indicatorGroup.label"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<el-table-column
|
||||||
|
v-for="column in indicatorGroup.columns"
|
||||||
|
:key="column.prop"
|
||||||
|
:prop="column.prop"
|
||||||
|
:label="column.label"
|
||||||
|
min-width="110"
|
||||||
|
align="center"
|
||||||
|
>
|
||||||
|
<template #default="{ row }">
|
||||||
|
{{ row[column.prop] ?? '-' }}
|
||||||
|
</template>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table-column>
|
||||||
|
</el-table>
|
||||||
|
|
||||||
|
<div class="table-footer">
|
||||||
|
<el-pagination
|
||||||
|
v-model:current-page="currentPage"
|
||||||
|
v-model:page-size="pageSize"
|
||||||
|
background
|
||||||
|
layout="total, sizes, prev, pager, next, jumper"
|
||||||
|
:page-sizes="[500, 1000, 2000, 5000]"
|
||||||
|
:total="tableModel.timeValues.length"
|
||||||
|
@size-change="currentPage = 1"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</el-dialog>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Download } from '@element-plus/icons-vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import {
|
||||||
|
buildSteadyTrendExcelHtml,
|
||||||
|
buildSteadyTrendTableModel,
|
||||||
|
buildSteadyTrendTableRows,
|
||||||
|
createEmptySteadyTrendTableModel
|
||||||
|
} from '../utils/trendTable'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendDataTableDialog'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
trendResult: SteadyTrend.SteadyTrendQueryResult | null
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const currentPage = ref(1)
|
||||||
|
const pageSize = ref(500)
|
||||||
|
const downloading = ref(false)
|
||||||
|
const tableModel = shallowRef(createEmptySteadyTrendTableModel())
|
||||||
|
const visibleProxy = computed({
|
||||||
|
get: () => props.modelValue,
|
||||||
|
set: value => emit('update:modelValue', value)
|
||||||
|
})
|
||||||
|
const pagedRows = computed(() =>
|
||||||
|
buildSteadyTrendTableRows(tableModel.value, (currentPage.value - 1) * pageSize.value, pageSize.value)
|
||||||
|
)
|
||||||
|
|
||||||
|
const downloadSteadyTrendData = async () => {
|
||||||
|
if (!tableModel.value.timeValues.length || !tableModel.value.columns.length) {
|
||||||
|
ElMessage.warning('暂无可下载的数据')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
downloading.value = true
|
||||||
|
try {
|
||||||
|
await nextTick()
|
||||||
|
|
||||||
|
const excelContent = buildSteadyTrendExcelHtml(tableModel.value)
|
||||||
|
const blob = new Blob([excelContent], { type: 'application/vnd.ms-excel;charset=utf-8;' })
|
||||||
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
|
const exportFile = document.createElement('a')
|
||||||
|
|
||||||
|
exportFile.style.display = 'none'
|
||||||
|
exportFile.download = `steady-trend-data-${Date.now()}.xls`
|
||||||
|
exportFile.href = blobUrl
|
||||||
|
document.body.appendChild(exportFile)
|
||||||
|
exportFile.click()
|
||||||
|
document.body.removeChild(exportFile)
|
||||||
|
URL.revokeObjectURL(blobUrl)
|
||||||
|
|
||||||
|
ElMessage.success('数据下载成功')
|
||||||
|
} finally {
|
||||||
|
downloading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
visible => {
|
||||||
|
if (visible) {
|
||||||
|
tableModel.value = buildSteadyTrendTableModel(props.trendResult?.series || [])
|
||||||
|
currentPage.value = 1
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 弹窗关闭后释放当前页表格模型,避免大数据量结果长期占用额外内存。
|
||||||
|
tableModel.value = createEmptySteadyTrendTableModel()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-data-table {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 70vh;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-header {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 12px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button-lf,
|
||||||
|
.header-button-ri {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-button-ri {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-data-table :deep(.el-table) {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-data-table :deep(.el-table__inner-wrapper) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.table-footer {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<Teleport to="body">
|
||||||
|
<div v-if="modelValue" class="steady-trend-fullscreen">
|
||||||
|
<header class="steady-trend-fullscreen__header">
|
||||||
|
<span class="steady-trend-fullscreen__title">趋势图全屏展示</span>
|
||||||
|
<el-button class="steady-trend-fullscreen__close" :icon="Close" text circle @click="closeFullscreen" />
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="steady-trend-fullscreen__body">
|
||||||
|
<div class="steady-trend-fullscreen__tool-row">
|
||||||
|
<SteadyTrendChartTools
|
||||||
|
class="steady-trend-fullscreen__tools"
|
||||||
|
:tool-groups="toolGroups"
|
||||||
|
:is-tool-active="isToolActive"
|
||||||
|
:is-tool-disabled="isToolDisabled"
|
||||||
|
:get-tool-tooltip="getToolTooltip"
|
||||||
|
@tool-action="emit('tool-action', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
v-if="chartGroups.length"
|
||||||
|
class="steady-trend-fullscreen__chart-list"
|
||||||
|
:style="{ '--steady-trend-visible-chart-count': visibleChartCount }"
|
||||||
|
>
|
||||||
|
<div v-for="group in chartGroups" :key="group.key" class="steady-trend-fullscreen__chart-group">
|
||||||
|
<div class="steady-trend-fullscreen__chart-body">
|
||||||
|
<SteadyTrendChartRenderer
|
||||||
|
:group="group"
|
||||||
|
:zoom-range="zoomRange"
|
||||||
|
:active-tool="activeTool"
|
||||||
|
:wheel-zoom-enabled="wheelZoomEnabled"
|
||||||
|
:y-zoom-scale="yZoomScale"
|
||||||
|
:show-time-axis="chartGroups.indexOf(group) === chartGroups.length - 1"
|
||||||
|
@chart-data-zoom="handleChartDataZoom"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<el-empty v-else class="steady-trend-fullscreen__empty" description="请选择监测点和指标后查询趋势" />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</Teleport>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { Close } from '@element-plus/icons-vue'
|
||||||
|
import { onBeforeUnmount, onMounted } from 'vue'
|
||||||
|
import type { SteadyTrendToolAction, SteadyTrendToolGroup, SteadyTrendToolItem } from './chartTools'
|
||||||
|
import SteadyTrendChartTools from './SteadyTrendChartTools.vue'
|
||||||
|
import SteadyTrendChartRenderer from './SteadyTrendChartRenderer.vue'
|
||||||
|
import type { SteadyTrendActiveTool, SteadyTrendChartGroup, SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendFullscreen'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: boolean
|
||||||
|
chartGroups: SteadyTrendChartGroup[]
|
||||||
|
visibleChartCount: number
|
||||||
|
toolGroups: SteadyTrendToolGroup[]
|
||||||
|
zoomRange: SteadyTrendZoomRange
|
||||||
|
activeTool: SteadyTrendActiveTool
|
||||||
|
wheelZoomEnabled: boolean
|
||||||
|
yZoomScale: number
|
||||||
|
isToolActive: (action: SteadyTrendToolAction) => boolean
|
||||||
|
isToolDisabled: (action: SteadyTrendToolAction) => boolean
|
||||||
|
getToolTooltip: (item: SteadyTrendToolItem) => string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: boolean]
|
||||||
|
'chart-data-zoom': [value: SteadyTrendZoomRange]
|
||||||
|
'tool-action': [action: SteadyTrendToolAction]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const closeFullscreen = () => {
|
||||||
|
emit('update:modelValue', false)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeydown = (event: KeyboardEvent) => {
|
||||||
|
if (!props.modelValue || event.key !== 'Escape') return
|
||||||
|
|
||||||
|
closeFullscreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleChartDataZoom = (value: SteadyTrendZoomRange) => {
|
||||||
|
emit('chart-data-zoom', value)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
window.addEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
window.removeEventListener('keydown', handleKeydown)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-fullscreen {
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
z-index: 3000;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--el-bg-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__header {
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
height: 44px;
|
||||||
|
padding: 0 10px;
|
||||||
|
color: #ffffff;
|
||||||
|
background: var(--el-color-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__title {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__close {
|
||||||
|
flex: none;
|
||||||
|
color: #ffffff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__body {
|
||||||
|
--steady-trend-chart-gap: 8px;
|
||||||
|
--steady-trend-visible-chart-count: 6;
|
||||||
|
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 12px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__tool-row {
|
||||||
|
position: static;
|
||||||
|
display: flex;
|
||||||
|
flex: none;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__tools {
|
||||||
|
flex: 0 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-list {
|
||||||
|
display: flex;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: var(--steady-trend-chart-gap);
|
||||||
|
min-height: 0;
|
||||||
|
overflow-x: hidden;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-group {
|
||||||
|
box-sizing: border-box;
|
||||||
|
display: flex;
|
||||||
|
flex: 0 0
|
||||||
|
calc(
|
||||||
|
(100% - var(--steady-trend-chart-gap) * (var(--steady-trend-visible-chart-count) - 1)) /
|
||||||
|
var(--steady-trend-visible-chart-count)
|
||||||
|
);
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--el-border-color-lighter);
|
||||||
|
border-radius: 4px;
|
||||||
|
background: var(--cn-color-canvas-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__chart-body {
|
||||||
|
flex: 1;
|
||||||
|
min-height: 0;
|
||||||
|
padding: 0 8px 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-fullscreen__empty {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,246 @@
|
|||||||
|
<template>
|
||||||
|
<div ref="chartRef" class="steady-trend-scichart"></div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import {
|
||||||
|
CursorModifier,
|
||||||
|
EXyDirection,
|
||||||
|
FastLineRenderableSeries,
|
||||||
|
MouseWheelZoomModifier,
|
||||||
|
NumberRange,
|
||||||
|
NumericAxis,
|
||||||
|
RubberBandXyZoomModifier,
|
||||||
|
SciChartSurface,
|
||||||
|
Thickness,
|
||||||
|
XyDataSeries,
|
||||||
|
ZoomPanModifier
|
||||||
|
} from 'scichart'
|
||||||
|
import sciChart2dNoSimdWasmUrl from 'scichart/_wasm/scichart2d-nosimd.wasm?url'
|
||||||
|
import sciChart2dWasmUrl from 'scichart/_wasm/scichart2d.wasm?url'
|
||||||
|
import { onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import { resolvePhaseThemeColor } from '@/utils/phaseColors'
|
||||||
|
import type { SteadyTrendActiveTool, SteadyTrendZoomRange } from '../utils/trendOptions'
|
||||||
|
import { buildSteadyTrendSciChartSeriesData, type SteadyTrendSciChartSeriesData } from '../utils/sciChartData'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendSciChart'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[]
|
||||||
|
zoomRange: SteadyTrendZoomRange
|
||||||
|
activeTool: SteadyTrendActiveTool
|
||||||
|
wheelZoomEnabled: boolean
|
||||||
|
yZoomScale: number
|
||||||
|
showTimeAxis: boolean
|
||||||
|
title: string
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'chart-data-zoom': [value: SteadyTrendZoomRange]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const chartRef = ref<HTMLDivElement>()
|
||||||
|
let sciChartSurface: SciChartSurface | null = null
|
||||||
|
let xAxis: NumericAxis | null = null
|
||||||
|
let yAxis: NumericAxis | null = null
|
||||||
|
let zoomPanModifier: ZoomPanModifier | null = null
|
||||||
|
let mouseWheelZoomModifier: MouseWheelZoomModifier | null = null
|
||||||
|
let rubberBandZoomModifier: RubberBandXyZoomModifier | null = null
|
||||||
|
let disposeChart: (() => void) | null = null
|
||||||
|
let isApplyingExternalZoom = false
|
||||||
|
|
||||||
|
// Vite/Electron 下 wasm 资源需要显式交给 SciChart,避免默认相对路径加载失败导致空白图表。
|
||||||
|
SciChartSurface.configure({
|
||||||
|
wasmUrl: sciChart2dWasmUrl,
|
||||||
|
wasmNoSimdUrl: sciChart2dNoSimdWasmUrl
|
||||||
|
})
|
||||||
|
|
||||||
|
const fallbackTrendColors = ['#409eff', '#00a870', '#e6a23c', '#f56c6c', '#626aef', '#14b8a6', '#f97316']
|
||||||
|
|
||||||
|
const getSeriesColor = (series: SteadyTrend.SteadyTrendSeries, index: number) => {
|
||||||
|
const phaseColor = resolvePhaseThemeColor(series.phase || '')
|
||||||
|
|
||||||
|
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const getSeriesRange = (seriesData: SteadyTrendSciChartSeriesData[]) => {
|
||||||
|
let minX = Number.POSITIVE_INFINITY
|
||||||
|
let maxX = Number.NEGATIVE_INFINITY
|
||||||
|
let minY = Number.POSITIVE_INFINITY
|
||||||
|
let maxY = Number.NEGATIVE_INFINITY
|
||||||
|
|
||||||
|
seriesData.forEach(series => {
|
||||||
|
series.xValues.forEach((xValue, index) => {
|
||||||
|
const yValue = series.yValues[index]
|
||||||
|
|
||||||
|
if (Number.isFinite(xValue)) {
|
||||||
|
minX = Math.min(minX, xValue)
|
||||||
|
maxX = Math.max(maxX, xValue)
|
||||||
|
}
|
||||||
|
if (Number.isFinite(yValue)) {
|
||||||
|
minY = Math.min(minY, yValue)
|
||||||
|
maxY = Math.max(maxY, yValue)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
minX: Number.isFinite(minX) ? minX : 0,
|
||||||
|
maxX: Number.isFinite(maxX) && maxX > minX ? maxX : minX + 1,
|
||||||
|
minY: Number.isFinite(minY) ? minY : 0,
|
||||||
|
maxY: Number.isFinite(maxY) && maxY > minY ? maxY : minY + 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveZoomRange = (range: ReturnType<typeof getSeriesRange>, zoomRange: SteadyTrendZoomRange) => {
|
||||||
|
const start = Math.min(Math.max(zoomRange.start, 0), 100)
|
||||||
|
const end = Math.min(Math.max(zoomRange.end, start), 100)
|
||||||
|
const width = range.maxX - range.minX
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: range.minX + width * (start / 100),
|
||||||
|
max: range.minX + width * (end / 100)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveYRange = (range: ReturnType<typeof getSeriesRange>, yZoomScale: number) => {
|
||||||
|
const center = (range.minY + range.maxY) / 2
|
||||||
|
const baseHalfRange = Math.max((range.maxY - range.minY) / 2, 1)
|
||||||
|
const scale = Number.isFinite(yZoomScale) ? Math.min(Math.max(yZoomScale, 0.1), 10) : 1
|
||||||
|
const halfRange = baseHalfRange * scale
|
||||||
|
|
||||||
|
return {
|
||||||
|
min: center - halfRange,
|
||||||
|
max: center + halfRange
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateModifierState = () => {
|
||||||
|
if (zoomPanModifier) zoomPanModifier.isEnabled = props.activeTool === 'pan'
|
||||||
|
if (rubberBandZoomModifier) rubberBandZoomModifier.isEnabled = props.activeTool === 'box-zoom'
|
||||||
|
if (mouseWheelZoomModifier) mouseWheelZoomModifier.isEnabled = props.wheelZoomEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
const syncZoomRangeFromAxis = (range: ReturnType<typeof getSeriesRange>) => {
|
||||||
|
if (!xAxis || isApplyingExternalZoom) return
|
||||||
|
|
||||||
|
const visibleRange = xAxis.visibleRange
|
||||||
|
const width = range.maxX - range.minX
|
||||||
|
if (!visibleRange || width <= 0) return
|
||||||
|
|
||||||
|
emit('chart-data-zoom', {
|
||||||
|
start: Math.min(Math.max(((visibleRange.min - range.minX) / width) * 100, 0), 100),
|
||||||
|
end: Math.min(Math.max(((visibleRange.max - range.minX) / width) * 100, 0), 100)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSurface = () => {
|
||||||
|
sciChartSurface?.renderableSeries.clear()
|
||||||
|
sciChartSurface?.xAxes.clear()
|
||||||
|
sciChartSurface?.yAxes.clear()
|
||||||
|
sciChartSurface?.chartModifiers.clear()
|
||||||
|
}
|
||||||
|
|
||||||
|
const renderSciChart = async () => {
|
||||||
|
if (!chartRef.value) return
|
||||||
|
|
||||||
|
const seriesData = buildSteadyTrendSciChartSeriesData(props.seriesList)
|
||||||
|
const range = getSeriesRange(seriesData)
|
||||||
|
|
||||||
|
// SciChart.js 3.2+ 默认使用 Community License 启动,这里不写入商业授权 key。
|
||||||
|
// 高性能趋势图由 SciChart 独立持有数据,避免走 ECharts category 轴的全量标签对齐。
|
||||||
|
if (!sciChartSurface) {
|
||||||
|
const createdChart = await SciChartSurface.create(chartRef.value, {
|
||||||
|
title: props.title,
|
||||||
|
padding: new Thickness(4, 8, props.showTimeAxis ? 4 : 0, 0)
|
||||||
|
})
|
||||||
|
|
||||||
|
sciChartSurface = createdChart.sciChartSurface
|
||||||
|
disposeChart = () => {
|
||||||
|
sciChartSurface?.delete()
|
||||||
|
sciChartSurface = null
|
||||||
|
xAxis = null
|
||||||
|
yAxis = null
|
||||||
|
zoomPanModifier = null
|
||||||
|
mouseWheelZoomModifier = null
|
||||||
|
rubberBandZoomModifier = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
clearSurface()
|
||||||
|
|
||||||
|
xAxis = new NumericAxis(sciChartSurface.webAssemblyContext2D, {
|
||||||
|
visibleRange: new NumberRange(range.minX, range.maxX),
|
||||||
|
drawMajorBands: false,
|
||||||
|
drawMinorGridLines: false,
|
||||||
|
drawMinorTickLines: false
|
||||||
|
})
|
||||||
|
yAxis = new NumericAxis(sciChartSurface.webAssemblyContext2D, {
|
||||||
|
axisTitle: seriesData.find(series => series.unit)?.unit || '',
|
||||||
|
visibleRange: new NumberRange(range.minY, range.maxY),
|
||||||
|
drawMajorBands: false,
|
||||||
|
drawMinorGridLines: false,
|
||||||
|
drawMinorTickLines: false
|
||||||
|
})
|
||||||
|
|
||||||
|
sciChartSurface.xAxes.add(xAxis)
|
||||||
|
sciChartSurface.yAxes.add(yAxis)
|
||||||
|
|
||||||
|
seriesData.forEach((series, index) => {
|
||||||
|
const dataSeries = new XyDataSeries(sciChartSurface!.webAssemblyContext2D, {
|
||||||
|
dataSeriesName: series.name
|
||||||
|
})
|
||||||
|
dataSeries.appendRange(series.xValues, series.yValues)
|
||||||
|
sciChartSurface!.renderableSeries.add(
|
||||||
|
new FastLineRenderableSeries(sciChartSurface!.webAssemblyContext2D, {
|
||||||
|
dataSeries,
|
||||||
|
stroke: getSeriesColor(props.seriesList[index], index),
|
||||||
|
strokeThickness: 1
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
zoomPanModifier = new ZoomPanModifier({ xyDirection: EXyDirection.XDirection })
|
||||||
|
mouseWheelZoomModifier = new MouseWheelZoomModifier({ xyDirection: EXyDirection.XDirection })
|
||||||
|
rubberBandZoomModifier = new RubberBandXyZoomModifier({ xyDirection: EXyDirection.XDirection })
|
||||||
|
sciChartSurface.chartModifiers.add(zoomPanModifier, mouseWheelZoomModifier, rubberBandZoomModifier, new CursorModifier())
|
||||||
|
updateModifierState()
|
||||||
|
|
||||||
|
const nextXRange = resolveZoomRange(range, props.zoomRange)
|
||||||
|
const nextYRange = resolveYRange(range, props.yZoomScale)
|
||||||
|
isApplyingExternalZoom = true
|
||||||
|
xAxis.visibleRange = new NumberRange(nextXRange.min, nextXRange.max)
|
||||||
|
yAxis.visibleRange = new NumberRange(nextYRange.min, nextYRange.max)
|
||||||
|
isApplyingExternalZoom = false
|
||||||
|
xAxis.visibleRangeChanged.subscribe(() => syncZoomRangeFromAxis(range))
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
renderSciChart()
|
||||||
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
disposeChart?.()
|
||||||
|
disposeChart = null
|
||||||
|
})
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => [props.seriesList, props.zoomRange, props.activeTool, props.wheelZoomEnabled, props.yZoomScale],
|
||||||
|
() => {
|
||||||
|
renderSciChart()
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-scichart {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,222 @@
|
|||||||
|
<template>
|
||||||
|
<section class="card trend-toolbar">
|
||||||
|
<div class="toolbar-field toolbar-field--time">
|
||||||
|
<span class="toolbar-field__label">时间:</span>
|
||||||
|
<TimePeriodSearch
|
||||||
|
class="trend-toolbar__time"
|
||||||
|
:unit="modelValue.timeUnit"
|
||||||
|
:model-value="modelValue.timeBaseDate"
|
||||||
|
@update:unit="handleTimeUnitChange"
|
||||||
|
@update:model-value="handleTimeBaseDateChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-field">
|
||||||
|
<span class="toolbar-field__label">统计:</span>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue.statType"
|
||||||
|
placeholder="选择统计类型"
|
||||||
|
@update:model-value="updateField('statType', $event)"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-field">
|
||||||
|
<span class="toolbar-field__label">数据质量:</span>
|
||||||
|
<el-switch
|
||||||
|
:model-value="modelValue.qualityFlag ?? 0"
|
||||||
|
class="quality-switch"
|
||||||
|
width="72"
|
||||||
|
inline-prompt
|
||||||
|
active-text="有效"
|
||||||
|
inactive-text="无效"
|
||||||
|
:active-value="0"
|
||||||
|
:inactive-value="1"
|
||||||
|
@update:model-value="handleQualityFlagChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">
|
||||||
|
<span class="toolbar-field__label">谐波次数:</span>
|
||||||
|
<el-select
|
||||||
|
:model-value="modelValue.harmonicOrders"
|
||||||
|
multiple
|
||||||
|
placeholder="选择谐波次数"
|
||||||
|
@update:model-value="handleHarmonicOrdersChange"
|
||||||
|
>
|
||||||
|
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
|
||||||
|
</el-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-actions">
|
||||||
|
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
|
||||||
|
<el-button @click="emit('reset')">重置</el-button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
|
||||||
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
import { MAX_HARMONIC_ORDER_COUNT } from '../utils/selectionRules'
|
||||||
|
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendToolbar'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: SteadyTrendFormState
|
||||||
|
statOptions: SteadyTrend.SteadyTrendStatType[]
|
||||||
|
showHarmonicOrders: boolean
|
||||||
|
loading: boolean
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:modelValue': [value: SteadyTrendFormState]
|
||||||
|
query: []
|
||||||
|
reset: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
|
||||||
|
const statLabelMap: Record<SteadyTrend.SteadyTrendStatType, string> = {
|
||||||
|
AVG: '平均值',
|
||||||
|
MAX: '最大值',
|
||||||
|
MIN: '最小值',
|
||||||
|
CP95: '95%概率大值'
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: SteadyTrendFormState[K]) => {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
[field]: value
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQualityFlagChange = (value: string | number | boolean) => {
|
||||||
|
updateField('qualityFlag', Number(value))
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeHarmonicOrders = (orders: number[]) => {
|
||||||
|
return Array.from(
|
||||||
|
new Set(
|
||||||
|
orders
|
||||||
|
.map(item => Number(item))
|
||||||
|
.filter(item => Number.isInteger(item) && item >= 2 && item <= 50)
|
||||||
|
)
|
||||||
|
).sort((left, right) => left - right)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateHarmonicOrders = (orders: number[]) => {
|
||||||
|
const nextOrders = normalizeHarmonicOrders(orders)
|
||||||
|
|
||||||
|
if (nextOrders.length > MAX_HARMONIC_ORDER_COUNT) {
|
||||||
|
ElMessage.warning(`谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`)
|
||||||
|
const currentOrders = normalizeHarmonicOrders(props.modelValue.harmonicOrders)
|
||||||
|
updateField(
|
||||||
|
'harmonicOrders',
|
||||||
|
currentOrders.length ? currentOrders : nextOrders.slice(0, MAX_HARMONIC_ORDER_COUNT)
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
updateField('harmonicOrders', nextOrders)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleHarmonicOrdersChange = (value: number[]) => {
|
||||||
|
updateHarmonicOrders(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
|
||||||
|
emit('update:modelValue', {
|
||||||
|
...props.modelValue,
|
||||||
|
timeUnit: unit,
|
||||||
|
timeBaseDate: baseDate,
|
||||||
|
timeRange: buildTimePeriodRange(unit, baseDate)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeUnitChange = (value: TimePeriodUnit) => {
|
||||||
|
updateTimeRange(value, props.modelValue.timeBaseDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleTimeBaseDateChange = (value: Date) => {
|
||||||
|
updateTimeRange(props.modelValue.timeUnit, value)
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.trend-toolbar {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(360px, 1.35fr) repeat(3, minmax(0, 1fr)) auto;
|
||||||
|
gap: 10px;
|
||||||
|
align-items: center;
|
||||||
|
padding: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field {
|
||||||
|
display: flex;
|
||||||
|
min-width: 0;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field--time {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field__label {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
color: #606266;
|
||||||
|
font-size: 14px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-field :deep(.el-select) {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quality-switch {
|
||||||
|
min-width: 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-toolbar__time {
|
||||||
|
flex: 1 1 0;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-toolbar__time :deep(.time-period-search__unit) {
|
||||||
|
width: 72px;
|
||||||
|
flex: 0 0 72px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-toolbar__time :deep(.time-period-search__picker) {
|
||||||
|
width: 136px;
|
||||||
|
flex: 0 0 136px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.harmonic-select {
|
||||||
|
grid-column: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
display: flex;
|
||||||
|
grid-column: 5;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1280px) {
|
||||||
|
.trend-toolbar {
|
||||||
|
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-actions {
|
||||||
|
justify-content: flex-start;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
<template>
|
||||||
|
<div class="steady-trend-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsedProxy }">
|
||||||
|
<aside class="selector-column">
|
||||||
|
<div class="ledger-panel-body">
|
||||||
|
<SteadyLedgerTree
|
||||||
|
:key="selectorResetKey"
|
||||||
|
:tree-data="ledgerTree"
|
||||||
|
:loading="loading.ledger"
|
||||||
|
:keyword="ledgerKeyword"
|
||||||
|
:default-checked-keys="defaultLedgerCheckedKeys"
|
||||||
|
:collapsed="ledgerPanelCollapsedProxy"
|
||||||
|
@refresh="emit('refreshLedger')"
|
||||||
|
@search="emit('ledgerSearch', $event)"
|
||||||
|
@change="emit('ledgerChange', $event)"
|
||||||
|
@toggle="emit('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
<main class="trend-main">
|
||||||
|
<SteadyTrendToolbar
|
||||||
|
v-model="trendFormProxy"
|
||||||
|
:stat-options="statOptions"
|
||||||
|
:show-harmonic-orders="showHarmonicOrders"
|
||||||
|
:loading="loading.trend"
|
||||||
|
@query="emit('queryTrend')"
|
||||||
|
@reset="emit('resetTrend')"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div class="trend-content">
|
||||||
|
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
|
||||||
|
|
||||||
|
<SteadyIndicatorFloatingPanel
|
||||||
|
v-model:collapsed="indicatorPanelCollapsedProxy"
|
||||||
|
:selector-reset-key="selectorResetKey"
|
||||||
|
:tree-data="indicatorTree"
|
||||||
|
:default-checked-keys="defaultIndicatorCheckedKeys"
|
||||||
|
@change="emit('indicatorChange', $event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import type { SteadyTrendFormState } from '../utils/trendPayload'
|
||||||
|
import SteadyIndicatorFloatingPanel from './SteadyIndicatorFloatingPanel.vue'
|
||||||
|
import SteadyLedgerTree from './SteadyLedgerTree.vue'
|
||||||
|
import SteadyTrendChartPanel from './SteadyTrendChartPanel.vue'
|
||||||
|
import SteadyTrendToolbar from './SteadyTrendToolbar.vue'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrendWorkbench'
|
||||||
|
})
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
ledgerTree: SteadyTrend.SteadyLedgerNode[]
|
||||||
|
indicatorTree: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
trendResult: SteadyTrend.SteadyTrendQueryResult | null
|
||||||
|
trendForm: SteadyTrendFormState
|
||||||
|
statOptions: SteadyTrend.SteadyTrendStatType[]
|
||||||
|
showHarmonicOrders: boolean
|
||||||
|
loading: {
|
||||||
|
ledger: boolean
|
||||||
|
indicator: boolean
|
||||||
|
trend: boolean
|
||||||
|
}
|
||||||
|
ledgerKeyword: string
|
||||||
|
defaultLedgerCheckedKeys: string[]
|
||||||
|
defaultIndicatorCheckedKeys: string[]
|
||||||
|
ledgerPanelCollapsed: boolean
|
||||||
|
indicatorPanelCollapsed: boolean
|
||||||
|
selectorResetKey: number
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
'update:trendForm': [value: SteadyTrendFormState]
|
||||||
|
'update:ledgerPanelCollapsed': [value: boolean]
|
||||||
|
'update:indicatorPanelCollapsed': [value: boolean]
|
||||||
|
refreshLedger: []
|
||||||
|
ledgerSearch: [value: string]
|
||||||
|
ledgerChange: [nodes: SteadyTrend.SteadyLedgerNode[]]
|
||||||
|
indicatorChange: [nodes: SteadyTrend.SteadyIndicatorNode[]]
|
||||||
|
queryTrend: []
|
||||||
|
resetTrend: []
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const trendFormProxy = computed({
|
||||||
|
get: () => props.trendForm,
|
||||||
|
set: value => emit('update:trendForm', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const ledgerPanelCollapsedProxy = computed({
|
||||||
|
get: () => props.ledgerPanelCollapsed,
|
||||||
|
set: value => emit('update:ledgerPanelCollapsed', value)
|
||||||
|
})
|
||||||
|
|
||||||
|
const indicatorPanelCollapsedProxy = computed({
|
||||||
|
get: () => props.indicatorPanelCollapsed,
|
||||||
|
set: value => emit('update:indicatorPanelCollapsed', value)
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-layout {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 320px minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-layout.is-ledger-collapsed {
|
||||||
|
grid-template-columns: 0 minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
|
||||||
|
.selector-column {
|
||||||
|
position: relative;
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-layout.is-ledger-collapsed .selector-column {
|
||||||
|
z-index: 4;
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-panel-body {
|
||||||
|
height: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.steady-trend-layout.is-ledger-collapsed .ledger-panel-body {
|
||||||
|
overflow: visible;
|
||||||
|
}
|
||||||
|
|
||||||
|
.ledger-panel-body :deep(.steady-tree-card) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-main {
|
||||||
|
display: grid;
|
||||||
|
grid-template-rows: auto minmax(0, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-content {
|
||||||
|
position: relative;
|
||||||
|
min-width: 0;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.trend-content :deep(.trend-chart-panel) {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 1360px) {
|
||||||
|
.steady-trend-layout:not(.is-ledger-collapsed) {
|
||||||
|
grid-template-columns: 280px minmax(0, 1fr);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import type { Component } from 'vue'
|
||||||
|
|
||||||
|
export type SteadyTrendToolAction =
|
||||||
|
| 'x-zoom-in'
|
||||||
|
| 'x-zoom-out'
|
||||||
|
| 'y-zoom-in'
|
||||||
|
| 'y-zoom-out'
|
||||||
|
| 'box-zoom'
|
||||||
|
| 'wheel-zoom'
|
||||||
|
| 'reset'
|
||||||
|
| 'pan'
|
||||||
|
| 'fullscreen'
|
||||||
|
| 'download-image'
|
||||||
|
| 'query-data'
|
||||||
|
| 'missing-data'
|
||||||
|
|
||||||
|
export interface SteadyTrendToolItem {
|
||||||
|
action: SteadyTrendToolAction
|
||||||
|
label: string
|
||||||
|
icon: Component
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendToolGroup {
|
||||||
|
key: string
|
||||||
|
items: SteadyTrendToolItem[]
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const trendOptionsFile = path.join(currentDir, '..', 'utils', 'trendOptions.ts')
|
||||||
|
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['steady y-axis upper padding uses 1.15', /const\s+STEADY_AXIS_EXPAND_RATIO\s*=\s*1\.15/],
|
||||||
|
['steady y-axis lower padding uses 0.85', /const\s+STEADY_AXIS_SHRINK_RATIO\s*=\s*0\.85/],
|
||||||
|
[
|
||||||
|
'steady integer ranges still enter readable-axis normalization',
|
||||||
|
/rawInterval\s*>=\s*STEADY_AXIS_SMALL_INTERVAL_THRESHOLD/,
|
||||||
|
true
|
||||||
|
],
|
||||||
|
['steady y-axis keeps readable interval normalization', /getReadableAxisInterval\(axisRange\s*\/\s*currentSplitCount\)/],
|
||||||
|
['steady y-axis keeps min label visible', /showMinLabel:\s*true/],
|
||||||
|
['steady y-axis keeps max label visible', /showMaxLabel:\s*true/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
|
||||||
|
const exists = pattern.test(trendOptionsSource)
|
||||||
|
return shouldBeMissing ? exists : !exists
|
||||||
|
})
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steady axis range contract check failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steady axis range contract check passed')
|
||||||
@@ -0,0 +1,512 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const viewDir = path.join(currentDir, '..')
|
||||||
|
const read = file => {
|
||||||
|
const filePath = path.join(viewDir, file)
|
||||||
|
|
||||||
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const toolbarSource = read('components/SteadyTrendToolbar.vue')
|
||||||
|
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||||
|
const fullscreenSource = read('components/SteadyTrendFullscreen.vue')
|
||||||
|
const chartToolsSource = read('components/SteadyTrendChartTools.vue')
|
||||||
|
const chartRendererSource = read('components/SteadyTrendChartRenderer.vue')
|
||||||
|
const lineChartSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'components', 'echarts', 'line', 'index.vue'), 'utf8')
|
||||||
|
const trendPayloadSource = read('utils/trendPayload.ts')
|
||||||
|
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||||
|
const selectionRulesSource = read('utils/selectionRules.ts')
|
||||||
|
const sharedPhaseColorSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'utils', 'phaseColors.ts'), 'utf8')
|
||||||
|
const sharedStyleSource = fs.readFileSync(path.join(viewDir, '..', '..', '..', 'styles', 'var.scss'), 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'toolbar uses a single active stat type',
|
||||||
|
/:model-value="modelValue\.statType"[\s\S]*@update:model-value="updateField\('statType', \$event\)/,
|
||||||
|
toolbarSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'toolbar no longer allows multiple stat types in one query',
|
||||||
|
/<el-select(?![\s\S]*multiple[\s\S]*placeholder="[^"]*统计类型")[\s\S]*placeholder="[^"]*统计类型"/,
|
||||||
|
toolbarSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend payload keeps API statTypes array but only sends active stat',
|
||||||
|
/statTypes:\s*\[formState\.statType\]/,
|
||||||
|
trendPayloadSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selection rules cap monitoring points at six',
|
||||||
|
/MAX_SELECTED_LINE_COUNT\s*=\s*6[\s\S]*lineIds\.length\s*>\s*MAX_SELECTED_LINE_COUNT/,
|
||||||
|
selectionRulesSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selection rules cap indicators at six',
|
||||||
|
/MAX_SELECTED_INDICATOR_COUNT\s*=\s*6[\s\S]*indicators\.length\s*>\s*MAX_SELECTED_INDICATOR_COUNT/,
|
||||||
|
selectionRulesSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel renders a list of grouped charts through the renderer adapter',
|
||||||
|
/v-for="group in chartGroups"[\s\S]*<SteadyTrendChartRenderer[\s\S]*:group="group"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart loading mask stays outside the fullscreen dialog tree',
|
||||||
|
/<section\s+class="card trend-chart-panel"(?![^>]*v-loading)[\s\S]*<div[^>]*class="chart-panel-body"[^>]*v-loading="loading"[\s\S]*<SteadyTrendFullscreen/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'normal chart list caps visible charts at three while smaller counts fill the viewport',
|
||||||
|
/(?=[\s\S]*normalVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*3\))(?=[\s\S]*:style="\{\s*'--steady-trend-visible-chart-count':\s*normalVisibleChartCount\s*\}")/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen chart list caps visible charts at six while smaller counts fill the viewport',
|
||||||
|
/(?=[\s\S]*fullscreenVisibleChartCount[\s\S]*Math\.min\(chartGroups\.value\.length,\s*6\))(?=[\s\S]*:visible-chart-count="fullscreenVisibleChartCount")/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart groups divide the current viewport by visible chart count',
|
||||||
|
/\.chart-group\s*\{[\s\S]*flex:\s*0 0\s*calc\(\s*\(100% - var\(--steady-trend-chart-gap\)\s*\*\s*\(var\(--steady-trend-visible-chart-count\)\s*-\s*1\)\)\s*\/\s*var\(--steady-trend-visible-chart-count\)\s*\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart groups include their border in the divided height to avoid false scrollbars',
|
||||||
|
/\.chart-group\s*\{[\s\S]*box-sizing:\s*border-box/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel delegates fullscreen rendering to a dedicated component',
|
||||||
|
/<SteadyTrendFullscreen[\s\S]*v-model="fullscreenVisible"[\s\S]*:chart-groups="chartGroups"[\s\S]*:visible-chart-count="fullscreenVisibleChartCount"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes shared trend tools into fullscreen',
|
||||||
|
/<SteadyTrendFullscreen[\s\S]*:tool-groups="fullscreenToolGroups"[\s\S]*:is-tool-active="isTrendToolActive"[\s\S]*:is-tool-disabled="isTrendToolDisabled"[\s\S]*:get-tool-tooltip="getTrendToolTooltip"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen tool groups omit nested fullscreen action',
|
||||||
|
/fullscreenToolGroups[\s\S]*item\.action\s*!==\s*'fullscreen'/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel does not use Element Plus dialog for steady fullscreen',
|
||||||
|
/^(?![\s\S]*<el-dialog[\s\S]*steady-trend-fullscreen)(?![\s\S]*fullscreen-chart-body)[\s\S]*$/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component renders through a fixed body teleport',
|
||||||
|
/<Teleport\s+to="body">[\s\S]*class="steady-trend-fullscreen"[\s\S]*position:\s*fixed[\s\S]*inset:\s*0/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component has an explicit close button and Esc handling',
|
||||||
|
/@click="closeFullscreen"[\s\S]*event\.key\s*!==\s*'Escape'[\s\S]*window\.addEventListener\('keydown',\s*handleKeydown\)/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen component gives chart body a concrete flex viewport',
|
||||||
|
/\.steady-trend-fullscreen__body\s*\{[\s\S]*display:\s*flex[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*min-height:\s*0[\s\S]*\.steady-trend-fullscreen__chart-list\s*\{[\s\S]*flex:\s*1\s+1\s+auto[\s\S]*overflow-y:\s*auto/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen keeps trend tool groups in a real workspace row below the title bar',
|
||||||
|
/<main class="steady-trend-fullscreen__body">[\s\S]*<div class="steady-trend-fullscreen__tool-row">[\s\S]*<SteadyTrendChartTools[\s\S]*<\/div>[\s\S]*class="steady-trend-fullscreen__chart-list"/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen title header does not contain trend tool groups',
|
||||||
|
/<header class="steady-trend-fullscreen__header">(?:(?!SteadyTrendChartTools)[\s\S])*<\/header>/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen tool row reserves layout space instead of floating over charts',
|
||||||
|
/\.steady-trend-fullscreen__tool-row\s*\{(?=[\s\S]*position:\s*static)(?=[\s\S]*display:\s*flex)(?=[\s\S]*flex:\s*none)[\s\S]*\}/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart renderer uses SciChart for steadyTrend charts',
|
||||||
|
/<SteadyTrendSciChart[\s\S]*:series-list="group\.seriesList"[\s\S]*@chart-data-zoom="emit\('chart-data-zoom', \$event\)"/,
|
||||||
|
chartRendererSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart renderer does not import ECharts LineChart',
|
||||||
|
/^(?![\s\S]*LineChart)[\s\S]*$/,
|
||||||
|
chartRendererSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel shows total point count with explicit label',
|
||||||
|
/总点数:\{\{\s*trendResult\.displayPointCount\s*\|\|\s*0\s*\}\}/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel does not render bucket slash prefix before point count',
|
||||||
|
/^(?![\s\S]*trendResult\.bucket[\s\S]*\/[\s\S]*trendResult\.displayPointCount)[\s\S]*$/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel renders shared trend tool groups',
|
||||||
|
/<SteadyTrendChartTools[\s\S]*:tool-groups="trendToolGroups"[\s\S]*@tool-action="handleTrendToolAction"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel keeps total point count on the right of the trend toolbar with 15px spacing',
|
||||||
|
/\.panel-meta\s*\{[\s\S]*margin-left:\s*15px/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'shared chart tools component renders the trend tool buttons',
|
||||||
|
/trend-tool-groups[\s\S]*v-for="group in toolGroups"[\s\S]*v-for="item in group\.items"[\s\S]*@click="emit\('tool-action', item\.action\)"/,
|
||||||
|
chartToolsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen renders shared trend tool groups',
|
||||||
|
/<SteadyTrendChartTools[\s\S]*:tool-groups="toolGroups"[\s\S]*@tool-action="emit\('tool-action', \$event\)"/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel exposes core trend toolbar actions except marker and data export',
|
||||||
|
/x-zoom-in[\s\S]*x-zoom-out[\s\S]*y-zoom-in[\s\S]*y-zoom-out[\s\S]*box-zoom[\s\S]*reset[\s\S]*pan[\s\S]*fullscreen[\s\S]*download-image/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel exposes a wheel zoom toggle action',
|
||||||
|
/wheel-zoom/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel places wheel zoom after pan in the viewport toolbar',
|
||||||
|
/items:\s*\[[\s\S]*action:\s*'pan'[\s\S]*action:\s*'wheel-zoom'/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel defaults wheel zoom disabled',
|
||||||
|
/const\s+wheelZoomEnabled\s*=\s*ref\(false\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes wheel zoom state into grouped options',
|
||||||
|
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*wheelZoomEnabled:\s*wheelZoomEnabled\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel keeps full range as fallback x zoom range',
|
||||||
|
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*100\s*\}/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel defaults x zoom range by 20 30 60 day thresholds',
|
||||||
|
/STEADY_TREND_HALF_RANGE_DAYS\s*=\s*20[\s\S]*STEADY_TREND_QUARTER_RANGE_DAYS\s*=\s*30[\s\S]*STEADY_TREND_TENTH_RANGE_DAYS\s*=\s*60[\s\S]*resolveSteadyTrendDefaultZoomRange[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_TENTH_RANGE_DAYS[\s\S]*end:\s*10[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_QUARTER_RANGE_DAYS[\s\S]*end:\s*25[\s\S]*timeRangeDays\s*>\s*STEADY_TREND_HALF_RANGE_DAYS[\s\S]*end:\s*50/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel stores shared x zoom range from the default range',
|
||||||
|
/const trendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>\(\{\s*\.\.\.DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*\}\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel compares reset state against the current data-derived x zoom range',
|
||||||
|
/const defaultTrendXZoomRange\s*=\s*ref<SteadyTrendZoomRange>[\s\S]*const isDefaultTrendXZoomRange[\s\S]*defaultTrendXZoomRange\.value[\s\S]*const canResetTrendChart[\s\S]*!isDefaultTrendXZoomRange\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel stores y zoom scale and active chart tool',
|
||||||
|
/const trendYZoomScale\s*=\s*ref\(1\)[\s\S]*const activeTrendInteractionMode\s*=\s*ref<SteadyTrendInteractionMode>\('none'\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes active chart tool and y zoom scale into grouped options',
|
||||||
|
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*activeTool:\s*activeTrendInteractionMode\.value[\s\S]*yZoomScale:\s*trendYZoomScale\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel resets x zoom range to the current data-derived default when trend result changes',
|
||||||
|
/const\s+resetTrendToolState\s*=\s*\(\)\s*=>\s*\{[\s\S]*trendXZoomRange\.value\s*=\s*\{\s*\.\.\.defaultTrendXZoomRange\.value\s*\}[\s\S]*watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*defaultTrendXZoomRange\.value\s*=\s*resolveSteadyTrendDefaultZoomRange\(props\.trendResult\)[\s\S]*resetTrendToolState\(\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel resets toolbar state when trend result changes',
|
||||||
|
/watch\(\s*\(\)\s*=>\s*props\.trendResult[\s\S]*resetTrendToolState\(\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel can export the visible trend charts as an image',
|
||||||
|
/steady-trend-export-target[\s\S]*html2canvas[\s\S]*downloadSteadyTrendImage/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
['chart utilities expose grouped chart options', /export const buildSteadyTrendChartGroups/, trendOptionsSource],
|
||||||
|
[
|
||||||
|
'chart utilities carry one shared ECharts group for steady multi-chart sync',
|
||||||
|
/group:\s*STEADY_TREND_CHART_GROUP/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart utilities accept shared x zoom range when building options',
|
||||||
|
/buildSteadyTrendChartOptions\s*=\s*\([^)]*zoomRange:\s*SteadyTrendZoomRange/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart utilities accept active tool and y zoom options',
|
||||||
|
/interface\s+SteadyTrendChartBuildOptions[\s\S]*activeTool\?:[\s\S]*yZoomScale\?:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options expose activeTool for LineChart interactions',
|
||||||
|
/activeTool:\s*chartOptions\.activeTool\s*\|\|\s*'none'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options apply y zoom scale to steady y axis',
|
||||||
|
/applySteadyYAxisZoom\([\s\S]*buildSteadyTrendAxisConfig\(values,\s*unit\)[\s\S]*chartOptions\.yZoomScale/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options bind dataZoom to shared x zoom range',
|
||||||
|
/dataZoom:\s*\[[\s\S]*start:\s*zoomRange\.start[\s\S]*end:\s*zoomRange\.end/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options enlarge slider dataZoom handles for easier horizontal dragging',
|
||||||
|
/handleSize:\s*'300%'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options highlight slider dataZoom handles on hover',
|
||||||
|
/emphasis:\s*\{[\s\S]*handleStyle:\s*\{[\s\S]*borderColor:\s*'#409eff'[\s\S]*shadowBlur:\s*6/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options show pointer cursor on slider dataZoom handles',
|
||||||
|
/handleSize:\s*'300%'[\s\S]*cursor:\s*'pointer'[\s\S]*handleStyle:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart slider dataZoom only syncs after dragging settles for dense trend charts',
|
||||||
|
/handleSize:\s*'300%'[\s\S]*realtime:\s*false[\s\S]*cursor:\s*'pointer'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'line chart overrides slider dataZoom handle cursor to pointer on hover',
|
||||||
|
/isSliderDataZoomResizeHandle[\s\S]*target\?\.type\s*===\s*'path'[\s\S]*viewportRoot\.style\.cursor\s*=\s*'pointer'/,
|
||||||
|
lineChartSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options accept wheel zoom option',
|
||||||
|
/interface\s+SteadyTrendChartBuildOptions[\s\S]*wheelZoomEnabled\?:\s*boolean/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options only zoom on mouse wheel when enabled',
|
||||||
|
/zoomOnMouseWheel:\s*chartOptions\.wheelZoomEnabled\s*===\s*true[\s\S]*moveOnMouseWheel:\s*false/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options keep hover axis pointer as a vertical line',
|
||||||
|
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options hide hover x-axis pointer labels to avoid overlapping time axis labels',
|
||||||
|
/tooltip:\s*\{[\s\S]*axisPointer:\s*\{[\s\S]*type:\s*'line'[\s\S]*label:\s*\{[\s\S]*show:\s*false/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart tooltip avoids heavy immediate transitions on dense trend charts',
|
||||||
|
/tooltip:\s*\{[\s\S]*showDelay:\s*80[\s\S]*hideDelay:\s*80[\s\S]*transitionDuration:\s*0/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options register hidden toolbox dataZoom for external box zoom',
|
||||||
|
/toolbox:\s*\{[\s\S]*show:\s*true[\s\S]*itemSize:\s*0[\s\S]*left:\s*-100[\s\S]*feature:\s*\{[\s\S]*dataZoom:\s*\{[\s\S]*yAxisIndex:\s*'none'[\s\S]*brushStyle:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options calculate visible point count from shared zoom range',
|
||||||
|
/resolveSteadyTrendVisiblePointCount[\s\S]*zoomRange\.end\s*-\s*zoomRange\.start[\s\S]*Math\.ceil/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart series line width uses visible point count after x zoom',
|
||||||
|
/const\s+pointCount\s*=\s*series\.points\?\.length\s*\|\|\s*0[\s\S]*width:\s*resolveSteadyTrendLineWidth\(\s*resolveSteadyTrendVisiblePointCount\(pointCount,\s*zoomRange\)\s*\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart grouping splits by indicator for one monitoring point',
|
||||||
|
/lineIds\.length\s*===\s*1[\s\S]*indicatorCodes\.length\s*>\s*1[\s\S]*'indicator'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart grouping splits by monitoring point for one indicator',
|
||||||
|
/lineIds\.length\s*>\s*1[\s\S]*indicatorCodes\.length\s*===\s*1[\s\S]*'line'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options keep fixed left grid for multi-chart alignment',
|
||||||
|
/left:\s*STEADY_TREND_GRID_LEFT[\s\S]*containLabel:\s*false/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options use compact top grid to reduce title legend height',
|
||||||
|
/STEADY_TREND_GRID_TOP\s*=\s*28[\s\S]*top:\s*STEADY_TREND_GRID_TOP/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel does not render external chart title rows',
|
||||||
|
/^(?![\s\S]*<div class="chart-title">)[\s\S]*$/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options render centered group title through ECharts title',
|
||||||
|
/title:\s*\{[\s\S]*text:\s*chartTitle[\s\S]*left:\s*'center'[\s\S]*top:\s*0/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart grouping passes resolved title into ECharts options',
|
||||||
|
/const\s+groupTitle\s*=\s*resolveGroupTitle\(groupSeries\)[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options explicitly show min and max y labels',
|
||||||
|
/showMinLabel:\s*true[\s\S]*showMaxLabel:\s*true/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart y axis keeps waveform unit placement style without changing unit content',
|
||||||
|
/nameLocation:\s*'middle'[\s\S]*nameGap:\s*42[\s\S]*nameTextStyle:/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis shows daily first points for ranges over one day and minute-level endpoints otherwise',
|
||||||
|
/STEADY_TREND_ONE_DAY_MS[\s\S]*resolveSteadyTimeAxisLabelMeta[\s\S]*firstDailyLabelIndexSet[\s\S]*formatSteadyTimeAxisDateLabel[\s\S]*formatSteadyTimeAxisShortDateLabel[\s\S]*formatSteadyTimeAxisMinuteLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis keeps only the first year for same-year daily labels',
|
||||||
|
/firstYear[\s\S]*formatSteadyTimeAxisDailyLabel[\s\S]*date\.getFullYear\(\)\s*===\s*labelMeta\.firstYear[\s\S]*formatSteadyTimeAxisShortDateLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis first label uses rich padding to avoid touching the y axis line',
|
||||||
|
/formatSteadyTimeAxisFirstLabel[\s\S]*return `\{first\|\$\{label\}\}`[\s\S]*rich:\s*\{[\s\S]*first:\s*\{[\s\S]*padding:\s*\[0,\s*0,\s*0,\s*6\]/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis label layout follows waveform except unit display',
|
||||||
|
/axisLabel:\s*\{[\s\S]*hideOverlap:\s*true[\s\S]*showMinLabel:\s*true[\s\S]*showMaxLabel:\s*false[\s\S]*interval:\s*0[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0[\s\S]*alignMinLabel:\s*'left'[\s\S]*alignMaxLabel:\s*'right'[\s\S]*width:\s*72[\s\S]*overflow:\s*'truncate'[\s\S]*formatter:\s*buildSteadyTimeAxisLabelFormatter\(timeLabels\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis label moves down without increasing bottom grid space',
|
||||||
|
/bottom:\s*showTimeAxis\s*\?\s*40\s*:\s*8[\s\S]*axisLabel:\s*\{[\s\S]*margin:\s*showTimeAxis\s*\?\s*16\s*:\s*0/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options allow hiding the x axis for non-final grouped charts',
|
||||||
|
/buildSteadyTrendChartOptions\s*=\s*\([^)]*showTimeAxis\s*=\s*true/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart x axis keeps labels visible but hides baseline and dense ticks',
|
||||||
|
/axisLine:\s*\{[\s\S]*show:\s*false[\s\S]*axisLabel:\s*\{[\s\S]*show:\s*showTimeAxis[\s\S]*axisTick:\s*\{[\s\S]*show:\s*false/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart grouping only shows the x axis on the final grouped chart',
|
||||||
|
/const\s+groupEntries\s*=\s*Array\.from\(groupMap\.entries\(\)\)[\s\S]*const\s+isLastChart\s*=\s*index\s*===\s*groupEntries\.length\s*-\s*1[\s\S]*buildSteadyTrendChartOptions\(groupSeries,\s*zoomRange,\s*isLastChart,\s*chartOptions,\s*groupTitle\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options use waveform line width buckets including the final three widths',
|
||||||
|
/resolveSteadyTrendLineWidth[\s\S]*200000\)\s*return\s*0\.35[\s\S]*100000\)\s*return\s*0\.45[\s\S]*50000\)\s*return\s*0\.55[\s\S]*20000\)\s*return\s*0\.65[\s\S]*10000\)\s*return\s*0\.75[\s\S]*5000\)\s*return\s*0\.9[\s\S]*2000\)\s*return\s*1[\s\S]*800\)\s*return\s*1\.1[\s\S]*200\)\s*return\s*1\.2[\s\S]*return\s*STEADY_TREND_LINE_MAX_WIDTH/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
['chart options read phase colors from shared theme utility', /resolvePhaseThemeColor/, trendOptionsSource],
|
||||||
|
[
|
||||||
|
'chart legend combines harmonic order and phase when harmonic order exists',
|
||||||
|
/const formatSeriesName[\s\S]*const harmonicOrder = resolveHarmonicOrder\(series\)[\s\S]*return harmonicOrder \? `\$\{harmonicOrder\}次_\$\{phaseLabel\}` : phaseLabel/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart series are sorted by harmonic order before phase so same orders stay together in the legend',
|
||||||
|
/const sortSteadyTrendSeries[\s\S]*resolveHarmonicOrder\(left\.series\)[\s\S]*resolvePhaseOrder\(left\.series\)[\s\S]*const sortedSeriesList = sortSteadyTrendSeries\((?:seriesList|displaySeriesList)\)[\s\S]*series: sortedSeriesList\.map/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options keep phase colors while styling harmonic orders by line type',
|
||||||
|
/lineStyle:\s*\{[\s\S]*type:\s*resolveHarmonicLineType\(series,\s*pointCount\)[\s\S]*opacity:\s*resolveHarmonicLineOpacity\(series\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart options force solid harmonic lines for large point counts',
|
||||||
|
/STEADY_TREND_LARGE_POINT_COUNT\s*=\s*20000[\s\S]*resolveHarmonicLineType[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT[\s\S]*return 'solid'/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart series disable animation and use lttb sampling for large point counts',
|
||||||
|
/resolveSteadyTrendSampling[\s\S]*pointCount\s*>=\s*STEADY_TREND_LARGE_POINT_COUNT\s*\?\s*'lttb'[\s\S]*animation:\s*false[\s\S]*sampling:\s*resolveSteadyTrendSampling\(pointCount\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart group title joins monitoring point and indicator with underscore',
|
||||||
|
/\[firstSeries\.lineName[\s\S]*firstSeries\.indicatorName[\s\S]*\][\s\S]*\.filter\(Boolean\)[\s\S]*\.join\('_'\)/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis above-one upper padding uses 1.05',
|
||||||
|
/STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE\s*=\s*1\.05/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis above-one lower padding uses 0.95',
|
||||||
|
/STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE\s*=\s*0\.95/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis compact padding uses 1.015 for narrow above-one ranges',
|
||||||
|
/STEADY_AXIS_COMPACT_EXPAND_RATIO\s*=\s*1\.015/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis compact padding uses 0.985 for narrow above-one ranges',
|
||||||
|
/STEADY_AXIS_COMPACT_SHRINK_RATIO\s*=\s*0\.985/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis compact split penalty stays low',
|
||||||
|
/STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE\s*=\s*0\.05/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady y-axis enables compact readable range for narrow above-one data',
|
||||||
|
/shouldUseCompactReadableAxisRange[\s\S]*maxAbs\s*>\s*1[\s\S]*STEADY_AXIS_COMPACT_RANGE_RATIO/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
['shared phase colors read the global T phase theme variable', /T:\s*'--cn-color-phase-t'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors keep T phase black fallback', /T:\s*'#000000'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors map AB line voltage to phase A color', /AB:\s*'--cn-color-phase-ab'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors map BC line voltage to phase B color', /BC:\s*'--cn-color-phase-bc'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors map CA line voltage to phase C color', /CA:\s*'--cn-color-phase-ca'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors keep AB fallback aligned with phase A', /AB:\s*'#daa520'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors keep BC fallback aligned with phase B', /BC:\s*'#2e8b57'/, sharedPhaseColorSource],
|
||||||
|
['shared phase colors keep CA fallback aligned with phase C', /CA:\s*'#a52a2a'/, sharedPhaseColorSource],
|
||||||
|
['global style defines AB line voltage color variable', /--cn-color-phase-ab:\s*#daa520/, sharedStyleSource],
|
||||||
|
['global style defines BC line voltage color variable', /--cn-color-phase-bc:\s*#2e8b57/, sharedStyleSource],
|
||||||
|
['global style defines CA line voltage color variable', /--cn-color-phase-ca:\s*#a52a2a/, sharedStyleSource]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend chart display contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend chart display contract passed')
|
||||||
@@ -0,0 +1,96 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const viewDir = path.join(currentDir, '..')
|
||||||
|
const read = file => {
|
||||||
|
const targetFile = path.join(viewDir, file)
|
||||||
|
|
||||||
|
return fs.existsSync(targetFile) ? fs.readFileSync(targetFile, 'utf8') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||||
|
const dialogSource = read('components/SteadyTrendDataTableDialog.vue')
|
||||||
|
const tableUtilSource = read('utils/trendTable.ts')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'chart panel exposes data query action in the existing trend tool groups',
|
||||||
|
/trendToolGroups[\s\S]*query-data[\s\S]*handleTrendToolAction/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel opens data table dialog from current trend result',
|
||||||
|
/SteadyTrendDataTableDialog[\s\S]*v-model="dataTableVisible"[\s\S]*:trend-result="trendResult"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel warns before opening data table without series data',
|
||||||
|
/ElMessage\.warning\('请先查询趋势数据'\)/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'data table dialog follows dictdata table-main card table-header structure',
|
||||||
|
/class="table-main card steady-trend-data-table"[\s\S]*class="table-header"[\s\S]*class="header-button-lf"[\s\S]*class="header-button-ri"/,
|
||||||
|
dialogSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'data table dialog provides full Excel download action on the left header',
|
||||||
|
/class="header-button-lf"[\s\S]*type="primary"[\s\S]*:icon="Download"[\s\S]*plain[\s\S]*downloadSteadyTrendData/,
|
||||||
|
dialogSource
|
||||||
|
],
|
||||||
|
['data table dialog no longer renders refresh button', /:icon="Refresh"|refreshKey/, dialogSource, true],
|
||||||
|
[
|
||||||
|
'data table dialog renders fixed common time column',
|
||||||
|
/<el-table-column\s+prop="time"[\s\S]*fixed="left"/,
|
||||||
|
dialogSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'data table dialog renders monitoring point then indicator then phase columns',
|
||||||
|
/v-for="lineGroup in tableModel\.lineGroups"[\s\S]*v-for="indicatorGroup in lineGroup\.indicatorGroups"[\s\S]*v-for="column in indicatorGroup\.columns"/,
|
||||||
|
dialogSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'data table dialog renders paged rows instead of full model rows',
|
||||||
|
/:data="pagedRows"[\s\S]*<el-pagination[\s\S]*v-model:current-page="currentPage"[\s\S]*v-model:page-size="pageSize"/,
|
||||||
|
dialogSource
|
||||||
|
],
|
||||||
|
['data table dialog defaults to 500 rows per page', /const\s+pageSize\s*=\s*ref\(500\)/, dialogSource],
|
||||||
|
['data table dialog uses missing value placeholder', /row\[column\.prop\]\s*\?\?\s*'-'/, dialogSource],
|
||||||
|
[
|
||||||
|
'trend table utility carries indicator units into labels',
|
||||||
|
/resolveIndicatorLabel[\s\S]*series\.unit[\s\S]*(\$\{unit\})/,
|
||||||
|
tableUtilSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend table utility groups columns by monitoring point before indicator',
|
||||||
|
/lineGroupMap[\s\S]*indicatorGroupMap[\s\S]*columns\.push/,
|
||||||
|
tableUtilSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend table utility aligns rows by shared sorted time values without building all rows eagerly',
|
||||||
|
/timeValues:\s*Array\.from\(timeSet\)[\s\S]*\.sort\(\)[\s\S]*buildSteadyTrendTableRows/,
|
||||||
|
tableUtilSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend table utility builds full Excel html export',
|
||||||
|
/colspan="\$\{lineColspan\}"[\s\S]*rowspan="3"[\s\S]*buildSteadyTrendTableRows\(model,\s*0,\s*model\.timeValues\.length\)[\s\S]*buildSteadyTrendExcelHtml/,
|
||||||
|
tableUtilSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, source, forbidden]) =>
|
||||||
|
forbidden ? pattern.test(source) : !pattern.test(source)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend data table contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend data table contract passed')
|
||||||
@@ -0,0 +1,26 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||||
|
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||||
|
const harmonicSelectSource = toolbarSource.match(
|
||||||
|
/<div v-if="showHarmonicOrders" class="toolbar-field harmonic-select">[\s\S]*?<\/div>/
|
||||||
|
)?.[0] || ''
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['harmonic order select exists', /class="toolbar-field harmonic-select"/],
|
||||||
|
['harmonic order select keeps multiple mode', /<el-select[\s\S]*multiple/],
|
||||||
|
['harmonic order select shows every selected tag', /^(?![\s\S]*collapse-tags)[\s\S]*$/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, pattern]) => !pattern.test(harmonicSelectSource)).map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyTrend harmonic tags contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend harmonic tags contract passed')
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const viewDir = path.join(currentDir, '..')
|
||||||
|
const read = file => {
|
||||||
|
const filePath = path.join(viewDir, file)
|
||||||
|
|
||||||
|
return fs.existsSync(filePath) ? fs.readFileSync(filePath, 'utf8') : ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const rendererSource = read('utils/chartRenderer.ts')
|
||||||
|
const sciChartDataSource = read('utils/sciChartData.ts')
|
||||||
|
const trendOptionsSource = read('utils/trendOptions.ts')
|
||||||
|
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
|
||||||
|
const fullscreenSource = read('components/SteadyTrendFullscreen.vue')
|
||||||
|
const chartRendererSource = read('components/SteadyTrendChartRenderer.vue')
|
||||||
|
const sciChartComponentSource = read('components/SteadyTrendSciChart.vue')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['renderer utility defines only scichart renderer type', /SteadyTrendChartRendererType\s*=\s*'scichart'/, rendererSource],
|
||||||
|
[
|
||||||
|
'large dataset threshold is one million points',
|
||||||
|
/STEADY_TREND_LARGE_DATASET_POINT_THRESHOLD\s*=\s*1_000_000/,
|
||||||
|
rendererSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'renderer helper counts series points without flattening arrays',
|
||||||
|
/reduce\(\(total,\s*series\)\s*=>\s*total\s*\+\s*\(series\.points\?\.length\s*\|\|\s*0\)/,
|
||||||
|
rendererSource
|
||||||
|
],
|
||||||
|
['renderer always selects SciChart for steadyTrend', /return\s+'scichart'/, rendererSource],
|
||||||
|
[
|
||||||
|
'large dataset helper is threshold based and independent from renderer choice',
|
||||||
|
/isSteadyTrendLargeDataset[\s\S]*countSteadyTrendSeriesPoints\(seriesList\)\s*>=\s*STEADY_TREND_LARGE_DATASET_POINT_THRESHOLD/,
|
||||||
|
rendererSource
|
||||||
|
],
|
||||||
|
['trend chart group carries renderer metadata', /renderer:\s*SteadyTrendChartRendererType/, trendOptionsSource],
|
||||||
|
['trend chart group keeps original ECharts options', /options:\s*Record<string,\s*unknown>/, trendOptionsSource],
|
||||||
|
[
|
||||||
|
'trend chart group carries raw series for high performance renderer',
|
||||||
|
/seriesList:\s*SteadyTrend\.SteadyTrendSeries\[\]/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
['chart groups resolve renderer per group', /renderer:\s*resolveSteadyTrendChartRenderer\(groupSeries\)/, trendOptionsSource],
|
||||||
|
['SciChart data adapter emits xValues and yValues arrays', /xValues:\s*number\[\][\s\S]*yValues:\s*number\[\]/, sciChartDataSource],
|
||||||
|
['SciChart data adapter parses time into timestamps', /Date\.parse\(value\.replace\(' ',\s*'T'\)\)/, sciChartDataSource],
|
||||||
|
['SciChart data adapter preserves nulls as NaN gaps', /Number\.NaN/, sciChartDataSource],
|
||||||
|
['SciChart data adapter does not build global timeLabels', /^(?![\s\S]*timeLabels)[\s\S]*$/, sciChartDataSource],
|
||||||
|
['SciChart component exists with stable name', /name:\s*'SteadyTrendSciChart'/, sciChartComponentSource],
|
||||||
|
[
|
||||||
|
'SciChart component consumes raw steady series',
|
||||||
|
/seriesList:\s*SteadyTrend\.SteadyTrendSeries\[\]/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SciChart component emits chart-data-zoom',
|
||||||
|
/'chart-data-zoom':\s*\[value:\s*SteadyTrendZoomRange\]/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SciChart component uses SciChart data adapter',
|
||||||
|
/buildSteadyTrendSciChartSeriesData\(props\.seriesList\)/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SciChart component resolves series color from phase theme color first',
|
||||||
|
/resolvePhaseThemeColor[\s\S]*series\.phase[\s\S]*phaseColor\s*\|\|\s*fallbackTrendColors/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SciChart component uses community license by default without commercial key',
|
||||||
|
/Community License[\s\S]*不写入商业授权 key/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'SciChart component does not set a commercial runtime license key',
|
||||||
|
/^(?![\s\S]*setRuntimeLicenseKey)[\s\S]*$/,
|
||||||
|
sciChartComponentSource
|
||||||
|
],
|
||||||
|
['SciChart component cleans up chart resources', /onBeforeUnmount[\s\S]*disposeChart\?\.\(\)/, sciChartComponentSource],
|
||||||
|
['chart renderer renders SciChart directly', /<SteadyTrendSciChart[\s\S]*:series-list="group\.seriesList"/, chartRendererSource],
|
||||||
|
['chart renderer no longer imports the ECharts LineChart fallback', /^(?![\s\S]*LineChart)[\s\S]*$/, chartRendererSource],
|
||||||
|
[
|
||||||
|
'chart panel renders through chart renderer wrapper',
|
||||||
|
/<SteadyTrendChartRenderer[\s\S]*:group="group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen renders through chart renderer wrapper',
|
||||||
|
/<SteadyTrendChartRenderer[\s\S]*:group="group"[\s\S]*@chart-data-zoom="handleChartDataZoom"/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'fullscreen receives shared zoom and tool state',
|
||||||
|
/zoomRange:\s*SteadyTrendZoomRange[\s\S]*activeTool:\s*SteadyTrendActiveTool[\s\S]*wheelZoomEnabled:\s*boolean[\s\S]*yZoomScale:\s*number/,
|
||||||
|
fullscreenSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel prevents in-memory table build for large trend datasets',
|
||||||
|
/isLargeTrendDataset[\s\S]*query-data[\s\S]*当前趋势点数较大/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
['chart panel keeps image export path', /html2canvas[\s\S]*downloadSteadyTrendImage/, chartPanelSource]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend high performance chart contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend high performance chart contract passed')
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import { performance } from 'node:perf_hooks'
|
||||||
|
|
||||||
|
const pointCount = Number(process.argv[2] || 1000000)
|
||||||
|
const startTime = Date.parse('2026-01-01T00:00:00')
|
||||||
|
const points = []
|
||||||
|
|
||||||
|
for (let index = 0; index < pointCount; index += 1) {
|
||||||
|
points.push({
|
||||||
|
time: new Date(startTime + index * 1000).toISOString().slice(0, 19).replace('T', ' '),
|
||||||
|
value: Math.sin(index / 1000) * 10 + 220
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const startedAt = performance.now()
|
||||||
|
let validCount = 0
|
||||||
|
|
||||||
|
for (const point of points) {
|
||||||
|
const timestamp = Date.parse(point.time.replace(' ', 'T'))
|
||||||
|
if (Number.isFinite(timestamp) && Number.isFinite(point.value)) validCount += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const elapsedMs = performance.now() - startedAt
|
||||||
|
|
||||||
|
console.log(
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
pointCount,
|
||||||
|
validCount,
|
||||||
|
elapsedMs: Math.round(elapsedMs)
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if (validCount !== pointCount) process.exit(1)
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||||
|
const ledgerTreeSource = read('SteadyLedgerTree.vue')
|
||||||
|
const workbenchSource = read('SteadyTrendWorkbench.vue')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['ledger panel title is renamed to device tree', /<span class="panel-title">设备树<\/span>/, ledgerTreeSource],
|
||||||
|
['ledger panel no longer renders old title', !/台账监测点/.test(ledgerTreeSource), ledgerTreeSource],
|
||||||
|
[
|
||||||
|
'ledger refresh button is placed in the search row after the input',
|
||||||
|
/<div class="tree-search-row">[\s\S]*<el-input[\s\S]*<\/el-input>[\s\S]*<el-button[\s\S]*@click="emit\('refresh'\)"[\s\S]*<\/div>/,
|
||||||
|
ledgerTreeSource
|
||||||
|
],
|
||||||
|
['ledger panel receives collapsed state', /collapsed:\s*boolean/, ledgerTreeSource],
|
||||||
|
['ledger panel emits toggle event', /toggle:\s*\[\]/, ledgerTreeSource],
|
||||||
|
['ledger panel renders internal collapse or expand button', /@click="emit\('toggle'\)"/, ledgerTreeSource],
|
||||||
|
[
|
||||||
|
'ledger collapse buttons use primary theme color',
|
||||||
|
/class="panel-toggle"[\s\S]*type="primary"[\s\S]*@click="emit\('toggle'\)"/,
|
||||||
|
ledgerTreeSource
|
||||||
|
],
|
||||||
|
['workbench passes collapsed state to ledger tree', /:collapsed="ledgerPanelCollapsedProxy"/, workbenchSource],
|
||||||
|
['workbench wires ledger tree toggle event', /@toggle="emit\('update:ledgerPanelCollapsed', !ledgerPanelCollapsedProxy\)"/, workbenchSource],
|
||||||
|
['workbench no longer renders external ledger toggle button', !/class="ledger-toggle"/.test(workbenchSource), workbenchSource],
|
||||||
|
['workbench collapsed ledger column does not reserve button width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||||
|
['workbench selector column passes full height to the ledger card', /\.selector-column\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||||
|
['workbench ledger panel body passes full height to the ledger card', /\.ledger-panel-body\s*{[^}]*height:\s*100%/, workbenchSource],
|
||||||
|
[
|
||||||
|
'workbench selector column no longer reserves an external toggle column',
|
||||||
|
!/grid-template-columns:\s*minmax\(0,\s*1fr\)\s+36px/.test(workbenchSource),
|
||||||
|
workbenchSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ledger collapsed state only floats the expand button',
|
||||||
|
/\.steady-tree-card\.is-collapsed[\s\S]*position:\s*absolute[\s\S]*width:\s*36px[\s\S]*height:\s*36px/,
|
||||||
|
ledgerTreeSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'expanded ledger panel remains in normal layout',
|
||||||
|
/\.steady-tree-card:not\(\.is-collapsed\)[\s\S]*height:\s*100%/,
|
||||||
|
ledgerTreeSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, expectation, source]) => {
|
||||||
|
return expectation instanceof RegExp ? !expectation.test(source) : !expectation
|
||||||
|
})
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend ledger panel layout contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend ledger panel layout contract passed')
|
||||||
@@ -0,0 +1,175 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import { createRequire } from 'node:module'
|
||||||
|
import path from 'node:path'
|
||||||
|
import vm from 'node:vm'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import ts from 'typescript'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const require = createRequire(import.meta.url)
|
||||||
|
const utilsFile = path.join(currentDir, '..', 'utils', 'ledgerTree.ts')
|
||||||
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
|
const ledgerTreeComponentFile = path.join(currentDir, '..', 'components', 'SteadyLedgerTree.vue')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
|
||||||
|
if (!fs.existsSync(utilsFile)) {
|
||||||
|
console.error('steadyTrend ledger tree normalize contract failed:')
|
||||||
|
console.error('- ledgerTree utility should exist')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const source = read(utilsFile)
|
||||||
|
const compiled = ts.transpileModule(source, {
|
||||||
|
compilerOptions: {
|
||||||
|
module: ts.ModuleKind.CommonJS,
|
||||||
|
target: ts.ScriptTarget.ES2020,
|
||||||
|
esModuleInterop: true
|
||||||
|
}
|
||||||
|
}).outputText
|
||||||
|
|
||||||
|
const moduleContext = { exports: {} }
|
||||||
|
const sandbox = {
|
||||||
|
exports: moduleContext.exports,
|
||||||
|
module: moduleContext,
|
||||||
|
require
|
||||||
|
}
|
||||||
|
|
||||||
|
vm.runInNewContext(compiled, sandbox, { filename: utilsFile })
|
||||||
|
|
||||||
|
const { normalizeSteadyLedgerTree } = moduleContext.exports
|
||||||
|
|
||||||
|
if (typeof normalizeSteadyLedgerTree !== 'function') {
|
||||||
|
console.error('steadyTrend ledger tree normalize contract failed:')
|
||||||
|
console.error('- normalizeSteadyLedgerTree should be exported')
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flatNodes = [
|
||||||
|
{ id: 'line-1', parentId: 'device-1', name: '监测点_1', level: 3, lineCount: 1, selectable: true },
|
||||||
|
{ id: 'line-disabled', parentId: 'device-1', name: '不可选监测点', level: 3, lineCount: 1, selectable: false },
|
||||||
|
{ id: 'engineering-1', name: '工程', level: 0, deviceCount: 1, lineCount: 2 },
|
||||||
|
{ id: 'device-1', parentId: 'project-1', name: '设备', level: 2, lineCount: 2 },
|
||||||
|
{ id: 'project-1', parentId: 'engineering-1', name: '项目', level: 1, deviceCount: 1, lineCount: 2 },
|
||||||
|
{ id: 'line-2', parentId: 'device-1', name: '监测点_2', level: 3, lineCount: 1, selectable: true }
|
||||||
|
]
|
||||||
|
|
||||||
|
const aliasedNodes = [
|
||||||
|
{ engineeringId: 'engineering-alias', engineeringName: '别名工程', level: 0, deviceCount: 1, lineCount: 1 },
|
||||||
|
{
|
||||||
|
projectId: 'project-alias',
|
||||||
|
parentId: 'engineering-alias',
|
||||||
|
projectName: '别名项目',
|
||||||
|
level: 1,
|
||||||
|
deviceCount: 1,
|
||||||
|
lineCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
equipmentId: 'device-alias',
|
||||||
|
parentId: 'project-alias',
|
||||||
|
equipmentName: '别名设备',
|
||||||
|
level: 2,
|
||||||
|
lineCount: 1
|
||||||
|
},
|
||||||
|
{
|
||||||
|
lineId: 'line-alias',
|
||||||
|
deviceId: 'device-alias',
|
||||||
|
lineName: '别名监测点',
|
||||||
|
level: 3,
|
||||||
|
selectable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const snakeCaseNodes = [
|
||||||
|
{
|
||||||
|
engineering_id: 'engineering-snake',
|
||||||
|
engineering_name: 'Snake Engineering',
|
||||||
|
level: 0,
|
||||||
|
equipment_count: 1,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
project_id: 'project-snake',
|
||||||
|
parent_id: 'engineering-snake',
|
||||||
|
project_name: 'Snake Project',
|
||||||
|
level: 1,
|
||||||
|
equipment_count: 1,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
device_id: 'device-snake',
|
||||||
|
parent_id: 'project-snake',
|
||||||
|
device_name: 'Snake Device',
|
||||||
|
level: 2,
|
||||||
|
monitor_count: 1,
|
||||||
|
childrenList: [
|
||||||
|
{
|
||||||
|
line_id: 'line-snake',
|
||||||
|
device_id: 'device-snake',
|
||||||
|
line_name: 'Snake Line',
|
||||||
|
level: 3,
|
||||||
|
selectable: true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
const normalized = normalizeSteadyLedgerTree(flatNodes)
|
||||||
|
const expectedPath = normalized[0]?.children?.[0]?.children?.[0]?.children?.map(item => item.name)
|
||||||
|
const aliasedNormalized = normalizeSteadyLedgerTree(aliasedNodes)
|
||||||
|
const aliasedLine = aliasedNormalized[0]?.children?.[0]?.children?.[0]?.children?.[0]
|
||||||
|
const snakeCaseNormalized = normalizeSteadyLedgerTree(snakeCaseNodes)
|
||||||
|
const snakeCaseDevice = snakeCaseNormalized[0]?.children?.[0]?.children?.[0]
|
||||||
|
const snakeCaseLine = snakeCaseDevice?.children?.[0]
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['flat nodes rebuild to one root engineering node', normalized.length === 1 && normalized[0].id === 'engineering-1'],
|
||||||
|
['project is nested under engineering', normalized[0]?.children?.[0]?.id === 'project-1'],
|
||||||
|
['device is nested under project', normalized[0]?.children?.[0]?.children?.[0]?.id === 'device-1'],
|
||||||
|
['lines are nested under device', JSON.stringify(expectedPath) === JSON.stringify(['监测点_1', '监测点_2'])],
|
||||||
|
[
|
||||||
|
'line nodes keep selectable query identity',
|
||||||
|
normalized[0]?.children?.[0]?.children?.[0]?.children?.every(item => item.selectable && item.level === 3)
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'unselectable line nodes are removed from steady query tree',
|
||||||
|
!normalized[0]?.children?.[0]?.children?.[0]?.children?.some(item => item.id === 'line-disabled')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'backend alias fields keep monitor point nodes',
|
||||||
|
aliasedLine?.id === 'line-alias' && aliasedLine?.name === '别名监测点' && aliasedLine?.parentId === 'device-alias'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'snake case backend fields and childrenList keep the full ledger hierarchy',
|
||||||
|
snakeCaseNormalized[0]?.id === 'engineering-snake' &&
|
||||||
|
snakeCaseNormalized[0]?.deviceCount === 1 &&
|
||||||
|
snakeCaseNormalized[0]?.lineCount === 1 &&
|
||||||
|
snakeCaseDevice?.id === 'device-snake' &&
|
||||||
|
snakeCaseDevice?.lineCount === 1 &&
|
||||||
|
snakeCaseLine?.id === 'line-snake' &&
|
||||||
|
snakeCaseLine?.name === 'Snake Line' &&
|
||||||
|
snakeCaseLine?.parentId === 'device-snake'
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ledger tree component hides count text on monitor point leaves',
|
||||||
|
/shouldShowLedgerCount[\s\S]*Number\(data\.level\)\s*<\s*3/.test(read(ledgerTreeComponentFile))
|
||||||
|
],
|
||||||
|
['page uses normalized ledger tree data', /normalizeSteadyLedgerTree/.test(read(pageFile))]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, matched]) => !matched)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend ledger tree normalize contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend ledger tree normalize contract passed')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const chartPanelFile = path.resolve(currentDir, '../components/SteadyTrendChartPanel.vue')
|
||||||
|
const trendOptionsFile = path.resolve(currentDir, '../utils/trendOptions.ts')
|
||||||
|
|
||||||
|
const chartPanelSource = fs.readFileSync(chartPanelFile, 'utf8')
|
||||||
|
const trendOptionsSource = fs.readFileSync(trendOptionsFile, 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['chart panel defines missing data tool action', /'missing-data'/, chartPanelSource],
|
||||||
|
['chart panel labels missing data action', /label:\s*'缺失数据'/, chartPanelSource],
|
||||||
|
['chart panel defaults missing data disabled for performance', /const\s+missingDataEnabled\s*=\s*ref\(false\)/, chartPanelSource],
|
||||||
|
[
|
||||||
|
'chart panel marks missing data action active only when enabled',
|
||||||
|
/action\s*===\s*'missing-data'[\s\S]*return\s+missingDataEnabled\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'chart panel passes missing data state into chart options',
|
||||||
|
/buildSteadyTrendChartGroups\([^)]*trendXZoomRange\.value[\s\S]*showMissingData:\s*missingDataEnabled\.value/,
|
||||||
|
chartPanelSource
|
||||||
|
],
|
||||||
|
['chart options accept missing data option', /showMissingData\?:\s*boolean/, trendOptionsSource],
|
||||||
|
[
|
||||||
|
'chart options only fills missing data when enabled',
|
||||||
|
/chartOptions\.showMissingData\s*===\s*true\s*\?\s*fillSteadyTrendMissingPoints\(/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing data filler inserts null values',
|
||||||
|
/fillSteadyTrendMissingPoints[\s\S]*value:[\s\S]*:\s*null/,
|
||||||
|
trendOptionsSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'missing data filler infers interval from existing time gaps',
|
||||||
|
/resolveSteadyTrendPointIntervalMs[\s\S]*gaps[\s\S]*Math\.min/,
|
||||||
|
trendOptionsSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks.filter(([, pattern, source]) => !pattern.test(source)).map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyTrend missing data contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend missing data contract passed')
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const toolbarFile = path.resolve(currentDir, '../components/SteadyTrendToolbar.vue')
|
||||||
|
const payloadFile = path.resolve(currentDir, '../utils/trendPayload.ts')
|
||||||
|
|
||||||
|
const toolbarSource = fs.readFileSync(toolbarFile, 'utf8')
|
||||||
|
const payloadSource = fs.readFileSync(payloadFile, 'utf8')
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['toolbar labels the filter as data quality', /toolbar-field__label">数据质量:<\/span>/],
|
||||||
|
['toolbar renders quality flag with switch', /<el-switch[\s\S]*:model-value="modelValue\.qualityFlag \?\? 0"/],
|
||||||
|
['quality switch maps valid data to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||||
|
['quality switch maps invalid data to one', /inactive-text="无效"[\s\S]*:inactive-value="1"/],
|
||||||
|
['quality switch updates qualityFlag', /@update:model-value="handleQualityFlagChange"/],
|
||||||
|
['quality switch reserves enough prompt width', /\.quality-switch\s*\{[\s\S]*min-width:\s*72px/],
|
||||||
|
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||||
|
['utilities send quality flag in trend query payload', /qualityFlag:\s*formState\.qualityFlag/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failed = checks
|
||||||
|
.filter(([, pattern], index) => !pattern.test(index < 6 ? toolbarSource : payloadSource))
|
||||||
|
.map(([message]) => message)
|
||||||
|
|
||||||
|
if (failed.length) {
|
||||||
|
console.error('steadyTrend quality switch contract failed:')
|
||||||
|
failed.forEach(message => console.error(`- ${message}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend quality switch contract passed')
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||||
|
|
||||||
|
const readFunctionBody = name => {
|
||||||
|
const match = pageSource.match(new RegExp(`const\\s+${name}\\s*=\\s*(?:async\\s*)?\\([^)]*\\)\\s*=>\\s*\\{([\\s\\S]*?)\\n\\}`))
|
||||||
|
|
||||||
|
return match?.[1] || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'ledger selection change keeps current trend result until next query',
|
||||||
|
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||||
|
readFunctionBody('handleLedgerChange')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'indicator selection change keeps current trend result until next query',
|
||||||
|
source => !/trendResult\.value\s*=\s*null/.test(source),
|
||||||
|
readFunctionBody('handleIndicatorChange')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'new trend query clears stale trend result before loading',
|
||||||
|
source => /trendResult\.value\s*=\s*null[\s\S]*loading\.trend\s*=\s*true/.test(source),
|
||||||
|
readFunctionBody('handleQueryTrend')
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, check, source]) => !check(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend query state contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend query state contract passed')
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const srcRoot = path.resolve(currentDir, '../../../..')
|
||||||
|
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
|
||||||
|
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
|
||||||
|
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['static router registers /steadyTrend/index', /path:\s*'\/steadyTrend\/index'/],
|
||||||
|
['static route name is steadyTrend', /name:\s*'steadyTrend'/],
|
||||||
|
['static router imports steadyTrend page', /@\/views\/steady\/steadyTrend\/index\.vue/],
|
||||||
|
['dynamic router aliases steady-trend to steadyTrend', /\/steady\/steady-trend[\s\S]*\/steady\/steadyTrend/],
|
||||||
|
['dynamic router keeps steadyTrend static route from being overwritten', /STATIC_ROUTE_NAMES[\s\S]*'steadyTrend'/],
|
||||||
|
['auth normalizes backend steady menu to static steadyTrend entry', /isSteadyTrendMenu[\s\S]*menu\.path\s*=\s*'\/steadyTrend\/index'/],
|
||||||
|
['business menu path resolver handles steadyTrend', /isSteadyTrendMenu\(menu\)[\s\S]*return\s+'\/steadyTrend\/index'/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceByExpectation = [staticRouter, staticRouter, staticRouter, dynamicRouter, dynamicRouter, authStore, authStore]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern], index) => !pattern.test(sourceByExpectation[index]))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend route contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend route contract passed')
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||||
|
const pageSource = fs.readFileSync(path.join(currentDir, '..', 'index.vue'), 'utf8')
|
||||||
|
const selectionRulesSource = fs.readFileSync(path.join(currentDir, '..', 'utils', 'selectionRules.ts'), 'utf8')
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
[
|
||||||
|
'ledger tree excludes half-checked parents when collecting checked nodes',
|
||||||
|
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||||
|
read('SteadyLedgerTree.vue')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'indicator tree excludes half-checked parents when collecting checked nodes',
|
||||||
|
/getCheckedNodes\(\s*false\s*,\s*false\s*\)/,
|
||||||
|
read('SteadyIndicatorTree.vue')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selection rules expose the first selectable ledger resolver',
|
||||||
|
/export const findFirstSelectableLedgerNode/,
|
||||||
|
selectionRulesSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'selection rules expose the first leaf indicator resolver',
|
||||||
|
/export const findFirstLeafIndicator/,
|
||||||
|
selectionRulesSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady page applies default selected ledger keys after ledger tree load',
|
||||||
|
/defaultLedgerCheckedKeys\.value\s*=/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'steady page applies default selected indicator keys after indicator tree load',
|
||||||
|
/defaultIndicatorCheckedKeys\.value\s*=/,
|
||||||
|
pageSource
|
||||||
|
],
|
||||||
|
['ledger tree receives default checked keys', /defaultCheckedKeys/, read('SteadyLedgerTree.vue')],
|
||||||
|
[
|
||||||
|
'ledger tree renders level icons',
|
||||||
|
/<component\s+:is="resolveLedgerIcon\(data\.level\)"/,
|
||||||
|
read('SteadyLedgerTree.vue')
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'ledger tree resolves icons by ledger level',
|
||||||
|
/const\s+ledgerIcons[\s\S]*0:[\s\S]*1:[\s\S]*2:[\s\S]*3:/,
|
||||||
|
read('SteadyLedgerTree.vue')
|
||||||
|
],
|
||||||
|
['indicator tree receives default checked keys', /defaultCheckedKeys/, read('SteadyIndicatorTree.vue')],
|
||||||
|
[
|
||||||
|
'selection rules dedupe leaf indicators collected from checked parents and children',
|
||||||
|
/seenIndicatorKeys[\s\S]*node\.indicatorCode[\s\S]*seenIndicatorKeys\.has[\s\S]*seenIndicatorKeys\.add/,
|
||||||
|
selectionRulesSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([, pattern, source]) => !pattern.test(source))
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend selection contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend selection contract passed')
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const rootDir = path.resolve(currentDir, '../../../..')
|
||||||
|
|
||||||
|
const files = {
|
||||||
|
staticRouter: path.resolve(rootDir, 'routers/modules/staticRouter.ts'),
|
||||||
|
dynamicRouter: path.resolve(rootDir, 'routers/modules/dynamicRouter.ts'),
|
||||||
|
authStore: path.resolve(rootDir, 'stores/modules/auth.ts'),
|
||||||
|
page: path.resolve(rootDir, 'views/steady/steadyTrend/index.vue'),
|
||||||
|
api: path.resolve(rootDir, 'api/steady/steadyTrend/index.ts'),
|
||||||
|
apiInterface: path.resolve(rootDir, 'api/steady/steadyTrend/interface/index.ts')
|
||||||
|
}
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
const exists = file => fs.existsSync(file)
|
||||||
|
|
||||||
|
const checks = [
|
||||||
|
['steadyTrend page exists', () => exists(files.page)],
|
||||||
|
['steadyTrend API exists', () => exists(files.api)],
|
||||||
|
['steadyTrend API interface exists', () => exists(files.apiInterface)],
|
||||||
|
['static router registers /steadyTrend/index', () => /path:\s*'\/steadyTrend\/index'/.test(read(files.staticRouter))],
|
||||||
|
['static route name is steadyTrend', () => /name:\s*'steadyTrend'/.test(read(files.staticRouter))],
|
||||||
|
['static router imports steadyTrend page', () => /@\/views\/steady\/steadyTrend\/index\.vue/.test(read(files.staticRouter))],
|
||||||
|
[
|
||||||
|
'dynamic router aliases steady-trend to steadyTrend',
|
||||||
|
() => /\/steady\/steady-trend[\s\S]*\/steady\/steadyTrend/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'dynamic router keeps steadyTrend static route from being overwritten',
|
||||||
|
() => /STATIC_ROUTE_NAMES[\s\S]*'steadyTrend'/.test(read(files.dynamicRouter))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'auth normalizes backend steadyTrend menu to static entry',
|
||||||
|
() => /isSteadyTrendMenu[\s\S]*menu\.path\s*=\s*'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'business menu path resolver handles steadyTrend',
|
||||||
|
() => /isSteadyTrendMenu\(menu\)[\s\S]*return\s+'\/steadyTrend\/index'/.test(read(files.authStore))
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = checks.filter(([, check]) => !check()).map(([name]) => name)
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend route contract failed:')
|
||||||
|
failures.forEach(name => console.error(`- ${name}`))
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend route contract passed')
|
||||||
@@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
|
const apiFile = path.resolve(currentDir, '../../../../api/steady/steadyTrend/index.ts')
|
||||||
|
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyTrend/interface/index.ts')
|
||||||
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
|
const utilsDir = path.join(currentDir, '..', 'utils')
|
||||||
|
|
||||||
|
const read = file => fs.readFileSync(file, 'utf8')
|
||||||
|
const pageSource = read(pageFile)
|
||||||
|
const apiSource = read(apiFile)
|
||||||
|
const interfaceSource = read(interfaceFile)
|
||||||
|
const componentSource = fs.existsSync(componentDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(componentDir)
|
||||||
|
.filter(file => file.endsWith('.vue'))
|
||||||
|
.map(file => read(path.join(componentDir, file)))
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
const utilitySource = fs.existsSync(utilsDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(utilsDir)
|
||||||
|
.filter(file => file.endsWith('.ts'))
|
||||||
|
.map(file => read(path.join(utilsDir, file)))
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
|
||||||
|
const expectations = [
|
||||||
|
['page imports extracted trend workbench', /SteadyTrendWorkbench/],
|
||||||
|
['trend workbench component exists', fs.existsSync(path.join(componentDir, 'SteadyTrendWorkbench.vue'))],
|
||||||
|
['floating indicator panel component exists', fs.existsSync(path.join(componentDir, 'SteadyIndicatorFloatingPanel.vue'))],
|
||||||
|
['components import ledger tree panel', /SteadyLedgerTree/],
|
||||||
|
['components import indicator tree panel', /SteadyIndicatorTree/],
|
||||||
|
['components import trend toolbar', /SteadyTrendToolbar/],
|
||||||
|
['components import trend chart panel', /SteadyTrendChartPanel/],
|
||||||
|
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
|
||||||
|
['page does not import data table panel', /SteadyDataTablePanel/],
|
||||||
|
['components render floating indicator panel', /indicator-floating-panel/],
|
||||||
|
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
|
||||||
|
['page restores default harmonic order when harmonic filter becomes visible', /DEFAULT_HARMONIC_ORDERS[\s\S]*showHarmonicOrders\.value[\s\S]*trendForm\.value\.harmonicOrders\.length[\s\S]*DEFAULT_HARMONIC_ORDERS/],
|
||||||
|
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
|
||||||
|
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
|
||||||
|
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
|
||||||
|
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
|
||||||
|
['API disables global loading for trend query', /querySteadyTrend[\s\S]*\/steady\/data-view\/trend\/query[\s\S]*loading:\s*false/],
|
||||||
|
['API disables global loading for trend day query', /querySteadyTrendDay[\s\S]*\/steady\/data-view\/trend\/day[\s\S]*loading:\s*false/],
|
||||||
|
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
|
||||||
|
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
|
||||||
|
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
|
||||||
|
['interfaces do not define trend summary', /interface\s+SteadyTrendSummary/],
|
||||||
|
['components render ledger checkbox tree', /show-checkbox[\s\S]*@check/],
|
||||||
|
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
|
||||||
|
['components route trend charts through SciChart renderer', /<SteadyTrendChartRenderer[\s\S]*<SteadyTrendSciChart/],
|
||||||
|
['toolbar uses shared time period search', /TimePeriodSearch/],
|
||||||
|
['toolbar labels stat quality filters', /toolbar-field__label[\s\S]*统计:[\s\S]*toolbar-field__label[\s\S]*数据质量:/],
|
||||||
|
['toolbar does not render bucket selector', /modelValue\.bucket|bucketOptions|粒度:|选择时间粒度/],
|
||||||
|
['toolbar does not render phase selector', /modelValue\.phases|phaseOptions|resolvePhaseLabel/],
|
||||||
|
['toolbar renders quality flag with switch', /<el-switch[\s\S]*@update:model-value="handleQualityFlagChange"/],
|
||||||
|
['toolbar maps valid quality flag to zero', /active-text="有效"[\s\S]*:active-value="0"/],
|
||||||
|
['utilities default to valid quality flag zero', /qualityFlag:\s*0/],
|
||||||
|
['utilities default harmonic order to second harmonic', /DEFAULT_HARMONIC_ORDERS\s*=\s*\[2\]/],
|
||||||
|
['utilities collect selected line ids', /export const collectSelectedLineIds/],
|
||||||
|
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
|
||||||
|
['utilities do not require phase selection', /if\s*\(!phases\.length\)/],
|
||||||
|
['utilities cap harmonic order count at three', /MAX_HARMONIC_ORDER_COUNT\s*=\s*3/],
|
||||||
|
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个/],
|
||||||
|
['utilities count harmonic orders as one indicator in selection estimates', /const harmonicMultiplier\s*=\s*1/],
|
||||||
|
[
|
||||||
|
'toolbar does not provide harmonic quick groups',
|
||||||
|
/HARMONIC_ORDER_QUICK_GROUPS|harmonic-select__quick|appendHarmonicQuickOrders/
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'toolbar warns when harmonic selection exceeds three instead of using silent multiple-limit',
|
||||||
|
/(?=[\s\S]*MAX_HARMONIC_ORDER_COUNT)(?=[\s\S]*ElMessage\.warning\(`谐波次数最多选择 \$\{MAX_HARMONIC_ORDER_COUNT\} 个`\))(?![\s\S]*multiple-limit)/
|
||||||
|
],
|
||||||
|
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
|
||||||
|
['utilities strip milliseconds from trend query time', /formatSteadyTrendQueryTime[\s\S]*replace\(\s*\/\\\.\[\^.\]\+\$\//],
|
||||||
|
['utilities do not send bucket in trend query payload', /bucket:\s*formState\.bucket/],
|
||||||
|
['utilities do not send phases in trend query payload', /phases:\s*formState\.phases/],
|
||||||
|
['trend query params do not include bucket', /interface\s+SteadyTrendQueryParams\s*{[^}]*bucket\??:\s*string/],
|
||||||
|
['trend query params do not include phases', /phases:\s*string\[\]/],
|
||||||
|
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
|
||||||
|
]
|
||||||
|
|
||||||
|
const sourceByExpectation = [
|
||||||
|
pageSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
pageSource,
|
||||||
|
componentSource,
|
||||||
|
pageSource,
|
||||||
|
pageSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
apiSource,
|
||||||
|
interfaceSource,
|
||||||
|
interfaceSource,
|
||||||
|
interfaceSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
componentSource,
|
||||||
|
componentSource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource,
|
||||||
|
interfaceSource,
|
||||||
|
interfaceSource,
|
||||||
|
utilitySource,
|
||||||
|
utilitySource
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = expectations.filter(([name, pattern], index) => {
|
||||||
|
const matched = typeof pattern === 'boolean' ? pattern : pattern.test(sourceByExpectation[index])
|
||||||
|
return name.includes('does not') || name.includes('do not') ? matched : !matched
|
||||||
|
})
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend trend contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend trend contract passed')
|
||||||
@@ -0,0 +1,119 @@
|
|||||||
|
/* eslint-env node */
|
||||||
|
import fs from 'node:fs'
|
||||||
|
import path from 'node:path'
|
||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
|
||||||
|
const currentDir = path.dirname(fileURLToPath(import.meta.url))
|
||||||
|
const pageFile = path.join(currentDir, '..', 'index.vue')
|
||||||
|
const componentDir = path.join(currentDir, '..', 'components')
|
||||||
|
const apiFile = path.resolve(currentDir, '../../../../api/steady/steadyTrend/index.ts')
|
||||||
|
const interfaceFile = path.resolve(currentDir, '../../../../api/steady/steadyTrend/interface/index.ts')
|
||||||
|
|
||||||
|
const source = fs.readFileSync(pageFile, 'utf8')
|
||||||
|
const componentSource = fs.existsSync(componentDir)
|
||||||
|
? fs
|
||||||
|
.readdirSync(componentDir)
|
||||||
|
.filter(file => file.endsWith('.vue'))
|
||||||
|
.map(file => fs.readFileSync(path.join(componentDir, file), 'utf8'))
|
||||||
|
.join('\n')
|
||||||
|
: ''
|
||||||
|
const readComponent = file => fs.readFileSync(path.join(componentDir, file), 'utf8')
|
||||||
|
const toolbarSource = readComponent('SteadyTrendToolbar.vue')
|
||||||
|
const chartPanelSource = readComponent('SteadyTrendChartPanel.vue')
|
||||||
|
const workbenchSource = readComponent('SteadyTrendWorkbench.vue')
|
||||||
|
const floatingPanelSource = readComponent('SteadyIndicatorFloatingPanel.vue')
|
||||||
|
const ledgerTreeSource = readComponent('SteadyLedgerTree.vue')
|
||||||
|
const indicatorTreeSource = readComponent('SteadyIndicatorTree.vue')
|
||||||
|
const viewSource = `${source}\n${componentSource}`
|
||||||
|
const apiSource = fs.readFileSync(apiFile, 'utf8')
|
||||||
|
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
|
||||||
|
|
||||||
|
const forbiddenPatterns = [
|
||||||
|
['data detail tab is removed', /数据明细|name="detail"|SteadyDataTablePanel/, source],
|
||||||
|
['detail ProTable is removed', /buildSteadyDataQueryParams|SteadyDataSearchParams/, source],
|
||||||
|
['trend summary panel is removed', /SteadyTrendSummaryPanel|trendSummary|loading\.summary/, source],
|
||||||
|
[
|
||||||
|
'page detail API is removed',
|
||||||
|
/getSteadyDataPage|getSteadyDataDetail|getSteadyDataTemplates|\/steady\/data-view\/page|\/steady\/data-view\/detail|\/steady\/data-view\/templates/,
|
||||||
|
apiSource
|
||||||
|
],
|
||||||
|
['trend summary API is removed', /getSteadyTrendSummary|\/steady\/data-view\/trend\/summary/, apiSource],
|
||||||
|
[
|
||||||
|
'page detail types are removed',
|
||||||
|
/PageResult|SteadyDataPageParams|SteadyDataDetailParams|SteadyDataTemplate|SteadyDataRecord/,
|
||||||
|
interfaceSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend summary types are removed',
|
||||||
|
/SteadyTrendSummary|SteadyTrendSummaryItem/,
|
||||||
|
interfaceSource
|
||||||
|
],
|
||||||
|
['chart panel title text is removed', /panel-title/, chartPanelSource],
|
||||||
|
['collapsed indicator vertical trigger is removed', /indicator-collapsed-trigger/, floatingPanelSource],
|
||||||
|
['collapsed indicator label is removed', /collapsedLabel/, floatingPanelSource],
|
||||||
|
['indicator tree refresh button is removed', /@click="emit\('refresh'\)"|:icon="Refresh"/, indicatorTreeSource],
|
||||||
|
['floating indicator panel refresh passthrough is removed', /@refresh="emit\('refresh'\)"|refresh:\s*\[\]/, floatingPanelSource],
|
||||||
|
['workbench indicator refresh passthrough is removed', /@refresh="emit\('refreshIndicator'\)"|refreshIndicator:\s*\[\]/, workbenchSource],
|
||||||
|
['page indicator refresh binding is removed', /@refresh-indicator="loadIndicatorTree"/, source]
|
||||||
|
]
|
||||||
|
|
||||||
|
const requiredPatterns = [
|
||||||
|
['page defines SteadyTrend component name', /name:\s*'SteadyTrend'/, source],
|
||||||
|
['page renders extracted trend workbench', /<SteadyTrendWorkbench/, source],
|
||||||
|
['trend workbench component exists', /SteadyTrendWorkbench/, viewSource],
|
||||||
|
['floating indicator panel component exists', /SteadyIndicatorFloatingPanel/, viewSource],
|
||||||
|
['components keep trend chart panel', /SteadyTrendChartPanel/, viewSource],
|
||||||
|
['components keep right floating indicator panel', /indicator-floating-panel/, viewSource],
|
||||||
|
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||||
|
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
|
||||||
|
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource],
|
||||||
|
[
|
||||||
|
'trend toolbar gives the time selector a wider first column',
|
||||||
|
/grid-template-columns:\s*minmax\(360px,\s*1\.35fr\)\s+repeat\(3,\s*minmax\(0,\s*1fr\)\)\s*auto/,
|
||||||
|
toolbarSource
|
||||||
|
],
|
||||||
|
['trend toolbar keeps actions after four search columns', /grid-column:\s*5/, toolbarSource],
|
||||||
|
[
|
||||||
|
'trend toolbar widens the shared time period unit selector',
|
||||||
|
/\.trend-toolbar__time\s*:deep\(\.time-period-search__unit\)[\s\S]*width:\s*72px[\s\S]*flex:\s*0 0 72px/,
|
||||||
|
toolbarSource
|
||||||
|
],
|
||||||
|
[
|
||||||
|
'trend toolbar widens the shared time period date picker',
|
||||||
|
/\.trend-toolbar__time\s*:deep\(\.time-period-search__picker\)[\s\S]*width:\s*136px[\s\S]*flex:\s*0 0 136px/,
|
||||||
|
toolbarSource
|
||||||
|
],
|
||||||
|
['floating indicator panel expanded width is reduced', /width:\s*300px/, floatingPanelSource],
|
||||||
|
['floating indicator collapsed state keeps icon only', /width:\s*0/, floatingPanelSource],
|
||||||
|
['floating indicator body is hidden when collapsed', /\.indicator-floating-panel\.is-collapsed\s+\.indicator-panel-body/, floatingPanelSource],
|
||||||
|
['floating indicator toggle keeps enough distance from title', /left:\s*-28px/, floatingPanelSource],
|
||||||
|
['floating indicator toggle uses primary theme color', /class="indicator-toggle"[\s\S]*type="primary"/, floatingPanelSource],
|
||||||
|
['ledger collapse buttons use primary theme color', /class="panel-toggle"[\s\S]*type="primary"/, ledgerTreeSource],
|
||||||
|
['page tracks collapsed ledger panel state', /ledgerPanelCollapsed\s*=\s*ref\(false\)/, source],
|
||||||
|
['page passes collapsed ledger state to workbench', /v-model:ledger-panel-collapsed="ledgerPanelCollapsed"/, source],
|
||||||
|
['query collapses floating indicator panel', /indicatorPanelCollapsed\.value\s*=\s*true[\s\S]*querySteadyTrend/, source],
|
||||||
|
['workbench exposes collapsed ledger panel model', /ledgerPanelCollapsed[\s\S]*update:ledgerPanelCollapsed/, workbenchSource],
|
||||||
|
['workbench applies collapsed ledger layout class', /is-ledger-collapsed/, workbenchSource],
|
||||||
|
['ledger panel stays in normal layout instead of floating', /\.selector-column[\s\S]*position:\s*relative/, workbenchSource],
|
||||||
|
['collapsed ledger panel does not reserve trigger column width', /grid-template-columns:\s*0\s+minmax\(0,\s*1fr\)/, workbenchSource],
|
||||||
|
[
|
||||||
|
'collapsed ledger panel allows only the expand button to float',
|
||||||
|
/\.steady-trend-layout\.is-ledger-collapsed\s+\.selector-column[\s\S]*overflow:\s*visible/,
|
||||||
|
workbenchSource
|
||||||
|
]
|
||||||
|
]
|
||||||
|
|
||||||
|
const failures = [
|
||||||
|
...forbiddenPatterns.filter(([, pattern, target]) => pattern.test(target)),
|
||||||
|
...requiredPatterns.filter(([, pattern, target]) => !pattern.test(target))
|
||||||
|
]
|
||||||
|
|
||||||
|
if (failures.length) {
|
||||||
|
console.error('steadyTrend visible contract failed:')
|
||||||
|
for (const [name] of failures) {
|
||||||
|
console.error(`- ${name}`)
|
||||||
|
}
|
||||||
|
process.exit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('steadyTrend visible contract passed')
|
||||||
200
frontend/src/views/steady/steadyTrend/index.vue
Normal file
200
frontend/src/views/steady/steadyTrend/index.vue
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
<template>
|
||||||
|
<div class="table-box steady-trend-page">
|
||||||
|
<SteadyTrendWorkbench
|
||||||
|
v-model:trend-form="trendForm"
|
||||||
|
v-model:ledger-panel-collapsed="ledgerPanelCollapsed"
|
||||||
|
v-model:indicator-panel-collapsed="indicatorPanelCollapsed"
|
||||||
|
:ledger-tree="ledgerTree"
|
||||||
|
:indicator-tree="indicatorTree"
|
||||||
|
:trend-result="trendResult"
|
||||||
|
:stat-options="statOptions"
|
||||||
|
:show-harmonic-orders="showHarmonicOrders"
|
||||||
|
:loading="loading"
|
||||||
|
:ledger-keyword="ledgerKeyword"
|
||||||
|
:default-ledger-checked-keys="defaultLedgerCheckedKeys"
|
||||||
|
:default-indicator-checked-keys="defaultIndicatorCheckedKeys"
|
||||||
|
:selector-reset-key="selectorResetKey"
|
||||||
|
@refresh-ledger="loadLedgerTree"
|
||||||
|
@ledger-search="handleLedgerSearch"
|
||||||
|
@ledger-change="handleLedgerChange"
|
||||||
|
@indicator-change="handleIndicatorChange"
|
||||||
|
@query-trend="handleQueryTrend"
|
||||||
|
@reset-trend="resetTrendState"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, reactive, ref, watch } from 'vue'
|
||||||
|
import { ElMessage } from 'element-plus'
|
||||||
|
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyTrend'
|
||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import SteadyTrendWorkbench from './components/SteadyTrendWorkbench.vue'
|
||||||
|
import {
|
||||||
|
collectSelectedLineIds,
|
||||||
|
findFirstLeafIndicator,
|
||||||
|
findFirstSelectableLedgerNode,
|
||||||
|
hasHarmonicIndicator,
|
||||||
|
resolveAvailableStats,
|
||||||
|
sortSteadyIndicatorTree,
|
||||||
|
validateTrendSelection
|
||||||
|
} from './utils/selectionRules'
|
||||||
|
import { normalizeSteadyLedgerTree } from './utils/ledgerTree'
|
||||||
|
import { DEFAULT_HARMONIC_ORDERS, buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
|
||||||
|
|
||||||
|
defineOptions({
|
||||||
|
name: 'SteadyTrend'
|
||||||
|
})
|
||||||
|
|
||||||
|
const ledgerTree = ref<SteadyTrend.SteadyLedgerNode[]>([])
|
||||||
|
const indicatorTree = ref<SteadyTrend.SteadyIndicatorNode[]>([])
|
||||||
|
const selectedLedgerNodes = ref<SteadyTrend.SteadyLedgerNode[]>([])
|
||||||
|
const selectedIndicators = ref<SteadyTrend.SteadyIndicatorNode[]>([])
|
||||||
|
const trendResult = ref<SteadyTrend.SteadyTrendQueryResult | null>(null)
|
||||||
|
const trendForm = ref(defaultTrendFormState())
|
||||||
|
const ledgerKeyword = ref('')
|
||||||
|
const ledgerPanelCollapsed = ref(false)
|
||||||
|
const indicatorPanelCollapsed = ref(false)
|
||||||
|
const selectorResetKey = ref(0)
|
||||||
|
const defaultLedgerCheckedKeys = ref<string[]>([])
|
||||||
|
const defaultIndicatorCheckedKeys = ref<string[]>([])
|
||||||
|
const loading = reactive({
|
||||||
|
ledger: false,
|
||||||
|
indicator: false,
|
||||||
|
trend: false
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
|
||||||
|
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
|
||||||
|
const statOptions = computed<SteadyTrend.SteadyTrendStatType[]>(() => {
|
||||||
|
const stats = resolveAvailableStats(selectedIndicators.value)
|
||||||
|
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
|
||||||
|
})
|
||||||
|
|
||||||
|
const unwrapData = <T,>(response: { data: T } | T): T => {
|
||||||
|
if (response && typeof response === 'object' && 'data' in response) {
|
||||||
|
return (response as { data: T }).data
|
||||||
|
}
|
||||||
|
|
||||||
|
return response as T
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
|
||||||
|
loading.ledger = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
|
||||||
|
// 台账树接口在搜索场景可能返回扁平节点,前端统一恢复工程、项目、设备、监测点层级。
|
||||||
|
ledgerTree.value = normalizeSteadyLedgerTree(unwrapData(response) || [])
|
||||||
|
const firstLedgerNode = findFirstSelectableLedgerNode(ledgerTree.value)
|
||||||
|
// 台账树首次加载后默认选中第一个可查询监测点,避免趋势查询初始状态为空。
|
||||||
|
selectedLedgerNodes.value = firstLedgerNode ? [firstLedgerNode] : []
|
||||||
|
defaultLedgerCheckedKeys.value = firstLedgerNode ? [firstLedgerNode.id] : []
|
||||||
|
} finally {
|
||||||
|
loading.ledger = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadIndicatorTree = async () => {
|
||||||
|
loading.indicator = true
|
||||||
|
try {
|
||||||
|
const response = await getSteadyTrendIndicatorTree()
|
||||||
|
indicatorTree.value = sortSteadyIndicatorTree(unwrapData(response) || [])
|
||||||
|
const firstIndicator = findFirstLeafIndicator(indicatorTree.value)
|
||||||
|
const firstIndicatorKey = firstIndicator?.id || firstIndicator?.indicatorCode
|
||||||
|
// 指标树首次加载后默认选中第一个叶子指标,并同步驱动统计类型默认值。
|
||||||
|
selectedIndicators.value = firstIndicator ? [firstIndicator] : []
|
||||||
|
defaultIndicatorCheckedKeys.value = firstIndicatorKey ? [firstIndicatorKey] : []
|
||||||
|
} finally {
|
||||||
|
loading.indicator = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const handleLedgerSearch = (value: string) => {
|
||||||
|
ledgerKeyword.value = value
|
||||||
|
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
|
||||||
|
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleLedgerChange = (nodes: SteadyTrend.SteadyLedgerNode[]) => {
|
||||||
|
selectedLedgerNodes.value = nodes
|
||||||
|
// 鐩戞祴鐐瑰垏鎹㈠彧鏇存柊寰呮煡璇㈡潯浠讹紝瓒嬪娍鏁版嵁淇濈暀鍒扮敤鎴峰啀娆$偣鍑绘煡璇㈡椂鍐嶆竻鐞嗐€?
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleIndicatorChange = (nodes: SteadyTrend.SteadyIndicatorNode[]) => {
|
||||||
|
selectedIndicators.value = nodes
|
||||||
|
// 鎸囨爣鍒囨崲鍙洿鏂板緟鏌ヨ鏉′欢锛岃秼鍔挎暟鎹繚鐣欏埌鐢ㄦ埛鍐嶆鐐瑰嚮鏌ヨ鏃跺啀娓呯悊銆?
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetTrendState = () => {
|
||||||
|
trendForm.value = defaultTrendFormState()
|
||||||
|
selectedLedgerNodes.value = []
|
||||||
|
selectedIndicators.value = []
|
||||||
|
defaultLedgerCheckedKeys.value = []
|
||||||
|
defaultIndicatorCheckedKeys.value = []
|
||||||
|
trendResult.value = null
|
||||||
|
selectorResetKey.value += 1
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleQueryTrend = async () => {
|
||||||
|
indicatorPanelCollapsed.value = true
|
||||||
|
|
||||||
|
const selectionError = validateTrendSelection({
|
||||||
|
lineIds: lineIds.value,
|
||||||
|
indicators: selectedIndicators.value,
|
||||||
|
statType: trendForm.value.statType,
|
||||||
|
harmonicOrders: trendForm.value.harmonicOrders
|
||||||
|
})
|
||||||
|
if (selectionError) {
|
||||||
|
ElMessage.warning(selectionError)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
|
||||||
|
ElMessage.warning('\u8bf7\u9009\u62e9\u8d8b\u52bf\u65f6\u95f4\u8303\u56f4')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
|
||||||
|
trendResult.value = null
|
||||||
|
|
||||||
|
loading.trend = true
|
||||||
|
try {
|
||||||
|
// 趋势查询只驱动主图,右侧指标树作为筛选面板独立加载,避免额外请求拖慢页面响应。
|
||||||
|
const trendResponse = await querySteadyTrend(payload)
|
||||||
|
trendResult.value = unwrapData(trendResponse)
|
||||||
|
} finally {
|
||||||
|
loading.trend = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
selectedIndicators,
|
||||||
|
() => {
|
||||||
|
const availableStats = statOptions.value
|
||||||
|
|
||||||
|
trendForm.value = {
|
||||||
|
...trendForm.value,
|
||||||
|
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
|
||||||
|
harmonicOrders: showHarmonicOrders.value
|
||||||
|
? trendForm.value.harmonicOrders.length
|
||||||
|
? trendForm.value.harmonicOrders
|
||||||
|
: [...DEFAULT_HARMONIC_ORDERS]
|
||||||
|
: []
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ deep: true }
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
loadLedgerTree()
|
||||||
|
loadIndicatorTree()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped lang="scss">
|
||||||
|
.steady-trend-page {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
19
frontend/src/views/steady/steadyTrend/utils/chartRenderer.ts
Normal file
19
frontend/src/views/steady/steadyTrend/utils/chartRenderer.ts
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
export type SteadyTrendChartRendererType = 'scichart'
|
||||||
|
|
||||||
|
export const STEADY_TREND_LARGE_DATASET_POINT_THRESHOLD = 1_000_000
|
||||||
|
|
||||||
|
export const countSteadyTrendSeriesPoints = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
return seriesList.reduce((total, series) => total + (series.points?.length || 0), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveSteadyTrendChartRenderer = (
|
||||||
|
_seriesList: SteadyTrend.SteadyTrendSeries[]
|
||||||
|
): SteadyTrendChartRendererType => {
|
||||||
|
return 'scichart'
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isSteadyTrendLargeDataset = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
return countSteadyTrendSeriesPoints(seriesList) >= STEADY_TREND_LARGE_DATASET_POINT_THRESHOLD
|
||||||
|
}
|
||||||
213
frontend/src/views/steady/steadyTrend/utils/ledgerTree.ts
Normal file
213
frontend/src/views/steady/steadyTrend/utils/ledgerTree.ts
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
type LedgerLevel = SteadyTrend.SteadyLedgerNode['level']
|
||||||
|
type RawLedgerNode = Partial<SteadyTrend.SteadyLedgerNode> & Record<string, unknown>
|
||||||
|
type IndexedLedgerNode = SteadyTrend.SteadyLedgerNode & {
|
||||||
|
parentIds?: string
|
||||||
|
__order: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveText = (data: RawLedgerNode, ...keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = data[key]
|
||||||
|
if (value === null || value === undefined) continue
|
||||||
|
const text = String(value).trim()
|
||||||
|
if (text) return text
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveNumber = (data: RawLedgerNode, ...keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = data[key]
|
||||||
|
if (value === null || value === undefined || value === '') continue
|
||||||
|
const parsed = Number(value)
|
||||||
|
if (Number.isFinite(parsed)) return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveBoolean = (data: RawLedgerNode, ...keys: string[]) => {
|
||||||
|
for (const key of keys) {
|
||||||
|
const value = data[key]
|
||||||
|
if (value === null || value === undefined || value === '') continue
|
||||||
|
if (typeof value === 'boolean') return value
|
||||||
|
if (typeof value === 'number') return value !== 0
|
||||||
|
|
||||||
|
const text = String(value).trim().toLowerCase()
|
||||||
|
if (text === 'true' || text === '1') return true
|
||||||
|
if (text === 'false' || text === '0') return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeLevel = (value: unknown): LedgerLevel => {
|
||||||
|
const level = Number(value)
|
||||||
|
if (level === 0 || level === 1 || level === 2 || level === 3) {
|
||||||
|
return level
|
||||||
|
}
|
||||||
|
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitParentIds = (parentIds?: string) => {
|
||||||
|
if (!parentIds) return []
|
||||||
|
|
||||||
|
return parentIds
|
||||||
|
.split(/[,\s/|>]+/)
|
||||||
|
.map(item => item.trim())
|
||||||
|
.filter(Boolean)
|
||||||
|
}
|
||||||
|
|
||||||
|
const flattenLedgerNodes = (
|
||||||
|
nodes: SteadyTrend.SteadyLedgerNode[],
|
||||||
|
output: IndexedLedgerNode[] = [],
|
||||||
|
inheritedParentId = ''
|
||||||
|
) => {
|
||||||
|
nodes.forEach(node => {
|
||||||
|
const rawNode = node as RawLedgerNode
|
||||||
|
const rawChildren = rawNode.children ?? rawNode.Children ?? rawNode.childrenList ?? rawNode.childList
|
||||||
|
const children = Array.isArray(rawChildren) ? (rawChildren as SteadyTrend.SteadyLedgerNode[]) : []
|
||||||
|
const level = normalizeLevel(rawNode.level ?? rawNode.Level)
|
||||||
|
const id = resolveText(
|
||||||
|
rawNode,
|
||||||
|
'id',
|
||||||
|
'Id',
|
||||||
|
'lineId',
|
||||||
|
'line_id',
|
||||||
|
'equipmentId',
|
||||||
|
'equipment_id',
|
||||||
|
'deviceId',
|
||||||
|
'device_id',
|
||||||
|
'projectId',
|
||||||
|
'project_id',
|
||||||
|
'engineeringId',
|
||||||
|
'engineering_id'
|
||||||
|
)
|
||||||
|
const parentId =
|
||||||
|
resolveText(
|
||||||
|
rawNode,
|
||||||
|
'parentId',
|
||||||
|
'parent_id',
|
||||||
|
'pid',
|
||||||
|
'Pid',
|
||||||
|
level === 3 ? 'deviceId' : '',
|
||||||
|
level === 3 ? 'device_id' : '',
|
||||||
|
level === 2 ? 'projectId' : '',
|
||||||
|
level === 2 ? 'project_id' : ''
|
||||||
|
) ||
|
||||||
|
inheritedParentId
|
||||||
|
const rawSelectable = resolveBoolean(rawNode, 'selectable', 'Selectable')
|
||||||
|
|
||||||
|
if (!id) return
|
||||||
|
if (level === 3 && rawSelectable === false) return
|
||||||
|
|
||||||
|
output.push({
|
||||||
|
id,
|
||||||
|
parentId,
|
||||||
|
parentIds: resolveText(rawNode, 'parentIds', 'pids', 'Pids'),
|
||||||
|
name:
|
||||||
|
resolveText(
|
||||||
|
rawNode,
|
||||||
|
'name',
|
||||||
|
'Name',
|
||||||
|
'lineName',
|
||||||
|
'line_name',
|
||||||
|
'equipmentName',
|
||||||
|
'equipment_name',
|
||||||
|
'deviceName',
|
||||||
|
'device_name',
|
||||||
|
'projectName',
|
||||||
|
'project_name',
|
||||||
|
'engineeringName',
|
||||||
|
'engineering_name'
|
||||||
|
) ||
|
||||||
|
id,
|
||||||
|
level,
|
||||||
|
sort: resolveNumber(rawNode, 'sort', 'Sort'),
|
||||||
|
deviceCount: resolveNumber(rawNode, 'deviceCount', 'DeviceCount', 'equipmentCount', 'equipment_count'),
|
||||||
|
lineCount: resolveNumber(rawNode, 'lineCount', 'LineCount', 'monitorCount', 'monitor_count'),
|
||||||
|
selectable: level === 3 ? rawSelectable !== false : rawSelectable === true,
|
||||||
|
children: [],
|
||||||
|
__order: output.length
|
||||||
|
})
|
||||||
|
|
||||||
|
flattenLedgerNodes(children, output, id)
|
||||||
|
})
|
||||||
|
|
||||||
|
return output
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveExpectedParentId = (node: IndexedLedgerNode, nodeMap: Map<string, IndexedLedgerNode>) => {
|
||||||
|
if (node.level === 0) return ''
|
||||||
|
|
||||||
|
const parentNode = node.parentId ? nodeMap.get(node.parentId) : undefined
|
||||||
|
if (parentNode && parentNode.level === node.level - 1) {
|
||||||
|
return parentNode.id
|
||||||
|
}
|
||||||
|
|
||||||
|
// 后端搜索可能返回扁平节点或错误嵌套,优先按 parentIds 中的上一层节点恢复固定台账层级。
|
||||||
|
const parentIds = splitParentIds(node.parentIds).reverse()
|
||||||
|
const matchedParent = parentIds.map(id => nodeMap.get(id)).find(item => item && item.level === node.level - 1)
|
||||||
|
|
||||||
|
return matchedParent?.id || node.parentId || ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortLedgerNodes = (left: IndexedLedgerNode, right: IndexedLedgerNode) => {
|
||||||
|
const leftSort = Number.isFinite(left.sort) ? Number(left.sort) : left.__order
|
||||||
|
const rightSort = Number.isFinite(right.sort) ? Number(right.sort) : right.__order
|
||||||
|
if (leftSort !== rightSort) return leftSort - rightSort
|
||||||
|
|
||||||
|
return left.__order - right.__order
|
||||||
|
}
|
||||||
|
|
||||||
|
const stripInternalFields = (node: IndexedLedgerNode): SteadyTrend.SteadyLedgerNode => {
|
||||||
|
return {
|
||||||
|
id: node.id,
|
||||||
|
parentId: node.parentId,
|
||||||
|
name: node.name,
|
||||||
|
level: node.level,
|
||||||
|
sort: node.sort,
|
||||||
|
deviceCount: node.deviceCount,
|
||||||
|
lineCount: node.lineCount,
|
||||||
|
selectable: node.selectable,
|
||||||
|
children: node.children?.map(item => stripInternalFields(item as IndexedLedgerNode))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const normalizeSteadyLedgerTree = (
|
||||||
|
nodes: SteadyTrend.SteadyLedgerNode[] = []
|
||||||
|
): SteadyTrend.SteadyLedgerNode[] => {
|
||||||
|
const flatNodes = flattenLedgerNodes(nodes)
|
||||||
|
const nodeMap = new Map(flatNodes.map(node => [node.id, node]))
|
||||||
|
const roots: IndexedLedgerNode[] = []
|
||||||
|
|
||||||
|
flatNodes.forEach(node => {
|
||||||
|
node.children = []
|
||||||
|
})
|
||||||
|
|
||||||
|
flatNodes.forEach(node => {
|
||||||
|
const parentId = resolveExpectedParentId(node, nodeMap)
|
||||||
|
const parentNode = parentId ? nodeMap.get(parentId) : undefined
|
||||||
|
|
||||||
|
if (!parentNode || parentNode.id === node.id) {
|
||||||
|
roots.push(node)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
node.parentId = parentNode.id
|
||||||
|
parentNode.children = [...(parentNode.children || []), node]
|
||||||
|
})
|
||||||
|
|
||||||
|
const sortChildren = (items: IndexedLedgerNode[]) => {
|
||||||
|
items.sort(sortLedgerNodes)
|
||||||
|
items.forEach(item => sortChildren((item.children || []) as IndexedLedgerNode[]))
|
||||||
|
}
|
||||||
|
|
||||||
|
sortChildren(roots)
|
||||||
|
|
||||||
|
return roots.map(stripInternalFields)
|
||||||
|
}
|
||||||
49
frontend/src/views/steady/steadyTrend/utils/sciChartData.ts
Normal file
49
frontend/src/views/steady/steadyTrend/utils/sciChartData.ts
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
export interface SteadyTrendSciChartSeriesData {
|
||||||
|
key: string
|
||||||
|
name: string
|
||||||
|
unit: string
|
||||||
|
xValues: number[]
|
||||||
|
yValues: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSteadyTrendPointTime = (value?: string) => {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSciChartSeriesName = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
return [series.lineName || series.lineId, series.indicatorName || series.indicatorCode, series.phase, series.statType]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('_')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendSciChartSeriesData = (
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[]
|
||||||
|
): SteadyTrendSciChartSeriesData[] => {
|
||||||
|
return seriesList.map(series => {
|
||||||
|
const xValues: number[] = []
|
||||||
|
const yValues: number[] = []
|
||||||
|
|
||||||
|
;(series.points || []).forEach(point => {
|
||||||
|
const timestamp = parseSteadyTrendPointTime(point.time)
|
||||||
|
|
||||||
|
if (timestamp === null) return
|
||||||
|
|
||||||
|
xValues.push(timestamp)
|
||||||
|
yValues.push(typeof point.value === 'number' && Number.isFinite(point.value) ? point.value : Number.NaN)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
key: series.seriesKey,
|
||||||
|
name: formatSciChartSeriesName(series),
|
||||||
|
unit: series.unit || '',
|
||||||
|
xValues,
|
||||||
|
yValues
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
171
frontend/src/views/steady/steadyTrend/utils/selectionRules.ts
Normal file
171
frontend/src/views/steady/steadyTrend/utils/selectionRules.ts
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
export const MAX_TREND_SERIES_COUNT = 24
|
||||||
|
export const MAX_SELECTED_LINE_COUNT = 6
|
||||||
|
export const MAX_SELECTED_INDICATOR_COUNT = 6
|
||||||
|
export const MAX_HARMONIC_ORDER_COUNT = 3
|
||||||
|
const STEADY_INDICATOR_GROUP_ORDER = ['电压趋势', '电流趋势']
|
||||||
|
|
||||||
|
const isSelectableLineNode = (node: SteadyTrend.SteadyLedgerNode) => {
|
||||||
|
return node.level === 3 && node.selectable !== false
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectSelectedLineIds = (nodes: SteadyTrend.SteadyLedgerNode[]) => {
|
||||||
|
const lineIds = new Set<string>()
|
||||||
|
|
||||||
|
const collect = (node: SteadyTrend.SteadyLedgerNode) => {
|
||||||
|
if (isSelectableLineNode(node)) {
|
||||||
|
lineIds.add(node.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
node.children?.forEach(collect)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach(collect)
|
||||||
|
|
||||||
|
return Array.from(lineIds)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const collectLeafIndicators = (nodes: SteadyTrend.SteadyIndicatorNode[]) => {
|
||||||
|
const indicators: SteadyTrend.SteadyIndicatorNode[] = []
|
||||||
|
const seenIndicatorKeys = new Set<string>()
|
||||||
|
|
||||||
|
const collect = (node: SteadyTrend.SteadyIndicatorNode) => {
|
||||||
|
if (node.children?.length) {
|
||||||
|
node.children.forEach(collect)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorKey = node.indicatorCode || node.treeKey || node.id
|
||||||
|
if (!indicatorKey || seenIndicatorKeys.has(indicatorKey)) return
|
||||||
|
|
||||||
|
seenIndicatorKeys.add(indicatorKey)
|
||||||
|
indicators.push(node)
|
||||||
|
}
|
||||||
|
|
||||||
|
nodes.forEach(collect)
|
||||||
|
|
||||||
|
return indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyIndicatorGroupOrder = (node: SteadyTrend.SteadyIndicatorNode) => {
|
||||||
|
const orderName = String(node.name || '').trim()
|
||||||
|
const orderIndex = STEADY_INDICATOR_GROUP_ORDER.findIndex(name => orderName === name)
|
||||||
|
|
||||||
|
return orderIndex === -1 ? STEADY_INDICATOR_GROUP_ORDER.length : orderIndex
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sortSteadyIndicatorTree = (
|
||||||
|
nodes: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
): SteadyTrend.SteadyIndicatorNode[] => {
|
||||||
|
return nodes
|
||||||
|
.map((node, index) => ({ node, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = resolveSteadyIndicatorGroupOrder(left.node)
|
||||||
|
const rightOrder = resolveSteadyIndicatorGroupOrder(right.node)
|
||||||
|
|
||||||
|
return leftOrder === rightOrder ? left.index - right.index : leftOrder - rightOrder
|
||||||
|
})
|
||||||
|
.map(({ node }) => ({
|
||||||
|
...node,
|
||||||
|
children: node.children?.length ? sortSteadyIndicatorTree(node.children) : node.children
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findFirstSelectableLedgerNode = (
|
||||||
|
nodes: SteadyTrend.SteadyLedgerNode[]
|
||||||
|
): SteadyTrend.SteadyLedgerNode | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (isSelectableLineNode(node)) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
|
||||||
|
const childNode = findFirstSelectableLedgerNode(node.children || [])
|
||||||
|
if (childNode) return childNode
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const findFirstLeafIndicator = (
|
||||||
|
nodes: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
): SteadyTrend.SteadyIndicatorNode | null => {
|
||||||
|
for (const node of nodes) {
|
||||||
|
if (node.children?.length) {
|
||||||
|
const childNode = findFirstLeafIndicator(node.children)
|
||||||
|
if (childNode) return childNode
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.indicatorCode) {
|
||||||
|
return node
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const hasHarmonicIndicator = (indicators: SteadyTrend.SteadyIndicatorNode[]) => {
|
||||||
|
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
|
||||||
|
}
|
||||||
|
|
||||||
|
export const resolveAvailableStats = (indicators: SteadyTrend.SteadyIndicatorNode[]) => {
|
||||||
|
const statSet = new Set<SteadyTrend.SteadyTrendStatType>()
|
||||||
|
|
||||||
|
indicators.forEach(indicator => {
|
||||||
|
indicator.supportStats?.forEach(stat => statSet.add(stat))
|
||||||
|
})
|
||||||
|
|
||||||
|
return Array.from(statSet)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const estimateTrendSeriesCount = (
|
||||||
|
lineIds: string[],
|
||||||
|
indicators: SteadyTrend.SteadyIndicatorNode[],
|
||||||
|
statType: SteadyTrend.SteadyTrendStatType,
|
||||||
|
harmonicOrders: number[]
|
||||||
|
) => {
|
||||||
|
void harmonicOrders
|
||||||
|
const harmonicMultiplier = 1
|
||||||
|
|
||||||
|
return indicators.reduce((count, indicator) => {
|
||||||
|
const phaseCount = indicator.phaseCodes?.length || 1
|
||||||
|
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
|
||||||
|
|
||||||
|
return count + lineIds.length * phaseCount * (statType ? 1 : 0) * fieldCount * harmonicMultiplier
|
||||||
|
}, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateHarmonicOrders = (indicators: SteadyTrend.SteadyIndicatorNode[], harmonicOrders: number[]) => {
|
||||||
|
if (!hasHarmonicIndicator(indicators)) return ''
|
||||||
|
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
|
||||||
|
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return `谐波次数最多选择 ${MAX_HARMONIC_ORDER_COUNT} 个`
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
export const validateTrendSelection = (params: {
|
||||||
|
lineIds: string[]
|
||||||
|
indicators: SteadyTrend.SteadyIndicatorNode[]
|
||||||
|
statType: SteadyTrend.SteadyTrendStatType
|
||||||
|
harmonicOrders: number[]
|
||||||
|
}) => {
|
||||||
|
const { lineIds, indicators, statType, harmonicOrders } = params
|
||||||
|
|
||||||
|
if (!lineIds.length) return '请选择监测点'
|
||||||
|
if (!indicators.length) return '请选择趋势指标'
|
||||||
|
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
|
||||||
|
if (lineIds.length > MAX_SELECTED_LINE_COUNT) return '监测点最多选择 6 个'
|
||||||
|
if (indicators.length > MAX_SELECTED_INDICATOR_COUNT) return '趋势指标最多选择 6 个'
|
||||||
|
if (!statType) return '请选择统计类型'
|
||||||
|
|
||||||
|
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
|
||||||
|
if (harmonicError) return harmonicError
|
||||||
|
|
||||||
|
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statType, harmonicOrders)
|
||||||
|
if (seriesCount > MAX_TREND_SERIES_COUNT) {
|
||||||
|
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围'
|
||||||
|
}
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
977
frontend/src/views/steady/steadyTrend/utils/trendOptions.ts
Normal file
977
frontend/src/views/steady/steadyTrend/utils/trendOptions.ts
Normal file
@@ -0,0 +1,977 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import { resolvePhaseThemeColor } from '@/utils/phaseColors'
|
||||||
|
import { resolveSteadyTrendChartRenderer, type SteadyTrendChartRendererType } from './chartRenderer'
|
||||||
|
|
||||||
|
export interface SteadyTrendChartGroup {
|
||||||
|
key: string
|
||||||
|
title: string
|
||||||
|
group: string
|
||||||
|
renderer: SteadyTrendChartRendererType
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[]
|
||||||
|
options: Record<string, unknown>
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendZoomRange {
|
||||||
|
start: number
|
||||||
|
end: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type SteadyTrendActiveTool = 'none' | 'box-zoom' | 'pan'
|
||||||
|
|
||||||
|
export interface SteadyTrendChartBuildOptions {
|
||||||
|
activeTool?: SteadyTrendActiveTool
|
||||||
|
wheelZoomEnabled?: boolean
|
||||||
|
showMissingData?: boolean
|
||||||
|
yZoomScale?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const STEADY_TREND_CHART_GROUP = 'steady-trend-chart-sync'
|
||||||
|
const STEADY_AXIS_EXPAND_RATIO = 1.15
|
||||||
|
const STEADY_AXIS_SHRINK_RATIO = 0.85
|
||||||
|
const STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE = 1.05
|
||||||
|
const STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE = 0.95
|
||||||
|
const STEADY_AXIS_COMPACT_EXPAND_RATIO = 1.015
|
||||||
|
const STEADY_AXIS_COMPACT_SHRINK_RATIO = 0.985
|
||||||
|
const STEADY_AXIS_COMPACT_RANGE_RATIO = 0.25
|
||||||
|
const STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE = 0.05
|
||||||
|
const STEADY_AXIS_BALANCED_RATIO = 0.9
|
||||||
|
const STEADY_AXIS_DEFAULT_SPLIT_COUNT = 4
|
||||||
|
const STEADY_AXIS_EXTRA_SPLIT_SCORE = 0.25
|
||||||
|
const STEADY_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
|
||||||
|
const STEADY_TREND_GRID_LEFT = '64px'
|
||||||
|
const STEADY_TREND_GRID_RIGHT = '28px'
|
||||||
|
const STEADY_TREND_GRID_TOP = 28
|
||||||
|
const STEADY_TREND_LINE_MAX_WIDTH = 1.3
|
||||||
|
const STEADY_TREND_LARGE_POINT_COUNT = 20000
|
||||||
|
const STEADY_TREND_ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||||
|
const STEADY_AXIS_TEXT_COLOR = 'var(--el-text-color-regular)'
|
||||||
|
const STEADY_AXIS_LINE_COLOR = 'var(--el-border-color)'
|
||||||
|
|
||||||
|
const fallbackTrendColors = [
|
||||||
|
'var(--el-color-primary)',
|
||||||
|
'#00a870',
|
||||||
|
'#e6a23c',
|
||||||
|
'#f56c6c',
|
||||||
|
'#626aef',
|
||||||
|
'#909399',
|
||||||
|
'#14b8a6',
|
||||||
|
'#f97316'
|
||||||
|
]
|
||||||
|
const harmonicLineTypes = ['solid', 'dashed', 'dotted'] as const
|
||||||
|
const harmonicLineOpacities = [1, 0.86, 0.72, 0.58]
|
||||||
|
|
||||||
|
interface ReadableAxisRangeOptions {
|
||||||
|
preferCompact?: boolean
|
||||||
|
compactMin?: number
|
||||||
|
compactMax?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const getUniqueValues = (
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[],
|
||||||
|
getValue: (series: SteadyTrend.SteadyTrendSeries) => string
|
||||||
|
) => {
|
||||||
|
return Array.from(new Set(seriesList.map(getValue).filter(Boolean)))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveGroupMode = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
const lineIds = getUniqueValues(seriesList, series => series.lineId)
|
||||||
|
const indicatorCodes = getUniqueValues(seriesList, series => series.indicatorCode)
|
||||||
|
|
||||||
|
if (lineIds.length === 1 && indicatorCodes.length > 1) return 'indicator'
|
||||||
|
if (lineIds.length > 1 && indicatorCodes.length === 1) return 'line'
|
||||||
|
|
||||||
|
return 'single'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveGroupKey = (series: SteadyTrend.SteadyTrendSeries, groupMode: string) => {
|
||||||
|
if (groupMode === 'indicator') return series.indicatorCode || series.seriesKey
|
||||||
|
if (groupMode === 'line') return series.lineId || series.seriesKey
|
||||||
|
|
||||||
|
return 'steady-trend'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveGroupTitle = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
const firstSeries = seriesList[0]
|
||||||
|
if (!firstSeries) return '趋势图'
|
||||||
|
|
||||||
|
return (
|
||||||
|
[firstSeries.lineName || firstSeries.lineId, firstSeries.indicatorName || firstSeries.seriesName]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join('_') || '趋势图'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const isValidHarmonicOrder = (value: number) => Number.isInteger(value) && value >= 2 && value <= 50
|
||||||
|
|
||||||
|
const parseHarmonicOrderToken = (value: string) => {
|
||||||
|
const orderText =
|
||||||
|
value.match(/^([2-9]|[1-4]\d|50)$/)?.[1] ||
|
||||||
|
value.match(/^[a-z]+_([2-9]|[1-4]\d|50)$/i)?.[1] ||
|
||||||
|
value.match(/([2-9]|[1-4]\d|50)\s*次/)?.[1]
|
||||||
|
const order = Number(orderText)
|
||||||
|
|
||||||
|
return isValidHarmonicOrder(order) ? order : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicOrder = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
if (isValidHarmonicOrder(Number(series.harmonicOrder))) return Number(series.harmonicOrder)
|
||||||
|
|
||||||
|
const harmonicText = [series.indicatorCode, series.indicatorName, series.seriesName, series.seriesKey].join('|')
|
||||||
|
if (!/HARMONIC|谐波/i.test(harmonicText)) return null
|
||||||
|
|
||||||
|
for (const value of [series.seriesName, series.indicatorName].filter(Boolean) as string[]) {
|
||||||
|
const order = parseHarmonicOrderToken(value)
|
||||||
|
if (order) return order
|
||||||
|
}
|
||||||
|
|
||||||
|
const keyTokens = String(series.seriesKey || '').split(/[|_\s-]+/)
|
||||||
|
for (const value of keyTokens.reverse()) {
|
||||||
|
const order = parseHarmonicOrderToken(value)
|
||||||
|
if (order) return order
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPhaseLabel = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const phase = String(series.phase || '').trim()
|
||||||
|
if (!phase) return series.seriesName || series.seriesKey
|
||||||
|
|
||||||
|
return phase.endsWith('相') ? phase : `${phase}相`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvePhaseOrder = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const phaseOrderMap: Record<string, number> = {
|
||||||
|
A: 1,
|
||||||
|
B: 2,
|
||||||
|
C: 3
|
||||||
|
}
|
||||||
|
const phase = String(series.phase || '').replace(/相$/, '').toUpperCase()
|
||||||
|
|
||||||
|
return phaseOrderMap[phase] || 99
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSeriesName = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
const phaseLabel = formatPhaseLabel(series)
|
||||||
|
|
||||||
|
return harmonicOrder ? `${harmonicOrder}次_${phaseLabel}` : phaseLabel
|
||||||
|
}
|
||||||
|
|
||||||
|
const sortSteadyTrendSeries = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
return seriesList
|
||||||
|
.map((series, index) => ({ series, index }))
|
||||||
|
.sort((left, right) => {
|
||||||
|
const leftOrder = resolveHarmonicOrder(left.series)
|
||||||
|
const rightOrder = resolveHarmonicOrder(right.series)
|
||||||
|
|
||||||
|
if (!leftOrder && !rightOrder) return left.index - right.index
|
||||||
|
if (!leftOrder) return 1
|
||||||
|
if (!rightOrder) return -1
|
||||||
|
if (leftOrder !== rightOrder) return leftOrder - rightOrder
|
||||||
|
|
||||||
|
const leftPhaseOrder = resolvePhaseOrder(left.series)
|
||||||
|
const rightPhaseOrder = resolvePhaseOrder(right.series)
|
||||||
|
|
||||||
|
return leftPhaseOrder === rightPhaseOrder ? left.index - right.index : leftPhaseOrder - rightPhaseOrder
|
||||||
|
})
|
||||||
|
.map(item => item.series)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAxisPrecision = (step: number) => {
|
||||||
|
const absStep = Math.abs(step)
|
||||||
|
|
||||||
|
if (!Number.isFinite(absStep) || absStep >= 1) return 0
|
||||||
|
|
||||||
|
const stepText = `${absStep}`
|
||||||
|
if (stepText.includes('e-')) {
|
||||||
|
return Number(stepText.split('e-')[1] || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Math.min(stepText.split('.')[1]?.length || 0, 4)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getAxisBoundaryPrecision = (axisMin: number, axisMax: number, interval: number) => {
|
||||||
|
const boundaryPrecision = Math.max(Math.abs(axisMin), Math.abs(axisMax)) < 1 ? 2 : 0
|
||||||
|
|
||||||
|
return Math.max(getAxisPrecision(interval), boundaryPrecision)
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizeAxisValue = (value: number, precision: number) => {
|
||||||
|
const factor = 10 ** precision
|
||||||
|
const normalizedValue = Math.round(value * factor) / factor
|
||||||
|
return Object.is(normalizedValue, -0) ? 0 : normalizedValue
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadableAxisInterval = (value: number) => {
|
||||||
|
if (!Number.isFinite(value) || value <= 0) return 1
|
||||||
|
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(value))
|
||||||
|
const normalizedValue = value / magnitude
|
||||||
|
const step = STEADY_AXIS_READABLE_INTERVAL_STEPS.find(item => normalizedValue <= item) || 10
|
||||||
|
|
||||||
|
return step * magnitude
|
||||||
|
}
|
||||||
|
|
||||||
|
const roundAxisValueUp = (value: number) => {
|
||||||
|
const absValue = Math.abs(value)
|
||||||
|
|
||||||
|
if (!Number.isFinite(absValue) || absValue === 0) return 0
|
||||||
|
|
||||||
|
const magnitude = 10 ** Math.floor(Math.log10(absValue))
|
||||||
|
const step = magnitude >= 10 ? magnitude / 10 : magnitude
|
||||||
|
const precision = getAxisPrecision(step)
|
||||||
|
|
||||||
|
return normalizeAxisValue(Math.ceil(absValue / step) * step, precision)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatAxisLabel = (value: number, precision: number) => {
|
||||||
|
if (!Number.isFinite(value)) return ''
|
||||||
|
return `${normalizeAxisValue(value, precision)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseSteadyTrendPointTime = (value?: string) => {
|
||||||
|
if (!value) return null
|
||||||
|
|
||||||
|
const timestamp = Date.parse(value.replace(' ', 'T'))
|
||||||
|
|
||||||
|
return Number.isFinite(timestamp) ? timestamp : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const padSteadyTrendTimePart = (value: number) => `${value}`.padStart(2, '0')
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisDateLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
padSteadyTrendTimePart(date.getMonth() + 1),
|
||||||
|
padSteadyTrendTimePart(date.getDate())
|
||||||
|
].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisShortDateLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return [padSteadyTrendTimePart(date.getMonth() + 1), padSteadyTrendTimePart(date.getDate())].join('-')
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisMinuteLabel = (timestamp: number) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return `${formatSteadyTimeAxisDateLabel(timestamp)} ${[
|
||||||
|
padSteadyTrendTimePart(date.getHours()),
|
||||||
|
padSteadyTrendTimePart(date.getMinutes())
|
||||||
|
].join(':')}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisFirstLabel = (label: string) => {
|
||||||
|
return `{first|${label}}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTimeAxisLabelMeta = (timeLabels: string[]) => {
|
||||||
|
const parsedTimeLabels = timeLabels
|
||||||
|
.map((time, index) => ({
|
||||||
|
index,
|
||||||
|
timestamp: parseSteadyTrendPointTime(time)
|
||||||
|
}))
|
||||||
|
.filter((item): item is { index: number; timestamp: number } => item.timestamp !== null)
|
||||||
|
const firstTimestamp = parsedTimeLabels[0]?.timestamp
|
||||||
|
const lastTimestamp = parsedTimeLabels[parsedTimeLabels.length - 1]?.timestamp
|
||||||
|
const firstYear = firstTimestamp === undefined ? undefined : new Date(firstTimestamp).getFullYear()
|
||||||
|
const firstDailyLabelIndexSet = new Set<number>()
|
||||||
|
const seenDateSet = new Set<string>()
|
||||||
|
|
||||||
|
parsedTimeLabels.forEach(item => {
|
||||||
|
const dateLabel = formatSteadyTimeAxisDateLabel(item.timestamp)
|
||||||
|
if (seenDateSet.has(dateLabel)) return
|
||||||
|
|
||||||
|
seenDateSet.add(dateLabel)
|
||||||
|
firstDailyLabelIndexSet.add(item.index)
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
firstDailyLabelIndexSet,
|
||||||
|
firstYear,
|
||||||
|
isRangeOverOneDay:
|
||||||
|
firstTimestamp !== undefined && lastTimestamp !== undefined && lastTimestamp - firstTimestamp > STEADY_TREND_ONE_DAY_MS
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTimeAxisDailyLabel = (timestamp: number, labelMeta: ReturnType<typeof resolveSteadyTimeAxisLabelMeta>) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
|
||||||
|
return date.getFullYear() === labelMeta.firstYear
|
||||||
|
? formatSteadyTimeAxisShortDateLabel(timestamp)
|
||||||
|
: formatSteadyTimeAxisDateLabel(timestamp)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSteadyTimeAxisLabelFormatter = (timeLabels: string[]) => {
|
||||||
|
const lastIndex = timeLabels.length - 1
|
||||||
|
const labelMeta = resolveSteadyTimeAxisLabelMeta(timeLabels)
|
||||||
|
|
||||||
|
return (value: string | number, index: number) => {
|
||||||
|
const timestamp = parseSteadyTrendPointTime(`${value}`)
|
||||||
|
|
||||||
|
if (labelMeta.isRangeOverOneDay) {
|
||||||
|
if (!labelMeta.firstDailyLabelIndexSet.has(index)) return ''
|
||||||
|
const label = timestamp === null ? `${value}` : formatSteadyTimeAxisDailyLabel(timestamp, labelMeta)
|
||||||
|
|
||||||
|
return index === 0 ? formatSteadyTimeAxisFirstLabel(timestamp === null ? label : formatSteadyTimeAxisDateLabel(timestamp)) : label
|
||||||
|
}
|
||||||
|
|
||||||
|
if (index !== 0 && index !== lastIndex) return ''
|
||||||
|
const label = timestamp === null ? `${value}` : formatSteadyTimeAxisMinuteLabel(timestamp)
|
||||||
|
|
||||||
|
return index === 0 ? formatSteadyTimeAxisFirstLabel(label) : label
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTrendPointTime = (timestamp: number, sampleTime: string) => {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
const separator = sampleTime.includes('T') ? 'T' : ' '
|
||||||
|
|
||||||
|
return [
|
||||||
|
date.getFullYear(),
|
||||||
|
padSteadyTrendTimePart(date.getMonth() + 1),
|
||||||
|
padSteadyTrendTimePart(date.getDate())
|
||||||
|
].join('-') +
|
||||||
|
separator +
|
||||||
|
[
|
||||||
|
padSteadyTrendTimePart(date.getHours()),
|
||||||
|
padSteadyTrendTimePart(date.getMinutes()),
|
||||||
|
padSteadyTrendTimePart(date.getSeconds())
|
||||||
|
].join(':')
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendPointIntervalMs = (points: SteadyTrend.SteadyTrendPoint[]) => {
|
||||||
|
const timestamps = Array.from(
|
||||||
|
new Set(
|
||||||
|
points
|
||||||
|
.map(point => parseSteadyTrendPointTime(point.time))
|
||||||
|
.filter((timestamp): timestamp is number => timestamp !== null)
|
||||||
|
)
|
||||||
|
).sort((left, right) => left - right)
|
||||||
|
const gaps = timestamps
|
||||||
|
.slice(1)
|
||||||
|
.map((timestamp, index) => timestamp - timestamps[index])
|
||||||
|
.filter(gap => gap > 0)
|
||||||
|
|
||||||
|
return gaps.length ? Math.min(...gaps) : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
const fillSteadyTrendMissingPoints = (seriesList: SteadyTrend.SteadyTrendSeries[]) => {
|
||||||
|
return seriesList.map(series => {
|
||||||
|
const points = series.points || []
|
||||||
|
const intervalMs = resolveSteadyTrendPointIntervalMs(points)
|
||||||
|
|
||||||
|
if (points.length < 2 || intervalMs <= 0) return series
|
||||||
|
|
||||||
|
const pointEntries = points
|
||||||
|
.map(point => ({
|
||||||
|
timestamp: parseSteadyTrendPointTime(point.time),
|
||||||
|
point
|
||||||
|
}))
|
||||||
|
.filter((item): item is { timestamp: number; point: SteadyTrend.SteadyTrendPoint } => item.timestamp !== null)
|
||||||
|
.sort((left, right) => left.timestamp - right.timestamp)
|
||||||
|
|
||||||
|
if (pointEntries.length < 2) return series
|
||||||
|
|
||||||
|
const pointMap = new Map(pointEntries.map(item => [item.timestamp, item.point.value]))
|
||||||
|
const firstTimestamp = pointEntries[0].timestamp
|
||||||
|
const lastTimestamp = pointEntries[pointEntries.length - 1].timestamp
|
||||||
|
const sampleTime = pointEntries[0].point.time
|
||||||
|
const filledPoints: SteadyTrend.SteadyTrendPoint[] = []
|
||||||
|
|
||||||
|
// 缺失数据只在用户点亮按钮后补齐,默认不走该路径以避免放大点数和渲染成本。
|
||||||
|
for (let timestamp = firstTimestamp; timestamp <= lastTimestamp; timestamp += intervalMs) {
|
||||||
|
filledPoints.push({
|
||||||
|
time: formatSteadyTrendPointTime(timestamp, sampleTime),
|
||||||
|
value: pointMap.has(timestamp) ? pointMap.get(timestamp)! : null
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...series,
|
||||||
|
points: filledPoints
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const getReadableAxisIntervalCandidates = (value: number) => {
|
||||||
|
return Array.from(new Set([getReadableAxisInterval(value), getReadableAxisInterval(value / 2)])).filter(
|
||||||
|
item => Number.isFinite(item) && item > 0
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildReadableAxisRangeCandidate = (
|
||||||
|
readableMin: number,
|
||||||
|
readableMax: number,
|
||||||
|
interval: number,
|
||||||
|
currentSplitCount: number,
|
||||||
|
baseSplitCount: number,
|
||||||
|
coverMin: number,
|
||||||
|
coverMax: number,
|
||||||
|
scoreRange: number,
|
||||||
|
extraSplitScore = STEADY_AXIS_EXTRA_SPLIT_SCORE
|
||||||
|
) => {
|
||||||
|
const precision = getAxisPrecision(interval)
|
||||||
|
const normalizedMin = normalizeAxisValue(readableMin, precision)
|
||||||
|
const normalizedMax = normalizeAxisValue(readableMax, precision)
|
||||||
|
const normalizedInterval = normalizeAxisValue(interval, precision)
|
||||||
|
const candidateRange = normalizedMax - normalizedMin
|
||||||
|
const epsilon = Math.max(Math.abs(normalizedInterval), 1) * 1e-10
|
||||||
|
|
||||||
|
if (normalizedMin - coverMin > epsilon || coverMax - normalizedMax > epsilon || candidateRange <= 0) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const extraSplitCost = Math.max(currentSplitCount - baseSplitCount, 0) * extraSplitScore
|
||||||
|
const wasteRatio = scoreRange > 0 ? Math.max((candidateRange - scoreRange) / scoreRange, 0) : 0
|
||||||
|
|
||||||
|
return {
|
||||||
|
axisMin: normalizedMin,
|
||||||
|
axisMax: normalizedMax,
|
||||||
|
interval: normalizedInterval,
|
||||||
|
splitCount: currentSplitCount,
|
||||||
|
score: getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveCenteredReadableAxisRange = (
|
||||||
|
coverMin: number,
|
||||||
|
coverMax: number,
|
||||||
|
interval: number,
|
||||||
|
currentSplitCount: number,
|
||||||
|
baseSplitCount: number,
|
||||||
|
scoreRange: number,
|
||||||
|
extraSplitScore = STEADY_AXIS_EXTRA_SPLIT_SCORE
|
||||||
|
) => {
|
||||||
|
const candidateRange = interval * currentSplitCount
|
||||||
|
const coverRange = coverMax - coverMin
|
||||||
|
|
||||||
|
if (!Number.isFinite(candidateRange) || candidateRange < coverRange) return null
|
||||||
|
|
||||||
|
const center = (coverMin + coverMax) / 2
|
||||||
|
let readableMin = Math.floor((center - candidateRange / 2) / interval) * interval
|
||||||
|
let readableMax = readableMin + candidateRange
|
||||||
|
|
||||||
|
if (readableMin > coverMin) {
|
||||||
|
const shiftCount = Math.ceil((readableMin - coverMin) / interval)
|
||||||
|
readableMin -= shiftCount * interval
|
||||||
|
readableMax -= shiftCount * interval
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readableMax < coverMax) {
|
||||||
|
const shiftCount = Math.ceil((coverMax - readableMax) / interval)
|
||||||
|
readableMin += shiftCount * interval
|
||||||
|
readableMax += shiftCount * interval
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildReadableAxisRangeCandidate(
|
||||||
|
readableMin,
|
||||||
|
readableMax,
|
||||||
|
interval,
|
||||||
|
currentSplitCount,
|
||||||
|
baseSplitCount,
|
||||||
|
coverMin,
|
||||||
|
coverMax,
|
||||||
|
scoreRange,
|
||||||
|
extraSplitScore
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveReadableAxisRange = (
|
||||||
|
axisMin: number,
|
||||||
|
axisMax: number,
|
||||||
|
splitCount: number,
|
||||||
|
options: ReadableAxisRangeOptions = {}
|
||||||
|
) => {
|
||||||
|
const axisRange = axisMax - axisMin
|
||||||
|
const rawInterval = axisRange / splitCount
|
||||||
|
|
||||||
|
if (!Number.isFinite(axisRange) || axisRange <= 0) {
|
||||||
|
return {
|
||||||
|
axisMin,
|
||||||
|
axisMax,
|
||||||
|
interval: rawInterval,
|
||||||
|
splitCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const splitCountCandidates =
|
||||||
|
splitCount >= STEADY_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
|
||||||
|
const compactMin = Number(options.compactMin)
|
||||||
|
const compactMax = Number(options.compactMax)
|
||||||
|
const canUseCompact =
|
||||||
|
options.preferCompact && Number.isFinite(compactMin) && Number.isFinite(compactMax) && compactMax > compactMin
|
||||||
|
const scoreRange = canUseCompact ? compactMax - compactMin : axisRange
|
||||||
|
const candidates = splitCountCandidates.reduce<
|
||||||
|
Array<{
|
||||||
|
axisMin: number
|
||||||
|
axisMax: number
|
||||||
|
interval: number
|
||||||
|
splitCount: number
|
||||||
|
score: number
|
||||||
|
}>
|
||||||
|
>((result, currentSplitCount) => {
|
||||||
|
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
|
||||||
|
let readableMin = axisMin
|
||||||
|
let readableMax = axisMax
|
||||||
|
|
||||||
|
for (let index = 0; index < STEADY_AXIS_READABLE_INTERVAL_STEPS.length; index += 1) {
|
||||||
|
readableMin = Math.floor(axisMin / interval) * interval
|
||||||
|
readableMax = readableMin + interval * currentSplitCount
|
||||||
|
|
||||||
|
if (readableMax < axisMax) {
|
||||||
|
readableMax = Math.ceil(axisMax / interval) * interval
|
||||||
|
readableMin = readableMax - interval * currentSplitCount
|
||||||
|
}
|
||||||
|
|
||||||
|
if (readableMin <= axisMin && readableMax >= axisMax) break
|
||||||
|
|
||||||
|
interval = getReadableAxisInterval(interval * 1.01)
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidate = buildReadableAxisRangeCandidate(
|
||||||
|
readableMin,
|
||||||
|
readableMax,
|
||||||
|
interval,
|
||||||
|
currentSplitCount,
|
||||||
|
splitCount,
|
||||||
|
axisMin,
|
||||||
|
axisMax,
|
||||||
|
scoreRange
|
||||||
|
)
|
||||||
|
if (candidate) result.push(candidate)
|
||||||
|
|
||||||
|
return result
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
if (canUseCompact) {
|
||||||
|
const compactSplitCountCandidates = [splitCount + 2, splitCount + 3, splitCount + 4]
|
||||||
|
|
||||||
|
compactSplitCountCandidates.forEach(currentSplitCount => {
|
||||||
|
getReadableAxisIntervalCandidates(axisRange / currentSplitCount).forEach(interval => {
|
||||||
|
const candidate = resolveCenteredReadableAxisRange(
|
||||||
|
compactMin,
|
||||||
|
compactMax,
|
||||||
|
interval,
|
||||||
|
currentSplitCount,
|
||||||
|
splitCount,
|
||||||
|
scoreRange,
|
||||||
|
STEADY_AXIS_COMPACT_EXTRA_SPLIT_SCORE
|
||||||
|
)
|
||||||
|
if (candidate) candidates.push(candidate)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return candidates.reduce(
|
||||||
|
(currentBest, candidate) => (candidate.score < currentBest.score ? candidate : currentBest),
|
||||||
|
{
|
||||||
|
axisMin,
|
||||||
|
axisMax,
|
||||||
|
interval: rawInterval,
|
||||||
|
splitCount,
|
||||||
|
score: Number.POSITIVE_INFINITY
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
|
||||||
|
if (value === 0) return 0
|
||||||
|
|
||||||
|
const useAboveOneRatio = Math.abs(value) > 1
|
||||||
|
const expandRatio = useAboveOneRatio ? STEADY_AXIS_EXPAND_RATIO_ABOVE_ONE : STEADY_AXIS_EXPAND_RATIO
|
||||||
|
const shrinkRatio = useAboveOneRatio ? STEADY_AXIS_SHRINK_RATIO_ABOVE_ONE : STEADY_AXIS_SHRINK_RATIO
|
||||||
|
const ratio = isMin ? (value < 0 ? expandRatio : shrinkRatio) : value > 0 ? expandRatio : shrinkRatio
|
||||||
|
|
||||||
|
return value * ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveCompactAxisBoundary = (value: number, isMin: boolean) => {
|
||||||
|
if (value === 0) return 0
|
||||||
|
|
||||||
|
const ratio = isMin
|
||||||
|
? value < 0
|
||||||
|
? STEADY_AXIS_COMPACT_EXPAND_RATIO
|
||||||
|
: STEADY_AXIS_COMPACT_SHRINK_RATIO
|
||||||
|
: value > 0
|
||||||
|
? STEADY_AXIS_COMPACT_EXPAND_RATIO
|
||||||
|
: STEADY_AXIS_COMPACT_SHRINK_RATIO
|
||||||
|
|
||||||
|
return value * ratio
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
|
||||||
|
if (min >= 0 || max <= 0) return false
|
||||||
|
|
||||||
|
const minAbs = Math.abs(min)
|
||||||
|
const maxAbs = Math.abs(max)
|
||||||
|
const smallerAbs = Math.min(minAbs, maxAbs)
|
||||||
|
const largerAbs = Math.max(minAbs, maxAbs)
|
||||||
|
|
||||||
|
return largerAbs > 0 && smallerAbs / largerAbs >= STEADY_AXIS_BALANCED_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
|
const shouldUseCompactReadableAxisRange = (min: number, max: number) => {
|
||||||
|
const dataRange = max - min
|
||||||
|
const maxAbs = Math.max(Math.abs(min), Math.abs(max))
|
||||||
|
|
||||||
|
return maxAbs > 1 && dataRange > 0 && dataRange / maxAbs <= STEADY_AXIS_COMPACT_RANGE_RATIO
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildSteadyTrendAxisConfig = (values: number[], unit = '') => {
|
||||||
|
if (!values.length) {
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
name: unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const min = Math.min(...values)
|
||||||
|
const max = Math.max(...values)
|
||||||
|
let axisMin = resolveExpandedAxisBoundary(min, true)
|
||||||
|
let axisMax = resolveExpandedAxisBoundary(max, false)
|
||||||
|
|
||||||
|
if (shouldUseBalancedAxisBoundary(min, max)) {
|
||||||
|
const axisBoundary = roundAxisValueUp(Math.max(Math.abs(axisMin), Math.abs(axisMax)))
|
||||||
|
axisMin = -axisBoundary
|
||||||
|
axisMax = axisBoundary
|
||||||
|
}
|
||||||
|
|
||||||
|
if (axisMin === axisMax) {
|
||||||
|
const fallbackBoundary = Math.max(Math.abs(axisMin), 1) * STEADY_AXIS_EXPAND_RATIO
|
||||||
|
axisMin -= fallbackBoundary
|
||||||
|
axisMax += fallbackBoundary
|
||||||
|
}
|
||||||
|
|
||||||
|
const useCompactReadableAxisRange = shouldUseCompactReadableAxisRange(min, max)
|
||||||
|
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, STEADY_AXIS_DEFAULT_SPLIT_COUNT, {
|
||||||
|
preferCompact: useCompactReadableAxisRange,
|
||||||
|
compactMin: useCompactReadableAxisRange ? resolveCompactAxisBoundary(min, true) : undefined,
|
||||||
|
compactMax: useCompactReadableAxisRange ? resolveCompactAxisBoundary(max, false) : undefined
|
||||||
|
})
|
||||||
|
const precision = getAxisBoundaryPrecision(
|
||||||
|
readableAxisRange.axisMin,
|
||||||
|
readableAxisRange.axisMax,
|
||||||
|
readableAxisRange.interval
|
||||||
|
)
|
||||||
|
const normalizedInterval = normalizeAxisValue(readableAxisRange.interval, precision)
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: 'value',
|
||||||
|
name: unit,
|
||||||
|
nameLocation: 'middle',
|
||||||
|
nameGap: 42,
|
||||||
|
nameTextStyle: {
|
||||||
|
color: STEADY_AXIS_TEXT_COLOR,
|
||||||
|
fontSize: 12,
|
||||||
|
align: 'center'
|
||||||
|
},
|
||||||
|
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
|
||||||
|
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
|
||||||
|
interval: normalizedInterval,
|
||||||
|
minInterval: normalizedInterval,
|
||||||
|
maxInterval: normalizedInterval,
|
||||||
|
splitNumber: readableAxisRange.splitCount,
|
||||||
|
axisLabel: {
|
||||||
|
showMinLabel: true,
|
||||||
|
showMaxLabel: true,
|
||||||
|
hideOverlap: true,
|
||||||
|
formatter: (value: number) => formatAxisLabel(value, precision)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const applySteadyYAxisZoom = (yAxisConfig: Record<string, unknown>, yZoomScale = 1) => {
|
||||||
|
const axisMin = Number(yAxisConfig.min)
|
||||||
|
const axisMax = Number(yAxisConfig.max)
|
||||||
|
const scale = Number(yZoomScale)
|
||||||
|
|
||||||
|
if (!Number.isFinite(axisMin) || !Number.isFinite(axisMax) || axisMin === axisMax || scale === 1) {
|
||||||
|
return yAxisConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
const center = (axisMin + axisMax) / 2
|
||||||
|
const halfRange = ((axisMax - axisMin) * scale) / 2
|
||||||
|
const nextMin = center - halfRange
|
||||||
|
const nextMax = center + halfRange
|
||||||
|
const splitNumber = Math.max(Math.round(Number(yAxisConfig.splitNumber) || STEADY_AXIS_DEFAULT_SPLIT_COUNT), 1)
|
||||||
|
const readableAxisRange = resolveReadableAxisRange(nextMin, nextMax, splitNumber)
|
||||||
|
const precision = getAxisBoundaryPrecision(
|
||||||
|
readableAxisRange.axisMin,
|
||||||
|
readableAxisRange.axisMax,
|
||||||
|
readableAxisRange.interval
|
||||||
|
)
|
||||||
|
const normalizedInterval = normalizeAxisValue(readableAxisRange.interval, precision)
|
||||||
|
|
||||||
|
return {
|
||||||
|
...yAxisConfig,
|
||||||
|
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
|
||||||
|
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
|
||||||
|
interval: normalizedInterval,
|
||||||
|
minInterval: normalizedInterval,
|
||||||
|
maxInterval: normalizedInterval,
|
||||||
|
splitNumber: readableAxisRange.splitCount
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendLineWidth = (pointCount: number) => {
|
||||||
|
if (pointCount >= 200000) return 0.35
|
||||||
|
if (pointCount >= 100000) return 0.45
|
||||||
|
if (pointCount >= 50000) return 0.55
|
||||||
|
if (pointCount >= 20000) return 0.65
|
||||||
|
if (pointCount >= 10000) return 0.75
|
||||||
|
if (pointCount >= 5000) return 0.9
|
||||||
|
if (pointCount >= 2000) return 1
|
||||||
|
if (pointCount >= 800) return 1.1
|
||||||
|
if (pointCount >= 200) return 1.2
|
||||||
|
|
||||||
|
return STEADY_TREND_LINE_MAX_WIDTH
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendVisiblePointCount = (pointCount: number, zoomRange: SteadyTrendZoomRange) => {
|
||||||
|
const visibleRatio = Math.max((zoomRange.end - zoomRange.start) / 100, 0)
|
||||||
|
|
||||||
|
return Math.ceil(pointCount * visibleRatio)
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSeriesColor = (series: SteadyTrend.SteadyTrendSeries, index: number) => {
|
||||||
|
const phaseColor = resolvePhaseThemeColor(series.phase || '')
|
||||||
|
|
||||||
|
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicLineType = (series: SteadyTrend.SteadyTrendSeries, pointCount: number) => {
|
||||||
|
if (pointCount >= STEADY_TREND_LARGE_POINT_COUNT) return 'solid'
|
||||||
|
|
||||||
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
if (!harmonicOrder) return 'solid'
|
||||||
|
|
||||||
|
return harmonicLineTypes[(harmonicOrder - 2) % harmonicLineTypes.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveHarmonicLineOpacity = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const harmonicOrder = resolveHarmonicOrder(series)
|
||||||
|
if (!harmonicOrder) return 1
|
||||||
|
|
||||||
|
return harmonicLineOpacities[Math.floor((harmonicOrder - 2) / harmonicLineTypes.length) % harmonicLineOpacities.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveSteadyTrendSampling = (pointCount: number) =>
|
||||||
|
pointCount >= STEADY_TREND_LARGE_POINT_COUNT ? 'lttb' : undefined
|
||||||
|
|
||||||
|
export const buildSteadyTrendChartOptions = (
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[],
|
||||||
|
zoomRange: SteadyTrendZoomRange,
|
||||||
|
showTimeAxis = true,
|
||||||
|
chartOptions: SteadyTrendChartBuildOptions = {},
|
||||||
|
chartTitle = ''
|
||||||
|
): Record<string, unknown> => {
|
||||||
|
const displaySeriesList =
|
||||||
|
chartOptions.showMissingData === true ? fillSteadyTrendMissingPoints(seriesList) : seriesList
|
||||||
|
const sortedSeriesList = sortSteadyTrendSeries(displaySeriesList)
|
||||||
|
const timeLabels = Array.from(
|
||||||
|
new Set(displaySeriesList.flatMap(series => (series.points || []).map(point => point.time)))
|
||||||
|
).sort()
|
||||||
|
const values = displaySeriesList.flatMap(series =>
|
||||||
|
(series.points || [])
|
||||||
|
.map(point => point.value)
|
||||||
|
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
|
||||||
|
)
|
||||||
|
const unit = displaySeriesList.find(series => series.unit)?.unit || ''
|
||||||
|
|
||||||
|
return {
|
||||||
|
activeTool: chartOptions.activeTool || 'none',
|
||||||
|
title: {
|
||||||
|
text: chartTitle,
|
||||||
|
left: 'center',
|
||||||
|
top: 0,
|
||||||
|
textStyle: {
|
||||||
|
color: '#303133',
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: 600
|
||||||
|
}
|
||||||
|
},
|
||||||
|
tooltip: {
|
||||||
|
trigger: 'axis',
|
||||||
|
showDelay: 80,
|
||||||
|
hideDelay: 80,
|
||||||
|
transitionDuration: 0,
|
||||||
|
axisPointer: {
|
||||||
|
type: 'line',
|
||||||
|
snap: true,
|
||||||
|
lineStyle: {
|
||||||
|
color: 'rgba(24, 144, 255, 0.55)',
|
||||||
|
width: 1
|
||||||
|
},
|
||||||
|
label: {
|
||||||
|
show: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
legend: {
|
||||||
|
type: 'scroll',
|
||||||
|
top: 0,
|
||||||
|
right: 12
|
||||||
|
},
|
||||||
|
grid: {
|
||||||
|
top: STEADY_TREND_GRID_TOP,
|
||||||
|
left: STEADY_TREND_GRID_LEFT,
|
||||||
|
right: STEADY_TREND_GRID_RIGHT,
|
||||||
|
bottom: showTimeAxis ? 40 : 8,
|
||||||
|
containLabel: false
|
||||||
|
},
|
||||||
|
xAxis: {
|
||||||
|
type: 'category',
|
||||||
|
data: timeLabels,
|
||||||
|
boundaryGap: false,
|
||||||
|
axisLine: {
|
||||||
|
show: false,
|
||||||
|
onZero: false,
|
||||||
|
lineStyle: {
|
||||||
|
color: STEADY_AXIS_LINE_COLOR
|
||||||
|
}
|
||||||
|
},
|
||||||
|
axisLabel: {
|
||||||
|
show: showTimeAxis,
|
||||||
|
hideOverlap: true,
|
||||||
|
showMinLabel: true,
|
||||||
|
showMaxLabel: false,
|
||||||
|
interval: 0,
|
||||||
|
margin: showTimeAxis ? 16 : 0,
|
||||||
|
alignMinLabel: 'left',
|
||||||
|
alignMaxLabel: 'right',
|
||||||
|
width: 72,
|
||||||
|
overflow: 'truncate',
|
||||||
|
color: STEADY_AXIS_TEXT_COLOR,
|
||||||
|
rich: {
|
||||||
|
first: {
|
||||||
|
padding: [0, 0, 0, 6]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
formatter: buildSteadyTimeAxisLabelFormatter(timeLabels)
|
||||||
|
},
|
||||||
|
axisTick: {
|
||||||
|
show: false,
|
||||||
|
lineStyle: {
|
||||||
|
color: STEADY_AXIS_LINE_COLOR
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
yAxis: applySteadyYAxisZoom(buildSteadyTrendAxisConfig(values, unit), chartOptions.yZoomScale),
|
||||||
|
dataZoom: [
|
||||||
|
{
|
||||||
|
type: 'inside',
|
||||||
|
start: zoomRange.start,
|
||||||
|
end: zoomRange.end,
|
||||||
|
zoomOnMouseWheel: chartOptions.wheelZoomEnabled === true,
|
||||||
|
moveOnMouseWheel: false
|
||||||
|
},
|
||||||
|
{
|
||||||
|
start: zoomRange.start,
|
||||||
|
height: 13,
|
||||||
|
handleSize: '300%',
|
||||||
|
realtime: false,
|
||||||
|
cursor: 'pointer',
|
||||||
|
handleStyle: {
|
||||||
|
color: '#f5f9ff',
|
||||||
|
borderColor: '#9bbcf3',
|
||||||
|
borderWidth: 1
|
||||||
|
},
|
||||||
|
emphasis: {
|
||||||
|
handleStyle: {
|
||||||
|
color: '#e8f2ff',
|
||||||
|
borderColor: '#409eff',
|
||||||
|
borderWidth: 2,
|
||||||
|
shadowBlur: 6,
|
||||||
|
shadowColor: 'rgba(64, 158, 255, 0.35)'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
bottom: '20px',
|
||||||
|
end: zoomRange.end
|
||||||
|
}
|
||||||
|
],
|
||||||
|
toolbox: {
|
||||||
|
// 外部工具栏通过 takeGlobalCursor 激活框选放大,ECharts 仍需要注册 toolbox.dataZoom 才会创建框选控制器。
|
||||||
|
show: true,
|
||||||
|
showTitle: false,
|
||||||
|
itemSize: 0,
|
||||||
|
itemGap: 0,
|
||||||
|
left: -100,
|
||||||
|
top: -100,
|
||||||
|
feature: {
|
||||||
|
dataZoom: {
|
||||||
|
yAxisIndex: 'none',
|
||||||
|
brushStyle: {
|
||||||
|
color: 'rgba(64, 158, 255, 0.18)',
|
||||||
|
borderColor: 'rgba(64, 158, 255, 0.65)',
|
||||||
|
borderWidth: 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
color: sortedSeriesList.map(resolveSeriesColor),
|
||||||
|
series: sortedSeriesList.map((series, index) => {
|
||||||
|
const pointCount = series.points?.length || 0
|
||||||
|
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
|
||||||
|
|
||||||
|
return {
|
||||||
|
name: formatSeriesName(series),
|
||||||
|
type: 'line',
|
||||||
|
animation: false,
|
||||||
|
sampling: resolveSteadyTrendSampling(pointCount),
|
||||||
|
showSymbol: false,
|
||||||
|
connectNulls: false,
|
||||||
|
data: timeLabels.map(time => pointMap.get(time) ?? null),
|
||||||
|
lineStyle: {
|
||||||
|
width: resolveSteadyTrendLineWidth(
|
||||||
|
resolveSteadyTrendVisiblePointCount(pointCount, zoomRange)
|
||||||
|
),
|
||||||
|
type: resolveHarmonicLineType(series, pointCount),
|
||||||
|
opacity: resolveHarmonicLineOpacity(series),
|
||||||
|
color: resolveSeriesColor(series, index)
|
||||||
|
},
|
||||||
|
itemStyle: {
|
||||||
|
color: resolveSeriesColor(series, index)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendChartGroups = (
|
||||||
|
seriesList: SteadyTrend.SteadyTrendSeries[],
|
||||||
|
zoomRange: SteadyTrendZoomRange,
|
||||||
|
chartOptions: SteadyTrendChartBuildOptions = {}
|
||||||
|
): SteadyTrendChartGroup[] => {
|
||||||
|
if (!seriesList.length) return []
|
||||||
|
|
||||||
|
const groupMode = resolveGroupMode(seriesList)
|
||||||
|
const groupMap = new Map<string, SteadyTrend.SteadyTrendSeries[]>()
|
||||||
|
|
||||||
|
seriesList.forEach(series => {
|
||||||
|
const key = resolveGroupKey(series, groupMode)
|
||||||
|
const currentGroup = groupMap.get(key) || []
|
||||||
|
currentGroup.push(series)
|
||||||
|
groupMap.set(key, currentGroup)
|
||||||
|
})
|
||||||
|
|
||||||
|
const groupEntries = Array.from(groupMap.entries())
|
||||||
|
|
||||||
|
return groupEntries.map(([key, groupSeries], index) => {
|
||||||
|
const isLastChart = index === groupEntries.length - 1
|
||||||
|
const groupTitle = resolveGroupTitle(groupSeries)
|
||||||
|
|
||||||
|
return {
|
||||||
|
key,
|
||||||
|
title: groupTitle,
|
||||||
|
group: STEADY_TREND_CHART_GROUP,
|
||||||
|
renderer: resolveSteadyTrendChartRenderer(groupSeries),
|
||||||
|
seriesList: groupSeries,
|
||||||
|
options: buildSteadyTrendChartOptions(groupSeries, zoomRange, isLastChart, chartOptions, groupTitle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
47
frontend/src/views/steady/steadyTrend/utils/trendPayload.ts
Normal file
47
frontend/src/views/steady/steadyTrend/utils/trendPayload.ts
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
|
||||||
|
|
||||||
|
export interface SteadyTrendFormState {
|
||||||
|
timeRange: string[]
|
||||||
|
timeUnit: TimePeriodUnit
|
||||||
|
timeBaseDate: Date
|
||||||
|
statType: SteadyTrend.SteadyTrendStatType
|
||||||
|
qualityFlag?: number
|
||||||
|
harmonicOrders: number[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const DEFAULT_HARMONIC_ORDERS = [2]
|
||||||
|
|
||||||
|
export const defaultTrendFormState = (): SteadyTrendFormState => {
|
||||||
|
const baseDate = new Date()
|
||||||
|
|
||||||
|
return {
|
||||||
|
timeRange: buildTimePeriodRange('month', baseDate),
|
||||||
|
timeUnit: 'month',
|
||||||
|
timeBaseDate: baseDate,
|
||||||
|
statType: 'AVG',
|
||||||
|
qualityFlag: 0,
|
||||||
|
harmonicOrders: [...DEFAULT_HARMONIC_ORDERS]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatSteadyTrendQueryTime = (value: string) => {
|
||||||
|
// 后端趋势接口只接受 yyyy-MM-dd HH:mm:ss,公共时间组件生成的毫秒需要在入参层收敛。
|
||||||
|
return value.replace(/\.[^.]+$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendQueryPayload = (
|
||||||
|
lineIds: string[],
|
||||||
|
indicators: SteadyTrend.SteadyIndicatorNode[],
|
||||||
|
formState: SteadyTrendFormState
|
||||||
|
): SteadyTrend.SteadyTrendQueryParams => {
|
||||||
|
return {
|
||||||
|
lineIds,
|
||||||
|
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
|
||||||
|
statTypes: [formState.statType],
|
||||||
|
timeStart: formatSteadyTrendQueryTime(formState.timeRange[0] || ''),
|
||||||
|
timeEnd: formatSteadyTrendQueryTime(formState.timeRange[1] || ''),
|
||||||
|
qualityFlag: formState.qualityFlag,
|
||||||
|
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
223
frontend/src/views/steady/steadyTrend/utils/trendTable.ts
Normal file
223
frontend/src/views/steady/steadyTrend/utils/trendTable.ts
Normal file
@@ -0,0 +1,223 @@
|
|||||||
|
import type { SteadyTrend } from '@/api/steady/steadyTrend/interface'
|
||||||
|
|
||||||
|
export interface SteadyTrendTableColumn {
|
||||||
|
prop: string
|
||||||
|
label: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendTableIndicatorGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
columns: SteadyTrendTableColumn[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendTableLineGroup {
|
||||||
|
key: string
|
||||||
|
label: string
|
||||||
|
indicatorGroups: SteadyTrendTableIndicatorGroup[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SteadyTrendTableModel {
|
||||||
|
lineGroups: SteadyTrendTableLineGroup[]
|
||||||
|
columns: SteadyTrendTableColumn[]
|
||||||
|
timeValues: string[]
|
||||||
|
pointValueMap: Map<string, Map<string, number | null>>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const createEmptySteadyTrendTableModel = (): SteadyTrendTableModel => ({
|
||||||
|
lineGroups: [],
|
||||||
|
columns: [],
|
||||||
|
timeValues: [],
|
||||||
|
pointValueMap: new Map()
|
||||||
|
})
|
||||||
|
|
||||||
|
const resolvePhaseLabel = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const phase = String(series.phase || '').trim()
|
||||||
|
|
||||||
|
if (!phase) return series.seriesName || series.seriesKey
|
||||||
|
if (phase.endsWith('相')) return phase
|
||||||
|
|
||||||
|
return `${phase}相`
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveLineLabel = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
return series.lineName || series.lineId || '监测点'
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveIndicatorLabel = (series: SteadyTrend.SteadyTrendSeries) => {
|
||||||
|
const indicatorName = series.indicatorName || series.seriesName || series.indicatorCode || '指标'
|
||||||
|
const unit = String(series.unit || '').trim()
|
||||||
|
|
||||||
|
return unit ? `${indicatorName}(${unit})` : indicatorName
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolveColumnProp = (series: SteadyTrend.SteadyTrendSeries, index: number) => {
|
||||||
|
return (
|
||||||
|
series.seriesKey || `${series.lineId || 'line'}_${series.indicatorCode || 'indicator'}_${series.phase || index}`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendTableModel = (seriesList: SteadyTrend.SteadyTrendSeries[]): SteadyTrendTableModel => {
|
||||||
|
const lineGroupMap = new Map<
|
||||||
|
string,
|
||||||
|
SteadyTrendTableLineGroup & {
|
||||||
|
indicatorGroupMap: Map<string, SteadyTrendTableIndicatorGroup>
|
||||||
|
}
|
||||||
|
>()
|
||||||
|
const columns: SteadyTrendTableColumn[] = []
|
||||||
|
const pointValueMap = new Map<string, Map<string, number | null>>()
|
||||||
|
const timeSet = new Set<string>()
|
||||||
|
|
||||||
|
seriesList.forEach((series, index) => {
|
||||||
|
const lineKey = series.lineId || series.lineName || `line-${index}`
|
||||||
|
const indicatorKey = `${lineKey}__${series.indicatorCode || series.indicatorName || series.seriesName || index}`
|
||||||
|
const columnProp = resolveColumnProp(series, index)
|
||||||
|
|
||||||
|
if (!lineGroupMap.has(lineKey)) {
|
||||||
|
lineGroupMap.set(lineKey, {
|
||||||
|
key: lineKey,
|
||||||
|
label: resolveLineLabel(series),
|
||||||
|
indicatorGroups: [],
|
||||||
|
indicatorGroupMap: new Map()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const lineGroup = lineGroupMap.get(lineKey)!
|
||||||
|
|
||||||
|
if (!lineGroup.indicatorGroupMap.has(indicatorKey)) {
|
||||||
|
const indicatorGroup = {
|
||||||
|
key: indicatorKey,
|
||||||
|
label: resolveIndicatorLabel(series),
|
||||||
|
columns: []
|
||||||
|
}
|
||||||
|
|
||||||
|
lineGroup.indicatorGroupMap.set(indicatorKey, indicatorGroup)
|
||||||
|
lineGroup.indicatorGroups.push(indicatorGroup)
|
||||||
|
}
|
||||||
|
|
||||||
|
const indicatorGroup = lineGroup.indicatorGroupMap.get(indicatorKey)!
|
||||||
|
const column = {
|
||||||
|
prop: columnProp,
|
||||||
|
label: resolvePhaseLabel(series)
|
||||||
|
}
|
||||||
|
|
||||||
|
indicatorGroup.columns.push(column)
|
||||||
|
columns.push(column)
|
||||||
|
|
||||||
|
const seriesPointMap = new Map<string, number | null>()
|
||||||
|
;(series.points || []).forEach(point => {
|
||||||
|
if (!point.time) return
|
||||||
|
timeSet.add(point.time)
|
||||||
|
seriesPointMap.set(point.time, point.value)
|
||||||
|
})
|
||||||
|
pointValueMap.set(columnProp, seriesPointMap)
|
||||||
|
})
|
||||||
|
|
||||||
|
const lineGroups = Array.from(lineGroupMap.values()).map(group => ({
|
||||||
|
key: group.key,
|
||||||
|
label: group.label,
|
||||||
|
indicatorGroups: group.indicatorGroups
|
||||||
|
}))
|
||||||
|
|
||||||
|
return {
|
||||||
|
lineGroups,
|
||||||
|
columns,
|
||||||
|
timeValues: Array.from(timeSet).sort(),
|
||||||
|
pointValueMap
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendTableRows = (
|
||||||
|
model: SteadyTrendTableModel,
|
||||||
|
startIndex: number,
|
||||||
|
pageSize: number
|
||||||
|
): Array<Record<string, number | string | null>> => {
|
||||||
|
return model.timeValues.slice(startIndex, startIndex + pageSize).map(timeValue => {
|
||||||
|
const row: Record<string, number | string | null> = {
|
||||||
|
time: timeValue
|
||||||
|
}
|
||||||
|
|
||||||
|
model.columns.forEach(column => {
|
||||||
|
if (model.pointValueMap.get(column.prop)?.has(timeValue)) {
|
||||||
|
row[column.prop] = model.pointValueMap.get(column.prop)?.get(timeValue) ?? null
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return row
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const escapeHtml = (value: unknown) => {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
const countLineColumns = (lineGroup: SteadyTrendTableLineGroup) => {
|
||||||
|
return lineGroup.indicatorGroups.reduce((total, indicatorGroup) => total + indicatorGroup.columns.length, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildExcelHeaderRows = (model: SteadyTrendTableModel) => {
|
||||||
|
const lineCells = model.lineGroups
|
||||||
|
.map(lineGroup => {
|
||||||
|
const lineColspan = countLineColumns(lineGroup)
|
||||||
|
|
||||||
|
return `<th colspan="${lineColspan}">${escapeHtml(lineGroup.label)}</th>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
const indicatorCells = model.lineGroups
|
||||||
|
.flatMap(lineGroup =>
|
||||||
|
lineGroup.indicatorGroups.map(
|
||||||
|
indicatorGroup =>
|
||||||
|
`<th colspan="${indicatorGroup.columns.length}">${escapeHtml(indicatorGroup.label)}</th>`
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.join('')
|
||||||
|
const phaseCells = model.columns.map(column => `<th>${escapeHtml(column.label)}</th>`).join('')
|
||||||
|
|
||||||
|
return [
|
||||||
|
`<tr><th rowspan="3">时间</th>${lineCells}</tr>`,
|
||||||
|
`<tr>${indicatorCells}</tr>`,
|
||||||
|
`<tr>${phaseCells}</tr>`
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
const buildExcelBodyRows = (model: SteadyTrendTableModel) => {
|
||||||
|
return buildSteadyTrendTableRows(model, 0, model.timeValues.length)
|
||||||
|
.map(row => {
|
||||||
|
const valueCells = model.columns.map(column => `<td>${escapeHtml(row[column.prop] ?? '-')}</td>`).join('')
|
||||||
|
|
||||||
|
return `<tr><td>${escapeHtml(row.time)}</td>${valueCells}</tr>`
|
||||||
|
})
|
||||||
|
.join('')
|
||||||
|
}
|
||||||
|
|
||||||
|
export const buildSteadyTrendExcelHtml = (model: SteadyTrendTableModel) => {
|
||||||
|
return [
|
||||||
|
'<!DOCTYPE html>',
|
||||||
|
'<html xmlns:o="urn:schemas-microsoft-com:office:office" xmlns:x="urn:schemas-microsoft-com:office:excel">',
|
||||||
|
'<head>',
|
||||||
|
'<meta charset="UTF-8" />',
|
||||||
|
'<style>',
|
||||||
|
'table{border-collapse:collapse;}',
|
||||||
|
'th,td{border:1px solid #999;padding:4px 8px;white-space:nowrap;}',
|
||||||
|
'th{background:#f5f7fa;font-weight:700;text-align:center;}',
|
||||||
|
'td{text-align:center;}',
|
||||||
|
'</style>',
|
||||||
|
'</head>',
|
||||||
|
'<body>',
|
||||||
|
'<table>',
|
||||||
|
'<thead>',
|
||||||
|
buildExcelHeaderRows(model),
|
||||||
|
'</thead>',
|
||||||
|
'<tbody>',
|
||||||
|
buildExcelBodyRows(model),
|
||||||
|
'</tbody>',
|
||||||
|
'</table>',
|
||||||
|
'</body>',
|
||||||
|
'</html>'
|
||||||
|
].join('')
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user