feat(data-tools): 新增入库类型选择功能并优化数据工具界面

- 在补数任务面板中添加入库类型单选按钮组,支持 MySQL 和 InfluxDB
- 更新 AddData 接口定义,添加 StorageType 相关类型和选项接口
- 修改补数 API 请求逻辑,根据入库类型动态调整接口路径前缀
- 重构台账设备表单,统一使用装置网络参数作为 MAC 和 NDID 的单一数据源
- 优化台账线路表单,仅当存在 ID 时才设置 lineId 字段,避免空值传递
- 添加入库类型列表获取接口和相关数据处理逻辑
- 更新台账字典代码常量,新增终端型号字典码
- 优化台账树节点添加逻辑,增加前置条件验证和禁用原因提示
- 添加 InfluxDB 配置文件到额外资源目录
- 更新稳定数据分析视图,优化台账树数据结构处理和样式布局
- 完善 API 调试契约检查,确保设备和线路数据映射正确性
- 优化趋势查询性能,禁用全局加载状态提升用户体验
This commit is contained in:
2026-05-21 14:07:10 +08:00
parent f1eaabae0e
commit b9ddfb5275
23 changed files with 2462 additions and 180 deletions

View File

@@ -6,6 +6,10 @@ $primary-color: var(--el-color-primary);
--cn-color-phase-a: #daa520;
--cn-color-phase-b: #2e8b57;
--cn-color-phase-c: #a52a2a;
--cn-color-phase-t: #000000;
--cn-color-phase-ab: #daa520;
--cn-color-phase-bc: #2e8b57;
--cn-color-phase-ca: #a52a2a;
/* 波形状态与常用业务颜色 */
--cn-color-run: #20b2aa;

View File

@@ -0,0 +1,39 @@
export const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
export const phaseColorFallbackMap: Record<string, string> = {
A: '#daa520',
B: '#2e8b57',
C: '#a52a2a',
T: '#000000',
AB: '#daa520',
BC: '#2e8b57',
CA: '#a52a2a'
}
const phaseColorVariableMap: Record<string, string> = {
A: '--cn-color-phase-a',
B: '--cn-color-phase-b',
C: '--cn-color-phase-c',
T: '--cn-color-phase-t',
AB: '--cn-color-phase-ab',
BC: '--cn-color-phase-bc',
CA: '--cn-color-phase-ca'
}
export const resolvePhaseThemeColor = (phase: string) => {
const normalizedPhase = String(phase || '')
.trim()
.toUpperCase()
const variableName = phaseColorVariableMap[normalizedPhase]
const fallback = phaseColorFallbackMap[normalizedPhase]
return variableName && fallback ? readThemeColor(variableName, fallback) : ''
}
export const getDefaultPhaseThemeColors = () => ['A', 'B', 'C'].map(resolvePhaseThemeColor)

View File

