我叫洪圣文

This commit is contained in:
2026-05-15 16:36:50 +08:00
parent b6006e0dfe
commit 6687cf0339
36 changed files with 2201 additions and 271 deletions

View File

@@ -0,0 +1,239 @@
# steady-DataView API 调试文档
## 1. 基础信息
- 模块:`steady/steady-DataView`
- 控制器:`SteadyDataViewController`
- 接口前缀:`/steady/data-view`
- 本地默认地址:`http://localhost:18192`
- Content-Type`application/json`
- 认证:除登录和 Swagger 资源外,请求需要携带登录后的 `Authorization` 头。
## 2. 分页查询稳态数据
- 路径:`POST /steady/data-view/page`
- 返回:`HttpResult<Page<SteadyDataViewVO>>`
- 默认表:`data_v`
- 默认时间范围:当前月 1 日 `00:00:00` 到当前时间
- 默认排序:`TIMEID DESC, LINEID ASC, PHASIC_TYPE ASC`
请求示例:
```json
{
"pageNum": 1,
"pageSize": 10,
"tableName": "data_v",
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-09 23:59:59",
"phasicType": "A",
"qualityFlag": 1,
"lineIds": [
"line-001"
],
"engineeringName": "示例工程",
"projectName": "示例项目",
"equipmentName": "示例设备",
"lineName": "示例测点"
}
```
`tableName` 只允许 `tools/add-data` 已注册的 13 张 `data_*` 表;台账关键字会先通过 `add-ledger` 转换为监测点 ID再查询稳态数据表。
## 3. 查询稳态数据详情
- 路径:`POST /steady/data-view/detail`
- 返回:`HttpResult<SteadyDataViewVO>`
请求示例:
```json
{
"tableName": "data_v",
"lineId": "line-001",
"timeId": "2026-05-09 10:20:30",
"phasicType": "A"
}
```
详情使用 `LINEID + TIMEID + PHASIC_TYPE` 定位单条数据。
## 4. 查询稳态数据模板
- 路径:`GET /steady/data-view/templates`
- 返回:`HttpResult<List<SteadyDataViewTemplateVO>>`
模板来自 `tools/add-data` 的前端展示模板,返回参数名称、表名、相别和当前表可展示值字段。
## 5. 查询趋势台账树
- 路径:`GET /steady/data-view/ledger-tree`
- 返回:`HttpResult<List<SteadyDataViewLedgerNodeVO>>`
- 查询参数:`keyword`,可选,按台账节点名称搜索并保留父级路径。
节点字段:
| 字段 | 说明 |
| --- | --- |
| `id` | 台账节点 ID |
| `parentId` | 父节点 ID |
| `name` | 节点名称 |
| `level` | 层级0 工程1 项目2 设备3 监测点 |
| `deviceCount` | 当前节点下有效设备数 |
| `lineCount` | 当前节点下有效监测点数 |
| `selectable` | 是否可直接选择 |
| `children` | 子节点 |
## 6. 查询趋势指标树
- 路径:`GET /steady/data-view/indicator-tree`
- 返回:`HttpResult<List<SteadyDataViewIndicatorNodeVO>>`
当前指标目录覆盖:
- 电压趋势:`V_RMS``V_LINE_RMS`
- 电流趋势:`I_RMS`
- 频率趋势:`FREQ`
- 谐波趋势:`V_THD``I_THD``V_HARMONIC``I_HARMONIC``V_HARMONIC_RATE``I_HARMONIC_RATE``I_INTER_HARMONIC``P_HARMONIC_POWER``Q_HARMONIC_POWER``S_HARMONIC_POWER`
- 闪变趋势:`FLUC``PST``PLT`
叶子节点会返回 `tableName``phaseCodes``seriesFields``supportStats``harmonicOrderStart``harmonicOrderEnd``unit`,前端按这些字段驱动相别、统计类型和谐波次数选择。
## 7. 查询趋势数据
- 路径:`POST /steady/data-view/trend/query`
- 返回:`HttpResult<SteadyTrendQueryVO>`
请求示例:
```json
{
"lineIds": ["line-001"],
"indicatorCodes": ["V_RMS"],
"statTypes": ["AVG", "MAX", "MIN", "CP95"],
"phases": ["A", "B", "C"],
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-01 23:59:59",
"bucket": "10m",
"qualityFlag": 1
}
```
返回示例:
```json
{
"sampled": true,
"bucket": "10m",
"sourcePointCount": 144,
"displayPointCount": 144,
"loadableDays": ["2026-05-01"],
"series": [
{
"seriesKey": "line-001|V_RMS|A|AVG|RMS",
"lineId": "line-001",
"lineName": "进线一",
"indicatorCode": "V_RMS",
"indicatorName": "相电压有效值",
"seriesName": "相电压有效值",
"phase": "A",
"statType": "AVG",
"unit": "V",
"points": [
{
"time": "2026-05-01 00:00:00",
"value": 220.1
}
]
}
]
}
```
谐波请求必须指定 `harmonicOrders`,最多 6 个:
```json
{
"lineIds": ["line-001"],
"indicatorCodes": ["V_HARMONIC"],
"statTypes": ["MAX"],
"phases": ["A"],
"harmonicOrders": [3, 5, 7],
"timeStart": "2026-05-01 00:00:00",
"timeEnd": "2026-05-01 23:59:59",
"bucket": "10m",
"qualityFlag": 1
}
```
## 8. 按天查询趋势数据
- 路径:`POST /steady/data-view/trend/day`
- 返回:`HttpResult<SteadyTrendQueryVO>`
请求体与 `/trend/query` 一致。前端切换日期或加载某一天数据时,将 `timeStart``timeEnd` 控制在当天范围即可。
## 9. 查询趋势统计摘要
- 路径:`POST /steady/data-view/trend/summary`
- 返回:`HttpResult<SteadyTrendSummaryVO>`
请求体与 `/trend/query` 一致。后端按当前查询范围返回每条曲线的 `max``avg``min``cp95`
## 10. InfluxDB 配置
配置项前缀:`steady.influxdb`
```yaml
steady:
influxdb:
url: http://192.168.1.103:18086
database: pqsbase
username: admin
password: ${STEADY_INFLUXDB_PASSWORD:}
ssl: false
connect-timeout-ms: 5000
read-timeout-ms: 30000
```
接口按 InfluxDB 1.x InfluxQL `/query` 方式访问。代码不会提交明文密码;本地密码请通过环境变量或本地覆盖配置提供。
## 11. 返回字段说明
| 字段 | 说明 |
| --- | --- |
| `tableName` | 数据表名 |
| `lineId` | 监测点 ID |
| `timeId` | 数据时间 |
| `phasicType` | 相别 |
| `qualityFlag` | 质量标识 |
| `equipmentName` | 设备名称,台账缺失时为 `-` |
| `engineeringName` | 工程名称,台账缺失时为 `-` |
| `projectName` | 项目名称,台账缺失时为 `-` |
| `lineName` | 监测点名称,台账缺失时为 `-` |
| `values` | 动态指标字段,字段名与目标 `data_*` 表保持一致 |
## 12. 常见错误场景
| 场景 | 后端提示 |
| --- | --- |
| 表名不在 add-data 注册表范围内 | `稳态数据表不支持xxx` |
| 开始时间大于结束时间 | `开始时间不能大于结束时间` |
| 时间格式无法解析 | `时间格式不正确,仅支持 yyyy-MM-dd HH:mm:ss` |
| 相别不为 `A/B/C/T` | `相别只能是 A、B、C、T` |
| 质量标识不为 `0/1` | `质量标识只能是 0 或 1` |
| `lineIds` 超过 1000 个 | `监测点 ID 查询数量不能超过 1000 个` |
| 台账关键字匹配监测点超过 1000 个 | `台账检索匹配监测点过多,请缩小查询条件` |
| 趋势监测点为空 | `监测点 ID 不能为空` |
| 趋势指标为空 | `指标不能为空` |
| 多监测点同时多指标查询 | `多监测点查询时只能选择 1 个指标` |
| 趋势曲线超过 24 条 | `趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围` |
| 谐波指标未传次数 | `谐波次数不能为空` |
| 谐波次数超过 6 个 | `谐波次数最多选择 6 个` |
| InfluxDB 未配置地址 | `InfluxDB 地址未配置` |
## 13. 当前限制
- 当前仅提供分页、详情和模板查询,未提供动态 Excel 导出。
- 趋势接口已提供后端结构和 InfluxQL 查询封装,未做真实 InfluxDB 联调。
- `sourcePointCount` 当前与实际返回点数一致,未额外发 InfluxDB `count` 查询。

View File

@@ -0,0 +1,34 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const srcRoot = path.resolve(currentDir, '../../..')
const staticRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/staticRouter.ts'), 'utf8')
const dynamicRouter = fs.readFileSync(path.join(srcRoot, 'routers/modules/dynamicRouter.ts'), 'utf8')
const authStore = fs.readFileSync(path.join(srcRoot, 'stores/modules/auth.ts'), 'utf8')
const expectations = [
['static router registers /steadyDataView/index', /path:\s*'\/steadyDataView\/index'/],
['static route name is steadyDataView', /name:\s*'steadyDataView'/],
['static router imports steadyDataView page', /@\/views\/steady\/steadyDataView\/index\.vue/],
['dynamic router aliases steady-data-view to steadyDataView', /\/steady\/steady-data-view[\s\S]*\/steady\/steadyDataView/],
['dynamic router keeps steadyDataView static route from being overwritten', /STATIC_ROUTE_NAMES[\s\S]*'steadyDataView'/],
['auth normalizes backend steady menu to static steadyDataView entry', /isSteadyDataViewMenu[\s\S]*menu\.path\s*=\s*'\/steadyDataView\/index'/],
['business menu path resolver handles steadyDataView', /isSteadyDataViewMenu\(menu\)\s*\?\s*'\/steadyDataView\/index'/]
]
const sourceByExpectation = [staticRouter, staticRouter, staticRouter, dynamicRouter, dynamicRouter, authStore, authStore]
const failures = expectations.filter(([, pattern], index) => !pattern.test(sourceByExpectation[index]))
if (failures.length) {
console.error('steadyDataView route contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView route contract passed')

View File

@@ -0,0 +1,107 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageFile = path.join(currentDir, 'index.vue')
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
const componentDir = path.join(currentDir, 'components')
const utilsDir = path.join(currentDir, 'utils')
const read = file => fs.readFileSync(file, 'utf8')
const pageSource = read(pageFile)
const apiSource = read(apiFile)
const interfaceSource = read(interfaceFile)
const componentSource = fs.existsSync(componentDir)
? fs
.readdirSync(componentDir)
.filter(file => file.endsWith('.vue'))
.map(file => read(path.join(componentDir, file)))
.join('\n')
: ''
const utilitySource = fs.existsSync(utilsDir)
? fs
.readdirSync(utilsDir)
.filter(file => file.endsWith('.ts'))
.map(file => read(path.join(utilsDir, file)))
.join('\n')
: ''
const expectations = [
['page imports ledger tree panel', /SteadyLedgerTree/],
['page imports indicator tree panel', /SteadyIndicatorTree/],
['page imports trend toolbar', /SteadyTrendToolbar/],
['page imports trend chart panel', /SteadyTrendChartPanel/],
['page does not import trend summary panel', /SteadyTrendSummaryPanel/],
['page does not import data table panel', /SteadyDataTablePanel/],
['page renders floating indicator panel', /indicator-floating-panel/],
['page defaults floating indicator panel expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/],
['API exposes ledger tree endpoint', /\/steady\/data-view\/ledger-tree/],
['API exposes indicator tree endpoint', /\/steady\/data-view\/indicator-tree/],
['API exposes trend query endpoint', /\/steady\/data-view\/trend\/query/],
['API exposes trend day endpoint', /\/steady\/data-view\/trend\/day/],
['API does not expose trend summary endpoint', /\/steady\/data-view\/trend\/summary/],
['interfaces define trend query params', /interface\s+SteadyTrendQueryParams/],
['interfaces define trend series', /interface\s+SteadyTrendSeries/],
['interfaces do not define trend summary', /interface\s+SteadyTrendSummary/],
['components render ledger checkbox tree', /show-checkbox[\s\S]*@check/],
['components render indicator checkbox tree', /indicator-tree[\s\S]*show-checkbox[\s\S]*@check/],
['components reuse LineChart', /<LineChart/],
['toolbar uses shared time period search', /TimePeriodSearch/],
['toolbar labels phase options descriptively', /resolvePhaseLabel/],
['toolbar labels bucket options descriptively', /bucketOptions[\s\S]*1分钟[\s\S]*1小时/],
['toolbar labels quality options descriptively', /仅有效数据[\s\S]*仅无效数据/],
['utilities collect selected line ids', /export const collectSelectedLineIds/],
['utilities validate selection limits', /export const validateTrendSelection[\s\S]*24/],
['utilities validate harmonic orders', /export const validateHarmonicOrders[\s\S]*6/],
['utilities build trend query payload', /export const buildSteadyTrendQueryPayload/],
['utilities build chart options', /export const buildSteadyTrendChartOptions/]
]
const sourceByExpectation = [
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
pageSource,
apiSource,
apiSource,
apiSource,
apiSource,
apiSource,
interfaceSource,
interfaceSource,
interfaceSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
componentSource,
utilitySource,
utilitySource,
utilitySource,
utilitySource,
utilitySource
]
const failures = expectations.filter(([name, pattern], index) => {
const matched = pattern.test(sourceByExpectation[index])
return name.includes('does not') || name.includes('do not') ? matched : !matched
})
if (failures.length) {
console.error('steadyDataView trend contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView trend contract passed')

View File

@@ -0,0 +1,59 @@
/* eslint-env node */
import fs from 'node:fs'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
const currentDir = path.dirname(fileURLToPath(import.meta.url))
const pageFile = path.join(currentDir, 'index.vue')
const apiFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/index.ts')
const interfaceFile = path.resolve(currentDir, '../../../api/steady/steadyDataView/interface/index.ts')
const source = fs.readFileSync(pageFile, 'utf8')
const apiSource = fs.readFileSync(apiFile, 'utf8')
const interfaceSource = fs.readFileSync(interfaceFile, 'utf8')
const forbiddenPatterns = [
['data detail tab is removed', /数据明细|name="detail"|SteadyDataTablePanel/, source],
['detail ProTable is removed', /buildSteadyDataQueryParams|SteadyDataSearchParams/, source],
['trend summary panel is removed', /SteadyTrendSummaryPanel|trendSummary|loading\.summary/, source],
[
'page detail API is removed',
/getSteadyDataPage|getSteadyDataDetail|getSteadyDataTemplates|\/steady\/data-view\/page|\/steady\/data-view\/detail|\/steady\/data-view\/templates/,
apiSource
],
['trend summary API is removed', /getSteadyTrendSummary|\/steady\/data-view\/trend\/summary/, apiSource],
[
'page detail types are removed',
/PageResult|SteadyDataPageParams|SteadyDataDetailParams|SteadyDataTemplate|SteadyDataRecord/,
interfaceSource
],
[
'trend summary types are removed',
/SteadyTrendSummary|SteadyTrendSummaryItem/,
interfaceSource
]
]
const requiredPatterns = [
['page defines SteadyDataView component name', /name:\s*'SteadyDataView'/, source],
['page keeps trend chart panel', /SteadyTrendChartPanel/, source],
['page keeps right floating indicator panel', /indicator-floating-panel/, source],
['indicator panel defaults expanded', /indicatorPanelCollapsed\s*=\s*ref\(false\)/, source],
['indicator panel supports collapsed state', /is-collapsed/, source],
['API keeps trend query endpoint', /\/steady\/data-view\/trend\/query/, apiSource]
]
const failures = [
...forbiddenPatterns.filter(([, pattern, target]) => pattern.test(target)),
...requiredPatterns.filter(([, pattern, target]) => !pattern.test(target))
]
if (failures.length) {
console.error('steadyDataView visible contract failed:')
for (const [name] of failures) {
console.error(`- ${name}`)
}
process.exit(1)
}
console.log('steadyDataView visible contract passed')

View File

@@ -0,0 +1,117 @@
<template>
<section class="card steady-tree-card indicator-tree">
<div class="panel-header">
<span class="panel-title">稳态指标</span>
<el-button :icon="Refresh" circle :loading="loading" @click="emit('refresh')" />
</div>
<el-scrollbar class="tree-scrollbar">
<el-tree
ref="treeRef"
class="indicator-tree"
:data="normalizedTreeData"
node-key="treeKey"
show-checkbox
default-expand-all
:expand-on-click-node="false"
:props="{ label: 'name', children: 'children' }"
@check="handleCheck"
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-name">{{ data.name }}</span>
<el-tag v-if="data.unit" size="small" effect="plain">{{ data.unit }}</el-tag>
</div>
</template>
</el-tree>
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { collectLeafIndicators } from '../utils/selectionRules'
defineOptions({
name: 'SteadyIndicatorTree'
})
const props = defineProps<{
treeData: SteadyDataView.SteadyIndicatorNode[]
loading: boolean
}>()
const emit = defineEmits<{
refresh: []
change: [nodes: SteadyDataView.SteadyIndicatorNode[]]
}>()
const treeRef = ref<TreeInstance>()
const normalizedTreeData = computed(() => {
const normalize = (nodes: SteadyDataView.SteadyIndicatorNode[], parentKey = ''): SteadyDataView.SteadyIndicatorNode[] => {
return nodes.map((node, index) => {
const treeKey = node.id || node.indicatorCode || `${parentKey}${node.groupCode || node.name || 'node'}-${index}`
return {
...node,
treeKey,
children: node.children?.length ? normalize(node.children, `${treeKey}-`) : undefined
} as SteadyDataView.SteadyIndicatorNode
})
}
return normalize(props.treeData)
})
const handleCheck = () => {
const checkedNodes = (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyIndicatorNode[]
emit('change', collectLeafIndicators(checkedNodes))
}
</script>
<style scoped lang="scss">
.steady-tree-card {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 12px;
}
.panel-header,
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.tree-scrollbar {
flex: 1;
min-height: 0;
}
.tree-node {
width: 100%;
min-width: 0;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.indicator-tree :deep(.el-tree-node__content) {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,123 @@
<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')" />
</div>
<el-input
:model-value="keyword"
clearable
placeholder="搜索工程、项目、设备、监测点"
@update:model-value="handleKeywordChange"
/>
<el-scrollbar class="tree-scrollbar">
<el-tree
ref="treeRef"
class="ledger-tree"
:data="treeData"
node-key="id"
show-checkbox
default-expand-all
:expand-on-click-node="false"
:props="{ label: 'name', children: 'children' }"
@check="handleCheck"
>
<template #default="{ data }">
<div class="tree-node">
<span class="node-name">{{ data.name }}</span>
<span class="node-count">
<template v-if="Number(data.deviceCount) || Number(data.lineCount)">
{{ Number(data.deviceCount || 0) }} / {{ Number(data.lineCount || 0) }}
</template>
</span>
</div>
</template>
</el-tree>
</el-scrollbar>
</section>
</template>
<script setup lang="ts">
import { ref } from 'vue'
import { Refresh } from '@element-plus/icons-vue'
import type { TreeInstance } from 'element-plus'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
defineOptions({
name: 'SteadyLedgerTree'
})
defineProps<{
treeData: SteadyDataView.SteadyLedgerNode[]
loading: boolean
keyword: string
}>()
const emit = defineEmits<{
refresh: []
search: [value: string]
change: [nodes: SteadyDataView.SteadyLedgerNode[]]
}>()
const treeRef = ref<TreeInstance>()
const handleKeywordChange = (value: string) => {
emit('search', value)
}
const handleCheck = () => {
emit('change', (treeRef.value?.getCheckedNodes(false, true) || []) as SteadyDataView.SteadyLedgerNode[])
}
</script>
<style scoped lang="scss">
.steady-tree-card {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
padding: 12px;
}
.panel-header,
.tree-node {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
color: var(--el-text-color-primary);
}
.tree-scrollbar {
flex: 1;
min-height: 0;
}
.tree-node {
width: 100%;
min-width: 0;
}
.node-name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.node-count {
flex: none;
color: var(--el-text-color-secondary);
font-size: 12px;
}
.ledger-tree :deep(.el-tree-node__content) {
min-width: 0;
}
</style>

View File

@@ -0,0 +1,74 @@
<template>
<section class="card trend-chart-panel" v-loading="loading">
<div class="panel-header">
<span class="panel-title">趋势图</span>
<span class="panel-meta">
<template v-if="trendResult">
{{ trendResult.bucket || '-' }} / {{ trendResult.displayPointCount || 0 }}
</template>
</span>
</div>
<div v-if="hasSeries" class="chart-body">
<LineChart :options="chartOptions" />
</div>
<el-empty v-else class="chart-empty" description="请选择监测点和指标后查询趋势" />
</section>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import LineChart from '@/components/echarts/line/index.vue'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildSteadyTrendChartOptions } from '../utils/trendOptions'
defineOptions({
name: 'SteadyTrendChartPanel'
})
const props = defineProps<{
trendResult: SteadyDataView.SteadyTrendQueryResult | null
loading: boolean
}>()
const hasSeries = computed(() => Boolean(props.trendResult?.series?.length))
const chartOptions = computed(() => buildSteadyTrendChartOptions(props.trendResult?.series || []))
</script>
<style scoped lang="scss">
.trend-chart-panel {
display: flex;
flex-direction: column;
min-width: 0;
min-height: 0;
padding: 12px;
}
.panel-header {
display: flex;
flex: none;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 10px;
}
.panel-title {
font-size: 14px;
font-weight: 600;
}
.panel-meta {
color: var(--el-text-color-secondary);
font-size: 12px;
}
.chart-body {
flex: 1;
min-height: 0;
}
.chart-empty {
flex: 1;
}
</style>

View File

@@ -0,0 +1,175 @@
<template>
<section class="card trend-toolbar">
<TimePeriodSearch
class="trend-toolbar__time"
:unit="modelValue.timeUnit"
:model-value="modelValue.timeBaseDate"
@update:unit="handleTimeUnitChange"
@update:model-value="handleTimeBaseDateChange"
/>
<el-select
:model-value="modelValue.phases"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择相别"
@update:model-value="updateField('phases', $event)"
>
<el-option v-for="item in phaseOptions" :key="item" :label="resolvePhaseLabel(item)" :value="item" />
</el-select>
<el-select
:model-value="modelValue.statTypes"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择统计类型"
@update:model-value="updateField('statTypes', $event)"
>
<el-option v-for="item in statOptions" :key="item" :label="statLabelMap[item]" :value="item" />
</el-select>
<el-select
:model-value="modelValue.bucket"
placeholder="选择时间粒度"
@update:model-value="updateField('bucket', $event)"
>
<el-option v-for="item in bucketOptions" :key="item.value" :label="item.label" :value="item.value" />
</el-select>
<el-select
:model-value="modelValue.qualityFlag"
clearable
placeholder="选择数据质量"
@update:model-value="updateField('qualityFlag', $event)"
>
<el-option label="仅有效数据" :value="1" />
<el-option label="仅无效数据" :value="0" />
</el-select>
<el-select
v-if="showHarmonicOrders"
:model-value="modelValue.harmonicOrders"
class="harmonic-select"
multiple
collapse-tags
collapse-tags-tooltip
placeholder="选择谐波次数"
@update:model-value="updateField('harmonicOrders', $event)"
>
<el-option v-for="item in harmonicOrderOptions" :key="item" :label="`${item}次`" :value="item" />
</el-select>
<div class="toolbar-actions">
<el-button type="primary" :loading="loading" @click="emit('query')">查询</el-button>
<el-button @click="emit('reset')">重置</el-button>
</div>
</section>
</template>
<script setup lang="ts">
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import TimePeriodSearch from '@/views/components/TimePeriodSearch/index.vue'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
import type { SteadyTrendFormState } from '../utils/trendPayload'
defineOptions({
name: 'SteadyTrendToolbar'
})
const props = defineProps<{
modelValue: SteadyTrendFormState
phaseOptions: string[]
statOptions: SteadyDataView.SteadyTrendStatType[]
showHarmonicOrders: boolean
loading: boolean
}>()
const emit = defineEmits<{
'update:modelValue': [value: SteadyTrendFormState]
query: []
reset: []
}>()
const bucketOptions = [
{ label: '1分钟', value: '1m' },
{ label: '5分钟', value: '5m' },
{ label: '10分钟', value: '10m' },
{ label: '30分钟', value: '30m' },
{ label: '1小时', value: '1h' }
]
const harmonicOrderOptions = Array.from({ length: 49 }, (_item, index) => index + 2)
const statLabelMap: Record<SteadyDataView.SteadyTrendStatType, string> = {
AVG: '平均值',
MAX: '最大值',
MIN: '最小值',
CP95: '95%概率大值'
}
const phaseLabelMap: Record<string, string> = {
A: 'A相',
B: 'B相',
C: 'C相',
T: '总相'
}
const resolvePhaseLabel = (phase: string) => phaseLabelMap[phase] || `${phase}`
const updateField = <K extends keyof SteadyTrendFormState>(field: K, value: SteadyTrendFormState[K]) => {
emit('update:modelValue', {
...props.modelValue,
[field]: value
})
}
const updateTimeRange = (unit: TimePeriodUnit, baseDate: Date) => {
emit('update:modelValue', {
...props.modelValue,
timeUnit: unit,
timeBaseDate: baseDate,
timeRange: buildTimePeriodRange(unit, baseDate)
})
}
const handleTimeUnitChange = (value: TimePeriodUnit) => {
updateTimeRange(value, props.modelValue.timeBaseDate)
}
const handleTimeBaseDateChange = (value: Date) => {
updateTimeRange(props.modelValue.timeUnit, value)
}
</script>
<style scoped lang="scss">
.trend-toolbar {
display: grid;
grid-template-columns: minmax(260px, 1.3fr) repeat(4, minmax(132px, 0.7fr)) auto;
gap: 10px;
align-items: center;
padding: 12px;
}
.trend-toolbar__time {
min-width: 260px;
}
.harmonic-select {
grid-column: span 2;
}
.toolbar-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
}
@media (max-width: 1280px) {
.trend-toolbar {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.toolbar-actions {
justify-content: flex-start;
}
}
</style>

View File

@@ -0,0 +1,321 @@
<template>
<div class="table-box steady-data-view-page">
<div class="steady-trend-layout">
<aside class="selector-column">
<SteadyLedgerTree
:key="selectorResetKey"
:tree-data="ledgerTree"
:loading="loading.ledger"
:keyword="ledgerKeyword"
@refresh="loadLedgerTree"
@search="handleLedgerSearch"
@change="handleLedgerChange"
/>
</aside>
<main class="trend-main">
<SteadyTrendToolbar
v-model="trendForm"
:phase-options="phaseOptions"
:stat-options="statOptions"
:show-harmonic-orders="showHarmonicOrders"
:loading="loading.trend"
@query="handleQueryTrend"
@reset="resetTrendState"
/>
<div class="trend-content">
<SteadyTrendChartPanel :trend-result="trendResult" :loading="loading.trend" />
<aside class="indicator-floating-panel" :class="{ 'is-collapsed': indicatorPanelCollapsed }">
<el-button
class="indicator-toggle"
:icon="indicatorPanelCollapsed ? ArrowLeft : ArrowRight"
circle
@click="indicatorPanelCollapsed = !indicatorPanelCollapsed"
/>
<button
v-if="indicatorPanelCollapsed"
class="indicator-collapsed-trigger"
type="button"
@click="indicatorPanelCollapsed = false"
>
稳态指标
</button>
<div v-show="!indicatorPanelCollapsed" class="indicator-panel-body">
<SteadyIndicatorTree
:key="selectorResetKey"
:tree-data="indicatorTree"
:loading="loading.indicator"
@refresh="loadIndicatorTree"
@change="handleIndicatorChange"
/>
</div>
</aside>
</div>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from 'vue'
import { ElMessage } from 'element-plus'
import { ArrowLeft, ArrowRight } from '@element-plus/icons-vue'
import { getSteadyTrendIndicatorTree, getSteadyTrendLedgerTree, querySteadyTrend } from '@/api/steady/steadyDataView'
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import SteadyIndicatorTree from './components/SteadyIndicatorTree.vue'
import SteadyLedgerTree from './components/SteadyLedgerTree.vue'
import SteadyTrendChartPanel from './components/SteadyTrendChartPanel.vue'
import SteadyTrendToolbar from './components/SteadyTrendToolbar.vue'
import {
collectSelectedLineIds,
hasHarmonicIndicator,
resolveAvailablePhases,
resolveAvailableStats,
validateTrendSelection
} from './utils/selectionRules'
import { buildSteadyTrendQueryPayload, defaultTrendFormState } from './utils/trendPayload'
defineOptions({
name: 'SteadyDataView'
})
const ledgerTree = ref<SteadyDataView.SteadyLedgerNode[]>([])
const indicatorTree = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const selectedLedgerNodes = ref<SteadyDataView.SteadyLedgerNode[]>([])
const selectedIndicators = ref<SteadyDataView.SteadyIndicatorNode[]>([])
const trendResult = ref<SteadyDataView.SteadyTrendQueryResult | null>(null)
const trendForm = ref(defaultTrendFormState())
const ledgerKeyword = ref('')
const indicatorPanelCollapsed = ref(false)
const selectorResetKey = ref(0)
const loading = reactive({
ledger: false,
indicator: false,
trend: false
})
const lineIds = computed(() => collectSelectedLineIds(selectedLedgerNodes.value))
const showHarmonicOrders = computed(() => hasHarmonicIndicator(selectedIndicators.value))
const phaseOptions = computed(() => {
const phases = resolveAvailablePhases(selectedIndicators.value)
return phases.length ? phases : ['A', 'B', 'C', 'T']
})
const statOptions = computed<SteadyDataView.SteadyTrendStatType[]>(() => {
const stats = resolveAvailableStats(selectedIndicators.value)
return stats.length ? stats : ['AVG', 'MAX', 'MIN', 'CP95']
})
const unwrapData = <T,>(response: { data: T } | T): T => {
if (response && typeof response === 'object' && 'data' in response) {
return (response as { data: T }).data
}
return response as T
}
const loadLedgerTree = async (keyword = ledgerKeyword.value) => {
loading.ledger = true
try {
const response = await getSteadyTrendLedgerTree(keyword ? { keyword } : undefined)
ledgerTree.value = unwrapData(response) || []
} finally {
loading.ledger = false
}
}
const loadIndicatorTree = async () => {
loading.indicator = true
try {
const response = await getSteadyTrendIndicatorTree()
indicatorTree.value = unwrapData(response) || []
} finally {
loading.indicator = false
}
}
let ledgerSearchTimer: ReturnType<typeof setTimeout> | null = null
const handleLedgerSearch = (value: string) => {
ledgerKeyword.value = value
if (ledgerSearchTimer) clearTimeout(ledgerSearchTimer)
ledgerSearchTimer = setTimeout(() => loadLedgerTree(value), 300)
}
const handleLedgerChange = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
selectedLedgerNodes.value = nodes
}
const handleIndicatorChange = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
selectedIndicators.value = nodes
}
const resetTrendState = () => {
trendForm.value = defaultTrendFormState()
selectedLedgerNodes.value = []
selectedIndicators.value = []
trendResult.value = null
selectorResetKey.value += 1
}
const handleQueryTrend = async () => {
const selectionError = validateTrendSelection({
lineIds: lineIds.value,
indicators: selectedIndicators.value,
phases: trendForm.value.phases,
statTypes: trendForm.value.statTypes,
harmonicOrders: trendForm.value.harmonicOrders
})
if (selectionError) {
ElMessage.warning(selectionError)
return
}
if (!trendForm.value.timeRange[0] || !trendForm.value.timeRange[1]) {
ElMessage.warning('请选择趋势时间范围')
return
}
const payload = buildSteadyTrendQueryPayload(lineIds.value, selectedIndicators.value, trendForm.value)
loading.trend = true
try {
// 趋势查询只驱动主图,右侧稳态指标作为筛选面板独立加载,避免额外摘要请求拖慢页面响应。
const trendResponse = await querySteadyTrend(payload)
trendResult.value = unwrapData(trendResponse)
} finally {
loading.trend = false
}
}
watch(
selectedIndicators,
() => {
const availablePhases = phaseOptions.value
const availableStats = statOptions.value
trendForm.value = {
...trendForm.value,
phases: trendForm.value.phases.filter(phase => availablePhases.includes(phase)),
statTypes: trendForm.value.statTypes.filter(stat => availableStats.includes(stat)),
harmonicOrders: showHarmonicOrders.value ? trendForm.value.harmonicOrders : []
}
if (!trendForm.value.phases.length) {
trendForm.value.phases = availablePhases.slice(0, Math.min(3, availablePhases.length))
}
if (!trendForm.value.statTypes.length) {
trendForm.value.statTypes = availableStats.slice(0, 1)
}
},
{ deep: true }
)
onMounted(() => {
loadLedgerTree()
loadIndicatorTree()
})
</script>
<style scoped lang="scss">
.steady-data-view-page {
min-height: 0;
overflow: hidden;
}
.steady-trend-layout {
display: grid;
grid-template-columns: 320px minmax(0, 1fr);
gap: 12px;
width: 100%;
height: 100%;
min-height: 0;
}
.selector-column {
display: grid;
min-height: 0;
}
.trend-main {
display: grid;
grid-template-rows: auto minmax(0, 1fr);
gap: 12px;
min-width: 0;
min-height: 0;
}
.trend-content {
position: relative;
min-width: 0;
min-height: 0;
}
.trend-content :deep(.trend-chart-panel) {
height: 100%;
}
.indicator-floating-panel {
position: absolute;
top: 12px;
right: 12px;
bottom: 12px;
z-index: 2;
width: 360px;
transition: width 0.2s ease;
}
.indicator-floating-panel.is-collapsed {
width: 44px;
}
.indicator-toggle {
position: absolute;
top: 12px;
left: -18px;
z-index: 3;
}
.indicator-panel-body {
width: 100%;
height: 100%;
}
.indicator-panel-body :deep(.steady-tree-card) {
height: 100%;
box-shadow: var(--el-box-shadow-light);
}
.indicator-collapsed-trigger {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
padding: 8px 0;
border: 1px solid var(--el-border-color-light);
border-radius: 4px;
background: var(--el-bg-color);
box-shadow: var(--el-box-shadow-light);
color: var(--el-text-color-primary);
cursor: pointer;
font-size: 13px;
font-weight: 600;
line-height: 1.2;
writing-mode: vertical-rl;
}
.indicator-collapsed-trigger:hover {
border-color: var(--el-color-primary-light-5);
color: var(--el-color-primary);
}
@media (max-width: 1360px) {
.steady-trend-layout {
grid-template-columns: 280px minmax(0, 1fr);
}
.indicator-floating-panel {
width: 320px;
}
}
</style>

View File

@@ -0,0 +1,118 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
export const MAX_TREND_SERIES_COUNT = 24
export const MAX_HARMONIC_ORDER_COUNT = 6
export const collectSelectedLineIds = (nodes: SteadyDataView.SteadyLedgerNode[]) => {
const lineIds = new Set<string>()
const collect = (node: SteadyDataView.SteadyLedgerNode) => {
if (node.level === 3 || node.selectable) {
lineIds.add(node.id)
}
node.children?.forEach(collect)
}
nodes.forEach(collect)
return Array.from(lineIds)
}
export const collectLeafIndicators = (nodes: SteadyDataView.SteadyIndicatorNode[]) => {
const indicators: SteadyDataView.SteadyIndicatorNode[] = []
const collect = (node: SteadyDataView.SteadyIndicatorNode) => {
if (node.children?.length) {
node.children.forEach(collect)
return
}
if (node.indicatorCode) {
indicators.push(node)
}
}
nodes.forEach(collect)
return indicators
}
export const hasHarmonicIndicator = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
return indicators.some(item => item.harmonic || Boolean(item.harmonicOrderStart || item.harmonicOrderEnd))
}
export const resolveAvailablePhases = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
const phaseSet = new Set<string>()
indicators.forEach(indicator => {
indicator.phaseCodes?.forEach(phase => phaseSet.add(phase))
})
return Array.from(phaseSet)
}
export const resolveAvailableStats = (indicators: SteadyDataView.SteadyIndicatorNode[]) => {
const statSet = new Set<SteadyDataView.SteadyTrendStatType>()
indicators.forEach(indicator => {
indicator.supportStats?.forEach(stat => statSet.add(stat))
})
return Array.from(statSet)
}
export const estimateTrendSeriesCount = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
phases: string[],
statTypes: SteadyDataView.SteadyTrendStatType[],
harmonicOrders: number[]
) => {
const harmonicMultiplier = hasHarmonicIndicator(indicators) ? Math.max(harmonicOrders.length, 1) : 1
return indicators.reduce((count, indicator) => {
const indicatorPhases = indicator.phaseCodes?.length ? indicator.phaseCodes : phases
const selectedPhaseCount = indicatorPhases.filter(phase => phases.includes(phase)).length || indicatorPhases.length || 1
const fieldCount = Math.max(indicator.seriesFields?.length || indicator.baseFields?.length || 1, 1)
return count + lineIds.length * selectedPhaseCount * Math.max(statTypes.length, 1) * fieldCount * harmonicMultiplier
}, 0)
}
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 个'
return ''
}
export const validateTrendSelection = (params: {
lineIds: string[]
indicators: SteadyDataView.SteadyIndicatorNode[]
phases: string[]
statTypes: SteadyDataView.SteadyTrendStatType[]
harmonicOrders: number[]
}) => {
const { lineIds, indicators, phases, statTypes, harmonicOrders } = params
if (!lineIds.length) return '请选择监测点'
if (!indicators.length) return '请选择趋势指标'
if (lineIds.length > 1 && indicators.length > 1) return '多监测点查询时只能选择 1 个指标'
if (!statTypes.length) return '请选择统计类型'
if (!phases.length) return '请选择相别'
const harmonicError = validateHarmonicOrders(indicators, harmonicOrders)
if (harmonicError) return harmonicError
const seriesCount = estimateTrendSeriesCount(lineIds, indicators, phases, statTypes, harmonicOrders)
if (seriesCount > MAX_TREND_SERIES_COUNT) {
return '趋势曲线数量不能超过 24 条,请缩小监测点、指标、相别或统计类型范围'
}
return ''
}