@@ -2,6 +2,7 @@
<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)"
@@ -61,7 +62,7 @@ const emit = defineEmits<{
.indicator-toggle {
position: absolute;
top: 12px;
left: -18px;
left: -28px;
z-index: 3;
}

View File

@@ -1,54 +1,67 @@
<template>
<section class="card steady-tree-card">
<div class="panel-header">
<span class="panel-title">台账监测点</span>
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
<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>
<el-input
:model-value="keyword"
clearable
placeholder="搜索工程、项目、设备、监测点"
@update:model-value="handleKeywordChange"
/>
<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>
<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 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 { Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
import { ArrowLeft, ArrowRight, Folder, Location, Monitor, OfficeBuilding, Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
@@ -61,12 +74,14 @@ const props = defineProps<{
loading: boolean
keyword: string
defaultCheckedKeys: string[]
collapsed: boolean
}>()
const emit = defineEmits<{
refresh: []
search: [value: string]
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
toggle: []
}>()
const treeRef = ref<TreeInstance>()
@@ -134,6 +149,46 @@ watch(
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;
@@ -148,6 +203,21 @@ watch(
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;

View File

@@ -1,23 +1,79 @@
<template>
<section class="card trend-chart-panel" v-loading="loading">
<div v-if="trendResult" class="panel-header">
<span class="panel-meta">
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }}
</span>
<div class="panel-header">
<span v-if="trendResult" class="panel-meta">{{ trendResult.displayPointCount || 0 }} </span>
<span v-else class="panel-meta">趋势图</span>
<div class="trend-tool-groups">
<div v-for="group in trendToolGroups" :key="group.key" class="trend-tool-group">
<el-tooltip
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>
</div>
</div>
</div>
<div v-if="hasSeries" class="chart-body">
<LineChart :options="chartOptions" />
<div v-if="hasSeries" ref="chartExportTargetRef" class="chart-list steady-trend-export-target">
<div v-for="group in chartGroups" :key="group.key" class="chart-group">
<div class="chart-body">
<LineChart :options="group.options" :group="group.group" @chart-data-zoom="handleChartDataZoom" />
</div>
</div>
</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 class="chart-body">
<LineChart
:options="group.options"
:group="group.group"
@chart-data-zoom="handleChartDataZoom"
/>
</div>
</div>
</div>
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
</el-dialog>
<SteadyTrendDataTableDialog v-model="dataTableVisible" :trend-result="trendResult" />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import {
ArrowDownBold,
ArrowLeftBold,
ArrowRightBold,
ArrowUpBold,
Crop,
DataAnalysis,
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 LineChart from '@/components/echarts/line/index.vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildSteadyTrendChartOptions } from '../utils/trendOptions'
import { buildSteadyTrendChartGroups, type SteadyTrendZoomRange } from '../utils/trendOptions'
import SteadyTrendDataTableDialog from './SteadyTrendDataTableDialog.vue'
import type { Component } from 'vue'
defineOptions({
name: 'SteadyTrendChartPanel'
@@ -28,8 +84,235 @@ const props = defineProps<{
loading: boolean
}>()
type SteadyTrendInteractionMode = 'none' | 'box-zoom' | 'pan'
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'
const trendToolGroups: Array<{
key: string
items: Array<{
action: SteadyTrendToolAction
label: string
icon: Component
}>
}> = [
{
key: 'viewport',
items: [
{ action: 'wheel-zoom', label: '滚轮缩放', icon: Mouse },
{ 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 }
]
},
{
key: 'view',
items: [
{ 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: 10 }
const trendXZoomRange = ref<SteadyTrendZoomRange>({ ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE })
const trendYZoomScale = ref(1)
const activeTrendInteractionMode = ref<SteadyTrendInteractionMode>('none')
const wheelZoomEnabled = ref(false)
const fullscreenVisible = ref(false)
const dataTableVisible = ref(false)
const chartExportTargetRef = ref<HTMLElement>()
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResult?.series || []))
const chartGroups = computed(() =>
buildSteadyTrendChartGroups(props.trendResult?.series || [], trendXZoomRange.value, {
activeTool: activeTrendInteractionMode.value,
wheelZoomEnabled: wheelZoomEnabled.value,
yZoomScale: trendYZoomScale.value
})
)
const canPanTrendChart = computed(() => {
const { start, end } = trendXZoomRange.value
return hasSeries.value && (start > 0 || end < 100)
})
const isDefaultTrendXZoomRange = computed(() => {
const { start, end } = trendXZoomRange.value
return start === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.start && end === DEFAULT_STEADY_TREND_X_ZOOM_RANGE.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 resetTrendToolState = () => {
trendXZoomRange.value = { ...DEFAULT_STEADY_TREND_X_ZOOM_RANGE }
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 === '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: { action: SteadyTrendToolAction; label: string }) => {
if (item.action === 'pan' && isTrendToolDisabled(item.action) && hasSeries.value) {
return '请先放大 X 轴或框选局部区域后再平移'
}
return item.label
}
const downloadSteadyTrendImage = async () => {
await nextTick()
const targetElement = 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 '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
}
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,
() => {
// 新查询结果应回到完整时间范围,避免沿用上一批数据的局部缩放窗口。
resetTrendToolState()
}
)
</script>
<style scoped lang="scss">
@@ -46,8 +329,8 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
display: flex;
flex: none;
align-items: center;
justify-content: flex-end;
gap: 10px;
justify-content: flex-start;
gap: 12px;
margin-bottom: 10px;
}
@@ -56,9 +339,64 @@ const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResu
font-size: 12px;
}
.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;
}
.chart-list {
display: flex;
flex: 1;
flex-direction: column;
gap: 8px;
min-height: 0;
overflow: auto;
}
.fullscreen-chart-body {
display: flex;
flex-direction: column;
gap: 8px;
height: calc(100vh - 96px);
min-height: 0;
overflow: auto;
}
.chart-group {
display: flex;
flex: 1 0 240px;
flex-direction: column;
min-height: 220px;
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 {

View File

@@ -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 { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import {
buildSteadyTrendExcelHtml,
buildSteadyTrendTableModel,
buildSteadyTrendTableRows,
createEmptySteadyTrendTableModel
} from '../utils/trendTable'
defineOptions({
name: 'SteadyTrendDataTableDialog'
})
const props = defineProps<{
modelValue: boolean
trendResult: SteadyDataView.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>

View File

@@ -14,12 +14,9 @@
<div class="toolbar-field">
<span class="toolbar-field__label">统计</span>
<el-select
:model-value="modelValue.statTypes"
multiple
collapse-tags
collapse-tags-tooltip
:model-value="modelValue.statType"
placeholder="选择统计类型"
@update:model-value="updateField('statTypes', $event)"
@update:model-value="updateField('statType', $event)"
>
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
</el-select>
@@ -118,7 +115,7 @@ const handleTimeBaseDateChange = (value: Date) => {
<style scoped lang="scss">
.trend-toolbar {
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr)) auto;
grid-template-columns: minmax(360px, 1.35fr) repeat(3, minmax(0, 1fr)) auto;
gap: 10px;
align-items: center;
padding: 12px;
@@ -152,6 +149,16 @@ const handleTimeBaseDateChange = (value: Date) => {
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;
}

View File

@@ -1,16 +1,20 @@
<template>
<div class="steady-trend-layout">
<div class="steady-trend-layout" :class="{ 'is-ledger-collapsed': ledgerPanelCollapsedProxy }">
<aside class="selector-column">
<SteadyLedgerTree
:key="selectorResetKey"
:tree-data="ledgerTree"
:loading="loading.ledger"
:keyword="ledgerKeyword"
:default-checked-keys="defaultLedgerCheckedKeys"
@refresh="emit('refreshLedger')"
@search="emit('ledgerSearch', $event)"
@change="emit('ledgerChange', $event)"
/>
<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">
@@ -67,12 +71,14 @@ const props = defineProps<{
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]
@@ -88,6 +94,11 @@ const trendFormProxy = computed({
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)
@@ -104,12 +115,37 @@ const indicatorPanelCollapsedProxy = computed({
min-height: 0;
}
.steady-trend-layout.is-ledger-collapsed {
grid-template-columns: 0 minmax(0, 1fr);
}
.selector-column {
display: grid;
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);
@@ -131,7 +167,7 @@ const indicatorPanelCollapsedProxy = computed({
}
@media (max-width: 1360px) {
.steady-trend-layout {
.steady-trend-layout:not(.is-ledger-collapsed) {
grid-template-columns: 280px minmax(0, 1fr);
}
}

View File

@@ -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')

View File

@@ -0,0 +1,329 @@
/* 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 toolbarSource = read('components/SteadyTrendToolbar.vue')
const chartPanelSource = read('components/SteadyTrendChartPanel.vue')
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',
/v-for="group in chartGroups"[\s\S]*:options="group\.options"/,
chartPanelSource
],
[
'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"/,
chartPanelSource
],
[
'chart panel only shows trend point count without bucket prefix',
/trendResult\.displayPointCount\s*\|\|\s*0[\s\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 waveform-style trend tool groups',
/trend-tool-groups[\s\S]*trendToolGroups[\s\S]*handleTrendToolAction/,
chartPanelSource
],
[
'chart panel keeps trend toolbar next to point count on the left',
/\.panel-header\s*\{[\s\S]*justify-content:\s*flex-start/,
chartPanelSource
],
[
'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 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 defaults to first tenth of the x range',
/DEFAULT_STEADY_TREND_X_ZOOM_RANGE\s*:\s*SteadyTrendZoomRange\s*=\s*\{\s*start:\s*0,\s*end:\s*10\s*\}/,
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 default x zoom range',
/const isDefaultTrendXZoomRange[\s\S]*DEFAULT_STEADY_TREND_X_ZOOM_RANGE[\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 default first tenth 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\(\)/,
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 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 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',
/width:\s*resolveSteadyTrendLineWidth\(\s*resolveSteadyTrendVisiblePointCount\(series\.points\?\.length\s*\|\|\s*0,\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 only keeps first and last labels like waveform',
/buildSteadyTimeAxisLabelFormatter[\s\S]*index\s*!==\s*0\s*&&\s*index\s*!==\s*lastIndex[\s\S]*return\s*''/,
trendOptionsSource
],
[
'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\)/,
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 only uses phase name',
/const formatSeriesName[\s\S]*return series\.phase \|\| series\.seriesKey/,
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('steadyDataView chart display contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView chart display contract passed')

View File

@@ -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('steadyDataView data table contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView data table contract passed')

View File

@@ -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('steadyDataView ledger panel layout contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView ledger panel layout contract passed')

View File

@@ -41,11 +41,7 @@ const expectations = [
/defaultIndicatorCheckedKeys\.value\s*=/,
pageSource
],
[
'ledger tree receives default checked keys',
/defaultCheckedKeys/,
read('SteadyLedgerTree.vue')
],
['ledger tree receives default checked keys', /defaultCheckedKeys/, read('SteadyLedgerTree.vue')],
[
'ledger tree renders level icons',
/<component\s+:is="resolveLedgerIcon\(data\.level\)"/,
@@ -56,10 +52,11 @@ const expectations = [
/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')],
[
'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
]
]

View File

@@ -20,7 +20,9 @@ const componentSource = fs.existsSync(componentDir)
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')
@@ -62,15 +64,40 @@ const requiredPatterns = [
['indicator panel supports collapsed state', /is-collapsed/, viewSource],
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource],
[
'trend toolbar reserves four evenly distributed search columns',
/grid-template-columns:\s*repeat\(4,\s*minmax\(0,\s*1fr\)\)\s*auto/,
'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],
['indicator tree header separates title and refresh icon', /justify-content:\s*flex-start/, indicatorTreeSource]
['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],
['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 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 = [

View File

@@ -2,6 +2,7 @@
<div class="table-box steady-data-view-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"
@@ -52,6 +53,7 @@ const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const trendResult = ref<SteadyDataView.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[]>([])
@@ -133,10 +135,12 @@ const resetTrendState = () => {
}
const handleQueryTrend = async () => {
indicatorPanelCollapsed.value = true
const selectionError = validateTrendSelection({
lineIds: lineIds.value,
indicators: selectedIndicators.value,
statTypes: trendForm.value.statTypes,
statType: trendForm.value.statType,
harmonicOrders: trendForm.value.harmonicOrders
})
if (selectionError) {
@@ -167,13 +171,9 @@ watch(
trendForm.value = {
...trendForm.value,
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
statType: availableStats.includes(trendForm.value.statType) ? trendForm.value.statType : availableStats[0],
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
}
if (!trendForm.value.statTypes.length) {
trendForm.value.statTypes = availableStats.slice(0, 1)
}
},
{ deep: true }
)

View File

@@ -1,6 +1,8 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/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 = 6
const isSelectableLineNode = (node: SteadyDataView.SteadyLedgerNode) => {
@@ -25,6 +27,7 @@ export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[])
export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
const indicators: SteadyDataView.SteadyIndicatorNode[] = []
const seenIndicatorKeys = new Set<string>()
const collect = (node: SteadyDataView.SteadyIndicatorNode) => {
if (node.children?.length) {
@@ -32,9 +35,11 @@ export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[
return
}
if (node.indicatorCode) {
indicators.push(node)
}
const indicatorKey = node.indicatorCode || node.treeKey || node.id
if (!indicatorKey || seenIndicatorKeys.has(indicatorKey)) return
seenIndicatorKeys.add(indicatorKey)
indicators.push(node)
}
nodes.forEach(collect)
@@ -92,7 +97,7 @@ export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicator
export const estimateTrendSeriesCount = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
statTypes: SteadyDataView.SteadyTrendStatType[],
statType: SteadyDataView.SteadyTrendStatType,
harmonicOrders: number[]
) => {
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
@@ -101,14 +106,11 @@ export const estimateTrendSeriesCount = (
const phaseCount = indicator.phaseCodes?.length || 1
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
return count + lineIds.length * phaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
return count + lineIds.length * phaseCount * (statType ? 1 : 0) * fieldCount * harmonicMultiplier
}, 0)
}
export const validateHarmonicOrders = (
indicators: SteadyDataView.SteadyIndicatorNode[],
harmonicOrders: number[]
) => {
export const validateHarmonicOrders = (indicators: SteadyDataView.SteadyIndicatorNode[], harmonicOrders: number[]) => {
if (!hasHarmonicIndicator(indicators)) return ''
if (!harmonicOrders.length) return '谐波指标必须选择谐波次数'
if (harmonicOrders.length > MAX_HARMONIC_ORDER_COUNT) return '谐波次数最多选择 6 个'
@@ -119,20 +121,22 @@ export const validateHarmonicOrders = (
export const validateTrendSelection = (params: {
lineIds: string[]
indicators: SteadyDataView.SteadyIndicatorNode[]
statTypes: SteadyDataView.SteadyTrendStatType[]
statType: SteadyDataView.SteadyTrendStatType
harmonicOrders: number[]
}) => {
const { lineIds, indicators, statTypes, harmonicOrders } = params
const { lineIds, indicators, statType, harmonicOrders } = params
if (!lineIds.length) return '请选择监测点'
if (!indicators.length) return '请选择趋势指标'
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
if (!statTypes.length) return '请选择统计类型'
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, statTypes, harmonicOrders)
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, statType, harmonicOrders)
if (seriesCount > MAX_TREND_SERIES_COUNT) {
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标或统计类型范围'
}

View File

@@ -1,6 +1,47 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { resolvePhaseThemeColor } from '@/utils/phaseColors'
const trendColors = [
export interface SteadyTrendChartGroup {
key: string
title: string
group: string
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
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_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',
@@ -11,23 +52,497 @@ const trendColors = [
'#f97316'
]
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
return [series.lineName, series.indicatorName || series.seriesName, series.phase, series.statType]
.filter(Boolean)
.join(' / ')
interface ReadableAxisRangeOptions {
preferCompact?: boolean
compactMin?: number
compactMax?: number
}
export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
const getUniqueValues = (
seriesList: SteadyDataView.SteadyTrendSeries[],
getValue: (series: SteadyDataView.SteadyTrendSeries) => string
) => {
return Array.from(new Set(seriesList.map(getValue).filter(Boolean)))
}
const resolveGroupMode = (seriesList: SteadyDataView.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: SteadyDataView.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: SteadyDataView.SteadyTrendSeries[]) => {
const firstSeries = seriesList[0]
if (!firstSeries) return '趋势图'
return (
[firstSeries.lineName || firstSeries.lineId, firstSeries.indicatorName || firstSeries.seriesName]
.filter(Boolean)
.join('_') || '趋势图'
)
}
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
return series.phase || series.seriesKey
}
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 buildSteadyTimeAxisLabelFormatter = (timeLabels: string[]) => {
const lastIndex = timeLabels.length - 1
return (value: string | number, index: number) => {
if (index !== 0 && index !== lastIndex) return ''
return `${value}`
}
}
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: SteadyDataView.SteadyTrendSeries, index: number) => {
const phaseColor = resolvePhaseThemeColor(series.phase || '')
return phaseColor || fallbackTrendColors[index % fallbackTrendColors.length]
}
export const buildSteadyTrendChartOptions = (
seriesList: SteadyDataView.SteadyTrendSeries[],
zoomRange: SteadyTrendZoomRange,
showTimeAxis = true,
chartOptions: SteadyTrendChartBuildOptions = {},
chartTitle = ''
): Record<string, unknown> => {
const timeLabels = Array.from(
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
).sort()
const values = seriesList.flatMap(series =>
(series.points || [])
.map(point => point.value)
.filter((value): value is number => typeof value === 'number' && Number.isFinite(value))
)
const unit = seriesList.find(series => series.unit)?.unit || ''
return {
activeTool: chartOptions.activeTool || 'none',
title: {
text: ''
text: chartTitle,
left: 'center',
top: 0,
textStyle: {
color: '#303133',
fontSize: 13,
fontWeight: 600
}
},
tooltip: {
trigger: 'axis'
trigger: 'axis',
axisPointer: {
type: 'line',
snap: true,
lineStyle: {
color: 'rgba(24, 144, 255, 0.55)',
width: 1
}
}
},
legend: {
type: 'scroll',
@@ -35,26 +550,56 @@ export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTr
right: 12
},
grid: {
top: 48,
left: 64,
right: 28,
bottom: 48,
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
},
yAxis: {
type: 'value',
scale: true,
boundaryGap: false,
axisLine: {
show: false,
onZero: false,
lineStyle: {
color: STEADY_AXIS_LINE_COLOR
}
},
axisLabel: {
formatter: '{value}'
show: showTimeAxis,
hideOverlap: false,
interval: 0,
margin: showTimeAxis ? 10 : 0,
color: STEADY_AXIS_TEXT_COLOR,
formatter: buildSteadyTimeAxisLabelFormatter(timeLabels)
},
axisTick: {
show: false,
lineStyle: {
color: STEADY_AXIS_LINE_COLOR
}
}
},
color: trendColors,
series: seriesList.map(series => {
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,
bottom: '20px',
end: zoomRange.end
}
],
color: seriesList.map(resolveSeriesColor),
series: seriesList.map((series, index) => {
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
return {
@@ -64,9 +609,47 @@ export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTr
connectNulls: false,
data: timeLabels.map(time => pointMap.get(time) ?? null),
lineStyle: {
width: 1.2
width: resolveSteadyTrendLineWidth(
resolveSteadyTrendVisiblePointCount(series.points?.length || 0, zoomRange)
),
color: resolveSeriesColor(series, index)
},
itemStyle: {
color: resolveSeriesColor(series, index)
}
}
})
}
}
export const buildSteadyTrendChartGroups = (
seriesList: SteadyDataView.SteadyTrendSeries[],
zoomRange: SteadyTrendZoomRange,
chartOptions: SteadyTrendChartBuildOptions = {}
): SteadyTrendChartGroup[] => {
if (!seriesList.length) return []
const groupMode = resolveGroupMode(seriesList)
const groupMap = new Map<string, SteadyDataView.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,
options: buildSteadyTrendChartOptions(groupSeries, zoomRange, isLastChart, chartOptions, groupTitle)
}
})
}

View File

@@ -5,7 +5,7 @@ export interface SteadyTrendFormState {
timeRange: string[]
timeUnit: TimePeriodUnit
timeBaseDate: Date
statTypes: SteadyDataView.SteadyTrendStatType[]
statType: SteadyDataView.SteadyTrendStatType
qualityFlag?: number
harmonicOrders: number[]
}
@@ -17,7 +17,7 @@ export const defaultTrendFormState = (): SteadyTrendFormState => {
timeRange: buildTimePeriodRange('month', baseDate),
timeUnit: 'month',
timeBaseDate: baseDate,
statTypes: ['AVG'],
statType: 'AVG',
qualityFlag: 0,
harmonicOrders: []
}
@@ -36,7 +36,7 @@ export const buildSteadyTrendQueryPayload = (
return {
lineIds,
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
statTypes: formState.statTypes,
statTypes: [formState.statType],
timeStart: formatSteadyTrendQueryTime(formState.timeRange[0] || ''),
timeEnd: formatSteadyTrendQueryTime(formState.timeRange[1] || ''),
qualityFlag: formState.qualityFlag,

View File

@@ -0,0 +1,223 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/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: SteadyDataView.SteadyTrendSeries) => {
const phase = String(series.phase || '').trim()
if (!phase) return series.seriesName || series.seriesKey
if (phase.endsWith('相')) return phase
return `${phase}`
}
const resolveLineLabel = (series: SteadyDataView.SteadyTrendSeries) => {
return series.lineName || series.lineId || '监测点'
}
const resolveIndicatorLabel = (series: SteadyDataView.SteadyTrendSeries) => {
const indicatorName = series.indicatorName || series.seriesName || series.indicatorCode || '指标'
const unit = String(series.unit || '').trim()
return unit ? `${indicatorName}${unit}` : indicatorName
}
const resolveColumnProp = (series: SteadyDataView.SteadyTrendSeries, index: number) => {
return (
series.seriesKey || `${series.lineId || 'line'}_${series.indicatorCode || 'indicator'}_${series.phase || index}`
)
}
export const buildSteadyTrendTableModel = (seriesList: SteadyDataView.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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;')
}
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('')
}

View File

@@ -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 viewFile = path.join(currentDir, '..', 'index.vue')
const viewSource = fs.readFileSync(viewFile, 'utf8')
const expectations = [
['waveform y-axis upper padding uses 1.15', /const\s+TREND_AXIS_EXPAND_RATIO\s*=\s*1\.15/],
['waveform y-axis lower padding uses 0.85', /const\s+TREND_AXIS_SHRINK_RATIO\s*=\s*0\.85/],
['waveform y-axis above-one upper padding uses 1.05', /const\s+TREND_AXIS_EXPAND_RATIO_ABOVE_ONE\s*=\s*1\.05/],
['waveform y-axis above-one lower padding uses 0.95', /const\s+TREND_AXIS_SHRINK_RATIO_ABOVE_ONE\s*=\s*0\.95/],
[
'waveform y-axis switches padding by absolute boundary value',
/Math\.abs\(value\)\s*>\s*1[\s\S]*TREND_AXIS_EXPAND_RATIO_ABOVE_ONE[\s\S]*TREND_AXIS_SHRINK_RATIO_ABOVE_ONE/
],
['waveform y-axis compact padding uses 1.015 for narrow above-one ranges', /TREND_AXIS_COMPACT_EXPAND_RATIO\s*=\s*1\.015/],
['waveform y-axis compact padding uses 0.985 for narrow above-one ranges', /TREND_AXIS_COMPACT_SHRINK_RATIO\s*=\s*0\.985/],
['waveform y-axis compact split penalty stays low', /TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE\s*=\s*0\.05/],
[
'waveform y-axis enables compact readable range for narrow above-one data',
/shouldUseCompactReadableAxisRange[\s\S]*maxAbs\s*>\s*1[\s\S]*TREND_AXIS_COMPACT_RANGE_RATIO/
],
[
'waveform integer ranges still enter readable-axis normalization',
/rawInterval\s*>=\s*TREND_AXIS_SMALL_INTERVAL_THRESHOLD/,
true
],
['waveform y-axis keeps readable interval normalization', /getReadableAxisInterval\(axisRange\s*\/\s*currentSplitCount\)/],
[
'waveform y-axis keeps upper boundary aligned to interval and split count',
/normalizedRange\s*=\s*normalizeAxisValue\(normalizedInterval\s*\*\s*currentSplitCount,\s*precision\)[\s\S]*normalizedMin\s*\+\s*normalizedRange/
],
['waveform y-axis keeps min label visible', /showMinLabel:\s*true/],
['waveform y-axis keeps max label visible', /showMaxLabel:\s*true/]
]
const failures = expectations.filter(([, pattern, shouldBeMissing]) => {
const exists = pattern.test(viewSource)
return shouldBeMissing ? exists : !exists
})
if (failures.length) {
console.error('waveform axis range contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('waveform axis range contract check passed')

View File

@@ -0,0 +1,32 @@
/* 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 viewFile = path.join(currentDir, '..', 'index.vue')
const viewSource = fs.readFileSync(viewFile, 'utf8')
const expectations = [
['waveform trend max line width is 1.3', /const\s+TREND_LINE_MAX_WIDTH\s*=\s*1\.3/],
[
'waveform trend line width uses visible point buckets including the final three widths',
/resolveTrendLineWidth[\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*TREND_LINE_MAX_WIDTH/
],
[
'waveform trend series applies line width from visible point count',
/width:\s*resolveTrendLineWidth\(visiblePointCount\)/
]
]
const failures = expectations.filter(([, pattern]) => !pattern.test(viewSource))
if (failures.length) {
console.error('waveform line width contract check failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('waveform line width contract check passed')

View File

@@ -348,12 +348,17 @@ const disabledTrendToolStates = computed<Partial<Record<TrendToolAction, boolean
reset: !canResetTrendChart.value
}))
const TREND_AXIS_EXPAND_RATIO = 1.2
const TREND_AXIS_SHRINK_RATIO = 0.8
const TREND_AXIS_EXPAND_RATIO = 1.15
const TREND_AXIS_SHRINK_RATIO = 0.85
const TREND_AXIS_EXPAND_RATIO_ABOVE_ONE = 1.05
const TREND_AXIS_SHRINK_RATIO_ABOVE_ONE = 0.95
const TREND_AXIS_COMPACT_EXPAND_RATIO = 1.015
const TREND_AXIS_COMPACT_SHRINK_RATIO = 0.985
const TREND_AXIS_COMPACT_RANGE_RATIO = 0.25
const TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE = 0.05
const TREND_AXIS_BALANCED_RATIO = 0.9
const TREND_AXIS_DEFAULT_SPLIT_COUNT = 4
const TREND_AXIS_COMPACT_SPLIT_COUNT = 2
const TREND_AXIS_SMALL_INTERVAL_THRESHOLD = 1
const TREND_AXIS_EXTRA_SPLIT_SCORE = 0.25
const TREND_AXIS_READABLE_INTERVAL_STEPS = [1, 2, 2.5, 5, 10]
const TREND_GRID_TOP = '6px'
@@ -363,7 +368,13 @@ const TREND_GRID_BOTTOM = {
withTimeAxis: '34px',
withoutTimeAxis: '6px'
}
const TREND_LINE_MAX_WIDTH = 1.6
const TREND_LINE_MAX_WIDTH = 1.3
interface ReadableAxisRangeOptions {
preferCompact?: boolean
compactMin?: number
compactMax?: number
}
const getAxisPrecision = (step: number) => {
const absStep = Math.abs(step)
@@ -435,8 +446,8 @@ const resolveTrendLineWidth = (pointCount: number) => {
if (pointCount >= 10000) return 0.75
if (pointCount >= 5000) return 0.9
if (pointCount >= 2000) return 1
if (pointCount >= 800) return 1.2
if (pointCount >= 200) return 1.4
if (pointCount >= 800) return 1.1
if (pointCount >= 200) return 1.2
return TREND_LINE_MAX_WIDTH
}
@@ -510,26 +521,113 @@ const applyYAxisZoom = (yAxisConfig: Record<string, unknown>) => {
const halfRange = ((axisMax - axisMin) * scale) / 2
const nextMin = center - halfRange
const nextMax = center + halfRange
const splitNumber = Number(yAxisConfig.splitNumber) || TREND_AXIS_DEFAULT_SPLIT_COUNT
const interval = (nextMax - nextMin) / splitNumber
const precision = getAxisBoundaryPrecision(nextMin, nextMax, interval)
const normalizedInterval = normalizeAxisValue(interval, precision)
const splitNumber = Math.max(Math.round(Number(yAxisConfig.splitNumber) || TREND_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(nextMin, precision),
max: normalizeAxisValue(nextMax, precision),
min: normalizeAxisValue(readableAxisRange.axisMin, precision),
max: normalizeAxisValue(readableAxisRange.axisMax, precision),
interval: normalizedInterval,
minInterval: normalizedInterval,
maxInterval: normalizedInterval
maxInterval: normalizedInterval,
splitNumber: readableAxisRange.splitCount
}
}
const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount: number) => {
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,
interval: number,
currentSplitCount: number,
baseSplitCount: number,
coverMin: number,
coverMax: number,
scoreRange: number,
extraSplitScore = TREND_AXIS_EXTRA_SPLIT_SCORE
) => {
const precision = getAxisPrecision(interval)
const normalizedMin = normalizeAxisValue(readableMin, precision)
const normalizedInterval = normalizeAxisValue(interval, precision)
const normalizedRange = normalizeAxisValue(normalizedInterval * currentSplitCount, precision)
const normalizedMax = normalizeAxisValue(normalizedMin + normalizedRange, 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 = TREND_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,
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 || rawInterval >= TREND_AXIS_SMALL_INTERVAL_THRESHOLD) {
if (!Number.isFinite(axisRange) || axisRange <= 0) {
return {
axisMin,
axisMax,
@@ -538,12 +636,22 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
}
}
const splitCountCandidates =
splitCount >= TREND_AXIS_DEFAULT_SPLIT_COUNT ? [splitCount, splitCount + 1] : [splitCount]
return splitCountCandidates.reduce(
(currentBest, currentSplitCount) => {
let interval = getReadableAxisInterval(axisRange / currentSplitCount)
const splitCountCandidates = splitCount >= TREND_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
@@ -561,24 +669,41 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
interval = getReadableAxisInterval(interval * 1.01)
}
const precision = getAxisPrecision(interval)
const normalizedMin = normalizeAxisValue(readableMin, precision)
const normalizedMax = normalizeAxisValue(readableMax, precision)
const normalizedInterval = normalizeAxisValue(interval, precision)
const extraSplitCost = Math.max(currentSplitCount - splitCount, 0) * TREND_AXIS_EXTRA_SPLIT_SCORE
const wasteRatio = (normalizedMax - normalizedMin - axisRange) / axisRange
const score = getAxisPrecision(normalizedInterval) * 10 + extraSplitCost + wasteRatio
const candidate = buildReadableAxisRangeCandidate(
readableMin,
interval,
currentSplitCount,
splitCount,
axisMin,
axisMax,
scoreRange
)
if (candidate) result.push(candidate)
if (score >= currentBest.score) return currentBest
return result
}, [])
return {
axisMin: normalizedMin,
axisMax: normalizedMax,
interval: normalizedInterval,
splitCount: currentSplitCount,
score
}
},
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,
TREND_AXIS_COMPACT_EXTRA_SPLIT_SCORE
)
if (candidate) candidates.push(candidate)
})
})
}
return candidates.reduce(
(currentBest, candidate) => (candidate.score < currentBest.score ? candidate : currentBest),
{
axisMin,
axisMax,
@@ -592,13 +717,30 @@ const resolveReadableAxisRange = (axisMin: number, axisMax: number, splitCount:
const resolveExpandedAxisBoundary = (value: number, isMin: boolean) => {
if (value === 0) return 0
const useAboveOneRatio = Math.abs(value) > 1
const expandRatio = useAboveOneRatio ? TREND_AXIS_EXPAND_RATIO_ABOVE_ONE : TREND_AXIS_EXPAND_RATIO
const shrinkRatio = useAboveOneRatio ? TREND_AXIS_SHRINK_RATIO_ABOVE_ONE : TREND_AXIS_SHRINK_RATIO
const ratio = isMin
? value < 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
? expandRatio
: shrinkRatio
: value > 0
? TREND_AXIS_EXPAND_RATIO
: TREND_AXIS_SHRINK_RATIO
? expandRatio
: shrinkRatio
return value * ratio
}
const resolveCompactAxisBoundary = (value: number, isMin: boolean) => {
if (value === 0) return 0
const ratio = isMin
? value < 0
? TREND_AXIS_COMPACT_EXPAND_RATIO
: TREND_AXIS_COMPACT_SHRINK_RATIO
: value > 0
? TREND_AXIS_COMPACT_EXPAND_RATIO
: TREND_AXIS_COMPACT_SHRINK_RATIO
return value * ratio
}
@@ -618,6 +760,13 @@ const shouldUseBalancedAxisBoundary = (min: number, max: number) => {
return largerAbs > 0 && smallerAbs / largerAbs >= TREND_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 <= TREND_AXIS_COMPACT_RANGE_RATIO
}
const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = TREND_AXIS_DEFAULT_SPLIT_COUNT) => {
const min = Number(trendPayload.min)
const max = Number(trendPayload.max)
@@ -645,7 +794,12 @@ const buildTrendAxisConfig = (trendPayload: WaveformTrendPayload, splitCount = T
}
const safeSplitCount = Math.max(Math.round(splitCount), 1)
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount)
const useCompactReadableAxisRange = shouldUseCompactReadableAxisRange(min, max)
const readableAxisRange = resolveReadableAxisRange(axisMin, axisMax, safeSplitCount, {
preferCompact: useCompactReadableAxisRange,
compactMin: useCompactReadableAxisRange ? resolveCompactAxisBoundary(min, true) : undefined,
compactMax: useCompactReadableAxisRange ? resolveCompactAxisBoundary(max, false) : undefined
})
const precision = getAxisBoundaryPrecision(
readableAxisRange.axisMin,
readableAxisRange.axisMax,

View File

@@ -1,15 +1,5 @@
const readThemeColor = (name: string, fallback: string) => {
if (typeof window === 'undefined') return fallback
const value = getComputedStyle(document.documentElement).getPropertyValue(name).trim()
return value || fallback
}
import { getDefaultPhaseThemeColors, readThemeColor } from '@/utils/phaseColors'
const phaseColors = {
a: readThemeColor('--cn-color-phase-a', '#daa520'),
b: readThemeColor('--cn-color-phase-b', '#2e8b57'),
c: readThemeColor('--cn-color-phase-c', '#a52a2a')
}
export const defaultPhaseColors = [phaseColors.a, phaseColors.b, phaseColors.c]
export const defaultPhaseColors = getDefaultPhaseThemeColors()
export const axisTextColor = readThemeColor('--el-text-color-regular', '#606266')
export const axisLineColor = readThemeColor('--el-border-color', '#dcdfe6')