View File

@@ -0,0 +1,72 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
const trendColors = [
'var(--el-color-primary)',
'#00a870',
'#e6a23c',
'#f56c6c',
'#626aef',
'#909399',
'#14b8a6',
'#f97316'
]
const formatSeriesName = (series: SteadyDataView.SteadyTrendSeries) => {
return [series.lineName, series.indicatorName || series.seriesName, series.phase, series.statType]
.filter(Boolean)
.join(' / ')
}
export const buildSteadyTrendChartOptions = (seriesList: SteadyDataView.SteadyTrendSeries[]) => {
const timeLabels = Array.from(
new Set(seriesList.flatMap(series => (series.points || []).map(point => point.time)))
).sort()
return {
title: {
text: ''
},
tooltip: {
trigger: 'axis'
},
legend: {
type: 'scroll',
top: 0,
right: 12
},
grid: {
top: 48,
left: 64,
right: 28,
bottom: 48,
containLabel: false
},
xAxis: {
type: 'category',
data: timeLabels,
boundaryGap: false
},
yAxis: {
type: 'value',
scale: true,
axisLabel: {
formatter: '{value}'
}
},
color: trendColors,
series: seriesList.map(series => {
const pointMap = new Map((series.points || []).map(point => [point.time, point.value]))
return {
name: formatSeriesName(series),
type: 'line',
showSymbol: false,
connectNulls: false,
data: timeLabels.map(time => pointMap.get(time) ?? null),
lineStyle: {
width: 1.2
}
}
})
}
}

View File

@@ -0,0 +1,46 @@
import type { SteadyDataView } from '@/api/steady/steadyDataView/interface'
import { buildTimePeriodRange, type TimePeriodUnit } from '@/views/components/TimePeriodSearch/timePeriod'
export interface SteadyTrendFormState {
timeRange: string[]
timeUnit: TimePeriodUnit
timeBaseDate: Date
phases: string[]
statTypes: SteadyDataView.SteadyTrendStatType[]
bucket: string
qualityFlag?: number
harmonicOrders: number[]
}
export const defaultTrendFormState = (): SteadyTrendFormState => {
const baseDate = new Date()
return {
timeRange: buildTimePeriodRange('month', baseDate),
timeUnit: 'month',
timeBaseDate: baseDate,
phases: ['A', 'B', 'C'],
statTypes: ['AVG'],
bucket: '10m',
qualityFlag: 1,
harmonicOrders: []
}
}
export const buildSteadyTrendQueryPayload = (
lineIds: string[],
indicators: SteadyDataView.SteadyIndicatorNode[],
formState: SteadyTrendFormState
): SteadyDataView.SteadyTrendQueryParams => {
return {
lineIds,
indicatorCodes: indicators.map(item => item.indicatorCode).filter(Boolean) as string[],
statTypes: formState.statTypes,
phases: formState.phases,
timeStart: formState.timeRange[0] || '',
timeEnd: formState.timeRange[1] || '',
bucket: formState.bucket,
qualityFlag: formState.qualityFlag,
harmonicOrders: formState.harmonicOrders.length ? formState.harmonicOrders : undefined
}
}